diff --git a/.claude/rules/codegen-architecture.md b/.claude/rules/codegen-architecture.md index 0d0ec99f4..53141388b 100644 --- a/.claude/rules/codegen-architecture.md +++ b/.claude/rules/codegen-architecture.md @@ -168,7 +168,7 @@ origins live on `object.projection` only. Gated end-to-end by `fixtures/api-contract-conformance/projection/` (view-only) and `.../write-through/` (table + replica view). -**`columnNamingStrategy`** in `metaobjects.config.ts`: `snake_case` (default) | `literal` | `kebab-case`. +**Column and table naming** is `naming` in `.metaobjects/config.json` (ADR-0058), read by every port: `columns` `snake_case` (default) | `literal` | `kebab-case`, `tables` `snake_case` (default) | `literal`. Generators read it as `ctx.columnNamingStrategy` (set from the loaded root); `metaobjects.config.ts` no longer takes it. ### Currency (Project F) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45cb6b224..a20ccf233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,74 @@ here.** ## [Unreleased] +### Changed — one physical-naming setting, read by every port (ADR-0058) + +**This changes generated column names in C#, Java, Kotlin's runtime and Python unless you add +one line.** Column and table names that the metadata does not give (`@column`, `@table`, +`@view`, a source's `name`) now come from one project setting, `naming` in +`.metaobjects/config.json`: +`{ "naming": { "columns": "snake_case" | "literal" | "kebab-case", "tables": "snake_case" | "literal" } }`. +`meta migrate`, every port's codegen (the names generators included), every runtime +`ObjectManager` and `verify` read it from the loaded model, so codegen and the runtime can +no longer disagree. Both rules default to `snake_case` in every port, which is what +`meta migrate` creates; C#, Java, the JVM runtime and Python defaulted to `literal`. A +database whose columns are named exactly like the fields adds +`"naming": { "columns": "literal" }`. Every former place to set the rule is refused with a +message naming the setting and the guide, never silently ignored: `columnNamingStrategy` in +`metaobjects.config.ts` and on `ObjectManager` and `agentDocsFile()` (TypeScript), +`--column-naming` (C#, Python), the `columnNaming` generator arg in `pom.xml` and +`SimpleMappingHandlerDB.setColumnNaming` (Java, Kotlin), `ObjectManager(column_naming=)` +(Python). The engine options that carried the rule (`buildExpectedSchema`, +`buildProjectionViews`, `buildReportViews`, `computeDrift`, `planOffline`, +`baselineFromMetadata`) read it from the model instead. Table names gain a `literal` rule. +Every port runs the shared `fixtures/naming-conformance/physical-names/` corpus. +Migration: `docs/features/migrations/physical-naming.md`. + +Found by the application-building test of 1.1.0-rc.3: following the migration guide, a plain +`metaobjects gen` rewrote every `*_names.py` with camelCase column names the snake_case +database does not have. + +### Added + +- **Python: `templates` in `metaobjects.config.yaml`.** The template directory was a flag + only; `gen`, `verify --codegen` and `verify --templates` read the key, and a `--templates` + that contradicts it is refused. +- **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 rule, another table rule, or both, and names the `naming` setting that reproduces it, + instead of advising a regeneration that would commit the wrong column or table 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 +1254,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-audit/references/capability-checklist.md b/agent-context/skills/metaobjects-audit/references/capability-checklist.md index bd8e3486f..39d9a51cd 100644 --- a/agent-context/skills/metaobjects-audit/references/capability-checklist.md +++ b/agent-context/skills/metaobjects-audit/references/capability-checklist.md @@ -351,9 +351,10 @@ to nothing, and does not check that each claimed node complies). non-`table` source-kind / flattened-object codegen is partial). **C#** has no ObjectManager runtime tier (EF Core *is* the runtime) — hand services over the generated `DbContext` are expected. -- **Single-source config** — `apiPrefix` (URL prefix wired into routes + hooks) and - `columnNamingStrategy` (snake_case / literal / kebab-case) and per-target output dirs: - hunt these values hard-coded in multiple places instead of resolved from config. +- **Single-source config** — `apiPrefix` (URL prefix wired into routes + hooks), the + `naming` block of `.metaobjects/config.json` (column and table rules, read by every port) + and per-target output dirs: hunt these values hard-coded in multiple places instead of + resolved from config. - **CALIBRATION — planned, not yet shipped:** the declared-API surface (`api.base`, `api.operational`, `operation.query`, `operation.command`, `binding.rest`) and MCP exposure of declared prompts/tools are **not yet in the registry** — their absence is not an adopter 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..f95895c95 100644 --- a/agent-context/skills/metaobjects-codegen/references/csharp.md +++ b/agent-context/skills/metaobjects-codegen/references/csharp.md @@ -51,6 +51,19 @@ public static class Generators } ``` +**Column names come from `naming` in `.metaobjects/config.json`, the one setting every port +reads** (ADR-0058): `meta migrate`, every port's codegen and every runtime `ObjectManager`. +The default is `snake_case`, what `meta migrate` creates. A database whose columns are named +exactly like the fields declares it there, once, for every port: + +```json +{ "schema_version": 1, "naming": { "columns": "literal" } } +``` + +There is no per-port or command-line naming: `--column-naming` is refused, and +`verify --codegen` names a naming mismatch as one rather than telling you to regenerate. Through 1.0 this port defaulted to the field names; a +database built that way needs the `literal` line above. + ```bash dotnet meta gen metaobjects --out Generated --namespace Acme.Generated # runs the list dotnet meta gen --list # list registered generators @@ -63,6 +76,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 +100,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 +186,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 +194,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..244c9253a 100644 --- a/agent-context/skills/metaobjects-codegen/references/python.md +++ b/agent-context/skills/metaobjects-codegen/references/python.md @@ -37,12 +37,26 @@ generated that the config does not declare. ```yaml # metaobjects.config.yaml metadata: metadata +templates: prompts # where template refs resolve from (default: prompts/, else templates/) targets: app: outDir: generated generators: [entity, filter-allowlist, routes] ``` +**Column names come from `naming` in `.metaobjects/config.json`, the one setting every port +reads** (ADR-0058): `meta migrate`, every port's codegen and every runtime `ObjectManager`. +The default is `snake_case`, what `meta migrate` creates. A database whose columns are named +exactly like the fields declares it there, once, for every port: + +```json +{ "schema_version": 1, "naming": { "columns": "literal" } } +``` + +There is no per-port or command-line naming: `--column-naming` is refused, and +`verify --codegen` names a naming mismatch as one rather than telling you to regenerate. A `--templates` that contradicts +the config's `templates` is refused. + ```bash metaobjects gen # runs every target's generators into its outDir metaobjects gen --generators entity # narrow to declared entries (never adds one) @@ -51,8 +65,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; +`--templates` only where the config declares no `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 +83,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 +93,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-mysql.md b/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md index 9cf2d06f0..3e40002a0 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md @@ -97,7 +97,8 @@ The loop above ignores `view.schema`, which is the report source's `@schema` whe one. If yours does, create the view in that schema yourself (qualify the name in your migration); the loop will not. -Pass `columnNamingStrategy` to match your tables' column names (the default is `snake_case`). +The column names follow the project's `naming.columns` rule in `.metaobjects/config.json` +(default `snake_case`); set it there to match your tables. The bodies are valid under MySQL's default `sql_mode`, `ONLY_FULL_GROUP_BY` included, and a change to a report means a new `CREATE OR REPLACE VIEW` (or `DROP` and `CREATE`) in your migrations; nothing diffs the live view for you. A `@spine` report's body is valid as diff --git a/agent-context/skills/metaobjects-codegen/references/typescript.md b/agent-context/skills/metaobjects-codegen/references/typescript.md index 6b767be20..daca582a3 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`, @@ -81,7 +86,6 @@ export default defineConfig({ dialect: "postgres", // "postgres" | "sqlite" | "d1" | "mysql" (mysql: codegen + runtime; migrate does not own it) extStyle: "js", // "js" (default) for Node ESM / plain tsc; "none" for a bundler-resolution toolchain — see `owning-generators.md` "Your framework isn't the default" apiPrefix: "/api", // flows to routes AND client fetch URLs - columnNamingStrategy: "snake_case", // "snake_case" (default) | "literal" | "kebab-case" timestampMode: "string", // "string" (default, ISO-8601 wire contract) | "date" (Drizzle native Date) pluralizeCollections: true, // default; table VARS auto-pluralize (AgentConfig → agentConfigs) collectionNameOverrides: { // per-entity escape hatch for names the rule gets wrong diff --git a/agent-context/skills/metaobjects-runtime-ui/references/java.md b/agent-context/skills/metaobjects-runtime-ui/references/java.md index dfa75a819..1c36c656f 100644 --- a/agent-context/skills/metaobjects-runtime-ui/references/java.md +++ b/agent-context/skills/metaobjects-runtime-ui/references/java.md @@ -172,8 +172,8 @@ restate. `AuthorNames.COLUMNS_BY_FIELD` carries the whole map when you need to build a projection list. It is opt-in on the JVM — add `SpringNamesGenerator` to the pom's `` -(see the codegen reference). Its `columnNaming` defaults to `literal`, the same -resolution OMDB uses at runtime; pass the same value to both, or the constant names a -column no row lands in. There is no typed handle to prefer on this port — `Names` +(see the codegen reference). It resolves columns through the project's one `naming` setting +in `.metaobjects/config.json` (default `snake_case`), the same setting OMDB reads at +runtime, so the constant always names the column a row lands in. There is no typed handle to prefer on this port — `Names` is the only compile-checked route to a physical name, which is also why nothing else here catches a wrong pairing for free. diff --git a/agent-context/skills/metaobjects-runtime-ui/references/kotlin.md b/agent-context/skills/metaobjects-runtime-ui/references/kotlin.md index 08f31eb37..cdf4b1bf7 100644 --- a/agent-context/skills/metaobjects-runtime-ui/references/kotlin.md +++ b/agent-context/skills/metaobjects-runtime-ui/references/kotlin.md @@ -93,8 +93,8 @@ column — never `AuthorNames.NAME_COLUMN`. The constant is for what the DSL doe reach: `exec("…")` raw SQL, a Flyway script, a log line, an external system's column mapping. There it is `AuthorNames.SOURCE_PRIMARY_TABLE` / `AuthorNames.NAME_COLUMN` (opt-in: `KotlinNamesGenerator` in the pom — see the codegen reference), never a literal. -Give both generators the same `columnNaming`, or the table and the constants disagree about -a column. +Both read the project's one `naming` setting in `.metaobjects/config.json`, so the table +and the constants always agree about a column. The object mirrors the metadata tree: `AuthorNames.NAME` is the OBJECT's name (`"Author"`), and a physical name sits under the member that says what it IS — diff --git a/agent-context/skills/metaobjects-runtime-ui/references/python.md b/agent-context/skills/metaobjects-runtime-ui/references/python.md index 7fea94304..de1d4870b 100644 --- a/agent-context/skills/metaobjects-runtime-ui/references/python.md +++ b/agent-context/skills/metaobjects-runtime-ui/references/python.md @@ -122,5 +122,6 @@ row = await conn.fetchrow( ``` There is no typed handle to prefer on this port, so this is never the wrong choice here. -Pass the same `--column-naming` to `metaobjects gen` that you pass as `column_naming=` to -`ObjectManager` (both default to `literal`), or the constant names a column no row lands in. +The constants and the `ObjectManager` both resolve columns through the project's one `naming` +setting in `.metaobjects/config.json` (default `snake_case`), so the constant always names +the column a row lands in. 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..3c34c2980 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -352,6 +352,7 @@ 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 +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 +367,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 ``` @@ -387,9 +388,11 @@ targets: - **`--config `** picks the config file explicitly on either command; without it both look for `./metaobjects.config.yaml` in the current directory. **No config is an error**: `gen` and `verify --codegen` print `no metaobjects.config.yaml`, exit 2 and write nothing. -- **Run options still apply to the list:** `--column-naming`, `--entities` (narrows each - target's entities), `--provider` (added to `providers`), `--templates` (`--prompts` on - `verify`), `--baseline`. +- **Run options still apply to the list:** `--entities` (narrows each target's entities), + `--provider` (added to `providers`), `--templates` (`--prompts` on `verify`), `--baseline`. + Column naming is not a run option: it is `naming` in `.metaobjects/config.json`, which every + port reads, and `--column-naming` is refused + ([physical-naming.md](migrations/physical-naming.md)). - **Providers resolve config-relative.** A `providers:` entry is imported with the config file's own directory prepended to `sys.path`, so a consumer provider module living beside the config resolves with no diff --git a/docs/features/cube-export.md b/docs/features/cube-export.md index 5b1f68151..e6d1d86db 100644 --- a/docs/features/cube-export.md +++ b/docs/features/cube-export.md @@ -79,8 +79,8 @@ YAML writer (`renderCubeYaml`) stay in the package, both exported from | `filter` | A predicate over entities, ANDed with the gate in the table above. It is the universe of the build: an entity it excludes gets no cube of its own, and its dimensions add no member to another cube. If another cube's `@via` reaches an excluded entity, it is still written as a join-target cube, without its own measures, segments or rollups, unless every hop onto it goes through an alias cube. | | `target` | The named output target the files are written under. | -The config's `columnNamingStrategy` (default `snake_case`) names the columns in the SQL, as it -does for the report views. +The project's `naming.columns` rule in `.metaobjects/config.json` (default `snake_case`) names +the columns in the SQL, as it does for the report views. **Dialects.** The exporter writes SQL for Postgres and MySQL only, the two dialects it quotes and casts for. A config whose `dialect` is `sqlite` or `d1` raises diff --git a/docs/features/field-types.md b/docs/features/field-types.md index 19570f511..5eb2ae6f0 100644 --- a/docs/features/field-types.md +++ b/docs/features/field-types.md @@ -89,72 +89,53 @@ Three rows need a footnote: | `name` | string | Field name (camelCase). | | `@required` | bool | If true, generated type omits `?` / `Optional`. | | `@maxLength` | int | Length for string types (drives `varchar(N)`). | -| `@column` | string | Physical column name (overrides `columnNamingStrategy`). | +| `@column` | string | Physical column name (overrides the project's `naming.columns` rule). | | `@default` | any | Default value (literal or canonical SQL default function). | | `@filterable` | bool | Field appears in the generated server-side filter allowlist. | | `@sortable` | bool | Field appears in the server-side sort allowlist (defaults to `@filterable`). | -## Column naming — `@column` and `columnNamingStrategy` +## Column naming — `@column` and the `naming` setting A field has **two names**: the one your code calls it (`createdAt`, the metadata `name`, and what every port's generated property/attribute is called) and the one the database calls it. `@column` sets the second. It never changes the first. -With no `@column`, the physical name comes from the project's **column-naming -strategy** — `literal` (the field name verbatim), `snake_case`, or `kebab-case`. That +With no `@column`, the physical name comes from the project's **column rule**: +`snake_case` (the default), `literal` (the field name verbatim) or `kebab-case`. That is CONFIG, not metadata, on purpose: the same model has to be able to drive a -snake_case Postgres schema and a literal-column one. +snake_case Postgres schema and a literal-column one. It is ONE setting for the whole +project, `naming` in `.metaobjects/config.json` (ADR-0058): -**The defaults are not the same everywhere, and that is the thing to know:** +```json +{ "schema_version": 1, "naming": { "columns": "literal" } } +``` -| Where | Default | How to set it | -|---|---|---| -| `meta migrate` (schema, every port — ADR-0015) | `snake_case` | `columnNamingStrategy` in `metaobjects.config.ts` | -| TypeScript codegen + `ObjectManager` | `snake_case` | `columnNamingStrategy` in `metaobjects.config.ts`; `columnNamingStrategy` option on `ObjectManager` | -| C# (`dotnet meta gen` + `verify`) | `literal` | `--column-naming snake_case` (both `gen` and `verify` take it — `verify` must be told the same strategy `gen` used, or its regen false-drifts) | -| Python (`metaobjects gen` + `verify` + `ObjectManager`) | `literal` | `--column-naming snake_case` (`gen` and `verify`); `ObjectManager(..., column_naming=...)` (runtime) | -| Java (Maven `generate`/`verify` + `ObjectManagerDB`) | `literal` | `snake_case` on a generator in the pom (codegen — `verify` reads the same pom `` as `generate`, so nothing separate to pass); `SimpleMappingHandlerDB.setColumnNaming(...)` (runtime) | -| Kotlin (Exposed table codegen) | `snake_case` | `literal` in the pom | - -**Kotlin's `snake_case` codegen default and the JVM's shared `literal` runtime -default point opposite ways on purpose, not by typo.** The Kotlin generator has -always emitted `snake_case`; changing that default now would silently re-point -every existing Exposed table binding at columns that no longer exist. -`ObjectManagerDB`'s `literal` default predates the Kotlin generator entirely. -Read the two rows above side by side and it looks like a copy-paste mistake — -`ColumnNaming.java:26-28` states the divergence is intentional and why. - -Every port's codegen now takes exactly one column-naming option (the table -above), and every port ships a per-object `names` generator that resolves -physical names through it — one generated constants file per object, so a -hand-written consumer references a constant instead of respelling a physical -name as a string literal. Enablement and defaults differ by port; see each -port's own doc (`docs/ports/{typescript,csharp,java,kotlin,python}.md`). - -**The option is the answer.** A project sets that one option per port to match -the strategy `meta migrate` used to create the schema, once, and every -generated artifact in that port agrees with the database. A per-field -`@column` is for a column whose name genuinely cannot be derived at all — a -legacy column, a table whose schema is owned outside this project — **not** a -workaround for getting five ports' defaults to agree; treating it as the -general answer means touching every field with a case boundary instead of -touching one config value per port. - -**The defaults disagree, and that is a real trap, not a hypothetical one.** -`meta migrate` defaults `snake_case`; Java, Python and C# codegen default -`literal`. A project that sets neither ends up with a migration and a -generated binding spelling the same column two ways — the DDL creates -`created_at`, the generated constant/property says `createdAt` — and nothing -catches it before a query fails at the database. Worse, **codegen cannot see a -runtime call**: nothing reconciles a codegen-time `--column-naming` / -`columnNamingStrategy` / `` with a *runtime* call like -`SimpleMappingHandlerDB.setColumnNaming(...)` or -`ObjectManager(column_naming=...)`. A project that changes one must change the -other by hand, every time, in every port that has both a codegen-side and a -runtime-side setting. - -A field can still declare `@column` explicitly for the case that setting the -option cannot cover — a name that no strategy would ever produce: +Every port reads it: `meta migrate`, every port's codegen (its `names` generator +included), every runtime `ObjectManager`, and `verify`. They cannot disagree, because +none of them is configured separately. Table names follow the same pattern: +`naming.tables` (`snake_case`, the default, or `literal`) applies to a source with no +`@table`/`@view`/`@proc`/`@function`, after its own `name` and before the object's name +is made plural. + +Every port ships a per-object `names` generator that resolves physical names through +that setting: one generated constants file per object, so a hand-written consumer +references a constant instead of respelling a physical name as a string literal. +Enablement differs by port; see each port's own doc +(`docs/ports/{typescript,csharp,java,kotlin,python}.md`). + +**The setting is the answer.** Set it once, to the rule `meta migrate` created the schema +with (that is `snake_case` unless you changed it), and every generated artifact in every +port agrees with the database. A per-field `@column` is for a column whose name +genuinely cannot be derived at all (a legacy column, a table whose schema is owned +outside this project), not a substitute for the setting. + +Through 1.0 each port had its own column-naming option and its own default, and C#, +Java and Python defaulted to `literal` while `meta migrate` created `snake_case`. Those +options are refused in 1.1.0; [the migration guide](migrations/physical-naming.md) gives +the one line that keeps 1.0's `literal` names where a database needs them. + +A field can still declare `@column` explicitly for the case the setting cannot +cover — a name that no strategy would ever produce: ```json { "field.timestamp": { "name": "createdAt", "@column": "created_at" } } diff --git a/docs/features/migrations/docs-are-generators.md b/docs/features/migrations/docs-are-generators.md index 6116da114..4c307f00a 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`) | `"naming": { "columns": "" }` in `.metaobjects/config.json`, the one setting every port reads; the flag is refused. See [physical-naming.md](physical-naming.md) | | `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,15 @@ 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. A line that passed `--column-naming` is refused; +move the value to `naming` in `.metaobjects/config.json` ([physical-naming.md](physical-naming.md)). ## 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 +413,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 +442,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 +463,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 +490,23 @@ 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`) | `"naming": { "columns": "" }` in `.metaobjects/config.json`, the one setting every port reads; the flag is refused. See [physical-naming.md](physical-naming.md) | +| `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**: `--templates` becomes +`templates`, and `--column-naming` moves to `naming` in `.metaobjects/config.json` +([physical-naming.md](physical-naming.md)). To write only the documentation, name the docs +entry (or its target): ``` metaobjects gen --generators api-docs @@ -499,7 +518,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 @@ -523,6 +542,10 @@ it found. com.metaobjects.generator.spring.SpringDtoGenerator ${project.build.directory}/generated-sources/java + + com.metaobjects.generator.spring.SpringValueObjectGenerator + ${project.build.directory}/generated-sources/java + @@ -551,6 +574,10 @@ to the same paths as the 8.0 pom above: com.metaobjects.generator.spring.SpringDtoGenerator ${project.build.directory}/generated-sources/java + + com.metaobjects.generator.spring.SpringValueObjectGenerator + ${project.build.directory}/generated-sources/java + com.metaobjects.generator.apidocs.JavaApiDocsGenerator @@ -596,6 +623,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/migrations/physical-naming.md b/docs/features/migrations/physical-naming.md new file mode 100644 index 000000000..c3b746652 --- /dev/null +++ b/docs/features/migrations/physical-naming.md @@ -0,0 +1,110 @@ +# Migration: one physical-naming setting, read by every port + +**Changed in:** 1.1.0 (Maven 8.1.0) · **Ports affected:** all five +**Automatic fix:** none. Each section below is written so you, or a coding agent working in +your repository, can make the change by hand. Decision record: +[ADR-0058](../../../spec/decisions/ADR-0058-one-physical-naming-setting.md). + +## What changed + +A database column or table name comes from your metadata when the metadata gives one +(`@column` on a field; `@table`, `@view` or a source's `name` on a source). When it does not, +it comes from a rule. Through 1.0 each port configured that rule in a place of its own, with +a default of its own, and nothing checked that a port's codegen and its runtime agreed. In +1.1.0 the rule is one project setting, in the file every port already reads: + +```json +{ + "schema_version": 1, + "naming": { "columns": "snake_case", "tables": "snake_case" } +} +``` + +That is `.metaobjects/config.json`. Codegen (the names generators included), `meta migrate`, +every runtime `ObjectManager` and `verify` read it, so they always produce the same names. + +| Key | Values | Applies to | Default | +|---|---|---|---| +| `columns` | `snake_case`, `literal`, `kebab-case` | a field with no `@column` | `snake_case` | +| `tables` | `snake_case`, `literal` | a source with no `@table`/`@view`/`@proc`/`@function`: its `name`, else the object's name made plural | `snake_case` | + +An unknown key or value fails when the file is read. Index and constraint names are unchanged. + +**The default is now `snake_case` in every port.** It is what `meta migrate` has always +created. C#, Java, Kotlin's runtime and Python used `literal` by default. **If your database's +columns are named exactly like your fields** (`firstName`, not `first_name`), add one line to +`.metaobjects/config.json` before you regenerate: + +```json +"naming": { "columns": "literal" } +``` + +Without it, regenerated code and the runtime look for snake_case columns your database does +not have. If your database was created by `meta migrate`, its columns are already snake_case +and you need no line at all. + +Every place the rule used to be set now fails with a message that names this setting, for +example: + +``` +--column-naming was removed in 1.1.0. Column and table naming is one project setting now, read by every port: "naming" in .metaobjects/config.json, for example "naming": { "columns": "literal" }. Move it there and delete --column-naming. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md +``` + +It never falls back to the default, because the old value may be the one that matches your +database, and the default would rename every column. + +## TypeScript + +Default unchanged (`snake_case`). + +| 1.0 | 1.1.0 | +|---|---| +| `columnNamingStrategy` in `metaobjects.config.ts` | `"naming": { "columns": "" }` in `.metaobjects/config.json`. `meta gen`, `meta migrate` and `meta verify` refuse a config that still sets it (exit 2) | +| `new ObjectManager({ metadata, driver, columnNamingStrategy })` | `new ObjectManager({ metadata, driver })`. The manager reads the rule from the loaded root; load the model with `loadMemory(projectRoot)` so the root carries it. The option throws | +| `agentDocsFile({ columnNamingStrategy })` | `agentDocsFile()`. The option throws | +| `columnNamingStrategy` on `buildExpectedSchema`, `buildProjectionViews`, `buildReportViews`, `computeDrift`, `planOffline`, and the third argument of `baselineFromMetadata` | removed: each reads the rule from the root it is given | + +A model loaded without a project (`new MetaDataLoader().load(...)`) has the default rules; +`loadMemory(projectRoot)` and every `meta` command load through the project and apply the +`naming` block. + +## C# + +| 1.0 | 1.1.0 | +|---|---| +| `dotnet meta gen --column-naming `, `dotnet meta verify --codegen --column-naming ` | `"naming": { "columns": "" }` in `.metaobjects/config.json`. The flag is refused (exit 2) | +| no flag: generated EF mappings used the field names (`literal`) | no setting: snake_case. Add `"naming": { "columns": "literal" }` to keep 1.0's column names | + +`dotnet meta verify --codegen` reports drift that another column rule, another table rule, or +both would reproduce exactly as a naming mismatch, naming the setting, instead of advising a +regenerate. + +## Java and Kotlin + +| 1.0 | 1.1.0 | +|---|---| +| the `columnNaming` generator arg in `pom.xml` | `"naming": { "columns": "" }` in `.metaobjects/config.json`. `mvn metaobjects:generate` fails while any generator still passes it | +| `SimpleMappingHandlerDB.setColumnNaming(...)` | the rule comes from the loaded metadata; the setter throws. A Spring application loading metadata from the classpath points `metaobjects.physical-naming-config` at the project's `.metaobjects/config.json` packaged as a resource (for example `classpath:.metaobjects/config.json`); the loader sets the rules from that file | +| no arg: Java codegen and the JVM runtime used the field names (`literal`) | no setting: snake_case in Java codegen, Kotlin codegen and the runtime alike. Add `"naming": { "columns": "literal" }` to keep 1.0's column names | + +## Python + +| 1.0 | 1.1.0 | +|---|---| +| `metaobjects gen --column-naming `, `metaobjects verify --codegen --column-naming ` | `"naming": { "columns": "" }` in `.metaobjects/config.json`. The flag is refused (exit 2) | +| `ObjectManager(..., column_naming="")` | `ObjectManager(...)`. The manager reads the rule from the loaded root; the argument raises. A model loaded outside the project directory gets the project's rules with `attach_naming(root, project_dir)` from `metaobjects.naming`, which reads that project's `.metaobjects/config.json` | +| no flag: `*_names.py` and the `ObjectManager` used the field names (`literal`) | no setting: snake_case. Add `"naming": { "columns": "literal" }` to keep 1.0's column names | + +`metaobjects verify --codegen` reports drift that another column rule, another table rule, or +both would reproduce exactly as a naming mismatch, naming the setting, instead of advising a +regenerate. + +The 1.1.0 release candidates read a `columnNaming` key in `metaobjects.config.yaml`. It is +refused the same way; move its value to `naming` in `.metaobjects/config.json`. + +## Checking the result + +After the change, regenerate and run your port's `verify --codegen`. It should report no +drift. If you see every generated column name change, your database's columns follow a +different rule than the one now set: set `naming.columns` to the rule the database uses +rather than committing the regenerated names. diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 1e31a7293..c1c9be664 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -274,10 +274,10 @@ directory each `apiDocsFile()` writes to, from its `emitsApiDocs` marker) and wh model pages are written (`docsFile()`'s `emitsModelDocs` marker). An owned copy keeps the marker, so it is read the same way. `docsFile({ apiSurfaces })` names api pages that live somewhere else (another port's, with a `baseUrl` each) and wins over the fact. The agent -pages need the physical schema, which `meta gen` computes from your dialect and column -naming strategy when the list includes `agentDocsFile()` (the generator package owns none of -it) and hands over on `ctx.docs.agentSchema`; pass `schema` and `columnNamingStrategy` as -options to state them yourself. +pages need the physical schema, which `meta gen` computes from your dialect and the +project's `naming` setting when the list includes `agentDocsFile()` (the generator package +owns none of it) and hands over on `ctx.docs.agentSchema`; pass `schema` as an option to state +it yourself. | | The generator file decides | Copied beside it, so yours too | |---|---|---| @@ -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/llms/llms.txt b/docs/llms/llms.txt index d13c1ade4..c948ab48f 100644 --- a/docs/llms/llms.txt +++ b/docs/llms/llms.txt @@ -70,7 +70,7 @@ MetaObjects pillars are capabilities of the same metadata spine, not separate pr MetaObjects deliberately does **not** ship one universal binary. Schema operations are language-agnostic and live in **one canonical Node `meta` CLI**; codegen is language-specific and runs in each language's own build tool (ADR-0015). - Node CLI binary name: `meta` -- Project config file: `metaobjects.config.ts` (configures `outDir`, `targets`, `dialect`, `dbImport`, `apiPrefix`, `columnNamingStrategy`, and the `generators` array) +- Project config file: `metaobjects.config.ts` (configures `outDir`, `targets`, `dialect`, `dbImport`, `apiPrefix`, and the `generators` array); column and table naming is `naming` in `.metaobjects/config.json`, read by every port - Codegen is **opt-in**: no port ships a default generator suite, `meta init` scaffolds `generators: []`, and `meta gen --list` is the catalog (`--probe` reports how many files each generator would emit for your model) - Project marker directory: `.metaobjects/` - Install (TypeScript reference): `npm install -D @metaobjectsdev/cli` (or `bun add @metaobjectsdev/cli`) diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index b2055aee3..1d4ae8d03 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -278,12 +278,16 @@ entity extends gets a class of its own carrying the columns it declares and **no members at all** — it has no table and must never acquire one. `ColumnsByField` stays 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. +Column names come from the project's `naming` setting in `.metaobjects/config.json` +(ADR-0058), the one every port reads, default `snake_case` (what `meta migrate` creates). A +database whose columns are named exactly like the fields declares +`"naming": { "columns": "literal" }`; through 1.0 that was this port's default. `dotnet meta gen` +and `dotnet meta verify --codegen` both read the setting, so a plain run and the drift gate +resolve the identical column strings; `--column-naming` is refused +([physical-naming.md](../features/migrations/physical-naming.md)). `verify --codegen` says so +when the committed output was generated with another rule, rather than telling you to +regenerate. See the "Column naming" section of +[`features/field-types.md`](../features/field-types.md). ## Use diff --git a/docs/ports/java.md b/docs/ports/java.md index dd5ee060e..d714b20ab 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -79,6 +79,18 @@ For Spring integration: add `metaobjects-core-spring`. ${project.build.directory}/generated-sources/java + + com.metaobjects.generator.spring.SpringValueObjectGenerator + + ${project.build.directory}/generated-sources/java + + + + com.metaobjects.generator.spring.SpringFilterAllowlistGenerator + + ${project.build.directory}/generated-sources/java + + @@ -298,8 +310,10 @@ name in every port. Wire any generator via the Maven plugin's `` entry pointing at its class. Every one is independently configurable; the typical starting set is -`SpringControllerGenerator` + `SpringDtoGenerator` + `SpringRepositoryGenerator` -(controller + DTO + repository). +`SpringControllerGenerator` + `SpringDtoGenerator` + `SpringRepositoryGenerator` + +`SpringFilterAllowlistGenerator` + `SpringValueObjectGenerator` (controller, DTO and +repository, plus the filter allowlist the controller reads and the value-object records a DTO +carries). **Extractors are their own entry.** `ExtractorCodeGenerator` is the one generator that writes `Extractor` classes, as in the other four ports: the pom lists it, or none are @@ -489,26 +503,16 @@ purely for the hand-written persistence layer the consumer supplies (Spring Data JPA, jOOQ, plain JDBC); nothing this toolchain itself generates ever reads it. It is the *only* route to a compile-checked physical name in this port, which is also why nothing else here can catch a wrong pairing for -free — get `columnNaming` wrong and the constant simply names a column the +free — a constant resolved under the wrong rule simply names a column the migration never created. -`SpringNamesGenerator` takes the same `columnNaming` generator arg as every -other physical-naming lever in this program, defaulting to `literal` (matching -`ObjectManagerDB`'s runtime resolution — deliberately not Kotlin's `snake_case` -codegen default, since a Java artifact defaulting differently from the Java -runtime would itself be the drift this program exists to remove): - -```xml - - ${project.build.directory}/generated-sources/java - snake_case - -``` - -Codegen cannot see a runtime `SimpleMappingHandlerDB.setColumnNaming(...)` -call — a project pairing that call with this generator must pass the same -strategy string to both, by hand (see -[`features/field-types.md`](../features/field-types.md)). +`SpringNamesGenerator` resolves names through the project's `naming` setting in +`.metaobjects/config.json` (ADR-0058), the same setting `ObjectManagerDB` and every other +port read, default `snake_case`. A database whose columns are named exactly like the fields +declares `"naming": { "columns": "literal" }`. The 1.0 `columnNaming` generator arg and +`SimpleMappingHandlerDB.setColumnNaming(...)` are refused +([physical-naming.md](../features/migrations/physical-naming.md)), so codegen and the runtime +cannot be configured apart. ### Requirement tests — `JUnitRequirementTestsGenerator` diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md index b0a114a69..ba921accc 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -567,9 +567,8 @@ With `useNames` on, `AuthorTable` reads `Table(AuthorNames.SOURCE_PRIMARY_TABLE) above — useful once you have hand-written code depending on `AuthorNames` too, so the table binding and that code share one resolution instead of two independent -ones that could drift. Both generators must be given the **same** -`columnNaming` argument, or the table and the constants file disagree with -each other about a column's name. +ones that could drift. Both read the project's one `naming` setting +(`.metaobjects/config.json`, ADR-0058), so they always agree about a column's name. A suite without the names generator keeps the literals, which is what makes the output compile either way — referencing `AuthorNames` in a run that generated no such object diff --git a/docs/ports/python.md b/docs/ports/python.md index 5e5d28001..70fe9e6de 100644 --- a/docs/ports/python.md +++ b/docs/ports/python.md @@ -139,8 +139,13 @@ 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 template +directory is a setting of the run, so it is a config key too: `templates` at the top level of +`metaobjects.config.yaml`, read by `gen`, `verify --codegen` and `verify --templates` alike; +`--templates` applies only where the config declares none, and one that contradicts the +config is refused. Column naming is not a Python setting at all: it is `naming` in +`.metaobjects/config.json`, which every port reads, and `--column-naming` is refused +([physical-naming.md](../features/migrations/physical-naming.md)). See [the migration section](../features/migrations/docs-are-generators.md#python) and the [config reference](../features/cli.md). ### Reports @@ -181,13 +186,14 @@ 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 -`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.) +**The column names come from the project's `naming` setting in `.metaobjects/config.json`** +(ADR-0058), the one `meta migrate`, the `ObjectManager` and every other port read. It +defaults to `snake_case`, what `meta migrate` creates, so on a schema it built a plain +`metaobjects gen` emits `PROGRAM_PRICE_CENTS_COLUMN = "price_cents"`. A database whose +columns are named exactly like the fields declares `"naming": { "columns": "literal" }`. +When `verify --codegen` finds the committed output was generated with another rule, 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` @@ -305,7 +311,7 @@ SUBSCRIBER_COLUMNS_BY_FIELD: Final[dict[str, str]] = { | `_SOURCE__TABLE` / `_VIEW` / `_PROC` / … | the physical name, under the member for that `kind` | | `_SOURCE__SCHEMA` | the DB schema, emitted only when one is declared | | `__FIELD` | the **logical** name — the field as your code and the wire call it | -| `__COLUMN` | the **physical** column — `@column` if declared, else the name through `column_naming` | +| `__COLUMN` | the **physical** column — `@column` if declared, else the name through the project's `naming.columns` rule | | `_IDENTITY__*` / `_INDEX__*` | one `identity.*` / `index.*` child, keyed by its metamodel name | | `…_INDEX` | the database index name — on `identity.secondary` and `index.lookup` only | @@ -392,14 +398,11 @@ by default here, unlike the JVM ports where it is opt-in — it fills a gap every other Python generated artifact leaves completely open, rather than 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 -`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 -spurious drift against a differently-configured run. +The constants resolve through the **same column rule** the `ObjectManager` uses, because +both read it from the loaded metadata, which carries the project's `naming` setting: the +constant a consumer imports always names the column a row actually lands in. `metaobjects +verify --codegen` reads the same setting, so its regen resolves the same column strings `gen` +did. ### Requirement tests diff --git a/docs/ports/typescript-client.md b/docs/ports/typescript-client.md index aa27c548e..10aea1445 100644 --- a/docs/ports/typescript-client.md +++ b/docs/ports/typescript-client.md @@ -89,7 +89,6 @@ export default defineConfig({ outDir: "packages/database/src/generated", dialect: "postgres", apiPrefix: "/api", - columnNamingStrategy: "snake_case", generators: [ entityFile(), queriesFile(), diff --git a/docs/ports/typescript.md b/docs/ports/typescript.md index d7c96aa1a..29fc36fd8 100644 --- a/docs/ports/typescript.md +++ b/docs/ports/typescript.md @@ -89,7 +89,6 @@ export default defineConfig({ extStyle: "js", // ".js"-extensioned imports — nodenext- AND bundler-safe ("none" to opt out) apiPrefix: "/api", dbImport: "../db", // the module routesFile() imports `db` from — yours to write - columnNamingStrategy: "snake_case", // "snake_case" | "literal" | "kebab-case" targets: { docs: { outDir: "./docs/generated" } }, // where the docs generators write generators: [entityFile(), queriesFile(), routesFile(), barrel(), docsFile(), apiDocsFile()], // providers: [yourProvider], // optional — add custom metamodel subtypes/attrs @@ -415,7 +414,7 @@ export const ProgramNames = { | `sources..table` / `.view` / `.proc` / … | the physical name, under the alias for that `kind` | | `sources..schema` | the DB schema, emitted only when one is declared | | `fields..name` | the **logical** name — the field as your code and the wire call it | -| `fields..column` | the **physical** column — `@column` if declared, else `name` through `columnNamingStrategy` | +| `fields..column` | the **physical** column — `@column` if declared, else `name` through the project's `naming.columns` rule (`.metaobjects/config.json`) | | `identities.` / `indexes.` | one `identity.*` / `index.*` child, keyed by its metamodel name | | `…​.index` | the database index name — on `identity.secondary` and `index.lookup` only | diff --git a/docs/recipes/mysql.md b/docs/recipes/mysql.md index 106141aff..1d7df6892 100644 --- a/docs/recipes/mysql.md +++ b/docs/recipes/mysql.md @@ -113,7 +113,8 @@ The loop above ignores `view.schema`, which is the report source's `@schema` whe one. If yours does, create the view in that schema yourself (qualify the name in your migration); the loop will not. -Pass `columnNamingStrategy` to match your tables' column names (the default is `snake_case`). +The column names follow the project's `naming.columns` rule in `.metaobjects/config.json` +(default `snake_case`); set it there to match your tables. The bodies are valid under MySQL's default `sql_mode`, `ONLY_FULL_GROUP_BY` included, and a change to a report means a new `CREATE OR REPLACE VIEW` (or `DROP` and `CREATE`) in your migrations; nothing diffs the live view for you. A `@spine` report's body is valid as diff --git a/examples/showcase/generated/java/acme/SubscriberBlurbPayload.java b/examples/showcase/generated/java/acme/SubscriberBlurbPayload.java new file mode 100644 index 000000000..b0e6be9ee --- /dev/null +++ b/examples/showcase/generated/java/acme/SubscriberBlurbPayload.java @@ -0,0 +1,27 @@ +package acme; + +import jakarta.validation.constraints.*; + +/** GENERATED — value object SubscriberBlurbPayload. Do not hand-edit; regenerated from metadata. */ +public record SubscriberBlurbPayload( + String name, + @NotNull Status status +) { + public enum Status { active, paused, cancelled } + + /** A fluent builder, so a caller can set a subset of the components. */ + public static Builder builder() { return new Builder(); } + + /** Builds a {@link SubscriberBlurbPayload}; an unset component stays null. */ + public static final class Builder { + private String name; + private Status status; + + private Builder() {} + + public Builder name(String v) { this.name = v; return this; } + public Builder status(Status v) { this.status = v; return this; } + + public SubscriberBlurbPayload build() { return new SubscriberBlurbPayload(name, status); } + } +} diff --git a/examples/showcase/jvm/pom.xml b/examples/showcase/jvm/pom.xml index 41ee6752b..7148b5ac8 100644 --- a/examples/showcase/jvm/pom.xml +++ b/examples/showcase/jvm/pom.xml @@ -62,6 +62,10 @@ com.metaobjects.generator.spring.SpringDtoGenerator ../generated/java + + com.metaobjects.generator.spring.SpringValueObjectGenerator + ../generated/java + com.metaobjects.generator.kotlin.KotlinEntityGenerator ../generated/kotlin diff --git a/examples/showcase/metaobjects/meta.subscriber.yaml b/examples/showcase/metaobjects/meta.subscriber.yaml index 5590281fd..a51c72fb2 100644 --- a/examples/showcase/metaobjects/meta.subscriber.yaml +++ b/examples/showcase/metaobjects/meta.subscriber.yaml @@ -5,19 +5,14 @@ # Sigil-free YAML (ADR-0006): bare keys here lower to @-prefixed attrs in canonical # JSON, while name/extends/children stay bare in both surfaces. # -# `column: created_at` is declared rather than left to a default ON PURPOSE. The -# per-port DEFAULT for a field with no `@column` differs by design — `meta migrate` -# and TypeScript codegen snake_case it; C#, Java and Python codegen take the field -# name verbatim (EF's property=column convention); Kotlin codegen defaults -# snake_case while the shared JVM runtime defaults literal. For a real polyglot -# project the lever is each port's column-naming OPTION (`columnNamingStrategy` / -# `--column-naming` / ``), set once per port to match the strategy -# `meta migrate` created the schema with — see docs/features/field-types.md. -# `@column` is for a name that genuinely cannot be derived (a legacy column, a -# schema owned outside this project), not a workaround for getting five ports' -# defaults to agree. This showcase declares it anyway, deliberately: the model -# is meant to be copy-pasteable and self-describing on its own, without also -# requiring every port's `meta gen` invocation on the site to pass a matching flag. +# `column: created_at` is declared rather than left to the default ON PURPOSE. A +# field with no `@column` takes its column name from the project's one `naming` +# setting in .metaobjects/config.json, which every port reads (default snake_case, +# what `meta migrate` creates) — see docs/features/field-types.md. `@column` is for a +# name that genuinely cannot be derived (a legacy column, a schema owned outside this +# project). This showcase declares it anyway, deliberately: the model is meant to be +# copy-pasteable and self-describing on its own, whatever naming the project that +# pastes it has set. # # It changes the COLUMN only. Every port's generated code still calls the field # `createdAt` — the language-level name and the physical name are separate axes. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index bd8e3486f..39d9a51cd 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -351,9 +351,10 @@ to nothing, and does not check that each claimed node complies). non-`table` source-kind / flattened-object codegen is partial). **C#** has no ObjectManager runtime tier (EF Core *is* the runtime) — hand services over the generated `DbContext` are expected. -- **Single-source config** — `apiPrefix` (URL prefix wired into routes + hooks) and - `columnNamingStrategy` (snake_case / literal / kebab-case) and per-target output dirs: - hunt these values hard-coded in multiple places instead of resolved from config. +- **Single-source config** — `apiPrefix` (URL prefix wired into routes + hooks), the + `naming` block of `.metaobjects/config.json` (column and table rules, read by every port) + and per-target output dirs: hunt these values hard-coded in multiple places instead of + resolved from config. - **CALIBRATION — planned, not yet shipped:** the declared-API surface (`api.base`, `api.operational`, `operation.query`, `operation.command`, `binding.rest`) and MCP exposure of declared prompts/tools are **not yet in the registry** — their absence is not an adopter 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/.claude/skills/metaobjects-runtime-ui/references/java.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/references/java.md index dfa75a819..1c36c656f 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/references/java.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/references/java.md @@ -172,8 +172,8 @@ restate. `AuthorNames.COLUMNS_BY_FIELD` carries the whole map when you need to build a projection list. It is opt-in on the JVM — add `SpringNamesGenerator` to the pom's `` -(see the codegen reference). Its `columnNaming` defaults to `literal`, the same -resolution OMDB uses at runtime; pass the same value to both, or the constant names a -column no row lands in. There is no typed handle to prefer on this port — `Names` +(see the codegen reference). It resolves columns through the project's one `naming` setting +in `.metaobjects/config.json` (default `snake_case`), the same setting OMDB reads at +runtime, so the constant always names the column a row lands in. There is no typed handle to prefer on this port — `Names` is the only compile-checked route to a physical name, which is also why nothing else here catches a wrong pairing for free. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/references/kotlin.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/references/kotlin.md index 08f31eb37..cdf4b1bf7 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/references/kotlin.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/references/kotlin.md @@ -93,8 +93,8 @@ column — never `AuthorNames.NAME_COLUMN`. The constant is for what the DSL doe reach: `exec("…")` raw SQL, a Flyway script, a log line, an external system's column mapping. There it is `AuthorNames.SOURCE_PRIMARY_TABLE` / `AuthorNames.NAME_COLUMN` (opt-in: `KotlinNamesGenerator` in the pom — see the codegen reference), never a literal. -Give both generators the same `columnNaming`, or the table and the constants disagree about -a column. +Both read the project's one `naming` setting in `.metaobjects/config.json`, so the table +and the constants always agree about a column. The object mirrors the metadata tree: `AuthorNames.NAME` is the OBJECT's name (`"Author"`), and a physical name sits under the member that says what it IS — 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-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index bd8e3486f..39d9a51cd 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -351,9 +351,10 @@ to nothing, and does not check that each claimed node complies). non-`table` source-kind / flattened-object codegen is partial). **C#** has no ObjectManager runtime tier (EF Core *is* the runtime) — hand services over the generated `DbContext` are expected. -- **Single-source config** — `apiPrefix` (URL prefix wired into routes + hooks) and - `columnNamingStrategy` (snake_case / literal / kebab-case) and per-target output dirs: - hunt these values hard-coded in multiple places instead of resolved from config. +- **Single-source config** — `apiPrefix` (URL prefix wired into routes + hooks), the + `naming` block of `.metaobjects/config.json` (column and table rules, read by every port) + and per-target output dirs: hunt these values hard-coded in multiple places instead of + resolved from config. - **CALIBRATION — planned, not yet shipped:** the declared-API surface (`api.base`, `api.operational`, `operation.query`, `operation.command`, `binding.rest`) and MCP exposure of declared prompts/tools are **not yet in the registry** — their absence is not an adopter 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/.claude/skills/metaobjects-runtime-ui/references/java.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-runtime-ui/references/java.md index dfa75a819..1c36c656f 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-runtime-ui/references/java.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-runtime-ui/references/java.md @@ -172,8 +172,8 @@ restate. `AuthorNames.COLUMNS_BY_FIELD` carries the whole map when you need to build a projection list. It is opt-in on the JVM — add `SpringNamesGenerator` to the pom's `` -(see the codegen reference). Its `columnNaming` defaults to `literal`, the same -resolution OMDB uses at runtime; pass the same value to both, or the constant names a -column no row lands in. There is no typed handle to prefer on this port — `Names` +(see the codegen reference). It resolves columns through the project's one `naming` setting +in `.metaobjects/config.json` (default `snake_case`), the same setting OMDB reads at +runtime, so the constant always names the column a row lands in. There is no typed handle to prefer on this port — `Names` is the only compile-checked route to a physical name, which is also why nothing else here catches a wrong pairing for free. 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-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index bd8e3486f..39d9a51cd 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -351,9 +351,10 @@ to nothing, and does not check that each claimed node complies). non-`table` source-kind / flattened-object codegen is partial). **C#** has no ObjectManager runtime tier (EF Core *is* the runtime) — hand services over the generated `DbContext` are expected. -- **Single-source config** — `apiPrefix` (URL prefix wired into routes + hooks) and - `columnNamingStrategy` (snake_case / literal / kebab-case) and per-target output dirs: - hunt these values hard-coded in multiple places instead of resolved from config. +- **Single-source config** — `apiPrefix` (URL prefix wired into routes + hooks), the + `naming` block of `.metaobjects/config.json` (column and table rules, read by every port) + and per-target output dirs: hunt these values hard-coded in multiple places instead of + resolved from config. - **CALIBRATION — planned, not yet shipped:** the declared-API surface (`api.base`, `api.operational`, `operation.query`, `operation.command`, `binding.rest`) and MCP exposure of declared prompts/tools are **not yet in the registry** — their absence is not an adopter 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..244c9253a 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,26 @@ generated that the config does not declare. ```yaml # metaobjects.config.yaml metadata: metadata +templates: prompts # where template refs resolve from (default: prompts/, else templates/) targets: app: outDir: generated generators: [entity, filter-allowlist, routes] ``` +**Column names come from `naming` in `.metaobjects/config.json`, the one setting every port +reads** (ADR-0058): `meta migrate`, every port's codegen and every runtime `ObjectManager`. +The default is `snake_case`, what `meta migrate` creates. A database whose columns are named +exactly like the fields declares it there, once, for every port: + +```json +{ "schema_version": 1, "naming": { "columns": "literal" } } +``` + +There is no per-port or command-line naming: `--column-naming` is refused, and +`verify --codegen` names a naming mismatch as one rather than telling you to regenerate. A `--templates` that contradicts +the config's `templates` is refused. + ```bash metaobjects gen # runs every target's generators into its outDir metaobjects gen --generators entity # narrow to declared entries (never adds one) @@ -51,8 +65,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; +`--templates` only where the config declares no `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 +83,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 +93,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/.claude/skills/metaobjects-runtime-ui/references/python.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-runtime-ui/references/python.md index 7fea94304..de1d4870b 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-runtime-ui/references/python.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-runtime-ui/references/python.md @@ -122,5 +122,6 @@ row = await conn.fetchrow( ``` There is no typed handle to prefer on this port, so this is never the wrong choice here. -Pass the same `--column-naming` to `metaobjects gen` that you pass as `column_naming=` to -`ObjectManager` (both default to `literal`), or the constant names a column no row lands in. +The constants and the `ObjectManager` both resolve columns through the project's one `naming` +setting in `.metaobjects/config.json` (default `snake_case`), so the constant always names +the column a row lands in. 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-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index bd8e3486f..39d9a51cd 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -351,9 +351,10 @@ to nothing, and does not check that each claimed node complies). non-`table` source-kind / flattened-object codegen is partial). **C#** has no ObjectManager runtime tier (EF Core *is* the runtime) — hand services over the generated `DbContext` are expected. -- **Single-source config** — `apiPrefix` (URL prefix wired into routes + hooks) and - `columnNamingStrategy` (snake_case / literal / kebab-case) and per-target output dirs: - hunt these values hard-coded in multiple places instead of resolved from config. +- **Single-source config** — `apiPrefix` (URL prefix wired into routes + hooks), the + `naming` block of `.metaobjects/config.json` (column and table rules, read by every port) + and per-target output dirs: hunt these values hard-coded in multiple places instead of + resolved from config. - **CALIBRATION — planned, not yet shipped:** the declared-API surface (`api.base`, `api.operational`, `operation.query`, `operation.command`, `binding.rest`) and MCP exposure of declared prompts/tools are **not yet in the registry** — their absence is not an adopter 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-mysql.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md index 9cf2d06f0..3e40002a0 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md @@ -97,7 +97,8 @@ The loop above ignores `view.schema`, which is the report source's `@schema` whe one. If yours does, create the view in that schema yourself (qualify the name in your migration); the loop will not. -Pass `columnNamingStrategy` to match your tables' column names (the default is `snake_case`). +The column names follow the project's `naming.columns` rule in `.metaobjects/config.json` +(default `snake_case`); set it there to match your tables. The bodies are valid under MySQL's default `sql_mode`, `ONLY_FULL_GROUP_BY` included, and a change to a report means a new `CREATE OR REPLACE VIEW` (or `DROP` and `CREATE`) in your migrations; nothing diffs the live view for you. A `@spine` report's body is valid as 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..daca582a3 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`, @@ -81,7 +86,6 @@ export default defineConfig({ dialect: "postgres", // "postgres" | "sqlite" | "d1" | "mysql" (mysql: codegen + runtime; migrate does not own it) extStyle: "js", // "js" (default) for Node ESM / plain tsc; "none" for a bundler-resolution toolchain — see `owning-generators.md` "Your framework isn't the default" apiPrefix: "/api", // flows to routes AND client fetch URLs - columnNamingStrategy: "snake_case", // "snake_case" (default) | "literal" | "kebab-case" timestampMode: "string", // "string" (default, ISO-8601 wire contract) | "date" (Drizzle native Date) pluralizeCollections: true, // default; table VARS auto-pluralize (AgentConfig → agentConfigs) collectionNameOverrides: { // per-entity escape hatch for names the rule gets wrong 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-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index bd8e3486f..39d9a51cd 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -351,9 +351,10 @@ to nothing, and does not check that each claimed node complies). non-`table` source-kind / flattened-object codegen is partial). **C#** has no ObjectManager runtime tier (EF Core *is* the runtime) — hand services over the generated `DbContext` are expected. -- **Single-source config** — `apiPrefix` (URL prefix wired into routes + hooks) and - `columnNamingStrategy` (snake_case / literal / kebab-case) and per-target output dirs: - hunt these values hard-coded in multiple places instead of resolved from config. +- **Single-source config** — `apiPrefix` (URL prefix wired into routes + hooks), the + `naming` block of `.metaobjects/config.json` (column and table rules, read by every port) + and per-target output dirs: hunt these values hard-coded in multiple places instead of + resolved from config. - **CALIBRATION — planned, not yet shipped:** the declared-API surface (`api.base`, `api.operational`, `operation.query`, `operation.command`, `binding.rest`) and MCP exposure of declared prompts/tools are **not yet in the registry** — their absence is not an adopter 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-mysql.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md index 9cf2d06f0..3e40002a0 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md @@ -97,7 +97,8 @@ The loop above ignores `view.schema`, which is the report source's `@schema` whe one. If yours does, create the view in that schema yourself (qualify the name in your migration); the loop will not. -Pass `columnNamingStrategy` to match your tables' column names (the default is `snake_case`). +The column names follow the project's `naming.columns` rule in `.metaobjects/config.json` +(default `snake_case`); set it there to match your tables. The bodies are valid under MySQL's default `sql_mode`, `ONLY_FULL_GROUP_BY` included, and a change to a report means a new `CREATE OR REPLACE VIEW` (or `DROP` and `CREATE`) in your migrations; nothing diffs the live view for you. A `@spine` report's body is valid as 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..daca582a3 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`, @@ -81,7 +86,6 @@ export default defineConfig({ dialect: "postgres", // "postgres" | "sqlite" | "d1" | "mysql" (mysql: codegen + runtime; migrate does not own it) extStyle: "js", // "js" (default) for Node ESM / plain tsc; "none" for a bundler-resolution toolchain — see `owning-generators.md` "Your framework isn't the default" apiPrefix: "/api", // flows to routes AND client fetch URLs - columnNamingStrategy: "snake_case", // "snake_case" (default) | "literal" | "kebab-case" timestampMode: "string", // "string" (default, ISO-8601 wire contract) | "date" (Drizzle native Date) pluralizeCollections: true, // default; table VARS auto-pluralize (AgentConfig → agentConfigs) collectionNameOverrides: { // per-entity escape hatch for names the rule gets wrong 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/fixtures/api-contract-conformance/.metaobjects/config.json b/fixtures/api-contract-conformance/.metaobjects/config.json new file mode 100644 index 000000000..1e4b8166e --- /dev/null +++ b/fixtures/api-contract-conformance/.metaobjects/config.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "naming": { "columns": "literal" } +} diff --git a/fixtures/generator-registry-conformance/registry.json b/fixtures/generator-registry-conformance/registry.json index c9bf66bc8..eff2ba41f 100644 --- a/fixtures/generator-registry-conformance/registry.json +++ b/fixtures/generator-registry-conformance/registry.json @@ -367,7 +367,6 @@ "codegenPackages": ["MetaObjects.Codegen.**"], "engine": [ "MetaObjects.Codegen.CSharpNaming", - "MetaObjects.Codegen.ColumnNamingStrategy", "MetaObjects.Codegen.EmittedFile", "MetaObjects.Codegen.FieldNames", "MetaObjects.Codegen.GenConfig", diff --git a/fixtures/metamodel-docs/expected/types/field.md b/fixtures/metamodel-docs/expected/types/field.md index 641657702..d51cdb8ba 100644 --- a/fixtures/metamodel-docs/expected/types/field.md +++ b/fixtures/metamodel-docs/expected/types/field.md @@ -20,7 +20,7 @@ Abstract base field — the shared root subtype that concrete field subtypes spe | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | @@ -53,7 +53,7 @@ True/false flag. Binds to the native boolean type; DB column is BOOLEAN. | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | @@ -88,7 +88,7 @@ Stores money as integer minor units (cents). Binds to long; the client formats v | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@currency` | string | no | `USD` | | metaobjects-core-types | ISO 4217 currency code for a currency-subtype field. Storage is integer minor units; defaults to 'USD' when omitted. | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | @@ -123,7 +123,7 @@ Calendar date (no time-of-day). Binds to the native date/temporal type; DB colum | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | | `@autoSet` | string | no | | `onCreate`, `onUpdate` | metaobjects-db | Auto-set semantics for timestamp-like fields: 'onCreate' stamps on insert, 'onUpdate' stamps on every write. | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | @@ -158,7 +158,7 @@ Precision-exact decimal (use @precision/@scale). Native TS binding is string (lo | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | @@ -193,7 +193,7 @@ Double-precision (64-bit) IEEE-754 floating point. Binds to the native double/nu | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | @@ -229,7 +229,7 @@ String-backed enumeration constrained to a closed set of member symbols (@values | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | | `@coerceDefault` | string | no | | | metaobjects-prompt | Fallback enum member used by tolerant extract when a present value cannot be coerced; must be one of the field's @values. | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | @@ -266,7 +266,7 @@ Single-precision floating point. Binds to the native double/number type (TS has | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | @@ -299,7 +299,7 @@ An IP-address string (IPv4 or IPv6) (ADR-0036/0037 — a concept with a native t | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | @@ -333,7 +333,7 @@ An IP-address string (IPv4 or IPv6) (ADR-0036/0037 — a concept with a native t | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | @@ -366,7 +366,7 @@ An IP-address string (IPv4 or IPv6) (ADR-0036/0037 — a concept with a native t | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | @@ -401,7 +401,7 @@ An open-keyed map (Record / dict[str,V]) stored in a single jsonb colu | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | @@ -438,7 +438,7 @@ A nested structured value (set @objectRef to the target object). Storage is gove | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | @@ -473,7 +473,7 @@ Variable-length text. Binds to the native string type; DB column is VARCHAR/TEXT | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | @@ -507,7 +507,7 @@ Time-of-day (no calendar date). Binds to the native date/temporal type; DB colum | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | | `@autoSet` | string | no | | `onCreate`, `onUpdate` | metaobjects-db | Auto-set semantics for timestamp-like fields: 'onCreate' stamps on insert, 'onUpdate' stamps on every write. | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | @@ -541,7 +541,7 @@ Date + time-of-day instant (optionally with timezone). Binds to the native date/ | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | | `@autoSet` | string | no | | `onCreate`, `onUpdate` | metaobjects-db | Auto-set semantics for timestamp-like fields: 'onCreate' stamps on insert, 'onUpdate' stamps on every write. | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | @@ -575,7 +575,7 @@ A URI/URL string (ADR-0036/0037 — a concept with a native type + behavior, so | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | @@ -609,7 +609,7 @@ Logical UUID identity scalar. A bare scalar (no required attrs, no loader value- | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | -| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | diff --git a/fixtures/metamodel-docs/expected/types/source.md b/fixtures/metamodel-docs/expected/types/source.md index 4c671e776..0689d890a 100644 --- a/fixtures/metamodel-docs/expected/types/source.md +++ b/fixtures/metamodel-docs/expected/types/source.md @@ -46,7 +46,7 @@ The relational-database paradigm source (ADR-0007): binds an object to a physica | `@role` | string | no | | `primary`, `replica` | metaobjects-db | Role this source plays when an object has multiple sources: primary (default, system of record) or replica. The former members index, cache, publish and mirror are reserved-not-registered (ADR-0007 Amendment 2): a role member enters the registry only when a shipping consumer dispatches on it. | | `@schema` | string | no | | | metaobjects-db | Optional database schema name (e.g. 'catalog', 'public'). Postgres defaults to 'public'; SQLite rejects any non-default value. | | `@sql` | string | no | | | metaobjects-db | FR-024/#208 escape valve — a hand-written SQL body the tool REGISTERS + fingerprints + drift-checks but never authors or parses. The body goes INSIDE `CREATE AS …` (never the CREATE wrapper, never the object name). Legal only on a read-only kind (not @kind: table); migrate lowers it on @kind: view (matview/proc/tableFunction: registered but not yet migrate-managed). Mutually exclusive with @unmanaged; forbids origin.* children (two sources of truth). | -| `@table` | string | no | | | metaobjects-db | Physical SQL table name for source.rdb @kind: "table" (default). FR-016: Defaults from the source's bare structural `name` via the project's columnNamingStrategy when omitted, then from the owning entity's name. Pre-1.0 legacy spelling for view/materializedView/storedProc/tableFunction kinds during the transition; canonical-serializer rewrites to the kind-matching alias. | +| `@table` | string | no | | | metaobjects-db | Physical SQL table name for source.rdb @kind: "table" (default). FR-016: Defaults from the source's bare structural `name` under the project's naming.tables rule (the "naming" setting in .metaobjects/config.json) when omitted, then from the owning entity's name. Pre-1.0 legacy spelling for view/materializedView/storedProc/tableFunction kinds during the transition; canonical-serializer rewrites to the kind-matching alias. | | `@unmanaged` | boolean | no | | | metaobjects-db | FR-024/#208 escape valve — this DB object is managed elsewhere (Flyway / a hand-migration owns its DDL). meta migrate does NOT create, drop, or drift-check it; verify --db reports it as external (declared). Legal on any @kind including table (the externally-managed-entity case). Mutually exclusive with @sql. | | `@view` | string | no | | | metaobjects-db | Physical SQL view name for source.rdb @kind: "view". Same internal slot as @table. | diff --git a/fixtures/naming-conformance/physical-names/cases.json b/fixtures/naming-conformance/physical-names/cases.json new file mode 100644 index 000000000..fbc0c7792 --- /dev/null +++ b/fixtures/naming-conformance/physical-names/cases.json @@ -0,0 +1,61 @@ +{ + "_comment": "Cross-port physical naming (ADR-0058). Each case is the `naming` block of a project's .metaobjects/config.json. A port parses it with the reader it uses for that file, loads meta.naming.json, and resolves every table and column below through the resolver its codegen, migrate (TypeScript), runtime and verify use. `tables` keys are object names; `columns` keys are `.`. Overrides (@table, a source's structural name, @column) win under every rule; the entity-name fallback keeps the frozen legacy pluralizer under every rule. `invalid` blocks must be refused when the config is read.", + "cases": [ + { + "name": "default", + "naming": null, + "tables": { "OrderItem": "order_items", "Person": "people", "AuditEntry": "audit_log", "Category": "categories", "HTTPRequest": "http_requests" }, + "columns": { + "OrderItem.id": "id", "OrderItem.createdAt": "created_at", "OrderItem.unitPriceCents": "unit_price_cents", "OrderItem.sku": "SKU_CODE", + "Person.firstName": "first_name", "Person.lastName": "family_name", + "AuditEntry.eventType": "event_type", "Category.displayName": "display_name", "HTTPRequest.requestURL": "request_url" + } + }, + { + "name": "empty block is the default", + "naming": {}, + "tables": { "OrderItem": "order_items", "Person": "people", "AuditEntry": "audit_log", "Category": "categories", "HTTPRequest": "http_requests" }, + "columns": { + "OrderItem.id": "id", "OrderItem.createdAt": "created_at", "OrderItem.unitPriceCents": "unit_price_cents", "OrderItem.sku": "SKU_CODE", + "Person.firstName": "first_name", "Person.lastName": "family_name", + "AuditEntry.eventType": "event_type", "Category.displayName": "display_name", "HTTPRequest.requestURL": "request_url" + } + }, + { + "name": "literal columns", + "naming": { "columns": "literal" }, + "tables": { "OrderItem": "order_items", "Person": "people", "AuditEntry": "audit_log", "Category": "categories", "HTTPRequest": "http_requests" }, + "columns": { + "OrderItem.id": "id", "OrderItem.createdAt": "createdAt", "OrderItem.unitPriceCents": "unitPriceCents", "OrderItem.sku": "SKU_CODE", + "Person.firstName": "firstName", "Person.lastName": "family_name", + "AuditEntry.eventType": "eventType", "Category.displayName": "displayName", "HTTPRequest.requestURL": "requestURL" + } + }, + { + "name": "literal tables", + "naming": { "tables": "literal" }, + "tables": { "OrderItem": "OrderItems", "Person": "people", "AuditEntry": "AuditLog", "Category": "Categories", "HTTPRequest": "HTTPRequests" }, + "columns": { + "OrderItem.id": "id", "OrderItem.createdAt": "created_at", "OrderItem.unitPriceCents": "unit_price_cents", "OrderItem.sku": "SKU_CODE", + "Person.firstName": "first_name", "Person.lastName": "family_name", + "AuditEntry.eventType": "event_type", "Category.displayName": "display_name", "HTTPRequest.requestURL": "request_url" + } + }, + { + "name": "kebab-case columns, literal tables", + "naming": { "columns": "kebab-case", "tables": "literal" }, + "tables": { "OrderItem": "OrderItems", "Person": "people", "AuditEntry": "AuditLog", "Category": "Categories", "HTTPRequest": "HTTPRequests" }, + "columns": { + "OrderItem.id": "id", "OrderItem.createdAt": "created-at", "OrderItem.unitPriceCents": "unit-price-cents", "OrderItem.sku": "SKU_CODE", + "Person.firstName": "first-name", "Person.lastName": "family_name", + "AuditEntry.eventType": "event-type", "Category.displayName": "display-name", "HTTPRequest.requestURL": "request-url" + } + } + ], + "invalid": [ + { "name": "unknown column rule", "naming": { "columns": "camelCase" } }, + { "name": "kebab-case is not a table rule", "naming": { "tables": "kebab-case" } }, + { "name": "unknown key", "naming": { "column": "literal" } }, + { "name": "not an object", "naming": "snake_case" } + ] +} diff --git a/fixtures/naming-conformance/physical-names/meta.naming.json b/fixtures/naming-conformance/physical-names/meta.naming.json new file mode 100644 index 000000000..45402809c --- /dev/null +++ b/fixtures/naming-conformance/physical-names/meta.naming.json @@ -0,0 +1,65 @@ +{ + "metadata.root": { + "package": "acme::naming", + "children": [ + { + "object.entity": { + "name": "OrderItem", + "children": [ + { "source.rdb": {} }, + { "field.long": { "name": "id" } }, + { "field.timestamp": { "name": "createdAt" } }, + { "field.int": { "name": "unitPriceCents" } }, + { "field.string": { "name": "sku", "@column": "SKU_CODE" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ] + } + }, + { + "object.entity": { + "name": "Person", + "children": [ + { "source.rdb": { "@table": "people" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "firstName" } }, + { "field.string": { "name": "lastName", "@column": "family_name" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ] + } + }, + { + "object.entity": { + "name": "AuditEntry", + "children": [ + { "source.rdb": { "name": "AuditLog" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "eventType" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ] + } + }, + { + "object.entity": { + "name": "Category", + "children": [ + { "source.rdb": {} }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "displayName" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ] + } + }, + { + "object.entity": { + "name": "HTTPRequest", + "children": [ + { "source.rdb": {} }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "requestURL" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ] + } + } + ] + } +} diff --git a/fixtures/persistence-conformance/.metaobjects/config.json b/fixtures/persistence-conformance/.metaobjects/config.json new file mode 100644 index 000000000..1e4b8166e --- /dev/null +++ b/fixtures/persistence-conformance/.metaobjects/config.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "naming": { "columns": "literal" } +} diff --git a/fixtures/registry-conformance/expected-registry.json b/fixtures/registry-conformance/expected-registry.json index c1888bd28..ce27d0aa9 100644 --- a/fixtures/registry-conformance/expected-registry.json +++ b/fixtures/registry-conformance/expected-registry.json @@ -149,7 +149,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "db.indexed", @@ -291,7 +291,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "db.indexed", @@ -434,7 +434,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "currency", @@ -594,7 +594,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "db.indexed", @@ -737,7 +737,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "db.indexed", @@ -893,7 +893,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "db.indexed", @@ -1043,7 +1043,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "db.indexed", @@ -1231,7 +1231,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "db.indexed", @@ -1373,7 +1373,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "db.indexed", @@ -1522,7 +1522,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "db.indexed", @@ -1664,7 +1664,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "db.indexed", @@ -1807,7 +1807,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "db.indexed", @@ -1964,7 +1964,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "db.indexed", @@ -2125,7 +2125,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "db.indexed", @@ -2295,7 +2295,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "db.indexed", @@ -2448,7 +2448,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "db.indexed", @@ -2597,7 +2597,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "db.indexed", @@ -2746,7 +2746,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "name": "db.indexed", @@ -4330,7 +4330,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Physical SQL table name for source.rdb @kind: \"table\" (default). FR-016: Defaults from the source's bare structural `name` via the project's columnNamingStrategy when omitted, then from the owning entity's name. Pre-1.0 legacy spelling for view/materializedView/storedProc/tableFunction kinds during the transition; canonical-serializer rewrites to the kind-matching alias." + "description": "Physical SQL table name for source.rdb @kind: \"table\" (default). FR-016: Defaults from the source's bare structural `name` under the project's naming.tables rule (the \"naming\" setting in .metaobjects/config.json) when omitted, then from the owning entity's name. Pre-1.0 legacy spelling for view/materializedView/storedProc/tableFunction kinds during the transition; canonical-serializer rewrites to the kind-matching alias." }, { "name": "unmanaged", 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..ac359d4f1 --- /dev/null +++ b/scripts/prerelease-version.mjs @@ -0,0 +1,137 @@ +// 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, and +// the way it runs its steps so that a signal stops the run and restores what it bumped. +// +// 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. + +import { spawn } from "node:child_process"; + +/** 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`); +} + +const active = new Set(); + +/** + * Run a shell command without blocking the event loop, and resolve to its stdout. A synchronous + * exec holds the event loop for the whole step, so a SIGTERM listener could not run until the + * script ended: a killed run went on publishing every remaining registry. Each child leads its own + * process group, so a signal can stop the whole command line, not only the shell running it. + */ +export function run(command, { stdio = "pipe", ...opts } = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, { shell: true, detached: true, stdio, ...opts }); + active.add(child); + let out = ""; + let err = ""; + child.stdout?.setEncoding("utf8").on("data", (d) => { out += d; }); + child.stderr?.setEncoding("utf8").on("data", (d) => { err += d; }); + child.on("error", (e) => { active.delete(child); reject(e); }); + child.on("close", (code, signal) => { + active.delete(child); + if (code === 0) resolve(out); + else reject(new Error(`Command failed (${signal ?? `exit ${code}`}): ${command}${err ? `\n${err}` : ""}`)); + }); + }); +} + +/** + * 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. A signal first stops + * every command `run` still has in flight, so nothing publishes or rewrites a file after it. + */ +export function installRestoreHandlers(restore, proc = process) { + proc.on("exit", restore); + for (const [signal, code] of [["SIGINT", 130], ["SIGTERM", 143], ["SIGHUP", 129]]) { + proc.on(signal, () => { + for (const child of active) { + try { process.kill(-child.pid, "SIGKILL"); } catch {} + } + restore(); + proc.exit(code); + }); + } +} diff --git a/scripts/prerelease.mjs b/scripts/prerelease.mjs index c6b5aac9c..4ece092b4 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 @@ -55,10 +57,13 @@ // bare `mvn deploy` has no target at all. // 6. Version declarations are edited in place and ALWAYS restored on exit; the script // refuses to start if any of them is already dirty. -import { execSync, execFileSync } from "node:child_process"; +import { execSync } 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, run as runStep, +} 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]; }; @@ -72,6 +77,8 @@ const ok = (m) => console.log(`\x1b[32m✓\x1b[0m ${m}`); const info = (m) => console.log(` ${m}`); const die = (m) => { console.error(`\x1b[31m✗ ${m}\x1b[0m`); process.exit(1); }; const sh = (cmd, o = {}) => execSync(cmd, { encoding: "utf8", cwd: ROOT, stdio: "pipe", ...o }); +// Every step after the restore handlers are installed goes through this, so a signal can stop it. +const run = (cmd, o = {}) => runStep(cmd, { cwd: ROOT, ...o }); // ── registry config: environment first, then the gitignored env file ────────────────── const envFile = join(ROOT, "tools/prerelease/registry.env"); @@ -122,58 +129,76 @@ 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; + +// One public-npm read, shared by the two callers that make it: the parsed install-v1 +// document, `null` for a MISS (the package or version is not published there yet), and the +// UNREACHABLE sentinel for a network or parse failure. A miss and a failure collapse +// together for `npmLatest` but not for `burnedPublicIters` — a 404 is silence, being +// offline is a warning — so the helper keeps them apart and each caller picks its posture. +const UNREACHABLE = Symbol("npm-unreachable"); +const fetchNpmDoc = async (name) => { + try { + const r = await fetch(`https://registry.npmjs.org/${name.replace("/", "%2f")}`, + { headers: { accept: "application/vnd.npm.install-v1+json" }, signal: AbortSignal.timeout(10_000) }); + return r.ok ? await r.json() : null; + } catch { return UNREACHABLE; } +}; -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 () => { + const doc = await fetchNpmDoc("@metaobjectsdev/cli"); + if (!doc || doc === UNREACHABLE) return null; + return (doc["dist-tags"] || {}).latest ?? 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 () => { + let list; try { - const list = JSON.parse(sh(`curl -fsS --max-time 10 -u "${OWNER}:${TOKEN}" "${BASE}/api/v1/packages/${OWNER}?limit=1000"`)); - return list.map((p) => (p.version.match(ITER_RE) || [])[1]).filter(Boolean).map(Number); - } catch { return []; } + 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}"`))); + } catch (e) { + die(`could not list the pre-release registry's packages, so the next free iteration is unknown: ${e.message}\n` + + ` Retry, or pass --iter .`); + } + return list.map((p) => (p.version.match(ITER_RE) || [])[1]).filter(Boolean).map(Number); }; // ...and across PUBLIC npm as well. A version published there is permanent — unpublish is @@ -183,13 +208,10 @@ const usedIters = () => { // promoted to a public RC, so the counter skips it here instead of failing there. const burnedPublicIters = async () => { const results = await Promise.all(LOCKSTEP.map(async ({ pkg }) => { - try { - const r = await fetch(`https://registry.npmjs.org/${pkg.name.replace("/", "%2f")}`, - { headers: { accept: "application/vnd.npm.install-v1+json" }, signal: AbortSignal.timeout(10_000) }); - if (!r.ok) return []; - const doc = await r.json(); - return Object.keys(doc.versions || {}).map((v) => (v.match(ITER_RE) || [])[1]).filter(Boolean).map(Number); - } catch { return null; } // offline: fail OPEN, the release preflight is the backstop + const doc = await fetchNpmDoc(pkg.name); + if (doc === UNREACHABLE) return null; // offline: fail OPEN, the release preflight is the backstop + if (!doc) return []; // not on public npm yet: no burned numbers + return Object.keys(doc.versions || {}).map((v) => (v.match(ITER_RE) || [])[1]).filter(Boolean).map(Number); })); if (results.some((r) => r === null)) console.warn(`\x1b[33m! could not reach public npm — the iteration number may reuse one that is already burned there\x1b[0m`); @@ -198,10 +220,13 @@ const burnedPublicIters = async () => { const explicitIter = flag("iter", null); const burned = await burnedPublicIters(); -const ITER = Number(explicitIter ?? (() => { - const taken = [...usedIters(), ...burned]; +// The next free iteration across all four ecosystems. The `??` above skips this whole +// call — including its registry fetches — when --iter was given. +const nextIter = async () => { + const taken = [...await usedIters(), ...burned]; return taken.length ? Math.max(...taken) + 1 : 1; -})()); +}; +const ITER = Number(explicitIter ?? await nextIter()); if (!Number.isInteger(ITER) || ITER < 1) die(`--iter must be a positive integer, got ${ITER}`); if (explicitIter && burned.includes(ITER)) console.warn(`\x1b[33m! ${BASEVER}-rc.${ITER} is already published on PUBLIC npm for at least one package.\n` + @@ -232,7 +257,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 +297,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 @@ -295,13 +319,13 @@ if (wants("npm")) { ok(`npm lockstep set: ${set.length} packages → ${V.npm}`); touch("bun.lock"); - sh("rm -f bun.lock && bun install"); // re-pins workspace:* to V.npm - sh("bun run clean && bun run build"); + await run("rm -f bun.lock && bun install"); // re-pins workspace:* to V.npm + await run("bun run clean && bun run build"); ok("relocked + clean rebuild"); const p = scratch("pack"); - sh(`cd server/typescript/packages/cli && bun pm pack --destination ${p}`); - const pj = JSON.parse(sh(`tar -xzOf ${p}/*.tgz package/package.json`)); + await run(`cd server/typescript/packages/cli && bun pm pack --destination ${p}`); + const pj = JSON.parse(await run(`tar -xzOf ${p}/*.tgz package/package.json`)); const bad = Object.entries(pj.dependencies || {}).filter(([k, v]) => k.startsWith("@metaobjectsdev/") && v !== V.npm); if (bad.length) die(`packed cli pins stale sibling deps: ${JSON.stringify(bad)}`); ok(`packed deps pinned to ${V.npm}`); @@ -315,15 +339,15 @@ if (wants("npm")) { const ordered = [...set].sort((a, b) => TIERS.indexOf(a.short) - TIERS.indexOf(b.short)); // GATE 3 — ask bun where it would actually publish, and believe only that. - const probe = execFileSync("bun", ["publish", "--dry-run"], - { cwd: join(ROOT, ordered[0].dir), env: { ...process.env, HOME: home }, encoding: "utf8" }); + const probe = await run("bun publish --dry-run", + { cwd: join(ROOT, ordered[0].dir), env: { ...process.env, HOME: home } }); const seen = (probe.match(/^Registry:\s*(\S+)/m) || [])[1]; if (seen !== NPM_REG) die(`bun would publish to ${seen}, not ${NPM_REG} — aborting`); ok(`bun publish target verified by dry-run: ${seen}`); if (!DRY) for (const pkg of ordered) { - execFileSync("bun", ["publish", "--tag", "prerelease"], - { cwd: join(ROOT, pkg.dir), env: { ...process.env, HOME: home }, stdio: "pipe" }); + await run("bun publish --tag prerelease", + { cwd: join(ROOT, pkg.dir), env: { ...process.env, HOME: home } }); info(`published ${pkg.short}@${V.npm}`); } ok(DRY ? "npm: dry run, nothing published" : `npm: ${ordered.length} packages @ ${V.npm} (dist-tag prerelease)`); @@ -335,10 +359,10 @@ if (wants("python")) { touch(f); writeFileSync(join(ROOT, f), readFileSync(join(ROOT, f), "utf8").replace(/^version = ".*"$/m, `version = "${V.pypi}"`)); const dist = scratch("pydist"); - sh(`cd server/python && uv build --out-dir ${dist}`); + await run(`cd server/python && uv build --out-dir ${dist}`); ok(`python built ${V.pypi}`); if (!DRY) { - sh(`cd server/python && uv publish --publish-url "${PYPI_URL}" --username "${OWNER}" --password "${TOKEN}" ${dist}/*`); + await run(`cd server/python && uv publish --publish-url "${PYPI_URL}" --username "${OWNER}" --password "${TOKEN}" ${dist}/*`); ok(`python: metaobjects ${V.pypi} → ${expectedHost}`); } else ok("python: dry run, nothing published"); } @@ -349,10 +373,10 @@ if (wants("csharp")) { const out = scratch("nupkg"); const projs = ["MetaObjects/MetaObjects", "MetaObjects.Render/MetaObjects.Render", "MetaObjects.Codegen/MetaObjects.Codegen", "MetaObjects.Cli/MetaObjects.Cli"]; - for (const p of projs) sh(`cd server/csharp && dotnet pack ${p}.csproj -c Release -o ${out} -p:Version=${V.nuget} --nologo -v q`); + for (const p of projs) await run(`cd server/csharp && dotnet pack ${p}.csproj -c Release -o ${out} -p:Version=${V.nuget} --nologo -v q`); ok(`csharp packed ${V.nuget} (4 packages)`); if (!DRY) { - sh(`dotnet nuget push "${out}/*.nupkg" --source "${NUGET_SRC}" --api-key "${TOKEN}"`); + await run(`dotnet nuget push "${out}/*.nupkg" --source "${NUGET_SRC}" --api-key "${TOKEN}"`); ok(`csharp: 4 packages @ ${V.nuget} → ${expectedHost}`); } else ok("csharp: dry run, nothing published"); } @@ -364,12 +388,12 @@ if (wants("java")) { // silently skips the two reactor-EXCLUDED integration-test modules, whose parent // version then lags (see scripts/check-pom-versions.sh, docs/RELEASING.md). const poms = [ - ...sh(`grep -rl '${javaReleased}' --include=pom.xml server/java`).trim().split("\n").filter(Boolean), + ...(await run(`grep -rl '${javaReleased}' --include=pom.xml server/java`)).trim().split("\n").filter(Boolean), "examples/showcase/jvm/pom.xml", ]; touch(...poms); - sh(`sed -i 's/${javaReleased.replace(/\./g, "\\.")}/${V.maven}/g' ${poms.map((f) => `'${f}'`).join(" ")}`); - sh("scripts/check-pom-versions.sh"); + await run(`sed -i 's/${javaReleased.replace(/\./g, "\\.")}/${V.maven}/g' ${poms.map((f) => `'${f}'`).join(" ")}`); + await run("scripts/check-pom-versions.sh"); ok(`java poms → ${V.maven}`); const settings = join(scratch("m2"), "settings.xml"); writeFileSync(settings, @@ -379,7 +403,7 @@ if (wants("java")) { // `package` for a dry run, not `install`: a dry run must not write artifacts anywhere. const goal = DRY ? "package" : "deploy"; const repo = keptScratch("m2repo"); - sh(`cd server/java && mvn -B -s ${settings} -Dmaven.repo.local=${repo} ${goal} -DskipTests` + + await run(`cd server/java && mvn -B -s ${settings} -Dmaven.repo.local=${repo} ${goal} -DskipTests` + ` -DaltDeploymentRepository="mo-prerelease::${MAVEN_URL}"`, { stdio: "inherit" }); ok(DRY ? "java: dry run (install only)" : `java: reactor @ ${V.maven} → ${expectedHost}`); diff --git a/scripts/test-prerelease-version.mjs b/scripts/test-prerelease-version.mjs new file mode 100644 index 000000000..d417c7a0e --- /dev/null +++ b/scripts/test-prerelease-version.mjs @@ -0,0 +1,154 @@ +#!/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, and stops a step that +// is still running instead of letting the run publish the remaining registries first. +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, run, +} 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 }); + } +}); + +await test("P3: SIGTERM during a running step stops the step and the run, then restores", async () => { + const dir = mkdtempSync(join(tmpdir(), "prerelease-sigterm-step-")); + try { + const at = (name) => join(dir, name); + const child = at("child.mjs"); + const mod = pathToFileURL(join(import.meta.dirname, "prerelease-version.mjs")).href; + // The step backgrounds a subshell, which a kill of the shell alone would leave running. + const step = `echo up > '${at("started")}'; (sleep 1; echo leaked > '${at("leaked")}') & wait`; + writeFileSync(child, ` + import { writeFileSync } from "node:fs"; + import { installRestoreHandlers, run } from ${JSON.stringify(mod)}; + installRestoreHandlers(() => writeFileSync(${JSON.stringify(at("restored"))}, "yes")); + await run(${JSON.stringify(step)}); + writeFileSync(${JSON.stringify(at("continued"))}, "yes"); + `); + const proc = spawn(process.execPath, [child], { stdio: "inherit" }); + const exited = new Promise((resolve) => proc.on("exit", (code, signal) => resolve({ code, signal }))); + for (let i = 0; i < 250 && !existsSync(at("started")); i++) await new Promise((r) => setTimeout(r, 20)); + assert.ok(existsSync(at("started")), "the step never started"); + proc.kill("SIGTERM"); + const { code } = await exited; + assert.equal(code, 143); + assert.ok(existsSync(at("restored")), "restore did not run on SIGTERM"); + assert.ok(!existsSync(at("continued")), "the run went on to its next step after SIGTERM"); + await new Promise((r) => setTimeout(r, 1500)); + assert.ok(!existsSync(at("leaked")), "the step's own children outlived the SIGTERM"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +await test("a step resolves to its stdout and rejects on a non-zero exit", async () => { + assert.equal(await run("printf 'a\\nb'"), "a\nb"); + await assert.rejects(run("echo boom >&2; exit 3"), /exit 3.*\n.*boom/s); +}); + +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..235c53b0e 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] @@ -104,9 +114,15 @@ public void Context_older_than_the_install_still_nudges() public void Prerelease_context_still_nudges() => Assert.NotNull(AgentContextScaffold.AgentContextStaleness(ManifestWith("0.24.5-rc.1"), "0.24.4")); + // Build metadata does not change the ordering either: the .NET SDK's `+` on the install + // made every context ahead of it unorderable, so the exemption never fired for a real build. [Fact] - public void Build_metadata_still_nudges() - => Assert.NotNull(AgentContextScaffold.AgentContextStaleness(ManifestWith("0.24.5+abc"), "0.24.4")); + public void Build_metadata_does_not_change_the_ordering() + { + Assert.Null(AgentContextScaffold.AgentContextStaleness(ManifestWith("1.1.2"), "1.1.0+0affc6adf1234")); + Assert.Null(AgentContextScaffold.AgentContextStaleness(ManifestWith("0.24.5+abc"), "0.24.4")); + Assert.NotNull(AgentContextScaffold.AgentContextStaleness(ManifestWith("1.1.0"), "1.1.2+0affc6adf1234")); + } [Fact] public void Unresolved_install_never_asserts_in_sync() diff --git a/server/csharp/MetaObjects.Cli.Tests/ColumnNamingFlagTests.cs b/server/csharp/MetaObjects.Cli.Tests/ColumnNamingFlagTests.cs index bcf4e96b6..cf5793a86 100644 --- a/server/csharp/MetaObjects.Cli.Tests/ColumnNamingFlagTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/ColumnNamingFlagTests.cs @@ -3,21 +3,21 @@ namespace MetaObjects.Cli.Tests; /// -/// dotnet meta gen --column-naming selects how a field with no explicit -/// @column becomes a physical column name. +/// ADR-0058 — column and table naming is one project setting now: naming in +/// .metaobjects/config.json, read by every port. This port's old +/// --column-naming flag on dotnet meta gen / dotnet meta verify --codegen +/// is REMOVED: it refuses with the migration message rather than silently doing nothing or +/// guessing (the old value may have been right for an adopter's real database). /// -/// The strategy already existed — GenConfig.ColumnNamingStrategy, honoured by -/// every C# generator — but the CLI never set it, so a dotnet meta gen adopter was -/// pinned to Literal with no way out. That is a real break rather than a -/// preference: schema migrations are Node-owned (ADR-0015) and meta migrate -/// defaults to snake_case, so any multi-word field produced an EF entity mapping -/// [Column("createdAt")] against a database column created_at. +/// The DEFAULT also flipped: this port used to default columns to literal (the EF +/// Core property=column convention); it now defaults to snake_case, matching what +/// meta migrate has always created — the defect ADR-0058 exists to fix (gen's and +/// migrate's defaults used to disagree). /// /// Driven through the entry point a project's codegen/Program.cs calls, /// CodegenCli.Run(args, list) (), not by calling -/// GenCommand.Run: the defect was precisely that the flag never reached the -/// config, and only the real argument parsing can prove the wiring (same reason as -/// ). +/// GenCommand.Run: only the real argument parsing proves the flag is actually refused +/// (same reason as ). /// [Collection(OwnedRunnerCollection.Name)] public sealed class ColumnNamingFlagTests : IDisposable @@ -25,7 +25,9 @@ public sealed class ColumnNamingFlagTests : IDisposable private readonly string _tmp = Path.Combine(Path.GetTempPath(), "meta-cli-colnaming-" + Guid.NewGuid().ToString("N")); // `createdAt` has a case boundary; `purposeCode` carries an explicit @column that is - // deliberately NOT its snake_case, so "the strategy ran" and "@column won" stay distinguishable. + // deliberately NOT its snake_case, so "the rule ran" and "@column won" stay distinguishable. + // `OrderItem` names no table, so its table name comes from the table rule + // (snake_case "order_items", literal "OrderItems"). private const string Metadata = """ { "metadata.root": { "package": "acme", "children": [ { "object.entity": { "name": "Subscriber", "children": [ @@ -34,6 +36,12 @@ public sealed class ColumnNamingFlagTests : IDisposable { "field.string": { "name": "createdAt" } }, { "field.string": { "name": "purposeCode", "@column": "reason" } }, { "identity.primary": { "@fields": "id" } } + ]}}, + { "object.entity": { "name": "OrderItem", "children": [ + { "source.rdb": {} }, + { "field.long": { "name": "id" } }, + { "field.long": { "name": "unitPrice" } }, + { "identity.primary": { "@fields": "id" } } ]}} ]}} """; @@ -48,10 +56,23 @@ public ColumnNamingFlagTests() public void Dispose() { try { Directory.Delete(_tmp, recursive: true); } catch { } } + /// Writes .metaobjects/config.json at the project root with the given + /// naming JSON body (e.g. "\"columns\": \"literal\""), or no naming + /// key at all when is null. + private void WriteConfig(string? naming) + { + var dir = Path.Combine(_tmp, ".metaobjects"); + Directory.CreateDirectory(dir); + var json = naming is null + ? """{ "schema_version": 1 }""" + : $$"""{ "schema_version": 1, "naming": { {{naming}} } }"""; + File.WriteAllText(Path.Combine(dir, "config.json"), json); + } + // §A6 (task 4) -- [Column] on Subscriber.g.cs now references // SubscriberNames.Column unconditionally; the entity file is BYTE-IDENTICAL - // regardless of --column-naming. The flag's effect is observable only in the - // names artifact's DECLARED CONSTANT VALUE, so a caller proving the flag reached + // regardless of the naming rule. The rule's effect is observable only in the names + // artifact's DECLARED CONSTANT VALUE, so a caller proving the rule reached // GenConfig.ColumnNamingStrategy must read SubscriberNames.g.cs, not Subscriber.g.cs // alone -- that is the whole reason this method returns both files. private (string Entity, string Names) GenerateWith(params string[] extraArgs) @@ -69,31 +90,31 @@ public ColumnNamingFlagTests() } [Fact] - public void Default_is_literal_unchanged() + public void Default_with_no_project_config_is_snake_case() { - // The entity always references the constant, unconditionally (§A6) -- that part - // does NOT vary with the flag and is asserted the same way in every test here. - // What DOES vary with the flag is the constant's declared VALUE, in - // SubscriberNames.g.cs: "createdAt" (unchanged) under the default strategy. + // No .metaobjects/config.json at all -- PhysicalNaming.Default applies. var (entity, names) = GenerateWith(); Assert.Contains("[Column(SubscriberNames.CreatedAtColumn)]", entity); - Assert.Contains("public const string CreatedAtColumn = \"createdAt\";", names); + Assert.Contains("public const string CreatedAtColumn = \"created_at\";", names); } [Fact] - public void Snake_case_maps_the_field_name_to_a_snake_case_column() + public void Config_declaring_literal_columns_generates_literal() { - var (entity, names) = GenerateWith("--column-naming", "snake_case"); + WriteConfig("\"columns\": \"literal\""); + var (entity, names) = GenerateWith(); Assert.Contains("[Column(SubscriberNames.CreatedAtColumn)]", entity); - Assert.Contains("public const string CreatedAtColumn = \"created_at\";", names); - Assert.DoesNotContain("public const string CreatedAtColumn = \"createdAt\";", names); + Assert.Contains("public const string CreatedAtColumn = \"createdAt\";", names); + Assert.DoesNotContain("created_at", names); } [Fact] - public void An_explicit_column_attr_wins_over_any_strategy() + public void An_explicit_column_attr_wins_over_any_rule() { - foreach (var (entity, names) in new[] { GenerateWith(), GenerateWith("--column-naming", "snake_case") }) + foreach (var naming in new string?[] { null, "\"columns\": \"literal\"" }) { + if (naming is not null) WriteConfig(naming); + var (entity, names) = GenerateWith(); Assert.Contains("[Column(SubscriberNames.PurposeCodeColumn)]", entity); Assert.Contains("public const string PurposeCodeColumn = \"reason\";", names); Assert.DoesNotContain("purpose_code", names); @@ -101,12 +122,136 @@ public void An_explicit_column_attr_wins_over_any_strategy() } [Fact] - public void An_unknown_strategy_is_a_usage_error_naming_the_valid_values() + public void The_removed_flag_is_refused_on_gen_naming_the_value_given() { var outDir = Path.Combine(_tmp, "generated-bad"); var (exit, stdout, stderr) = OwnedRunner.Run( - _tmp, ["entity", "names"], "gen", MetaDir, "--out", outDir, "--column-naming", "PascalCase"); + _tmp, ["entity", "names"], "gen", MetaDir, "--out", outDir, "--column-naming", "snake_case"); + var output = stdout + stderr; Assert.Equal(2, exit); - Assert.Contains("snake_case", stdout + stderr); + Assert.Contains("--column-naming was removed in 1.1.0", output); + Assert.Contains("\"naming\": { \"columns\": \"snake_case\" }", output); + Assert.Contains("docs/features/migrations/physical-naming.md", output); + Assert.Contains("delete --column-naming", output); + Assert.False(Directory.Exists(outDir)); + } + + [Fact] + public void The_removed_flag_is_refused_on_verify_codegen() + { + var outDir = Path.Combine(_tmp, "generated-ok"); + Assert.Equal(0, OwnedRunner.Run(_tmp, ["entity", "names"], "gen", MetaDir, "--out", outDir).ExitCode); + + var (exit, stdout, stderr) = OwnedRunner.Run( + _tmp, ["entity", "names"], "verify", "--codegen", MetaDir, "--out", outDir, "--column-naming", "literal"); + var output = stdout + stderr; + Assert.Equal(2, exit); + Assert.Contains("--column-naming was removed in 1.1.0", output); + Assert.Contains("\"naming\": { \"columns\": \"literal\" }", output); + } + + [Fact] + public void Verify_with_the_wrong_naming_says_the_naming_differs_instead_of_regenerate() + { + // Committed under the project's snake_case rule; then the config changes to literal. + // The two disagree, and literal reproduces the committed output exactly, so verify + // must say WHICH naming to declare rather than "regenerate" (that would commit the + // wrong column names against the live database). + var outDir = Path.Combine(_tmp, "generated-snake"); + WriteConfig("\"columns\": \"snake_case\""); + Assert.Equal(0, OwnedRunner.Run(_tmp, ["entity", "names"], "gen", MetaDir, "--out", outDir).ExitCode); + + WriteConfig("\"columns\": \"literal\""); + var (exit, stdout, stderr) = OwnedRunner.Run(_tmp, ["entity", "names"], "verify", "--codegen", MetaDir, "--out", outDir); + var output = stdout + stderr; + Assert.Equal(1, exit); + Assert.Contains( + "The committed output was generated with \"naming\": { \"columns\": \"snake_case\", \"tables\": \"snake_case\" }; " + + "this run used \"naming\": { \"columns\": \"literal\", \"tables\": \"snake_case\" } " + + "(.metaobjects/config.json; each rule defaults to snake_case). Do not regenerate", + output); + Assert.Contains( + "Declare \"naming\": { \"columns\": \"snake_case\", \"tables\": \"snake_case\" } in .metaobjects/config.json", + output); + Assert.DoesNotContain("Run 'dotnet meta gen' to regenerate", output); + } + + [Fact] + public void Verify_with_another_table_rule_says_the_naming_differs_instead_of_regenerate() + { + // Committed under literal tables ("OrderItems"); then the config drops the table rule, so + // the default snake_case ("order_items") disagrees. Regenerating would bind the entity to + // a table the database does not have, so verify names the table rule that reproduces it. + var outDir = Path.Combine(_tmp, "generated-literal-tables"); + WriteConfig("\"tables\": \"literal\""); + Assert.Equal(0, OwnedRunner.Run(_tmp, ["entity", "names"], "gen", MetaDir, "--out", outDir).ExitCode); + Assert.Contains("\"OrderItems\"", File.ReadAllText(Path.Combine(outDir, "OrderItemNames.g.cs"))); + + WriteConfig(null); + var (exit, stdout, stderr) = OwnedRunner.Run(_tmp, ["entity", "names"], "verify", "--codegen", MetaDir, "--out", outDir); + var output = stdout + stderr; + Assert.Equal(1, exit); + Assert.Contains( + "The committed output was generated with \"naming\": { \"columns\": \"snake_case\", \"tables\": \"literal\" }; " + + "this run used \"naming\": { \"columns\": \"snake_case\", \"tables\": \"snake_case\" }", + output); + Assert.Contains( + "Declare \"naming\": { \"columns\": \"snake_case\", \"tables\": \"literal\" } in .metaobjects/config.json", + output); + Assert.DoesNotContain("Run 'dotnet meta gen' to regenerate", output); + } + + [Fact] + public void Verify_finds_another_table_rule_when_only_the_entity_generator_runs() + { + // Without `names`, the entity's [Table("...")] carries the table name itself, resolved + // through the loaded tree (which caches it). The probe must still see the other rule. + var outDir = Path.Combine(_tmp, "generated-entity-only"); + WriteConfig("\"tables\": \"literal\""); + Assert.Equal(0, OwnedRunner.Run(_tmp, ["entity"], "gen", MetaDir, "--out", outDir).ExitCode); + Assert.Contains("[Table(\"OrderItems\")]", File.ReadAllText(Path.Combine(outDir, "OrderItem.g.cs"))); + + WriteConfig(null); + var (exit, stdout, stderr) = OwnedRunner.Run(_tmp, ["entity"], "verify", "--codegen", MetaDir, "--out", outDir); + var output = stdout + stderr; + Assert.Equal(1, exit); + Assert.Contains( + "Declare \"naming\": { \"columns\": \"snake_case\", \"tables\": \"literal\" } in .metaobjects/config.json", + output); + Assert.DoesNotContain("Run 'dotnet meta gen' to regenerate", output); + } + + [Fact] + public void Verify_finds_a_column_and_table_rule_that_differ_together() + { + // Committed under literal columns AND literal tables; the config now declares neither. + // No single rule change reproduces the committed tree; the two together do. + var outDir = Path.Combine(_tmp, "generated-literal-both"); + WriteConfig("\"columns\": \"literal\", \"tables\": \"literal\""); + Assert.Equal(0, OwnedRunner.Run(_tmp, ["entity", "names"], "gen", MetaDir, "--out", outDir).ExitCode); + + WriteConfig(null); + var (exit, stdout, stderr) = OwnedRunner.Run(_tmp, ["entity", "names"], "verify", "--codegen", MetaDir, "--out", outDir); + var output = stdout + stderr; + Assert.Equal(1, exit); + Assert.Contains( + "Declare \"naming\": { \"columns\": \"literal\", \"tables\": \"literal\" } in .metaobjects/config.json", + 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"); + WriteConfig("\"columns\": \"snake_case\""); + Assert.Equal(0, OwnedRunner.Run(_tmp, ["entity", "names"], "gen", MetaDir, "--out", outDir).ExitCode); + File.AppendAllText(Path.Combine(outDir, "SubscriberNames.g.cs"), "// edited\n"); + + var (exit, stdout, stderr) = OwnedRunner.Run(_tmp, ["entity", "names"], "verify", "--codegen", MetaDir, "--out", outDir); + var output = stdout + stderr; + Assert.Equal(1, exit); + Assert.Contains("Run 'dotnet meta gen' to regenerate", output); + Assert.DoesNotContain("The committed output was generated with", output); } } diff --git a/server/csharp/MetaObjects.Cli.Tests/EjectCommandTests.cs b/server/csharp/MetaObjects.Cli.Tests/EjectCommandTests.cs index 228711e9d..a9c686e61 100644 --- a/server/csharp/MetaObjects.Cli.Tests/EjectCommandTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/EjectCommandTests.cs @@ -273,15 +273,55 @@ 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() { EjectCommand.Run(["entity"], _root, force: false); var lines = GenCommand.ListLines(_root); - Assert.Contains(lines, l => l.Contains(" entity —") && l.Contains("[owned — identical]")); + Assert.Contains(lines, l => l.Contains(" entity —") && l.Contains("[owned — identical]") + && l.Contains("\n in codegen/Generators.cs: new Codegen.Generators.EntityGenerator()")); // Everything NOT ejected carries no ownership mark at all. - Assert.Contains(lines, l => l.Contains(" routes —") && !l.Contains("[owned")); + Assert.Contains(lines, l => l.Contains(" routes —") && !l.Contains("[owned") + && l.Contains("in codegen/Generators.cs: new MetaObjects.Codegen.Generators.RoutesGenerator()")); + } + + // An owned generator printed the packaged class: copying that line into codegen/Generators.cs + // wired the package back in and the owned edits silently stopped running. + [Fact] + public void GenListLines_print_the_owned_copy_for_an_ejected_generator_with_a_constructor_argument() + { + EjectCommand.Run(["render-helper"], _root, force: false); + + var renderHelper = Assert.Single(GenCommand.ListLines(_root), l => l.Contains(" render-helper —")); + Assert.Contains("[owned — identical]", renderHelper); + Assert.Contains("in codegen/Generators.cs: new Codegen.Generators.RenderHelperGenerator(\"prompts\")", renderHelper); + } + + // Eject listed every copy as `new Codegen.Generators.()`, so a project that ejected + // render-helper got a codegen/Generators.cs that did not compile (CS7036: no templateRoot). + [Fact] + public void Ejecting_render_helper_lists_it_with_its_template_root_argument() + { + EjectCommand.Run(["render-helper"], _root, force: false); + Assert.Contains(" new Codegen.Generators.RenderHelperGenerator(\"prompts\"),\n", + File.ReadAllText(Path.Combine(CodegenDir, "Generators.cs"))); + + var again = EjectCommand.Run(["render-helper"], _root, force: true); + Assert.Contains(" new Codegen.Generators.RenderHelperGenerator(\"prompts\"),", again.Out); } [Fact] 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/FmtCommandTests.cs b/server/csharp/MetaObjects.Cli.Tests/FmtCommandTests.cs index 5a246cbb5..e4c52c402 100644 --- a/server/csharp/MetaObjects.Cli.Tests/FmtCommandTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/FmtCommandTests.cs @@ -1,6 +1,7 @@ using MetaObjects.Cli; using MetaObjects.Config; using MetaObjects.Loader; +using MetaObjects.Meta; using Xunit; namespace MetaObjects.Cli.Tests; @@ -216,7 +217,7 @@ private void WriteFixture() public void Run_reformats_messy_leaves_canonical_skips_overlay_and_yaml() { WriteFixture(); - var resolved = new ResolvedMetadata(MetaDir, null, Array.Empty()); + var resolved = new ResolvedMetadata(MetaDir, null, Array.Empty(), PhysicalNaming.Default); var result = FmtCommand.Run(resolved, check: false); Assert.Null(result.Fatal); @@ -235,7 +236,7 @@ public void Run_reformats_messy_leaves_canonical_skips_overlay_and_yaml() public void Run_check_mode_changes_nothing_on_disk() { WriteFixture(); - var resolved = new ResolvedMetadata(MetaDir, null, Array.Empty()); + var resolved = new ResolvedMetadata(MetaDir, null, Array.Empty(), PhysicalNaming.Default); var before = File.ReadAllText(Path.Combine(MetaDir, "meta.base.json")); var result = FmtCommand.Run(resolved, check: true); @@ -248,7 +249,7 @@ public void Run_check_mode_changes_nothing_on_disk() public void Run_refuses_when_the_project_does_not_load_cleanly() { File.WriteAllText(Path.Combine(MetaDir, "bad.json"), "{ this is not valid json"); - var resolved = new ResolvedMetadata(MetaDir, null, Array.Empty()); + var resolved = new ResolvedMetadata(MetaDir, null, Array.Empty(), PhysicalNaming.Default); var result = FmtCommand.Run(resolved, check: false); Assert.NotNull(result.Fatal); diff --git a/server/csharp/MetaObjects.Cli.Tests/GenCommandTests.cs b/server/csharp/MetaObjects.Cli.Tests/GenCommandTests.cs index 34e7e484f..faa67858e 100644 --- a/server/csharp/MetaObjects.Cli.Tests/GenCommandTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/GenCommandTests.cs @@ -78,8 +78,7 @@ public void Gen_baseline_adopt_records_without_writing() var outcome = GenCommand.Run( MetaObjects.Loader.MetaDataLoader.FromDirectory(MetaDir), OutDir, "Acme.Generated", emitAbstractShapes: false, generatorNames: GenSuite.Names, templateRoot: null, - templateSpecPath: null, projectRoot: _tmp, - columnNaming: MetaObjects.Codegen.ColumnNamingStrategy.Literal, baseline: "adopt"); + templateSpecPath: null, projectRoot: _tmp, baseline: "adopt"); Assert.True(outcome.Ok); Assert.Equal(onDisk, File.ReadAllText(stale)); // not one byte written diff --git a/server/csharp/MetaObjects.Cli.Tests/OneGeneratorListTests.cs b/server/csharp/MetaObjects.Cli.Tests/OneGeneratorListTests.cs index badb093dc..936aac17d 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,7 @@ 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; } =\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 +320,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 +358,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 +374,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.Tests/VerifySubverbTests.cs b/server/csharp/MetaObjects.Cli.Tests/VerifySubverbTests.cs index f1c975d88..de1d182df 100644 --- a/server/csharp/MetaObjects.Cli.Tests/VerifySubverbTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/VerifySubverbTests.cs @@ -1,5 +1,6 @@ using MetaObjects.Cli; using MetaObjects.Codegen; +using MetaObjects.Meta; using Xunit; namespace MetaObjects.Cli.Tests; @@ -295,14 +296,14 @@ public void Combining_templates_and_codegen_aggregates_max_exit() Assert.Equal(1, r.ExitCode); // max(0, 1) } - // -------------------- --column-naming (the codegen regen must be told) ---- - // `gen --column-naming snake_case` emits `Names.g.cs` physical-column - // constants under that strategy (see ColumnNamingFlagTests). A `verify --codegen` - // blind to the flag regenerates with the Literal default and convicts every one - // of them as drift on an otherwise-clean project — the "gate convicts a correct - // project" pattern this project's own CHANGELOG names as a defect class. - // `createdAt` carries a case boundary so the two strategies produce different - // committed content (EntityMetadata's `email` field above has none). + // -------------------- naming.columns (ADR-0058; the codegen regen must agree) ---- + // `gen` reads the project's "naming" (`.metaobjects/config.json`) for how a field with + // no @column becomes a physical column name (see ColumnNamingFlagTests). A `verify + // --codegen` built with a DIFFERENT PhysicalNaming than gen used regenerates under the + // wrong rule and convicts every one of them as drift on an otherwise-clean project — the + // "gate convicts a correct project" pattern this project's own CHANGELOG names as a + // defect class. `createdAt` carries a case boundary so literal and snake_case produce + // different committed content (EntityMetadata's `email` field above has none). private const string ColumnNamingMetadata = """ { "metadata.root": { "package": "acme", "children": [ { "object.entity": { "name": "Subscriber", "children": [ @@ -314,6 +315,16 @@ public void Combining_templates_and_codegen_aggregates_max_exit() ]}} """; + /// Writes .metaobjects/config.json at the project root (_tmp, + /// the parent of ) declaring naming.columns. + private void WriteNamingConfig(string columns) + { + var dir = Path.Combine(_tmp, ".metaobjects"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "config.json"), + $$"""{ "schema_version": 1, "naming": { "columns": "{{columns}}" } }"""); + } + private VerifyCommand.Options ColumnNamingOpts(ColumnNamingStrategy verifyStrategy) => new() { MetadataDir = MetaDir, @@ -324,16 +335,17 @@ public void Combining_templates_and_codegen_aggregates_max_exit() Namespace = "Acme.Generated", NamespaceExplicit = true, Codegen = true, - ColumnNaming = verifyStrategy, + Naming = new PhysicalNaming(verifyStrategy, TableNamingRule.SnakeCase), }; [Fact] public void Codegen_with_matching_column_naming_is_clean() { File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), ColumnNamingMetadata); - GenCommand.Run(MetaDir, OutDir, "Acme.Generated", false, GenSuite.Names, null, null, ColumnNamingStrategy.SnakeCase); + WriteNamingConfig("literal"); + GenCommand.Run(MetaDir, OutDir, "Acme.Generated", false, GenSuite.Names, null, null); - var r = VerifyCommand.RunSubverbs(ColumnNamingOpts(ColumnNamingStrategy.SnakeCase)); + var r = VerifyCommand.RunSubverbs(ColumnNamingOpts(ColumnNamingStrategy.Literal)); Assert.Equal(0, r.ExitCode); Assert.True(r.Codegen!.Clean, string.Join("; ", r.Codegen!.Lines)); } @@ -342,13 +354,14 @@ public void Codegen_with_matching_column_naming_is_clean() public void Codegen_with_mismatched_column_naming_reports_drift() { File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), ColumnNamingMetadata); - GenCommand.Run(MetaDir, OutDir, "Acme.Generated", false, GenSuite.Names, null, null, ColumnNamingStrategy.SnakeCase); + WriteNamingConfig("literal"); + GenCommand.Run(MetaDir, OutDir, "Acme.Generated", false, GenSuite.Names, null, null); - // The discriminating half: a verify blind to --column-naming (accepting it but - // dropping it, or never reading Options.ColumnNaming) would ALSO pass the - // clean-case test above under its own hardcoded default — only a genuinely - // MISMATCHED strategy proves the flag is actually threaded into the regen. - var r = VerifyCommand.RunSubverbs(ColumnNamingOpts(ColumnNamingStrategy.Literal)); + // The discriminating half: a verify blind to the project's naming.columns (accepting + // it but dropping it, or never reading Options.Naming) would ALSO pass the clean-case + // test above under its own hardcoded default — only a genuinely MISMATCHED rule proves + // it is actually threaded into the regen. + var r = VerifyCommand.RunSubverbs(ColumnNamingOpts(ColumnNamingStrategy.SnakeCase)); Assert.NotEqual(0, r.ExitCode); Assert.Contains(r.Codegen!.DriftedFiles, f => f.EndsWith("SubscriberNames.g.cs")); } diff --git a/server/csharp/MetaObjects.Cli/EjectCommand.cs b/server/csharp/MetaObjects.Cli/EjectCommand.cs index 023b65c25..7909ed3f4 100644 --- a/server/csharp/MetaObjects.Cli/EjectCommand.cs +++ b/server/csharp/MetaObjects.Cli/EjectCommand.cs @@ -30,6 +30,7 @@ using System.Text.RegularExpressions; using System.Xml; using System.Xml.Linq; +using MetaObjects.AgentContext; using MetaObjects.Codegen; namespace MetaObjects.Cli; @@ -63,10 +64,7 @@ public static string ToolVersion() public static string PackageVersion(string? informational, Version? assembly) { if (!string.IsNullOrEmpty(informational)) - { - var plus = informational.IndexOf('+'); - return plus < 0 ? informational : informational[..plus]; - } + return AgentContextScaffold.WithoutBuildMetadata(informational)!; return assembly?.ToString(3) ?? "1.0.0"; } @@ -154,13 +152,6 @@ internal static IReadOnlyList OlderPinWarning(string csprojPath, string : ["", warning + "."]; } - /// The C# class name a stable registry name's ejected file declares — - /// derived from its SourceFileName (EntityGenerator.cs → EntityGenerator), - /// never re-derived from the stable name (which would drift from the real class the - /// moment the two spellings diverge, e.g. "db-context" → DbContextGenerator). - private static string ClassNameFor(GeneratorRegistryEntry entry) => - Path.GetFileNameWithoutExtension(entry.SourceFileName!); - /// /// Eject into /codegen/. Every name /// is validated before anything is written. Returns exit 2 (usage) for no names or @@ -223,7 +214,7 @@ public static Outcome Run(IReadOnlyList names, string root, bool force) // missing, the list with the generators just ejected. An existing file is the project's own. var csprojPath = Path.Combine(root, "codegen", "Codegen.csproj"); var csprojExisted = File.Exists(csprojPath); - var entries = names.Select(n => $"new Codegen.Generators.{ClassNameFor(known[n])}()").ToList(); + var entries = names.Select(n => EjectableGenerators.OwnedListEntry(known[n])).ToList(); var scaffolded = CodegenScaffold.WriteMissing(root, entries); if (scaffolded.Count > 0) { diff --git a/server/csharp/MetaObjects.Cli/GenCommand.cs b/server/csharp/MetaObjects.Cli/GenCommand.cs index 023172b14..7e9f6faa5 100644 --- a/server/csharp/MetaObjects.Cli/GenCommand.cs +++ b/server/csharp/MetaObjects.Cli/GenCommand.cs @@ -6,6 +6,7 @@ using System.IO; using System.Text.Json; using MetaObjects.Codegen; +using MetaObjects.Config; using MetaObjects.Loader; namespace MetaObjects.Cli; @@ -62,10 +63,9 @@ public sealed record Outcome(IReadOnlyList LoadErrors, CodegenRunner.Run public static Outcome Run( string metadataDir, string outDir, string ns, bool emitAbstractShapes, IReadOnlyList? generatorNames, string? templateRoot, string? templateSpecPath = null, - ColumnNamingStrategy columnNaming = ColumnNamingStrategy.Literal, string baseline = "default") - => Run(MetaDataLoader.FromDirectory(metadataDir), outDir, ns, emitAbstractShapes, - generatorNames, templateRoot, templateSpecPath, ProjectRootFor(metadataDir), columnNaming, + => Run(MetadataLocation.Resolve(metadataDir, Directory.GetCurrentDirectory()).Load(), outDir, ns, + emitAbstractShapes, generatorNames, templateRoot, templateSpecPath, ProjectRootFor(metadataDir), baseline); /// @@ -126,7 +126,6 @@ public static Outcome Run( LoadResult load, string outDir, string ns, bool emitAbstractShapes, IReadOnlyList? generatorNames, string? templateRoot, string? templateSpecPath = null, string? projectRoot = null, - ColumnNamingStrategy columnNaming = ColumnNamingStrategy.Literal, string baseline = "default") { var loadErrors = load.Errors.Select(e => e.Code.ToString()).ToList(); @@ -168,7 +167,7 @@ public static Outcome Run( // `codegen/Program.cs` calls too, with its owned generators composed into the same // selection (CodegenCli.ComposeGenerators) — one implementation, not two. var apiOutcome = MetaObjects.Codegen.CodegenCli.RunGen( - load, outDir, ns, emitAbstractShapes, generators, projectRoot, columnNaming, baseline); + load, outDir, ns, emitAbstractShapes, generators, projectRoot, baseline); return new Outcome(apiOutcome.LoadErrors, apiOutcome.Result); } @@ -185,7 +184,9 @@ 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 (, or the owned copy's + /// once ejected) 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 @@ -198,8 +199,11 @@ public static IReadOnlyList ListLines(string cwd) { var owned = OwnedCopy.Status(cwd, e); var ownedMark = owned is not null ? $" [owned — {owned}]" : ""; + var listEntry = owned is not null ? EjectableGenerators.OwnedListEntry(e) : GeneratorRegistry.ListEntry(e); 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: {listEntry}" + 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..f2dcbb7f7 100644 --- a/server/csharp/MetaObjects.Cli/InitCommand.cs +++ b/server/csharp/MetaObjects.Cli/InitCommand.cs @@ -8,6 +8,8 @@ // never edit it again. `dotnet meta eject` writes the same three files when they are missing, // with the generators it ejected already in the list. +using MetaObjects.Codegen; + namespace MetaObjects.Cli; /// The three files of the codegen project, as init and eject write them. @@ -54,7 +56,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 {CodegenCli.DocsMigrationGuide}.", ]; /// @@ -64,7 +66,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,11 +74,14 @@ 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" + + "// Column and table naming: the \"naming\" setting in .metaobjects/config.json, read by\n" + + "// every port (ADR-0058) -- not here.\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" + diff --git a/server/csharp/MetaObjects.Cli/Program.cs b/server/csharp/MetaObjects.Cli/Program.cs index d270d1154..c7a333b12 100644 --- a/server/csharp/MetaObjects.Cli/Program.cs +++ b/server/csharp/MetaObjects.Cli/Program.cs @@ -284,22 +284,6 @@ static int RunEject(string[] rest) return result.ExitCode; } -/// -/// Parse a --column-naming value into its . -/// The spellings are the cross-port ones (TS metaobjects.config.ts -/// columnNamingStrategy, Kotlin's columnNaming generator arg), not -/// .NET enum names, so one vocabulary reads the same in every port's config. -/// -static bool TryParseColumnNaming(string raw, out ColumnNamingStrategy strategy) -{ - switch (raw) - { - case "literal": strategy = ColumnNamingStrategy.Literal; return true; - case "snake_case": strategy = ColumnNamingStrategy.SnakeCase; return true; - case "kebab-case": strategy = ColumnNamingStrategy.KebabCase; return true; - default: strategy = ColumnNamingStrategy.Literal; return false; - } -} // The metadata-location ladder's rungs 3-4 (source-resolution design doc §3): // rung 1 is the explicit positional argument the caller already tried; rung 2 @@ -416,11 +400,9 @@ static int RunVerify(string[] rest) else if (a == "--namespace" && i + 1 < rest.Length) { ns = rest[++i]; nsExplicit = true; } else if (a == "--generators" && i + 1 < rest.Length) generatorsCsv = rest[++i]; else if (a == "--template-root" && i + 1 < rest.Length) templateRoot = rest[++i]; - // The --codegen gate's own regen must match the strategy `gen` produced --out - // with (same choices/default as `gen`'s --column-naming — see RunGen above), - // or the `names` generator's physical-column constants regenerate under the - // wrong strategy and every one reports spurious drift on an otherwise-clean - // project. + // ADR-0058 — the flag is removed: column and table naming is one project setting + // now, "naming" in .metaobjects/config.json, read by every port. Captured here (not + // dropped as an unknown flag) so the refusal below can say exactly what the user gave. else if (a == "--column-naming" && i + 1 < rest.Length) columnNamingRaw = rest[++i]; else if (a.StartsWith('-')) return RefuseFlag("verify", a, VerifyValueFlags, VerifyBoolFlags); else if (metadataDir is null) metadataDir = a; @@ -430,14 +412,9 @@ static int RunVerify(string[] rest) else templatesRoot ??= a; } - // Same parsing + default + error handling as RunGen's --column-naming above — an - // unknown value is a usage error, never a silent fallback to the default. - var columnNaming = ColumnNamingStrategy.Literal; - if (columnNamingRaw is not null && !TryParseColumnNaming(columnNamingRaw, out columnNaming)) + if (columnNamingRaw is not null) { - Console.Error.WriteLine( - $"error: unknown --column-naming \"{columnNamingRaw}\"; expected one of: " + - "literal, snake_case, kebab-case"); + Console.Error.WriteLine(CodegenCli.ColumnNamingFlagRemoved(columnNamingRaw)); return 2; } @@ -491,7 +468,6 @@ static int RunVerify(string[] rest) List codegenArgs = ["verify", "--codegen", "--out", outDir]; if (metadataDir is not null) codegenArgs.Insert(2, metadataDir); if (nsExplicit) codegenArgs.AddRange(["--namespace", ns]); - if (columnNamingRaw is not null) codegenArgs.AddRange(["--column-naming", columnNamingRaw]); if (generatorsCsv is not null) codegenArgs.AddRange(["--generators", generatorsCsv]); if (templateRoot is not null) codegenArgs.AddRange(["--template-root", templateRoot]); if (lax) codegenArgs.Add("--lax"); @@ -509,6 +485,9 @@ static int RunVerify(string[] rest) // FR-043 — read regardless of ladder vs. explicit (see // ResolveMetadataDirOrExit). Libraries = resolvedMeta.Libraries, + // ADR-0058 — same source as Libraries; the --codegen gate's regen reads it from + // the loaded root rather than a flag (see VerifyCommand.RunCodegenDrift). + Naming = resolvedMeta.Naming, TemplatesRoot = templatesRoot, OutDir = outDir, Namespace = ns, @@ -521,7 +500,6 @@ static int RunVerify(string[] rest) Db = db, // #96 / ADR-0023: verify is strict-by-default; --lax restores the legacy load. Strict = !lax, - ColumnNaming = columnNaming, }; var result = VerifyCommand.RunSubverbs(opts); @@ -609,12 +587,13 @@ partial class Program /// Each command's usage, printed by dotnet meta <command> --help. static readonly Dictionary CommandUsage = new() { - ["gen"] = "usage: dotnet meta gen [] --out [--namespace ] [--generators ] [--template-root ] [--emit-abstract-shapes] [--column-naming literal|snake_case|kebab-case] [--baseline default|adopt]\n" + + ["gen"] = "usage: dotnet meta gen [] --out [--namespace ] [--generators ] [--template-root ] [--emit-abstract-shapes] [--baseline default|adopt]\n" + " dotnet meta gen --list\n" + " runs the generator list in codegen/Generators.cs (`dotnet meta init` writes it);\n" + - " --generators narrows that list to the entries named, and never adds one", + " --generators narrows that list to the entries named, and never adds one.\n" + + " column/table naming: \"naming\" in .metaobjects/config.json (ADR-0058), not a flag here", ["init"] = "usage: dotnet meta init [--root ]", - ["verify"] = "usage: dotnet meta verify [--templates [--prompts ]] [--codegen --out [--namespace ] [--generators ] [--template-root ] [--column-naming literal|snake_case|kebab-case]] [--db] [--lax] [--no-field-lint] [--require-implementers]", + ["verify"] = "usage: dotnet meta verify [--templates [--prompts ]] [--codegen --out [--namespace ] [--generators ] [--template-root ]] [--db] [--lax] [--no-field-lint] [--require-implementers]", ["fmt"] = "usage: dotnet meta fmt [] [--check]", ["eject"] = "usage: dotnet meta eject ... [--force] [--root ]", }; diff --git a/server/csharp/MetaObjects.Cli/VerifyCommand.cs b/server/csharp/MetaObjects.Cli/VerifyCommand.cs index 1c28d3d21..478cbc879 100644 --- a/server/csharp/MetaObjects.Cli/VerifyCommand.cs +++ b/server/csharp/MetaObjects.Cli/VerifyCommand.cs @@ -101,6 +101,16 @@ public sealed record Options /// (Program.cs's ResolveMetadataDirOrExit). Empty when none declared. /// public IReadOnlyList Libraries { get; init; } = Array.Empty(); + /// + /// ADR-0058 — the project's physical-naming rules, read from the SAME + /// .metaobjects/config.json as (Program.cs's + /// ResolveMetadataDirOrExit). Set on the loaded root by + /// so the --codegen gate's regen agrees with whatever rule gen used, + /// without either command being passed a strategy value. Defaults to + /// for a caller that builds + /// directly and names no project. + /// + public MetaObjects.Meta.PhysicalNaming Naming { get; init; } = MetaObjects.Meta.PhysicalNaming.Default; /// Templates root for the --templates gate (--templates <root>). public string? TemplatesRoot { get; init; } /// The committed output dir for the --codegen gate (--out <dir>). @@ -132,18 +142,6 @@ public sealed record Options /// public bool Strict { get; init; } = true; - /// - /// The --column-naming strategy the committed --out was produced - /// with (default , matching gen's - /// default). Only consumed by the --codegen gate's own regen — see - /// . A regen defaulting to Literal while the - /// committed output was produced with, say, gen --column-naming snake_case - /// convicts every <Entity>Names.g.cs physical-column constant as - /// drift on an otherwise-clean project — the gate must be told which strategy to - /// regenerate under, exactly like --namespace above. - /// - public ColumnNamingStrategy ColumnNaming { get; init; } = ColumnNamingStrategy.Literal; - /// True when --codegen was requested but ran in an ejected /// project's owned runner instead (). It still counts /// as an explicit subverb: without it, verify --codegen in an ejected project @@ -249,9 +247,15 @@ public static SubverbResult RunSubverbs(Options opts) /// excluded) when present, else FromDirectory(opts.MetadataDir) (an /// explicit CLI argument — the legacy, unfiltered directory load). /// - private static LoadResult LoadMetadata(Options opts) => opts.MetadataFiles is { } files - ? MetaDataLoader.FromUris(files.Select(f => new Uri(f)).ToList(), opts.Libraries, opts.Strict) - : MetaDataLoader.FromDirectory(opts.MetadataDir, opts.Libraries, strict: opts.Strict); + private static LoadResult LoadMetadata(Options opts) + { + var result = opts.MetadataFiles is { } files + ? MetaDataLoader.FromUris(files.Select(f => new Uri(f)).ToList(), opts.Libraries, opts.Strict) + : MetaDataLoader.FromDirectory(opts.MetadataDir, opts.Libraries, strict: opts.Strict); + // ADR-0058 — every resolver reads naming.columns/naming.tables from the root. + result.Root.PhysicalNaming = opts.Naming; + return result; + } // ------------------------------------------------------------------------ // The requirement gate (ADR-0057) — runs on EVERY verify, with no subverb. @@ -434,10 +438,10 @@ private static Codegen.CodegenDrift.Result RunCodegenDrift(Options opts, out boo { OutDir = opts.OutDir, Namespace = ns, IncludeNames = GeneratorRegistry.IncludesNames(names), - // Must match the strategy `gen` produced --out with, or the `names` - // generator's physical-column constants regenerate under the wrong - // strategy and every one reports spurious drift (see Options.ColumnNaming). - ColumnNamingStrategy = opts.ColumnNaming, + // ADR-0058 — one project setting, read from the loaded root: `gen` produced + // --out reading the SAME root's naming.columns, so this regen agrees with it + // by construction rather than needing a flag told to match. + ColumnNamingStrategy = load.Root.PhysicalNaming.Columns, }; return Codegen.CodegenDrift.Compute(config, load.Root, generators); } diff --git a/server/csharp/MetaObjects.Codegen.Tests/ApiDocsCrossPortConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ApiDocsCrossPortConformanceTests.cs index f9b195ddc..884ae66c3 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ApiDocsCrossPortConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ApiDocsCrossPortConformanceTests.cs @@ -13,7 +13,7 @@ namespace MetaObjects.Codegen.Tests; /// surfaces against this manifest; this is the analogous C# assertion. /// /// It runs the api-docs entry the way dotnet meta gen runs a project's list -/// (ADR-0034 Amendment 9): through , +/// (ADR-0034 Amendment 9): through , /// with the entry's OutDir as the docs root. For every manifest unit it then asserts that /// <OutDir>/<apiCsharpPath> exists and carries the contract model back-link /// **Model / metadata:** [<node>](<apiCsharpToModel>). The manifest is the single diff --git a/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs index 512281d5c..25b0f7214 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs @@ -73,6 +73,9 @@ private static MetaRoot LoadCorpus() result.Errors.Count == 0, "The shared corpus must load cleanly before anything can be generated from it:\n" + string.Join("\n", result.Errors.Select(e => $" {e.Code}: {e.Message}"))); + // ADR-0058 — the corpus's committed naming rule (read from its .metaobjects/config.json + // via CorpusPaths.Naming), not a hardcoded default. + result.Root.PhysicalNaming = CorpusPaths.Naming; return result.Root; } @@ -110,7 +113,7 @@ public void Every_generated_file_compiles_with_zero_errors(string selection, boo // Never written — this run compiles in memory and emits nothing to disk. OutDir = Path.Combine(Path.GetTempPath(), "unused-compile-conformance"), Namespace = GeneratedNamespace, - ColumnNamingStrategy = ColumnNamingStrategy.Literal, + ColumnNamingStrategy = CorpusPaths.Naming.Columns, IncludeNames = includeNames, EmitAbstractShapes = false, }, diff --git a/server/csharp/MetaObjects.Codegen.Tests/CorpusPaths.cs b/server/csharp/MetaObjects.Codegen.Tests/CorpusPaths.cs index a0556cbfd..e0d707d52 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/CorpusPaths.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/CorpusPaths.cs @@ -7,6 +7,9 @@ // assembly boundaries — and likewise hardcodes no depth, so it survives a change to // the build output path. +using MetaObjects.Config; +using MetaObjects.Meta; + namespace MetaObjects.Codegen.Tests; internal static class CorpusPaths @@ -30,6 +33,24 @@ internal static string RepoRoot() $"starting from {AppContext.BaseDirectory}."); } + /// The persistence corpus root — where its .metaobjects/config.json lives, above + /// every metadata directory it holds so no directory load reads it as metadata. + internal static string CorpusDir => + Path.Combine(RepoRoot(), "fixtures", "persistence-conformance"); + + /// The canonical model's directory. + internal static string CanonicalDir => Path.Combine(CorpusDir, "canonical"); + + /// + /// ADR-0058 — the corpus's committed physical-naming rules, read with the C# config + /// reader (not hardcoded): fixtures/persistence-conformance/.metaobjects/config.json + /// pins {"naming":{"columns":"literal"}}, matching the committed schema artifacts' + /// literal column names. Every codegen gate that generates from this corpus reads this + /// instead of passing a strategy value, so a future change to that file is honoured here too. + /// + internal static PhysicalNaming Naming => + NeutralConfig.Read(CorpusDir)?.Naming ?? PhysicalNaming.Default; + /// The shared fitness corpus every codegen gate generates from. internal static string FitnessMetadata => Path.Combine(RepoRoot(), "fixtures", "persistence-conformance", "canonical", "meta.fitness.json"); diff --git a/server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorsAloneTests.cs b/server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorsAloneTests.cs index 9058349df..30ea9f671 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorsAloneTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorsAloneTests.cs @@ -50,7 +50,9 @@ public class EjectedGeneratorsAloneTests ["MetaObjects.Codegen.GenConfig"] = "the resolved generation config", ["MetaObjects.Codegen.GeneratorApplicability"] = "what the docs generator asks of the generators it documents, answered by the packaged ones", ["MetaObjects.Codegen.PerEntityGenerator"] = "the one-file-per-entity base class", - ["MetaObjects.Codegen.ColumnNamingStrategy"] = "config enum read by naming", + // ColumnNamingStrategy moved to MetaObjects.Meta (ADR-0058: one project setting read + // by every port, including core's MetaSource.PhysicalName) — no longer a type this + // package exports, so it has no entry here any more. // Naming, type mapping and binding: tables, ruled engine. ["MetaObjects.Codegen.CSharpNaming"] = "the naming and type-mapping tables", ["MetaObjects.Codegen.ValueObjectNames"] = "the one answer to what a value object's type is called and where it lives", 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.Tests/IntegrationFixtureDriftTests.cs b/server/csharp/MetaObjects.Codegen.Tests/IntegrationFixtureDriftTests.cs index 0d42f64fc..483691fa6 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/IntegrationFixtureDriftTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/IntegrationFixtureDriftTests.cs @@ -24,8 +24,10 @@ namespace MetaObjects.Codegen.Tests; public class IntegrationFixtureDriftTests { - // The exact GenConfig the committed Generated/*.g.cs imply: literal column - // naming (priceCents, createdAt — not snake_case), no abstract shapes, and the + // The exact GenConfig the committed Generated/*.g.cs imply: the corpus's committed + // literal column naming (priceCents, createdAt — not snake_case; ADR-0058 — + // CorpusPaths.Naming reads fixtures/persistence-conformance/.metaobjects/ + // config.json rather than this test hardcoding the rule), no abstract shapes, and the // names artifact referenced (the committed fixtures assume the default generator // suite, which includes `names`; GenConfig.IncludeNames itself now defaults to // false, so this fixture-modeling config sets it explicitly rather than relying @@ -37,7 +39,7 @@ public class IntegrationFixtureDriftTests { OutDir = "/unused", // the generators key off Path; OutDir is not embedded in content Namespace = IntegrationNamespace, - ColumnNamingStrategy = ColumnNamingStrategy.Literal, + ColumnNamingStrategy = CorpusPaths.Naming.Columns, IncludeNames = true, EmitAbstractShapes = false, }; diff --git a/server/csharp/MetaObjects.Codegen.Tests/NamingConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/NamingConformanceTests.cs new file mode 100644 index 000000000..304daf090 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/NamingConformanceTests.cs @@ -0,0 +1,142 @@ +// NamingConformanceTests — runs the shared cross-port ADR-0058 physical-naming corpus +// (fixtures/naming-conformance/physical-names/) against the C# port. +// +// Each `cases[]` entry is the `naming` block of a project's `.metaobjects/config.json`. +// This test writes exactly that file to a throwaway project directory and parses it with +// the C# config reader (MetaObjects.Config.NeutralConfig — the same reader every +// project-aware load goes through), loads the shared meta.naming.json model, sets the +// naming on the loaded root (ADR-0058: the loaded root carries it), and resolves every +// expected table and column name through the SAME doors codegen uses — CSharpNaming.Table +// and CSharpNaming.Column — never a reimplementation. Each `invalid` block must be refused +// when the config is read. +using System.Text.Json; +using MetaObjects.Config; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public class NamingConformanceTests +{ + private static string FixtureDir => + Path.Combine(CorpusPaths.RepoRoot(), "fixtures", "naming-conformance", "physical-names"); + + private static string CasesPath => Path.Combine(FixtureDir, "cases.json"); + private static string ModelPath => Path.Combine(FixtureDir, "meta.naming.json"); + + private sealed record Case( + string Name, JsonElement? Naming, Dictionary Tables, Dictionary Columns); + + private static JsonDocument CasesDoc() => JsonDocument.Parse(File.ReadAllText(CasesPath)); + + public static TheoryData ValidCaseNames() + { + var data = new TheoryData(); + using var doc = CasesDoc(); + foreach (var el in doc.RootElement.GetProperty("cases").EnumerateArray()) + data.Add(el.GetProperty("name").GetString()!); + return data; + } + + public static TheoryData InvalidCaseNames() + { + var data = new TheoryData(); + using var doc = CasesDoc(); + foreach (var el in doc.RootElement.GetProperty("invalid").EnumerateArray()) + data.Add(el.GetProperty("name").GetString()!); + return data; + } + + private static Case LoadValidCase(string name) + { + using var doc = CasesDoc(); + var el = doc.RootElement.GetProperty("cases").EnumerateArray() + .Single(c => c.GetProperty("name").GetString() == name); + var namingEl = el.GetProperty("naming"); + var naming = namingEl.ValueKind == JsonValueKind.Null ? (JsonElement?)null : namingEl.Clone(); + var tables = el.GetProperty("tables").EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString()!); + var columns = el.GetProperty("columns").EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString()!); + return new Case(name, naming, tables, columns); + } + + private static JsonElement LoadInvalidNaming(string name) + { + using var doc = CasesDoc(); + var el = doc.RootElement.GetProperty("invalid").EnumerateArray() + .Single(c => c.GetProperty("name").GetString() == name); + return el.GetProperty("naming").Clone(); + } + + /// + /// Writes a throwaway project's .metaobjects/config.json carrying (or no naming key at all when null), and parses it with + /// — the config reader every project-aware load uses. + /// + private static PhysicalNaming ReadNaming(JsonElement? naming) + { + var dir = Path.Combine(Path.GetTempPath(), "mo-naming-conf-" + Guid.NewGuid().ToString("N")); + var cfgDir = Path.Combine(dir, ".metaobjects"); + Directory.CreateDirectory(cfgDir); + var body = naming is { } n + ? $$"""{ "schema_version": 1, "naming": {{n.GetRawText()}} }""" + : """{ "schema_version": 1 }"""; + File.WriteAllText(Path.Combine(cfgDir, "config.json"), body); + try + { + return NeutralConfig.Read(dir)?.Naming ?? PhysicalNaming.Default; + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + private static MetaRoot LoadModel(PhysicalNaming naming) + { + var result = new MetaDataLoader().Load([new FileSource(ModelPath)]); + Assert.True(result.Errors.Count == 0, + "the shared naming corpus model must load cleanly:\n" + + string.Join("\n", result.Errors.Select(e => $" {e.Code}: {e.Message}"))); + // ADR-0058 — the loaded root carries the project's naming rules. + result.Root.PhysicalNaming = naming; + return result.Root; + } + + [Theory] + [MemberData(nameof(ValidCaseNames))] + public void Resolves_every_table_and_column_name(string name) + { + var c = LoadValidCase(name); + var naming = ReadNaming(c.Naming); + var root = LoadModel(naming); + + foreach (var (objectName, expectedTable) in c.Tables) + { + var obj = root.FindObject(objectName); + Assert.NotNull(obj); + // CSharpNaming.Table is the exact door the entity/db-context generators call. + Assert.Equal(expectedTable, CSharpNaming.Table(obj!)); + } + + foreach (var (key, expectedColumn) in c.Columns) + { + var parts = key.Split('.', 2); + var obj = root.FindObject(parts[0]); + Assert.NotNull(obj); + // ADR-0039: Fields() is the resolving accessor (an inherited field must resolve). + var field = obj!.Fields().FirstOrDefault(f => f.Name == parts[1]); + Assert.NotNull(field); + // CSharpNaming.Column is the exact door the entity/names generators call. + Assert.Equal(expectedColumn, CSharpNaming.Column(field!, naming.Columns)); + } + } + + [Theory] + [MemberData(nameof(InvalidCaseNames))] + public void Refuses_invalid_naming_blocks(string name) + { + var naming = LoadInvalidNaming(name); + Assert.ThrowsAny(() => ReadNaming(naming)); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs index e83f6f1f5..a704ee3c2 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs @@ -57,7 +57,15 @@ private static MetaRoot Load() // IncludeNames: true -- the b.ToJson(CustomerNames....Column) assertions below // need the db-context generator to reference the names artifact; // GenConfig.IncludeNames defaults to false. - Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", IncludeNames = true }, + // ColumnNamingStrategy: this file's assertions are about the FLATTENING mechanism + // (each nested scalar -> "{parentCol}_{nestedCol}", literal field names, unrelated + // to ADR-0058's naming.columns default), so it pins Literal explicitly now that the + // project default is SnakeCase rather than drifting with it. + Config = new GenConfig + { + OutDir = "/tmp", Namespace = "Acme.Generated", IncludeNames = true, + ColumnNamingStrategy = ColumnNamingStrategy.Literal, + }, }; [Fact] diff --git a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs index 8c6cd03dd..cbbebe0be 100644 --- a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs +++ b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs @@ -169,21 +169,6 @@ public sealed record ObjectNames /// The object whose names artifact this one extends. public sealed record SuperNames(string Name, string? Package); -/// -/// Column-naming strategy for fields with no @column override. C# port -/// defaults to (EF Core property=column convention); TS -/// port defaults to (PG convention). -/// -public enum ColumnNamingStrategy -{ - /// Field name verbatim. EF Core default. - Literal, - /// camelCase / PascalCase → snake_case. PG / TS default. - SnakeCase, - /// camelCase / PascalCase → kebab-case. - KebabCase, -} - /// Field-subtype -> C# type + naming helpers for the EF Core generators. public static class CSharpNaming { @@ -447,7 +432,7 @@ public static string ReverseFinderInName(MetaObject entity, string fkFieldName) /// field name run through . Shared so the schema /// DDL and the [Column] annotation agree. /// - public static string Column(MetaField field, ColumnNamingStrategy strategy = ColumnNamingStrategy.Literal) => + public static string Column(MetaField field, ColumnNamingStrategy strategy = ColumnNamingStrategy.SnakeCase) => field.DbColumn ?? ApplyStrategy(field.Name, strategy); /// Apply to a raw field name. @@ -508,7 +493,7 @@ public static bool HasPrimarySource(MetaObject obj) => /// the object subtype. /// /// - public static ObjectNames? ResolveObjectNames(MetaObject obj, ColumnNamingStrategy strategy = ColumnNamingStrategy.Literal) + public static ObjectNames? ResolveObjectNames(MetaObject obj, ColumnNamingStrategy strategy = ColumnNamingStrategy.SnakeCase) { // FR-044 — a report resolves no names artifact, so NamesGenerator emits none and // the report's generated row (ReportRows) spells its view and columns as literals @@ -726,7 +711,7 @@ private static bool DeclaresNamesContent(MetaObject obj) => /// walk and the emit cannot disagree about which ancestors exist. /// public static ObjectNames? ResolveSuperFragmentNames( - MetaObject obj, ColumnNamingStrategy strategy = ColumnNamingStrategy.Literal) + MetaObject obj, ColumnNamingStrategy strategy = ColumnNamingStrategy.SnakeCase) { if (!DeclaresNamesContent(obj)) return null; var own = obj.OwnFields(); diff --git a/server/csharp/MetaObjects.Codegen/CodegenCli.cs b/server/csharp/MetaObjects.Codegen/CodegenCli.cs index 664c04cd4..cb6e00f2f 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; @@ -78,7 +79,7 @@ public static string DefaultTemplateRoot(string? baseDir = null) => : LegacyTemplatesDir; /// The outcome of a gen run: pure data, no console I/O. - /// The prefixes + /// The prefixes /// puts on a GENERATOR's failure, as opposed to a metadata load error. public const string GeneratorFailedPrefix = "codegen failed: "; public const string RenderFailedPrefix = "template render failed: "; @@ -175,10 +176,9 @@ public sealed record GenOutcome(IReadOnlyList LoadErrors, CodegenRunner. /// public static GenOutcome RunGen( string metadataDir, string outDir, string ns, bool emitAbstractShapes, - IReadOnlyList generators, - ColumnNamingStrategy columnNaming = ColumnNamingStrategy.Literal, string baseline = "default") + IReadOnlyList generators, string baseline = "default") => RunGen(MetadataLocation.Resolve(metadataDir, Directory.GetCurrentDirectory()).Load(), outDir, ns, - emitAbstractShapes, generators, ProjectRootFor(metadataDir), columnNaming, baseline); + emitAbstractShapes, generators, ProjectRootFor(metadataDir), baseline); /// /// Build a from the given knobs and run generators, string? projectRoot = null, - ColumnNamingStrategy columnNaming = ColumnNamingStrategy.Literal, string baseline = "default", + string baseline = "default", bool? includeNames = null) { var loadErrors = load.Errors.Select(e => e.Code.ToString()).ToList(); @@ -225,7 +225,10 @@ public static GenOutcome RunGen( // to the legacy -marker rule. COMMIT .gen-state/.hashes.json. GenStateDir = Path.Combine(root, ".metaobjects", ".gen-state"), ProjectRoot = root, - ColumnNamingStrategy = columnNaming, + // ADR-0058 — one project setting, read from the loaded root rather than a CLI + // flag or a per-project generator list: codegen and the runtime cannot disagree, + // because neither is configured separately. + ColumnNamingStrategy = load.Root.PhysicalNaming.Columns, Baseline = baseline, // C1 — the presence gate: is the `names` generator (NamesGenerator.Name == // "names") actually part of THIS resolved list? Checked by NAME, not by @@ -261,10 +264,17 @@ public static GenOutcome RunGen( /// The project's generator list, relative to its root. public const string GeneratorListPath = "codegen/Generators.cs"; + /// The 1.1 docs migration guide, this port's section — the one pointer every fixed + /// line below shares. The other ports name it once (TypeScript docs-removed.ts's + /// DOCS_MIGRATION_GUIDE, Python's _MIGRATION_SECTION, Java's + /// DocsMojo.MIGRATION); moving the document is one edit here too, not a six-site grep. + public const string DocsMigrationGuide = + "https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\""; + /// 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 " + DocsMigrationGuide + "."; /// 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 +286,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#\"."; + DocsMigrationGuide + "."; /// 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 " + DocsMigrationGuide + "."; /// 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 +300,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 " + DocsMigrationGuide + "."; /// 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 +312,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 " + DocsMigrationGuide + "."; /// /// The 1.0 template-spec configuration a run would have read and no longer does, or null: @@ -401,7 +411,8 @@ private static IReadOnlyList RegistryNames(IReadOnlyList gen // and return ITS exit code. This is the far end of that hand-off. It takes the CLI's // `gen [] --out ...` / `verify --codegen [] --out ...` argument // surface, so the forwarding is transparent, and it owns the checks the CLI used to make - // in process: flag refusal, the 1.0-shape refusal, --column-naming and --baseline values. + // in process: flag refusal, the 1.0-shape refusal, the removed --column-naming flag + // (ADR-0058) and --baseline values. // Template/db drift stay with `dotnet meta verify`; they do not depend on the list, and // cross-referencing back into MetaObjects.Cli would invert the dependency direction. // ------------------------------------------------------------------------ @@ -417,10 +428,10 @@ public static int Run(string[] args, IReadOnlyList generators) { Console.Error.WriteLine( "usage: gen [] --out [--namespace ] [--generators ]\n" + - " [--emit-abstract-shapes] [--column-naming literal|snake_case|kebab-case]\n" + - " [--baseline default|adopt] [--template-root ]\n" + + " [--emit-abstract-shapes] [--baseline default|adopt] [--template-root ]\n" + " | verify --codegen [] --out [--namespace ] [--generators ]\n" + - " [--column-naming literal|snake_case|kebab-case] [--template-root ] [--lax]"); + " [--template-root ] [--lax]\n" + + " (column/table naming: \"naming\" in .metaobjects/config.json — ADR-0058)"); return 2; } return args[0] switch @@ -444,16 +455,33 @@ private static int Unknown(string cmd) return 2; } - private static bool TryParseColumnNaming(string raw, out ColumnNamingStrategy strategy) + /// The --column-naming spelling of — used only + /// to format the ADR-0058 drift message below, never to parse a flag any more. + public static string ColumnNamingFlag(ColumnNamingStrategy strategy) => strategy switch { - switch (raw) - { - case "literal": strategy = ColumnNamingStrategy.Literal; return true; - case "snake_case": strategy = ColumnNamingStrategy.SnakeCase; return true; - case "kebab-case": strategy = ColumnNamingStrategy.KebabCase; return true; - default: strategy = ColumnNamingStrategy.Literal; return false; - } - } + ColumnNamingStrategy.SnakeCase => "snake_case", + ColumnNamingStrategy.KebabCase => "kebab-case", + _ => "literal", + }; + + /// + /// ADR-0058 — the 1.1 physical-naming migration guide, named once so every refusal below + /// points at the same place. + /// + public const string PhysicalNamingMigrationGuide = + "https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md"; + + /// + /// What --column-naming <value> prints, exit 2, on dotnet meta gen or + /// dotnet meta verify --codegen (ADR-0058): column and table naming is one project + /// setting now, read from .metaobjects/config.json by every port. is the value the user gave, so the message proposes their exact setting. + /// + public static string ColumnNamingFlagRemoved(string value) => + "--column-naming was removed in 1.1.0. Column and table naming is one project setting now, " + + "read by every port: \"naming\" in .metaobjects/config.json, for example " + + $"\"naming\": {{ \"columns\": \"{value}\" }}. Move it there and delete --column-naming. See " + + PhysicalNamingMigrationGuide; /// /// The 1.0 composition of an owned list with a --generators selection, which ADDED @@ -591,15 +619,12 @@ 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)) + // ADR-0058 — column and table naming is one project setting now, read from + // .metaobjects/config.json by every port; the flag is refused rather than silently + // dropped, since the old value may have been right for that adopter's database. + if (a.ColumnNamingRaw is not null) { - Console.Error.WriteLine( - $"error: unknown --column-naming \"{a.ColumnNamingRaw}\"; expected one of: " + - "literal, snake_case, kebab-case"); + Console.Error.WriteLine(ColumnNamingFlagRemoved(a.ColumnNamingRaw)); return 2; } // Same rule. `fresh` is deliberately NOT accepted — TypeScript implements it and this @@ -658,7 +683,7 @@ private static int RunGenArgs(string[] rest, IReadOnlyList declared) // ADR-0023: gen loads leniently; name what `verify` would reject instead of staying silent. WarnUnknownAttrs(meta); - var outcome = RunGen(meta.Load(), a.OutDir, a.Namespace, a.EmitAbstractShapes, suite, projectRoot, columnNaming, a.Baseline, + var outcome = RunGen(meta.Load(), a.OutDir, a.Namespace, a.EmitAbstractShapes, suite, projectRoot, a.Baseline, includeNames: declared.Any(g => g.Name == "names")); if (!outcome.Ok) { @@ -683,6 +708,65 @@ private static int RunGenArgs(string[] rest, IReadOnlyList declared) return 0; } + /// + /// The other physical naming that reproduces the committed output exactly, or null. + /// Tries every other combination of the two ADR-0058 rules: another column rule alone, + /// then another table rule alone, then both together (). + /// Each probe regenerates from a FRESH load carrying the candidate: the table rule reaches + /// codegen through the root, and a loaded tree caches what it resolved from it + /// (), so re-pointing the already-generated root would + /// keep the table names the first regen resolved. + /// + private static PhysicalNaming? MatchingNaming( + GenConfig config, Func load, IReadOnlyList suite, bool narrowed, PhysicalNaming used) + { + foreach (var candidate in NamingCandidates(used)) + { + var root = load(); + root.PhysicalNaming = candidate; + var probe = CodegenDrift.Compute(config with { ColumnNamingStrategy = candidate.Columns }, root, suite, narrowed); + if (probe.Error is null && probe.Clean) return candidate; + } + return null; + } + + /// + /// Every naming other than , in the order verify probes them (the + /// Python port probes the same order): the used table rule first, so a different column + /// rule alone is tried before a different table rule; within each, the used column rule + /// first, then snake_case, literal, kebab-case. + /// + private static IEnumerable NamingCandidates(PhysicalNaming used) + { + ColumnNamingStrategy[] columns = + [ColumnNamingStrategy.SnakeCase, ColumnNamingStrategy.Literal, ColumnNamingStrategy.KebabCase]; + TableNamingRule[] tables = [TableNamingRule.SnakeCase, TableNamingRule.Literal]; + foreach (var t in tables.OrderBy(t => t == used.Tables ? 0 : 1)) + foreach (var c in columns.OrderBy(c => c == used.Columns ? 0 : 1)) + if (new PhysicalNaming(c, t) is var candidate && candidate != used) + yield return candidate; + } + + /// The naming.tables spelling of in + /// .metaobjects/config.json, for the ADR-0058 drift message below. + private static string TableNamingSpelling(TableNamingRule rule) => + rule == TableNamingRule.Literal ? "literal" : "snake_case"; + + /// A naming as the naming block that declares it. + private static string NamingBlock(PhysicalNaming naming) => + $"\"naming\": {{ \"columns\": \"{ColumnNamingFlag(naming.Columns)}\", " + + $"\"tables\": \"{TableNamingSpelling(naming.Tables)}\" }}"; + + /// What verify --codegen says, in place of "regenerate", when the committed output was + /// generated with and this run used — the rules + /// ADR-0058 reads from .metaobjects/config.json (each defaults to snake_case). The + /// Python port prints the same text. + private static string NamingDiffers(PhysicalNaming committed, PhysicalNaming used) => + $"The committed output was generated with {NamingBlock(committed)}; this run used " + + $"{NamingBlock(used)} (.metaobjects/config.json; each rule defaults to snake_case). Do not " + + "regenerate: that rewrites column or table names to ones the database does not have. Declare " + + $"{NamingBlock(committed)} in .metaobjects/config.json, 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 +783,10 @@ 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)) + // ADR-0058 — see RunGenArgs's identical refusal. + if (a.ColumnNamingRaw is not null) { - Console.Error.WriteLine($"error: unknown --column-naming \"{a.ColumnNamingRaw}\""); + Console.Error.WriteLine(ColumnNamingFlagRemoved(a.ColumnNamingRaw)); return 2; } if (a.OutDir is null) @@ -753,6 +837,10 @@ private static int RunVerifyArgs(string[] rest, IReadOnlyList declar // --namespace always wins; otherwise infer from the committed output so a regen // matches output produced with any namespace (avoids spurious drift). var effectiveNs = a.NamespaceExplicit ? a.Namespace : (CodegenDrift.InferNamespace(a.OutDir) ?? a.Namespace); + // ADR-0058 — the loaded root carries the project's column rule; `gen` read it the + // same way producing the committed output, so this regen agrees with it by + // construction instead of needing a flag told to match. + var columnNaming = load.Root.PhysicalNaming.Columns; var config = new GenConfig { OutDir = a.OutDir, @@ -773,6 +861,15 @@ 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 naming wrong: the naming reaches + // every column and table name, so regenerating would commit the wrong names. When + // another naming reproduces the committed tree exactly, say that instead of "regenerate". + var naming = load.Root.PhysicalNaming; + if (MatchingNaming(config, () => meta.Load(strict: !a.Lax).Root, suite, a.Selection is { Count: > 0 }, naming) is { } committed) + { + Console.Error.WriteLine(NamingDiffers(committed, naming)); + return 1; + } Console.Error.WriteLine("Run 'dotnet meta gen' to regenerate, then commit the result."); return 1; } diff --git a/server/csharp/MetaObjects.Codegen/EjectableGenerators.cs b/server/csharp/MetaObjects.Codegen/EjectableGenerators.cs index 9ab5ded26..2f5ac100e 100644 --- a/server/csharp/MetaObjects.Codegen/EjectableGenerators.cs +++ b/server/csharp/MetaObjects.Codegen/EjectableGenerators.cs @@ -23,6 +23,17 @@ public static class EjectableGenerators /// public const string ResourcePrefix = "MetaObjects.Codegen.Generators."; + /// The namespace every ejected copy is rewritten into (): + /// an owned generator is listed in codegen/Generators.cs as + /// Codegen.Generators.<Name>. + public const string OwnedNamespace = "Codegen.Generators"; + + /// The expression that lists the OWNED copy of in + /// codegen/Generators.cs: with the packaged + /// namespace swapped for , since eject keeps the class name. + public static string OwnedListEntry(GeneratorRegistryEntry entry) => + GeneratorRegistry.ListEntry(entry).Replace("MetaObjects.Codegen.Generators.", OwnedNamespace + ".", StringComparison.Ordinal); + /// /// Every ejectable registry entry ( /// is set) — Native-tier order, matching . The @@ -149,7 +160,7 @@ public static string RewriteForEject(string source) var usings = new[] { "MetaObjects", "MetaObjects.Codegen", "MetaObjects.Codegen.Generators" } .Where(ns => !source.Contains($"using {ns};", StringComparison.Ordinal)) .Select(ns => $"using {ns};\n"); - var replacement = string.Concat(usings) + "\nnamespace Codegen.Generators;"; + var replacement = string.Concat(usings) + $"\nnamespace {OwnedNamespace};"; var rewritten = source[..match.Index] + replacement + source[(match.Index + match.Length)..]; rewritten = DroppedUsings.Replace(rewritten, ""); return HelperRuntime.RewriteGeneratorForEject(rewritten); diff --git a/server/csharp/MetaObjects.Codegen/Generator.cs b/server/csharp/MetaObjects.Codegen/Generator.cs index a23c70b40..5d5658903 100644 --- a/server/csharp/MetaObjects.Codegen/Generator.cs +++ b/server/csharp/MetaObjects.Codegen/Generator.cs @@ -71,12 +71,18 @@ public sealed record GenConfig /// C# namespace for generated types. public required string Namespace { get; init; } /// - /// Strategy applied to field names with no @column override. Defaults - /// to (EF Core convention). + /// Strategy applied to field names with no @column override. ADR-0058 — one + /// project setting (naming.columns in .metaobjects/config.json), read by + /// every port; defaults to , matching what + /// meta migrate has always created. A real CLI run (CodegenCli.RunGen / + /// VerifyCommand's codegen-drift gate) always sets this from the loaded root's + /// rather than relying on this default; it is reached + /// only by a hand-built GenConfig that names no project (a programmatic embedder, + /// or a test fixture). /// Plumbed through every CSharpNaming.Column call site in the entity + /// DbContext generators. /// - public ColumnNamingStrategy ColumnNamingStrategy { get; init; } = ColumnNamingStrategy.Literal; + public ColumnNamingStrategy ColumnNamingStrategy { get; init; } = ColumnNamingStrategy.SnakeCase; /// /// C1 — the RUN-LEVEL presence gate for the <Entity>Names artifact: 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.Conformance.Tests/MetadataLocationTests.cs b/server/csharp/MetaObjects.Conformance.Tests/MetadataLocationTests.cs index e1f77790f..cb3706f92 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/MetadataLocationTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/MetadataLocationTests.cs @@ -5,6 +5,7 @@ using System.IO; using MetaObjects.Config; +using MetaObjects.Meta; using Xunit; namespace MetaObjects.Conformance.Tests; @@ -71,6 +72,33 @@ public void Without_the_library_the_same_model_does_not_load() Assert.NotEmpty(meta.Load().Errors); } + [Fact] + public void An_explicit_directory_nested_below_the_project_reads_the_root_config() + { + WriteConfig("""{ "schema_version": 1, "sources": [], "naming": { "columns": "literal", "tables": "literal" } }"""); + Directory.CreateDirectory(Path.Combine(_root, ".git")); + var nested = Path.Combine(_root, "model", "catalog"); + Directory.CreateDirectory(nested); + + var meta = MetadataLocation.Resolve(nested, cwd: Path.GetTempPath()); + + Assert.Equal(new PhysicalNaming(ColumnNamingStrategy.Literal, TableNamingRule.Literal), meta.Naming); + } + + [Fact] + public void The_config_search_stops_at_the_repository_root() + { + WriteConfig("""{ "schema_version": 1, "sources": [], "naming": { "columns": "literal", "tables": "literal" } }"""); + var repo = Path.Combine(_root, "repo"); + Directory.CreateDirectory(Path.Combine(repo, ".git")); + var nested = Path.Combine(repo, "model", "catalog"); + Directory.CreateDirectory(nested); + + var meta = MetadataLocation.Resolve(nested, cwd: Path.GetTempPath()); + + Assert.Equal(PhysicalNaming.Default, meta.Naming); + } + [Fact] public void Several_declared_sources_are_refused_in_words() { diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractCorpusPaths.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractCorpusPaths.cs index 78903ca7c..aec821f60 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractCorpusPaths.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractCorpusPaths.cs @@ -2,6 +2,9 @@ // relative to the test assembly. Mirrors the Java runner's findCorpusRoot() // walk so the fixture path resolves identically across ports. +using MetaObjects.Config; +using MetaObjects.Meta; + namespace MetaObjects.IntegrationTests.Api; internal static class ApiContractCorpusPaths @@ -15,6 +18,15 @@ internal static class ApiContractCorpusPaths public static readonly string ScenariosDir = Path.Combine(Corpus, "scenarios"); public static readonly string SeedFile = Path.Combine(Corpus, "seed.json"); + /// + /// ADR-0058 — the corpus's committed physical-naming rules, read with the C# config + /// reader (not hardcoded): fixtures/api-contract-conformance/.metaobjects/config.json + /// pins {"naming":{"columns":"literal"}}, matching every generated-server lane's + /// committed expectations. Every lane that generates from this corpus's meta.json files + /// reads this instead of passing a strategy value. + /// + public static readonly PhysicalNaming Naming = NeutralConfig.Read(Corpus)?.Naming ?? PhysicalNaming.Default; + // The corpus Author model — fed to the C# generators in the SP-F generated-server // lane (GeneratedAuthorServerFactory) to produce + host the real routes/AppDbContext. public static readonly string MetaJson = Path.Combine(Corpus, "meta.json"); diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/GeneratedAuthorServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/GeneratedAuthorServerFactory.cs index 244ca287d..7a91a2ce7 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/GeneratedAuthorServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/GeneratedAuthorServerFactory.cs @@ -28,6 +28,7 @@ // (bigserial id, the corpus column types). It is provisioning scaffolding, not the // artifact under test — the artifact is the generated routes + AppDbContext. +using MetaObjects.Meta; using System.Reflection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -156,6 +157,8 @@ private static Assembly CompileGeneratedServer() { var loadResult = new MetaDataLoader().Load( [new FileSource(ApiContractCorpusPaths.MetaJson)]); + // ADR-0058 — the corpus's committed naming rule, not a hardcoded default. + loadResult.Root.PhysicalNaming = ApiContractCorpusPaths.Naming; if (loadResult.Errors.Count != 0) throw new InvalidOperationException( "corpus metadata failed to load: " + @@ -171,7 +174,7 @@ private static Assembly CompileGeneratedServer() { OutDir = "/unused", Namespace = GeneratedNamespace, - ColumnNamingStrategy = ColumnNamingStrategy.Literal, + ColumnNamingStrategy = ApiContractCorpusPaths.Naming.Columns, EmitAbstractShapes = false, // GenConfig.IncludeNames defaults to false (§A6 task 4); without it // here the entity/DbContext output below never references the diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs index 6038ccac0..8e1dfac80 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs @@ -14,6 +14,7 @@ // hand-editing the emitted code. Mirrors TphGeneratedServerFactory; no host-level JSON // converter is needed (a JsonDocument property serializes/deserializes natively). +using MetaObjects.Meta; using System.Reflection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -89,6 +90,8 @@ public async ValueTask DisposeAsync() private static (Assembly Assembly, IReadOnlyList RouteEntities) CompileGeneratedServer() { var loadResult = new MetaDataLoader().Load([new FileSource(ApiContractCorpusPaths.JsonbMetaJson)]); + // ADR-0058 — the corpus's committed naming rule, not a hardcoded default. + loadResult.Root.PhysicalNaming = ApiContractCorpusPaths.Naming; if (loadResult.Errors.Count != 0) throw new InvalidOperationException( "jsonb corpus metadata failed to load: " + @@ -107,7 +110,7 @@ private static (Assembly Assembly, IReadOnlyList RouteEntities) CompileG { OutDir = "/unused", Namespace = GeneratedNamespace, - ColumnNamingStrategy = ColumnNamingStrategy.Literal, + ColumnNamingStrategy = ApiContractCorpusPaths.Naming.Columns, EmitAbstractShapes = false, // GenConfig.IncludeNames defaults to false (§A6 task 4); without it // here the entity/DbContext output below never references the diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/M2mGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/M2mGeneratedServerFactory.cs index e88967cab..783468b57 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/M2mGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/M2mGeneratedServerFactory.cs @@ -12,6 +12,7 @@ // Mirrors GeneratedAuthorServerFactory (the single-entity author lane) but fans out // over all six entities + schema-provisions the M:N tables via M2mFixture. +using MetaObjects.Meta; using System.Reflection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -88,6 +89,8 @@ public async ValueTask DisposeAsync() private static (Assembly Assembly, IReadOnlyList EntityNames) CompileGeneratedServer() { var loadResult = new MetaDataLoader().Load([new FileSource(ApiContractCorpusPaths.M2mMetaJson)]); + // ADR-0058 — the corpus's committed naming rule, not a hardcoded default. + loadResult.Root.PhysicalNaming = ApiContractCorpusPaths.Naming; if (loadResult.Errors.Count != 0) throw new InvalidOperationException( "m2m corpus metadata failed to load: " + @@ -106,7 +109,7 @@ private static (Assembly Assembly, IReadOnlyList EntityNames) CompileGen { OutDir = "/unused", Namespace = GeneratedNamespace, - ColumnNamingStrategy = ColumnNamingStrategy.Literal, + ColumnNamingStrategy = ApiContractCorpusPaths.Naming.Columns, EmitAbstractShapes = false, // GenConfig.IncludeNames defaults to false (§A6 task 4); without it // here the entity/DbContext output below never references the diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/MapNullColumnGeneratedServerTest.cs b/server/csharp/MetaObjects.IntegrationTests/Api/MapNullColumnGeneratedServerTest.cs index 88ba23b90..703c0569b 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/MapNullColumnGeneratedServerTest.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/MapNullColumnGeneratedServerTest.cs @@ -23,6 +23,7 @@ // dotnet test server/csharp/MetaObjects.IntegrationTests/MetaObjects.IntegrationTests.csproj \ // --filter "FullyQualifiedName~MapNullColumn" +using MetaObjects.Meta; using System.Reflection; using System.Text; using System.Text.Json.Nodes; diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/MapValueObjectWriteBehaviorGeneratedServerTest.cs b/server/csharp/MetaObjects.IntegrationTests/Api/MapValueObjectWriteBehaviorGeneratedServerTest.cs index 7f6f97467..fea4dcd3d 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/MapValueObjectWriteBehaviorGeneratedServerTest.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/MapValueObjectWriteBehaviorGeneratedServerTest.cs @@ -27,6 +27,7 @@ // dotnet test server/csharp/MetaObjects.IntegrationTests/MetaObjects.IntegrationTests.csproj \ // --filter "FullyQualifiedName~MapValueObjectWriteBehavior" +using MetaObjects.Meta; using System.Reflection; using System.Text; using System.Text.Json; diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionGeneratedServerFactory.cs index 2d91123bc..7f643b737 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionGeneratedServerFactory.cs @@ -14,6 +14,7 @@ // artifact under test and boot a server with no /api/invoice_summaries at all — every // scenario would then fail as a 404 and blame the generator for the harness. +using MetaObjects.Meta; using System.Reflection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -92,6 +93,8 @@ public async ValueTask DisposeAsync() private static (Assembly Assembly, IReadOnlyList RoutedNames) CompileGeneratedServer() { var loadResult = new MetaDataLoader().Load([new FileSource(ApiContractCorpusPaths.ProjectionMetaJson)]); + // ADR-0058 — the corpus's committed naming rule, not a hardcoded default. + loadResult.Root.PhysicalNaming = ApiContractCorpusPaths.Naming; if (loadResult.Errors.Count != 0) throw new InvalidOperationException( "projection corpus metadata failed to load: " + @@ -115,7 +118,7 @@ private static (Assembly Assembly, IReadOnlyList RoutedNames) CompileGen { OutDir = "/unused", Namespace = GeneratedNamespace, - ColumnNamingStrategy = ColumnNamingStrategy.Literal, + ColumnNamingStrategy = ApiContractCorpusPaths.Naming.Columns, EmitAbstractShapes = false, // The DbContext maps the projection with .ToView(InvoiceSummaryNames.SourcePrimaryView); // without IncludeNames that constant is never referenced and the NamesGenerator diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs index 38fe53dcf..c434b62e8 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs @@ -20,6 +20,7 @@ // (`issuedOnMonth` as YYYY-MM-DD) is what the generated row's DateOnly property gives by // default, which is what an adopter mounting these routes gets. +using MetaObjects.Meta; using System.Reflection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -123,6 +124,8 @@ public async ValueTask DisposeAsync() private static (Assembly Assembly, IReadOnlyList RoutedNames) CompileGeneratedServer() { var loadResult = new MetaDataLoader().Load([new FileSource(ApiContractCorpusPaths.ReportMetaJson)]); + // ADR-0058 — the corpus's committed naming rule, not a hardcoded default. + loadResult.Root.PhysicalNaming = ApiContractCorpusPaths.Naming; if (loadResult.Errors.Count != 0) throw new InvalidOperationException( "report corpus metadata failed to load: " + @@ -150,7 +153,7 @@ private static (Assembly Assembly, IReadOnlyList RoutedNames) CompileGen { OutDir = "/unused", Namespace = GeneratedNamespace, - ColumnNamingStrategy = ColumnNamingStrategy.Literal, + ColumnNamingStrategy = ApiContractCorpusPaths.Naming.Columns, EmitAbstractShapes = false, // The entities bind through their names artifacts. A report has none (it binds its // view and columns by literal), so this also proves no generated report file diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/TphGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/TphGeneratedServerFactory.cs index f9940dc48..736f155a8 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/TphGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/TphGeneratedServerFactory.cs @@ -14,6 +14,7 @@ // host-level JSON config an adopter sets in their ASP.NET host (the generated routes // return the entity object; the host owns serialization), NOT generator output. +using MetaObjects.Meta; using System.Reflection; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Builder; @@ -95,6 +96,8 @@ public async ValueTask DisposeAsync() private static (Assembly Assembly, IReadOnlyList RouteEntities) CompileGeneratedServer() { var loadResult = new MetaDataLoader().Load([new FileSource(ApiContractCorpusPaths.TphMetaJson)]); + // ADR-0058 — the corpus's committed naming rule, not a hardcoded default. + loadResult.Root.PhysicalNaming = ApiContractCorpusPaths.Naming; if (loadResult.Errors.Count != 0) throw new InvalidOperationException( "tph corpus metadata failed to load: " + @@ -115,7 +118,7 @@ private static (Assembly Assembly, IReadOnlyList RouteEntities) CompileG { OutDir = "/unused", Namespace = GeneratedNamespace, - ColumnNamingStrategy = ColumnNamingStrategy.Literal, + ColumnNamingStrategy = ApiContractCorpusPaths.Naming.Columns, EmitAbstractShapes = false, // GenConfig.IncludeNames defaults to false (§A6 task 4); without it // here the entity/DbContext output below never references the diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/WriteThroughGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/WriteThroughGeneratedServerFactory.cs index c8db0e7a3..9924297df 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/WriteThroughGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/WriteThroughGeneratedServerFactory.cs @@ -11,6 +11,7 @@ // // Mirrors M2mGeneratedServerFactory; provisions the tables + view via WriteThroughFixture. +using MetaObjects.Meta; using System.Reflection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -87,6 +88,8 @@ public async ValueTask DisposeAsync() private static (Assembly Assembly, IReadOnlyList EntityNames) CompileGeneratedServer() { var loadResult = new MetaDataLoader().Load([new FileSource(ApiContractCorpusPaths.WriteThroughMetaJson)]); + // ADR-0058 — the corpus's committed naming rule, not a hardcoded default. + loadResult.Root.PhysicalNaming = ApiContractCorpusPaths.Naming; if (loadResult.Errors.Count != 0) throw new InvalidOperationException( "write-through corpus metadata failed to load: " + @@ -105,7 +108,7 @@ private static (Assembly Assembly, IReadOnlyList EntityNames) CompileGen { OutDir = "/unused", Namespace = GeneratedNamespace, - ColumnNamingStrategy = ColumnNamingStrategy.Literal, + ColumnNamingStrategy = ApiContractCorpusPaths.Naming.Columns, EmitAbstractShapes = false, // GenConfig.IncludeNames defaults to false (§A6 task 4); without it // here the entity/DbContext output below never references the diff --git a/server/csharp/MetaObjects.IntegrationTests/Issue294EfModelDeleteBehaviorTests.cs b/server/csharp/MetaObjects.IntegrationTests/Issue294EfModelDeleteBehaviorTests.cs index f950789dd..754ab5bfb 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Issue294EfModelDeleteBehaviorTests.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Issue294EfModelDeleteBehaviorTests.cs @@ -14,6 +14,7 @@ // adopter detected the failure (model-only, no database: an unreachable host is enough // because IModel is built without connecting). +using MetaObjects.Meta; using System.Reflection; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; diff --git a/server/csharp/MetaObjects.IntegrationTests/Runner/CorpusPaths.cs b/server/csharp/MetaObjects.IntegrationTests/Runner/CorpusPaths.cs index 7e10b5b35..444f90b0f 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Runner/CorpusPaths.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Runner/CorpusPaths.cs @@ -1,6 +1,9 @@ // CorpusPaths — resolves the persistence-conformance corpus location relative to // the test assembly. Shared by the query-scenario runner + tests. +using MetaObjects.Config; +using MetaObjects.Meta; + namespace MetaObjects.IntegrationTests.Runner; /// Resolves the corpus location relative to this test assembly. @@ -55,4 +58,17 @@ public static IEnumerable ReportQueryScenarios() => /// junction entity's identity.reference children (FR-017). /// public static readonly string CanonicalMetadataDir = CanonicalDir; + + /// + /// The corpus's single metadata file. + /// loads exactly this file, the one metadata file the corpus declares. + /// + public static readonly string FitnessMetadataFile = Path.Combine(CanonicalDir, "meta.fitness.json"); + + /// + /// ADR-0058 — the corpus's committed physical-naming rules, read with the C# config + /// reader: fixtures/persistence-conformance/.metaobjects/config.json + /// pins {"naming":{"columns":"literal"}}, matching the committed schema artifacts. + /// + public static readonly PhysicalNaming Naming = NeutralConfig.Read(Corpus)?.Naming ?? PhysicalNaming.Default; } diff --git a/server/csharp/MetaObjects.IntegrationTests/Runner/QueryScenarioRunner.cs b/server/csharp/MetaObjects.IntegrationTests/Runner/QueryScenarioRunner.cs index 0208f80d7..1a725849f 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Runner/QueryScenarioRunner.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Runner/QueryScenarioRunner.cs @@ -129,13 +129,22 @@ private static async Task RunAsync(QueryScenario scenario, ScenarioEngine engine } } - /// Load the canonical corpus metadata (the M:N relationship + junction declarations). + /// + /// Load the canonical corpus metadata (the M:N relationship + junction declarations): + /// the corpus's one metadata file, . The + /// corpus's project config is one level up, at + /// fixtures/persistence-conformance/.metaobjects/config.json, so a load of this + /// file never reads it; its naming rule is set on the root from + /// . + /// private static MetaRoot LoadCorpusMetadata() { - var result = MetaDataLoader.FromDirectory(CorpusPaths.CanonicalMetadataDir); + var result = MetaDataLoader.FromUris([new Uri(CorpusPaths.FitnessMetadataFile)]); if (result.Errors.Count > 0) throw new InvalidOperationException( "failed to load corpus metadata: " + string.Join("; ", result.Errors.Select(e => e.Message))); + // ADR-0058 — the corpus's committed naming rule, not a hardcoded default. + result.Root.PhysicalNaming = CorpusPaths.Naming; return result.Root; } diff --git a/server/csharp/MetaObjects/AgentContext/AgentContextScaffold.cs b/server/csharp/MetaObjects/AgentContext/AgentContextScaffold.cs index 197267316..509ae04ab 100644 --- a/server/csharp/MetaObjects/AgentContext/AgentContextScaffold.cs +++ b/server/csharp/MetaObjects/AgentContext/AgentContextScaffold.cs @@ -37,16 +37,32 @@ 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 - if (ContextIsAheadOfInstall(manifest.GeneratedBy, currentVersion)) return null; + // 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. + var stamped = WithoutBuildMetadata(manifest.GeneratedBy); + var installed = WithoutBuildMetadata(currentVersion)!; + if (stamped == installed) return null; // in sync + if (ContextIsAheadOfInstall(stamped, installed)) return null; var from = manifest.GeneratedBy ?? "an older MetaObjects"; return $"MetaObjects agent context was generated by {from}; you're on {currentVersion}. " + "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. + /// Public because the CLI's EjectCommand.PackageVersion makes the same semver §10 + /// strip from the other end — one rule, one implementation. + public 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"; @@ -57,9 +73,10 @@ public sealed record Manifest( /// <m>.<p> on its historical major 7, so minor.patch IS the shared /// release coordinate across all four ports. /// null means "not orderable, so nudge", and deliberately covers prereleases - /// (0.24.5-rc.1), build metadata (0.24.5+abc) and the 0.0.0 sentinel. - /// Each must keep nudging: an RC-scaffolded context against a final release is still worth - /// refreshing, and an unknown install must never assert "in sync". + /// (0.24.5-rc.1) and the 0.0.0 sentinel. Each must keep nudging: an + /// RC-scaffolded context against a final release is still worth refreshing, and an unknown + /// install must never assert "in sync". Build metadata is stripped before this is asked + /// (): it is not part of the version. /// private static (int Minor, int Patch)? ReleaseSeries(string? version) { diff --git a/server/csharp/MetaObjects/Config/MetadataLocation.cs b/server/csharp/MetaObjects/Config/MetadataLocation.cs index 4cbe36fb3..2b8a65e32 100644 --- a/server/csharp/MetaObjects/Config/MetadataLocation.cs +++ b/server/csharp/MetaObjects/Config/MetadataLocation.cs @@ -8,6 +8,7 @@ // into a library could not regenerate at all once it ejected a generator. using MetaObjects.Loader; +using MetaObjects.Meta; namespace MetaObjects.Config; @@ -19,12 +20,25 @@ namespace MetaObjects.Config; /// list when present: FromDirectory would re-walk the tree AND include /// _pending drafts, since only turns that exclusion on. /// -public sealed record ResolvedMetadata(string Directory, IReadOnlyList? Files, IReadOnlyList Libraries) +/// +/// ADR-0058 — the project's physical-naming rules, read from the SAME +/// .metaobjects/config.json this location came from (or the default when there is +/// none). sets it on the loaded root, so every resolver (codegen, the +/// runtime, verify) agrees with the project's config without being passed a strategy value. +/// +public sealed record ResolvedMetadata( + string Directory, IReadOnlyList? Files, IReadOnlyList Libraries, PhysicalNaming Naming) { - /// Load this metadata, with the project's libraries. - public LoadResult Load(bool strict = false) => Files is { } files - ? MetaDataLoader.FromUris(files.Select(f => new Uri(f)).ToList(), Libraries, strict) - : MetaDataLoader.FromDirectory(Directory, Libraries, strict: strict); + /// Load this metadata, with the project's libraries, and set the loaded + /// root's from (ADR-0058). + public LoadResult Load(bool strict = false) + { + var result = Files is { } files + ? MetaDataLoader.FromUris(files.Select(f => new Uri(f)).ToList(), Libraries, strict) + : MetaDataLoader.FromDirectory(Directory, Libraries, strict: strict); + result.Root.PhysicalNaming = Naming; + return result; + } } /// A metadata location this port cannot load, stated for the user. @@ -39,6 +53,24 @@ public static class MetadataLocation public static string ProjectRootFor(string metadataDir) => Path.GetDirectoryName(Path.GetFullPath(metadataDir)) ?? System.IO.Directory.GetCurrentDirectory(); + /// + /// The .metaobjects/config.json of the project holding an explicit metadata + /// directory: the nearest ancestor from up that carries one, + /// searched no further than the repository root (a directory containing .git), so + /// dotnet meta gen /repo/model/catalog reads /repo/.metaobjects/config.json, + /// as the Maven plugin does. Null when no such ancestor exists. + /// + private static NeutralConfig? ConfigFor(string metadataDir) + { + for (var dir = new DirectoryInfo(ProjectRootFor(metadataDir)); dir is not null; dir = dir.Parent) + { + if (NeutralConfig.Read(dir.FullName) is { } cfg) return cfg; + var git = Path.Combine(dir.FullName, ".git"); + if (System.IO.Directory.Exists(git) || File.Exists(git)) break; + } + return null; + } + /// /// Resolve where metadata lives. An explicit is used as /// given; otherwise 's .metaobjects/config.json @@ -52,20 +84,23 @@ public static ResolvedMetadata Resolve(string? metadataDir, string cwd) { if (metadataDir is not null) { - var explicitLibs = NeutralConfig.Read(ProjectRootFor(metadataDir))?.Libraries ?? Array.Empty(); - return new ResolvedMetadata(metadataDir, null, explicitLibs); + var explicitCfg = ConfigFor(metadataDir); + var explicitLibs = explicitCfg?.Libraries ?? Array.Empty(); + var explicitNaming = explicitCfg?.Naming ?? PhysicalNaming.Default; + return new ResolvedMetadata(metadataDir, null, explicitLibs, explicitNaming); } var cfg = NeutralConfig.Read(cwd); var specs = cfg?.Sources ?? Array.Empty>(); var libraries = cfg?.Libraries ?? Array.Empty(); + var naming = cfg?.Naming ?? PhysicalNaming.Default; if (specs.Count == 0) { // No declared sources: resolve the DEFAULT directory through the same ladder // the shared conformance corpus gates (ERR_COLLECTION_NOT_FOUND when absent). var defaultFiles = SourceResolver.ResolveCollection(cwd); - return new ResolvedMetadata(Path.Combine(cwd, NeutralConfig.DefaultMetadataDir), defaultFiles, libraries); + return new ResolvedMetadata(Path.Combine(cwd, NeutralConfig.DefaultMetadataDir), defaultFiles, libraries, naming); } if (specs.Count > 1) @@ -95,6 +130,6 @@ public static ResolvedMetadata Resolve(string? metadataDir, string cwd) "\"sources\" at the file's containing directory."); } - return new ResolvedMetadata(resolved, files, libraries); + return new ResolvedMetadata(resolved, files, libraries, naming); } } diff --git a/server/csharp/MetaObjects/Config/NeutralConfig.cs b/server/csharp/MetaObjects/Config/NeutralConfig.cs index d1a7b1908..d9d84adc0 100644 --- a/server/csharp/MetaObjects/Config/NeutralConfig.cs +++ b/server/csharp/MetaObjects/Config/NeutralConfig.cs @@ -13,7 +13,13 @@ // can opt into a shipped library at all. See docs/superpowers/specs/2026-09-13-fr-043- // feature-and-nfr-packages-design.md §12 Q4 ("should libraries move to // .metaobjects/config.json? yes, outright... the JVM/C# ports can follow"). +// +// `naming` (ADR-0058) is read here too — the one project setting for how a metadata name +// becomes a physical database name, read by every port. An unknown key, an unknown value, +// or a non-object `naming` is refused (ERR_BAD_ATTR_VALUE), the same "present but +// wrong-shaped raises" rule `sources`/`libraries` already follow. using System.Text.Json; +using MetaObjects.Meta; namespace MetaObjects.Config; @@ -42,11 +48,18 @@ public sealed class NeutralConfig /// `resolveCollection` (ERR_UNKNOWN_LIBRARY). public IReadOnlyList Libraries { get; } + /// ADR-0058 — the project's physical-naming rules (`naming` in config.json), each + /// absent rule at its default. Never null: an absent `naming` key is the default, + /// same as an empty `naming: {}` block. + public PhysicalNaming Naming { get; } + private NeutralConfig( - IReadOnlyList> sources, IReadOnlyList libraries) + IReadOnlyList> sources, IReadOnlyList libraries, + PhysicalNaming naming) { Sources = sources; Libraries = libraries; + Naming = naming; } /// Returns null when `/.metaobjects/config.json` does not exist. @@ -164,9 +177,65 @@ private NeutralConfig( } } + var columns = ColumnNamingStrategy.SnakeCase; + var tables = TableNamingRule.SnakeCase; + if (root.TryGetProperty("naming", out var naming)) + { + // Present but not an object — the same "wrong-shaped raises" rule as + // `sources`/`libraries` above. + if (naming.ValueKind != JsonValueKind.Object) + throw new MetaModelException($"{path}: \"naming\" must be an object", ErrorCode.ERR_BAD_ATTR_VALUE); + + foreach (var p in naming.EnumerateObject()) + { + switch (p.Name) + { + case "columns": + if (p.Value.ValueKind != JsonValueKind.String || !TryParseColumnNaming(p.Value.GetString()!, out columns)) + throw new MetaModelException( + $"{path}: \"naming.columns\" must be one of \"snake_case\", \"literal\", \"kebab-case\"", + ErrorCode.ERR_BAD_ATTR_VALUE); + break; + case "tables": + if (p.Value.ValueKind != JsonValueKind.String || !TryParseTableNaming(p.Value.GetString()!, out tables)) + throw new MetaModelException( + $"{path}: \"naming.tables\" must be one of \"snake_case\", \"literal\"", + ErrorCode.ERR_BAD_ATTR_VALUE); + break; + default: + throw new MetaModelException( + $"{path}: \"naming\" has an unknown key \"{p.Name}\" (expected \"columns\" and/or \"tables\")", + ErrorCode.ERR_BAD_ATTR_VALUE); + } + } + } + // Unknown top-level keys (including `scope`/`migrate`) are IGNORED by // design — see the file header. - return new NeutralConfig(specs, libraries); + return new NeutralConfig(specs, libraries, new PhysicalNaming(columns, tables)); + } + } + + /// The cross-port spellings (ADR-0058) — not .NET enum names, so the JSON value reads + /// the same in every port's config. + private static bool TryParseColumnNaming(string raw, out ColumnNamingStrategy strategy) + { + switch (raw) + { + case "literal": strategy = ColumnNamingStrategy.Literal; return true; + case "snake_case": strategy = ColumnNamingStrategy.SnakeCase; return true; + case "kebab-case": strategy = ColumnNamingStrategy.KebabCase; return true; + default: strategy = ColumnNamingStrategy.Literal; return false; + } + } + + private static bool TryParseTableNaming(string raw, out TableNamingRule rule) + { + switch (raw) + { + case "literal": rule = TableNamingRule.Literal; return true; + case "snake_case": rule = TableNamingRule.SnakeCase; return true; + default: rule = TableNamingRule.Literal; return false; } } } diff --git a/server/csharp/MetaObjects/Meta/MetaRoot.cs b/server/csharp/MetaObjects/Meta/MetaRoot.cs index dbf9f6cde..9cb0ae734 100644 --- a/server/csharp/MetaObjects/Meta/MetaRoot.cs +++ b/server/csharp/MetaObjects/Meta/MetaRoot.cs @@ -10,6 +10,15 @@ namespace MetaObjects.Meta; /// public class MetaRoot(TypeId typeId, string name) : MetaData(typeId, name) { + /// + /// The project's physical-naming rules (naming in .metaobjects/config.json, + /// ADR-0058). Set by the project-aware load (), + /// read by every name resolver through . A tree loaded with + /// no project — or built directly via MetaDataLoader.FromDirectory/FromUris + /// with no project-aware wrapper — keeps the default. + /// + public PhysicalNaming PhysicalNaming { get; set; } = PhysicalNaming.Default; + /// Object entities defined at this root level. public IReadOnlyList Objects() { diff --git a/server/csharp/MetaObjects/Meta/MetaSource.cs b/server/csharp/MetaObjects/Meta/MetaSource.cs index 577a58d96..fc43ae6bb 100644 --- a/server/csharp/MetaObjects/Meta/MetaSource.cs +++ b/server/csharp/MetaObjects/Meta/MetaSource.cs @@ -143,10 +143,16 @@ public bool IsCallable() => /// /// Kind-matching alias (e.g. @proc when @kind: "storedProc"). /// Legacy @table for non-table kind (pre-1.0 fallback). - /// Source's bare structural name via snake_case. - /// Owning entity's name via pluralize(snake_case). + /// Source's bare structural name, cased by the project's table rule. + /// Owning entity's name, cased by the project's table rule, through the frozen + /// legacy pluralizer. /// /// + /// The table rule is naming.tables in .metaobjects/config.json (ADR-0058), + /// carried by the loaded root (); default + /// snake_case. + /// + /// /// Callers needing the legacy raw @table slot only should use /// ; codegen / migrate / runtime should use /// . @@ -174,19 +180,20 @@ public string PhysicalName if (Attr(SOURCE_ATTR_TABLE) is string legacy && legacy != "") return legacy; } - // Step 3: source's structural `name` via snake_case (no pluralization — - // the source's name IS the logical name). + // Step 3: source's structural `name`, cased by the project's table rule + // (ADR-0058; no pluralization — the source's name IS the logical name). if (Name != "") { - return SourceNaming.ToSnakeCase(Name); + return PhysicalNaming.ApplyTableNamingRule(Name, PhysicalNaming.Of(this).Tables); } - // Step 4: owning entity's name via pluralize(snake_case). The MetaData - // parent of a source is always the entity that declared it. + // Step 4: owning entity's name, cased by the project's table rule, through the + // FROZEN legacy pluralizer under every rule. The MetaData parent of a source is + // always the entity that declared it. var owner = Parent; if (owner is not null && owner.Name != "") { - return SourceNaming.Pluralize(SourceNaming.ToSnakeCase(owner.Name)); + return SourceNaming.Pluralize(PhysicalNaming.ApplyTableNamingRule(owner.Name, PhysicalNaming.Of(this).Tables)); } return ""; diff --git a/server/csharp/MetaObjects/Meta/PhysicalNaming.cs b/server/csharp/MetaObjects/Meta/PhysicalNaming.cs new file mode 100644 index 000000000..e30f5f80d --- /dev/null +++ b/server/csharp/MetaObjects/Meta/PhysicalNaming.cs @@ -0,0 +1,68 @@ +// Physical naming — ADR-0058: one project setting for how a metadata name becomes a +// physical database name, read by every port (codegen, migrate (TS-only), the runtime, +// verify). Mirrors the TypeScript reference (metadata/src/naming.ts): ColumnNamingStrategy / +// TableNamingRule / PhysicalNaming / the default + the root-carried lookup. + +using MetaObjects.Persistence.Source; + +namespace MetaObjects.Meta; + +/// +/// How a field with no @column becomes a column name. One project setting, +/// naming.columns in .metaobjects/config.json, read by every port (ADR-0058): +/// the loaded root carries it (), so codegen, the runtime and +/// verify resolve the same name. Not a metadata attr: @column is the per-field +/// override, this is the rule for the rest. +/// +public enum ColumnNamingStrategy +{ + /// Field name verbatim. + Literal, + /// camelCase / PascalCase → snake_case. The default — what meta migrate + /// has always created. + SnakeCase, + /// camelCase / PascalCase → kebab-case. + KebabCase, +} + +/// +/// How an object with no physical-name attribute (@table/@view/...) and no +/// source name gets its table name: the entity (or source) name cased by this rule. The +/// entity-name fallback keeps the frozen legacy pluralizer under every rule +/// (). +/// +public enum TableNamingRule +{ + /// Casing verbatim; the entity-name fallback is still pluralized (see ). + Literal, + /// camelCase / PascalCase → snake_case. The default — what meta migrate + /// has always created. + SnakeCase, +} + +/// +/// The project's physical-naming rules: naming in .metaobjects/config.json +/// (ADR-0058). Both rules default to , equal to what meta migrate +/// has always created. +/// +public sealed record PhysicalNaming(ColumnNamingStrategy Columns, TableNamingRule Tables) +{ + /// The default in every port: snake_case columns, snake_case tables. + public static readonly PhysicalNaming Default = new(ColumnNamingStrategy.SnakeCase, TableNamingRule.SnakeCase); + + /// + /// The physical-naming rules for the tree belongs to: what its + /// root carries, set by the project-aware load from .metaobjects/config.json + /// (), else . Read through the + /// root, not passed around, so every consumer of one loaded model resolves the same + /// physical names. Falls back to for a tree whose top is not a + /// at all (an in-memory fragment with no loader-built root). + /// + public static PhysicalNaming Of(MetaData node) => + node.Root() is MetaRoot root ? root.PhysicalNaming : Default; + + /// cased by the table rule — no pluralization (the + /// caller decides whether to pluralize; see ). + public static string ApplyTableNamingRule(string name, TableNamingRule rule) => + rule == TableNamingRule.Literal ? name : SourceNaming.ToSnakeCase(name); +} diff --git a/server/csharp/MetaObjects/Persistence/Db/DbSchema.cs b/server/csharp/MetaObjects/Persistence/Db/DbSchema.cs index 23091ebf5..05b25e817 100644 --- a/server/csharp/MetaObjects/Persistence/Db/DbSchema.cs +++ b/server/csharp/MetaObjects/Persistence/Db/DbSchema.cs @@ -18,7 +18,7 @@ public static class DbSchema Name: DbConstants.FIELD_ATTR_COLUMN, ValueType: AttrConstants.ATTR_SUBTYPE_STRING, Required: false, - Description: "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy."); + Description: "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)."); /// @db.indexed — suppress the @filterable-without-index warning; on every field subtype. public static readonly AttrSchema DbIndexedSchema = new AttrSchema( diff --git a/server/csharp/MetaObjects/Persistence/Source/SourceSchema.cs b/server/csharp/MetaObjects/Persistence/Source/SourceSchema.cs index d8ef8a94d..30d735d01 100644 --- a/server/csharp/MetaObjects/Persistence/Source/SourceSchema.cs +++ b/server/csharp/MetaObjects/Persistence/Source/SourceSchema.cs @@ -22,8 +22,9 @@ public static class SourceSchema ValueType: AttrConstants.ATTR_SUBTYPE_STRING, Required: false, Description: "Physical SQL table name for source.rdb @kind: \"table\" (default). FR-016: " + - "Defaults from the source's bare structural `name` via the project's columnNamingStrategy " + - "when omitted, then from the owning entity's name. Pre-1.0 legacy spelling for " + + "Defaults from the source's bare structural `name` under the project's naming.tables rule " + + "(the \"naming\" setting in .metaobjects/config.json) when omitted, then from the owning " + + "entity's name. Pre-1.0 legacy spelling for " + "view/materializedView/storedProc/tableFunction kinds during the transition; " + "canonical-serializer rewrites to the kind-matching alias."), diff --git a/server/csharp/MetaObjects/SpecMetamodel/db.json b/server/csharp/MetaObjects/SpecMetamodel/db.json index 2e15ae0f7..44c1048d7 100644 --- a/server/csharp/MetaObjects/SpecMetamodel/db.json +++ b/server/csharp/MetaObjects/SpecMetamodel/db.json @@ -5,7 +5,7 @@ "type": "field", "subType": "*", "children": [ - { "type": "attr", "subType": "string", "name": "column", "min": 0, "max": 1, "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." }, + { "type": "attr", "subType": "string", "name": "column", "min": 0, "max": 1, "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, { "type": "attr", "subType": "boolean", "name": "db.indexed", "min": 0, "max": 1, "description": "When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means)." }, { "type": "attr", "subType": "string", "name": "dbColumnType", "min": 0, "max": 1, "allowedValues": ["uuid", "jsonb"], "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2." } ] @@ -35,7 +35,7 @@ "type": "source", "subType": "rdb", "children": [ - { "type": "attr", "subType": "string", "name": "table", "min": 0, "max": 1, "description": "Physical SQL table name for source.rdb @kind: \"table\" (default). FR-016: Defaults from the source's bare structural `name` via the project's columnNamingStrategy when omitted, then from the owning entity's name. Pre-1.0 legacy spelling for view/materializedView/storedProc/tableFunction kinds during the transition; canonical-serializer rewrites to the kind-matching alias." }, + { "type": "attr", "subType": "string", "name": "table", "min": 0, "max": 1, "description": "Physical SQL table name for source.rdb @kind: \"table\" (default). FR-016: Defaults from the source's bare structural `name` under the project's naming.tables rule (the \"naming\" setting in .metaobjects/config.json) when omitted, then from the owning entity's name. Pre-1.0 legacy spelling for view/materializedView/storedProc/tableFunction kinds during the transition; canonical-serializer rewrites to the kind-matching alias." }, { "type": "attr", "subType": "string", "name": "view", "min": 0, "max": 1, "description": "Physical SQL view name for source.rdb @kind: \"view\". Same internal slot as @table." }, { "type": "attr", "subType": "string", "name": "materializedView", "min": 0, "max": 1, "description": "Physical SQL materialized-view name for source.rdb @kind: \"materializedView\". Same internal slot as @table." }, { "type": "attr", "subType": "string", "name": "proc", "min": 0, "max": 1, "description": "Physical SQL stored-procedure name for source.rdb @kind: \"storedProc\". Same internal slot as @table." }, 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-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt index 527e67332..ee31661e5 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt @@ -14,8 +14,10 @@ import com.metaobjects.generator.kotlin.KotlinTypeMapper import com.metaobjects.generator.kotlin.PackageMapping import com.metaobjects.MetaData +import com.metaobjects.database.ColumnNaming import com.metaobjects.database.CoreDBMetaDataProvider import com.metaobjects.database.IndexNaming +import com.metaobjects.database.PhysicalNaming import com.metaobjects.MetaRoot import com.metaobjects.field.EnumField import com.metaobjects.field.MapField @@ -68,22 +70,6 @@ import com.metaobjects.generator.util.RestSurfaceGate */ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase() { - /** - * The project's column-naming strategy for fields carrying no explicit `@column`, - * from the `columnNaming` generator arg - * (`literal` in the pom). Values match the - * cross-port vocabulary: `snake_case` (this port's default, and its historical - * behaviour), `literal`, `kebab-case`. - * - * It is CONFIG, not metadata — ADR-0023 does not apply. The same model must be able - * to drive a snake_case Postgres schema and a literal-column one, which is precisely - * why the strategy lives beside the generator rather than in the metadata, and why - * the byte-gated registry prose for `@column` describes it as the default *"via - * columnNamingStrategy"*. - */ - protected fun columnNaming(): String = - getArg(KotlinGenUtil.ARG_COLUMN_NAMING, KotlinGenUtil.DEFAULT_COLUMN_NAMING) - /** * Task 6 — whether the table binding references `Names.NAME` / * `Names._COLUMN` instead of respelling the physical name/column as a @@ -124,6 +110,14 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase): String { - if (!bindsThroughNames(entity)) return "\"${KotlinGenUtil.resolveColumnName(f, columnNaming())}\"" + if (!bindsThroughNames(entity)) return "\"${KotlinGenUtil.resolveColumnName(f)}\"" // ADR-0039: metaFields is the RESOLVING accessor — an inherited field is a HIT here, // and the artifact of `entity` is where its constant is read from. if (entity.metaFields.any { it.name == f.name }) return ownColumnExpr(entity, f) @@ -1167,7 +1161,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBaseNames has no // constant for it. Always literal; useNames() does not reach here. @@ -1912,7 +1906,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase` in - * the pom). Hoisted here (was a private-to-[KotlinExposedTableGenerator] companion - * constant) so [KotlinNamesGenerator] reads the SAME arg name as - * [KotlinExposedTableGenerator] — a second `"columnNaming"` string literal beside a - * second default is exactly the bug this whole program exists to remove. + * REMOVED (ADR-0058, 1.1.0) — the pom `` generator arg. Kept as a + * named constant only so every generator's refusal (`hasArg(ARG_COLUMN_NAMING)`) names + * the same key; see [ColumnNaming.removedConfigurationMessage]. */ const val ARG_COLUMN_NAMING: String = "columnNaming" @@ -903,7 +900,7 @@ public object KotlinGenUtil { * * ADR-0039: read RESOLVING — `@column` may be inherited through `extends`. */ - fun resolveColumnName(field: MetaField<*>, strategy: String = DEFAULT_COLUMN_NAMING): String = + fun resolveColumnName(field: MetaField<*>, strategy: String = PhysicalNaming.of(field).columns()): String = ColumnNaming.resolve(field, strategy) fun camelToSnake(name: String): String { diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNamesGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNamesGenerator.kt index d4451df91..713b2e426 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNamesGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNamesGenerator.kt @@ -76,24 +76,27 @@ import java.nio.file.Paths * * Args: * - `outputDir` (required): output directory root. - * - `columnNaming` (optional): the column-naming strategy — the SAME generator arg - * [KotlinExposedTableGenerator] reads (via [KotlinGenUtil.ARG_COLUMN_NAMING]), so a - * run resolves the column string this artifact declares and the column - * [KotlinExposedTableGenerator] binds through the identical resolver + argument. + * + * `columnNaming` is REMOVED (ADR-0058, 1.1.0) — column naming is `naming.columns` in + * `.metaobjects/config.json` now, read from the loaded root + * ([com.metaobjects.database.PhysicalNaming.of]), the SAME rule [KotlinExposedTableGenerator] + * resolves, so the column this artifact declares and the column that generator binds + * through cannot disagree any more. */ open class KotlinNamesGenerator : MultiFileDirectGeneratorBase(), EmitsPhysicalNameConstants { - /** See [KotlinExposedTableGenerator.columnNaming] — same arg, same default. */ - protected fun columnNaming(): String = - getArg(KotlinGenUtil.ARG_COLUMN_NAMING, KotlinGenUtil.DEFAULT_COLUMN_NAMING) - override fun getFilterClass(): Class = MetaObject::class.java override fun execute(loader: MetaDataLoader) { parseArgs() + // ADR-0058 — the pom generator arg is REMOVED; refuse by name. + if (hasArg(KotlinGenUtil.ARG_COLUMN_NAMING)) { + throw GeneratorException(com.metaobjects.database.ColumnNaming.removedConfigurationMessage( + "the columnNaming generator arg in pom.xml", getArg(KotlinGenUtil.ARG_COLUMN_NAMING))) + } val outRoot = Paths.get(outDir.absolutePath) - val strategy = columnNaming() + val strategy = com.metaobjects.database.PhysicalNaming.of(loader.root).columns() // Pass 1 — every object that participates in the database (#248). val emitted = mutableSetOf() for (entity in loader.metaObjects) { diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinStoredProcGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinStoredProcGenerator.kt index 2c5f5ae1b..14b58d9e6 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinStoredProcGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinStoredProcGenerator.kt @@ -79,23 +79,19 @@ import com.metaobjects.generator.util.GeneratedFileWriter *

