From e19b24a27f6516af9cd62df5cb53f48a48b1c4a7 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Thu, 5 Nov 2026 21:20:05 -0500
Subject: [PATCH 1/5] feat: publish a shared model from Java, Kotlin and C#
(FR-023 S8, S9)
Adds a shared-model generator to the Java/Kotlin Maven goals and to the C# CLI,
each ejectable, byte-identical to the TypeScript publisher and gated by the
shared-model publish corpus. The registry now lists java, kotlin and csharp.
---
CHANGELOG.md | 10 +
docs/CONFORMANCE.md | 6 +-
docs/features/metadata-dependencies.md | 20 +-
docs/ports/csharp.md | 27 ++
docs/ports/java.md | 28 ++
docs/ports/kotlin.md | 10 +
.../registry.json | 4 +-
fixtures/shared-model-conformance/README.md | 7 +-
.../GenListAndSelectionTests.cs | 2 +-
.../CodegenDriftTests.cs | 3 +-
.../DependencyCodegenTests.cs | 3 +-
.../EjectContractTests.cs | 5 +-
.../EjectedGeneratorsAloneTests.cs | 4 +-
.../GeneratorRegistryTests.cs | 2 +
.../NoMagicPhysicalNamesTests.cs | 2 +-
.../ReportingInertTests.cs | 8 +-
.../SharedModelGeneratorTests.cs | 109 ++++++
.../SharedModelTestSupport.cs | 26 ++
.../MetaObjects.Codegen/GeneratorRegistry.cs | 15 +
.../Generators/SharedModelGenerator.cs | 113 +++++++
.../MetaObjects.Codegen.csproj | 1 +
.../SharedModelConformanceTests.cs | 95 ++++++
.../csharp/MetaObjects/Config/SharedModel.cs | 252 ++++++++++++++
server/csharp/MetaObjects/SerializerJson.cs | 49 +++
server/java/codegen-base/pom.xml | 8 +
.../generator/ProjectVersionAware.java | 15 +
.../generator/SharedModelGenerator.java | 91 +++++
.../generator/SharedModelGeneratorTest.java | 99 ++++++
server/java/codegen-kotlin/pom.xml | 1 +
.../generator/kotlin/GeneratorRegistry.kt | 12 +-
.../kotlin/KotlinSharedModelGenerator.kt | 79 +++++
.../generator/kotlin/EjectContractTest.kt | 10 +
.../kotlin/KotlinSharedModelGeneratorTest.kt | 60 ++++
.../kotlin/NoMagicPhysicalNamesTest.kt | 4 +-
.../generator/kotlin/ReportingInertTest.kt | 4 +-
.../generator/GeneratorRegistry.java | 7 +
.../spring/NoMagicPhysicalNamesTest.java | 8 +
.../generator/spring/ReportingInertTest.java | 4 +-
.../mojo/AbstractMetaDataMojo.java | 23 ++
.../metaobjects/mojo/EjectContractTest.java | 14 +
.../mojo/EjectRuntimeRoundTripTest.java | 5 +
.../com/metaobjects/config/SharedModel.java | 314 ++++++++++++++++++
.../io/json/CanonicalJsonSerializer.java | 48 +++
.../config/SharedModelConformanceTest.java | 143 ++++++++
44 files changed, 1723 insertions(+), 27 deletions(-)
create mode 100644 server/csharp/MetaObjects.Codegen.Tests/SharedModelGeneratorTests.cs
create mode 100644 server/csharp/MetaObjects.Codegen.Tests/SharedModelTestSupport.cs
create mode 100644 server/csharp/MetaObjects.Codegen/Generators/SharedModelGenerator.cs
create mode 100644 server/csharp/MetaObjects.Conformance.Tests/SharedModelConformanceTests.cs
create mode 100644 server/csharp/MetaObjects/Config/SharedModel.cs
create mode 100644 server/java/codegen-base/src/main/java/com/metaobjects/generator/ProjectVersionAware.java
create mode 100644 server/java/codegen-base/src/main/java/com/metaobjects/generator/SharedModelGenerator.java
create mode 100644 server/java/codegen-base/src/test/java/com/metaobjects/generator/SharedModelGeneratorTest.java
create mode 100644 server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSharedModelGenerator.kt
create mode 100644 server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSharedModelGeneratorTest.kt
create mode 100644 server/java/metadata/src/main/java/com/metaobjects/config/SharedModel.java
create mode 100644 server/java/metadata/src/test/java/com/metaobjects/config/SharedModelConformanceTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f71c20e79..8b9f08676d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,16 @@ loading. A port that reports `1.2` does not know that vocabulary; upgrade every
### Added
+- **Java, Kotlin and C#: publish a shared model (FR-023).** A `shared-model` generator in every one
+ of the three ports writes the artifact `.metaobjects.json` and the manifest
+ `metaobjects.pkg.json`, byte-identical to the TypeScript `sharedModelFile()` and gated by
+ `fixtures/shared-model-conformance/` (the engine is `SharedModel` in the Java `metadata` module
+ and `MetaObjects.Config.SharedModel` in C#). Java and Kotlin run it through `metaobjects:generate`
+ (version from the project's ``) and eject it with `mvn metaobjects:eject`; C# declares
+ `SharedModelGenerator` in `codegen/Generators.cs` (version from ``) and ejects it with
+ `dotnet meta eject shared-model`. `generator-registry-conformance` now lists `java`, `kotlin` and
+ `csharp` under `shared-model`. The Node `meta deps sync` remains the only writer of the lock and
+ the snapshot.
- **Java and Kotlin: a Spring app loads a shared model's committed snapshot at runtime, and
`metaobjects:verify` checks dependency drift.** `metaobjects:generate` now ships
`.metaobjects/deps.lock.json` and `.metaobjects/deps/**` as resources (target path `.metaobjects`)
diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md
index 87ac9847c0..0a680b69d1 100644
--- a/docs/CONFORMANCE.md
+++ b/docs/CONFORMANCE.md
@@ -50,7 +50,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor
| [`fixtures/source-resolution-conformance/`](../fixtures/source-resolution-conformance/) | 25 cases | ✓ (reference implementation) | ✓ | inherits via Java | ✓ | ✓ |
| [`fixtures/scope-conformance/`](../fixtures/scope-conformance/) | 10 cases | ✓ (reference implementation) | ✓ | inherits via Java | ✓ | ✓ |
| [`fixtures/dependency-conformance/`](../fixtures/dependency-conformance/) | 37 cases (26 resolution + 11 check) + 14 transport cases | ✓ (reference implementation; all three arms) | ✓ (resolution arm, all 26; check arm, all 11) | inherits via Java | ✓ (resolution arm, all 26; check arm, all 11, as `verify --deps`) | ✓ (resolution arm, all 26 identically; check arm, all 11, as `deps check` and `verify --deps`; the transport arm is Node-only) |
-| [`fixtures/shared-model-conformance/`](../fixtures/shared-model-conformance/) | 11 cases (7 publish + 4 refusal) | ✓ (reference implementation) | — (Phase 2) | — (Phase 2) | — (Phase 2) | ✓ (serializer arm: the 7 publish cases' document bytes; no publisher) |
+| [`fixtures/shared-model-conformance/`](../fixtures/shared-model-conformance/) | 11 cases (7 publish + 4 refusal) | ✓ (reference implementation) | ✓ (all 11; `SharedModelConformanceTest`) | inherits via Java (`KotlinSharedModelGeneratorTest` pins the generator) | ✓ (all 11; `SharedModelConformanceTests`) | ✓ (serializer arm: the 7 publish cases' document bytes; no publisher) |
| [`fixtures/agent-context-conformance/`](../fixtures/agent-context-conformance/) | 4 | ✓ (the emitter is TS-owned) | — | — | — | — |
| [`fixtures/metamodel-docs/`](../fixtures/metamodel-docs/) | 1 | ✓ (docs emit is TS-owned) | — | — | — | — |
| [`fixtures/fmt-conformance/`](../fixtures/fmt-conformance/) (#304 — `meta fmt`) | 12 | ✓ (reference) | ✓ | inherits via Java | ✓ | ✓ |
@@ -514,7 +514,9 @@ the manifest records the toolchain's `metamodelVersion`, so they do not move wit
the serializer arm**: for each publish case it loads `files`, takes the nodes the pinned manifest
lists, and `serialize_shared_document` must reproduce the artifact byte for byte (and the sha256
the manifest records). Python has no publisher, so selection and the refusals are TypeScript's.
-Java, Kotlin and C# have no runner (Phase 2).
+**Java runs all 11**, in `metadata`'s `SharedModelConformanceTest` against the `SharedModel` engine
+the `shared-model` generator calls; **Kotlin** runs through that same engine and Maven goals and adds
+only `KotlinSharedModelGeneratorTest`. **C# runs all 11**, in `SharedModelConformanceTests`.
> **ADR-0055 (deferred overlay application) — SHIPPED in every port.** The nine cases added
> for it (`overlay-mixed-file-base-in-later-file`, `overlay-mixed-file-base-in-earlier-file`,
diff --git a/docs/features/metadata-dependencies.md b/docs/features/metadata-dependencies.md
index 7bde769f95..f4d03af863 100644
--- a/docs/features/metadata-dependencies.md
+++ b/docs/features/metadata-dependencies.md
@@ -408,7 +408,8 @@ publisher reachable.
## Publishing a dependency: `sharedModelFile()`
-A publisher wires the `shared-model` generator (TypeScript only, in Phase 1a) to
+A publisher wires the `shared-model` generator (TypeScript, Java, Kotlin and C#; Python
+consumes only) to
select a subset of its own metadata by the same include/exclude scope-pattern
grammar `scope` already uses, and emit it as one canonical-JSON artifact plus a
manifest:
@@ -580,16 +581,18 @@ Then `meta deps sync` as for any other installed package. Nothing about the regi
reaches `.metaobjects/`: the lock records the transport (`npm`/`python` + `dir`), never a
URL.
-**Only TypeScript publishes in Phase 1a — TypeScript, Python, Java, Kotlin and C# consume.**
+**TypeScript, Java, Kotlin and C# publish; TypeScript, Python, Java, Kotlin and C# consume.**
TypeScript, Python, Java, Kotlin and C# can *consume* a dependency (load its snapshot, extend
it, overlay it). Java reads it at every rung of the Maven plugin's source ladder
(``/``, the neutral config, the default directory), and Kotlin inherits that
through the same plugin and loader, so a Spring or Kotlin build consumes a shared model with no
-extra configuration. Only the TypeScript toolchain can *publish*: it alone can generate the
-artifact a publisher ships, and the Node `meta deps sync` is the only writer of the lock and the
-snapshot. A Python, Java, Kotlin or C# project that wants to publish a shared model runs a
-TypeScript `sharedModelFile()` generator against its own metadata the same way any TypeScript
-consumer would.
+extra configuration. Java, Kotlin and C# can also *publish*: each ships a `shared-model`
+generator (Java and Kotlin through the Maven plugin's `metaobjects:generate`, C# through
+`dotnet meta gen`; all three ejectable) that writes the artifact and manifest, byte-identical to
+the TypeScript one and gated by `fixtures/shared-model-conformance/`. The Node `meta deps sync`
+stays the only writer of the lock and the snapshot, in every port. A Python project that wants
+to publish a shared model runs a TypeScript `sharedModelFile()` generator against its own
+metadata.
**C#.** `dotnet meta` reads `dependencies` from `.metaobjects/config.json` at every rung of its
source ladder — C# has no native config surface, so the explicit `` form reads the
@@ -710,8 +713,7 @@ but **not** built yet — do not assume any of the following exists:
- **A runtime `ObjectManager` scope predicate.** Nothing at runtime refuses to serve
an imported entity — "what's imported" is a load-time/codegen concept today, not a
runtime one.
-- **Java, Kotlin, Python and C# as publishers** — Java, Kotlin and C# consume (S3 and S6);
- publishing is TypeScript only.
+- **Python as a publisher** — Java, Kotlin and C# publish (S8 and S9); Python consumes only.
- **`npm` and `python` transports in the Python and C# check.** Both ports' check resolves
`path` only; an `npm` or `python` dependency reports `unresolved` and names the Node `meta deps
check`, because resolving it needs the consumer's installed packages (a check that cannot check
diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md
index f8406b5c75..dc01f83abb 100644
--- a/docs/ports/csharp.md
+++ b/docs/ports/csharp.md
@@ -283,6 +283,33 @@ the attribute, node and file, and gen generates anyway. `dotnet meta gen --stric
`verify` does (ADR-0023): the same `ERR_UNKNOWN_ATTR` lines with a gen-specific hint (drop `--strict`), exit 1, before any file is
written. `verify` itself stays strict unless you pass `--lax`.
+## Publishing a shared model (`shared-model`)
+
+A C# project can publish part of its model for another repository to consume (FR-023). The
+`shared-model` generator writes `.metaobjects.json` and the manifest
+`metaobjects.pkg.json` that pins it by `sha256`, byte-identical to what the TypeScript generator
+writes (`fixtures/shared-model-conformance/`). Declare it in `codegen/Generators.cs`:
+
+```csharp
+new SharedModelGenerator
+{
+ ModelName = "acme-common",
+ Include = ["acme::common::**"],
+ Files = ["metaobjects/meta.common.json"],
+ OutDir = "shared",
+}
+```
+
+`ModelName`, `Include` and `Files` are required; `Exclude` removes patterns from `Include`, and
+`Version` overrides the version recorded in the manifest, which otherwise is the project's
+`` (from the project file, else the nearest `Directory.Build.props`). It writes nothing and
+names the option to change when the selection is empty, is not closed over `extends` and reference
+attributes, or needs vocabulary outside the library's. Give `OutDir` a directory that holds nothing
+else. `requirement.*` and `template.*` nodes are never published. `dotnet meta eject shared-model`
+copies the generator into your project; the artifact bytes stay in the package, because a copy that
+changed them would publish a model a consumer's sync refuses. The consumer runs the Node `meta deps
+sync`, which stays the only writer of the lock and the snapshot.
+
## Write your own generator
A generator is a class that implements `IGenerator` (`MetaObjects.Codegen/Generator.cs`): a
diff --git a/docs/ports/java.md b/docs/ports/java.md
index 02da98cc63..c168e6ff6c 100644
--- a/docs/ports/java.md
+++ b/docs/ports/java.md
@@ -423,6 +423,7 @@ name in every port.
| `value-object` | `SpringValueObjectGenerator` | `metaobjects-codegen-spring` | One Java 21 `record` per concrete `object.value` and per sourceless `object.projection`, in the value object's own package. It carries jakarta bean-validation constraints plus `@Valid` on nested members, so a VO jsonb column POSTs and PATCHes with validation cascading to depth ≥ 2. It is THE Java type for the value object (ADR-0056): `Dto` / `Patch` bind to it, a render helper takes it, and a response parser returns it. The template tier declares no copy, so it needs this generator in the same run. |
| `names` | `SpringNamesGenerator` | `metaobjects-codegen-spring` | One `Names.java` per object with a declared/inherited primary `source.rdb` — `public static final` physical database name constants (table/view name, schema, per-field columns). See "`Names`" below. |
| `requirement-tests` | `JUnitRequirementTestsGenerator` | `metaobjects-codegen-spring` | Per metamodel package that holds a tested requirement, two files in `testPackage`: `Requirements__Witnesses.java` (an interface with one default member per non-skipped test, failing with `unimplemented requirement: ...`) and `Requirements__Test.java` (one JUnit Jupiter `@Test` per requirement, calling that member on your `witnessClass`; a planned or retired requirement is `@Disabled`). Args: `testPackage` and `witnessClass` (required), `grain` (`concern` or `member`), `filter` and `renderer` (class names on your project classpath), `warnUncovered`. Your test classpath needs `org.junit.jupiter:junit-jupiter-api`. A model with no requirement writes nothing. See "Requirement tests" below. |
+| `shared-model` | `SharedModelGenerator` | `metaobjects-codegen-base` | FR-023 publishing: writes `.metaobjects.json` (the shared model artifact) and `metaobjects.pkg.json` (its manifest) into `outputDir`, from the top-level nodes your `include` (and optional `exclude`) scope selects out of `files`. Args: `outputDir`, `name`, `include` and `files` (comma-separated paths, resolved against the module basedir) are required; `version` is optional and defaults to the Maven project's ``. A selection that is empty, not closed over `extends` and reference attributes, or that uses vocabulary outside the core set is refused with the reason, and nothing is written. See "Publishing a shared model" below. |
| `entity` | `JavaObjectCodeGenerator` | `metaobjects-codegen-base` | Flavor-selected via the `flavor` generator arg (`com.metaobjects.generator.direct.object.javacode`). `flavor=pojoAware` emits `class extends PojoObject` — a concrete `MetaObjectAware` class whose inherited `getMetaData()` back-reference breaks a default Jackson/Gson mapper (see [Serializing generated objects](#serializing-generated-objects) below). `flavor=valueObject` emits a map-backed `class extends ValueObject` instead. Either flavor also emits a self-registering `ObjectClassBindingProvider`. It writes no `Extractor`: list `extractor` for those (8.0 wrote them from `entity` when no `extractor` was listed; see [the migration](../features/migrations/docs-are-generators.md#java-and-kotlin)). For a plain default-Jackson-friendly type, use the `codegen-spring` record surface instead — never `pojoAware`. |
| `output-parser` | `SpringOutputParserGenerator` | `metaobjects-codegen-spring` | One `Parser` per **responding** `template.prompt` (ADR-0052: one carrying `@responseRef`) — a Jackson-backed throw-only parser returning the `@responseRef` value object's own record (ADR-0056). FR-006 / ADR-0010; the Java sibling of TS's `outputParser()`. See [FR-006 — response parsing](#fr-006--response-parsing) below. |
| `output-prompt` | `SpringOutputPromptGenerator` | `metaobjects-codegen-spring` | One `ResponseFormat` per responding `template.prompt` — a static `renderFormat()` / `renderFormat(PromptOverrides)` pair emitting the output-format prompt fragment (FR-010). The reply's syntax comes from `@responseFormat`, never `@format` (which is the syntax of the rendered prompt BODY). |
@@ -782,6 +783,33 @@ The identity function, the digest and the hook types stay in the package, and th
copy imports them. The model and the other ports' spelling are in
[Generated requirement tests and witnesses](../features/requirements.md#generated-requirement-tests-and-witnesses).
+### Publishing a shared model
+
+`shared-model` (FR-023) publishes a slice of your model so another repository can depend on it.
+Wire it like any generator:
+
+```xml
+
+ com.metaobjects.generator.SharedModelGenerator
+
+ ${project.basedir}/shared
+ acme-common
+ acme::common::**
+ src/main/metaobjects/meta.common.json
+
+
+```
+
+It writes the artifact and the manifest with the same bytes as the TypeScript `sharedModelFile()`:
+the shared corpus (`fixtures/shared-model-conformance/`) is the gate. The version comes from the
+module's `` unless you pass `version`. Requirement and template nodes are never published.
+Moving the artifact into a registry, and recording a consumer's snapshot and lock, is the Node
+`meta deps sync`'s job; this generator only produces the files.
+
+To own it, `mvn metaobjects:eject -Dnames=shared-model -Dport=java` copies `SharedModelGenerator.java`
+into a `codegen/` module (`-Dport` because the name is ejectable on both JVM ports). The artifact
+engine, `com.metaobjects.config.SharedModel`, stays in the package, so the owned copy is thin.
+
### Authoring your own — two paths
**Own a generator.** `mvn metaobjects:eject -Dnames=` copies a reference generator into a `codegen/` Maven module under your own package, and prints the module, dependency and `` to wire. See [Own your codegen → Java and Kotlin](../features/own-your-codegen.md#java-and-kotlin-mvn-metaobjectseject). Every generator in the table above can be ejected except `template` and the deprecated `mustache` (see [the primitive](../features/own-your-codegen.md#java-and-kotlin-mvn-metaobjectseject)).
diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md
index 6f9def8b25..a7e29b2cd6 100644
--- a/docs/ports/kotlin.md
+++ b/docs/ports/kotlin.md
@@ -297,6 +297,16 @@ doc](java.md#consuming-a-shared-model-dependencies): the committed snapshot load
entities can extend and reference it, and no Kotlin generator emits a file for an imported
object (a `codegen-kotlin` test pins that) unless `scope.include` names its package.
+### Publishing a shared model (`shared-model`)
+
+`com.metaobjects.generator.kotlin.KotlinSharedModelGenerator` writes the same artifact and
+manifest as the [Java generator](java.md#publishing-a-shared-model), under the same args
+(`outputDir`, `name`, `include`, `files`, optional `exclude` and `version`, which defaults to
+the module's ``). Both call the one engine in `metaobjects-metadata`, so the bytes match
+the shared corpus (`fixtures/shared-model-conformance/`), and a `codegen-kotlin` test pins that
+by running a corpus case through this class. `mvn metaobjects:eject -Dnames=shared-model -Dport=kotlin`
+copies `KotlinSharedModelGenerator.kt` into a `codegen/` module under your package.
+
### Declarative template-codegen (`TemplateScopeGenerator`)
The 17 generators above are a starting point, not the ceiling. When you need a shape
diff --git a/fixtures/generator-registry-conformance/registry.json b/fixtures/generator-registry-conformance/registry.json
index bbbc651384..ff7dcb3e3b 100644
--- a/fixtures/generator-registry-conformance/registry.json
+++ b/fixtures/generator-registry-conformance/registry.json
@@ -195,7 +195,7 @@
"tier": "native",
"layer": "capability",
"eject": "full",
- "ports": ["typescript"]
+ "ports": ["typescript", "java", "kotlin", "csharp"]
},
"form": {
"concept": "Per-entity React form component over the generated Zod schema.",
@@ -440,6 +440,7 @@
"com.metaobjects.generator.GeneratorIOWriter",
"com.metaobjects.generator.MetaDataFilters",
"com.metaobjects.generator.ProjectClassLoaderAware",
+ "com.metaobjects.generator.ProjectVersionAware",
"com.metaobjects.generator.RunAware",
"com.metaobjects.generator.apidocs.GeneratorApplicability",
"com.metaobjects.generator.direct.FileDirectWriter",
@@ -477,6 +478,7 @@
"com.metaobjects.generator.GeneratorException",
"com.metaobjects.generator.GeneratorIOWriter",
"com.metaobjects.generator.ProjectClassLoaderAware",
+ "com.metaobjects.generator.ProjectVersionAware",
"com.metaobjects.generator.RunAware",
"com.metaobjects.generator.direct.MultiFileDirectGeneratorBase",
"com.metaobjects.generator.kotlin.ConfiguredRequires",
diff --git a/fixtures/shared-model-conformance/README.md b/fixtures/shared-model-conformance/README.md
index cdfe8889ae..bace05e1cd 100644
--- a/fixtures/shared-model-conformance/README.md
+++ b/fixtures/shared-model-conformance/README.md
@@ -80,7 +80,12 @@ attribute `secretHandling` of type string, not required).
- **Python** — the **serializer arm** only: load `files`, take exactly the nodes the pinned
manifest lists, and `serialize_shared_document` must reproduce the artifact byte for byte (and the
manifest's sha256). Python has no publisher (`server/python/tests/conformance/test_shared_model_conformance.py`).
-- **Java / Kotlin / C#** — Phase 2 publishing; each runs everything when it gains a publisher.
+- **Java** — all of it, in `server/java/metadata` (`SharedModelConformanceTest`, against the
+ `SharedModel` engine that the `shared-model` generator in `codegen-base` calls).
+- **Kotlin** — inherits the Java engine and the Maven goals; `KotlinSharedModelGeneratorTest` pins
+ that the Kotlin generator publishes the same bytes.
+- **C#** — all of it, in `MetaObjects.Conformance.Tests` (`SharedModelConformanceTests`, against
+ `MetaObjects.Config.SharedModel`).
## Reference implementation
diff --git a/server/csharp/MetaObjects.Cli.Tests/GenListAndSelectionTests.cs b/server/csharp/MetaObjects.Cli.Tests/GenListAndSelectionTests.cs
index 0e8635e746..af5dbf87b3 100644
--- a/server/csharp/MetaObjects.Cli.Tests/GenListAndSelectionTests.cs
+++ b/server/csharp/MetaObjects.Cli.Tests/GenListAndSelectionTests.cs
@@ -47,7 +47,7 @@ public void ListHeader_says_generators_are_owned_via_eject()
public void ListLines_prints_all_generators_with_stable_names_and_descriptions()
{
var lines = GenCommand.ListLines(_tmp);
- Assert.Equal(14, lines.Count);
+ Assert.Equal(15, lines.Count);
foreach (var name in new[]
{
"entity", "db-context", "routes", "output-parser", "extractor",
diff --git a/server/csharp/MetaObjects.Codegen.Tests/CodegenDriftTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CodegenDriftTests.cs
index 6fa7a1e2ae..b2a498ae6f 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/CodegenDriftTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/CodegenDriftTests.cs
@@ -48,7 +48,8 @@ public CodegenDriftTests()
// from a bare fixture rather than because it is uninteresting:
// render-helper -- requires --template-root for its build-time drift gate
// template -- a PRIMITIVE; real use supplies name/walk/template via config
- private static readonly IReadOnlyList ExcludedFromDrift = ["render-helper", "template"];
+ // shared-model -- publishes the metadata files its options name; a bare fixture names none
+ private static readonly IReadOnlyList ExcludedFromDrift = ["render-helper", "template", "shared-model"];
private static readonly IReadOnlyList DefaultNames =
[.. GeneratorRegistry.Entries.Keys.Where(n => !ExcludedFromDrift.Contains(n))];
diff --git a/server/csharp/MetaObjects.Codegen.Tests/DependencyCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/DependencyCodegenTests.cs
index a75364ab18..f7dd6d69a9 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/DependencyCodegenTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/DependencyCodegenTests.cs
@@ -19,7 +19,8 @@ public sealed class DependencyCodegenTests : IDisposable
private string OutDir => Path.Combine(_tmp, "generated");
// render-helper needs a template root for its drift gate; template is a primitive configured by its caller.
- private static readonly string[] Excluded = ["render-helper", "template"];
+ // shared-model publishes the files its options name and writes no code; a bare fixture names none.
+ private static readonly string[] Excluded = ["render-helper", "template", "shared-model"];
public void Dispose() { try { Directory.Delete(_tmp, recursive: true); } catch { } }
diff --git a/server/csharp/MetaObjects.Codegen.Tests/EjectContractTests.cs b/server/csharp/MetaObjects.Codegen.Tests/EjectContractTests.cs
index a0e72851d3..7c0a3b1487 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/EjectContractTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/EjectContractTests.cs
@@ -279,8 +279,9 @@ List Run(IGenerator generator) =>
Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", IncludeNames = true },
}).OrderBy(f => f.Path, StringComparer.Ordinal).ToList();
- var packaged = Run(GeneratorRegistry.Get(stableName)!.Factory(new GeneratorBuildContext(corpus.TemplateRoot)));
- var owned = Run(Instantiate(copy.Assembly, corpus.TemplateRoot));
+ var packaged = Run(SharedModelTestSupport.Configure(
+ GeneratorRegistry.Get(stableName)!.Factory(new GeneratorBuildContext(corpus.TemplateRoot))));
+ var owned = Run(SharedModelTestSupport.Configure(Instantiate(copy.Assembly, corpus.TemplateRoot)));
emitted += packaged.Count;
Assert.Equal(packaged.Select(f => f.Path), owned.Select(f => f.Path));
for (var i = 0; i < packaged.Count; i++)
diff --git a/server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorsAloneTests.cs b/server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorsAloneTests.cs
index 30ea9f6711..e09fe272da 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorsAloneTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorsAloneTests.cs
@@ -288,7 +288,9 @@ private static List Run(IGenerator generator, MetaRoot root)
public void Ejected_alone_it_emits_what_the_packaged_generator_emits_byte_for_byte(string name)
{
var alone = EjectAlone(name);
- var packaged = GeneratorRegistry.Get(name)!.Factory(new GeneratorBuildContext(CorpusPaths.FitnessTemplateRoot));
+ var packaged = SharedModelTestSupport.Configure(
+ GeneratorRegistry.Get(name)!.Factory(new GeneratorBuildContext(CorpusPaths.FitnessTemplateRoot)));
+ SharedModelTestSupport.Configure(alone.Generator);
// The ONE documented difference: a generator whose output imports the helper runtime imports the
// adopter's owned copy once ejected — `using Codegen.Runtime;` where the packaged output says
// `using MetaObjects.Codegen.Runtime;`. Nothing else.
diff --git a/server/csharp/MetaObjects.Codegen.Tests/GeneratorRegistryTests.cs b/server/csharp/MetaObjects.Codegen.Tests/GeneratorRegistryTests.cs
index bd151c46ac..ea333e5be1 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/GeneratorRegistryTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/GeneratorRegistryTests.cs
@@ -24,6 +24,8 @@ public sealed class GeneratorRegistryTests
"names",
// ADR-0057 — per-requirement xUnit tests calling a project-owned witness.
"requirement-tests",
+ // FR-023 — publishes a shared model: the artifact and the manifest a consumer syncs.
+ "shared-model",
// Report export — the CSV / JSON / XML download of a served report; standalone.
"report-export",
// ADR-0025 — the api reference, an entry in codegen/Generators.cs a project can own.
diff --git a/server/csharp/MetaObjects.Codegen.Tests/NoMagicPhysicalNamesTests.cs b/server/csharp/MetaObjects.Codegen.Tests/NoMagicPhysicalNamesTests.cs
index 4f68b80fdb..3443a969cd 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/NoMagicPhysicalNamesTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/NoMagicPhysicalNamesTests.cs
@@ -409,7 +409,7 @@ private static readonly (string Literal, string ShouldUse, Reach Reach, string W
/// primitive with no default walk; neither can emit here.
///
private static readonly IReadOnlyList GeneratorNames =
- [.. GeneratorRegistry.Entries.Keys.Where(n => n is not ("render-helper" or "template"))];
+ [.. GeneratorRegistry.Entries.Keys.Where(n => n is not ("render-helper" or "template" or "shared-model"))];
/// Run the generator suite over the fixture.
private static IReadOnlyList Generate()
diff --git a/server/csharp/MetaObjects.Codegen.Tests/ReportingInertTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ReportingInertTests.cs
index df54c9c63a..d949379215 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/ReportingInertTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/ReportingInertTests.cs
@@ -85,7 +85,7 @@ private static IGenerator Build(GeneratorRegistryEntry entry) =>
public static TheoryData GeneratorNames()
{
var data = new TheoryData();
- foreach (var name in GeneratorRegistry.Entries.Keys) data.Add(name);
+ foreach (var name in GeneratorRegistry.Entries.Keys.Where(n => n != SharedModelTestSupport.Name)) data.Add(name);
return data;
}
@@ -290,7 +290,9 @@ public void Exactly_these_generators_cannot_run_from_a_bare_model()
.Select(e => e.Key)
.OrderBy(n => n, StringComparer.Ordinal)
.ToList();
- Assert.Equal(new List(), threw);
+ // shared-model publishes the metadata files its options name; a bare model names none, and it writes no
+ // code a report could leak into.
+ Assert.Equal(new List { SharedModelTestSupport.Name }, threw);
}
[Fact]
@@ -311,7 +313,7 @@ public void Every_runnable_generator_in_one_run_emits_the_same_files()
[Fact]
public void A_report_reaches_no_tier_but_its_row_its_mapping_its_allowlist_and_its_routes()
{
- var files = Emit(Load("with"), GeneratorRegistry.Entries.Values.Select(Build).ToList());
+ var files = Emit(Load("with"), GeneratorRegistry.Entries.Values.Where(e => e.Name != SharedModelTestSupport.Name).Select(Build).ToList());
Assert.False(files.ContainsKey(""), files.GetValueOrDefault(""));
// The view-backed report: its row, its allowlist and its routes file; nothing in the
diff --git a/server/csharp/MetaObjects.Codegen.Tests/SharedModelGeneratorTests.cs b/server/csharp/MetaObjects.Codegen.Tests/SharedModelGeneratorTests.cs
new file mode 100644
index 0000000000..ab03d6c3e5
--- /dev/null
+++ b/server/csharp/MetaObjects.Codegen.Tests/SharedModelGeneratorTests.cs
@@ -0,0 +1,109 @@
+// FR-023 publish: the shared-model generator writes the two files the shared corpus
+// (fixtures/shared-model-conformance/) pins, byte for byte, through the path `dotnet meta gen` takes (options
+// in, files out, version from the project). The artifact's bytes themselves are gated by
+// SharedModelConformanceTests in the core test assembly.
+using MetaObjects.Codegen.Generators;
+using MetaObjects.Config;
+using MetaObjects.Loader;
+using MetaObjects.Meta;
+using Xunit;
+
+namespace MetaObjects.Codegen.Tests;
+
+public sealed class SharedModelGeneratorTests : IDisposable
+{
+ private readonly string _out = Directory.CreateTempSubdirectory("mo-shared-model-out-").FullName;
+ private readonly string _project = Directory.CreateTempSubdirectory("mo-shared-model-project-").FullName;
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_out, true); } catch (IOException) { /* temp dir, best effort */ }
+ try { Directory.Delete(_project, true); } catch (IOException) { /* temp dir, best effort */ }
+ }
+
+ private static string Corpus => Path.Combine(CorpusPaths.RepoRoot(), "fixtures", "shared-model-conformance");
+
+ private GenContext Context()
+ {
+ var root = new MetaDataLoader().Load([]).Root;
+ return new GenContext
+ {
+ Entities = [],
+ Root = root,
+ Config = new GenConfig { OutDir = _out, ProjectRoot = _project },
+ };
+ }
+
+ private SharedModelGenerator Generator(string? version = null, string? include = null) => new()
+ {
+ ModelName = "acme-common",
+ Include = [include ?? "acme::common::**"],
+ Exclude = include is null ? ["acme::common::Secret"] : [],
+ Files = [Path.Combine(Corpus, "source/meta.base.json"), Path.Combine(Corpus, "source/meta.customer.json")],
+ Version = version,
+ };
+
+ [Fact]
+ public void WritesTheArtifactAndManifestTheCorpusPins()
+ {
+ File.WriteAllText(Path.Combine(_project, "App.csproj"),
+ "1.0.0");
+
+ var files = Generator().Generate(Context()).ToDictionary(f => f.Path, f => f.Content);
+
+ Assert.Equal(["acme-common.metaobjects.json", "metaobjects.pkg.json"], files.Keys.Order().ToArray());
+ Assert.Equal(
+ File.ReadAllText(Path.Combine(Corpus, "..", "dependency-conformance", "artifacts", "acme-common-v1.json")),
+ files["acme-common.metaobjects.json"]);
+ Assert.Equal(
+ File.ReadAllText(Path.Combine(Corpus, "expected", "acme-common-v1", "metaobjects.pkg.json"))
+ .Replace("@@METAMODEL_VERSION@@", RegistryManifest.MetamodelVersion),
+ files["metaobjects.pkg.json"]);
+ }
+
+ [Fact]
+ public void TheVersionComesFromTheNearestDirectoryBuildPropsWhenTheProjectFileHasNone()
+ {
+ File.WriteAllText(Path.Combine(_project, "App.csproj"), "");
+ File.WriteAllText(Path.Combine(_project, "Directory.Build.props"),
+ "2.3.4");
+
+ var manifest = Generator().Generate(Context()).Single(f => f.Path == "metaobjects.pkg.json").Content;
+
+ Assert.Contains("\"version\": \"2.3.4\"", manifest);
+ }
+
+ [Fact]
+ public void AnExplicitVersionBeatsTheProjectVersion()
+ {
+ File.WriteAllText(Path.Combine(_project, "App.csproj"),
+ "9.9.9");
+
+ var manifest = Generator("1.0.0").Generate(Context()).Single(f => f.Path == "metaobjects.pkg.json").Content;
+
+ Assert.Contains("\"version\": \"1.0.0\"", manifest);
+ }
+
+ [Fact]
+ public void NoVersionAnywhereIsRefusedAfterTheSelectionPasses()
+ {
+ // The temp project sits under no Directory.Build.props the generator can find.
+ var e = Assert.Throws(() => Generator().Generate(Context()).ToList());
+ Assert.Contains("no version available", e.Message);
+ }
+
+ [Fact]
+ public void ARefusedSelectionSaysWhy()
+ {
+ var e = Assert.Throws(() => Generator("1.0.0", "nothing::**").Generate(Context()).ToList());
+ Assert.Equal(SharedModelRefusalReason.EmptySelection, e.Reason);
+ }
+
+ [Fact]
+ public void AMissingOptionNamesTheOption()
+ {
+ var e = Assert.Throws(() =>
+ new SharedModelGenerator { Include = ["a::**"], Files = ["x.json"] }.Generate(Context()).ToList());
+ Assert.Contains("ModelName", e.Message);
+ }
+}
diff --git a/server/csharp/MetaObjects.Codegen.Tests/SharedModelTestSupport.cs b/server/csharp/MetaObjects.Codegen.Tests/SharedModelTestSupport.cs
new file mode 100644
index 0000000000..d7ec854dba
--- /dev/null
+++ b/server/csharp/MetaObjects.Codegen.Tests/SharedModelTestSupport.cs
@@ -0,0 +1,26 @@
+// The shared-model generator publishes the metadata files its options name, so unlike every other
+// generator it cannot run bare over a loaded corpus. The suites that run every registered generator (the eject
+// parity ones) configure it here, through its public properties, so the same code serves the packaged type and
+// an owned copy compiled from its source.
+namespace MetaObjects.Codegen.Tests;
+
+internal static class SharedModelTestSupport
+{
+ internal const string Name = "shared-model";
+
+ /// Set the options of the corpus case publishes-the-pinned-acme-common-artifact on
+ /// when it is the shared-model generator (packaged or owned); any other is untouched.
+ internal static IGenerator Configure(IGenerator generator)
+ {
+ if (generator.Name != Name) return generator;
+ var corpus = Path.Combine(CorpusPaths.RepoRoot(), "fixtures", "shared-model-conformance");
+ void Set(string property, object value) =>
+ generator.GetType().GetProperty(property)!.SetValue(generator, value);
+ Set("ModelName", "acme-common");
+ Set("Include", new[] { "acme::common::**" });
+ Set("Exclude", new[] { "acme::common::Secret" });
+ Set("Files", new[] { Path.Combine(corpus, "source/meta.base.json"), Path.Combine(corpus, "source/meta.customer.json") });
+ Set("Version", "1.0.0");
+ return generator;
+ }
+}
diff --git a/server/csharp/MetaObjects.Codegen/GeneratorRegistry.cs b/server/csharp/MetaObjects.Codegen/GeneratorRegistry.cs
index 09cb1f9311..5d32717045 100644
--- a/server/csharp/MetaObjects.Codegen/GeneratorRegistry.cs
+++ b/server/csharp/MetaObjects.Codegen/GeneratorRegistry.cs
@@ -318,6 +318,21 @@ private static IGenerator RenderHelper(GeneratorBuildContext ctx) =>
// The modules are written over the report's row class and called from its routes file.
Requires = ["entity", "routes"],
},
+ // FR-023 — publishes a shared model: the artifact and the manifest a consumer's `meta deps sync`
+ // copies. Its options (ModelName, Include, Exclude, Files, Version, Providers) are public init
+ // properties, set where the generator is declared in codegen/Generators.cs. The artifact bytes are
+ // MetaObjects.Config.SharedModel's, in the package, so an owned copy cannot change them.
+ ["shared-model"] = new()
+ {
+ Name = "shared-model",
+ Description = "FR-023: a publisher's flattened shared-model artifact + manifest for a consumer's `meta deps sync`.",
+ Tier = GeneratorTier.Native,
+ Layer = GeneratorLayer.Capability,
+ Factory = _ => new SharedModelGenerator(),
+ Options = "ModelName, Include, Files (required); Exclude, Version, Providers, OutDir (init properties on the entry in codegen/Generators.cs). Version defaults to the project's .",
+ Note = "Writes .metaobjects.json and metaobjects.pkg.json. A selection that is empty, not closed over extends and reference attributes, or that needs vocabulary outside the library's is refused with the reason and nothing is written. Give it an OutDir of its own.",
+ SourceFileName = "SharedModelGenerator.cs",
+ },
// ADR-0057 — one xUnit test per declared requirement, calling a project-owned witness.
// Its six options (TestNamespace, WitnessClass, Grain, Filter, Renderer, WarnUncovered) are
// public init properties: this port has no per-generator option channel, and a project sets
diff --git a/server/csharp/MetaObjects.Codegen/Generators/SharedModelGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/SharedModelGenerator.cs
new file mode 100644
index 0000000000..8d6ef5db3e
--- /dev/null
+++ b/server/csharp/MetaObjects.Codegen/Generators/SharedModelGenerator.cs
@@ -0,0 +1,113 @@
+// shared-model — publishes a project's shared model (FR-023): a flattened, canonical-JSON artifact
+// .metaobjects.json plus the manifest metaobjects.pkg.json that pins it, for a consumer's
+// `meta deps sync`. The consumer's `meta deps check` / `dotnet meta verify --deps` then compares the two.
+//
+// This file decides what belongs to the PUBLISHING PROJECT: which of its source files may contribute
+// (Files), which nodes are published (Include / Exclude) and which version is recorded (Version, else the
+// project's ). Everything a consumer checks is MetaObjects.Config.SharedModel's, in the package:
+// what is selected, that the selection is closed, the document's bytes, the hash over them and the
+// manifest's shape and file names. It is a reference helper (ADR-0034 Amendment 3): `dotnet meta eject
+// shared-model` copies this one file into your project, and the artifact bytes stay in the package, because
+// a copy that changed them would publish a model a consumer's sync refuses, or accepts and cannot load.
+//
+// OPTIONS are public init properties, set where the generator is declared in codegen/Generators.cs:
+//
+// new SharedModelGenerator
+// {
+// ModelName = "acme-common",
+// Include = ["acme::common::**"],
+// Files = ["metaobjects/meta.common.json"],
+// OutDir = "shared",
+// }
+//
+// Writes nothing and says why (an exception naming the option to change) when the selection is empty, is
+// not closed over `extends` and reference attributes, or needs vocabulary outside the library's.
+
+using System.Xml.Linq;
+using MetaObjects.Config;
+using MetaObjects.Meta;
+
+namespace MetaObjects.Codegen.Generators;
+
+/// Publishes a shared model: the artifact and the manifest that pins it (FR-023).
+public class SharedModelGenerator : IGenerator
+{
+ public string Name => "shared-model";
+
+ /// Where the artifact and the manifest are written. Give it a directory that holds nothing else:
+ /// verify --codegen compares the whole tree. null writes under the run's --out.
+ public string? OutDir { get; init; }
+
+ /// The dependency key a consumer syncs under. The artifact is <ModelName>.metaobjects.json.
+ /// Required.
+ public string? ModelName { get; init; }
+
+ /// Scope patterns naming what to publish, such as acme::common::** (the grammar of
+ /// scope). Required: an empty list would publish everything by accident.
+ public IReadOnlyList Include { get; init; } = [];
+
+ /// Scope patterns removed from .
+ public IReadOnlyList Exclude { get; init; } = [];
+
+ /// The metadata files that may contribute, relative to the project root. Required. They load
+ /// standalone: a node in a file not named here cannot be published, and one the selection refers to
+ /// must be in a named file.
+ public IReadOnlyList Files { get; init; } = [];
+
+ /// The version recorded in the manifest. Default: the <Version> of the project (read
+ /// from its project file, else the nearest Directory.Build.props).
+ public string? Version { get; init; }
+
+ /// Your own vocabulary providers, so attributes they register load here as they did for the
+ /// project's main load. The artifact must still load WITHOUT them: a consumer does not have them.
+ public IReadOnlyList? Providers { get; init; }
+
+ public IEnumerable Generate(GenContext ctx)
+ {
+ var name = Required(ModelName, nameof(ModelName), "the dependency key a consumer will sync under");
+ if (Include.Count == 0) throw Missing(nameof(Include), "the scope patterns to publish, such as acme::common::**");
+ if (Files.Count == 0) throw Missing(nameof(Files), "the metadata files that may contribute");
+
+ var projectRoot = ctx.Config.ProjectRoot ?? Directory.GetCurrentDirectory();
+ var model = SharedModel.Build(new SharedModelInput(
+ name,
+ Files.Select(f => Path.GetFullPath(f, projectRoot)).ToList(),
+ Include, Exclude, Providers));
+
+ // Resolved after the selection has passed every check, so a model that cannot be published says
+ // so before a missing version does.
+ var version = Version?.Trim() is { Length: > 0 } explicitVersion
+ ? explicitVersion
+ : ProjectVersion(projectRoot)
+ ?? throw new InvalidOperationException(
+ $"shared-model \"{name}\": no version available - set Version on the generator, or give the " +
+ $"project a in its project file or Directory.Build.props (looked from {projectRoot}).");
+
+ var files = SharedModel.Render(model, version);
+ return [new EmittedFile(files.ArtifactPath, files.Artifact), new EmittedFile(files.ManifestPath, files.Manifest)];
+ }
+
+ private static string Required(string? value, string option, string what) =>
+ string.IsNullOrWhiteSpace(value) ? throw Missing(option, what) : value.Trim();
+
+ private static InvalidOperationException Missing(string option, string what) =>
+ new($"shared-model: set {option} on the generator ({what}): new SharedModelGenerator {{ {option} = ... }}");
+
+ /// The <Version> the project builds with: the project file in the root, then
+ /// Directory.Build.props in the root or the nearest directory above it. null when none declares one.
+ private static string? ProjectVersion(string projectRoot)
+ {
+ foreach (var project in Directory.EnumerateFiles(projectRoot, "*.csproj").Order(StringComparer.Ordinal))
+ if (VersionIn(project) is { } own) return own;
+ for (var dir = new DirectoryInfo(projectRoot); dir is not null; dir = dir.Parent)
+ if (VersionIn(Path.Combine(dir.FullName, "Directory.Build.props")) is { } inherited) return inherited;
+ return null;
+ }
+
+ private static string? VersionIn(string file)
+ {
+ if (!File.Exists(file)) return null;
+ var version = XDocument.Load(file).Descendants().FirstOrDefault(e => e.Name.LocalName == "Version")?.Value.Trim();
+ return string.IsNullOrEmpty(version) ? null : version;
+ }
+}
diff --git a/server/csharp/MetaObjects.Codegen/MetaObjects.Codegen.csproj b/server/csharp/MetaObjects.Codegen/MetaObjects.Codegen.csproj
index c829561da5..aa0b82d43f 100644
--- a/server/csharp/MetaObjects.Codegen/MetaObjects.Codegen.csproj
+++ b/server/csharp/MetaObjects.Codegen/MetaObjects.Codegen.csproj
@@ -56,6 +56,7 @@
+
diff --git a/server/csharp/MetaObjects.Conformance.Tests/SharedModelConformanceTests.cs b/server/csharp/MetaObjects.Conformance.Tests/SharedModelConformanceTests.cs
new file mode 100644
index 0000000000..643801b093
--- /dev/null
+++ b/server/csharp/MetaObjects.Conformance.Tests/SharedModelConformanceTests.cs
@@ -0,0 +1,95 @@
+// Runs the shared FR-023 publish corpus (fixtures/shared-model-conformance/cases.json) against this port.
+// The reference is server/typescript/packages/codegen-ts/src/shared-model/build-shared-model.ts;
+// MetaObjects.Config.SharedModel is this port's counterpart. Every publish case pins the artifact and the
+// manifest BYTES; every refusal pins its reason (and, for not-closed, the exhaustive sorted pairs). The
+// message text is not the contract.
+using System.Text.Json;
+using MetaObjects.Config;
+using MetaObjects.Meta;
+using Xunit;
+
+namespace MetaObjects.Conformance.Tests;
+
+public class SharedModelConformanceTests
+{
+ private const string MetamodelVersionToken = "@@METAMODEL_VERSION@@";
+
+ /// The corpus's secret-handling provider: one common string attribute no library provider ships.
+ private sealed class SecretHandlingProvider : IMetaDataTypeProvider
+ {
+ public string Id => "secret-handling";
+ public IReadOnlyList Dependencies => ["metaobjects-core-types"];
+
+ public void RegisterTypes(TypeRegistry registry) =>
+ registry.RegisterCommonAttrs([new AttrSchema("secretHandling", "string", Required: false, Description: "Test vocabulary.")]);
+ }
+
+ private static string CorpusDir()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "fixtures", "shared-model-conformance")))
+ dir = dir.Parent;
+ Assert.NotNull(dir);
+ return Path.Combine(dir!.FullName, "fixtures", "shared-model-conformance");
+ }
+
+ private static JsonElement Cases()
+ {
+ using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(CorpusDir(), "cases.json")));
+ return doc.RootElement.GetProperty("cases").Clone();
+ }
+
+ public static TheoryData CaseNames()
+ {
+ var data = new TheoryData();
+ foreach (var c in Cases().EnumerateArray()) data.Add(c.GetProperty("name").GetString()!);
+ return data;
+ }
+
+ private static List Strings(JsonElement obj, string key) =>
+ obj.TryGetProperty(key, out var arr) ? arr.EnumerateArray().Select(e => e.GetString()!).ToList() : [];
+
+ [Fact]
+ public void TheCorpusIsNotEmpty() => Assert.NotEmpty(Cases().EnumerateArray());
+
+ [Theory]
+ [MemberData(nameof(CaseNames))]
+ public void PublishesLikeTheReference(string caseName)
+ {
+ var c = Cases().EnumerateArray().Single(x => x.GetProperty("name").GetString() == caseName);
+ var input = c.GetProperty("input");
+ var providers = new List();
+ foreach (var p in Strings(input, "providers"))
+ {
+ Assert.Equal("secret-handling", p);
+ providers.Add(new SecretHandlingProvider());
+ }
+ var req = new SharedModelInput(
+ input.GetProperty("name").GetString()!,
+ Strings(input, "files").Select(f => Path.Combine(CorpusDir(), f)).ToList(),
+ Strings(input, "include"), Strings(input, "exclude"), providers);
+
+ if (c.TryGetProperty("expectRefusal", out var refusal))
+ {
+ var thrown = Assert.Throws(() => SharedModel.Build(req));
+ Assert.Equal(refusal.GetProperty("reason").GetString(), thrown.ReasonId);
+ if (refusal.TryGetProperty("pairs", out var pairs))
+ {
+ var expected = pairs.EnumerateArray()
+ .Select(p => new SharedModelPair(p.GetProperty("referrer").GetString()!, p.GetProperty("target").GetString()!))
+ .ToList();
+ Assert.Equal(expected, thrown.Pairs.ToList());
+ }
+ return;
+ }
+
+ var files = SharedModel.Render(SharedModel.Build(req), input.GetProperty("version").GetString()!);
+ var expectedArtifact = File.ReadAllText(Path.Combine(CorpusDir(), c.GetProperty("expectArtifact").GetString()!));
+ var expectedManifest = File.ReadAllText(Path.Combine(CorpusDir(), c.GetProperty("expectManifest").GetString()!))
+ .Replace(MetamodelVersionToken, RegistryManifest.MetamodelVersion);
+ Assert.Equal(expectedArtifact, files.Artifact);
+ Assert.Equal(expectedManifest, files.Manifest);
+ Assert.Equal(input.GetProperty("name").GetString() + ".metaobjects.json", files.ArtifactPath);
+ Assert.Equal("metaobjects.pkg.json", files.ManifestPath);
+ }
+}
diff --git a/server/csharp/MetaObjects/Config/SharedModel.cs b/server/csharp/MetaObjects/Config/SharedModel.cs
new file mode 100644
index 0000000000..abe43a2aa7
--- /dev/null
+++ b/server/csharp/MetaObjects/Config/SharedModel.cs
@@ -0,0 +1,252 @@
+// FR-023 — the publisher's half of a shared model: the flattened artifact and the manifest that pins it,
+// the pair a consumer's `meta deps sync` copies into its own repo and verifies (design §2.2, §3.2, §3.5).
+//
+// Mirrors codegen-ts/src/shared-model/build-shared-model.ts and the Java com.metaobjects.config.SharedModel;
+// the shared corpus fixtures/shared-model-conformance/ pins the bytes and the refusals.
+//
+// Two stages, both pure of the run, and both in the package on purpose. A generator an application owns
+// (`dotnet meta eject shared-model`) decides which source files may contribute and which version is
+// published, and calls these for everything a consumer checks: what is selected, that the selection is
+// closed, the document's bytes, the hash over them, and the manifest's shape and file names. An owned
+// copy that changed any of those would publish an artifact a consumer's sync refuses, or worse, accepts
+// and cannot load, so they are not in the copy.
+//
+// Build — standalone strict load of the files with the publisher's own providers; select top-level
+// nodes (never requirement.* or template.*) by include / exclude; check the selection is closed
+// over `extends` and every ref-bearing attribute; serialize; re-load the document with the
+// library's own providers only.
+// Render — .metaobjects.json and metaobjects.pkg.json, the manifest carrying the sha256 of the
+// artifact's bytes.
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using MetaObjects.Loader;
+using MetaObjects.Meta;
+
+namespace MetaObjects.Config;
+
+/// Why a publisher refused to publish; the corpus's expectRefusal.reason.
+public enum SharedModelRefusalReason
+{
+ /// The scope selected no node.
+ EmptySelection,
+
+ /// A selected node refers to one that is not selected.
+ NotClosed,
+
+ /// The artifact needs vocabulary only the publisher's own providers register.
+ NonCoreVocabulary,
+}
+
+/// One (referrer, target) resolution-key pair a selection is not closed over.
+public sealed record SharedModelPair(string Referrer, string Target);
+
+/// A selection that cannot be published; carries the classified reason and, for not-closed, every pair.
+public sealed class SharedModelRefusal(
+ SharedModelRefusalReason reason, string message, IReadOnlyList pairs)
+ : InvalidOperationException(message)
+{
+ /// The classified reason.
+ public SharedModelRefusalReason Reason { get; } = reason;
+
+ /// Sorted by referrer then target (ordinal); empty unless .
+ public IReadOnlyList Pairs { get; } = pairs;
+
+ /// The corpus spelling of .
+ public string ReasonId => Reason switch
+ {
+ SharedModelRefusalReason.EmptySelection => "empty-selection",
+ SharedModelRefusalReason.NotClosed => "not-closed",
+ _ => "non-core-vocabulary",
+ };
+}
+
+/// What to publish.
+/// The dependency key a consumer will sync under; also the artifact's basename.
+/// The publisher's own source files that may contribute, loaded standalone.
+/// Scope patterns (the same grammar as scope); empty selects everything.
+/// Applied after .
+/// The publisher's own vocabulary providers, so its extra attributes load as they
+/// did for the project's main load; empty is the library's own vocabulary.
+public sealed record SharedModelInput(
+ string Name,
+ IReadOnlyList Files,
+ IReadOnlyList? Include = null,
+ IReadOnlyList? Exclude = null,
+ IReadOnlyList? Providers = null);
+
+///
+/// A selection that passed every check, serialized. The document is exactly the bytes that were re-loaded
+/// with the library's vocabulary, and the manifest's hash is taken over them.
+///
+/// The dependency key.
+/// The canonical-JSON metadata.root document: the artifact's bytes.
+/// The metadata packages the selection spans, sorted.
+/// The resolution key of every selected top-level node, sorted.
+public sealed record SharedModelBuilt(
+ string Name, string Document, IReadOnlyList Packages, IReadOnlyList Nodes);
+
+/// The two files a shared model is published as.
+public sealed record SharedModelFiles(string ArtifactPath, string Artifact, string ManifestPath, string Manifest);
+
+/// The FR-023 publisher.
+public static class SharedModel
+{
+ /// The manifest schema's own version, distinct from metamodelVersion.
+ private const int ManifestSchemaVersion = 1;
+
+ private static readonly JsonSerializerOptions ManifestOptions = new()
+ {
+ WriteIndented = true,
+ // JSON.stringify does not escape <, >, & or '. Match that.
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ };
+
+ /// <name>.metaobjects.json: the artifact's file name, as meta deps sync reads it.
+ public static string ArtifactFileName(string name) => name + DependencyFiles.ArtifactSuffix;
+
+ ///
+ /// Load the files standalone, select by include / exclude, and return the selection as a checked,
+ /// serialized shared model (design §4.3 steps 1 to 5).
+ ///
+ /// The selection is empty, is not closed, or needs vocabulary a
+ /// consumer's toolchain does not ship.
+ /// The files do not load.
+ public static SharedModelBuilt Build(SharedModelInput input)
+ {
+ // Step 1: standalone strict load, the publisher's own providers.
+ var root = Load(input.Providers, input.Files.Select(f => (IMetaDataSource)new FileSource(f)).ToList()).Root;
+
+ // Step 2: select. The root has no super chain (ADR-0039 sanctioned own read).
+ var scope = Scope.CompileScope(input.Include, input.Exclude);
+ var selected = root.OwnChildren()
+ .Where(n => n.Type != TYPE_REQUIREMENT && n.Type != TYPE_TEMPLATE)
+ .Where(n => Scope.MatchesScope(n.ResolutionKey(), scope))
+ .ToList();
+ if (selected.Count == 0)
+ {
+ throw new SharedModelRefusal(SharedModelRefusalReason.EmptySelection,
+ $"shared-model \"{input.Name}\": selected no nodes (include/exclude, always excluding " +
+ "requirement.* and template.*) - nothing to publish. Check `include`.", []);
+ }
+
+ // Step 3: closure.
+ var selectedKeys = selected.Select(n => n.ResolutionKey()).ToHashSet(StringComparer.Ordinal);
+ var failures = CheckClosure(root, selected, selectedKeys);
+ if (failures.Count > 0)
+ {
+ var lines = string.Concat(failures.Select(p => $"\n {p.Referrer} -> {p.Target}"));
+ throw new SharedModelRefusal(SharedModelRefusalReason.NotClosed,
+ $"shared-model \"{input.Name}\": the selection is not closed - these references point " +
+ "outside it; include it or exclude the referrer:" + lines, failures);
+ }
+
+ // Step 4: serialize (sorted by resolution key).
+ var document = SerializerJson.SerializeSharedDocument(selected);
+
+ // Step 5: re-load with the library's own providers only. A consumer loads the artifact with ITS
+ // providers, never the publisher's, so an export needing publisher-only vocabulary fails HERE.
+ try
+ {
+ Load(null, [new InMemoryStringSource(document, ArtifactFileName(input.Name))]);
+ }
+ catch (Exception e) when (e is MetaModelException or InvalidOperationException)
+ {
+ throw new SharedModelRefusal(SharedModelRefusalReason.NonCoreVocabulary,
+ $"shared-model \"{input.Name}\": the export needs a provider this toolchain does not " +
+ $"ship; Phase 1 exports must load with core vocabulary: {e.Message}", []);
+ }
+
+ var packages = selected.Select(n => DependencyResolver.PackageOf(n.ResolutionKey()))
+ .Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList();
+ var nodes = selected.Select(n => n.ResolutionKey()).Order(StringComparer.Ordinal).ToList();
+ return new SharedModelBuilt(input.Name, document, packages, nodes);
+ }
+
+ ///
+ /// The artifact and the manifest that pins it (design §4.3 step 6, §3.2): the manifest names the
+ /// artifact and carries the sha256 of its bytes, so the pair is rendered together and never one
+ /// without the other.
+ ///
+ public static SharedModelFiles Render(SharedModelBuilt model, string version)
+ {
+ var artifactPath = ArtifactFileName(model.Name);
+ var manifest = new JsonObject
+ {
+ ["schema_version"] = ManifestSchemaVersion,
+ ["name"] = model.Name,
+ ["version"] = version,
+ ["metamodelVersion"] = RegistryManifest.MetamodelVersion,
+ ["artifact"] = artifactPath,
+ ["integrity"] = DependencyResolver.Sha256Integrity(Encoding.UTF8.GetBytes(model.Document)),
+ ["packages"] = new JsonArray(model.Packages.Select(p => (JsonNode?)JsonValue.Create(p)).ToArray()),
+ ["nodes"] = new JsonArray(model.Nodes.Select(p => (JsonNode?)JsonValue.Create(p)).ToArray()),
+ };
+ return new SharedModelFiles(
+ artifactPath, model.Document, DependencyResolver.ManifestFile,
+ manifest.ToJsonString(ManifestOptions) + "\n");
+ }
+
+ private static LoadResult Load(IReadOnlyList? providers, IReadOnlyList sources)
+ {
+ var registry = Provider.ComposeRegistry(ProviderLoader.Compose(providers));
+ var result = new MetaDataLoader(registry, freeze: true, strict: true).Load(sources);
+ if (result.Errors.Count > 0)
+ throw new MetaModelException(result.Errors[0].Message, result.Errors[0].Code);
+ return result;
+ }
+
+ // ------------------------------------------------------------------------------------
+ // closure
+
+ /// Depth-first walk of a node and its OWN descendants, node itself first. ADR-0039
+ /// sanctioned own-only case: the shared document emits each selected node's OWN layer only
+ /// (extends preserved, not flattened), so the closure check walks exactly what will be emitted.
+ private static void WalkOwn(MetaData node, List output)
+ {
+ output.Add(node);
+ foreach (var child in node.OwnChildren()) WalkOwn(child, output);
+ }
+
+ private static List CheckClosure(
+ MetaData root, IReadOnlyList selected, HashSet selectedKeys)
+ {
+ var seen = new HashSet();
+ var pairs = new List();
+ foreach (var top in selected)
+ {
+ var referrerKey = top.ResolutionKey();
+ // R3: every own reference of a node resolves in the package of the top-level node that declares it.
+ var referrerPkg = NamingRefs.EffectivePackageOf(top);
+ var walk = new List();
+ WalkOwn(top, walk);
+ foreach (var node in walk)
+ {
+ var candidates = new List();
+ if ((node.AuthoredSuperRef ?? node.SuperRef) is { } superRef) candidates.Add(superRef);
+ // ADR-0039 sanctioned own-only case: an INHERITED ref-bearing attr belongs to the base
+ // node's own layer and is checked when the base itself is walked.
+ foreach (var name in NamingRefs.RefBearingAttrNames.Order(StringComparer.Ordinal))
+ {
+ if (node.OwnAttr(name) is string s) candidates.Add(s);
+ }
+ foreach (var raw in candidates)
+ {
+ var target = NamingRefs.ResolveObjectRef(root, NamingRefs.SplitChildTail(raw).Owner, referrerPkg);
+ if (target is null) continue; // already validated at load: defensive only
+ var targetKey = target.ResolutionKey();
+ if (selectedKeys.Contains(targetKey)) continue;
+ var pair = new SharedModelPair(referrerKey, targetKey);
+ if (seen.Add(pair)) pairs.Add(pair);
+ }
+ }
+ }
+ pairs.Sort((a, b) =>
+ {
+ var byReferrer = string.CompareOrdinal(a.Referrer, b.Referrer);
+ return byReferrer != 0 ? byReferrer : string.CompareOrdinal(a.Target, b.Target);
+ });
+ return pairs;
+ }
+}
diff --git a/server/csharp/MetaObjects/SerializerJson.cs b/server/csharp/MetaObjects/SerializerJson.cs
index cc489aa866..b86ad83860 100644
--- a/server/csharp/MetaObjects/SerializerJson.cs
+++ b/server/csharp/MetaObjects/SerializerJson.cs
@@ -64,6 +64,55 @@ public static string CanonicalSerializeEffective(MetaData model)
return Stringify(sorted) + "\n";
}
+ ///
+ /// The FR-023 shared-model artifact: one canonical-JSON metadata.root holding the given
+ /// top-level nodes, from any number of packages. The document has NO root package, and every
+ /// node carries its own explicit, RESOLVED package (a root-level child may name its package,
+ /// ADR-0029's addressing model), so the artifact re-loads to the same resolution keys in every port.
+ /// Each node is its form: raw own layer, extends preserved
+ /// (never flattened), attribute keys alphabetized. Nodes are sorted by resolution key (ordinal);
+ /// each node's children keep their authored order. Body key order: name, package,
+ /// then the canonical rest. Byte-identical to the TypeScript serializeSharedDocument.
+ ///
+ /// A node has no package: a shared document carries
+ /// only packaged nodes.
+ public static string SerializeSharedDocument(IEnumerable nodes)
+ {
+ var children = new JsonArray();
+ foreach (var node in nodes.OrderBy(n => n.ResolutionKey(), StringComparer.Ordinal))
+ {
+ var key = node.ResolutionKey();
+ var sep = key.LastIndexOf(PACKAGE_SEPARATOR, StringComparison.Ordinal);
+ var pkg = sep == -1 ? "" : key[..sep];
+ if (pkg.Length == 0)
+ throw new InvalidOperationException(
+ $"SerializeSharedDocument: {key} has no package; a shared document carries only packaged nodes");
+
+ var parsed = (JsonObject)JsonNode.Parse(CanonicalSerialize(node))!;
+ var (fused, bodyNode) = parsed.Single();
+ var body = (JsonObject)bodyNode!;
+ // The node's own `package` (if it declared one) is replaced by the RESOLVED one: a node
+ // inheriting its file's root package declares none of its own.
+ var ordered = new JsonObject();
+ var name = body[RESERVED_KEY_NAME];
+ body.Remove(RESERVED_KEY_NAME);
+ ordered.Add(RESERVED_KEY_NAME, name);
+ body.Remove(RESERVED_KEY_PACKAGE);
+ ordered.Add(RESERVED_KEY_PACKAGE, pkg);
+ foreach (var (k, v) in body.ToList())
+ {
+ body.Remove(k);
+ ordered.Add(k, v);
+ }
+ children.Add(new JsonObject { [fused] = ordered });
+ }
+ var doc = new JsonObject
+ {
+ [FusedKey(TYPE_METADATA, SUBTYPE_ROOT)] = new JsonObject { [RESERVED_KEY_CHILDREN] = children },
+ };
+ return Stringify(doc) + "\n";
+ }
+
// ---------------------------------------------------------------------------
// FR-016 / ADR-0018 — per-kind physical-name rewrite (pre-sort).
//
diff --git a/server/java/codegen-base/pom.xml b/server/java/codegen-base/pom.xml
index f0d07409ea..31cbf479a2 100644
--- a/server/java/codegen-base/pom.xml
+++ b/server/java/codegen-base/pom.xml
@@ -138,6 +138,14 @@
BaseObjectCodeWriter.java
+
+
+ src/main/java/com/metaobjects/generator
+
+ SharedModelGenerator.java
+
+
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/ProjectVersionAware.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/ProjectVersionAware.java
new file mode 100644
index 0000000000..a4f0c20f55
--- /dev/null
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/ProjectVersionAware.java
@@ -0,0 +1,15 @@
+package com.metaobjects.generator;
+
+/**
+ * Implemented by a generator that publishes the version of the project it runs in (the shared
+ * model's manifest records it). The Maven plugin hands such a generator {@code ${project.version}}
+ * right after constructing it, so a pom need not repeat the version in an arg.
+ *
+ *
A generator that needs no project version does not implement this. An explicit arg a
+ * generator reads still wins; this is only the default.
+ */
+public interface ProjectVersionAware {
+
+ /** The running project's version, as Maven reports it. Never {@code null}. */
+ void setProjectVersion(String projectVersion);
+}
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/SharedModelGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/SharedModelGenerator.java
new file mode 100644
index 0000000000..ef0f528429
--- /dev/null
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/SharedModelGenerator.java
@@ -0,0 +1,91 @@
+package com.metaobjects.generator;
+
+import com.metaobjects.config.SharedModel;
+import com.metaobjects.generator.FileEmittingGenerator;
+import com.metaobjects.generator.GeneratorException;
+import com.metaobjects.generator.ProjectVersionAware;
+import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.registry.MetaDataTypeProvider;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * FR-023: publishes a project's shared model - a flattened, canonical-JSON artifact plus the
+ * manifest that pins it - for a consumer's {@code meta deps sync}.
+ *
+ *
This file decides what belongs to the PUBLISHING PROJECT: which of its source files may
+ * contribute ({@code files}), which nodes are published ({@code include} / {@code exclude}) and
+ * which version is recorded. Everything a consumer checks is {@link SharedModel}'s, in the package:
+ * what is selected, that the selection is closed, the document's bytes, the hash over them and the
+ * manifest's shape and file names. It is a reference helper (ADR-0034 Amendment 3):
+ * {@code mvn metaobjects:eject -Dnames=shared-model} copies this one file into your project, and
+ * the artifact bytes stay in the package, because a copy that changed them would publish a model a
+ * consumer's sync refuses, or accepts and cannot load.
+ *
+ *
Args: {@code outputDir}, {@code name} (the dependency key a consumer syncs under; the artifact
+ * is {@code .metaobjects.json}), {@code include} (comma-separated scope patterns, such as
+ * {@code acme::common::**}), {@code files} (comma-separated metadata files that may contribute),
+ * and optionally {@code exclude} (applied after {@code include}) and {@code version} (default: the
+ * project's version).
+ */
+public class SharedModelGenerator extends FileEmittingGenerator implements ProjectVersionAware {
+
+ public static final String ARG_NAME = "name";
+ public static final String ARG_INCLUDE = "include";
+ public static final String ARG_EXCLUDE = "exclude";
+ public static final String ARG_FILES = "files";
+ public static final String ARG_VERSION = "version";
+
+ private String projectVersion;
+
+ @Override
+ public void setProjectVersion(String projectVersion) {
+ this.projectVersion = projectVersion;
+ }
+
+ @Override
+ protected List generate(MetaDataLoader loader) {
+ String name = required(ARG_NAME, "the dependency key a consumer will sync under");
+ List include = list(required(ARG_INCLUDE, "the scope patterns to publish, such as acme::common::**"));
+ List exclude = hasArg(ARG_EXCLUDE) ? list(getArg(ARG_EXCLUDE)) : List.of();
+ List files = new ArrayList<>();
+ for (String f : list(required(ARG_FILES, "the metadata files that may contribute, comma-separated"))) {
+ files.add(Path.of(f));
+ }
+ // The providers the project's own load used, so its extra attributes load here too.
+ List providers = loader.getLoaderOptions().getProviders();
+
+ SharedModel.Built model = SharedModel.build(new SharedModel.Input(name, files, include, exclude, providers));
+
+ // Resolved after the selection has passed every check, so a model that cannot be published
+ // says so before a missing version does.
+ String version = hasArg(ARG_VERSION) ? getArg(ARG_VERSION).trim() : projectVersion;
+ if (version == null || version.isBlank()) {
+ throw new GeneratorException("shared-model \"" + name + "\": no version available - set <"
+ + ARG_VERSION + ">..." + ARG_VERSION + ">, or run through the Maven plugin, which supplies ${project.version}.");
+ }
+
+ SharedModel.Files out = SharedModel.render(model, version);
+ return List.of(new EmittedFile(out.artifactPath(), out.artifact()),
+ new EmittedFile(out.manifestPath(), out.manifest()));
+ }
+
+ private String required(String arg, String what) {
+ String value = getArg(arg);
+ if (value == null || value.isBlank()) {
+ throw new GeneratorException("shared-model: set '" + arg + "' with <" + arg + ">..." + arg
+ + "> (" + what + ")");
+ }
+ return value.trim();
+ }
+
+ private static List list(String commaSeparated) {
+ List out = new ArrayList<>();
+ for (String part : commaSeparated.split(",")) {
+ if (!part.isBlank()) out.add(part.trim());
+ }
+ return out;
+ }
+}
diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/SharedModelGeneratorTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/SharedModelGeneratorTest.java
new file mode 100644
index 0000000000..7824ac2c88
--- /dev/null
+++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/SharedModelGeneratorTest.java
@@ -0,0 +1,99 @@
+package com.metaobjects.generator;
+
+import com.metaobjects.config.SharedModel;
+import com.metaobjects.loader.LoaderOptions;
+import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.registry.RegistryManifest;
+import org.junit.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * FR-023 publish: the {@code shared-model} generator writes the two files the shared corpus
+ * ({@code fixtures/shared-model-conformance/}) pins, byte for byte, through the same path the Maven
+ * goal takes (args in, files out, version from the project). The artifact's bytes themselves are
+ * gated by {@code SharedModelConformanceTest} in the metadata module.
+ */
+public class SharedModelGeneratorTest {
+
+ private static Path corpus() {
+ Path dir = Paths.get("").toAbsolutePath();
+ while (dir != null && !Files.isDirectory(dir.resolve("fixtures"))) dir = dir.getParent();
+ assertNotNull("could not locate the repository fixtures/ directory", dir);
+ return dir.resolve("fixtures/shared-model-conformance");
+ }
+
+ private static MetaDataLoader loader() {
+ MetaDataLoader l = new MetaDataLoader(LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "publisher");
+ l.init();
+ return l;
+ }
+
+ private static Map args(Path out) {
+ return Map.of(
+ "outputDir", out.toString(),
+ "name", "acme-common",
+ "include", "acme::common::**",
+ "exclude", "acme::common::Secret",
+ "files", corpus().resolve("source/meta.base.json") + "," + corpus().resolve("source/meta.customer.json"));
+ }
+
+ @Test public void writesTheArtifactAndManifestTheCorpusPins() throws Exception {
+ Path out = Files.createTempDirectory("shared-model-gen-");
+ SharedModelGenerator g = new SharedModelGenerator();
+ g.setProjectVersion("1.0.0"); // what the Maven plugin supplies from ${project.version}
+ g.setArgs(args(out));
+ g.execute(loader());
+
+ String artifact = Files.readString(out.resolve("acme-common.metaobjects.json"), StandardCharsets.UTF_8);
+ String manifest = Files.readString(out.resolve("metaobjects.pkg.json"), StandardCharsets.UTF_8);
+ assertEquals(Files.readString(corpus().resolve("../dependency-conformance/artifacts/acme-common-v1.json").normalize(), StandardCharsets.UTF_8), artifact);
+ assertEquals(Files.readString(corpus().resolve("expected/acme-common-v1/metaobjects.pkg.json"), StandardCharsets.UTF_8)
+ .replace("@@METAMODEL_VERSION@@", RegistryManifest.METAMODEL_VERSION), manifest);
+ }
+
+ @Test public void anExplicitVersionArgBeatsTheProjectVersion() throws Exception {
+ Path out = Files.createTempDirectory("shared-model-gen-");
+ SharedModelGenerator g = new SharedModelGenerator();
+ g.setProjectVersion("9.9.9");
+ g.setArgs(new java.util.HashMap<>(args(out)) {{ put("version", "1.0.0"); }});
+ g.execute(loader());
+ assertTrue(Files.readString(out.resolve("metaobjects.pkg.json")).contains("\"version\": \"1.0.0\""));
+ }
+
+ @Test public void noVersionAnywhereIsRefusedAfterTheSelectionPasses() throws Exception {
+ Path out = Files.createTempDirectory("shared-model-gen-");
+ SharedModelGenerator g = new SharedModelGenerator();
+ g.setArgs(args(out));
+ GeneratorException e = assertThrows(GeneratorException.class, () -> g.execute(loader()));
+ assertTrue(e.getMessage(), e.getMessage().contains("no version available"));
+ assertFalse(Files.exists(out.resolve("metaobjects.pkg.json")));
+ }
+
+ @Test public void aRefusedSelectionWritesNothing() throws Exception {
+ Path out = Files.createTempDirectory("shared-model-gen-");
+ SharedModelGenerator g = new SharedModelGenerator();
+ g.setProjectVersion("1.0.0");
+ g.setArgs(new java.util.HashMap<>(args(out)) {{ put("include", "nothing::**"); put("exclude", ""); }});
+ SharedModel.Refusal r = assertThrows(SharedModel.Refusal.class, () -> g.execute(loader()));
+ assertEquals(SharedModel.Reason.EMPTY_SELECTION, r.reason());
+ assertFalse(Files.exists(out.resolve("acme-common.metaobjects.json")));
+ }
+
+ @Test public void aMissingArgNamesTheArg() throws Exception {
+ SharedModelGenerator g = new SharedModelGenerator();
+ g.setArgs(Map.of("outputDir", Files.createTempDirectory("shared-model-gen-").toString()));
+ GeneratorException e = assertThrows(GeneratorException.class, () -> g.execute(loader()));
+ assertTrue(e.getMessage(), e.getMessage().contains("'name'"));
+ }
+}
diff --git a/server/java/codegen-kotlin/pom.xml b/server/java/codegen-kotlin/pom.xml
index da709813b4..a3470ebaae 100644
--- a/server/java/codegen-kotlin/pom.xml
+++ b/server/java/codegen-kotlin/pom.xml
@@ -253,6 +253,7 @@
KotlinValidatorGenerator.ktKotlinRequirementTestsGenerator.ktKotlinReportExportGenerator.kt
+ KotlinSharedModelGenerator.ktKotlinEnumEmitter.kt
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 3d81165da4..992e727929 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
@@ -14,7 +14,7 @@
// Stable names mirror the canonical manifest exactly for the kotlin slice:
// entity, routes, output-parser, output-prompt, render-helper, extractor,
// filter-allowlist, names, exposed-table, relations, spring-config,
-// stored-proc, validator, requirement-tests, report-export. (Kotlin has NO `template` generator — the manifest
+// stored-proc, validator, requirement-tests, report-export, shared-model. (Kotlin has NO `template` generator — the manifest
// deliberately omits kotlin from it.)
package com.metaobjects.generator.kotlin
@@ -311,6 +311,16 @@ val GENERATOR_REGISTRY: Map = linkedMapOf(
ejectResourcePath = ejectPath("KotlinReportExportGenerator"),
requires = listOf("entity"),
),
+ "shared-model" to GeneratorInfo(
+ name = "shared-model",
+ description = "FR-023: a publisher's flattened shared-model artifact + manifest for a consumer's `meta deps sync`. " +
+ "[Args: outputDir, name, include, files; optional exclude and version, which defaults to the " +
+ "project's version.]",
+ tier = GeneratorTier.NATIVE,
+ layer = GeneratorLayer.CAPABILITY,
+ factory = ::KotlinSharedModelGenerator,
+ ejectResourcePath = ejectPath("KotlinSharedModelGenerator"),
+ ),
"requirement-tests" to GeneratorInfo(
name = "requirement-tests",
description = "Per-package JUnit Jupiter tests, one per declared requirement, each calling a " +
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSharedModelGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSharedModelGenerator.kt
new file mode 100644
index 0000000000..f562ef0ab0
--- /dev/null
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSharedModelGenerator.kt
@@ -0,0 +1,79 @@
+package com.metaobjects.generator.kotlin
+
+import com.metaobjects.config.SharedModel
+import com.metaobjects.generator.FileEmittingGenerator
+import com.metaobjects.generator.FileEmittingGenerator.EmittedFile
+import com.metaobjects.generator.GeneratorException
+import com.metaobjects.generator.ProjectVersionAware
+import com.metaobjects.loader.MetaDataLoader
+import java.nio.file.Path
+
+/**
+ * FR-023: publishes a project's shared model - a flattened, canonical-JSON artifact plus the
+ * manifest that pins it - for a consumer's `meta deps sync`. The Kotlin sibling of the JVM
+ * `SharedModelGenerator`; the artifact is language-neutral JSON, so both write identical bytes.
+ *
+ * This file decides what belongs to the PUBLISHING PROJECT: which of its source files may
+ * contribute (`files`), which nodes are published (`include` / `exclude`) and which version is
+ * recorded. Everything a consumer checks is [SharedModel]'s, in the package: what is selected,
+ * that the selection is closed, the document's bytes, the hash over them and the manifest's shape
+ * and file names. It is a reference helper (ADR-0034 Amendment 3): `mvn metaobjects:eject` copies
+ * this one file into your project, and the artifact bytes stay in the package, because a copy that
+ * changed them would publish a model a consumer's sync refuses, or accepts and cannot load.
+ *
+ * Args: `outputDir`, `name` (the dependency key a consumer syncs under; the artifact is
+ * `.metaobjects.json`), `include` (comma-separated scope patterns, such as
+ * `acme::common::**`), `files` (comma-separated metadata files that may contribute), and
+ * optionally `exclude` (applied after `include`) and `version` (default: the project's version).
+ */
+open class KotlinSharedModelGenerator : FileEmittingGenerator(), ProjectVersionAware {
+
+ private var projectVersion: String? = null
+
+ override fun setProjectVersion(projectVersion: String) {
+ this.projectVersion = projectVersion
+ }
+
+ override fun generate(loader: MetaDataLoader): List {
+ val name = required(ARG_NAME, "the dependency key a consumer will sync under")
+ val include = list(required(ARG_INCLUDE, "the scope patterns to publish, such as acme::common::**"))
+ val exclude = if (hasArg(ARG_EXCLUDE)) list(getArg(ARG_EXCLUDE)) else emptyList()
+ val files = list(required(ARG_FILES, "the metadata files that may contribute, comma-separated")).map { Path.of(it) }
+ // The providers the project's own load used, so its extra attributes load here too.
+ val providers = loader.loaderOptions.providers
+
+ val model = SharedModel.build(SharedModel.Input(name, files, include, exclude, providers))
+
+ // Resolved after the selection has passed every check, so a model that cannot be published
+ // says so before a missing version does.
+ val version = (if (hasArg(ARG_VERSION)) getArg(ARG_VERSION).trim() else projectVersion)
+ if (version.isNullOrBlank()) {
+ throw GeneratorException(
+ "shared-model \"$name\": no version available - set <$ARG_VERSION>...$ARG_VERSION>, " +
+ "or run through the Maven plugin, which supplies \${project.version}.",
+ )
+ }
+
+ val out = SharedModel.render(model, version)
+ return listOf(EmittedFile(out.artifactPath(), out.artifact()), EmittedFile(out.manifestPath(), out.manifest()))
+ }
+
+ private fun required(arg: String, what: String): String {
+ val value = getArg(arg)
+ if (value.isNullOrBlank()) {
+ throw GeneratorException("shared-model: set '$arg' with <$arg>...$arg> ($what)")
+ }
+ return value.trim()
+ }
+
+ private fun list(commaSeparated: String): List =
+ commaSeparated.split(",").map { it.trim() }.filter { it.isNotEmpty() }
+
+ companion object {
+ const val ARG_NAME = "name"
+ const val ARG_INCLUDE = "include"
+ const val ARG_EXCLUDE = "exclude"
+ const val ARG_FILES = "files"
+ const val ARG_VERSION = "version"
+ }
+}
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectContractTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectContractTest.kt
index fe6958c3cc..06a51bbe23 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectContractTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectContractTest.kt
@@ -229,6 +229,11 @@ class EjectContractTest {
val model: Path = FIXTURES.resolve(model)
val templates: Path? = templates?.let { FIXTURES.resolve(it) }
/** Loaded once and shared by both sides. A loader's name is an identifier. */
+ /** The model's metadata files, comma-separated: every .json and .yaml under the directory. */
+ fun modelFiles(): String = Files.walk(model).use { walk ->
+ walk.filter { it.isRegularFile() && (it.toString().endsWith(".json") || it.toString().endsWith(".yaml")) }
+ .map { it.toString() }.sorted().toList().joinToString(",")
+ }
val loader by lazy { loadDirectory("ejectContract_" + label.replace('-', '_'), this.model) }
}
@@ -241,6 +246,11 @@ class EjectContractTest {
"testPackage" to "com.example.requirements",
"witnessClass" to "com.example.Witnesses",
"warnUncovered" to "false",
+ // shared-model publishes the whole model, from the corpus's own metadata files.
+ "name" to "contract",
+ "include" to "**",
+ "version" to "1.0.0",
+ "files" to corpus.modelFiles(),
// Required by the generators that read template text, whatever the model holds.
"templateRoot" to (corpus.templates ?: CORPORA.first().templates).toString(),
)
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSharedModelGeneratorTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSharedModelGeneratorTest.kt
new file mode 100644
index 0000000000..220d37f7a7
--- /dev/null
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSharedModelGeneratorTest.kt
@@ -0,0 +1,60 @@
+package com.metaobjects.generator.kotlin
+
+import com.metaobjects.generator.ProjectVersionAware
+import com.metaobjects.loader.LoaderOptions
+import com.metaobjects.loader.MetaDataLoader
+import com.metaobjects.registry.RegistryManifest
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.Paths
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * FR-023 publish — Kotlin inherits the artifact engine and the Maven goals from the Java port; what
+ * it owns is its generator class. One corpus case (`fixtures/shared-model-conformance`) goes through
+ * the class the registry hands out, with the project version supplied the way the Maven plugin does,
+ * and the two files must equal the pinned bytes.
+ */
+class KotlinSharedModelGeneratorTest {
+
+ private fun corpus(): Path {
+ var dir: Path? = Paths.get("").toAbsolutePath()
+ while (dir != null && !Files.isDirectory(dir.resolve("fixtures"))) dir = dir.parent
+ return dir!!.resolve("fixtures/shared-model-conformance")
+ }
+
+ @Test fun `the registered shared-model generator writes the bytes the corpus pins`() {
+ val out = Files.createTempDirectory("kshared-out-")
+ try {
+ val gen = GENERATOR_REGISTRY.getValue("shared-model").factory()
+ (gen as ProjectVersionAware).setProjectVersion("1.0.0")
+ gen.setArgs(
+ mapOf(
+ "outputDir" to out.toString(),
+ "name" to "acme-common",
+ "include" to "acme::common::**",
+ "exclude" to "acme::common::Secret",
+ "files" to "${corpus().resolve("source/meta.base.json")},${corpus().resolve("source/meta.customer.json")}",
+ ),
+ )
+ val loader = MetaDataLoader(LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "kshared")
+ loader.init()
+ gen.execute(loader)
+
+ assertEquals(
+ Files.readString(corpus().resolve("../dependency-conformance/artifacts/acme-common-v1.json").normalize()),
+ Files.readString(out.resolve("acme-common.metaobjects.json")),
+ )
+ assertEquals(
+ Files.readString(corpus().resolve("expected/acme-common-v1/metaobjects.pkg.json"))
+ .replace("@@METAMODEL_VERSION@@", RegistryManifest.METAMODEL_VERSION),
+ Files.readString(out.resolve("metaobjects.pkg.json")),
+ )
+ assertTrue(Files.list(out).use { it.count() } == 2L, "exactly the artifact and the manifest")
+ } finally {
+ out.toFile().deleteRecursively()
+ }
+ }
+}
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/NoMagicPhysicalNamesTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/NoMagicPhysicalNamesTest.kt
index f80bdcc651..3a5f747e49 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/NoMagicPhysicalNamesTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/NoMagicPhysicalNamesTest.kt
@@ -366,7 +366,9 @@ class NoMagicPhysicalNamesTest {
// from the suite through the SHIPPED helper — the identical call the Maven
// mojo makes in AbstractMetaDataMojo.buildGenerators. Re-implementing the
// derivation here would leave the gate measuring the test's own logic.
- val suite = list().filter { it.tier == GeneratorTier.NATIVE }.map { it.factory() }
+ val suite = // shared-model writes no code (it publishes metadata files named by its args), so it has no
+ // physical name to leak.
+ list().filter { it.tier == GeneratorTier.NATIVE && it.name != "shared-model" }.map { it.factory() }
val runArgs = EmitsPhysicalNameConstants.deriveUseNames(args, suite)
for (generator in suite) {
generator.apply { setArgs(runArgs) }.execute(loader)
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt
index 85ed5cbfe7..44231cc291 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt
@@ -116,7 +116,9 @@ class ReportingInertTest {
* held to the same inertness by [api docs gain exactly the view-backed report's page], which
* compares its pages unit by unit.
*/
- private val codeGenerators get() = GENERATOR_REGISTRY.values.filter { it.name != "api-docs" }
+ // shared-model writes no code: it publishes the model's own metadata files, named by its args,
+ // which a bare in-memory model does not have. Its bytes are gated by the shared-model corpus.
+ private val codeGenerators get() = GENERATOR_REGISTRY.values.filter { it.name != "api-docs" && it.name != "shared-model" }
/** What a generator may add for the with-model: one file of the view-backed report, from five generators. */
private fun allowedFor(info: GeneratorInfo): Set = STORE_TOTALS_FILES[info.name]?.split('|')?.toSet() ?: emptySet()
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 e733380680..7917ebd62b 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
@@ -470,6 +470,13 @@ Tier.NATIVE, Layer.API, ejectPath(SpringReportExportGenerator.class), List.of(),
+ "witness class through a generated interface; a live test with no witness fails and a "
+ "planned or retired one is @Disabled.",
Tier.NATIVE, Layer.CAPABILITY, ejectPath(JUnitRequirementTestsGenerator.class));
+ // "shared-model" lives in codegen-base (language-neutral output: it publishes JSON), which ships
+ // its reference source. The artifact bytes are SharedModel's, in the metadata jar, so the owned
+ // copy decides the files and version and cannot change what a consumer checks.
+ register(m, "shared-model", SharedModelGenerator.class.getName(),
+ "FR-023: a publisher's flattened shared-model artifact + manifest for a consumer's `meta deps sync`. "
+ + "[Args: outputDir, name, include, files; optional exclude and version, which defaults to the "
+ + "project's version.]", Tier.NATIVE, Layer.CAPABILITY, ejectPath(SharedModelGenerator.class));
register(m, "names", SpringNamesGenerator.class.getName(),
"Per-object physical database name constants (table/view/schema/column) "
+ "for a hand-written consumer to reference instead of a string literal.", Tier.NATIVE, Layer.MODEL,
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/NoMagicPhysicalNamesTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/NoMagicPhysicalNamesTest.java
index e30031d406..492921e749 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/NoMagicPhysicalNamesTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/NoMagicPhysicalNamesTest.java
@@ -439,6 +439,14 @@ private Map generate() throws Exception {
args.put("template", "no-magic/probe");
args.put("scope", "perEntity");
args.put("outputPattern", "template-scope/{Name}.txt");
+ // Required by shared-model (it publishes the metadata files named here), ignored by the rest.
+ Path publishable = templateRoot.resolve("publishable.json");
+ Files.writeString(publishable, "{\"metadata.root\":{\"package\":\"acme\",\"children\":["
+ + "{\"object.value\":{\"name\":\"Probe\",\"children\":[{\"field.string\":{\"name\":\"a\"}}]}}]}}");
+ args.put("name", "no-magic");
+ args.put("include", "**");
+ args.put("version", "1.0.0");
+ args.put("files", publishable.toString());
List suite = new ArrayList<>();
Set notGenerators = new TreeSet<>();
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
index 18757b408f..67eed10e94 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
@@ -228,6 +228,8 @@ public void exactlyTheseGeneratorsCannotRunFromABareModel() throws Exception {
// output. Pinned by name so a generator that starts throwing cannot drop out
// silently; the list may only shrink.
// template needs a `template` arg (no default template exists)
+ // shared-model publishes the metadata files its `files` arg names; a bare in-memory
+ // model has none, and it writes no code to leak a report into
List threw = new ArrayList<>();
StringBuilder why = new StringBuilder();
for (GeneratorInfo info : GeneratorRegistry.list().values()) {
@@ -238,7 +240,7 @@ public void exactlyTheseGeneratorsCannotRunFromABareModel() throws Exception {
}
}
threw.sort(null);
- assertEquals(why.toString(), List.of("template"), threw);
+ assertEquals(why.toString(), List.of("shared-model", "template"), threw);
}
@Test
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
index a1c8a511ba..f0cc8d5146 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
@@ -8,7 +8,9 @@
import com.metaobjects.generator.Generator;
import com.metaobjects.generator.GeneratorBase;
import com.metaobjects.generator.ProjectClassLoaderAware;
+import com.metaobjects.generator.ProjectVersionAware;
import com.metaobjects.generator.RunAware;
+import com.metaobjects.generator.SharedModelGenerator;
import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase;
import com.metaobjects.generator.spring.SpringRenderHelperGenerator;
import com.metaobjects.generator.template.TemplateScopeGenerator;
@@ -526,6 +528,11 @@ protected List buildGenerators(ClassLoader projectClassLoader,
((ProjectClassLoaderAware) impl).setProjectClassLoader( projectClassLoader );
}
+ // A generator that publishes the project's version gets Maven's, as a default.
+ if ( impl instanceof ProjectVersionAware && project != null && project.getVersion() != null ) {
+ ((ProjectVersionAware) impl).setProjectVersion( project.getVersion() );
+ }
+
// Merge generator args and global args
Map allargs = mergeAndOverwriteArgs(g);
@@ -587,6 +594,10 @@ protected List buildGenerators(ClassLoader projectClassLoader,
// Kotlin's render helper reads the same key (KotlinRenderHelperGenerator.ARG_TEMPLATE_ROOT).
SpringRenderHelperGenerator.ARG_TEMPLATE_ROOT);
+ /** Generator args holding a comma-separated list of filesystem paths, resolved like {@link #PATH_ARGS}
+ * element by element (the shared-model generator's {@code files}). */
+ static final Set PATH_LIST_ARGS = Set.of(SharedModelGenerator.ARG_FILES);
+
public Map mergeAndOverwriteArgs(GeneratorParam g) {
Map allargs = mergeArgs(globals, g.getArgs());
@@ -615,6 +626,18 @@ private Map mergeArgs(Map globalArgs, Map resolved = new ArrayList<>();
+ for ( String part : value.split(",") ) {
+ String p = part.trim();
+ if ( p.isEmpty() ) continue;
+ resolved.add( new File(p).isAbsolute() ? p : basedir.toPath().resolve(p).normalize().toString() );
+ }
+ allargs.put(key, String.join(",", resolved));
+ }
}
return allargs;
}
diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java
index f7c99a10e8..0c4e5cb740 100644
--- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java
+++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java
@@ -470,6 +470,15 @@ private static final class Corpus {
this.templates = templates == null ? null : repoRoot().resolve("fixtures").resolve(templates);
}
+ /** The model's metadata files, comma-separated: the file itself, or every .json and .yaml under a directory. */
+ String modelFiles() throws IOException {
+ if (!Files.isDirectory(model)) return model.toString();
+ try (Stream s = Files.walk(model)) {
+ return s.filter(p -> Files.isRegularFile(p) && (p.toString().endsWith(".json") || p.toString().endsWith(".yaml"))).sorted()
+ .map(Path::toString).collect(Collectors.joining(","));
+ }
+ }
+
private MetaDataLoader loader;
/** Loaded once and shared by both sides: generated headers name the loader. */
@@ -511,6 +520,11 @@ private Map run(Generator generator, Corpus corpus, MetaDataLoad
args.put("testPackage", "com.example.requirements");
args.put("witnessClass", "com.example.Witnesses");
args.put("warnUncovered", "false");
+ // shared-model publishes a slice of the model it is given: all of it, from the corpus's files.
+ args.put("name", "contract");
+ args.put("include", "**");
+ args.put("version", "1.0.0");
+ args.put("files", corpus.modelFiles());
// The file the single-file generators write: the docs, diagram and schema generators. The
// per-object ones read this arg as the name of a file to merge into, so they must not get it.
if (generator instanceof DirectGeneratorBase && !(generator instanceof MultiFileDirectGeneratorBase)) {
diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java
index e7b9412f03..5da440f69a 100644
--- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java
+++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java
@@ -120,6 +120,11 @@ private Map args(Path outDir) {
// Required by the entity generator (JavaObjectCodeGenerator), ignored by the rest.
a.put("type", "class");
a.put("flavor", "pojoAware");
+ // Required by shared-model, ignored by the rest.
+ a.put("name", "fitness");
+ a.put("include", "**");
+ a.put("version", "1.0.0");
+ a.put("files", canonical.resolve("meta.fitness.json").toString());
return a;
}
diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/SharedModel.java b/server/java/metadata/src/main/java/com/metaobjects/config/SharedModel.java
new file mode 100644
index 0000000000..edb3dc4bc2
--- /dev/null
+++ b/server/java/metadata/src/main/java/com/metaobjects/config/SharedModel.java
@@ -0,0 +1,314 @@
+/*
+ * Copyright 2003 Doug Mealing LLC dba Meta Objects
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.metaobjects.config;
+
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.metaobjects.MetaData;
+import com.metaobjects.MetaDataException;
+import com.metaobjects.attr.MetaAttribute;
+import com.metaobjects.io.json.CanonicalJsonSerializer;
+import com.metaobjects.loader.FileSource;
+import com.metaobjects.loader.InMemoryStringSource;
+import com.metaobjects.loader.LoaderOptions;
+import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.loader.MetaDataSource;
+import com.metaobjects.loader.References;
+import com.metaobjects.registry.MetaDataTypeProvider;
+import com.metaobjects.registry.RegistryManifest;
+import com.metaobjects.requirement.MetaRequirement;
+import com.metaobjects.template.TemplateConstants;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * FR-023 — the publisher's half of a shared model: the flattened artifact and the manifest that
+ * pins it, the pair a consumer's {@code meta deps sync} copies into its own repo and verifies
+ * (design §2.2, §3.2, §3.5). Mirrors {@code codegen-ts/src/shared-model/build-shared-model.ts};
+ * the shared corpus {@code fixtures/shared-model-conformance/} pins the bytes and the refusals.
+ *
+ *
Two stages, both pure of the run, and both in the package on purpose. A generator an
+ * application owns ({@code mvn metaobjects:eject -Dnames=shared-model}) decides which source files
+ * may contribute and which version is published, and calls these for everything a consumer checks:
+ * what is selected, that the selection is closed, the document's bytes, the hash over them, and the
+ * manifest's shape and file names. An owned copy that changed any of those would publish an
+ * artifact a consumer's sync refuses, or worse, accepts and cannot load, so they are not in the
+ * copy.
+ *
+ *
+ *
{@link #build} — standalone strict load of {@code files} with the publisher's own
+ * providers; select top-level nodes (never {@code requirement.*} or {@code template.*}) by
+ * {@code include} / {@code exclude}; check the selection is closed over {@code extends} and
+ * every ref-bearing attribute; serialize; re-load the document with CORE providers only.
+ *
{@link #render} — {@code .metaobjects.json} and {@code metaobjects.pkg.json}, the
+ * manifest carrying the sha256 of the artifact's bytes.
+ *
+ */
+public final class SharedModel {
+
+ private SharedModel() {}
+
+ /** The manifest's file name (design §3.2); the same constant {@link Dependencies#MANIFEST_FILE}. */
+ public static final String MANIFEST_FILE = Dependencies.MANIFEST_FILE;
+
+ /** The manifest schema's own version, distinct from {@code metamodelVersion}. */
+ private static final int MANIFEST_SCHEMA_VERSION = 1;
+
+ /** Why a publisher refused to publish; the corpus's {@code expectRefusal.reason}. */
+ public enum Reason {
+ EMPTY_SELECTION("empty-selection"),
+ NOT_CLOSED("not-closed"),
+ NON_CORE_VOCABULARY("non-core-vocabulary");
+
+ private final String id;
+
+ Reason(String id) {
+ this.id = id;
+ }
+
+ /** The corpus spelling. */
+ public String id() {
+ return id;
+ }
+ }
+
+ /** One {@code (referrer, target)} resolution-key pair the selection is not closed over. */
+ public record Pair(String referrer, String target) {}
+
+ /** A selection that cannot be published; carries the classified reason and, for not-closed, every pair. */
+ public static final class Refusal extends MetaDataException {
+ private static final long serialVersionUID = 1L;
+ private final Reason reason;
+ private final transient List pairs;
+
+ Refusal(Reason reason, String message, List pairs) {
+ super(message);
+ this.reason = reason;
+ this.pairs = List.copyOf(pairs);
+ }
+
+ public Reason reason() {
+ return reason;
+ }
+
+ /** Sorted by referrer then target; empty unless {@link Reason#NOT_CLOSED}. */
+ public List pairs() {
+ return pairs;
+ }
+ }
+
+ /**
+ * What to publish.
+ *
+ * @param name the dependency key a consumer will sync under; also the artifact's basename
+ * @param files the publisher's own source files that may contribute, loaded standalone
+ * @param include scope patterns (the same grammar as {@code scope}); empty selects everything
+ * @param exclude applied after {@code include}
+ * @param providers the publisher's own vocabulary providers, so its extra attributes load as
+ * they did for the project's main load; empty = core vocabulary
+ */
+ public record Input(String name, List files, List include, List exclude,
+ List providers) {
+ public Input {
+ files = List.copyOf(files);
+ include = include == null ? List.of() : List.copyOf(include);
+ exclude = exclude == null ? List.of() : List.copyOf(exclude);
+ providers = providers == null ? List.of() : List.copyOf(providers);
+ }
+ }
+
+ /**
+ * A selection that passed every check, serialized. Produced by {@link #build} and consumed by
+ * {@link #render}; the document is exactly the bytes that were re-loaded with core vocabulary,
+ * and the manifest's hash is taken over them.
+ *
+ * @param document the canonical-JSON {@code metadata.root} document: the artifact's bytes
+ * @param packages the metadata packages the selection spans, sorted
+ * @param nodes the resolution key of every selected top-level node, sorted
+ */
+ public record Built(String name, String document, List packages, List nodes) {}
+
+ /** The two files a shared model is published as. */
+ public record Files(String artifactPath, String artifact, String manifestPath, String manifest) {}
+
+ /** {@code .metaobjects.json}: the artifact's file name, as {@code meta deps sync} reads it. */
+ public static String artifactFileName(String name) {
+ return name + Dependencies.ARTIFACT_SUFFIX;
+ }
+
+ /**
+ * Load {@code files} standalone, select by {@code include} / {@code exclude}, and return the
+ * selection as a checked, serialized shared model (design §4.3 steps 1 to 5).
+ *
+ * @throws Refusal when the selection is empty, is not closed, or needs vocabulary a consumer's
+ * toolchain does not ship
+ * @throws MetaDataException when the files do not load
+ */
+ public static Built build(Input input) {
+ // Step 1: standalone strict load, the publisher's own providers.
+ MetaDataLoader loader = load(input.providers(), sourcesOf(input.files()), "sharedModel");
+ MetaData root = loader.getRoot();
+
+ // Step 2: select. The root has no super chain (ADR-0039 sanctioned own read).
+ Scope.Compiled scope = Scope.compileScope(input.include(), input.exclude());
+ List selected = new ArrayList<>();
+ for (MetaData node : root.getChildren(MetaData.class, false)) {
+ if (node instanceof MetaAttribute) continue;
+ if (MetaRequirement.TYPE_REQUIREMENT.equals(node.getType())
+ || TemplateConstants.TYPE_TEMPLATE.equals(node.getType())) continue;
+ if (Scope.matchesScope(node.getName(), scope)) selected.add(node);
+ }
+ if (selected.isEmpty()) {
+ throw new Refusal(Reason.EMPTY_SELECTION,
+ "shared-model \"" + input.name() + "\": selected no nodes (include/exclude, always excluding "
+ + "requirement.* and template.*) - nothing to publish. Check `include`.", List.of());
+ }
+
+ // Step 3: closure.
+ Set selectedKeys = new HashSet<>();
+ for (MetaData n : selected) selectedKeys.add(n.getName());
+ List failures = checkClosure(root, selected, selectedKeys);
+ if (!failures.isEmpty()) {
+ StringBuilder lines = new StringBuilder();
+ for (Pair p : failures) lines.append("\n ").append(p.referrer()).append(" -> ").append(p.target());
+ throw new Refusal(Reason.NOT_CLOSED,
+ "shared-model \"" + input.name() + "\": the selection is not closed - these references point "
+ + "outside it; include it or exclude the referrer:" + lines, failures);
+ }
+
+ // Step 4: serialize (sorted by resolution key).
+ String document = CanonicalJsonSerializer.serializeSharedDocument(selected);
+
+ // Step 5: re-load with CORE providers only. A consumer loads the artifact with ITS providers,
+ // never the publisher's, so an export needing publisher-only vocabulary fails HERE.
+ try {
+ load(List.of(), List.of(new InMemoryStringSource(document, artifactFileName(input.name()))),
+ "sharedModelCore");
+ } catch (RuntimeException e) {
+ throw new Refusal(Reason.NON_CORE_VOCABULARY,
+ "shared-model \"" + input.name() + "\": the export needs a provider this toolchain does not "
+ + "ship; Phase 1 exports must load with core vocabulary: " + e.getMessage(), List.of());
+ }
+
+ Set packages = new TreeSet<>();
+ Set nodes = new TreeSet<>();
+ for (MetaData n : selected) {
+ packages.add(Dependencies.packageOf(n.getName()));
+ nodes.add(n.getName());
+ }
+ return new Built(input.name(), document, List.copyOf(packages), List.copyOf(nodes));
+ }
+
+ /**
+ * The artifact and the manifest that pins it (design §4.3 step 6, §3.2): the manifest names the
+ * artifact and carries the sha256 of its bytes, so the pair is rendered together and never one
+ * without the other.
+ */
+ public static Files render(Built model, String version) {
+ String artifactPath = artifactFileName(model.name());
+ String integrity = Dependencies.sha256Integrity(model.document().getBytes(StandardCharsets.UTF_8));
+ JsonObject manifest = new JsonObject();
+ manifest.addProperty("schema_version", MANIFEST_SCHEMA_VERSION);
+ manifest.addProperty("name", model.name());
+ manifest.addProperty("version", version);
+ manifest.addProperty("metamodelVersion", RegistryManifest.METAMODEL_VERSION);
+ manifest.addProperty("artifact", artifactPath);
+ manifest.addProperty("integrity", integrity);
+ manifest.add("packages", array(model.packages()));
+ manifest.add("nodes", array(model.nodes()));
+ String text = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create().toJson(manifest) + "\n";
+ return new Files(artifactPath, model.document(), MANIFEST_FILE, text);
+ }
+
+ private static JsonArray array(List values) {
+ JsonArray a = new JsonArray();
+ for (String v : values) a.add(v);
+ return a;
+ }
+
+ private static List sourcesOf(List files) {
+ List out = new ArrayList<>();
+ for (Path p : files) out.add(new FileSource(p));
+ return out;
+ }
+
+ private static MetaDataLoader load(List providers, List sources,
+ String name) {
+ LoaderOptions options = LoaderOptions.create(false, false, true);
+ if (!providers.isEmpty()) options.setProviders(providers);
+ MetaDataLoader loader = new MetaDataLoader(options, MetaDataLoader.SUBTYPE_MANUAL, name);
+ loader.init();
+ loader.load(sources);
+ List errors = loader.getErrors();
+ if (errors != null && !errors.isEmpty()) throw errors.get(0);
+ return loader;
+ }
+
+ // ------------------------------------------------------------------------------------
+ // closure
+
+ /** Depth-first walk of a node and its OWN descendants, node itself first. ADR-0039 sanctioned
+ * own-only case: the shared document emits each selected node's OWN layer only ({@code extends}
+ * preserved, not flattened), so the closure check walks exactly what will be emitted. */
+ private static void walkOwn(MetaData node, List out) {
+ out.add(node);
+ for (MetaData child : node.getChildren(MetaData.class, false)) {
+ if (child instanceof MetaAttribute) continue;
+ walkOwn(child, out);
+ }
+ }
+
+ private static List checkClosure(MetaData root, List selected, Set selectedKeys) {
+ Set seen = new LinkedHashSet<>();
+ for (MetaData top : selected) {
+ String referrerKey = top.getName();
+ List walk = new ArrayList<>();
+ walkOwn(top, walk);
+ for (MetaData node : walk) {
+ String referrerPkg = References.effectivePackageOf(node);
+ List candidates = new ArrayList<>();
+ String superRef = node.getAuthoredSuperRef();
+ if (superRef != null) candidates.add(superRef);
+ // ADR-0039 sanctioned own-only case: an INHERITED ref-bearing attr belongs to the
+ // base node's own layer and is checked when the base itself is walked.
+ for (MetaAttribute> attr : node.getMetaAttrs(false)) {
+ if (!References.REF_BEARING_ATTR_NAMES.contains(attr.getName())) continue;
+ Object value = attr.getValue();
+ if (value instanceof String s) candidates.add(s);
+ }
+ for (String raw : candidates) {
+ MetaData resolved = References.resolveObjectRef(root, References.splitChildTail(raw).owner(), referrerPkg).node();
+ if (resolved == null) continue; // already validated at load: defensive only
+ String targetKey = resolved.getName();
+ if (selectedKeys.contains(targetKey)) continue;
+ seen.add(new Pair(referrerKey, targetKey));
+ }
+ }
+ }
+ List pairs = new ArrayList<>(seen);
+ pairs.sort(Comparator.comparing(Pair::referrer).thenComparing(Pair::target));
+ return pairs;
+ }
+}
diff --git a/server/java/metadata/src/main/java/com/metaobjects/io/json/CanonicalJsonSerializer.java b/server/java/metadata/src/main/java/com/metaobjects/io/json/CanonicalJsonSerializer.java
index ed4e91473a..97f40a1964 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/io/json/CanonicalJsonSerializer.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/io/json/CanonicalJsonSerializer.java
@@ -151,6 +151,54 @@ public static String canonicalSerialize(MetaData node, RefSpelling refs) {
return toCanonicalString(tree);
}
+ /**
+ * The FR-023 shared-model artifact form: one canonical-JSON {@code metadata.root} document with
+ * NO root {@code package}, whose top-level nodes each carry their own explicit {@code package}
+ * (so the document re-loads to the same resolution keys in every port).
+ *
+ *
Each node is its {@link #canonicalSerialize(MetaData)} form: raw own layer, {@code extends}
+ * preserved, attribute keys alphabetized, the FR-016 physical-name rewrite applied. Top-level
+ * nodes are sorted by resolution key; each node's children keep their authored order. Body key
+ * order: {@code name}, {@code package}, then the canonical rest. Byte-identical to the
+ * TypeScript {@code serializeSharedDocument} and Python {@code serialize_shared_document}.
+ *
+ * @param nodes top-level nodes; each one's resolution key is its {@code getName()}
+ * @throws IllegalArgumentException when a node has no package
+ */
+ public static String serializeSharedDocument(List extends MetaData> nodes) {
+ List sorted = new ArrayList<>(nodes);
+ sorted.sort((a, b) -> a.getName().compareTo(b.getName()));
+ JsonArray children = new JsonArray();
+ for (MetaData node : sorted) {
+ String key = node.getName();
+ int sep = key.lastIndexOf("::");
+ String pkg = sep == -1 ? "" : key.substring(0, sep);
+ if (pkg.isEmpty()) {
+ throw new IllegalArgumentException("serializeSharedDocument: " + key
+ + " has no package; a shared document carries only packaged nodes");
+ }
+ JsonObject parsed = com.google.gson.JsonParser.parseString(canonicalSerialize(node)).getAsJsonObject();
+ Map.Entry only = parsed.entrySet().iterator().next();
+ JsonObject body = only.getValue().getAsJsonObject();
+ // The node's own `package` (if it declared one) is replaced by the RESOLVED one: a
+ // node inheriting its file's root package declares none of its own.
+ JsonObject ordered = new JsonObject();
+ if (body.has(KEY_NAME)) ordered.add(KEY_NAME, body.get(KEY_NAME));
+ ordered.addProperty(KEY_PACKAGE, pkg);
+ for (Map.Entry e : body.entrySet()) {
+ if (!e.getKey().equals(KEY_NAME) && !e.getKey().equals(KEY_PACKAGE)) ordered.add(e.getKey(), e.getValue());
+ }
+ JsonObject wrapper = new JsonObject();
+ wrapper.add(only.getKey(), ordered);
+ children.add(wrapper);
+ }
+ JsonObject rootBody = new JsonObject();
+ rootBody.add(KEY_CHILDREN, children);
+ JsonObject doc = new JsonObject();
+ doc.add(MetaData.TYPE_METADATA + TYPE_SUBTYPE_SEPARATOR + MetaData.SUBTYPE_ROOT, rootBody);
+ return toCanonicalString(doc);
+ }
+
/**
* Serializes the EFFECTIVE (super-chain-merged) structure of {@code node}
* to canonical JSON. Each node exposes the merged view of its own children
diff --git a/server/java/metadata/src/test/java/com/metaobjects/config/SharedModelConformanceTest.java b/server/java/metadata/src/test/java/com/metaobjects/config/SharedModelConformanceTest.java
new file mode 100644
index 0000000000..97ef0827e5
--- /dev/null
+++ b/server/java/metadata/src/test/java/com/metaobjects/config/SharedModelConformanceTest.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2003 Doug Mealing LLC dba Meta Objects
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.metaobjects.config;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.metaobjects.attr.StringAttribute;
+import com.metaobjects.registry.MetaDataRegistry;
+import com.metaobjects.registry.MetaDataTypeProvider;
+import com.metaobjects.registry.RegistryManifest;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Runs the shared FR-023 publish corpus against this port (Java; Kotlin publishes through the same
+ * engine and Maven plugin). Reads the single committed {@code fixtures/shared-model-conformance/cases.json}.
+ * The reference is {@code codegen-ts/src/shared-model/build-shared-model.ts}; {@link SharedModel} is this
+ * port's counterpart. Every publish case pins the artifact and the manifest BYTES; every refusal pins its
+ * reason (and, for {@code not-closed}, the exhaustive sorted pairs). The message text is not the contract.
+ */
+@RunWith(Parameterized.class)
+public class SharedModelConformanceTest {
+
+ /** The corpus's {@code secret-handling} provider: one common string attribute no core provider ships. */
+ static final class SecretHandlingProvider implements MetaDataTypeProvider {
+ @Override
+ public String getProviderId() {
+ return "secret-handling";
+ }
+
+ @Override
+ public void registerTypes(MetaDataRegistry registry) {
+ registry.registerCommonAttribute("secretHandling", StringAttribute.SUBTYPE_STRING, false);
+ }
+ }
+
+ static Path corpusDir() {
+ Path dir = Paths.get("").toAbsolutePath();
+ while (dir != null && !Files.isDirectory(dir.resolve("fixtures"))) dir = dir.getParent();
+ assertNotNull("could not locate the repository fixtures/ directory", dir);
+ return dir.resolve("fixtures/shared-model-conformance");
+ }
+
+ private static List strings(JsonObject o, String key) {
+ List out = new ArrayList<>();
+ if (o.has(key)) for (JsonElement e : o.getAsJsonArray(key)) out.add(e.getAsString());
+ return out;
+ }
+
+ @Parameters(name = "{0}")
+ public static Collection
*/
-public class SharedModelGenerator extends FileEmittingGenerator implements ProjectVersionAware {
+public class SharedModelGenerator extends FileEmittingGenerator {
public static final String ARG_NAME = "name";
public static final String ARG_INCLUDE = "include";
@@ -38,13 +37,6 @@ public class SharedModelGenerator extends FileEmittingGenerator implements Proje
public static final String ARG_FILES = "files";
public static final String ARG_VERSION = "version";
- private String projectVersion;
-
- @Override
- public void setProjectVersion(String projectVersion) {
- this.projectVersion = projectVersion;
- }
-
@Override
protected List generate(MetaDataLoader loader) {
String name = required(ARG_NAME, "the dependency key a consumer will sync under");
@@ -61,7 +53,7 @@ protected List generate(MetaDataLoader loader) {
// Resolved after the selection has passed every check, so a model that cannot be published
// says so before a missing version does.
- String version = hasArg(ARG_VERSION) && !getArg(ARG_VERSION).isBlank() ? getArg(ARG_VERSION).trim() : projectVersion;
+ String version = hasArg(ARG_VERSION) && !getArg(ARG_VERSION).isBlank() ? getArg(ARG_VERSION).trim() : null;
if (version == null || version.isBlank()) {
throw new GeneratorException("shared-model \"" + name + "\": no version available - set <"
+ ARG_VERSION + ">..." + ARG_VERSION + ">, or run through the Maven plugin, which supplies ${project.version}.");
diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/SharedModelGeneratorTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/SharedModelGeneratorTest.java
index af6c1ae707..8763039b66 100644
--- a/server/java/codegen-base/src/test/java/com/metaobjects/generator/SharedModelGeneratorTest.java
+++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/SharedModelGeneratorTest.java
@@ -39,6 +39,13 @@ private static MetaDataLoader loader() {
return l;
}
+ /** {@code base} plus one override, as a fresh mutable copy so the shared {@link #args} map stays untouched. */
+ private static Map with(Map base, String key, String value) {
+ Map copy = new java.util.HashMap<>(base);
+ copy.put(key, value);
+ return copy;
+ }
+
private static Map args(Path out) {
return Map.of(
"outputDir", out.toString(),
@@ -51,8 +58,7 @@ private static Map args(Path out) {
@Test public void writesTheArtifactAndManifestTheCorpusPins() throws Exception {
Path out = Files.createTempDirectory("shared-model-gen-");
SharedModelGenerator g = new SharedModelGenerator();
- g.setProjectVersion("1.0.0"); // what the Maven plugin supplies from ${project.version}
- g.setArgs(args(out));
+ g.setArgs(with(args(out), "version", "1.0.0")); // what the Maven plugin supplies from ${project.version}
g.execute(loader());
String artifact = Files.readString(out.resolve("acme-common.metaobjects.json"), StandardCharsets.UTF_8);
@@ -62,22 +68,13 @@ private static Map args(Path out) {
.replace("@@METAMODEL_VERSION@@", RegistryManifest.METAMODEL_VERSION), manifest);
}
- @Test public void anExplicitVersionArgBeatsTheProjectVersion() throws Exception {
- Path out = Files.createTempDirectory("shared-model-gen-");
- SharedModelGenerator g = new SharedModelGenerator();
- g.setProjectVersion("9.9.9");
- g.setArgs(new java.util.HashMap<>(args(out)) {{ put("version", "1.0.0"); }});
- g.execute(loader());
- assertTrue(Files.readString(out.resolve("metaobjects.pkg.json")).contains("\"version\": \"1.0.0\""));
- }
-
- @Test public void aBlankVersionArgFallsBackToTheProjectVersion() throws Exception {
+ @Test public void aBlankVersionArgIsRefused() throws Exception {
Path out = Files.createTempDirectory("shared-model-gen-");
SharedModelGenerator g = new SharedModelGenerator();
- g.setProjectVersion("2.0.0");
- g.setArgs(new java.util.HashMap<>(args(out)) {{ put("version", " "); }});
- g.execute(loader());
- assertTrue(Files.readString(out.resolve("metaobjects.pkg.json")).contains("\"version\": \"2.0.0\""));
+ g.setArgs(with(args(out), "version", " "));
+ GeneratorException e = assertThrows(GeneratorException.class, () -> g.execute(loader()));
+ assertTrue(e.getMessage(), e.getMessage().contains("no version available"));
+ assertFalse(Files.exists(out.resolve("metaobjects.pkg.json")));
}
@Test public void noVersionAnywhereIsRefusedAfterTheSelectionPasses() throws Exception {
@@ -92,8 +89,7 @@ private static Map args(Path out) {
@Test public void aRefusedSelectionWritesNothing() throws Exception {
Path out = Files.createTempDirectory("shared-model-gen-");
SharedModelGenerator g = new SharedModelGenerator();
- g.setProjectVersion("1.0.0");
- g.setArgs(new java.util.HashMap<>(args(out)) {{ put("include", "nothing::**"); put("exclude", ""); }});
+ g.setArgs(with(with(args(out), "include", "nothing::**"), "exclude", ""));
SharedModel.Refusal r = assertThrows(SharedModel.Refusal.class, () -> g.execute(loader()));
assertEquals(SharedModel.Reason.EMPTY_SELECTION, r.reason());
assertFalse(Files.exists(out.resolve("acme-common.metaobjects.json")));
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSharedModelGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSharedModelGenerator.kt
index ad5d876599..f8aab0223a 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSharedModelGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSharedModelGenerator.kt
@@ -4,7 +4,6 @@ import com.metaobjects.config.SharedModel
import com.metaobjects.generator.FileEmittingGenerator
import com.metaobjects.generator.FileEmittingGenerator.EmittedFile
import com.metaobjects.generator.GeneratorException
-import com.metaobjects.generator.ProjectVersionAware
import com.metaobjects.loader.MetaDataLoader
import java.nio.file.Path
@@ -26,13 +25,7 @@ import java.nio.file.Path
* `acme::common::**`), `files` (comma-separated metadata files that may contribute), and
* optionally `exclude` (applied after `include`) and `version` (default: the project's version).
*/
-open class KotlinSharedModelGenerator : FileEmittingGenerator(), ProjectVersionAware {
-
- private var projectVersion: String? = null
-
- override fun setProjectVersion(projectVersion: String) {
- this.projectVersion = projectVersion
- }
+open class KotlinSharedModelGenerator : FileEmittingGenerator() {
override fun generate(loader: MetaDataLoader): List {
val name = required(ARG_NAME, "the dependency key a consumer will sync under")
@@ -46,7 +39,7 @@ open class KotlinSharedModelGenerator : FileEmittingGenerator(), ProjectVersionA
// Resolved after the selection has passed every check, so a model that cannot be published
// says so before a missing version does.
- val version = (if (hasArg(ARG_VERSION) && !getArg(ARG_VERSION).isBlank()) getArg(ARG_VERSION).trim() else projectVersion)
+ val version = if (hasArg(ARG_VERSION) && !getArg(ARG_VERSION).isBlank()) getArg(ARG_VERSION).trim() else null
if (version.isNullOrBlank()) {
throw GeneratorException(
"shared-model \"$name\": no version available - set <$ARG_VERSION>...$ARG_VERSION>, " +
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSharedModelGeneratorTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSharedModelGeneratorTest.kt
index 220d37f7a7..a5e895f97f 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSharedModelGeneratorTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSharedModelGeneratorTest.kt
@@ -1,6 +1,5 @@
package com.metaobjects.generator.kotlin
-import com.metaobjects.generator.ProjectVersionAware
import com.metaobjects.loader.LoaderOptions
import com.metaobjects.loader.MetaDataLoader
import com.metaobjects.registry.RegistryManifest
@@ -29,10 +28,10 @@ class KotlinSharedModelGeneratorTest {
val out = Files.createTempDirectory("kshared-out-")
try {
val gen = GENERATOR_REGISTRY.getValue("shared-model").factory()
- (gen as ProjectVersionAware).setProjectVersion("1.0.0")
gen.setArgs(
mapOf(
"outputDir" to out.toString(),
+ "version" to "1.0.0",
"name" to "acme-common",
"include" to "acme::common::**",
"exclude" to "acme::common::Secret",
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/NoMagicPhysicalNamesTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/NoMagicPhysicalNamesTest.kt
index 3a5f747e49..28c9652dad 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/NoMagicPhysicalNamesTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/NoMagicPhysicalNamesTest.kt
@@ -366,9 +366,9 @@ class NoMagicPhysicalNamesTest {
// from the suite through the SHIPPED helper — the identical call the Maven
// mojo makes in AbstractMetaDataMojo.buildGenerators. Re-implementing the
// derivation here would leave the gate measuring the test's own logic.
- val suite = // shared-model writes no code (it publishes metadata files named by its args), so it has no
+ // shared-model writes no code (it publishes metadata files named by its args), so it has no
// physical name to leak.
- list().filter { it.tier == GeneratorTier.NATIVE && it.name != "shared-model" }.map { it.factory() }
+ val suite = list().filter { it.tier == GeneratorTier.NATIVE && it.name != "shared-model" }.map { it.factory() }
val runArgs = EmitsPhysicalNameConstants.deriveUseNames(args, suite)
for (generator in suite) {
generator.apply { setArgs(runArgs) }.execute(loader)
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
index f0cc8d5146..a314a3481f 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
@@ -8,7 +8,6 @@
import com.metaobjects.generator.Generator;
import com.metaobjects.generator.GeneratorBase;
import com.metaobjects.generator.ProjectClassLoaderAware;
-import com.metaobjects.generator.ProjectVersionAware;
import com.metaobjects.generator.RunAware;
import com.metaobjects.generator.SharedModelGenerator;
import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase;
@@ -528,11 +527,6 @@ protected List buildGenerators(ClassLoader projectClassLoader,
((ProjectClassLoaderAware) impl).setProjectClassLoader( projectClassLoader );
}
- // A generator that publishes the project's version gets Maven's, as a default.
- if ( impl instanceof ProjectVersionAware && project != null && project.getVersion() != null ) {
- ((ProjectVersionAware) impl).setProjectVersion( project.getVersion() );
- }
-
// Merge generator args and global args
Map allargs = mergeAndOverwriteArgs(g);
@@ -601,6 +595,8 @@ protected List buildGenerators(ClassLoader projectClassLoader,
public Map mergeAndOverwriteArgs(GeneratorParam g) {
Map allargs = mergeArgs(globals, g.getArgs());
+ // Maven's project version, as a default a generator's own `version` arg overrides.
+ if ( project != null && project.getVersion() != null ) allargs.putIfAbsent("version", project.getVersion());
// Dump the args to debug
getLog().debug( "-- Generator ["+g.getClass().getSimpleName()+"] merged Args");
@@ -623,10 +619,10 @@ private Map mergeArgs(Map globalArgs, Map mergeArgs(Map globalArgs, Map mergeArgs(Map globalArgs, Map checkClosure(MetaData root, List selected, S
List walk = new ArrayList<>();
walkOwn(top, walk);
for (MetaData node : walk) {
- String referrerPkg = References.effectivePackageOf(node);
List candidates = new ArrayList<>();
String superRef = node.getAuthoredSuperRef();
if (superRef != null) candidates.add(superRef);
@@ -298,6 +300,8 @@ private static List checkClosure(MetaData root, List selected, S
Object value = attr.getValue();
if (value instanceof String s) candidates.add(s);
}
+ if (candidates.isEmpty()) continue;
+ String referrerPkg = References.effectivePackageOf(node);
for (String raw : candidates) {
MetaData resolved = References.resolveObjectRef(root, References.splitChildTail(raw).owner(), referrerPkg).node();
if (resolved == null) continue; // already validated at load: defensive only
diff --git a/server/java/metadata/src/main/java/com/metaobjects/io/json/CanonicalJsonSerializer.java b/server/java/metadata/src/main/java/com/metaobjects/io/json/CanonicalJsonSerializer.java
index 97f40a1964..7a155cd73f 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/io/json/CanonicalJsonSerializer.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/io/json/CanonicalJsonSerializer.java
@@ -171,7 +171,7 @@ public static String serializeSharedDocument(List extends MetaData> nodes) {
JsonArray children = new JsonArray();
for (MetaData node : sorted) {
String key = node.getName();
- int sep = key.lastIndexOf("::");
+ int sep = key.lastIndexOf(MetaData.PKG_SEPARATOR);
String pkg = sep == -1 ? "" : key.substring(0, sep);
if (pkg.isEmpty()) {
throw new IllegalArgumentException("serializeSharedDocument: " + key
From e02bdbad4de6254134c1244ce7513306e56dd936 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Thu, 5 Nov 2026 22:37:53 -0500
Subject: [PATCH 4/5] no-mistakes(review): Reject MSBuild-expression versions;
cover mojo version/files args
---
.../SharedModelGeneratorTests.cs | 22 ++++++++++++---
.../Generators/SharedModelGenerator.cs | 9 ++++++-
.../generator/kotlin/EjectContractTest.kt | 2 +-
.../mojo/AbstractMetaDataMojo.java | 2 +-
.../mojo/RelativeGeneratorArgPathsTest.java | 27 +++++++++++++++++++
5 files changed, 56 insertions(+), 6 deletions(-)
diff --git a/server/csharp/MetaObjects.Codegen.Tests/SharedModelGeneratorTests.cs b/server/csharp/MetaObjects.Codegen.Tests/SharedModelGeneratorTests.cs
index ffa22da5a3..e586a2f1f1 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/SharedModelGeneratorTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/SharedModelGeneratorTests.cs
@@ -2,6 +2,7 @@
// (fixtures/shared-model-conformance/) pins, byte for byte, through the path `dotnet meta gen` takes (options
// in, files out, version from the project). The artifact's bytes themselves are gated by
// SharedModelConformanceTests in the core test assembly.
+using System.Text.Json;
using MetaObjects.Codegen.Generators;
using MetaObjects.Config;
using MetaObjects.Loader;
@@ -43,6 +44,9 @@ private GenContext Context()
Version = version,
};
+ private static string? VersionOf(string manifest) =>
+ JsonDocument.Parse(manifest).RootElement.GetProperty("version").GetString();
+
[Fact]
public void WritesTheArtifactAndManifestTheCorpusPins()
{
@@ -70,7 +74,7 @@ public void TheVersionComesFromTheNearestDirectoryBuildPropsWhenTheProjectFileHa
var manifest = Generator().Generate(Context()).Single(f => f.Path == "metaobjects.pkg.json").Content;
- Assert.Contains("\"version\": \"2.3.4\"", manifest);
+ Assert.Equal("2.3.4", VersionOf(manifest));
}
[Fact]
@@ -84,6 +88,18 @@ public void APackageReferencesVersionIsNotTheProjectVersion()
Assert.Contains("no version available", e.Message);
}
+ [Fact]
+ public void AnUnevaluatedMsBuildExpressionIsNotAVersion()
+ {
+ File.WriteAllText(Path.Combine(_project, "App.csproj"),
+ "$(VersionPrefix)-beta");
+ File.WriteAllText(Path.Combine(_project, "Directory.Build.props"),
+ "2.3.4");
+
+ var e = Assert.Throws(() => Generator().Generate(Context()).ToList());
+ Assert.Contains("no version available", e.Message);
+ }
+
[Fact]
public void FilesResolveAgainstTheInputRootWhenThereIsNoProjectRoot()
{
@@ -109,7 +125,7 @@ public void FilesResolveAgainstTheInputRootWhenThereIsNoProjectRoot()
var manifest = generator.Generate(ctx).Single(f => f.Path == "metaobjects.pkg.json").Content;
- Assert.Contains("\"version\": \"1.0.0\"", manifest);
+ Assert.Equal("1.0.0", VersionOf(manifest));
}
[Fact]
@@ -120,7 +136,7 @@ public void AnExplicitVersionBeatsTheProjectVersion()
var manifest = Generator("1.0.0").Generate(Context()).Single(f => f.Path == "metaobjects.pkg.json").Content;
- Assert.Contains("\"version\": \"1.0.0\"", manifest);
+ Assert.Equal("1.0.0", VersionOf(manifest));
}
[Fact]
diff --git a/server/csharp/MetaObjects.Codegen/Generators/SharedModelGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/SharedModelGenerator.cs
index 50654f4fe5..02ac797dfa 100644
--- a/server/csharp/MetaObjects.Codegen/Generators/SharedModelGenerator.cs
+++ b/server/csharp/MetaObjects.Codegen/Generators/SharedModelGenerator.cs
@@ -94,8 +94,15 @@ private static InvalidOperationException Missing(string option, string what) =>
new($"shared-model: set {option} on the generator ({what}): new SharedModelGenerator {{ {option} = ... }}");
/// The <Version> the project builds with: the project file in the root, then
- /// Directory.Build.props in the root or the nearest directory above it. null when none declares one.
+ /// Directory.Build.props in the root or the nearest directory above it. null when none declares one,
+ /// or when the one that does is an MSBuild expression ($(...)) this generator does not evaluate.
private static string? ProjectVersion(string projectRoot)
+ {
+ var declared = DeclaredVersion(projectRoot);
+ return declared is null || declared.Contains("$(", StringComparison.Ordinal) ? null : declared;
+ }
+
+ private static string? DeclaredVersion(string projectRoot)
{
foreach (var project in Directory.EnumerateFiles(projectRoot, "*.csproj").Order(StringComparer.Ordinal))
if (VersionIn(project) is { } own) return own;
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectContractTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectContractTest.kt
index 06a51bbe23..d20fe2f92f 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectContractTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectContractTest.kt
@@ -228,12 +228,12 @@ class EjectContractTest {
private class Corpus(val label: String, model: String, templates: String? = null) {
val model: Path = FIXTURES.resolve(model)
val templates: Path? = templates?.let { FIXTURES.resolve(it) }
- /** Loaded once and shared by both sides. A loader's name is an identifier. */
/** The model's metadata files, comma-separated: every .json and .yaml under the directory. */
fun modelFiles(): String = Files.walk(model).use { walk ->
walk.filter { it.isRegularFile() && (it.toString().endsWith(".json") || it.toString().endsWith(".yaml")) }
.map { it.toString() }.sorted().toList().joinToString(",")
}
+ /** Loaded once and shared by both sides. A loader's name is an identifier. */
val loader by lazy { loadDirectory("ejectContract_" + label.replace('-', '_'), this.model) }
}
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
index a314a3481f..dd6c418262 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
@@ -596,7 +596,7 @@ public Map mergeAndOverwriteArgs(GeneratorParam g) {
Map allargs = mergeArgs(globals, g.getArgs());
// Maven's project version, as a default a generator's own `version` arg overrides.
- if ( project != null && project.getVersion() != null ) allargs.putIfAbsent("version", project.getVersion());
+ if ( project != null && project.getVersion() != null ) allargs.putIfAbsent(SharedModelGenerator.ARG_VERSION, project.getVersion());
// Dump the args to debug
getLog().debug( "-- Generator ["+g.getClass().getSimpleName()+"] merged Args");
diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/RelativeGeneratorArgPathsTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/RelativeGeneratorArgPathsTest.java
index 3abf59e8fe..1d233d4b1e 100644
--- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/RelativeGeneratorArgPathsTest.java
+++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/RelativeGeneratorArgPathsTest.java
@@ -1,6 +1,7 @@
package com.metaobjects.mojo;
import com.metaobjects.generator.GeneratorBase;
+import com.metaobjects.generator.SharedModelGenerator;
import com.metaobjects.generator.kotlin.KotlinRenderHelperGenerator;
import com.metaobjects.generator.spring.SpringRenderHelperGenerator;
import com.metaobjects.generator.template.TemplateScopeGenerator;
@@ -77,6 +78,32 @@ public void absolutePathsAndNonPathArgsAreLeftAlone() throws Exception {
assertEquals("acme.generated", args.get("packageName"));
}
+ @Test
+ public void eachEntryOfARelativeFilesListResolvesAgainstTheModuleBasedir() throws Exception {
+ String abs = tempFolder.newFile("elsewhere.json").getAbsolutePath();
+ Map args = merged(Map.of(
+ SharedModelGenerator.ARG_FILES, "metaobjects/meta.base.json, ../shared/meta.customer.json," + abs));
+
+ assertEquals(String.join(",", underBasedir("metaobjects/meta.base.json"),
+ underBasedir("../shared/meta.customer.json"), abs),
+ args.get(SharedModelGenerator.ARG_FILES));
+ }
+
+ @Test
+ public void theVersionArgDefaultsToTheProjectVersion() {
+ Mockito.when(mojo.project.getVersion()).thenReturn("2.3.4");
+
+ assertEquals("2.3.4", merged(Map.of()).get(SharedModelGenerator.ARG_VERSION));
+ }
+
+ @Test
+ public void anExplicitVersionArgBeatsTheProjectVersion() {
+ Mockito.when(mojo.project.getVersion()).thenReturn("2.3.4");
+
+ assertEquals("1.0.0", merged(Map.of(SharedModelGenerator.ARG_VERSION, "1.0.0"))
+ .get(SharedModelGenerator.ARG_VERSION));
+ }
+
/** PATH_ARGS lists the render helper's key once; both ports must keep reading that key. */
@Test
public void kotlinAndSpringRenderHelpersShareTheTemplateRootKey() {
From 22f13c7961fa1793451cce385763b51d1f365f44 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Thu, 5 Nov 2026 23:07:58 -0500
Subject: [PATCH 5/5] no-mistakes(document): Fixed publish-scope docs;
agent-context skill left for S10.
---
README.md | 10 +++++-----
docs/features/metadata-dependencies.md | 3 +--
docs/features/own-your-codegen.md | 4 ++++
docs/llms/llms-full.txt | 2 +-
docs/llms/llms.txt | 2 +-
docs/ports/kotlin.md | 5 +++--
6 files changed, 15 insertions(+), 11 deletions(-)
diff --git a/README.md b/README.md
index a5749c8e2e..4748a8cb90 100644
--- a/README.md
+++ b/README.md
@@ -276,11 +276,11 @@ sixth ships two libraries at their own stability labels:
into its `/db` layer (`["iam", "iam/db"]`). Copy one into your repo with
`meta eject `. See [`docs/features/libraries.md`](docs/features/libraries.md).
-**Sharing a model across your own projects** *(the TypeScript toolchain publishes;
-TypeScript, Python, Java, Kotlin and C# projects consume, from an installed npm or Python
-package or over a `path` dependency such as a sibling checkout)* — one project publishes
-part of its model (a common `Customer`, an audited base, a set of enums) with
-`sharedModelFile()`, and another declares it in `dependencies`, syncs it into a
+**Sharing a model across your own projects** *(TypeScript, Java, Kotlin and C# projects
+publish; TypeScript, Python, Java, Kotlin and C# projects consume, from an installed npm or
+Python package or over a `path` dependency such as a sibling checkout)* — one project publishes
+part of its model (a common `Customer`, an audited base, a set of enums) with its `shared-model`
+generator (`sharedModelFile()` in TypeScript), and another declares it in `dependencies`, syncs it into a
committed, hash-locked snapshot and builds on it with `extends` and references. When the
publisher's model moves, `meta verify --deps` says so, instead of two copies drifting
apart. See [`docs/features/metadata-dependencies.md`](docs/features/metadata-dependencies.md).
diff --git a/docs/features/metadata-dependencies.md b/docs/features/metadata-dependencies.md
index f4d03af863..b7eb6ca81a 100644
--- a/docs/features/metadata-dependencies.md
+++ b/docs/features/metadata-dependencies.md
@@ -409,8 +409,7 @@ publisher reachable.
## Publishing a dependency: `sharedModelFile()`
A publisher wires the `shared-model` generator (TypeScript, Java, Kotlin and C#; Python
-consumes only) to
-select a subset of its own metadata by the same include/exclude scope-pattern
+consumes only) to select a subset of its own metadata by the same include/exclude scope-pattern
grammar `scope` already uses, and emit it as one canonical-JSON artifact plus a
manifest:
diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md
index b74b1cbb1e..9f2ef99357 100644
--- a/docs/features/own-your-codegen.md
+++ b/docs/features/own-your-codegen.md
@@ -358,6 +358,10 @@ import { sharedModelFile } from "./codegen/generators/shared-model.js";
generators: [sharedModelFile({ name: "acme-common", include: ["acme::common::**"] })],
```
+Java, Kotlin and C# publish with the same generator and eject it with their own commands; see
+[Java and Kotlin](../ports/java.md#publishing-a-shared-model), [Kotlin](../ports/kotlin.md#publishing-a-shared-model-shared-model)
+and [C#](../ports/csharp.md#publishing-a-shared-model-shared-model).
+
An unchanged copy publishes exactly what the packaged generator publishes, and a consumer's
sync accepts it. The two functions are in your project now, so they are yours to change, and
a consumer is the one that checks them: the selection, the closure, the bytes, the hash and
diff --git a/docs/llms/llms-full.txt b/docs/llms/llms-full.txt
index ce7886bf3f..30d79d6494 100644
--- a/docs/llms/llms-full.txt
+++ b/docs/llms/llms-full.txt
@@ -117,7 +117,7 @@ A library is **layered, and its core layer is inert**: the core declares no `sou
### Sharing a model across your own projects
-Today only the TypeScript toolchain publishes; TypeScript, Python, Java, Kotlin and C# projects consume, from an installed npm or Python package or over a `path` dependency (a sibling checkout or submodule). Maven and NuGet transports are design only. One project publishes part of its model with the `sharedModelFile()` generator; another declares it under `dependencies` in `.metaobjects/config.json`, runs `meta deps sync` to commit a hash-locked snapshot, and builds on it with `extends`, overlays and references. When the publisher's model moves, `meta verify --deps` reports it instead of two copies drifting apart. See docs/features/metadata-dependencies.md.
+TypeScript, Java, Kotlin and C# projects publish; TypeScript, Python, Java, Kotlin and C# projects consume, from an installed npm or Python package or over a `path` dependency (a sibling checkout or submodule). Maven and NuGet transports are design only. One project publishes part of its model with its `shared-model` generator (`sharedModelFile()` in TypeScript); another declares it under `dependencies` in `.metaobjects/config.json`, runs `meta deps sync` to commit a hash-locked snapshot, and builds on it with `extends`, overlays and references. When the publisher's model moves, `meta verify --deps` reports it instead of two copies drifting apart. See docs/features/metadata-dependencies.md.
---
diff --git a/docs/llms/llms.txt b/docs/llms/llms.txt
index 55839597a8..eb07608264 100644
--- a/docs/llms/llms.txt
+++ b/docs/llms/llms.txt
@@ -63,7 +63,7 @@ MetaObjects pillars are capabilities of one metadata model, not separate product
- **Requirements and testing** — declare what the software is supposed to *do* in the same model as the entities, so a capability claim is checkable instead of prose. `requirement.functional` / `requirement.architectural` are registered vocabulary in all five ports, and the loader enforces the closed `@status` enum. `@implementedBy` is checked against the model, not taken on faith: it must name a real member of the real model, so a claim whose implementation was renamed or deleted fails the build instead of going quietly stale. `meta verify` reports the ledger on every run — unresolved links, entities no claim covers, gaps recorded versus gaps nobody has ruled on — and lints the authoring; the documentation generators render it for humans and for agents (`requirementsFile()`, `agentDocsFile()` and their counterparts in the other ports). A live claim naming nothing is a warning; a green run proves the references resolve, never that an implementation is correct. **Every port ships a `requirement-tests` generator.** It scaffolds a test per claim, carrying the statement and counterexample in: a stub you fill in for TypeScript, a test that calls a witness you own in the other four. The vocabulary and the `verify` checks are in every port too. Only the authoring lint is TypeScript-only. A project that declares no `requirement.*` nodes sees no change at all.
- **Libraries** — reusable declared design you opt into by name (`"libraries": ["iam"]` in `.metaobjects/config.json`): entities, the requirements they promise and the generators they imply, as one drift-gated artifact. A library is layered and its core layer is inert: it declares no source, so it adds no tables until you also opt into its `/db` layer (`["iam", "iam/db"]`). `meta eject ` copies one into your repo to own. `ai` (the LLM-call trace envelope) is `stable`; `iam` (users, nestable groups, roles, grants) is `preview`.
-**Sharing a model across your own projects** — today only the TypeScript toolchain publishes; TypeScript, Python, Java, Kotlin and C# projects consume, from an installed npm or Python package or over a `path` dependency (a sibling checkout or submodule). Maven and NuGet transports are design only. One project publishes part of its model with the `sharedModelFile()` generator; another declares it under `dependencies` in `.metaobjects/config.json`, runs `meta deps sync` to commit a hash-locked snapshot, and builds on it with `extends` and references. When the publisher's model moves, `meta verify --deps` reports it instead of two copies drifting apart.
+**Sharing a model across your own projects** — TypeScript, Java, Kotlin and C# projects publish; TypeScript, Python, Java, Kotlin and C# projects consume, from an installed npm or Python package or over a `path` dependency (a sibling checkout or submodule). Maven and NuGet transports are design only. One project publishes part of its model with its `shared-model` generator (`sharedModelFile()` in TypeScript); another declares it under `dependencies` in `.metaobjects/config.json`, runs `meta deps sync` to commit a hash-locked snapshot, and builds on it with `extends` and references. When the publisher's model moves, `meta verify --deps` reports it instead of two copies drifting apart.
## Implementations (npm `1.2.0` / Maven Central `8.2.0`)
diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md
index a7e29b2cd6..6d1a56996b 100644
--- a/docs/ports/kotlin.md
+++ b/docs/ports/kotlin.md
@@ -157,7 +157,7 @@ your own ``, `` and ``:
## Configure
-The 17 generators registered in `codegen-kotlin` (`GeneratorRegistry.kt`):
+The 18 generators registered in `codegen-kotlin` (`GeneratorRegistry.kt`):
| Generator | Output | Per |
|---|---|---|
@@ -177,6 +177,7 @@ The 17 generators registered in `codegen-kotlin` (`GeneratorRegistry.kt`):
| `KotlinSpringControllerGenerator` | `Controller.kt` — Spring `@RestController` (5 CRUD endpoints; cross-port API contract). **Select `KotlinRelationsGenerator` with it** when the model has a M:N relationship: the traversal routes call the `Query` helpers only that generator emits, and without it the controller does not compile | entities with `source.rdb @kind="table"` |
| `KotlinRequirementTestsGenerator` | Per metamodel package that holds a tested requirement, two files in `testPackage`: `Requirements__Witnesses.kt` (an interface whose default members fail with `unimplemented requirement: ...`) and `Requirements__Test.kt` (one JUnit Jupiter `@Test` per requirement, calling a project-owned witness). See "Requirement tests" below | every tested `requirement.*` (functional, level 4 or above by default) |
| `KotlinReportExportGenerator` | Standalone export of a served report (`?format=csv\|json\|xml`, see [Report export](#report-export)): one `Export.kt` per served report beside its controller and one shared `ReportExportWriter.kt`. Args: `outputDir` (required), `maxRows` (default `100000`), `xmlNulls` (`empty`, the default, or `nil`), `csvBom` (default `false`) and `writerPackage` (default: the package prefix the served reports share, else `export`). Never implied by a library. Ejectable | every served report |
+| `KotlinSharedModelGenerator` | `.metaobjects.json` and `metaobjects.pkg.json` — the FR-023 shared-model artifact and manifest a consumer's `meta deps sync` reads. See [Publishing a shared model](#publishing-a-shared-model-shared-model) | once per project (`include` selects the nodes) |
| `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` (with no `modelBaseUrl` the pages carry no `Model / metadata` link). 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. The plugin bundles `metaobjects-codegen-kotlin`, so it is not a dependency of your
@@ -309,7 +310,7 @@ copies `KotlinSharedModelGenerator.kt` into a `codegen/` module under your packa
### Declarative template-codegen (`TemplateScopeGenerator`)
-The 17 generators above are a starting point, not the ceiling. When you need a shape
+The 18 generators above are a starting point, not the ceiling. When you need a shape
none of them emits, Kotlin has **both** authoring paths — and which to reach for is a
real decision (tradeoff table: [`codegen-concepts.md` §3](../features/codegen-concepts.md)).