diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5292f24a3..79f605864b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,23 @@ loading. A port that reports `1.2` does not know that vocabulary; upgrade every
### Added
+- **A shared model can carry its requirements, and a consumer can inherit them (FR-023).** The
+ publisher's `shared-model` generator takes `requirements: true` (TypeScript `sharedModelFile`,
+ the Java/Kotlin generator arg `requirements`, C# `SharedModelGenerator.Requirements`; default
+ off) and then publishes the selected `requirement.*` nodes beside the model they describe; a
+ requirement claiming a node outside the selection is refused at publish. `template.*` is never
+ published. On the consumer side, `requirements.inherit` in `.metaobjects/config.json` names the
+ dependency packages whose requirements join the project's ledger, by the same explicit-include
+ rule as `scope.include` (`**` names nothing). A dependency's requirement that is not inherited
+ still loads, so an overlay of it resolves, but the requirement gate, its summary and lint, the
+ `requirement-tests` generators and the requirements pages leave it out; `requirementTestIdentities`
+ (TypeScript) and `requirement_test_identities` (Python) take the collection's ledger predicate
+ as `inLedger` / `in_ledger`, as the generator does. An imported requirement never switches object
+ coverage on, inherited or not, as a library's never does. Every port reads the key (TypeScript,
+ Python, Java, Kotlin through Java, C#). A consumer on an older toolchain treats a published
+ requirement as its own, so upgrade consumers before publishing with `requirements: true`. See
+ `docs/features/metadata-dependencies.md`, "Inheriting a dependency's requirements".
+
- **`maven` and `nuget` dependency transports (FR-023).** `meta deps sync` and `meta deps check` can
now read a shared model out of a published package that is already in the local cache, so a JVM or
.NET consumer no longer needs the publisher's directory on disk. `{ "name": "x", "maven":
@@ -317,6 +334,10 @@ loading. A port that reports `1.2` does not know that vocabulary; upgrade every
### Changed
+- **A new top-level requirement declared into a dependency's package is refused** with
+ `ERR_DEPENDENCY_PACKAGE_NOT_OWNED`, as a new object there already was. An overlay of the
+ dependency's own requirement is still accepted.
+
- **`docsSiteFile()` builds the model explorer by default.** A project that lists
`docsSiteFile()` gets a different site under `site/` on its next `meta gen`, with new page
paths (object pages move under `model/`); `meta gen` removes the old pages it wrote, as it
diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md
index 28981161fc..1c8ef3632d 100644
--- a/docs/CONFORMANCE.md
+++ b/docs/CONFORMANCE.md
@@ -49,8 +49,8 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor
| [`fixtures/provider-composition-conformance/`](../fixtures/provider-composition-conformance/) | 18 (11 error-shape + 4 compose-load + 3 attr-ownership) | ✓ | ✓ | — (JVM registry via Java) | ✓ | ✓ |
| [`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/) | 39 cases (28 resolution + 11 check) + 32 transport cases | ✓ (reference implementation; all three arms) | ✓ (resolution arm, all 28; check arm, all 11) | inherits via Java | ✓ (resolution arm, all 28; check arm, all 11, as `verify --deps`) | ✓ (resolution arm, all 28 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) | ✓ (all 11; `SharedModelConformanceTest`) | inherits via Java (`KotlinSharedModelGeneratorTest` pins the generator) | ✓ (all 11; `SharedModelConformanceTests`) | ✓ (serializer arm: the 7 publish cases' document bytes; no publisher) |
+| [`fixtures/dependency-conformance/`](../fixtures/dependency-conformance/) | 47 cases (36 resolution, 7 of them with a ledger arm, + 11 check) + 32 transport cases | ✓ (reference implementation; all four arms) | ✓ (resolution arm, all 36, ledger included; check arm, all 11) | inherits via Java | ✓ (resolution arm, all 36, ledger included; check arm, all 11, as `verify --deps`) | ✓ (resolution arm, all 36 identically, ledger included; check arm, all 11, as `deps check` and `verify --deps`; the transport arm is Node-only) |
+| [`fixtures/shared-model-conformance/`](../fixtures/shared-model-conformance/) | 15 cases (9 publish + 6 refusal) | ✓ (reference implementation) | ✓ (all 15; `SharedModelConformanceTest`) | inherits via Java (`KotlinSharedModelGeneratorTest` pins the generator) | ✓ (all 15; `SharedModelConformanceTests`) | ✓ (serializer arm: the 9 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 | ✓ | ✓ |
@@ -463,21 +463,30 @@ also matches before a trailing newline. Kotlin has no runner of its own: it inhe
the Java loader. Each of those two guards is pinned by a test beside the corpus, since
the corpus asserts matches only and carries no invalid-pattern case.
-### `fixtures/dependency-conformance/` (39 cases + 32 transport cases)
+### `fixtures/dependency-conformance/` (47 cases + 32 transport cases)
-All 39 cases → [features/metadata-dependencies.md](features/metadata-dependencies.md)
+All 47 cases → [features/metadata-dependencies.md](features/metadata-dependencies.md)
(declaring a dependency, `meta deps sync`, the committed snapshot + lock, default
exclusion of imported metadata, overlay/extends across the boundary, every load-
-and resolution-time failure, and the upstream check). File-shaped like `scope-conformance/`
-above: one committed `cases.json`, no per-port fixture, no ledger. The corpus has three arms:
+and resolution-time failure, the upstream check, and which imported requirements are the
+project's own ledger). File-shaped like `scope-conformance/` above: one committed `cases.json`,
+no per-port fixture. The corpus has four arms:
-- **Resolution (28 cases).** Every case's `expectFiles`/`expectImported`/`expectSelected`/
+- **Resolution (36 cases).** Every case's `expectFiles`/`expectImported`/`expectSelected`/
`expectMigrateGoverned`/`expectLoadError` assertions. **TypeScript (the reference
- implementation), Python, Java and C# all run all 28, identically**, with no exemption. The two
+ implementation), Python, Java and C# all run all 36, identically**, with no exemption. The two
overlay cases that need a view child as incidental content use `view.currency` (the one
concrete `view.*` subtype registered cross-port), not `view.text` — a prior revision used
`view.text`, which Python did not register then, and carried a since-discharged allowlist
for it. Kotlin inherits the Java run through the same loader and Maven plugin.
+- **Ledger (7 of the resolution cases, `expectLedger` / `expectCoverageMeasured`).** Which loaded
+ requirements are this project's ledger (its own, plus a dependency's only when
+ `requirements.inherit` names the dependency's package) with each one's effective `@status`, and
+ whether object coverage is measured (never by an imported requirement). Each port asserts it
+ through its requirement gate's scan and again through its requirement-tests walk: TypeScript in
+ `cli/test/dependency-ledger-conformance.test.ts`, the other ports in their resolution runner.
+ An eighth case refuses a new requirement declared into a dependency's package
+ (`ERR_DEPENDENCY_PACKAGE_NOT_OWNED`) on the load-time path.
- **Check (11 cases, `expectCheck`).** For each declared dependency, `current`, `drifted` or
`unresolved` (a missing path, a bad manifest, a hash mismatch, or not yet in the lock). Run
by TypeScript, in `cli/test/dependency-check-conformance.test.ts`, by Java in
@@ -500,24 +509,25 @@ neutral one (`Resolve(null, dir)`), and the explicit metadata directory a
`DependencyConformanceTests`; it runs the 11 check cases through `DependencyResolver.CheckDependencies`
(S7) and skips the transport cases (C# never syncs).
-### `fixtures/shared-model-conformance/` (11 cases)
+### `fixtures/shared-model-conformance/` (15 cases)
→ [features/metadata-dependencies.md](features/metadata-dependencies.md) (the publisher's
`sharedModelFile()`). A source tree under `source/`, plus `files` / `include` / `exclude` /
-`version`, must produce **exactly** the pinned artifact and manifest bytes (seven cases), or be
-refused for the pinned reason (four: an empty selection, a reference or `extends` outside the
-selection with the `referrer → target` pairs named, and vocabulary only a consumer provider
-registers). `requirement.*` and `template.*` nodes are always dropped, and the order of `files`
-does not change the bytes. The manifest goldens carry the token `@@METAMODEL_VERSION@@` where
+`version` / `requirements`, must produce **exactly** the pinned artifact and manifest bytes (nine
+cases), or be refused for the pinned reason (six: an empty selection, a requirement alone
+without `requirements`, a requirement claiming a node outside the selection, a reference or
+`extends` outside the selection with the `referrer → target` pairs named, and vocabulary only a
+consumer provider registers). `template.*` nodes are always dropped and `requirement.*` nodes
+unless `requirements` is set, and the order of `files` does not change the bytes. The manifest goldens carry the token `@@METAMODEL_VERSION@@` where
the manifest records the toolchain's `metamodelVersion`, so they do not move with it.
-**TypeScript runs all 11**, in `codegen-ts/test/shared-model-conformance.test.ts`. **Python runs
+**TypeScript runs all 15**, in `codegen-ts/test/shared-model-conformance.test.ts`. **Python runs
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 runs all 11**, in `metadata`'s `SharedModelConformanceTest` against the `SharedModel` engine
+**Java runs all 15**, 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`.
+only `KotlinSharedModelGeneratorTest`. **C# runs all 15**, 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`,
@@ -641,7 +651,7 @@ JSON and verify payload feeds it unchanged.
The fixtures in the nine corpora mapped above (metamodel 449 + yaml 16 + verify 45
+ render 18 + persistence 59 + api-contract 121 + source-resolution 25 + scope 10 +
-dependency 39) each map to a feature doc, and so do the two requirement corpora, `shared-model-conformance`, the
+dependency 47) each map to a feature doc, and so do the two requirement corpora, `shared-model-conformance`, the
`cube-model` corpus and `explorer-conformance`, whose case lists live in their own READMEs. None are orphaned today. The remaining
corpora in the totals table gate tooling contracts (registry manifests, provider
composition, agent context, docs emit) rather than user-facing metamodel behaviour,
diff --git a/docs/features/metadata-dependencies.md b/docs/features/metadata-dependencies.md
index d188c5bf08..f3694b0672 100644
--- a/docs/features/metadata-dependencies.md
+++ b/docs/features/metadata-dependencies.md
@@ -401,6 +401,53 @@ scope too, whether or not it has any dependencies at all.
`field.enum` reaches the generated `enums.ts` iff a *selected* entity's field resolves
to it — decided by the using entity, never by the enum's own package.
+## Inheriting a dependency's requirements
+
+A dependency published with `requirements: true` carries its requirements, and they load
+with its model: an overlay of one resolves, and `meta verify` can see it. By default they
+stay the **publisher's** ledger, not yours. The requirement gate does not check, count,
+summarise or lint them, the `requirement-tests` generator writes no test for them, and the
+requirements pages (`requirementsFile()`, the agent docs' `requirements.md`, an entity page's
+"Required by") do not list them.
+That is the same default as an imported entity's code generation, for the same reason:
+you did not declare them.
+
+To take them on, name the dependency's package in `requirements.inherit`:
+
+```jsonc
+// .metaobjects/config.json (consumer)
+{
+ "dependencies": [{ "name": "acme-common", "npm": "@acme/common-model" }],
+ "requirements": { "inherit": ["acme::common::**"] }
+}
+```
+
+The inherited requirements then join your ledger: `meta verify` resolves their
+`@implementedBy` against the loaded model and reports each by its status, and
+`requirement-tests` scaffolds a test per claim. Change one's verdict for your project with
+an overlay (same package, same name, `overlay: true`, a new `@status`); a NEW requirement
+declared into the dependency's package is refused with `ERR_DEPENDENCY_PACKAGE_NOT_OWNED`,
+as a new object there is.
+
+Three rules keep it predictable:
+
+- **A pattern must NAME the package.** `acme::common::**` (or a node pattern such as
+ `acme::common::Customers`) inherits `acme::common`; a bare `acme::common` names the
+ package `acme`, and `**` alone names nothing, so neither inherits it. The final segment
+ names a node, which is the explicit-include rule `scope.include` follows.
+- **It is a separate key from `scope.include`.** Naming a package in `scope.include`
+ also generates code for it, which is not what taking on its requirements means.
+ `scope.include` alone inherits nothing.
+- **An imported requirement never switches object coverage on**, inherited or not. Only a
+ requirement you wrote yourself does, as for a [library](libraries.md)'s requirements:
+ depending on a model must not enrol you in a gate you did not ask for.
+
+`requirements` refuses any key but `inherit`, so a misspelling fails the load instead of
+quietly leaving the ledger out. A consumer on a toolchain older than this key treats a
+published requirement as its own, so pin the toolchain before you publish with
+`requirements: true` to a consumer you do not control. The manifest's `schema_version` does
+not change.
+
## What fails at load, when upstream moves
The loader's own validation already fails your build the moment a target you built on
@@ -473,6 +520,7 @@ export default defineConfig({
name: "acme-common",
include: ["acme::common::**"],
// exclude: ["acme::common::InternalOnly"],
+ // requirements: true, // also publish the selected requirement.* nodes (default false)
// files: [...], // default: the run's own source files
// version: "1.2.0", // default: the nearest package.json version
target: "shared",
@@ -490,13 +538,19 @@ it:
1. Loads `files` standalone with your project's own registry, strict.
2. Selects top-level nodes matching `include`/`exclude` — always excluding
- `requirement.*` and `template.*` (a publisher's ledger and prompts are not a
- consumer's shapes).
+ `template.*` (a publisher's prompts are not a consumer's shapes), and excluding
+ `requirement.*` unless `requirements: true`. With it, the publisher's ledger ships
+ with the model it describes, so a consumer can inherit it (see
+ [Inheriting a dependency's requirements](#inheriting-a-dependencys-requirements)).
+ Java and Kotlin take the same switch as the generator arg `requirements`, and C# as
+ `SharedModelGenerator.Requirements`.
3. **Checks the selection is closed** — every `extends` target and every ref-bearing
attribute of a selected node (and its descendants) must resolve to another
*selected* node, or the build fails naming the `(referrer → target)` pairs. This
is what stops an internal shape from silently leaking through a dangling
- reference.
+ reference. A published requirement's `@implementedBy` targets count as references
+ too: a requirement claiming a node you did not select is refused, so a consumer
+ never inherits a claim that cannot resolve.
4. **Re-loads the emitted artifact standalone with core providers only** — your
provider vocabulary is not carried into the export; a Phase 1a export that needs
it fails here, at publish time, not on a consumer's `meta deps sync`.
@@ -785,8 +839,8 @@ but **not** built yet — do not assume any of the following exists:
## Verified by
-- [`fixtures/dependency-conformance/`](../../fixtures/dependency-conformance/) — 39
- cases. 28 pin resolution (including `npm`, `python`, `maven` and `nuget` dependencies loading from their
+- [`fixtures/dependency-conformance/`](../../fixtures/dependency-conformance/) — 47
+ cases. 36 pin resolution (including `npm`, `python`, `maven` and `nuget` dependencies loading from their
committed snapshot with nothing installed), the exhaustive `imported`/`inScope`/`inMigrateScope` sets,
overlay/extends/reference behavior against a synced snapshot, and every load- and
resolution-time error above, run by TypeScript (the reference implementation),
@@ -795,22 +849,27 @@ but **not** built yet — do not assume any of the following exists:
check (`current`, `drifted`, `unresolved`), run by TypeScript, Java (Kotlin through the same
`metaobjects:verify` goal), Python (`metaobjects deps check`, `verify --deps`) and C#
(`dotnet meta verify --deps`).
- Three artifacts under `fixtures/dependency-conformance/artifacts/` are
+ Four artifacts under `fixtures/dependency-conformance/artifacts/` are
pinned by sha256 in the README so the corpus itself can never silently drift.
- Python's runner asserts all 28 resolution cases identically to TypeScript's, with no
+ Python's runner asserts all 36 resolution cases identically to TypeScript's, with no
exemptions — the two overlay cases that need a view child as incidental content
use `view.currency` (the one concrete `view.*` subtype registered cross-port,
per `fixtures/registry-conformance/expected-registry.json`), not `view.text`,
so they exercise the full imported/selected/governed assertions like every
- other case. `transport-cases.json` beside it pins the `npm`, `python`, `maven` and `nuget` transports
+ other case. Seven resolution cases also pin the ledger (`expectLedger`, `expectCoverageMeasured`):
+ which requirements are the project's own and whether coverage is measured, asserted in
+ TypeScript, Python, Java and C# through each port's requirement gate and through its
+ `requirement-tests` identities walk, and in TypeScript through the rows the requirements
+ pages list (the documentation generators that list requirements are TypeScript-only). `transport-cases.json` beside it pins the `npm`, `python`, `maven` and `nuget` transports
themselves (32 cases: packages laid out under a temp `node_modules`, on a temp
Python import path, as a jar in a temp local Maven repository and as a package in a temp NuGet
cache, the directory each resolves to, the exact lock `sync` writes, and
each refusal), run by Node `meta deps sync`, the only writer.
-- [`fixtures/shared-model-conformance/`](../../fixtures/shared-model-conformance/) — 11 cases
+- [`fixtures/shared-model-conformance/`](../../fixtures/shared-model-conformance/) — 15 cases
pinning what a publisher writes: the exact artifact and manifest bytes for a source tree and
- `include` / `exclude` / `files`, and the refusals (an empty selection, a closure failure naming
- every `referrer → target` pair, vocabulary a consumer's toolchain does not ship). TypeScript, Java
+ `include` / `exclude` / `files` / `requirements`, and the refusals (an empty selection, a
+ requirement claiming a node outside the selection, a closure failure naming every
+ `referrer → target` pair, vocabulary a consumer's toolchain does not ship). TypeScript, Java
(Kotlin through the same engine) and C# run all of it; Python runs the serializer arm.
## See also
diff --git a/docs/features/requirements.md b/docs/features/requirements.md
index 8f072f87f4..86094d6ddb 100644
--- a/docs/features/requirements.md
+++ b/docs/features/requirements.md
@@ -380,9 +380,16 @@ reaches that base; a functional claim does not spread. What is counted is concre
`object.entity` nodes: abstract entities, `object.value` and `object.projection` are not.
**When coverage is measured.** Only when the project authored at least one requirement of
-its own. A project whose only requirements came from a shipped library, with or without an
-overlay on one of them, is not measured, and the summary says so instead of printing a
-ratio.
+its own. A project whose only requirements came from a shipped library or a metadata
+dependency, with or without an overlay on one of them, is not measured, and the summary says
+so instead of printing a ratio.
+
+**A dependency's requirements are not yours by default.** A shared model published with
+`requirements: true` carries its requirements; they load, but the gate, the summary and the
+`requirement-tests` generator leave them out unless `requirements.inherit` in
+`.metaobjects/config.json` names the dependency's package. Inherited, they are checked and
+counted like your own, and still never switch coverage on. See
+[metadata-dependencies.md](metadata-dependencies.md#inheriting-a-dependencys-requirements).
**The summary**, one line whenever the model declares a requirement:
diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md
index ec80639894..a4a72b357c 100644
--- a/docs/ports/csharp.md
+++ b/docs/ports/csharp.md
@@ -114,7 +114,8 @@ A model published from another repository is declared under `dependencies` in
reads both, at every rung of the source ladder, and never writes them: a snapshot that no longer
matches the lock fails with `ERR_DEPENDENCY_SNAPSHOT_STALE`. The snapshot loads first, so your
own metadata can `extends`, overlay or reference its nodes, but an imported node is not generated
-(and is not counted by the requirement gate) unless `scope.include` names its package. What C#
+unless `scope.include` names its package, and an imported requirement is not counted by the
+requirement gate unless `requirements.inherit` names it. What C#
does and does not do is in
[Metadata dependencies](../features/metadata-dependencies.md).
@@ -305,7 +306,8 @@ new SharedModelGenerator
`` (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`
+else. `requirement.*` nodes are published only when `Requirements` is set (`requirements: true`, default
+off); `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.
diff --git a/docs/ports/java.md b/docs/ports/java.md
index d01f747798..3cd9e1343d 100644
--- a/docs/ports/java.md
+++ b/docs/ports/java.md
@@ -202,7 +202,8 @@ directory). `meta deps sync` (Node) is the only writer of the snapshot and `deps
the Maven build reads them offline and refuses a stale or hand-edited snapshot with
`ERR_DEPENDENCY_SNAPSHOT_STALE`. Your own entities can `extends` and reference the imported
ones; the generators emit nothing for an imported object unless `scope.include` names its
-package, and a new top-level node declared into a package a dependency owns fails with
+package, an imported requirement counts toward the gate only when `requirements.inherit` names
+its package, and a new top-level node declared into a package a dependency owns fails with
`ERR_DEPENDENCY_PACKAGE_NOT_OWNED`. Kotlin builds inherit all of this. See
[`metadata-dependencies.md`](../features/metadata-dependencies.md).
@@ -802,7 +803,8 @@ Wire it like any generator:
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.
+module's `` unless you pass `version`. `requirement.*` nodes are published only when you pass
+`true` (default off); `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.
diff --git a/fixtures/dependency-conformance/README.md b/fixtures/dependency-conformance/README.md
index 98dd19ed1a..5ec191b967 100644
--- a/fixtures/dependency-conformance/README.md
+++ b/fixtures/dependency-conformance/README.md
@@ -13,11 +13,13 @@ cases.json # { cases: [ { name, tree, treeFiles?, config, lock?,
# resolveFrom?, expectFiles?, expectImported?,
# expectSelected?, expectMigrateGoverned?,
# expectLoadError?, expectErrorFiles?, expectError?,
+ # expectLedger?, expectCoverageMeasured?,
# expectCheck? } ] }
transport-cases.json # the npm / python / maven / nuget transport arm (Node `meta deps sync` only — see below)
README.md
artifacts/ # pinned dependency artifacts, referenced by cases via `treeFiles`
acme-common-v1.json
+ acme-common-v1-requirements.json
acme-common-v1-widened.json
acme-common-v2-email-removed.json
```
@@ -42,6 +44,8 @@ artifacts/ # pinned dependency artifacts, referenced by cases via `tre
// (undefined inMigrateScope admits everything)
"expectLoadError": "ERR_*", // OPTIONAL: the collection resolves, then LOADING it fails with this code
"expectErrorFiles": ["dep:…"], // OPTIONAL with expectLoadError: source.files[0] of the first error
+ "expectLedger": { "::": "" }, // OPTIONAL: the LEDGER arm, EXHAUSTIVE (see below)
+ "expectCoverageMeasured": true, // OPTIONAL with expectLedger: default false
"expectError": "ERR_*", // resolution itself fails with this code
"expectCheck": { "": "current" | "drifted" | "unresolved" } // the CHECK arm
} ] }
@@ -95,6 +99,18 @@ belong to the other.
(`dep:/`) rather than a local one.
- **`expectError`** — the resolution-failure arm: `resolveCollection` itself must reject
with this exact code.
+- **`expectLedger`** — OPTIONAL, alongside `expectFiles`: the LEDGER arm. The EXHAUSTIVE map,
+ over every loaded requirement, of the ones that are this project's own ledger, keyed
+ `::` (the bare path when the package is empty), each to its
+ effective `@status` (after overlays). A requirement belongs to the ledger unless its package
+ is a dependency's and `requirements.inherit` does not NAME that package (the same
+ explicit-include rule as `scope.include`; `**` names nothing). A port asserts the map through
+ its requirement gate's scan, and the same set of addresses through its requirement-tests
+ identities walk; TypeScript also asserts the paths its requirements pages list (the only port
+ with a generator that lists requirements).
+- **`expectCoverageMeasured`** — OPTIONAL, alongside `expectLedger`, default `false`: whether
+ the gate measures object coverage. Only a requirement the project wrote itself switches it on;
+ an imported requirement never does, inherited or not, as a library's never does.
- **`expectCheck`** — the check arm (`meta deps check` / `verify --deps`): for each declared
dependency, resolve its `path` against the config's directory, read and validate the
@@ -122,6 +138,10 @@ belong to the other.
`field.string @maxLength: 120`). `acme-common-v1-widened.json` is identical except
`@maxLength: 200` (a compatible widening). `acme-common-v2-email-removed.json` is
identical except the whole `email` field child is absent (a breaking removal).
+`acme-common-v1-requirements.json` is the base artifact plus the publisher's ledger, published
+with `requirements: true`: `requirement.functional Customers` (live, claiming `Customer`, with a
+`ReachableByEmail` child claiming `Customer.email`) and `requirement.architectural AuditedRows`
+(live, claiming `Audited`). The shared-model corpus produces it byte for byte.
Verify the bytes from the repo root:
@@ -133,6 +153,7 @@ Expected, exactly:
```
sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d fixtures/dependency-conformance/artifacts/acme-common-v1.json
+sha256-4a812237fec408e10eaf1dfa25550cc642c9b3c0e41dc72581db8ba93c87eb57 fixtures/dependency-conformance/artifacts/acme-common-v1-requirements.json
sha256-fa00b9f3c54a2c302cf269af051589afc0a3217999ac82c54c77e33494638c36 fixtures/dependency-conformance/artifacts/acme-common-v1-widened.json
sha256-c4ba364bff0071b164b127326c095d6d32915b57781271e0be64a7003c27ce7a fixtures/dependency-conformance/artifacts/acme-common-v2-email-removed.json
```
@@ -265,6 +286,9 @@ package are `ERR_DEPENDENCY_UNRESOLVED`; a `dir` that exists but holds no `metao
- **Kotlin** — inherits via Java: it loads through the same loader and Maven plugin, and checks
through the same `metaobjects:verify` goal. The
Kotlin generators are covered by a codegen test that an imported entity emits nothing.
+- **The ledger arm** (`expectLedger`) runs in every port but Kotlin, which shares Java's gate:
+ TypeScript `cli/test/dependency-ledger-conformance.test.ts` (the gate lives in the CLI
+ package), Python, Java and C# in the same runners named below.
- **C#** — the resolution arm: resolution, load-time failure, lock/snapshot integrity, on the
neutral source ladder and again through the explicit metadata directory (C# has no native
config surface, so `.metaobjects/config.json` is where a dependency is declared at every
diff --git a/fixtures/dependency-conformance/artifacts/acme-common-v1-requirements.json b/fixtures/dependency-conformance/artifacts/acme-common-v1-requirements.json
new file mode 100644
index 0000000000..ee61ade5c2
--- /dev/null
+++ b/fixtures/dependency-conformance/artifacts/acme-common-v1-requirements.json
@@ -0,0 +1,109 @@
+{
+ "metadata.root": {
+ "children": [
+ {
+ "object.value": {
+ "name": "Address",
+ "package": "acme::common",
+ "children": [
+ {
+ "field.string": {
+ "name": "city"
+ }
+ },
+ {
+ "field.string": {
+ "name": "street"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "object.entity": {
+ "name": "Audited",
+ "package": "acme::common",
+ "abstract": true,
+ "children": [
+ {
+ "field.timestamp": {
+ "name": "createdAt"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "requirement.architectural": {
+ "name": "AuditedRows",
+ "package": "acme::common",
+ "@counterexample": "An audited row with no creation time.",
+ "@implementedBy": [
+ "Audited"
+ ],
+ "@statement": "Every audited row records when it was created.",
+ "@status": "live"
+ }
+ },
+ {
+ "object.entity": {
+ "name": "Customer",
+ "package": "acme::common",
+ "children": [
+ {
+ "source.rdb": {
+ "@table": "customers"
+ }
+ },
+ {
+ "field.long": {
+ "name": "id"
+ }
+ },
+ {
+ "field.string": {
+ "name": "email",
+ "@maxLength": 120
+ }
+ },
+ {
+ "identity.primary": {
+ "name": "pk",
+ "@fields": [
+ "id"
+ ]
+ }
+ }
+ ]
+ }
+ },
+ {
+ "requirement.functional": {
+ "name": "Customers",
+ "package": "acme::common",
+ "@counterexample": "Two rows for one customer.",
+ "@implementedBy": [
+ "Customer"
+ ],
+ "@level": 4,
+ "@statement": "A customer is recorded once.",
+ "@status": "live",
+ "children": [
+ {
+ "requirement.functional": {
+ "name": "ReachableByEmail",
+ "@counterexample": "A customer with no way to reach them.",
+ "@implementedBy": [
+ "Customer.email"
+ ],
+ "@level": 5,
+ "@statement": "A customer carries an email address we can write to.",
+ "@status": "live"
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/fixtures/dependency-conformance/cases.json b/fixtures/dependency-conformance/cases.json
index 33b77b3083..a1ffe77359 100644
--- a/fixtures/dependency-conformance/cases.json
+++ b/fixtures/dependency-conformance/cases.json
@@ -425,6 +425,445 @@
],
"expectSelected": []
},
+ {
+ "name": "an-imported-requirement-is-not-the-ledger-by-default",
+ "tree": {
+ "metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}"
+ },
+ "treeFiles": {
+ ".metaobjects/deps/acme-common/acme-common.metaobjects.json": "artifacts/acme-common-v1-requirements.json"
+ },
+ "config": {
+ "schema_version": 1,
+ "sources": [],
+ "dependencies": [
+ {
+ "name": "acme-common",
+ "path": "../acme-common/metaobjects"
+ }
+ ]
+ },
+ "lock": {
+ "schema_version": 1,
+ "dependencies": {
+ "acme-common": {
+ "version": "1.1.0",
+ "metamodelVersion": "1.0",
+ "resolvedFrom": {
+ "path": "../acme-common/metaobjects"
+ },
+ "artifact": "acme-common.metaobjects.json",
+ "integrity": "sha256-4a812237fec408e10eaf1dfa25550cc642c9b3c0e41dc72581db8ba93c87eb57",
+ "packages": [
+ "acme::common"
+ ],
+ "nodes": [
+ "acme::common::Address",
+ "acme::common::Audited",
+ "acme::common::AuditedRows",
+ "acme::common::Customer",
+ "acme::common::Customers"
+ ]
+ }
+ }
+ },
+ "expectFiles": [
+ ".metaobjects/deps/acme-common/acme-common.metaobjects.json",
+ "metaobjects/meta.app.json"
+ ],
+ "expectLedger": {},
+ "expectCoverageMeasured": false
+ },
+ {
+ "name": "an-own-requirement-beside-an-uninherited-dependency-ledger-is-the-whole-ledger",
+ "tree": {
+ "metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}},{\"requirement.functional\":{\"name\":\"OrdersTaken\",\"@level\":4,\"@status\":\"live\",\"@statement\":\"An order is recorded when it is placed.\",\"@counterexample\":\"A placed order with no row.\",\"@implementedBy\":[\"Order\"]}}]}}"
+ },
+ "treeFiles": {
+ ".metaobjects/deps/acme-common/acme-common.metaobjects.json": "artifacts/acme-common-v1-requirements.json"
+ },
+ "config": {
+ "schema_version": 1,
+ "sources": [],
+ "dependencies": [
+ {
+ "name": "acme-common",
+ "path": "../acme-common/metaobjects"
+ }
+ ]
+ },
+ "lock": {
+ "schema_version": 1,
+ "dependencies": {
+ "acme-common": {
+ "version": "1.1.0",
+ "metamodelVersion": "1.0",
+ "resolvedFrom": {
+ "path": "../acme-common/metaobjects"
+ },
+ "artifact": "acme-common.metaobjects.json",
+ "integrity": "sha256-4a812237fec408e10eaf1dfa25550cc642c9b3c0e41dc72581db8ba93c87eb57",
+ "packages": [
+ "acme::common"
+ ],
+ "nodes": [
+ "acme::common::Address",
+ "acme::common::Audited",
+ "acme::common::AuditedRows",
+ "acme::common::Customer",
+ "acme::common::Customers"
+ ]
+ }
+ }
+ },
+ "expectFiles": [
+ ".metaobjects/deps/acme-common/acme-common.metaobjects.json",
+ "metaobjects/meta.app.json"
+ ],
+ "expectLedger": {
+ "app::OrdersTaken": "live"
+ },
+ "expectCoverageMeasured": true
+ },
+ {
+ "name": "requirements-inherit-naming-the-package-takes-on-its-ledger",
+ "tree": {
+ "metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}"
+ },
+ "treeFiles": {
+ ".metaobjects/deps/acme-common/acme-common.metaobjects.json": "artifacts/acme-common-v1-requirements.json"
+ },
+ "config": {
+ "schema_version": 1,
+ "sources": [],
+ "dependencies": [
+ {
+ "name": "acme-common",
+ "path": "../acme-common/metaobjects"
+ }
+ ],
+ "requirements": {
+ "inherit": [
+ "acme::common::**"
+ ]
+ }
+ },
+ "lock": {
+ "schema_version": 1,
+ "dependencies": {
+ "acme-common": {
+ "version": "1.1.0",
+ "metamodelVersion": "1.0",
+ "resolvedFrom": {
+ "path": "../acme-common/metaobjects"
+ },
+ "artifact": "acme-common.metaobjects.json",
+ "integrity": "sha256-4a812237fec408e10eaf1dfa25550cc642c9b3c0e41dc72581db8ba93c87eb57",
+ "packages": [
+ "acme::common"
+ ],
+ "nodes": [
+ "acme::common::Address",
+ "acme::common::Audited",
+ "acme::common::AuditedRows",
+ "acme::common::Customer",
+ "acme::common::Customers"
+ ]
+ }
+ }
+ },
+ "expectFiles": [
+ ".metaobjects/deps/acme-common/acme-common.metaobjects.json",
+ "metaobjects/meta.app.json"
+ ],
+ "expectLedger": {
+ "acme::common::AuditedRows": "live",
+ "acme::common::Customers": "live",
+ "acme::common::Customers.ReachableByEmail": "live"
+ },
+ "expectCoverageMeasured": false
+ },
+ {
+ "name": "a-wildcard-inherit-does-not-name-a-package",
+ "tree": {
+ "metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}"
+ },
+ "treeFiles": {
+ ".metaobjects/deps/acme-common/acme-common.metaobjects.json": "artifacts/acme-common-v1-requirements.json"
+ },
+ "config": {
+ "schema_version": 1,
+ "sources": [],
+ "dependencies": [
+ {
+ "name": "acme-common",
+ "path": "../acme-common/metaobjects"
+ }
+ ],
+ "requirements": {
+ "inherit": [
+ "**"
+ ]
+ }
+ },
+ "lock": {
+ "schema_version": 1,
+ "dependencies": {
+ "acme-common": {
+ "version": "1.1.0",
+ "metamodelVersion": "1.0",
+ "resolvedFrom": {
+ "path": "../acme-common/metaobjects"
+ },
+ "artifact": "acme-common.metaobjects.json",
+ "integrity": "sha256-4a812237fec408e10eaf1dfa25550cc642c9b3c0e41dc72581db8ba93c87eb57",
+ "packages": [
+ "acme::common"
+ ],
+ "nodes": [
+ "acme::common::Address",
+ "acme::common::Audited",
+ "acme::common::AuditedRows",
+ "acme::common::Customer",
+ "acme::common::Customers"
+ ]
+ }
+ }
+ },
+ "expectFiles": [
+ ".metaobjects/deps/acme-common/acme-common.metaobjects.json",
+ "metaobjects/meta.app.json"
+ ],
+ "expectLedger": {},
+ "expectCoverageMeasured": false
+ },
+ {
+ "name": "a-scope-include-naming-the-package-does-not-inherit-its-requirements",
+ "tree": {
+ "metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}"
+ },
+ "treeFiles": {
+ ".metaobjects/deps/acme-common/acme-common.metaobjects.json": "artifacts/acme-common-v1-requirements.json"
+ },
+ "config": {
+ "schema_version": 1,
+ "sources": [],
+ "dependencies": [
+ {
+ "name": "acme-common",
+ "path": "../acme-common/metaobjects"
+ }
+ ],
+ "scope": {
+ "include": [
+ "app::**",
+ "acme::common::**"
+ ]
+ }
+ },
+ "lock": {
+ "schema_version": 1,
+ "dependencies": {
+ "acme-common": {
+ "version": "1.1.0",
+ "metamodelVersion": "1.0",
+ "resolvedFrom": {
+ "path": "../acme-common/metaobjects"
+ },
+ "artifact": "acme-common.metaobjects.json",
+ "integrity": "sha256-4a812237fec408e10eaf1dfa25550cc642c9b3c0e41dc72581db8ba93c87eb57",
+ "packages": [
+ "acme::common"
+ ],
+ "nodes": [
+ "acme::common::Address",
+ "acme::common::Audited",
+ "acme::common::AuditedRows",
+ "acme::common::Customer",
+ "acme::common::Customers"
+ ]
+ }
+ }
+ },
+ "expectFiles": [
+ ".metaobjects/deps/acme-common/acme-common.metaobjects.json",
+ "metaobjects/meta.app.json"
+ ],
+ "expectLedger": {},
+ "expectCoverageMeasured": false
+ },
+ {
+ "name": "an-inherited-ledger-beside-an-own-requirement-measures-coverage",
+ "tree": {
+ "metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}},{\"requirement.functional\":{\"name\":\"OrdersTaken\",\"@level\":4,\"@status\":\"live\",\"@statement\":\"An order is recorded when it is placed.\",\"@counterexample\":\"A placed order with no row.\",\"@implementedBy\":[\"Order\"]}}]}}"
+ },
+ "treeFiles": {
+ ".metaobjects/deps/acme-common/acme-common.metaobjects.json": "artifacts/acme-common-v1-requirements.json"
+ },
+ "config": {
+ "schema_version": 1,
+ "sources": [],
+ "dependencies": [
+ {
+ "name": "acme-common",
+ "path": "../acme-common/metaobjects"
+ }
+ ],
+ "requirements": {
+ "inherit": [
+ "acme::common::**"
+ ]
+ }
+ },
+ "lock": {
+ "schema_version": 1,
+ "dependencies": {
+ "acme-common": {
+ "version": "1.1.0",
+ "metamodelVersion": "1.0",
+ "resolvedFrom": {
+ "path": "../acme-common/metaobjects"
+ },
+ "artifact": "acme-common.metaobjects.json",
+ "integrity": "sha256-4a812237fec408e10eaf1dfa25550cc642c9b3c0e41dc72581db8ba93c87eb57",
+ "packages": [
+ "acme::common"
+ ],
+ "nodes": [
+ "acme::common::Address",
+ "acme::common::Audited",
+ "acme::common::AuditedRows",
+ "acme::common::Customer",
+ "acme::common::Customers"
+ ]
+ }
+ }
+ },
+ "expectFiles": [
+ ".metaobjects/deps/acme-common/acme-common.metaobjects.json",
+ "metaobjects/meta.app.json"
+ ],
+ "expectLedger": {
+ "acme::common::AuditedRows": "live",
+ "acme::common::Customers": "live",
+ "acme::common::Customers.ReachableByEmail": "live",
+ "app::OrdersTaken": "live"
+ },
+ "expectCoverageMeasured": true
+ },
+ {
+ "name": "an-overlay-of-an-inherited-requirement-changes-its-verdict-not-coverage",
+ "tree": {
+ "metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}",
+ "metaobjects/meta.ext.json": "{\"metadata.root\":{\"package\":\"acme::common\",\"children\":[{\"requirement.functional\":{\"name\":\"Customers\",\"overlay\":true,\"@status\":\"partial\"}}]}}"
+ },
+ "treeFiles": {
+ ".metaobjects/deps/acme-common/acme-common.metaobjects.json": "artifacts/acme-common-v1-requirements.json"
+ },
+ "config": {
+ "schema_version": 1,
+ "sources": [],
+ "dependencies": [
+ {
+ "name": "acme-common",
+ "path": "../acme-common/metaobjects"
+ }
+ ],
+ "requirements": {
+ "inherit": [
+ "acme::common::**"
+ ]
+ }
+ },
+ "lock": {
+ "schema_version": 1,
+ "dependencies": {
+ "acme-common": {
+ "version": "1.1.0",
+ "metamodelVersion": "1.0",
+ "resolvedFrom": {
+ "path": "../acme-common/metaobjects"
+ },
+ "artifact": "acme-common.metaobjects.json",
+ "integrity": "sha256-4a812237fec408e10eaf1dfa25550cc642c9b3c0e41dc72581db8ba93c87eb57",
+ "packages": [
+ "acme::common"
+ ],
+ "nodes": [
+ "acme::common::Address",
+ "acme::common::Audited",
+ "acme::common::AuditedRows",
+ "acme::common::Customer",
+ "acme::common::Customers"
+ ]
+ }
+ }
+ },
+ "expectFiles": [
+ ".metaobjects/deps/acme-common/acme-common.metaobjects.json",
+ "metaobjects/meta.app.json",
+ "metaobjects/meta.ext.json"
+ ],
+ "expectLedger": {
+ "acme::common::AuditedRows": "live",
+ "acme::common::Customers": "partial",
+ "acme::common::Customers.ReachableByEmail": "live"
+ },
+ "expectCoverageMeasured": false
+ },
+ {
+ "name": "a-local-requirement-in-a-dependency-package-is-refused",
+ "tree": {
+ "metaobjects/meta.ext.json": "{\"metadata.root\":{\"package\":\"acme::common\",\"children\":[{\"requirement.functional\":{\"name\":\"Invoicing\",\"@level\":4,\"@status\":\"planned\",\"@statement\":\"Customers are invoiced.\",\"@counterexample\":\"A customer never invoiced.\"}}]}}"
+ },
+ "treeFiles": {
+ ".metaobjects/deps/acme-common/acme-common.metaobjects.json": "artifacts/acme-common-v1-requirements.json"
+ },
+ "config": {
+ "schema_version": 1,
+ "sources": [],
+ "dependencies": [
+ {
+ "name": "acme-common",
+ "path": "../acme-common/metaobjects"
+ }
+ ],
+ "requirements": {
+ "inherit": [
+ "acme::common::**"
+ ]
+ }
+ },
+ "lock": {
+ "schema_version": 1,
+ "dependencies": {
+ "acme-common": {
+ "version": "1.1.0",
+ "metamodelVersion": "1.0",
+ "resolvedFrom": {
+ "path": "../acme-common/metaobjects"
+ },
+ "artifact": "acme-common.metaobjects.json",
+ "integrity": "sha256-4a812237fec408e10eaf1dfa25550cc642c9b3c0e41dc72581db8ba93c87eb57",
+ "packages": [
+ "acme::common"
+ ],
+ "nodes": [
+ "acme::common::Address",
+ "acme::common::Audited",
+ "acme::common::AuditedRows",
+ "acme::common::Customer",
+ "acme::common::Customers"
+ ]
+ }
+ }
+ },
+ "expectFiles": [
+ ".metaobjects/deps/acme-common/acme-common.metaobjects.json",
+ "metaobjects/meta.ext.json"
+ ],
+ "expectLoadError": "ERR_DEPENDENCY_PACKAGE_NOT_OWNED"
+ },
{
"name": "a-missing-lock-is-stale",
"tree": {
diff --git a/fixtures/shared-model-conformance/README.md b/fixtures/shared-model-conformance/README.md
index bace05e1cd..f81c9fa186 100644
--- a/fixtures/shared-model-conformance/README.md
+++ b/fixtures/shared-model-conformance/README.md
@@ -44,10 +44,11 @@ Exactly one of `expectArtifact` + `expectManifest`, `expectRefusal` is present p
token `@@METAMODEL_VERSION@@` there and a runner substitutes its own constant before comparing.
`packages` and `nodes` are sorted, and `integrity` is the sha256 of the artifact's bytes.
- **`expectRefusal.reason`** — one of:
- - `empty-selection` — `include` / `exclude` select no node (`requirement.*` and `template.*`
- never count).
+ - `empty-selection` — `include` / `exclude` select no node (`template.*` never counts, and
+ `requirement.*` counts only when `input.requirements` is `true`).
- `not-closed` — a selected node `extends` or references (any ref-bearing attribute) a node
- outside the selection. `pairs` is the EXHAUSTIVE list of `(referrer, target)` resolution
+ outside the selection, or a selected requirement's `@implementedBy` claim resolves to a node
+ outside it (only the owner segment counts; a claim that resolves nothing is not followed). `pairs` is the EXHAUSTIVE list of `(referrer, target)` resolution
keys, sorted by referrer then target, which the error must name.
- `non-core-vocabulary` — the selection loads with the publisher's own providers but not with
core vocabulary alone, which is how a consumer loads it.
@@ -57,8 +58,15 @@ Exactly one of `expectArtifact` + `expectManifest`, `expectRefusal` is present p
## Rules the cases pin
-- `requirement.*` and `template.*` top-level nodes are never published, whatever `include` says
- (`requirements-and-templates-are-always-dropped`).
+- `template.*` top-level nodes are never published, whatever `include` says. `requirement.*`
+ nodes are published only when the case sets `input.requirements: true`
+ (`requirements-and-templates-are-dropped-by-default`,
+ `requirements-are-published-when-asked`,
+ `templates-are-dropped-when-requirements-are-published`,
+ `a-requirement-alone-is-an-empty-selection-by-default`).
+- An exported requirement cannot claim a node the export leaves out: `@implementedBy` is not a
+ loader reference, but the closure check follows it
+ (`a-requirement-claiming-a-node-outside-the-selection-is-refused`).
- The order of `files` does not change the bytes (`the-order-of-files-does-not-change-the-bytes`).
- Nodes are sorted by resolution key; each carries its own `package`; `extends` is preserved, not
flattened (`a-selection-spanning-two-packages-lists-both-sorted`).
diff --git a/fixtures/shared-model-conformance/cases.json b/fixtures/shared-model-conformance/cases.json
index 67bf34569a..00cf0fcc7a 100644
--- a/fixtures/shared-model-conformance/cases.json
+++ b/fixtures/shared-model-conformance/cases.json
@@ -91,7 +91,7 @@
"expectManifest": "expected/address-audited/metaobjects.pkg.json"
},
{
- "name": "requirements-and-templates-are-always-dropped",
+ "name": "requirements-and-templates-are-dropped-by-default",
"input": {
"name": "acme-common",
"files": [
@@ -106,6 +106,95 @@
"expectArtifact": "expected/with-payloads/acme-common.metaobjects.json",
"expectManifest": "expected/with-payloads/metaobjects.pkg.json"
},
+ {
+ "name": "requirements-are-published-when-asked",
+ "input": {
+ "name": "acme-common",
+ "files": [
+ "source/meta.base.json",
+ "source/meta.customer.json",
+ "source/meta.ledger.json"
+ ],
+ "include": [
+ "acme::common::**"
+ ],
+ "exclude": [
+ "acme::common::KeepSecretsSafe",
+ "acme::common::Secret"
+ ],
+ "version": "1.1.0",
+ "requirements": true
+ },
+ "expectArtifact": "../dependency-conformance/artifacts/acme-common-v1-requirements.json",
+ "expectManifest": "expected/with-requirements/metaobjects.pkg.json"
+ },
+ {
+ "name": "templates-are-dropped-when-requirements-are-published",
+ "input": {
+ "name": "acme-common",
+ "files": [
+ "source/meta.base.json",
+ "source/meta.prompts.json"
+ ],
+ "include": [
+ "acme::common::**"
+ ],
+ "version": "0.4.0",
+ "requirements": true
+ },
+ "expectArtifact": "expected/with-payloads-and-requirements/acme-common.metaobjects.json",
+ "expectManifest": "expected/with-payloads-and-requirements/metaobjects.pkg.json"
+ },
+ {
+ "name": "a-requirement-alone-is-an-empty-selection-by-default",
+ "input": {
+ "name": "acme-common",
+ "files": [
+ "source/meta.base.json",
+ "source/meta.customer.json",
+ "source/meta.ledger.json"
+ ],
+ "include": [
+ "acme::common::Customers",
+ "acme::common::AuditedRows"
+ ],
+ "version": "1.0.0"
+ },
+ "expectRefusal": {
+ "reason": "empty-selection"
+ }
+ },
+ {
+ "name": "a-requirement-claiming-a-node-outside-the-selection-is-refused",
+ "input": {
+ "name": "acme-common",
+ "files": [
+ "source/meta.base.json",
+ "source/meta.customer.json",
+ "source/meta.ledger.json"
+ ],
+ "include": [
+ "acme::common::Address",
+ "acme::common::AuditedRows",
+ "acme::common::Customers"
+ ],
+ "version": "1.0.0",
+ "requirements": true
+ },
+ "expectRefusal": {
+ "reason": "not-closed",
+ "pairs": [
+ {
+ "referrer": "acme::common::AuditedRows",
+ "target": "acme::common::Audited"
+ },
+ {
+ "referrer": "acme::common::Customers",
+ "target": "acme::common::Customer"
+ }
+ ]
+ }
+ },
{
"name": "a-selection-spanning-two-packages-lists-both-sorted",
"input": {
diff --git a/fixtures/shared-model-conformance/expected/with-payloads-and-requirements/acme-common.metaobjects.json b/fixtures/shared-model-conformance/expected/with-payloads-and-requirements/acme-common.metaobjects.json
new file mode 100644
index 0000000000..f0e2be9105
--- /dev/null
+++ b/fixtures/shared-model-conformance/expected/with-payloads-and-requirements/acme-common.metaobjects.json
@@ -0,0 +1,74 @@
+{
+ "metadata.root": {
+ "children": [
+ {
+ "object.value": {
+ "name": "Address",
+ "package": "acme::common",
+ "children": [
+ {
+ "field.string": {
+ "name": "city"
+ }
+ },
+ {
+ "field.string": {
+ "name": "street"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "object.value": {
+ "name": "AnswerPayload",
+ "package": "acme::common",
+ "children": [
+ {
+ "field.string": {
+ "name": "text"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "requirement.functional": {
+ "name": "AnswersAreShort",
+ "package": "acme::common",
+ "@counterexample": "An answer that runs to three paragraphs.",
+ "@level": 5,
+ "@statement": "Every answer fits on one line.",
+ "@status": "live"
+ }
+ },
+ {
+ "object.value": {
+ "name": "AskPayload",
+ "package": "acme::common",
+ "children": [
+ {
+ "field.string": {
+ "name": "question"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "object.entity": {
+ "name": "Audited",
+ "package": "acme::common",
+ "abstract": true,
+ "children": [
+ {
+ "field.timestamp": {
+ "name": "createdAt"
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/fixtures/shared-model-conformance/expected/with-payloads-and-requirements/metaobjects.pkg.json b/fixtures/shared-model-conformance/expected/with-payloads-and-requirements/metaobjects.pkg.json
new file mode 100644
index 0000000000..92a07d1cce
--- /dev/null
+++ b/fixtures/shared-model-conformance/expected/with-payloads-and-requirements/metaobjects.pkg.json
@@ -0,0 +1,18 @@
+{
+ "schema_version": 1,
+ "name": "acme-common",
+ "version": "0.4.0",
+ "metamodelVersion": "@@METAMODEL_VERSION@@",
+ "artifact": "acme-common.metaobjects.json",
+ "integrity": "sha256-651917937ce0797bd0308e81987c10ffebe22cd17eb9e08dc25809c7391f22a0",
+ "packages": [
+ "acme::common"
+ ],
+ "nodes": [
+ "acme::common::Address",
+ "acme::common::AnswerPayload",
+ "acme::common::AnswersAreShort",
+ "acme::common::AskPayload",
+ "acme::common::Audited"
+ ]
+}
diff --git a/fixtures/shared-model-conformance/expected/with-requirements/metaobjects.pkg.json b/fixtures/shared-model-conformance/expected/with-requirements/metaobjects.pkg.json
new file mode 100644
index 0000000000..e3cc87bb15
--- /dev/null
+++ b/fixtures/shared-model-conformance/expected/with-requirements/metaobjects.pkg.json
@@ -0,0 +1,18 @@
+{
+ "schema_version": 1,
+ "name": "acme-common",
+ "version": "1.1.0",
+ "metamodelVersion": "@@METAMODEL_VERSION@@",
+ "artifact": "acme-common.metaobjects.json",
+ "integrity": "sha256-4a812237fec408e10eaf1dfa25550cc642c9b3c0e41dc72581db8ba93c87eb57",
+ "packages": [
+ "acme::common"
+ ],
+ "nodes": [
+ "acme::common::Address",
+ "acme::common::Audited",
+ "acme::common::AuditedRows",
+ "acme::common::Customer",
+ "acme::common::Customers"
+ ]
+}
diff --git a/fixtures/shared-model-conformance/source/meta.ledger.json b/fixtures/shared-model-conformance/source/meta.ledger.json
new file mode 100644
index 0000000000..16e21ad1e3
--- /dev/null
+++ b/fixtures/shared-model-conformance/source/meta.ledger.json
@@ -0,0 +1,44 @@
+{
+ "metadata.root": {
+ "package": "acme::common",
+ "children": [
+ {
+ "requirement.functional": {
+ "name": "Customers",
+ "@level": 4,
+ "@status": "live",
+ "@statement": "A customer is recorded once.",
+ "@counterexample": "Two rows for one customer.",
+ "@implementedBy": [
+ "Customer"
+ ],
+ "children": [
+ {
+ "requirement.functional": {
+ "name": "ReachableByEmail",
+ "@level": 5,
+ "@status": "live",
+ "@statement": "A customer carries an email address we can write to.",
+ "@counterexample": "A customer with no way to reach them.",
+ "@implementedBy": [
+ "Customer.email"
+ ]
+ }
+ }
+ ]
+ }
+ },
+ {
+ "requirement.architectural": {
+ "name": "AuditedRows",
+ "@status": "live",
+ "@statement": "Every audited row records when it was created.",
+ "@counterexample": "An audited row with no creation time.",
+ "@implementedBy": [
+ "Audited"
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/server/csharp/MetaObjects.Codegen/Generators/SharedModelGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/SharedModelGenerator.cs
index 02ac797dfa..319822c697 100644
--- a/server/csharp/MetaObjects.Codegen/Generators/SharedModelGenerator.cs
+++ b/server/csharp/MetaObjects.Codegen/Generators/SharedModelGenerator.cs
@@ -3,7 +3,8 @@
// `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
+// (Files), which nodes are published (Include / Exclude, plus Requirements to add the selected
+// requirement.* nodes) 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
@@ -62,6 +63,10 @@ public class SharedModelGenerator : IGenerator
/// project's main load. The artifact must still load WITHOUT them: a consumer does not have them.
public IReadOnlyList? Providers { get; init; }
+ /// Publish the selected requirement.* nodes too, so a consumer can inherit them. Default
+ /// false: shapes only. template.* is never published.
+ public bool Requirements { get; init; }
+
public IEnumerable Generate(GenContext ctx)
{
var name = Required(ModelName, nameof(ModelName), "the dependency key a consumer will sync under");
@@ -72,7 +77,7 @@ public IEnumerable Generate(GenContext ctx)
var model = SharedModel.Build(new SharedModelInput(
name,
Files.Select(f => Path.GetFullPath(f, projectRoot)).ToList(),
- Include, Exclude, Providers));
+ Include, Exclude, Providers, Requirements));
// Resolved after the selection has passed every check, so a model that cannot be published says
// so before a missing version does.
diff --git a/server/csharp/MetaObjects.Conformance.Tests/DependencyConformanceTests.cs b/server/csharp/MetaObjects.Conformance.Tests/DependencyConformanceTests.cs
index 3965bcf064..95f8ae1f99 100644
--- a/server/csharp/MetaObjects.Conformance.Tests/DependencyConformanceTests.cs
+++ b/server/csharp/MetaObjects.Conformance.Tests/DependencyConformanceTests.cs
@@ -11,6 +11,7 @@
using System.Text.Json;
using MetaObjects;
using MetaObjects.Config;
+using MetaObjects.Core.Requirement;
using MetaObjects.Meta;
using MetaObjects.Source;
using Xunit;
@@ -192,7 +193,8 @@ private void AssertResolvedFiles(ResolvedMetadata resolved, JsonElement expectFi
private static void AssertArms(JsonElement c, ResolvedMetadata resolved, string name)
{
var needsLoad = c.TryGetProperty("expectImported", out _) || c.TryGetProperty("expectSelected", out _) ||
- c.TryGetProperty("expectMigrateGoverned", out _) || c.TryGetProperty("expectLoadError", out _);
+ c.TryGetProperty("expectMigrateGoverned", out _) || c.TryGetProperty("expectLoadError", out _) ||
+ c.TryGetProperty("expectLedger", out _);
if (!needsLoad) return;
var result = resolved.Load();
@@ -226,5 +228,22 @@ private static void AssertArms(JsonElement c, ResolvedMetadata resolved, string
Assert.Equal(Sorted(Strings(expectSelected)), Sorted(topLevel.Where(imports.InScope)));
if (c.TryGetProperty("expectMigrateGoverned", out var expectGoverned))
Assert.Equal(Sorted(Strings(expectGoverned)), Sorted(topLevel.Where(imports.InMigrateScope)));
+ if (c.TryGetProperty("expectLedger", out var expectLedger))
+ {
+ // The LEDGER arm: which requirements are this project's own, keyed
+ // `::`, and whether object coverage is measured.
+ Assert.Same(imports, (result.Root as MetaRoot)?.ImportScope);
+ var scan = RequirementCheck.Scan(result.Root);
+ var ledger = scan.Addressed.ToDictionary(
+ a => RequirementTestIdentities.AddressOf(RequirementCheck.EffectivePackage(a.Node), a.Path),
+ a => a.Node.Status ?? "", StringComparer.Ordinal);
+ var expected = expectLedger.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString()!, StringComparer.Ordinal);
+ Assert.Equal(expected.OrderBy(kv => kv.Key, StringComparer.Ordinal), ledger.OrderBy(kv => kv.Key, StringComparer.Ordinal));
+ var measured = c.TryGetProperty("expectCoverageMeasured", out var m) && m.GetBoolean();
+ Assert.Equal(measured, scan.MeasureCoverage);
+ // The requirement-tests generator walks the same ledger: no test for the rest.
+ Assert.Equal(Sorted(expected.Keys),
+ Sorted(RequirementTestIdentities.Walk(result.Root).Select(w => RequirementTestIdentities.AddressOf(w.View.Package, w.View.Path))));
+ }
}
}
diff --git a/server/csharp/MetaObjects.Conformance.Tests/SharedModelConformanceTests.cs b/server/csharp/MetaObjects.Conformance.Tests/SharedModelConformanceTests.cs
index 643801b093..42d2aaf76a 100644
--- a/server/csharp/MetaObjects.Conformance.Tests/SharedModelConformanceTests.cs
+++ b/server/csharp/MetaObjects.Conformance.Tests/SharedModelConformanceTests.cs
@@ -67,7 +67,8 @@ public void PublishesLikeTheReference(string caseName)
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);
+ Strings(input, "include"), Strings(input, "exclude"), providers,
+ input.TryGetProperty("requirements", out var reqs) && reqs.GetBoolean());
if (c.TryGetProperty("expectRefusal", out var refusal))
{
diff --git a/server/csharp/MetaObjects/Config/Dependencies.cs b/server/csharp/MetaObjects/Config/Dependencies.cs
index e551482230..f1419e2369 100644
--- a/server/csharp/MetaObjects/Config/Dependencies.cs
+++ b/server/csharp/MetaObjects/Config/Dependencies.cs
@@ -439,6 +439,9 @@ public sealed class ImportScope
/// The project's declared migrate.scope, or null when it declares none.
public IReadOnlyList? MigrateScope { get; }
+ /// The project's declared requirements.inherit, for the explicit-include rule.
+ public IReadOnlyList RequirementsInherit { get; private init; } = [];
+
private readonly CompiledScope? _migrate;
private ImportScope(IReadOnlyList dependencies, IReadOnlyList scopeInclude, IReadOnlyList? migrateScope)
@@ -469,7 +472,10 @@ public static ImportScope Resolve(string configDir, NeutralConfig? config)
var resolved = specs.Count == 0 && lockEntries is null
? []
: DependencyResolver.VerifySnapshot(configDir, specs, lockEntries);
- return new ImportScope(resolved, scopeInclude, migrateScope);
+ return new ImportScope(resolved, scopeInclude, migrateScope)
+ {
+ RequirementsInherit = config?.RequirementsInherit ?? [],
+ };
}
/// Is in a package one of this project's dependencies owns?
@@ -489,6 +495,18 @@ public bool InScope(string fqn)
return !ImportedPackages.Contains(pkg) || DependencyResolver.ExplicitlyIncludes(ScopeInclude, pkg);
}
+ ///
+ /// FR-023 — is the requirement at (<package>::<dotted path>) part of
+ /// this project's ledger? Its own requirements always are; a dependency's only when
+ /// requirements.inherit NAMES the dependency's package. Read by the requirement gate and the
+ /// requirement-tests generator.
+ ///
+ public bool InLedger(string fqn)
+ {
+ var pkg = DependencyResolver.PackageOf(fqn);
+ return !ImportedPackages.Contains(pkg) || DependencyResolver.ExplicitlyIncludes(RequirementsInherit, pkg);
+ }
+
///
/// The schema-side twin of : matchesScope(fqn, migrate.scope) and the
/// same import rule against migrate.scope. True for everything when nothing is declared and
@@ -524,8 +542,12 @@ public IReadOnlyList Sources() =>
// ADR-0039 SANCTIONED own-accessor case: a root-level scan. `MetaRoot` has no super, so own and
// effective children are the same set here, and the question asked is precisely "what did this
- // tree declare at the top level", which is the own layer by definition.
- foreach (var node in root.Objects())
+ // tree declare at the top level", which is the own layer by definition. Requirements too: a
+ // publisher may export its ledger, and a new requirement in its package would read as the
+ // publisher's to every gate that keys the ledger by package.
+ var topLevel = root.Objects().Cast()
+ .Concat(root.Children().Where(c => c.Type == MetaObjects.Shared.BaseTypes.TYPE_REQUIREMENT));
+ foreach (var node in topLevel)
{
var key = node.ResolutionKey();
var pkg = DependencyResolver.PackageOf(key);
diff --git a/server/csharp/MetaObjects/Config/NeutralConfig.cs b/server/csharp/MetaObjects/Config/NeutralConfig.cs
index 50e3bb75b3..3631a49fa8 100644
--- a/server/csharp/MetaObjects/Config/NeutralConfig.cs
+++ b/server/csharp/MetaObjects/Config/NeutralConfig.cs
@@ -72,6 +72,12 @@ public sealed class NeutralConfig
/// FR-023 — migrate.scope, or null when the project declares none.
public IReadOnlyList? MigrateScope { get; }
+ ///
+ /// FR-023 — requirements.inherit: the dependencies whose requirements this project takes on
+ /// as its own ledger (). Empty when absent.
+ ///
+ public IReadOnlyList RequirementsInherit { get; private init; } = [];
+
///
/// The directory holding the .metaobjects/ folder this config was read from — where the lock and
/// the committed dependency snapshots live beside it.
@@ -282,7 +288,11 @@ private NeutralConfig(
// lists read above, are IGNORED by design — see the file header.
return new NeutralConfig(specs, libraries, new PhysicalNaming(columns, tables), ReadWire(root, path),
ReadCSharp(root, path, configDir), dependencies, scopeInclude, migrateScope,
- Path.GetFullPath(configDir)) { Enums = ReadEnums(root, path) };
+ Path.GetFullPath(configDir))
+ {
+ Enums = ReadEnums(root, path),
+ RequirementsInherit = ReadRequirementsInherit(root, path),
+ };
}
}
@@ -300,6 +310,23 @@ private static IReadOnlyList ReadDependencies(JsonElement root,
return specs;
}
+ /// FR-023 — requirements.inherit. Refused like naming: an unknown key inside
+ /// requirements would otherwise be a misspelt inherit that silently leaves the ledger out.
+ private static IReadOnlyList ReadRequirementsInherit(JsonElement root, string path)
+ {
+ if (!root.TryGetProperty("requirements", out var block) || block.ValueKind == JsonValueKind.Null) return [];
+ if (block.ValueKind != JsonValueKind.Object)
+ throw new MetaModelException($"{path}: \"requirements\" must be an object", ErrorCode.ERR_BAD_ATTR_VALUE);
+ foreach (var p in block.EnumerateObject())
+ {
+ if (p.Name != "inherit")
+ throw new MetaModelException(
+ $"{path}: \"requirements\" has an unknown key \"{p.Name}\" (expected \"inherit\")",
+ ErrorCode.ERR_BAD_ATTR_VALUE);
+ }
+ return ReadPatterns(root, path, "requirements", "inherit") ?? [];
+ }
+
/// One pattern list nested in a TypeScript-owned block: `scope.include` or `migrate.scope`. The
/// block must be an object when present and the list an array of non-empty strings; every OTHER key
/// of the block is TypeScript's and is not read. Null when the block or the list is absent.
diff --git a/server/csharp/MetaObjects/Config/SharedModel.cs b/server/csharp/MetaObjects/Config/SharedModel.cs
index 16d9149483..2f6a6405df 100644
--- a/server/csharp/MetaObjects/Config/SharedModel.cs
+++ b/server/csharp/MetaObjects/Config/SharedModel.cs
@@ -21,6 +21,7 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
+using MetaObjects.Core.Requirement;
using MetaObjects.Loader;
using MetaObjects.Meta;
@@ -69,12 +70,15 @@ public sealed class SharedModelRefusal(
/// 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.
+/// Publish the selected requirement.* nodes too. False (the default) publishes
+/// shapes only. template.* is never published.
public sealed record SharedModelInput(
string Name,
IReadOnlyList Files,
IReadOnlyList? Include = null,
IReadOnlyList? Exclude = null,
- IReadOnlyList? Providers = null);
+ IReadOnlyList? Providers = null,
+ bool Requirements = false);
///
/// A selection that passed every check, serialized. The document is exactly the bytes that were re-loaded
@@ -121,14 +125,17 @@ public static SharedModelBuilt Build(SharedModelInput input)
// 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 => n.Type != TYPE_TEMPLATE)
+ // A published ledger is the publisher's decision: off unless asked for.
+ .Where(n => input.Requirements || n.Type != TYPE_REQUIREMENT)
.Where(n => Scope.MatchesScope(n.ResolutionKey(), scope))
.ToList();
if (selected.Count == 0)
{
+ var dropped = input.Requirements ? "template.*" : "requirement.* and template.*";
throw new SharedModelRefusal(SharedModelRefusalReason.EmptySelection,
$"shared-model \"{input.Name}\": selected no nodes (include/exclude, always excluding " +
- "requirement.* and template.*) - nothing to publish. Check `include`.", []);
+ $"{dropped}) - nothing to publish. Check `include`.", []);
}
// Step 3: closure.
@@ -136,10 +143,17 @@ public static SharedModelBuilt Build(SharedModelInput input)
var failures = CheckClosure(root, selected, selectedKeys);
if (failures.Count > 0)
{
- var lines = string.Concat(failures.Select(p => $"\n {p.Referrer} -> {p.Target}"));
+ // Same sanctioned root-level own read as step 2: a template is never exported, so a
+ // reference to one can only be closed by excluding the referrer.
+ var templateKeys = root.OwnChildren().Where(n => n.Type == TYPE_TEMPLATE)
+ .Select(n => n.ResolutionKey()).ToHashSet(StringComparer.Ordinal);
+ var lines = string.Concat(failures.Select(p => $"\n {p.Referrer} -> {p.Target}: " +
+ (templateKeys.Contains(p.Target)
+ ? "templates are never exported; exclude the referrer"
+ : "include it or exclude the referrer")));
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);
+ "outside it:" + lines, failures);
}
// Step 4: serialize (sorted by resolution key).
@@ -232,10 +246,30 @@ private static List CheckClosure(
{
if (node.OwnAttr(name) is string s) candidates.Add(s);
}
+ var targets = new List();
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
+ if (target is not null) targets.Add(target); // else already refused at load: defensive only
+ }
+ // A selected requirement's @implementedBy claims are followed too. They are not loader
+ // references (a planned claim may name a node that does not exist yet), but an exported
+ // requirement claiming a node the export leaves out would dangle in every consumer that
+ // inherits it. Owner segment only, in the requirement's effective package (ADR-0042); a
+ // claim that resolves nothing names nothing the export could carry. ADR-0039 sanctioned
+ // own-only read, as for the ref-bearing attrs above.
+ if (node.Type == TYPE_REQUIREMENT && node.OwnAttr(REQUIREMENT_ATTR_IMPLEMENTED_BY) is IReadOnlyList claims)
+ {
+ var claimPkg = RequirementCheck.EffectivePackage(node);
+ foreach (var reference in claims)
+ {
+ var owner = RequirementClaims.ResolveClaimTarget(
+ root, RequirementClaims.SplitMemberRef(reference).Owner, claimPkg);
+ if (owner is not null) targets.Add(owner);
+ }
+ }
+ foreach (var target in targets)
+ {
var targetKey = target.ResolutionKey();
if (selectedKeys.Contains(targetKey)) continue;
var pair = new SharedModelPair(referrerKey, targetKey);
diff --git a/server/csharp/MetaObjects/Core/Requirement/RequirementCheck.cs b/server/csharp/MetaObjects/Core/Requirement/RequirementCheck.cs
index 812c81e60f..0858b5946a 100644
--- a/server/csharp/MetaObjects/Core/Requirement/RequirementCheck.cs
+++ b/server/csharp/MetaObjects/Core/Requirement/RequirementCheck.cs
@@ -21,6 +21,7 @@
// or that a claimed node implements the requirement claiming it.
using System.Globalization;
+using MetaObjects.Config;
using MetaObjects.Library;
using MetaObjects.Meta;
@@ -152,11 +153,11 @@ public static string EffectivePackage(MetaData node)
///
public static RequirementScan Scan(MetaData root, bool? measureCoverage = null, bool requireImplementers = false)
{
- var addressed = CollectAddressed(root);
+ var addressed = CollectLedger(root);
return new RequirementScan(
addressed,
ClaimedObjectKeys(root, addressed.Select(a => a.Node)),
- measureCoverage ?? ProjectAuthoredRequirements(addressed),
+ measureCoverage ?? ProjectAuthoredRequirements(addressed, (root as MetaRoot)?.ImportScope),
requireImplementers);
}
@@ -167,10 +168,31 @@ public static RequirementScan Scan(MetaData root, bool? measureCoverage = null,
/// PACKAGE, a manifest fact. An overlay on a library requirement stays in the library's
/// package and does not activate coverage.
///
- private static bool ProjectAuthoredRequirements(IReadOnlyList addressed)
+ private static bool ProjectAuthoredRequirements(IReadOnlyList addressed, ImportScope? imports)
{
var libraryPackages = LibrarySources.LibraryPackages();
- return addressed.Any(a => !libraryPackages.Contains(EffectivePackage(a.Node)));
+ // FR-023: a dependency's requirement, inherited or not, is the publisher's design for the same
+ // reason a library's is, and cannot switch coverage on either.
+ return addressed.Any(a =>
+ {
+ var pkg = EffectivePackage(a.Node);
+ return !libraryPackages.Contains(pkg)
+ && !(imports?.Imported(RequirementTestIdentities.AddressOf(pkg, a.Path)) ?? false);
+ });
+ }
+
+ ///
+ /// The requirements that are this project's ledger: every loaded requirement but a dependency's
+ /// that requirements.inherit does not name (, read from the
+ /// root). One left out is still loaded, so an overlay of it resolves, and nothing here or in the
+ /// requirement-tests generator addresses it.
+ ///
+ public static IReadOnlyList CollectLedger(MetaData root)
+ {
+ var imports = (root as MetaRoot)?.ImportScope;
+ var all = CollectAddressed(root);
+ if (imports is null) return all;
+ return all.Where(a => imports.InLedger(RequirementTestIdentities.AddressOf(EffectivePackage(a.Node), a.Path))).ToList();
}
///
diff --git a/server/csharp/MetaObjects/Core/Requirement/RequirementTestIdentities.cs b/server/csharp/MetaObjects/Core/Requirement/RequirementTestIdentities.cs
index aa4af1fdea..ebbec262d3 100644
--- a/server/csharp/MetaObjects/Core/Requirement/RequirementTestIdentities.cs
+++ b/server/csharp/MetaObjects/Core/Requirement/RequirementTestIdentities.cs
@@ -155,7 +155,9 @@ public static bool DefaultFilter(RequirementView view) =>
public static IReadOnlyList Walk(MetaData root)
{
var output = new List();
- foreach (var addressed in RequirementCheck.CollectAddressed(root))
+ // FR-023: only this project's ledger. A dependency's requirement it did not inherit
+ // (`requirements.inherit`) is loaded and gets no test.
+ foreach (var addressed in RequirementCheck.CollectLedger(root))
{
var node = addressed.Node;
// One effective package, shared with the gate.
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
index 89de68618c..f2707071b3 100644
--- 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
@@ -26,8 +26,9 @@
* 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).
+ * and optionally {@code exclude} (applied after {@code include}), {@code version} (default: the
+ * project's version) and {@code requirements} ({@code true} publishes the selected
+ * {@code requirement.*} nodes as well; default {@code false}).
*/
public class SharedModelGenerator extends FileEmittingGenerator {
@@ -36,6 +37,7 @@ public class SharedModelGenerator extends FileEmittingGenerator {
public static final String ARG_EXCLUDE = "exclude";
public static final String ARG_FILES = "files";
public static final String ARG_VERSION = "version";
+ public static final String ARG_REQUIREMENTS = "requirements";
@Override
protected List generate(MetaDataLoader loader) {
@@ -49,7 +51,11 @@ protected List generate(MetaDataLoader loader) {
// 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));
+ // Publishing the ledger is opt-in: "true" adds the selected requirement.* nodes.
+ boolean requirements = hasArg(ARG_REQUIREMENTS) && Boolean.parseBoolean(getArg(ARG_REQUIREMENTS).trim());
+
+ SharedModel.Built model = SharedModel.build(
+ new SharedModel.Input(name, files, include, exclude, providers, requirements));
// Resolved after the selection has passed every check, so a model that cannot be published
// says so before a missing version does.
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 8763039b66..3d24185927 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
@@ -68,6 +68,21 @@ private static Map args(Path out) {
.replace("@@METAMODEL_VERSION@@", RegistryManifest.METAMODEL_VERSION), manifest);
}
+ @Test public void theRequirementsArgPublishesTheLedgerTheCorpusPins() throws Exception {
+ Path out = Files.createTempDirectory("shared-model-gen-");
+ SharedModelGenerator g = new SharedModelGenerator();
+ Map a = with(with(with(args(out), "version", "1.1.0"), "requirements", "true"),
+ "exclude", "acme::common::KeepSecretsSafe,acme::common::Secret");
+ g.setArgs(with(a, "files", a.get("files") + "," + corpus().resolve("source/meta.ledger.json")));
+ g.execute(loader());
+
+ assertEquals(Files.readString(corpus().resolve("../dependency-conformance/artifacts/acme-common-v1-requirements.json").normalize(), StandardCharsets.UTF_8),
+ Files.readString(out.resolve("acme-common.metaobjects.json"), StandardCharsets.UTF_8));
+ assertEquals(Files.readString(corpus().resolve("expected/with-requirements/metaobjects.pkg.json"), StandardCharsets.UTF_8)
+ .replace("@@METAMODEL_VERSION@@", RegistryManifest.METAMODEL_VERSION),
+ Files.readString(out.resolve("metaobjects.pkg.json"), StandardCharsets.UTF_8));
+ }
+
@Test public void aBlankVersionArgIsRefused() throws Exception {
Path out = Files.createTempDirectory("shared-model-gen-");
SharedModelGenerator g = new SharedModelGenerator();
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 f8aab0223a..b3efd5782b 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
@@ -23,7 +23,8 @@ import java.nio.file.Path
* 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).
+ * optionally `exclude` (applied after `include`), `version` (default: the project's version) and
+ * `requirements` (`true` publishes the selected `requirement.*` nodes as well; default `false`).
*/
open class KotlinSharedModelGenerator : FileEmittingGenerator() {
@@ -35,7 +36,10 @@ open class KotlinSharedModelGenerator : FileEmittingGenerator() {
// 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))
+ // Publishing the ledger is opt-in: "true" adds the selected requirement.* nodes.
+ val requirements = hasArg(ARG_REQUIREMENTS) && getArg(ARG_REQUIREMENTS).trim().toBoolean()
+
+ val model = SharedModel.build(SharedModel.Input(name, files, include, exclude, providers, requirements))
// Resolved after the selection has passed every check, so a model that cannot be published
// says so before a missing version does.
@@ -68,5 +72,6 @@ open class KotlinSharedModelGenerator : FileEmittingGenerator() {
const val ARG_EXCLUDE = "exclude"
const val ARG_FILES = "files"
const val ARG_VERSION = "version"
+ const val ARG_REQUIREMENTS = "requirements"
}
}
diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/Dependencies.java b/server/java/metadata/src/main/java/com/metaobjects/config/Dependencies.java
index b7320cb732..6a834d6731 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/config/Dependencies.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/config/Dependencies.java
@@ -636,8 +636,12 @@ public static void refuseUnownedPackages(MetaRoot root, Set importedPack
if (importedPackages == null || importedPackages.isEmpty()) return;
// ADR-0039 SANCTIONED own-accessor case: a root-level scan. MetaRoot has no super, so own
// and effective children are the same set, and the question is "what did this tree declare
- // at the top level", which is the own layer by definition.
- for (MetaObject node : root.getChildren(MetaObject.class, false)) {
+ // at the top level", which is the own layer by definition. Requirements too: a publisher may
+ // export its ledger, and a new requirement in its package would read as the publisher's to
+ // every gate that keys the ledger by package.
+ List topLevel = new ArrayList<>(root.getChildren(MetaObject.class, false));
+ topLevel.addAll(root.getChildren(com.metaobjects.requirement.MetaRequirement.class, false));
+ for (MetaData node : topLevel) {
String key = node.getName();
String pkg = packageOf(key);
if (!importedPackages.contains(pkg) || importedNodes.contains(key)) continue;
diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/DependencySet.java b/server/java/metadata/src/main/java/com/metaobjects/config/DependencySet.java
index 93b7357477..2bf78990af 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/config/DependencySet.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/config/DependencySet.java
@@ -44,18 +44,20 @@ public final class DependencySet {
/** The set of a project that declares no dependencies. */
public static final DependencySet EMPTY =
- new DependencySet(List.of(), List.of(), null);
+ new DependencySet(List.of(), List.of(), null, List.of());
private final List dependencies;
private final List scopeInclude;
private final List migrateScope;
+ private final List requirementsInherit;
private final Set importedPackages;
private final Set importedNodes;
private final Scope.Compiled declaredMigrateScope;
private DependencySet(List dependencies, List scopeInclude,
- List migrateScope) {
+ List migrateScope, List requirementsInherit) {
this.dependencies = List.copyOf(dependencies);
+ this.requirementsInherit = List.copyOf(requirementsInherit);
this.scopeInclude = List.copyOf(scopeInclude);
this.migrateScope = migrateScope == null ? null : List.copyOf(migrateScope);
Set packages = new HashSet<>();
@@ -88,7 +90,8 @@ public static DependencySet of(Path configDir, Optional config) {
List resolved = specs.isEmpty() && lock.isEmpty()
? List.of()
: Dependencies.verifySnapshot(configDir, specs, lock);
- return new DependencySet(resolved, include, migrate);
+ return new DependencySet(resolved, include, migrate,
+ config.map(NeutralConfig::getRequirementsInherit).orElse(List.of()));
}
/**
@@ -114,7 +117,8 @@ public static DependencySet ofSnapshot(Optional config,
List resolved = declared.isEmpty() && lock.isEmpty()
? List.of()
: Dependencies.verifySnapshot(declared, lock, reader);
- return new DependencySet(resolved, include, migrate);
+ return new DependencySet(resolved, include, migrate,
+ config.map(NeutralConfig::getRequirementsInherit).orElse(List.of()));
}
/** The resolved dependencies, in dependency-NAME order. */
@@ -162,6 +166,18 @@ public boolean inScope(String fqn) {
return Dependencies.explicitlyIncludes(scopeInclude, pkg);
}
+ /**
+ * FR-023 — is the requirement at {@code fqn} ({@code ::}) part of this
+ * project's ledger? Its own requirements always are; a dependency's only when
+ * {@code requirements.inherit} NAMES the dependency's package. Read by the requirement gate and
+ * the requirement-tests generators.
+ */
+ public boolean inLedger(String fqn) {
+ String pkg = Dependencies.packageOf(fqn);
+ if (!importedPackages.contains(pkg)) return true;
+ return Dependencies.explicitlyIncludes(requirementsInherit, pkg);
+ }
+
/**
* The {@code migrate.scope}-governed predicate, or empty when the project declares no
* {@code migrate.scope} AND imports nothing (empty means "govern everything loaded").
diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java b/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java
index 7dd784ab73..eca092fcd3 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java
@@ -70,6 +70,7 @@ public final class NeutralConfig {
private final PhysicalNaming physicalNaming;
private final WireFormat wireFormat;
private EnumDefaults enumDefaults = EnumDefaults.DEFAULT;
+ private List requirementsInherit = List.of();
private NeutralConfig(List