Args: *

    *
  • {@code outputDir} (required): output directory root.
  • - *
  • {@code columnNaming} (optional): the column-naming strategy — the SAME arg - * [KotlinExposedTableGenerator] and [KotlinNamesGenerator] read, so the column a - * result row is read by and the column the names artifact declares resolve through - * the identical resolver + argument.
  • *
  • {@code useNames} (optional, derived by the Maven mojo — see * [com.metaobjects.generator.EmitsPhysicalNameConstants]): when the names artifact is * in the run, `PROC_NAME` is initialised from `Names.NAME` and each result * column is read through `Names._COLUMN`, so neither physical name * is spelled a second time.
  • *
+ * + *

{@code columnNaming} is REMOVED (ADR-0058, 1.1.0) — the column a result row is read by + * resolves from the loaded root now ([com.metaobjects.database.PhysicalNaming.of]), the SAME + * rule [KotlinExposedTableGenerator] and [KotlinNamesGenerator] resolve.

*/ open class KotlinStoredProcGenerator : MultiFileDirectGeneratorBase() { - /** See [KotlinExposedTableGenerator.columnNaming] — same arg, same default. */ - protected fun columnNaming(): String = - getArg(KotlinGenUtil.ARG_COLUMN_NAMING, KotlinGenUtil.DEFAULT_COLUMN_NAMING) - /** * See [KotlinExposedTableGenerator.useNames] — same arg, same default, same reason for * the default: a PRESENCE guard for a direct programmatic call, never a divergence @@ -111,6 +107,11 @@ open class KotlinStoredProcGenerator : MultiFileDirectGeneratorBase( override fun execute(loader: MetaDataLoader) { parseArgs() + // ADR-0058 — the pom generator arg is REMOVED; refuse by name. + if (hasArg(KotlinGenUtil.ARG_COLUMN_NAMING)) { + throw GeneratorException(com.metaobjects.database.ColumnNaming.removedConfigurationMessage( + "the columnNaming generator arg in pom.xml", getArg(KotlinGenUtil.ARG_COLUMN_NAMING))) + } val outRoot = Paths.get(outDir.absolutePath) for (entity in loader.metaObjects) { @@ -366,7 +367,7 @@ open class KotlinStoredProcGenerator : MultiFileDirectGeneratorBase( private fun columnExpr(shortName: String, field: MetaField<*>): String = if (useNames()) "${KotlinNaming.namesObjectName(shortName)}.${KotlinNaming.namesMember(field.name)}_COLUMN" - else "\"${KotlinGenUtil.resolveColumnName(field, columnNaming())}\"" + else "\"${KotlinGenUtil.resolveColumnName(field)}\"" /** * Fully qualified Kotlin parameter type for a call-parameter field. Uses the diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableColumnNamingTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableColumnNamingTest.kt index 79c77ff1b..d0f0afa18 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableColumnNamingTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableColumnNamingTest.kt @@ -35,12 +35,19 @@ class KotlinExposedTableColumnNamingTest { ] } }""".trimIndent() - private fun generate(args: Map): String { + private fun generate(args: Map, columnsRule: String? = null): String { val outDir = Files.createTempDirectory("ktbl-colnaming-") try { val gen = KotlinExposedTableGenerator() gen.setArgs(args + mapOf("outputDir" to outDir.toString())) - gen.execute(loadString("test", fixture)) + val loader = loadString("test", fixture) + // ADR-0058: the rule is read from the loaded root (naming.columns in + // .metaobjects/config.json), never the removed columnNaming generator arg. + if (columnsRule != null) { + loader.root.physicalNaming = + com.metaobjects.database.PhysicalNaming.of(columnsRule, "snake_case") + } + gen.execute(loader) return Files.readString(outDir.resolve("acme/demo/LlmCallTable.kt")) } finally { outDir.toFile().deleteRecursively() @@ -60,9 +67,20 @@ class KotlinExposedTableColumnNamingTest { } @Test fun `columnNaming literal leaves an unannotated field name alone`() { - val src = generate(mapOf("columnNaming" to "literal")) + val src = generate(emptyMap(), columnsRule = "literal") assertTrue("""val modelName = varchar("modelName", 40)""" in src, src) // An explicit @column always wins, whatever the strategy. assertTrue("""val callPurpose = varchar("purpose_code", 40)""" in src, src) } + + @Test fun `the removed columnNaming generator arg is refused`() { + val ex = kotlin.test.assertFailsWith { + generate(mapOf("columnNaming" to "literal")) + } + kotlin.test.assertEquals( + com.metaobjects.database.ColumnNaming.removedConfigurationMessage( + "the columnNaming generator arg in pom.xml", "literal"), + ex.message, + ) + } } diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinNamesGeneratorTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinNamesGeneratorTest.kt index b217288d7..6f6a860ad 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinNamesGeneratorTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinNamesGeneratorTest.kt @@ -66,12 +66,24 @@ class KotlinNamesGeneratorTest { ] } }""".trimIndent() - private fun emit(model: String, args: Map = emptyMap(), outDir: Path? = null): Map { + private fun emit( + model: String, + args: Map = emptyMap(), + outDir: Path? = null, + columnsRule: String? = null, + ): Map { val dir = outDir ?: Files.createTempDirectory("names-") try { val gen = KotlinNamesGenerator() gen.setArgs(args + mapOf("outputDir" to dir.toString())) - gen.execute(loadString("test", model)) + val loader = loadString("test", model) + // ADR-0058: the rule is read from the loaded root, never the removed + // columnNaming generator arg. + if (columnsRule != null) { + loader.root.physicalNaming = + com.metaobjects.database.PhysicalNaming.of(columnsRule, "snake_case") + } + gen.execute(loader) return Files.walk(dir).use { stream -> stream.filter { it.isRegularFile() } .toList() @@ -85,8 +97,8 @@ class KotlinNamesGeneratorTest { } } - private fun authorSource(args: Map = emptyMap()): String = - emit(authorModel, args).getValue("acme/blog/AuthorNames.kt") + private fun authorSource(args: Map = emptyMap(), columnsRule: String? = null): String = + emit(authorModel, args, columnsRule = columnsRule).getValue("acme/blog/AuthorNames.kt") @Test fun `emits const val members for the table and every column`() { val src = authorSource() @@ -152,15 +164,26 @@ class KotlinNamesGeneratorTest { assertTrue("const val SOURCE_PRIMARY_SCHEMA: String = \"inventory\"" in src, src) } - @Test fun `the columnNaming arg is honoured, both arms`() { + @Test fun `the project's naming columns rule is honoured, both arms`() { // Default (snake_case): createdAt -> created_at. assertTrue("const val CREATED_AT_COLUMN: String = \"created_at\"" in authorSource(), authorSource()) // literal: createdAt stays createdAt. An explicit @column still always wins. - val literalSrc = authorSource(mapOf("columnNaming" to "literal")) + val literalSrc = authorSource(columnsRule = "literal") assertTrue("const val CREATED_AT_COLUMN: String = \"createdAt\"" in literalSrc, literalSrc) assertTrue("const val CALL_PURPOSE_COLUMN: String = \"purpose_code\"" in literalSrc, literalSrc) } + @Test fun `the columnNaming generator arg is refused`() { + val ex = assertFailsWith { + authorSource(mapOf("columnNaming" to "literal")) + } + assertEquals( + com.metaobjects.database.ColumnNaming.removedConfigurationMessage( + "the columnNaming generator arg in pom.xml", "literal"), + ex.message, + ) + } + @Test fun `a view kind source is read-only and keeps its own kind`() { // resolveObjectNames dispatches on the primary source's KIND, never the object // subtype (#248) -- object.projection legally carries a read-only primary diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportRestSurfaceTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportRestSurfaceTest.kt index 1579d4450..19a033b77 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportRestSurfaceTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportRestSurfaceTest.kt @@ -78,8 +78,17 @@ class KotlinReportRestSurfaceTest { private fun withModel() = loadDirectory("report-rest-with", fixtureDir("fixtures/codegen-noop/reporting/with")) - private fun canonical() = - loadDirectory("report-rest-canonical", fixtureDir("fixtures/persistence-conformance/canonical")) + private fun canonical(): MetaDataLoader { + val dir = fixtureDir("fixtures/persistence-conformance/canonical") + val loader = loadDirectory("report-rest-canonical", dir) + // ADR-0058: this corpus's committed .metaobjects/config.json — at the corpus ROOT + // (fixtures/persistence-conformance/), not under canonical/, so a directory loader + // pointed at canonical/ never walks into it — declares naming.columns: literal. + // Read with the SAME NeutralConfig reader every port uses, rather than the removed + // columnNaming generator arg. + loader.root.physicalNaming = com.metaobjects.config.SourceResolver.resolvePhysicalNaming(dir.parent) + return loader + } /** Compile everything but the controllers: Spring is not on this module's classpath. */ private fun assertCompilesWithoutControllers(files: Map) { @@ -364,7 +373,7 @@ class KotlinReportRestSurfaceTest { @Test fun `the nine canonical reports' rows, tables and allowlists compile beside their entities`() { - val files = emit(canonical(), args = mapOf("columnNaming" to "literal")) + val files = emit(canonical()) for (report in listOf( "ProgramMinutes", "FitnessTotals", "ProgramsByMonth", "ProgramsByWeek", "RecentPrograms", "AssetActivity", "ProgramRoster", "ProgramLongWeeks", "FitnessTotalsFilled", diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportTableGeneratorTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportTableGeneratorTest.kt index 8353d81a0..f3f9cf44a 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportTableGeneratorTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportTableGeneratorTest.kt @@ -68,8 +68,23 @@ class KotlinReportTableGeneratorTest { } } - private fun canonical(args: Map = mapOf("columnNaming" to "literal")) = - emit(loadDirectory("report-table-canonical", canonicalDir()), args) + /** + * This corpus's committed {@code .metaobjects/config.json} — at the corpus ROOT + * ({@code fixtures/persistence-conformance/}), not under {@code canonical/}, because a + * directory loader pointed at {@code canonical/} would otherwise walk into it and hand + * it to the canonical JSON parser as a metadata document — declares + * {@code naming.columns: literal} (ADR-0058). Read it with the SAME NeutralConfig reader + * every port uses, rather than the removed {@code columnNaming} generator arg. + */ + private fun canonicalLoader(): MetaDataLoader { + val dir = canonicalDir() + val loader = loadDirectory("report-table-canonical", dir) + loader.root.physicalNaming = com.metaobjects.config.SourceResolver.resolvePhysicalNaming(dir.parent) + return loader + } + + private fun canonical(args: Map = emptyMap()) = + emit(canonicalLoader(), args) private fun assertCompiles(files: Map) { val sources = files.filterKeys { it.endsWith(".kt") } @@ -160,14 +175,18 @@ class KotlinReportTableGeneratorTest { @Test fun `the naming strategy applies to the derived field name`() { - val src = canonical(emptyMap()).getValue("fitness/ProgramMinutesTable.kt") // snake_case default + // The project DEFAULT (snake_case, ADR-0058) -- not this corpus's committed + // naming.columns: literal, which canonical()/canonicalLoader() apply; a plain + // loadDirectory() here keeps the root at PhysicalNaming.DEFAULT. + val src = emit(loadDirectory("report-table-default-naming", canonicalDir())) + .getValue("fitness/ProgramMinutesTable.kt") assertTrue(" val avgMinutes = decimal(\"avg_minutes\", 38, 18).nullable()\n" in src, src) assertTrue(" val programTitle = varchar(\"program_title\", 200)\n" in src, src) } @Test fun `the Exposed 1x mode emits the same report table against the v1 packages`() { - val files = canonical(mapOf("columnNaming" to "literal", "exposedApi" to "1")) + val files = canonical(mapOf("exposedApi" to "1")) val byMonth = files.getValue("fitness/ProgramsByMonthTable.kt") assertTrue("import org.jetbrains.exposed.v1.core.Table\n" in byMonth, byMonth) assertTrue("import org.jetbrains.exposed.v1.javatime.date\n" in byMonth, byMonth) @@ -180,8 +199,8 @@ class KotlinReportTableGeneratorTest { @Test fun `with the names generator in the run a report still binds by literal and gets a names artifact`() { val files = emit( - loadDirectory("report-table-names", canonicalDir()), - mapOf("columnNaming" to "literal", "useNames" to "true"), + canonicalLoader(), + mapOf("useNames" to "true"), listOf(KotlinNamesGenerator(), KotlinExposedTableGenerator()), ) // The entity beside it does reference its artifact, so the arg really was on. @@ -279,9 +298,11 @@ class KotlinReportTableGeneratorTest { /** The table and the row data class of `ProductRevenue`. */ private fun productRevenueFiles(json: String): Pair { + val loader = loadString("report-table-spine-default", json) + loader.root.physicalNaming = com.metaobjects.database.PhysicalNaming.of("literal", "snake_case") val files = emit( - loadString("report-table-spine-default", json), - mapOf("columnNaming" to "literal"), + loader, + emptyMap(), listOf(KotlinEntityGenerator(), KotlinExposedTableGenerator()), ) assertCompiles(files) @@ -587,12 +608,15 @@ class KotlinReportTableGeneratorTest { }) assertEquals(emptyList(), errors.map { it.message }) register() + // These fixtures assert literal column names (ADR-0058's default is now + // snake_case in every port). + root.physicalNaming = com.metaobjects.database.PhysicalNaming.of("literal", "snake_case") } private fun crossPackageTable(decoy: Boolean, measures: String = """["events"]"""): Map = emit( loadFiles(sharedBase, evFile(decoy, measures)), - mapOf("columnNaming" to "literal"), + emptyMap(), listOf(KotlinEntityGenerator(), KotlinExposedTableGenerator()), ) @@ -669,11 +693,21 @@ class KotlinReportTableGeneratorTest { @Test fun `the canonical report tables compile beside the entities they reference`() { val files = emit( - loadDirectory("report-table-compile", canonicalDir()), - mapOf("columnNaming" to "literal", "packageName" to "fitness"), + canonicalLoader(), + mapOf("packageName" to "fitness"), listOf(KotlinEntityGenerator(), KotlinExposedTableGenerator()), ) assertTrue(files.keys.count { it.endsWith("Table.kt") } >= 6, files.keys.toString()) assertCompiles(files) } + + @Test + fun `the columnNaming generator arg is refused`() { + val e = assertFailsWith { canonical(mapOf("columnNaming" to "literal")) } + assertEquals( + com.metaobjects.database.ColumnNaming.removedConfigurationMessage( + "the columnNaming generator arg in pom.xml", "literal"), + e.message, + ) + } } diff --git a/server/java/codegen-mustache/src/main/java/com/metaobjects/generator/mustache/HelperRegistry.java b/server/java/codegen-mustache/src/main/java/com/metaobjects/generator/mustache/HelperRegistry.java index cc38f3af4..b0e107e9a 100644 --- a/server/java/codegen-mustache/src/main/java/com/metaobjects/generator/mustache/HelperRegistry.java +++ b/server/java/codegen-mustache/src/main/java/com/metaobjects/generator/mustache/HelperRegistry.java @@ -1,5 +1,8 @@ package com.metaobjects.generator.mustache; +import com.metaobjects.database.ColumnNaming; +import com.metaobjects.database.PhysicalNaming; +import com.metaobjects.source.MetaSource; import com.metaobjects.object.MetaObject; import com.metaobjects.field.MetaField; import com.metaobjects.relationship.AssociationRelationship; @@ -546,52 +549,27 @@ private Object getScale(Object input) { // ======================================== /** - * Get the database column name for a MetaField. - * Returns the explicit dbColumn attribute if present, otherwise converts - * the field name from camelCase to snake_case. + * The database column name for a MetaField: its {@code @column}, else its name through the + * project's {@code naming.columns} rule (ADR-0058) — the one resolver codegen, OMDB and + * every other port use, so a template never spells a column the schema does not have. */ private String getColumnName(MetaField metaField) { - // Check for explicit dbColumn attribute first - if (metaField.hasMetaAttr(COLUMN)) { - String explicitColumn = metaField.getMetaAttr(COLUMN).getValueAsString(); - if (explicitColumn != null && !explicitColumn.trim().isEmpty()) { - log.debug("Using explicit dbColumn: {} for field: {}", explicitColumn, metaField.getName()); - return explicitColumn.trim(); - } - } - - // Default: Convert field name from camelCase to snake_case - String fieldName = metaField.getName(); - String columnName = camelCaseToSnakeCase(fieldName); - log.debug("Generated column name: {} from field: {}", columnName, fieldName); - return columnName; + return ColumnNaming.resolve(metaField); } /** - * Get the database table name for a MetaObject. - * - *

Source-v2 ADR-0007: returns the {@code @table} of the primary writable - * {@code source.rdb} child if present, or — for view-backed projections — - * the {@code @table} of the primary read-only source. Falls back to the - * snake_case form of the object name when neither is declared.

+ * The database table (or view) name for a MetaObject: the physical name of its primary + * writable {@code source.rdb}, else — for a view-backed projection — of its primary + * read-only one, resolved by the source's own four-step rule under the project's + * {@code naming.tables} rule (ADR-0018, ADR-0058). An object with no source has no table; + * its name is cased by the same table rule. */ private String getTableName(MetaObject metaObject) { - // Primary writable source.rdb @table - String explicitTable = metaObject.getPrimaryRdbTableName(); - if (explicitTable == null) { - // Projection: primary read-only source.rdb @table - explicitTable = metaObject.getPrimaryRdbViewName(); - } - if (explicitTable != null && !explicitTable.trim().isEmpty()) { - log.debug("Using source.rdb @table: {} for object: {}", explicitTable, metaObject.getName()); - return explicitTable.trim(); - } - - // Default: Convert object name from camelCase to snake_case - String objectName = metaObject.getName(); - String tableName = camelCaseToSnakeCase(objectName); - log.debug("Generated table name: {} from object: {}", tableName, objectName); - return tableName; + return metaObject.findPrimaryWritableSource() + .or(metaObject::findPrimaryReadOnlySource) + .map(MetaSource::getPhysicalName) + .orElseGet(() -> PhysicalNaming.applyTableRule( + metaObject.getName(), PhysicalNaming.of(metaObject).tables())); } /** diff --git a/server/java/codegen-mustache/src/test/java/com/metaobjects/generator/mustache/HelperRegistryPhysicalNamingTest.java b/server/java/codegen-mustache/src/test/java/com/metaobjects/generator/mustache/HelperRegistryPhysicalNamingTest.java new file mode 100644 index 000000000..4cb8b7b7b --- /dev/null +++ b/server/java/codegen-mustache/src/test/java/com/metaobjects/generator/mustache/HelperRegistryPhysicalNamingTest.java @@ -0,0 +1,98 @@ +package com.metaobjects.generator.mustache; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.metaobjects.config.NeutralConfig; +import com.metaobjects.field.MetaField; +import com.metaobjects.loader.FileSource; +import com.metaobjects.loader.LoaderOptions; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * ADR-0058 — the {@code dbTableName} and {@code dbColumnName} template helpers name what the + * shared physical-naming corpus ({@code fixtures/naming-conformance/physical-names/}) says every + * port's schema has, under each {@code naming} rule, so a Mustache template never spells a table + * or column the database does not have. + */ +public class HelperRegistryPhysicalNamingTest { + + private static final ObjectMapper JSON = new ObjectMapper(); + + private static Path corpusDir() { + Path dir = Paths.get("").toAbsolutePath(); + while (dir != null && !Files.isDirectory(dir.resolve("fixtures"))) dir = dir.getParent(); + if (dir == null) throw new IllegalStateException("could not locate the repository fixtures/ directory"); + return dir.resolve("fixtures/naming-conformance/physical-names"); + } + + private static MetaDataLoader load(String name, JsonNode naming) { + ObjectNode config = JSON.createObjectNode().put("schema_version", 1); + if (naming != null && !naming.isNull()) config.set("naming", naming); + MetaDataLoader loader = new MetaDataLoader( + LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, + "mustache_naming_" + name.replaceAll("[^a-zA-Z0-9]+", "_")); + loader.init(); + loader.load(List.of(new FileSource(corpusDir().resolve("meta.naming.json")))); + if (!loader.getErrors().isEmpty()) fail(name + ": expected a clean load, got: " + loader.getErrors()); + loader.getRoot().setPhysicalNaming(NeutralConfig.parse(config.toString(), name).getPhysicalNaming()); + return loader; + } + + private static MetaObject object(MetaDataLoader loader, String shortName) { + for (MetaObject obj : loader.getMetaObjects()) { + if (shortName.equals(obj.getShortName())) return obj; + } + throw new AssertionError("object '" + shortName + "' not found in meta.naming.json"); + } + + @Test + public void tableAndColumnHelpersFollowEveryCorpusNamingRule() throws IOException { + HelperRegistry helpers = new HelperRegistry(); + JsonNode corpus = JSON.readTree(corpusDir().resolve("cases.json").toFile()); + int checked = 0; + for (JsonNode c : corpus.get("cases")) { + String name = c.get("name").asText(); + MetaDataLoader loader = load(name, c.get("naming")); + for (Iterator> it = c.get("tables").fields(); it.hasNext(); ) { + Map.Entry e = it.next(); + assertEquals(name + ": dbTableName of " + e.getKey(), e.getValue().asText(), + helpers.get("dbTableName").apply(object(loader, e.getKey()))); + checked++; + } + for (Iterator> it = c.get("columns").fields(); it.hasNext(); ) { + Map.Entry e = it.next(); + String[] parts = e.getKey().split("\\.", 2); + MetaField field = object(loader, parts[0]).getMetaField(parts[1]); + assertEquals(name + ": dbColumnName of " + e.getKey(), e.getValue().asText(), + helpers.get("dbColumnName").apply(field)); + checked++; + } + } + assertTrue("the corpus named no tables or columns", checked > 0); + } + + @Test + public void tableHelperCasesAMultiWordEntityByTheTableRule() { + HelperRegistry helpers = new HelperRegistry(); + assertEquals("order_items", + helpers.get("dbTableName").apply(object(load("snake", JSON.createObjectNode()), "OrderItem"))); + assertEquals("OrderItems", + helpers.get("dbTableName").apply(object( + load("literal", JSON.createObjectNode().put("tables", "literal")), "OrderItem"))); + } +} 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/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNamesGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNamesGenerator.java index db2a63beb..a4729117a 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNamesGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNamesGenerator.java @@ -100,17 +100,14 @@ *

Args:

*
    *
  • {@code outputDir} (required): output directory root.
  • - *
  • {@code columnNaming} (optional): the column-naming strategy — one of - * {@link ColumnNaming#LITERAL}, {@link ColumnNaming#SNAKE_CASE}, - * {@link ColumnNaming#KEBAB_CASE}. Defaults to {@link ColumnNaming#DEFAULT} - * (literal) — matching {@code ObjectManagerDB}'s runtime resolution, NOT Kotlin's - * {@code snake_case} codegen default. A Java artifact defaulting differently from - * the Java runtime would itself be the kind of drift this program exists to - * remove. Codegen cannot see a runtime {@code setColumnNaming(...)} call on - * {@code ObjectManagerDB} — a project pairing that call with this generator must - * pass the SAME strategy string to both, by hand (see - * {@code docs/features/field-types.md}).
  • *
+ * + *

{@code columnNaming} is REMOVED (ADR-0058, 1.1.0). Column naming is one + * project setting now — {@code naming.columns} in {@code .metaobjects/config.json}, read + * from the loaded root ({@link com.metaobjects.database.PhysicalNaming#of(MetaData)}) — + * the SAME rule OMDB's {@code SimpleMappingHandlerDB} resolves, so a Java artifact and the + * Java runtime cannot disagree any more. A pom still passing {@code } fails + * the build by name.

*/ public class SpringNamesGenerator extends MultiFileDirectGeneratorBase implements EmitsPhysicalNameConstants { @@ -124,7 +121,14 @@ protected Class getFilterClass() { public void execute(MetaDataLoader loader) { parseArgs(); Path outRoot = Paths.get(outDir.getAbsolutePath()); - String strategy = getArg("columnNaming", ColumnNaming.DEFAULT); + // ADR-0058 — the pom generator arg is REMOVED; refuse by name rather than silently + // ignoring a value that used to work, which would re-point this artifact's columns + // without telling anyone. + if (hasArg("columnNaming")) { + throw new GeneratorException(ColumnNaming.removedConfigurationMessage( + "the columnNaming generator arg in pom.xml", getArg("columnNaming"))); + } + String strategy = com.metaobjects.database.PhysicalNaming.of(loader.getRoot()).columns(); // Pass 1 — every object that participates in the database (#248). Set emitted = new HashSet<>(); for (MetaObject entity : loader.getMetaObjects()) { diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringNamesGeneratorTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringNamesGeneratorTest.java index 1bca109a1..f09f76138 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringNamesGeneratorTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringNamesGeneratorTest.java @@ -101,9 +101,23 @@ public class SpringNamesGeneratorTest extends SharedRegistryTestBase { // ------------------------------------------------------------------------- private String emit(String fixtureJson, String relativeFilePath, Map extraArgs) throws IOException { + return emit(fixtureJson, relativeFilePath, extraArgs, null); + } + + /** + * @param columnsRule ADR-0058 — when non-null, set on the loaded root as the project's + * {@code naming.columns} rule before the generator runs (replaces the removed + * {@code columnNaming} generator arg). + */ + private String emit(String fixtureJson, String relativeFilePath, Map extraArgs, + String columnsRule) throws IOException { Path outDir = tempFolder.newFolder().toPath(); Path workspace = tempFolder.newFolder().toPath(); MetaDataLoader loader = SpringTestFixtures.loadFixture(workspace, "names-fixture", fixtureJson); + if (columnsRule != null) { + loader.getRoot().setPhysicalNaming( + com.metaobjects.database.PhysicalNaming.of(columnsRule, "snake_case")); + } SpringNamesGenerator gen = new SpringNamesGenerator(); Map args = new HashMap<>(extraArgs); @@ -124,6 +138,10 @@ private String emitFor(String entityShortName, Map args) throws return emit(AUTHOR_FIXTURE, "acme/blog/" + entityShortName + "Names.java", args); } + private String emitFor(String entityShortName, String columnsRule) throws IOException { + return emit(AUTHOR_FIXTURE, "acme/blog/" + entityShortName + "Names.java", Map.of(), columnsRule); + } + // ------------------------------------------------------------------------- // Step 1 tests (task-7-brief), plus the corrections/extensions its own text calls for // ------------------------------------------------------------------------- @@ -156,8 +174,9 @@ public void emitsStaticFinalConstantsForTableAndColumns() throws IOException { assertTrue(src, src.contains("public static final String IDENTITY_PRIMARY_SUB_TYPE = \"primary\";")); assertFalse(src, src.contains("IDENTITY_PRIMARY_INDEX")); assertTrue(src, src.contains("public static final String CREATED_AT_FIELD = \"createdAt\";")); - // Java's default strategy is `literal`, matching ObjectManagerDB -- NOT snake_case. - assertTrue(src, src.contains("public static final String CREATED_AT_COLUMN = \"createdAt\";")); + // ADR-0058: the project default is now `snake_case` in every port (what + // `meta migrate` has always created), not Java's former `literal` default. + assertTrue(src, src.contains("public static final String CREATED_AT_COLUMN = \"created_at\";")); assertTrue(src, src.contains("protected AuthorNames() {}")); } @@ -171,21 +190,38 @@ public void anImplicitlyNamedSourceStillResolvesItsPhysicalName() throws IOExcep } @Test - public void theColumnNamingArgIsHonoured() throws IOException { - assertTrue(emitFor("Author", Map.of("columnNaming", "snake_case")) + public void theProjectsNamingColumnsRuleIsHonoured() throws IOException { + // ADR-0058: the rule is read from the loaded root (naming.columns in + // .metaobjects/config.json), never a generator arg. + assertTrue(emitFor("Author", "literal") + .contains("public static final String CREATED_AT_COLUMN = \"createdAt\";")); + assertTrue(emitFor("Author", "snake_case") .contains("public static final String CREATED_AT_COLUMN = \"created_at\";")); } + @Test + public void theColumnNamingGeneratorArgIsRefused() throws IOException { + try { + emitFor("Author", Map.of("columnNaming", "snake_case")); + fail("expected the removed columnNaming generator arg to fail the build"); + } catch (GeneratorException e) { + assertEquals( + com.metaobjects.database.ColumnNaming.removedConfigurationMessage( + "the columnNaming generator arg in pom.xml", "snake_case"), + e.getMessage()); + } + } + @Test public void anExplicitColumnBeatsTheStrategyUnderBothArms() throws IOException { // Neither strategy would independently produce "purpose_code" // (literal -> "callPurpose", snake_case -> "call_purpose") -- the explicit // @column must win under BOTH. - String literal = emitFor("Author"); + String literal = emitFor("Author", "literal"); assertTrue(literal, literal.contains("public static final String CALL_PURPOSE_COLUMN = \"purpose_code\";")); assertFalse(literal, literal.contains("\"call_purpose\"")); - String snake = emitFor("Author", Map.of("columnNaming", "snake_case")); + String snake = emitFor("Author", "snake_case"); assertTrue(snake, snake.contains("public static final String CALL_PURPOSE_COLUMN = \"purpose_code\";")); assertFalse(snake, snake.contains("\"call_purpose\"")); } @@ -539,17 +575,20 @@ public void everyEmittedColumnConstantExistsInTheCanonicalSchema() throws IOExce Path workspace = tempFolder.newFolder().toPath(); MetaDataLoader loader = SpringTestFixtures.loadFixture(workspace, "fitness", canonicalMeta); - SpringNamesGenerator gen = new SpringNamesGenerator(); - Map args = new HashMap<>(); - args.put("outputDir", outDir.toString()); // The canonical schema is pinned to the `literal` strategy -- see // server/typescript/packages/integration-tests/src/canonical-schema.ts's // CANONICAL_COLUMN_NAMING and fixtures/persistence-conformance/README.md's - // "Result format" section, which both say so outright. That is ALSO Java's - // own codegen default (see SpringNamesGenerator's javadoc), but it is passed - // explicitly here rather than left implicit, so this test keeps pinning the - // resolver against real DDL even if that default ever changes. - args.put("columnNaming", "literal"); + // "Result format" section, which both say so outright, and (ADR-0058) the + // corpus's own committed fixtures/persistence-conformance/.metaobjects/ + // config.json declares it: {"naming": {"columns": "literal"}}. Read through the + // SAME NeutralConfig reader every port uses, and set on the loaded root — never + // a generator arg (removed). + loader.getRoot().setPhysicalNaming( + com.metaobjects.config.SourceResolver.resolvePhysicalNaming(corpusRoot)); + + SpringNamesGenerator gen = new SpringNamesGenerator(); + Map args = new HashMap<>(); + args.put("outputDir", outDir.toString()); gen.setArgs(args); gen.execute(loader); @@ -608,11 +647,15 @@ public void anObjectWithMoreThanTenFieldsCompiles() throws Exception { Path outDir = tempFolder.newFolder().toPath(); Path workspace = tempFolder.newFolder().toPath(); MetaDataLoader loader = SpringTestFixtures.loadFixture(workspace, "fitness-compile", canonicalMeta); + // ADR-0058: the corpus's committed naming.columns: literal (see + // everyEmittedColumnConstantExistsInTheCanonicalSchema above) — read from the + // root, never a generator arg. + loader.getRoot().setPhysicalNaming( + com.metaobjects.config.SourceResolver.resolvePhysicalNaming(corpusRoot)); SpringNamesGenerator gen = new SpringNamesGenerator(); Map args = new HashMap<>(); args.put("outputDir", outDir.toString()); - args.put("columnNaming", "literal"); gen.setArgs(args); gen.execute(loader); diff --git a/server/java/core-spring/src/main/java/com/metaobjects/spring/MetaDataLoaderConfiguration.java b/server/java/core-spring/src/main/java/com/metaobjects/spring/MetaDataLoaderConfiguration.java index 41a16c764..87cd1930d 100644 --- a/server/java/core-spring/src/main/java/com/metaobjects/spring/MetaDataLoaderConfiguration.java +++ b/server/java/core-spring/src/main/java/com/metaobjects/spring/MetaDataLoaderConfiguration.java @@ -1,5 +1,6 @@ package com.metaobjects.spring; +import com.metaobjects.config.NeutralConfig; import com.metaobjects.loader.LoaderOptions; import com.metaobjects.loader.MetaDataLoader; @@ -8,9 +9,13 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; +import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import java.io.IOException; +import java.io.InputStream; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -30,6 +35,9 @@ * - "file:/path/to/external-metadata.xml" * auto-attrs: true * strict: false + * # ADR-0058 — this classpath runtime has no project directory, so give it the + * # project's .metaobjects/config.json directly (a FILE location, never a bare rule). + * physical-naming-config: "classpath:.metaobjects/config.json" * } * *

Or use @Bean methods directly:

@@ -56,11 +64,24 @@ public class MetaDataLoaderConfiguration { private boolean autoAttrs = true; private boolean strict = false; private boolean verbose = false; - + + /** + * ADR-0058 — where to read the project's physical-naming rules from, for a runtime + * that loads metadata off the classpath and so has no project directory for + * {@code SourceResolver.resolvePhysicalNaming(Path)} to resolve against. A Spring + * resource location pointing at the {@code .metaobjects/config.json} FILE itself + * (never a directory) — e.g. {@code classpath:.metaobjects/config.json} or + * {@code file:/opt/app/.metaobjects/config.json}. Parsed with {@link NeutralConfig}; + * never a bare rule value ({@code naming.columns}/{@code naming.tables}) configured + * here directly — ADR-0058 removed every such setter. Absent means the default + * ({@code snake_case}/{@code snake_case}), same as a project with no config file. + */ + private String physicalNamingConfig; + /** * Creates MetaDataLoader from application properties. - * - *

This bean will be automatically discovered and registered by + * + *

This bean will be automatically discovered and registered by * {@link MetaDataAutoConfiguration}.

*/ @Bean @@ -70,12 +91,12 @@ public MetaDataLoader applicationMetaDataLoader() throws Exception { // Default: look for metadata files in classpath metadataSources = discoverDefaultMetadataSources(); } - + if (metadataSources.isEmpty()) { // No metadata sources configured - return null so it's optional return null; } - + // Convert string sources to URIs List sourceURIs = new ArrayList<>(); for (String source : metadataSources) { @@ -86,7 +107,27 @@ public MetaDataLoader applicationMetaDataLoader() throws Exception { // Preserve the pre-unification strict=true default (the no-options // fromUris overload routes through createManual which uses strict=false). LoaderOptions opts = LoaderOptions.create(false, false, true); - return MetaDataLoader.fromUris("applicationLoader", sourceURIs, opts); + MetaDataLoader loader = MetaDataLoader.fromUris("applicationLoader", sourceURIs, opts); + applyPhysicalNaming(loader); + return loader; + } + + /** + * ADR-0058 — reads {@link #physicalNamingConfig} (when set) and sets the project's + * physical-naming rules on the loaded root, so this classpath-runtime loader resolves + * the SAME rule codegen resolved against the project directory. A caller that never + * sets {@code metaobjects.physical-naming-config} gets the default, exactly like a + * project directory with no {@code .metaobjects/config.json}. + */ + private void applyPhysicalNaming(MetaDataLoader loader) throws IOException { + if (physicalNamingConfig == null || physicalNamingConfig.isBlank()) return; + Resource resource = new DefaultResourceLoader().getResource(physicalNamingConfig); + String content; + try (InputStream in = resource.getInputStream()) { + content = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + NeutralConfig cfg = NeutralConfig.parse(content, physicalNamingConfig); + loader.getRoot().setPhysicalNaming(cfg.getPhysicalNaming()); } /** @@ -151,4 +192,7 @@ private List discoverDefaultMetadataSources() { public boolean isVerbose() { return verbose; } public void setVerbose(boolean verbose) { this.verbose = verbose; } + + public String getPhysicalNamingConfig() { return physicalNamingConfig; } + public void setPhysicalNamingConfig(String physicalNamingConfig) { this.physicalNamingConfig = physicalNamingConfig; } } \ No newline at end of file diff --git a/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderConfigurationPhysicalNamingTest.java b/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderConfigurationPhysicalNamingTest.java new file mode 100644 index 000000000..b1e6adfd0 --- /dev/null +++ b/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderConfigurationPhysicalNamingTest.java @@ -0,0 +1,60 @@ +package com.metaobjects.spring; + +import com.metaobjects.database.PhysicalNaming; +import com.metaobjects.loader.MetaDataLoader; +import org.junit.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ADR-0058 — a runtime that loads metadata off the classpath has no project directory for + * {@code SourceResolver.resolvePhysicalNaming(Path)} to resolve against, so + * {@link MetaDataLoaderConfiguration} takes a Spring resource location naming the project's + * {@code .metaobjects/config.json} FILE directly ({@code metaobjects.physical-naming-config}), + * parses it with {@link com.metaobjects.config.NeutralConfig}, and sets the rules on the + * loaded root — never a bare rule value. + */ +public class MetaDataLoaderConfigurationPhysicalNamingTest { + + // ConfigurationPropertiesAutoConfiguration must be explicit: ApplicationContextRunner's + // AutoConfigurations.of(...) activates ONLY the listed classes, not Spring Boot's full + // auto-configuration chain -- without it @ConfigurationProperties binding never runs + // and every property silently reads as unset (metadataSources stays empty too, masked + // in the sibling MetaDataLoaderAutoConfigurationTest by its classpath-discovery + // fallback matching the same resource by coincidence). + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + MetaDataAutoConfiguration.class, ConfigurationPropertiesAutoConfiguration.class)); + + // metadataSources is left UNSET on both runs here (matches + // MetaDataLoaderAutoConfigurationTest's classpath-discovery path, + // discoverDefaultMetadataSources(), which resolves to proper `file:` URIs via Spring's + // own Resource abstraction): explicitly naming `metaobjects.metadata-sources` as a bare + // "classpath:" string hits a SEPARATE, pre-existing bug in this example bean -- the + // property is handed straight to `URI.create(...)`, which has no "classpath:" scheme + // handler, unlike `physicalNamingConfig` below, which this feature resolves through + // Spring's `DefaultResourceLoader` correctly. That bug is orthogonal to ADR-0058 and + // out of scope here. + + @Test + public void physicalNamingConfigPropertySetsTheRootsRules() { + runner.withPropertyValues("metaobjects.physical-naming-config=classpath:naming-config/config.json") + .run(ctx -> { + MetaDataLoader primary = ctx.getBean(MetaDataLoader.class); + assertThat(primary.getRoot().getPhysicalNaming().columns()) + .isEqualTo(PhysicalNaming.of("literal", "snake_case").columns()); + }); + } + + @Test + public void withNoPhysicalNamingConfigPropertyTheRootKeepsTheDefault() { + runner.run(ctx -> { + MetaDataLoader primary = ctx.getBean(MetaDataLoader.class); + assertThat(primary.getRoot().getPhysicalNaming().columns()) + .isEqualTo(PhysicalNaming.DEFAULT.columns()); + }); + } +} diff --git a/server/java/core-spring/src/test/resources/naming-config/config.json b/server/java/core-spring/src/test/resources/naming-config/config.json new file mode 100644 index 000000000..1e4b8166e --- /dev/null +++ b/server/java/core-spring/src/test/resources/naming-config/config.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "naming": { "columns": "literal" } +} 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 +