From e2c21260a34b7a3728513394720209c263a5abfc Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Fri, 23 Oct 2026 23:42:46 -0400 Subject: [PATCH 1/5] fix: first-touch surfaces for the 1.1.0 launch (zod range, quickstarts, README, CI wording) - sdk: zod ^4.6.6 (a fresh npm i of the CLI no longer reports advisories); config read on the zod 4 API - docs/ports: Java, Kotlin and C# quickstarts work as written against the published packages; 1.1.0 / 8.1.0 stamped - C#: verify --templates fails; bare verify uses the default prompt root; routes no longer warn CS8600 - Maven: stale-code advice names the generate command that runs for the module's configuration - README quick start; CONTRIBUTING says one thing about CI --- CHANGELOG.md | 33 +++++ CONTRIBUTING.md | 8 +- README.md | 25 +++- bun.lock | 4 +- docs/ports/csharp.md | 111 ++++++++++----- docs/ports/java.md | 131 ++++++++++++------ docs/ports/kotlin.md | 76 +++++++++- docs/ports/python.md | 2 +- docs/ports/typescript.md | 2 +- .../.metaobjects/.gen-state/.hashes.json | 2 +- .../generated/csharp/SubscriberRoutes.g.cs | 2 +- .../MetaObjects.Cli.Tests/CliSurfaceTests.cs | 37 +++++ server/csharp/MetaObjects.Cli/Program.cs | 25 ++-- .../EntityGeneratorTests.cs | 2 +- .../MetaObjects.Codegen/CodegenDrift.cs | 2 +- .../Generators/RoutesGenerator.cs | 2 +- .../metaobjects/mojo/MetaDataVerifyMojo.java | 31 ++++- .../mojo/MetaDataVerifyMojoManifestTest.java | 35 +++++ server/typescript/packages/sdk/package.json | 2 +- server/typescript/packages/sdk/src/config.ts | 15 +- 20 files changed, 428 insertions(+), 119 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49302bb73..98d4100d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,39 @@ here.** ## [Unreleased] +### Fixed + +- **A fresh `npm i @metaobjectsdev/cli` no longer reports high-severity advisories.** + `@metaobjectsdev/sdk` declared `zod` `^3.23.0`, which resolves to a release the zod advisory + GHSA-mrhx-c7xc-m3w5 covers (fixed in 4.6.6), and npm installed it beside the `zod` 4 the CLI + already used. The sdk now declares `^4.6.6` and reads its config with the zod 4 API; the + config it accepts and the messages it prints are unchanged. +- **C#: `dotnet meta verify --templates ` fails when `` does not exist.** It exited 0 + with "OK", since nothing in an empty root can drift. It now exits 2 and names the directory. + A bare `dotnet meta verify ` runs the templates gate its help describes, reading + `prompts/` when that directory exists and `templates/` otherwise, where it used to exit 2 asking + for a root. The generated-code gate is `verify --codegen --out `, as before. +- **C#: the generated create route no longer warns CS8600.** `Author input;` is now `Author? input;`. + The next regeneration changes that one line in every `*Routes.g.cs`. +- **C#: the drift message says `gen` needs `--out`.** "Run 'dotnet meta gen'" on its own exits 2. +- **Java and Kotlin: the stale-code message names a command that runs.** When the generate + configuration sits inside an ``, `mvn metaobjects:verify` now suggests + `mvn metaobjects:generate@`; a bare `mvn metaobjects:generate` fails there with "No + element was defined". + +### Documentation + +- The Java, Kotlin and C# quickstarts in `docs/ports/` work as written on a clean machine against + the published packages: the Java and Kotlin poms carry a Java level, the dependencies the + generated code compiles against and a plugin-level `` (Kotlin also its + `` and `kotlin-maven-plugin`), and the C# page fills the generator list before the + first `gen`, gives the app project, serializes enums as strings, marks fields `@filterable` and + ends in the real drift gate. +- The README opens with a copy-paste quick start, and its Languages table, the port pages and the + Python floor name 1.1.0 / 8.1.0. +- `CONTRIBUTING.md` says one thing about CI: GitHub Actions runs on pull requests, the same + checks as `scripts/ci-local.sh`. + ### Changed - **Python: `metaobjects gen` and `verify --codegen` run ruff as its own binary.** Every emitted diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b7cd3e79..67f907d07 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,10 +25,10 @@ conformance corpora. This guide covers how to propose changes. 1. **Fork** the repo and create a branch from `main`. 2. Make your change following the discipline below — **tests first (TDD)**. 3. Run the gates locally with **`scripts/ci-local.sh --quick`** and make sure it's - green. **This matters:** nothing in `.github/workflows/` runs any more — see the - *Local CI* section below. + green. **This matters:** GitHub Actions runs the same checks on your pull request, + so a red local run is a red PR — see the *Local CI* section below. 4. Open a **pull request** against `main` and fill out the PR template. -5. A maintainer runs the full gates locally and approves before merge. +5. A maintainer reviews the change and the checks on the pull request before merge. ## The cardinal rule: the metamodel is the spine @@ -71,7 +71,7 @@ cd server/python && pytest # Python (in its .venv) Cross-language persistence / api-contract corpora (Docker + Testcontainers) run via `scripts/integration-test.sh`. -### Local CI (this IS the CI — run it before opening/merging a PR) +### Local CI (the same checks the pull request runs — run it first) GitHub Actions is enabled on this repository. The three check workflows in `.github/workflows/` run on GitHub as thin wrappers over `scripts/ci-local.sh`, diff --git a/README.md b/README.md index 8c7659fa3..9fa97265f 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,19 @@ at something real, not that the something is correct. > **Scope.** It protects what the model declares; your hand-written logic is still > yours. +## Quick start + +```bash +npm i @metaobjectsdev/cli && npx meta init # scaffold metaobjects/, the config and agent skills +# declare an entity in metaobjects/ (a 10-line model: metaobjects.dev/getting-started) +npx meta gen --list --probe --format text # what your model can generate +npx meta eject entity queries routes names barrel # copy the generators you chose into your repo +npx meta gen && npx meta verify # generate, then fail if anything has drifted +``` + +Java, Kotlin, C# and Python start the same way from their own package: see +[`docs/ports/`](docs/ports/). + The metamodel is the **durable spine**; generated code is the **disposable artifact**. Substrate is local-first: typed metadata lives in your repo as YAML or JSON you own. The entity/model tier of the output is dependency-free, and the optional @@ -133,11 +146,11 @@ first-week wedge plan — and `meta init` picks up from there. | Language | Status | Quickstart | Source | |---|---|---|---| -| TypeScript | **npm `1.0.13`** — the `@metaobjectsdev/*` packages | [`docs/ports/typescript.md`](docs/ports/typescript.md) | [`server/typescript/`](server/typescript/) · [`client/web/`](client/web/) | -| Java | **Maven Central `8.0.13`** (`com.metaobjects:*` — the JVM major is npm major + 7) — loader + OMDB + render + Maven plugin all shipped; full conformance green | [`docs/ports/java.md`](docs/ports/java.md) | [`server/java/`](server/java/) | -| Kotlin | **Maven Central `8.0.13`** — codegen tier on top of Java: 16 generators (entity, Exposed table, names, relations, repository, output-parser, output-prompt, render-helper, extractor, filter-allowlist, validator, Spring config, storedProc, Spring controller, requirement-tests, api-docs); 24 / 24 persistence-conformance | [`docs/ports/kotlin.md`](docs/ports/kotlin.md) | [`server/java/codegen-kotlin/`](server/java/codegen-kotlin/) · [`server/java/metadata-ktx/`](server/java/metadata-ktx/) | -| C# | **NuGet `1.0.13`** — loader + conformance + EF Core codegen + render engine + `dotnet meta` CLI all shipped | [`docs/ports/csharp.md`](docs/ports/csharp.md) | [`server/csharp/`](server/csharp/) | -| Python | **PyPI `1.0.13`** — loader + conformance + render + entity-model codegen + ObjectManager runtime shipped; schema migrations are TS-owned (ADR-0015) | [`docs/ports/python.md`](docs/ports/python.md) | [`server/python/`](server/python/) | +| TypeScript | **npm `1.1.0`** — the `@metaobjectsdev/*` packages | [`docs/ports/typescript.md`](docs/ports/typescript.md) | [`server/typescript/`](server/typescript/) · [`client/web/`](client/web/) | +| Java | **Maven Central `8.1.0`** (`com.metaobjects:*` — the JVM major is npm major + 7) — loader + OMDB + render + Maven plugin all shipped; full conformance green | [`docs/ports/java.md`](docs/ports/java.md) | [`server/java/`](server/java/) | +| Kotlin | **Maven Central `8.1.0`** — codegen tier on top of Java: 16 generators (entity, Exposed table, names, relations, repository, output-parser, output-prompt, render-helper, extractor, filter-allowlist, validator, Spring config, storedProc, Spring controller, requirement-tests, api-docs); 24 / 24 persistence-conformance | [`docs/ports/kotlin.md`](docs/ports/kotlin.md) | [`server/java/codegen-kotlin/`](server/java/codegen-kotlin/) · [`server/java/metadata-ktx/`](server/java/metadata-ktx/) | +| C# | **NuGet `1.1.0`** — loader + conformance + EF Core codegen + render engine + `dotnet meta` CLI all shipped | [`docs/ports/csharp.md`](docs/ports/csharp.md) | [`server/csharp/`](server/csharp/) | +| Python | **PyPI `1.1.0`** — loader + conformance + render + entity-model codegen + ObjectManager runtime shipped; schema migrations are TS-owned (ADR-0015) | [`docs/ports/python.md`](docs/ports/python.md) | [`server/python/`](server/python/) | ## Capability matrix @@ -308,7 +321,7 @@ metaobjects/ |---|---| | TypeScript | `npm i @metaobjectsdev/cli && npx meta init` → [`docs/ports/typescript.md`](docs/ports/typescript.md) | | Java | Add `metaobjects-maven-plugin` to your `pom.xml` → [`docs/ports/java.md`](docs/ports/java.md) | -| Kotlin | Add `metaobjects-codegen-kotlin` + `metaobjects-metadata-ktx` → [`docs/ports/kotlin.md`](docs/ports/kotlin.md) | +| Kotlin | Add `metaobjects-metadata-ktx` and the Maven plugin (it bundles the Kotlin generators) → [`docs/ports/kotlin.md`](docs/ports/kotlin.md) | | C# | `dotnet tool install --global MetaObjects.Cli` → [`docs/ports/csharp.md`](docs/ports/csharp.md) | | Python | `pip install metaobjects` → [`docs/ports/python.md`](docs/ports/python.md) | diff --git a/bun.lock b/bun.lock index 011a33813..c82f169af 100644 --- a/bun.lock +++ b/bun.lock @@ -419,7 +419,7 @@ "version": "1.1.0", "dependencies": { "@metaobjectsdev/metadata": "workspace:*", - "zod": "^3.23.0", + "zod": "^4.6.6", }, "devDependencies": { "bun-types": "latest", @@ -1576,8 +1576,6 @@ "@metaobjectsdev/runtime-ts/@libsql/client": ["@libsql/client@0.15.15", "", { "dependencies": { "@libsql/core": "^0.15.14", "@libsql/hrana-client": "^0.7.0", "js-base64": "^3.7.5", "libsql": "^0.5.22", "promise-limit": "^2.7.0" } }, "sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w=="], - "@metaobjectsdev/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@stryker-mutator/instrumenter/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index 0781ebb0e..e9de27d48 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -6,24 +6,40 @@ Postgres + Npgsql. ## Install -Published to [NuGet](https://www.nuget.org/packages/MetaObjects) at `1.0.13` — four -packages, version-locked to the C# port version: +Published to [NuGet](https://www.nuget.org/packages/MetaObjects) at `1.1.0` — four +packages, version-locked to the C# port version: `MetaObjects` (loader), `MetaObjects.Codegen`, +`MetaObjects.Render` and the `MetaObjects.Cli` tool. -```xml - - - - - - -``` - -Install the CLI as a .NET tool: +Install the CLI as a .NET tool, then scaffold the generator list: ```bash dotnet tool install --global MetaObjects.Cli dotnet meta # bare invocation prints the usage banner -dotnet meta init # writes codegen/Generators.cs (the generator list), Program.cs, Codegen.csproj +dotnet meta init # writes codegen/Generators.cs (the generator list), codegen/Program.cs, codegen/Codegen.csproj +``` + +Your app is a separate project beside `codegen/`. `dotnet meta init` runs from the root of your +repository, and the app's `.csproj` at that root must not compile `codegen/`, which has its own +`Main` (without the `Compile Remove` the build fails with CS0579 and CS8802). This sample uses +SQLite so it runs with nothing else installed; the same generated code runs on Postgres (see +[Use](#use)): + +```xml + + + + net8.0 + enable + enable + + + + + + + + + ``` The C# CLI is invoked as `dotnet meta` (command `dotnet-meta`), or run directly @@ -33,10 +49,9 @@ canonical Node `meta` CLI, which owns schema + TS codegen (ADR-0015). ## Configure -Drop metadata under `metadata/`: +Drop metadata under `metaobjects/`, for example `metaobjects/meta.blog.json`: -```jsonc -// metadata/meta.blog.json +```json { "metadata.root": { "package": "acme::blog", "children": [ @@ -45,8 +60,9 @@ Drop metadata under `metadata/`: "children": [ { "source.rdb": { "@table": "authors" } }, { "field.long": { "name": "id" } }, - { "field.string": { "name": "name", "@required": true, "@maxLength": 200 } }, + { "field.string": { "name": "name", "@required": true, "@maxLength": 200, "@filterable": true } }, { "field.string": { "name": "bio", "@maxLength": 2000 } }, + { "field.enum": { "name": "role", "@values": ["writer", "editor"], "@required": true, "@filterable": true } }, { "identity.primary": { "@fields": "id", "@generation": "increment" } } ] }} @@ -68,7 +84,7 @@ var registry = Provider.ComposeRegistry(new IMetaDataTypeProvider[] { yourProvider, // adds your custom subtype/attrs }); -var loader = MetaDataLoader.FromDirectory("./metadata", registry); +var loader = MetaDataLoader.FromDirectory("./metaobjects", registry); ``` The provider object has the same four-member contract (`Id`, `Dependencies`, @@ -111,14 +127,24 @@ using MetaObjects.Codegen; return CodegenCli.Run(args, Generators.All); ``` +Replace the empty list `dotnet meta init` wrote with the one above before the first `gen`: with +an empty list `gen` writes nothing, and `--out` is required. + ```bash # Run the list: EF Core entities + AppDbContext + CRUD minimal-API routes -dotnet meta gen ./metadata --out ./Generated --namespace Acme.Blog +dotnet meta gen ./metaobjects --out ./Generated --namespace Acme.Blog -# Drift-check templates against payloads (FR-004) -dotnet meta verify ./metadata --templates ./prompts +# The drift gate: regenerate into a temp tree and fail (exit 1, naming the files) if the +# committed ./Generated differs. Run it in CI; the namespace is inferred from ./Generated. +dotnet meta verify ./metaobjects --codegen --out ./Generated ``` +`verify --codegen` is the gate for generated code. `dotnet meta verify ./metaobjects +--templates ./prompts` is a different gate, for prompt templates against their payloads +(FR-004): it says nothing about generated code, and it fails with exit 2 when the directory it +is given does not exist. A bare `dotnet meta verify ./metaobjects` runs that templates gate +against `prompts/` when it exists, else `templates/`; pass both subverbs to run both. + `dotnet meta gen --list` is the catalog, and it shows what each generator requires. `routes` requires `entity`, `db-context` and `filter-allowlist`, because the handlers it emits reference their output; `gen` warns when one is missing from the list, since the result @@ -308,19 +334,38 @@ regenerate. See the "Column naming" section of ```csharp // Program.cs +using System.Text.Json.Serialization; using Acme.Blog; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddDbContext(opts => - opts.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddDbContext(opts => opts.UseSqlite("Data Source=blog.db")); +// Enum fields (role) travel as their symbols ("writer"), not integers: the routes return the +// entity and the host owns serialization, so without this POST {"role":"writer"} is a 400. +builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); var app = builder.Build(); +using (var scope = app.Services.CreateScope()) + scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); // the sample creates its own table -app.MapAuthorRoutes(); // generated — GET/POST/PUT/DELETE on /api/authors +app.MapAuthorRoutes(); // generated — GET/POST/PATCH/PUT/DELETE on /api/authors app.Run(); ``` +```bash +dotnet run --urls http://127.0.0.1:5077 +curl -X POST -H 'content-type: application/json' -d '{"name":"Ada","role":"writer"}' http://127.0.0.1:5077/api/authors # 201 +curl -g 'http://127.0.0.1:5077/api/authors?filter[role][eq]=writer' # 200, the row +``` + +A filter works on a field marked `@filterable` (`name` and `role` above); any other field answers +400 `invalid_filter_field`. + +**Postgres.** Swap `UseSqlite(...)` for `UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))` +and add `Npgsql.EntityFrameworkCore.PostgreSQL` instead of the SQLite package. `EnsureCreated` +is for samples; on Postgres create the tables with the Node CLI's migrations +(`meta migrate`, ADR-0015) — the C# port has no migrate surface. + EF Core does the rest — the generated entities and `AppDbContext` are plain EF Core with no MetaObjects types in them. Note the one exception: generated **routes** emit `using MetaObjects.Codegen.Runtime;` for the shared filter/sort helpers, so a project that @@ -343,10 +388,10 @@ or per-file `DIFFERS`, `verify --codegen` does not treat it as drift, and has the diff recipe for pulling an upstream fix into it. **Consumer dependencies.** The generated `AppDbContext` and the `Program.cs` -wiring above use EF Core (`AddDbContext`, `DbContext`, `UseNpgsql`), which -MetaObjects does not pull in for you — add the two EF Core NuGet packages to -the consuming app: `Microsoft.EntityFrameworkCore` and -`Npgsql.EntityFrameworkCore.PostgreSQL`. +wiring above use EF Core (`AddDbContext`, `DbContext`, `UseSqlite`), which +MetaObjects does not pull in for you — add the EF Core provider package for your +database to the consuming app (`Microsoft.EntityFrameworkCore.Sqlite` above, +`Npgsql.EntityFrameworkCore.PostgreSQL` for Postgres). ```csharp // Optional handwritten service over the generated DbContext @@ -465,14 +510,14 @@ contract: `dotnet meta eject requirement-tests` copies it to are core and are not ejectable. ```bash -# The gate: runs beside whichever drift gate verify was given -dotnet meta verify ./metadata --templates ./prompts -dotnet meta verify ./metadata --templates ./prompts --require-implementers +# The gate: runs on every verify, beside whichever drift gate it was given +dotnet meta verify ./metaobjects --codegen --out ./Generated +dotnet meta verify ./metaobjects --codegen --out ./Generated --require-implementers # The tests: the same run as everything else codegen/Generators.cs declares. The entry's # OutDir puts them in the test project, which references xunit; the app's --out does not. -dotnet meta gen ./metadata --out ./Generated -dotnet meta verify ./metadata --codegen --out ./Generated +dotnet meta gen ./metaobjects --out ./Generated +dotnet meta verify ./metaobjects --codegen --out ./Generated ``` Give the entry an `OutDir` in `codegen/Generators.cs` (below): the tests need xunit, which diff --git a/docs/ports/java.md b/docs/ports/java.md index 046b9b910..f64b45188 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -12,13 +12,18 @@ Prompt / template drift is still checked via the `metaobjects-render` `Verify` A ## Install -Set `${metaobjects.version}` to the current Maven Central release (`8.0.13`) — both -the dependency and plugin blocks below resolve it from one `` entry: +Set `${metaobjects.version}` to the current Maven Central release (`8.1.0`) — both +the dependency and plugin blocks below resolve it from one `` entry. The +generated records need Java 17 or newer, so set the compiler level here too (Maven +defaults to Java 8, where `mvn compile` fails with "records are not supported in -source 8"): ```xml - 8.0.13 + 8.1.0 + 3.3.5 + 21 + UTF-8 @@ -37,6 +42,23 @@ the dependency and plugin blocks below resolve it from one `` entry: metaobjects-render ${metaobjects.version} + + + com.metaobjects + metaobjects-codegen-spring + ${metaobjects.version} + + + org.springframework.boot + spring-boot-starter-web + ${spring-boot.version} + + + org.springframework.boot + spring-boot-starter-validation + ${spring-boot.version} + ``` @@ -44,6 +66,15 @@ For Spring integration: add `metaobjects-core-spring`. ## Configure +Keep the `` at plugin level, as below, not inside the ``: the +execution binds the goal to `generate-sources`, and the plugin-level configuration is what a +bare `mvn metaobjects:generate` or `mvn metaobjects:verify` from the command line reads. +Inside the execution those fail with "No element was defined", and only +`mvn metaobjects:verify@generate` works. + +These two blocks go in your `pom.xml` beside your own ``, `` and +``. + ```xml @@ -51,48 +82,48 @@ For Spring integration: add `metaobjects-core-spring`. com.metaobjects metaobjects-maven-plugin ${metaobjects.version} + + + src/main/metaobjects + + + + com.metaobjects.generator.spring.SpringDtoGenerator + + ${project.build.directory}/generated-sources/java + + + + com.metaobjects.generator.spring.SpringControllerGenerator + + ${project.build.directory}/generated-sources/java + + + + com.metaobjects.generator.spring.SpringRepositoryGenerator + + ${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 + + + + generate generate-sources generate - - - src/main/metaobjects - - - - com.metaobjects.generator.spring.SpringDtoGenerator - - ${project.build.directory}/generated-sources/java - - - - com.metaobjects.generator.spring.SpringControllerGenerator - - ${project.build.directory}/generated-sources/java - - - - com.metaobjects.generator.spring.SpringRepositoryGenerator - - ${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 - - - - @@ -100,10 +131,9 @@ For Spring integration: add `metaobjects-core-spring`. ``` -Drop metadata under `src/main/metaobjects/`: +Drop metadata under `src/main/metaobjects/`, for example `src/main/metaobjects/meta.blog.json`: -```jsonc -// src/main/metaobjects/meta.blog.json +```json { "metadata.root": { "package": "acme::blog", "children": [ @@ -172,8 +202,23 @@ internal/test partial sets. The cross-port contract lives in ```bash mvn compile # runs the generate goal (bound to generate-sources) +mvn metaobjects:generate # or run it alone +``` + +The generators write the DTO records, the Spring controller, the repository and the filter +allowlist under `target/generated-sources/java`, which Maven compiles with your code. + +## Verify + +```bash +mvn metaobjects:verify # fails with the stale files named when the model moved and nobody regenerated ``` +It regenerates into a temporary directory and compares: change `src/main/metaobjects/` without +regenerating and the build exits 1 with `generated code is stale` and a `[content-differs]` line +per file; `mvn metaobjects:generate` (or `mvn compile`) and it is green again. The same goal also +runs the requirement gate. Add it to your CI after `mvn compile`. + Schema migrations are not a Java-port concern — author them with the TypeScript toolchain (`@metaobjectsdev/cli migrate`), then apply the resulting DDL to the database OMDB connects to. OMDB itself is pure data-access; the former runtime diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md index 96d3cd7bb..fab7bd9a8 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -15,9 +15,42 @@ Two modules: ## Install +Every version below comes from one `` entry. Set the Java level (Maven defaults to +Java 8, which the generated code does not target) and give the Kotlin compiler a `kotlin-stdlib`. The generated code also +imports Spring (`@Configuration`, `ApplicationReadyEvent`) and Jakarta Validation +(`@NotNull`, `@Size`), so `spring-boot-starter` and `spring-boot-starter-validation` are +dependencies of the generated code, not just of your app. Put these in your `pom.xml` beside +your own ``, `` and ``: + ```xml + + 8.1.0 + 2.0.21 + 3.3.5 + 0.55.0 + 2.17.2 + 1.7.3 + 21 + UTF-8 + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + org.springframework.boot + spring-boot-starter + ${spring-boot.version} + + + org.springframework.boot + spring-boot-starter-validation + ${spring-boot.version} + com.metaobjects metaobjects-metadata @@ -109,9 +142,16 @@ The 16 generators registered in `codegen-kotlin` (`GeneratorRegistry.kt`): | `KotlinRequirementTestsGenerator` | Per metamodel package that holds a tested requirement, two files in `testPackage`: `Requirements__Witnesses.kt` (an interface whose default members fail with `unimplemented requirement: ...`) and `Requirements__Test.kt` (one JUnit Jupiter `@Test` per requirement, calling a project-owned witness). See "Requirement tests" below | every tested `requirement.*` (functional, level 4 or above by default) | | `KotlinApiDocsGenerator` | The Kotlin SDK api reference, under `/`: `.md` per entity, value object, projection, served report and template, a `README.md` index and `AGENT-API.md`. A `` entry like any other, run by `metaobjects:generate` and checked by `metaobjects:verify`. Args: `outputDir` (required, generated files only), `apiSubDir` (default `api/kotlin`), `layout` (`flat` or `package`), `projectName` and `modelBaseUrl`, as for the Java `api-docs`. The `metaobjects:docs` goal was removed in 8.1.0 ([migration](../features/migrations/docs-are-generators.md#java-and-kotlin)). Ejectable, with the page model, builder, renderer and path math | the whole model, once | -Maven wiring: +Maven wiring. The plugin bundles `metaobjects-codegen-kotlin`, so it is not a dependency of your +project. Keep `` at plugin level and bind the goal in ``: without the +`` block `mvn compile` generates nothing and still exits 0, and with the configuration +inside the execution a bare `mvn metaobjects:generate` or `mvn metaobjects:verify` fails with +"No element was defined". The `kotlin-maven-plugin` that follows compiles what the first +plugin generated; it has to be told about `generated-sources/kotlin` and your own `src/main/kotlin`. ```xml + + com.metaobjects metaobjects-maven-plugin @@ -150,9 +190,39 @@ Maven wiring: + + + generate + generate-sources + generate + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + compile + compile + + + ${project.basedir}/src/main/kotlin + ${project.build.directory}/generated-sources/kotlin + + + + + + + ``` +The `KotlinSpringConfigGenerator` reads its model from the classpath (``), so the +same file goes under `src/main/resources/` as well as `src/main/metaobjects/`. + **Requirement tests.** `mvn metaobjects:verify` checks the model's `requirement.*` nodes on every run (the Java goal; Kotlin has no CLI of its own), and `-Dmeta.verify.requireImplementers=true` or `META_REQUIRE_IMPLEMENTERS=1` also fails the build on a live functional requirement that nothing implements. Those checks are core and are not ejectable. `KotlinRequirementTestsGenerator` is the other half: a reference helper and a recommended approach, not a contract. It is the Kotlin sibling of the Java `JUnitRequirementTestsGenerator` (the same args, the same test identities, one conformance corpus for both). It writes one JUnit Jupiter test per tested requirement; the test calls a **witness**, a function you write, and a live requirement with no witness fails. A JUnit 4 project replaces the text through the `renderer` arg, or ejects the generator. ```xml @@ -352,7 +422,9 @@ reactor's 2.0.21; its round-trip test additionally needs docker. Run via ## Generate ```bash -mvn compile # runs the codegen as part of generate-sources +mvn compile # runs the codegen as part of generate-sources, then compiles it +mvn metaobjects:generate # or run the codegen alone +mvn metaobjects:verify # fails, naming the files, when generated code is stale against the model ``` Schema migrations are owned by the TypeScript toolchain — see the diff --git a/docs/ports/python.md b/docs/ports/python.md index 4b8a89d17..86f16c9a4 100644 --- a/docs/ports/python.md +++ b/docs/ports/python.md @@ -26,7 +26,7 @@ pip install metaobjects # or: uv add metaobjects [project] requires-python = ">=3.11" dependencies = [ - "metaobjects>=0.19", + "metaobjects>=1.1", ] ``` diff --git a/docs/ports/typescript.md b/docs/ports/typescript.md index 0afef639f..f5db3e0e0 100644 --- a/docs/ports/typescript.md +++ b/docs/ports/typescript.md @@ -1,6 +1,6 @@ # TypeScript port -The reference implementation. Published to npm at `1.0.13` as 14 +The reference implementation. Published to npm at `1.1.0` as 14 `@metaobjectsdev/*` packages on the `latest` tag. Targets Node-compatible runtimes; Bun-first dev workflow. diff --git a/examples/showcase/.metaobjects/.gen-state/.hashes.json b/examples/showcase/.metaobjects/.gen-state/.hashes.json index a1f97cfa9..22528756c 100644 --- a/examples/showcase/.metaobjects/.gen-state/.hashes.json +++ b/examples/showcase/.metaobjects/.gen-state/.hashes.json @@ -4,7 +4,7 @@ "generated/csharp/SubscriberBlurbPayload.g.cs": "30ae31959c0cd98ba4ab12b2e827fa48f3bf59a05aa34974ebc4a1fe930378be", "generated/csharp/SubscriberFilterAllowlist.g.cs": "7428d47f6ccb64bb315266ee0564fa3803736623a34ab491435c042848c5d472", "generated/csharp/SubscriberNames.g.cs": "91f6eba78657b31fceef7021c6ca6bd799524dcb5120acbc8fcbe18786787174", - "generated/csharp/SubscriberRoutes.g.cs": "e98387adf0d9fd5c2743409e5820d72400373ca6a758176cba76112fa1efc02a", + "generated/csharp/SubscriberRoutes.g.cs": "e520084727642d242609cbf82b3598d7c996224c3d0aef250bd26c7540ab4423", "generated/python/Subscriber.py": "9dd4c0acea47f99d1daf79930f9de2e400f30780ebce6c63f023be528a72b762", "generated/python/SubscriberBlurbPayload.py": "a5ef8a08e63efbe0f73e39a21a368cff5150c68cc8beeea6e8aab0c755f8bbdd", "generated/python/__init__.py": "daa04cec586768866d0996c779f62073e6773fe63750ff668327991fac2fd0f5", diff --git a/examples/showcase/generated/csharp/SubscriberRoutes.g.cs b/examples/showcase/generated/csharp/SubscriberRoutes.g.cs index 8381645a7..1a23437a7 100644 --- a/examples/showcase/generated/csharp/SubscriberRoutes.g.cs +++ b/examples/showcase/generated/csharp/SubscriberRoutes.g.cs @@ -96,7 +96,7 @@ await db.Subscribers.FindAsync(id) is { } found if (__p.Value.ValueKind != System.Text.Json.JsonValueKind.Null) __present.Add(__p.Name); foreach (var __req in new[] { "email", "status" }) if (!__present.Contains(__req)) return Results.BadRequest(new { error = "validation" }); - Subscriber input; + Subscriber? input; try { input = System.Text.Json.JsonSerializer.Deserialize(__body.RootElement.GetRawText(), jsonOpts); } catch (System.Text.Json.JsonException) { return Results.BadRequest(new { error = "validation" }); } if (input is null) return Results.BadRequest(new { error = "validation" }); diff --git a/server/csharp/MetaObjects.Cli.Tests/CliSurfaceTests.cs b/server/csharp/MetaObjects.Cli.Tests/CliSurfaceTests.cs index c6ab48e9f..f7f0218df 100644 --- a/server/csharp/MetaObjects.Cli.Tests/CliSurfaceTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/CliSurfaceTests.cs @@ -72,4 +72,41 @@ public void A_value_flag_with_no_value_says_so_rather_than_calling_it_unknown() Assert.Equal(2, exit); Assert.Contains("--out needs a value", stderr); } + + private string WriteBlogModel() + { + var model = Path.Combine(_tmp, "metaobjects"); + Directory.CreateDirectory(model); + File.WriteAllText(Path.Combine(model, "meta.blog.json"), + """{ "metadata.root": { "package": "acme::blog", "children": [ { "object.entity": { "name": "Author", "children": [ { "field.long": { "name": "id" } } ] } } ] } }"""); + return model; + } + + [Fact] + public void Verify_templates_naming_a_missing_directory_fails_instead_of_passing() + { + var model = WriteBlogModel(); + var (exit, stdout, stderr) = CliProcess.Run(_tmp, "verify", model, "--templates", Path.Combine(_tmp, "no-such-prompts")); + Assert.Equal(2, exit); + Assert.Contains("templates directory not found", stderr); + Assert.DoesNotContain("OK", stdout); + } + + [Fact] + public void Verify_prompts_naming_a_missing_directory_fails_too() + { + var model = WriteBlogModel(); + var (exit, _, stderr) = CliProcess.Run(_tmp, "verify", model, "--templates", "--prompts", Path.Combine(_tmp, "no-such-prompts")); + Assert.Equal(2, exit); + Assert.Contains("templates directory not found", stderr); + } + + [Fact] + public void A_bare_verify_runs_the_templates_default_its_help_describes() + { + var model = WriteBlogModel(); + var (exit, stdout, stderr) = CliProcess.Run(_tmp, "verify", model); + Assert.True(exit == 0, $"exit={exit}\nstderr={stderr}"); + Assert.Contains("running --templates (default)", stdout); + } } diff --git a/server/csharp/MetaObjects.Cli/Program.cs b/server/csharp/MetaObjects.Cli/Program.cs index df4cf4f60..71d9fd989 100644 --- a/server/csharp/MetaObjects.Cli/Program.cs +++ b/server/csharp/MetaObjects.Cli/Program.cs @@ -36,7 +36,10 @@ " gen --list list available generators (stable names) and exit\n" + " verify [--templates [--prompts ]] [--codegen --out [--namespace ]] [--db] [--lax]\n" + " drift gates (ADR-0021 D2 subverbs):\n" + - " --templates template/prompt drift (default)\n" + + " --templates template/prompt drift (default); the\n" + + " directory is --prompts or the inline\n" + + " root and must exist; with none it reads\n" + + " prompts/ if present, else templates/\n" + " --codegen regen-to-temp vs committed --out\n" + " --namespace codegen regen namespace; inferred\n" + " from the committed --out when omitted\n" + @@ -422,15 +425,21 @@ static int RunVerify(string[] rest) // falls back to the port-neutral .metaobjects/config.json ladder. var resolvedMeta = ResolveMetadataDirOrExit(metadataDir); - // The templates gate needs a root. Bare verify (defaults to templates) and an - // explicit --templates both require it; surface a clear usage error if absent. + // The templates gate needs a root. One named on the command line must exist: a typo'd + // directory used to verify clean, because nothing in an empty root can drift. With none + // named (bare verify, or a bare --templates) it reads the same default `gen` does: + // prompts/ when that directory exists, else templates/. var wantsTemplates = templates || (!codegen && !db); - if (wantsTemplates && templatesRoot is null) + if (wantsTemplates) { - Console.Error.WriteLine("usage: dotnet meta verify --templates "); - Console.Error.WriteLine(" (the templates gate is the default; pass --codegen --out for codegen drift —"); - Console.Error.WriteLine(" --namespace is inferred from the committed output when omitted)"); - return 2; + if (templatesRoot is null) templatesRoot = GenCommand.DefaultTemplateRoot(); + else if (!Directory.Exists(templatesRoot)) + { + Console.Error.WriteLine($"dotnet meta verify --templates: templates directory not found: {templatesRoot}"); + Console.Error.WriteLine(" (name the directory that holds your prompt bodies, e.g. --templates ./prompts;"); + Console.Error.WriteLine(" with none named it reads prompts/ when that exists, else templates/)"); + return 2; + } } // Advisory: nudge a re-scaffold if the copied-in agent context predates this build. diff --git a/server/csharp/MetaObjects.Codegen.Tests/EntityGeneratorTests.cs b/server/csharp/MetaObjects.Codegen.Tests/EntityGeneratorTests.cs index 6fcc8c493..119b12f82 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/EntityGeneratorTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/EntityGeneratorTests.cs @@ -126,7 +126,7 @@ public void Routes_generator_emits_crud_endpoints() Assert.Contains("app.MapPost(prefix + \"/subscribers\", async (HttpContext http, AppDbContext db) =>", src); Assert.Contains("foreach (var __req in new[] { \"email\" })", src); Assert.Contains("if (!__present.Contains(__req)) return Results.BadRequest(new { error = \"validation\" });", src); - Assert.Contains("Subscriber input;", src); + Assert.Contains("Subscriber? input;", src); Assert.Contains("try { input = System.Text.Json.JsonSerializer.Deserialize(__body.RootElement.GetRawText(), jsonOpts); }", src); Assert.Contains("catch (System.Text.Json.JsonException) { return Results.BadRequest(new { error = \"validation\" }); }", src); // PATCH + PUT share the same update handler (matches TS reference). It takes the raw diff --git a/server/csharp/MetaObjects.Codegen/CodegenDrift.cs b/server/csharp/MetaObjects.Codegen/CodegenDrift.cs index 8e7ebc0b1..1d60595dd 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenDrift.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenDrift.cs @@ -81,7 +81,7 @@ public static IReadOnlyList Remedy(Result result) { var lines = new List(); if (result.DriftedFiles.Count > result.EditedOrphans.Count) - lines.Add("Run 'dotnet meta gen' to regenerate, then commit the result."); + lines.Add("Run 'dotnet meta gen' to regenerate (it needs --out, the same directory you gave this command), then commit the result."); if (result.EditedOrphans.Count > 0) lines.Add("For the file(s) above that no generator emits any more and that were edited by hand, " + "'dotnet meta gen' will NOT delete them. Delete each one, or move your edits into a file of your own first."); diff --git a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs index c831b218a..7ec86477a 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs @@ -793,7 +793,7 @@ private static void AppendCreateHandler( // throws JsonException — return the cross-port 400 {error:"validation"} rather than an // unhandled 500 (matches the no-required-keys path's automatic model-binding 400, and // every other port). Program D — the VO columns make wrong-shaped bodies reachable. - sb.AppendLine($" {cls} input;"); + sb.AppendLine($" {cls}? input;"); sb.AppendLine(" try { input = System.Text.Json.JsonSerializer.Deserialize<" + cls + ">(__body.RootElement.GetRawText(), jsonOpts); }"); sb.AppendLine(" catch (System.Text.Json.JsonException) { return Results.BadRequest(new { error = \"validation\" }); }"); sb.AppendLine(" if (input is null) return Results.BadRequest(new { error = \"validation\" });"); diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java index 635802a6f..42219d552 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java @@ -7,6 +7,8 @@ import com.metaobjects.generator.verify.TemplateVerify; import com.metaobjects.loader.MetaDataLoader; import com.metaobjects.requirement.RequirementCheck; +import org.apache.maven.model.Plugin; +import org.apache.maven.model.PluginExecution; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugins.annotations.LifecyclePhase; @@ -82,9 +84,26 @@ public class MetaDataVerifyMojo extends AbstractMetaDataMojo { /** What every line of this goal's requirement report starts with, as the field lint's does. */ private static final String PREFIX = "metaobjects:verify \u2014 "; - /** The gen goal to suggest in the failure message ({@code groupId:artifactId:goal}). */ + /** The gen goal to suggest in the failure message. */ private static final String GEN_GOAL = "metaobjects:generate"; + /** + * The generate invocation to suggest in a failure message. A generate configuration that + * lives inside an {@code } is not seen by a bare {@code mvn metaobjects:generate} + * (it fails with "No element was defined"), so name the execution that holds it. + * With the configuration at plugin level, or when the module is not known, the bare goal works. + */ + private String genGoal() { + Plugin plugin = project != null ? project.getPlugin(PLUGIN_KEY) : null; + if (plugin == null || plugin.getConfiguration() != null) return GEN_GOAL; + for (PluginExecution e : plugin.getExecutions()) { + if (e.getGoals().contains("generate") && !"none".equals(e.getPhase())) { + return GEN_GOAL + "@" + e.getId(); + } + } + return GEN_GOAL; + } + /** Verify mode: {@code codegen} (default), {@code templates}. */ static final String MODE_CODEGEN = "codegen"; static final String MODE_TEMPLATES = "templates"; @@ -412,7 +431,7 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException if (!drift.isEmpty()) { StringBuilder sb = new StringBuilder(); if (drift.size() > editedOrphans.size()) { - sb.append("generated code is stale — run `mvn ").append(GEN_GOAL) + sb.append("generated code is stale — run `mvn ").append(genGoal()) .append("` and commit. Drifted files:"); } else { // Every drifted file is an orphan that was edited by hand and that @@ -426,7 +445,7 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException } if (!editedOrphans.isEmpty()) { sb.append("\n\nFor the file(s) above that no generator emits any more and that were " - + "edited by hand, `mvn ").append(GEN_GOAL) + + "edited by hand, `mvn ").append(genGoal()) .append("` will NOT delete them. Delete each one, or move your edits into a " + "file of your own first."); } @@ -490,7 +509,7 @@ private List strandedOutsideTargets(Map ownHashes, List< switch (OrphanSweep.classify(ownHashes, root, file)) { case PRISTINE: drift.add("[stale-in-repo] " + file + " (committed; no configured generator produces it; " - + "'mvn " + GEN_GOAL + "' deletes it)"); + + "'mvn " + genGoal() + "' deletes it)"); break; case EDITED: drift.add("[stale-in-repo] " + file + " (no configured generator emits it any more, and " @@ -556,12 +575,12 @@ List compareTrees(GenTarget t, Map hashes, Path projectR break; case PRISTINE: drift.add("[stale-in-repo] " + committedFile + " (committed; generator no longer " - + "produces it; 'mvn " + GEN_GOAL + "' deletes it)"); + + "produces it; 'mvn " + genGoal() + "' deletes it)"); break; case EDITED: if (t.allOverwrite) { drift.add("[stale-in-repo] " + committedFile + " (no generator emits it any " - + "more, and it was edited by hand: 'mvn " + GEN_GOAL + + "more, and it was edited by hand: 'mvn " + genGoal() + "' will overwrite and delete it, handEdits: overwrite)"); } else { drift.add("[stale-in-repo] " + committedFile + " (no generator emits it any " diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyMojoManifestTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyMojoManifestTest.java index 9d37a4259..85295b4d0 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyMojoManifestTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyMojoManifestTest.java @@ -318,4 +318,39 @@ public void sharedOutputDir_anotherConfiguredGeneratorsFiles_areNotDrift() throw verify.execute(); } + + /** + * A generate configuration that sits inside an {@code } is invisible to a bare + * {@code mvn metaobjects:generate} (it fails with "No element was defined"), so the + * stale message names the execution; with the configuration at plugin level the bare goal + * is the command that works. + */ + @Test + public void staleMessage_namesTheGenerateCommandThatWorksForTheModule() throws Exception { + Path basedir = tempFolder.getRoot().toPath(); + String outDir = tempFolder.newFolder("generated-sources").getAbsolutePath(); + GeneratorParam dto = gen(DtoGenerator.class, outDir, "Dto.java"); + MavenProject inExecution = module(basedir, dto); + generateIn(inExecution, dto); + Files.writeString(Path.of(outDir, "Dto.java"), MARKER_BODY + "// drift\n"); + + assertTrue(staleMessage(inExecution, dto).contains("run `mvn metaobjects:generate@codegen` and commit")); + + Plugin atPluginLevel = inExecution.getModel().getBuild().getPlugins().get(0); + atPluginLevel.setConfiguration(configuration(dto)); + assertTrue(staleMessage(inExecution, dto).contains("run `mvn metaobjects:generate` and commit")); + } + + private String staleMessage(MavenProject module, GeneratorParam dto) throws Exception { + MetaDataVerifyMojo verify = new MetaDataVerifyMojo(); + verify.project = module; + verify.setLoader(testLoader()); + verify.setGenerators(List.of(dto)); + try { + verify.execute(); + } catch (MojoFailureException e) { + return e.getMessage(); + } + throw new AssertionError("expected MojoFailureException"); + } } diff --git a/server/typescript/packages/sdk/package.json b/server/typescript/packages/sdk/package.json index bf5f31e8f..e63943b93 100644 --- a/server/typescript/packages/sdk/package.json +++ b/server/typescript/packages/sdk/package.json @@ -52,7 +52,7 @@ }, "dependencies": { "@metaobjectsdev/metadata": "workspace:*", - "zod": "^3.23.0" + "zod": "^4.6.6" }, "devDependencies": { "bun-types": "latest", diff --git a/server/typescript/packages/sdk/src/config.ts b/server/typescript/packages/sdk/src/config.ts index f6c27ff14..5de2ed832 100644 --- a/server/typescript/packages/sdk/src/config.ts +++ b/server/typescript/packages/sdk/src/config.ts @@ -146,7 +146,7 @@ export const ConfigSchema = z.object({ pending_promote: z.number().min(0).max(1).default(0.8), drift_warn: z.number().min(0).max(1).default(0.7), }) - .default({}), + .prefault({}), sources: z.array(SourceSpecSchema).default([]), /** Declared metadata dependencies (FR-023) — read from this file at every * rung of the source ladder, by every port, not just TypeScript (DESIGN @@ -281,20 +281,23 @@ export function splitUnknownTopLevelKeys(doc: unknown): { known: unknown; unknow */ export function describeConfigError(path: string, error: z.ZodError): string { const lines = new Set(); - const visit = (issues: readonly z.ZodIssue[]): void => { + // zod 4 reports a union arm's issue paths relative to the union, so carry the prefix down. + const visit = (issues: readonly z.core.$ZodIssue[], prefix: readonly PropertyKey[]): void => { for (const issue of issues) { + const full = [...prefix, ...issue.path]; if (issue.code === "invalid_union") { - visit(issue.unionErrors.flatMap((e) => e.issues)); + for (const arm of issue.errors) visit(arm, full); continue; } - const at = issue.path.reduce( - (acc, seg) => (typeof seg === "number" ? `${acc}[${seg}]` : acc === "" ? seg : `${acc}.${seg}`), + const at = full.reduce( + (acc, seg) => + typeof seg === "number" ? `${acc}[${seg}]` : acc === "" ? String(seg) : `${acc}.${String(seg)}`, "", ); lines.add(` ${at === "" ? "(top level)" : at}: ${issue.message}`); } }; - visit(error.issues); + visit(error.issues, []); const touchesSources = error.issues.some((i) => i.path[0] === "sources"); return [ `${path} is not a valid MetaObjects config:`, From 9a6b921cc5dceb56f67d7d3a184572c0ab7fc875 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 24 Oct 2026 00:03:57 -0400 Subject: [PATCH 2/5] fix(scripts): make the showcase freshness check hermetic against Bun auto-install The pristine project the check generates in lives under the system temp dir, with no node_modules above it, so Bun auto-installed the config's @metaobjectsdev/* imports from the machine-wide cache. That copy cannot resolve @biomejs/wasm-nodejs, the formatter failed, and every ts file read as stale. An empty node_modules turns auto-install off. --- scripts/regen-showcase.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/regen-showcase.ts b/scripts/regen-showcase.ts index cdd1c71e5..787133435 100644 --- a/scripts/regen-showcase.ts +++ b/scripts/regen-showcase.ts @@ -380,6 +380,14 @@ function pristineProject(): string { for (const item of INPUTS) cpSync(join(SHOWCASE, item), join(tmp, item), { recursive: true }); cpSync(join(SHOWCASE, ".metaobjects", "config.json"), join(tmp, ".metaobjects", "config.json"), { recursive: true }); + // A project with no `node_modules` anywhere above it switches Bun into auto-install: the + // config's `@metaobjectsdev/*` imports would then resolve to a copy fetched into the + // machine-wide cache, not this tree, and that copy's formatter dependency cannot find its + // sibling `@biomejs/wasm-nodejs` ("Biome formatter error"), so every ts file comes out + // unformatted and the gate reports the whole committed tree as stale. An empty + // `node_modules` turns auto-install off; the CLI's own package aliases then resolve the + // workspace packages, which is what this check is meant to exercise. + mkdirSync(join(tmp, "node_modules")); return tmp; } From c09c56b82b43cbeb8b630f4835c207af8c1d506b Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 24 Oct 2026 00:09:47 -0400 Subject: [PATCH 3/5] no-mistakes(review): apply reviewer inline fixes --- CHANGELOG.md | 8 +++++--- CONTRIBUTING.md | 8 +++++--- README.md | 4 ++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d4100d7..c7d6deb31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ here.** `@metaobjectsdev/sdk` declared `zod` `^3.23.0`, which resolves to a release the zod advisory GHSA-mrhx-c7xc-m3w5 covers (fixed in 4.6.6), and npm installed it beside the `zod` 4 the CLI already used. The sdk now declares `^4.6.6` and reads its config with the zod 4 API; the - config it accepts and the messages it prints are unchanged. + config it accepts is unchanged, and a rejected config still names each offending path, with + the reason now in zod 4's wording. - **C#: `dotnet meta verify --templates ` fails when `` does not exist.** It exited 0 with "OK", since nothing in an empty root can drift. It now exits 2 and names the directory. A bare `dotnet meta verify ` runs the templates gate its help describes, reading @@ -40,8 +41,9 @@ here.** ends in the real drift gate. - The README opens with a copy-paste quick start, and its Languages table, the port pages and the Python floor name 1.1.0 / 8.1.0. -- `CONTRIBUTING.md` says one thing about CI: GitHub Actions runs on pull requests, the same - checks as `scripts/ci-local.sh`. +- `CONTRIBUTING.md` says one thing about CI: GitHub Actions runs `scripts/ci-local.sh` — the + leak scan on every pull request, the conformance and integration lanes on `main`, nightly + and on release tags. ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67f907d07..af453a206 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,8 +25,10 @@ conformance corpora. This guide covers how to propose changes. 1. **Fork** the repo and create a branch from `main`. 2. Make your change following the discipline below — **tests first (TDD)**. 3. Run the gates locally with **`scripts/ci-local.sh --quick`** and make sure it's - green. **This matters:** GitHub Actions runs the same checks on your pull request, - so a red local run is a red PR — see the *Local CI* section below. + green. **This matters:** on a pull request GitHub Actions runs only the leak scan + (`hygiene.yml`); the conformance and integration lanes run on `main`, nightly and on + release tags, so your local run is the only check of the rest before merge — see the + *Local CI* section below. 4. Open a **pull request** against `main` and fill out the PR template. 5. A maintainer reviews the change and the checks on the pull request before merge. @@ -71,7 +73,7 @@ cd server/python && pytest # Python (in its .venv) Cross-language persistence / api-contract corpora (Docker + Testcontainers) run via `scripts/integration-test.sh`. -### Local CI (the same checks the pull request runs — run it first) +### Local CI (the same checks GitHub runs — run it before opening a PR) GitHub Actions is enabled on this repository. The three check workflows in `.github/workflows/` run on GitHub as thin wrappers over `scripts/ci-local.sh`, diff --git a/README.md b/README.md index 9fa97265f..87dda419d 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ at something real, not that the something is correct. npm i @metaobjectsdev/cli && npx meta init # scaffold metaobjects/, the config and agent skills # declare an entity in metaobjects/ (a 10-line model: metaobjects.dev/getting-started) npx meta gen --list --probe --format text # what your model can generate -npx meta eject entity queries routes names barrel # copy the generators you chose into your repo -npx meta gen && npx meta verify # generate, then fail if anything has drifted +npx meta eject entity queries routes names barrel # copy them in; add the lines it prints to metaobjects.config.ts +npx meta gen && npx meta verify --codegen # generate, then fail if generated code has drifted ``` Java, Kotlin, C# and Python start the same way from their own package: see From 0f879850ba7b9c38c205c5b21ffd1f948dbdde3b Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 24 Oct 2026 00:47:36 -0400 Subject: [PATCH 4/5] no-mistakes(test): Drop routes from README quickstart; fail C# verify without templates --- README.md | 2 +- docs/ports/csharp.md | 3 ++- .../csharp/MetaObjects.Cli.Tests/CliSurfaceTests.cs | 11 +++++++++++ server/csharp/MetaObjects.Cli/Program.cs | 12 ++++++------ 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 87dda419d..4de1d9573 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ at something real, not that the something is correct. npm i @metaobjectsdev/cli && npx meta init # scaffold metaobjects/, the config and agent skills # declare an entity in metaobjects/ (a 10-line model: metaobjects.dev/getting-started) npx meta gen --list --probe --format text # what your model can generate -npx meta eject entity queries routes names barrel # copy them in; add the lines it prints to metaobjects.config.ts +npx meta eject entity queries names barrel # copy them in; add the lines it prints to metaobjects.config.ts npx meta gen && npx meta verify --codegen # generate, then fail if generated code has drifted ``` diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index e9de27d48..8d79fbbfe 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -143,7 +143,8 @@ dotnet meta verify ./metaobjects --codegen --out ./Generated --templates ./prompts` is a different gate, for prompt templates against their payloads (FR-004): it says nothing about generated code, and it fails with exit 2 when the directory it is given does not exist. A bare `dotnet meta verify ./metaobjects` runs that templates gate -against `prompts/` when it exists, else `templates/`; pass both subverbs to run both. +against `prompts/` when it exists, else `templates/`, and fails the same way when neither +exists; pass both subverbs to run both. `dotnet meta gen --list` is the catalog, and it shows what each generator requires. `routes` requires `entity`, `db-context` and `filter-allowlist`, because the handlers it emits diff --git a/server/csharp/MetaObjects.Cli.Tests/CliSurfaceTests.cs b/server/csharp/MetaObjects.Cli.Tests/CliSurfaceTests.cs index f7f0218df..9a09ca4ad 100644 --- a/server/csharp/MetaObjects.Cli.Tests/CliSurfaceTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/CliSurfaceTests.cs @@ -101,10 +101,21 @@ public void Verify_prompts_naming_a_missing_directory_fails_too() Assert.Contains("templates directory not found", stderr); } + [Fact] + public void A_bare_verify_with_neither_prompts_nor_templates_fails() + { + var model = WriteBlogModel(); + var (exit, stdout, stderr) = CliProcess.Run(_tmp, "verify", model); + Assert.Equal(2, exit); + Assert.Contains("templates directory not found", stderr); + Assert.DoesNotContain("OK", stdout); + } + [Fact] public void A_bare_verify_runs_the_templates_default_its_help_describes() { var model = WriteBlogModel(); + Directory.CreateDirectory(Path.Combine(_tmp, "prompts")); var (exit, stdout, stderr) = CliProcess.Run(_tmp, "verify", model); Assert.True(exit == 0, $"exit={exit}\nstderr={stderr}"); Assert.Contains("running --templates (default)", stdout); diff --git a/server/csharp/MetaObjects.Cli/Program.cs b/server/csharp/MetaObjects.Cli/Program.cs index 71d9fd989..4aae48719 100644 --- a/server/csharp/MetaObjects.Cli/Program.cs +++ b/server/csharp/MetaObjects.Cli/Program.cs @@ -425,15 +425,15 @@ static int RunVerify(string[] rest) // falls back to the port-neutral .metaobjects/config.json ladder. var resolvedMeta = ResolveMetadataDirOrExit(metadataDir); - // The templates gate needs a root. One named on the command line must exist: a typo'd - // directory used to verify clean, because nothing in an empty root can drift. With none - // named (bare verify, or a bare --templates) it reads the same default `gen` does: - // prompts/ when that directory exists, else templates/. + // The templates gate needs a root, and it must exist: a typo'd or absent directory used + // to verify clean, because nothing in an empty root can drift. With none named (bare + // verify, or a bare --templates) it reads the same default `gen` does: prompts/ when that + // directory exists, else templates/ — and neither existing fails the same way. var wantsTemplates = templates || (!codegen && !db); if (wantsTemplates) { - if (templatesRoot is null) templatesRoot = GenCommand.DefaultTemplateRoot(); - else if (!Directory.Exists(templatesRoot)) + templatesRoot ??= GenCommand.DefaultTemplateRoot(); + if (!Directory.Exists(templatesRoot)) { Console.Error.WriteLine($"dotnet meta verify --templates: templates directory not found: {templatesRoot}"); Console.Error.WriteLine(" (name the directory that holds your prompt bodies, e.g. --templates ./prompts;"); From d373bb723abca5ced6ff7415effb0302c4eae195 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 24 Oct 2026 01:09:24 -0400 Subject: [PATCH 5/5] no-mistakes(document): Docs checked against 1e644cd..0f87985. Fixed CONTRIBUTING Local CI header/trigger wording (PR runs leak scan only). Version restamps, zod, quickstart, port docs were already current in the diff. AGENTS.md left unedited. --- CONTRIBUTING.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af453a206..2b2ffc2c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,14 +73,15 @@ cd server/python && pytest # Python (in its .venv) Cross-language persistence / api-contract corpora (Docker + Testcontainers) run via `scripts/integration-test.sh`. -### Local CI (the same checks GitHub runs — run it before opening a PR) - -GitHub Actions is enabled on this repository. The three check workflows in -`.github/workflows/` run on GitHub as thin wrappers over `scripts/ci-local.sh`, -the single definition of the checks: each wrapper checks out, installs the -toolchains its lane needs, and calls the script, so a local run is the same -check a workflow run would be. Run the script locally before opening or merging -a PR, so nothing red leaves your machine: +### Local CI (the checks GitHub runs — run it before opening a PR) + +GitHub Actions is enabled on this repository. The check workflows in +`.github/workflows/` are thin wrappers over `scripts/ci-local.sh`, the single +definition of the checks: each wrapper checks out, installs the toolchains its +lane needs, and calls the script, so a local run is the same check a workflow +run would be. A pull request runs only the leak scan; the other lanes run on +`main`, nightly and on release tags. Run the script locally before opening or +merging a PR, so nothing red reaches `main`: ```bash scripts/ci-local.sh # full parity: hygiene.yml's leak-scan, all-port