From b3e55161b1bbe3fe30827d7e78d0c4c7dc752763 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 06:21:36 -0400 Subject: [PATCH 1/7] feat(deps): check and publish corpora for cross-repo model sharing; sync finishes on port-native layouts (FR-023) Add an expectCheck arm to fixtures/dependency-conformance (current, drifted, unresolved) run in TypeScript, and the fixtures/shared-model-conformance publish corpus (exact artifact and manifest bytes plus the refusals), run in TypeScript with a serializer arm in Python. meta deps sync and check now prove the locked dependencies load together when the project's own metadata is in a build-tool directory or uses another port's vocabulary, instead of failing after writing. Record the Maven and NuGet packaged transports as a design note. Fix the stale CONFORMANCE.md cell. --- AGENTS.md | 2 +- CHANGELOG.md | 20 + docs/CONFORMANCE.md | 58 ++- docs/features/cli.md | 2 +- docs/features/metadata-dependencies.md | 61 ++- fixtures/dependency-conformance/README.md | 41 +- fixtures/dependency-conformance/cases.json | 466 ++++++++++++++++++ fixtures/shared-model-conformance/README.md | 89 ++++ fixtures/shared-model-conformance/cases.json | 214 ++++++++ .../acme-common-v1/metaobjects.pkg.json | 16 + .../acme-common.metaobjects.json | 38 ++ .../address-audited/metaobjects.pkg.json | 15 + .../acme-platform.metaobjects.json | 71 +++ .../two-packages/metaobjects.pkg.json | 17 + .../acme-common.metaobjects.json | 64 +++ .../with-payloads/metaobjects.pkg.json | 17 + .../source/meta.base.json | 37 ++ .../source/meta.billing.json | 39 ++ .../source/meta.customer.json | 78 +++ .../source/meta.prompts.json | 50 ++ .../source/meta.vault.json | 33 ++ .../test_dependency_conformance.py | 4 +- .../test_shared_model_conformance.py | 57 +++ .../packages/cli/src/commands/deps.ts | 36 +- .../packages/cli/src/lib/dependency-load.ts | 134 +++++ .../test/dependency-check-conformance.test.ts | 67 +++ .../packages/cli/test/deps-sync.test.ts | 60 +++ .../test/shared-model-conformance.test.ts | 120 +++++ .../sdk/test/dependency-conformance.test.ts | 8 +- 29 files changed, 1863 insertions(+), 51 deletions(-) create mode 100644 fixtures/shared-model-conformance/README.md create mode 100644 fixtures/shared-model-conformance/cases.json create mode 100644 fixtures/shared-model-conformance/expected/acme-common-v1/metaobjects.pkg.json create mode 100644 fixtures/shared-model-conformance/expected/address-audited/acme-common.metaobjects.json create mode 100644 fixtures/shared-model-conformance/expected/address-audited/metaobjects.pkg.json create mode 100644 fixtures/shared-model-conformance/expected/two-packages/acme-platform.metaobjects.json create mode 100644 fixtures/shared-model-conformance/expected/two-packages/metaobjects.pkg.json create mode 100644 fixtures/shared-model-conformance/expected/with-payloads/acme-common.metaobjects.json create mode 100644 fixtures/shared-model-conformance/expected/with-payloads/metaobjects.pkg.json create mode 100644 fixtures/shared-model-conformance/source/meta.base.json create mode 100644 fixtures/shared-model-conformance/source/meta.billing.json create mode 100644 fixtures/shared-model-conformance/source/meta.customer.json create mode 100644 fixtures/shared-model-conformance/source/meta.prompts.json create mode 100644 fixtures/shared-model-conformance/source/meta.vault.json create mode 100644 server/python/tests/conformance/test_shared_model_conformance.py create mode 100644 server/typescript/packages/cli/src/lib/dependency-load.ts create mode 100644 server/typescript/packages/cli/test/dependency-check-conformance.test.ts create mode 100644 server/typescript/packages/codegen-ts/test/shared-model-conformance.test.ts diff --git a/AGENTS.md b/AGENTS.md index 9ef0ff6950..9ea8ce48bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken. - **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres. **Cross-port conformance corpora** (every port runs the shared corpus): -- Metamodel: `fixtures/conformance/` (413 fixtures; 31 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. +- Metamodel: `fixtures/conformance/` (413 fixtures; 32 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. - Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical. - Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015; the FR-044 `report-*` scenarios also run on SQLite, MySQL and D1 where a port has the engine, see [docs/CONFORMANCE.md](docs/CONFORMANCE.md)). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".) - API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed. Three sub-corpora run the **generated lane only, on all five ports** — `write-through/`, `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`) and `report/` (FR-044: a view-backed `object.report` serves GET list, answers `POST` with that same `405`, and mounts no `/{id}` route). That is deliberate, not a gap: what is under test is whether a port's GENERATOR emits those routes, and a hand-rolled reference server would answer every scenario by construction. The `m2m/` sub-corpus also gates **TPH x M:N together** (base-declared, subtype-declared, abstract-mid-declared, a non-subtype source onto a subtype TARGET, and the cross-subtype source id answering `200 []`) — the two corpora were originally built disjoint (`tph/` had no relationships, `m2m/` no discriminators), which is precisely how that defect class survived. diff --git a/CHANGELOG.md b/CHANGELOG.md index 874f7d0283..077baeb9b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,17 @@ here.** port yet, so no command's behavior changes. A pattern with an empty segment, a trailing `::` or an odd run of `:` is refused with `ERR_SCOPE_PATTERN_INVALID`. +- **Two cross-port corpora for sharing a model across repositories (FR-023).** + `fixtures/dependency-conformance/` gains a check arm (`expectCheck`, 11 cases): for each declared + dependency, `current`, `drifted`, or `unresolved` (a missing path, a bad manifest, a hash + mismatch, or not yet in the lock), run in TypeScript. The new `fixtures/shared-model-conformance/` + (11 cases) pins 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 runs all of + it and Python runs the serializer arm. These are the reference the Java, Kotlin and C# slices of + Phase 2 ([#482](https://github.com/metaobjectsdev/metaobjects/issues/482)) are written against. + The feature doc records the Maven and NuGet packaged transports as a design for a later slice. + - **C# reads its generated-code directory from `.metaobjects/config.json`, so a plain `dotnet meta verify` runs the codegen gate.** Set `"csharp": { "outDir": "Generated" }` (relative to the directory holding `.metaobjects/`). `dotnet meta gen` writes there and @@ -78,6 +89,15 @@ here.** ### Fixed +- **`meta deps sync` and `meta deps check` finish cleanly on a project whose own metadata the + Node toolchain cannot read.** A Maven project (`src/main/resources/metadata`), a C# project, or a + model using vocabulary only another port registers declares no neutral `sources`, so the closing + "does the dependency set load?" proof failed, and `sync` exited 1 for a layout nothing was wrong + with. In those two cases only, the proof now loads the locked snapshots together, prints that it + did, and says that the port's own `verify` is the full check. Any other load failure still fails + the command and rolls the sync back. A stale `docs/CONFORMANCE.md` cell that claimed Python passed + only 2 of the dependency corpus's cases is corrected. + - **A failed `meta deps sync` no longer leaves a lock and snapshot that cannot load.** The command wrote `deps.lock.json` and the `.metaobjects/deps/` snapshot before loading the combined model, and on a load failure it exited 1 but kept the new files. Because the lock diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 000ea2e354..a745e1fc1d 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -1,6 +1,6 @@ # Conformance coverage -The MetaObjects standard ships **31 shared conformance corpora** under +The MetaObjects standard ships **32 shared conformance corpora** under [`fixtures/`](../fixtures/). Every port runs every corpus that is *applicable to it* and asserts the same expected behaviour against the same fixtures. **This page is the inverse index**: fixture → feature doc + per-port pass status, and it is the @@ -46,7 +46,8 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/provider-composition-conformance/`](../fixtures/provider-composition-conformance/) | 9 (5 error-shape + 4 compose-load) | ✓ | ✓ | — (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/) | 23 cases | ✓ (reference implementation) | — (Phase 2) | — (Phase 2) | — (Phase 2) | ✓ (2 of 23 assert a documented Python-only vocabulary gap instead of the corpus's full contract — see below) | +| [`fixtures/dependency-conformance/`](../fixtures/dependency-conformance/) | 35 cases (24 resolution + 11 check) | ✓ (reference implementation; both arms) | — (Phase 2) | — (Phase 2) | — (Phase 2) | ✓ (resolution arm, all 24 identically; no check yet, so the 11 check cases are not run) | +| [`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/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 | ✓ | ✓ | @@ -73,6 +74,7 @@ the corpora above do two different jobs. Only the first is a promise to adopters - **Core — the contract.** `conformance/`, `yaml-conformance/`, `registry-conformance/`, `validation-conformance/`, `provider-composition-conformance/`, `source-resolution-conformance/`, `scope-conformance/`, `dependency-conformance/`, + `shared-model-conformance/` (the artifact and manifest bytes a consumer verifies), `object-model-conformance/`, `render-conformance/`, `template-codegen-conformance/` (the Mustache engine), `template-output-render-conformance/`, `output-prompt-conformance/`, `extract-conformance/`, `verify-conformance/`, `verify-strict-conformance/`, @@ -438,23 +440,47 @@ 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/` (23 cases) +### `fixtures/dependency-conformance/` (35 cases) -All 23 cases → [features/metadata-dependencies.md](features/metadata-dependencies.md) +All 35 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, and every load- -and resolution-time failure). File-shaped like `scope-conformance/` above: one -committed `cases.json`, no per-port fixture, no ledger. - -**TypeScript (the reference implementation) and Python both run all 23 cases, -identically** — every case's `expectFiles`/`expectImported`/`expectSelected`/ -`expectMigrateGoverned`/`expectLoadError` assertions apply to both runners 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. Java, Kotlin and C# have no runner — +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 two arms: + +- **Resolution (24 cases).** Every case's `expectFiles`/`expectImported`/`expectSelected`/ + `expectMigrateGoverned`/`expectLoadError` assertions. **TypeScript (the reference + implementation) and Python both run all 24, 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. +- **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 only, in `cli/test/dependency-check-conformance.test.ts`; Python has no + `deps check` yet and filters these cases out. The arm exists so that each port's check is + written against the same edge cases (the Phase 2 plan's risk 4). + +Java, Kotlin and C# have no runner — Phase 1a is TypeScript + Python only; those three ports arrive in Phase 2. +### `fixtures/shared-model-conformance/` (11 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 +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 +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). + > **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`, > `overlay-same-file-before-base`, `overlay-nested-under-plain-parent-base-later`, @@ -561,7 +587,7 @@ files into a real Cube. The fixtures in the nine corpora mapped above (metamodel 413 + yaml 16 + verify 37 + render 16 + persistence 59 + api-contract 117 + source-resolution 25 + scope 10 + -dependency 23) each map to a feature doc, and so do the two requirement corpora and the +dependency 35) each map to a feature doc, and so do the two requirement corpora, `shared-model-conformance` and the `cube-model` corpus, 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/cli.md b/docs/features/cli.md index c964f93e06..7cc095725b 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -33,7 +33,7 @@ command surface splits in two: | **Template/prompt drift** (`verify --templates`) | **Node `meta`** | `meta verify --templates` | TS reference (ADR-0021 D2) — `{{field}}`↔payload; part of the bare-`verify` default | | **Vocabulary upgrade** (`upgrade`) | **Node `meta`** | `meta upgrade [--to ] [--apply]` | **any backend** — rewrites RETIRED metadata vocabulary (`@violation` → `@counterexample`, `@readOnly` → `@mutability`, dropping `@verifiedBy`) and resolves ATTRIBUTE CONTRADICTIONS (`@fields` beside `@expr` on an index key). Node-only because it edits the metadata documents themselves, which every port shares; a non-TS project runs `npx meta upgrade` against its own `metaobjects/`. **Canonical JSON and YAML alike.** Previews by default. Retirements needing a human decision are refused and the run exits non-zero, so CI cannot record a partial migration as finished | | **Vocabulary search** (`types`) | **Node `meta`** | `meta types [query]` | **any backend** — apropos/`kubectl explain` over the live metamodel registry (names + descriptions + when-to-use); the vocabulary is cross-port identical (registry-conformance) | -| **Dependency sync** (`deps`) | **Node `meta`** | `meta deps sync \| check \| list` | **any backend** — resolves a declared metadata dependency's `path` transport into a committed snapshot + lock (FR-023), so `sync`/`check`/`list` themselves stay Node-only regardless of your server language. TypeScript and Python then LOAD that snapshot at every rung of their own source ladder — the two ports that implement dependency resolution in Phase 1a; Java, Kotlin, and C# don't read `dependencies` yet (Phase 2). See [`metadata-dependencies.md`](metadata-dependencies.md) | +| **Dependency sync** (`deps`) | **Node `meta`** | `meta deps sync \| check \| list` | **any backend** — resolves a declared metadata dependency's `path` transport into a committed snapshot + lock (FR-023), so `sync`/`check`/`list` themselves stay Node-only regardless of your server language. TypeScript and Python then LOAD that snapshot at every rung of their own source ladder — the two ports that implement dependency resolution in Phase 1a; Java, Kotlin, and C# don't read `dependencies` yet (Phase 2). On a project whose own metadata Node `meta` cannot read (a Maven `src/main/resources/metadata` directory, a C# metadata directory, or another port's vocabulary), `sync` and `check` prove the locked dependencies load together and say that the port's own `verify` is the full check. See [`metadata-dependencies.md`](metadata-dependencies.md) | | **Canonical format** (`fmt`) | every port | `meta fmt` / `dotnet meta fmt` / `mvn metaobjects:fmt` / `metaobjects fmt` | (#304) ships in **every** port — the canonical serializer already exists in all five, so formatting is local, not Node-only. `--check` lists non-canonical files and exits non-zero without changing anything. Formats each file STANDALONE — own-mode, declared-here layer only (ADR-0039); an `extends` onto another file's base is preserved as-is, never resolved or erred on; a file declaring `overlay: true` anywhere — whether or not a same-file base exists — is reported as skipped rather than merged (merging would drop the overlay marker, a structural change fmt never makes). A YAML file is always skipped — no canonical YAML emitter exists (ADR-0006). Before writing, every port reloads the whole project with the candidate swapped in and refuses the write unless the reloaded model's canonical form is byte-identical to the untouched baseline — fmt never changes meaning. See [Fixture corpus](#meta-fmt--canonical-formatting-304) below and `fixtures/fmt-conformance/README.md` | | TS codegen | Node `meta` | `meta gen` | TS projects. **No `--template-spec` flag, deliberately** — `metaobjects.config.ts` already takes generator VALUES, so a declarative template generator is declared there (`templateGenerator()`, or `templateSpecToGenerators(parseTemplateSpec(spec))` to reuse a C#/Python spec file). Keeping it in the config is what lets `meta verify --codegen` regenerate WITH it, since that gate re-runs the config's generator list; see [declarative template scopes](codegen-concepts.md#declarative-template-scopes) | | C# codegen | `dotnet meta` | `dotnet meta init` / `gen` / `verify --templates` / `verify --codegen` | a .NET tool (`ToolCommandName=dotnet-meta`); invoked `dotnet meta` so it never shadows the Node `meta`; ships the ADR-0021 D2 subverbs (`--db` rejected, exit 2; bare `verify` = `--templates` plus `--codegen` when an output directory (`csharp.outDir` in `.metaobjects/config.json`, or `--out`) and `codegen/Generators.cs` are present). **The generator list is `codegen/Generators.cs`** (ADR-0034 Amendment 9): `dotnet meta init` writes it with `codegen/Program.cs` (two lines that run it) and `codegen/Codegen.csproj`, and `gen` / `verify --codegen` always run it through `dotnet run --project codegen`. With no `codegen/Generators.cs` they exit 2 and write nothing; `--generators` only narrows the list to declared entries. Every entry may set its own output directory (`ApiDocsGenerator { OutDir = ... }`), and `verify --codegen` compares each one. A declarative Mustache template-spec (the cross-port JSON contract shared with Python) is one more entry, `TemplateSpecGenerator { Spec = "", TemplateRoot = "" }`; `gen` no longer reads `--template-spec` or a project-root `template-spec.json`, and a run that finds either with no such entry fails before writing anything; see [declarative template scopes](codegen-concepts.md#declarative-template-scopes). `dotnet meta docs` was removed in 1.1.0: it prints the line that points at [the migration section](migrations/docs-are-generators.md#c) and exits 2 | diff --git a/docs/features/metadata-dependencies.md b/docs/features/metadata-dependencies.md index da1f457ff4..2d8be83c2f 100644 --- a/docs/features/metadata-dependencies.md +++ b/docs/features/metadata-dependencies.md @@ -430,13 +430,56 @@ than reading it live off the classpath at build time — the classpath-at-build- read is recorded as a Phase 2 JVM option, not gone, but the committed snapshot is the cross-port contract every consumer gets today. +## Phase 2: Java, Kotlin and C# + +Tracked in [#482](https://github.com/metaobjectsdev/metaobjects/issues/482). Today only +TypeScript and Python consume a dependency; Java, Kotlin and C# ignore the `dependencies` key, +and a model there that references a node a dependency would supply fails loudly +(`ERR_UNRESOLVED_OBJECT_REF`), never silently. Phase 2 adds no new contract. The manifest, the +lock, the committed snapshot and the 24-case resolution corpus are plain files that every port +already has the pieces to read; what is missing is each port *loading* the snapshot. + +Two rulings shape it: + +- **Node `meta deps sync` stays the only writer of the lock and the snapshot.** The other ports + load and check; a Java or C# project needs Node to sync, as a schema-owning project already + needs it for `meta migrate`. Because the lock is then written by one tool, its byte format + never becomes a cross-port contract. +- **The Java Spring runtime loads the snapshot automatically** when + `.metaobjects/deps.lock.json` is on the classpath, so a Spring app whose entity `extends` a + dependency's node does not fail at boot for want of one line of configuration. + +**`meta deps sync` and `check` on a Maven- or C#-shaped project (shipped).** Both commands read +only `.metaobjects/config.json`, and finish with a proof that the dependency set loads. A project +whose own metadata is in a build-tool directory (`src/main/resources/metadata`, a C# project's +metadata directory) declares no neutral `sources`, and a project may use vocabulary only its own +port registers. Neither case can be loaded by the Node toolchain, and neither is a fault in the +dependencies. In those two cases (and only those: no `sources` and no default directory, or an +`ERR_UNKNOWN_TYPE` / `ERR_UNKNOWN_SUBTYPE` / `ERR_UNKNOWN_ATTR` from the project's own files) the +proof loads the locked snapshots together instead, prints which it did, and says that the port's +own `verify` is the full check. Any other load failure still fails the command and, for `sync`, +rolls the lock and snapshot back. + +**Packaged transports: Maven and NuGet (design only, not built).** The `path` transport needs the +publisher's directory on disk. A JVM or .NET consumer that cannot reach it can instead resolve a +published model from the package manager's own store: a Maven artifact (a JAR in the local +repository, or on the classpath) and a NuGet package (in the global packages folder), each +carrying the same `metaobjects.pkg.json` and artifact pair that `sharedModelFile()` writes. Per the +single-writer ruling it is Node `meta deps sync` that reads the pair out of the JAR or the package +and writes the same snapshot and lock entry a `path` sync writes, so everything downstream +(`check`, load, scope, the corpus) is unchanged; nothing in a port's own build resolves a +dependency. This generalizes the resources-JAR pattern described below, and it is a later slice +than consume and check. A JVM *classpath read with no committed snapshot* (the legacy behaviour) +stays a separate, deferred option. + ## Deferred Designed (see `docs/superpowers/specs/2026-09-11-fr-023-metadata-dependencies-design.md`) but **not** built in Phase 1a — do not assume any of the following exists: -- **The `npm` and `python` transports** (and `maven`/`nuget` in a later phase). Only - `path` resolves; the others are refused with `ERR_DEPENDENCY_UNRESOLVED`. +- **The `npm` and `python` transports** (and `maven`/`nuget`, see + [Phase 2](#phase-2-java-kotlin-and-c)). Only `path` resolves; the others are refused with + `ERR_DEPENDENCY_UNRESOLVED`. - **A local co-development override** (`deps.local.json`) that would point `sync` at an unpublished local checkout without editing the committed config. - **A usage-aware breaking-change classifier** at sync/check time. Nothing @@ -455,18 +498,24 @@ but **not** built in Phase 1a — do not assume any of the following exists: ## Verified by -- [`fixtures/dependency-conformance/`](../../fixtures/dependency-conformance/) — 23 - cases pinning resolution, the exhaustive `imported`/`inScope`/`inMigrateScope` sets, +- [`fixtures/dependency-conformance/`](../../fixtures/dependency-conformance/) — 35 + cases. 24 pin resolution, 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) and - Python. Three artifacts under `fixtures/dependency-conformance/artifacts/` are + Python. 11 pin the upstream check (`current`, `drifted`, `unresolved`), run by TypeScript; + Python has no check yet. Three 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 23 cases identically to TypeScript's, with no + Python's runner asserts all 24 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. +- [`fixtures/shared-model-conformance/`](../../fixtures/shared-model-conformance/) — 11 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 runs + all of it; Python runs the serializer arm. ## See also diff --git a/fixtures/dependency-conformance/README.md b/fixtures/dependency-conformance/README.md index 460ec84d09..2d6cf370a9 100644 --- a/fixtures/dependency-conformance/README.md +++ b/fixtures/dependency-conformance/README.md @@ -12,7 +12,8 @@ no per-port fixture. cases.json # { cases: [ { name, tree, treeFiles?, config, lock?, # resolveFrom?, expectFiles?, expectImported?, # expectSelected?, expectMigrateGoverned?, - # expectLoadError?, expectErrorFiles?, expectError? } ] } + # expectLoadError?, expectErrorFiles?, expectError?, + # expectCheck? } ] } README.md artifacts/ # pinned dependency artifacts, referenced by cases via `treeFiles` acme-common-v1.json @@ -40,12 +41,14 @@ 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 - "expectError": "ERR_*" // resolution itself fails with this code + "expectError": "ERR_*", // resolution itself fails with this code + "expectCheck": { "": "current" | "drifted" | "unresolved" } // the CHECK arm } ] } ``` -Exactly one of `expectFiles`, `expectError` is present per case; `expectLoadError` rides -with `expectFiles`. +Exactly one of `expectFiles`, `expectError`, `expectCheck` is present per case; +`expectLoadError` rides with `expectFiles`. A runner for one arm skips the cases that +belong to the other. - **`tree`** — a map of project-root-relative path → file content, materialized in a fresh temporary directory (same shape as `source-resolution-conformance`). @@ -92,6 +95,25 @@ with `expectFiles`. - **`expectError`** — the resolution-failure arm: `resolveCollection` itself must reject with this exact code. +- **`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 + publisher's `metaobjects.pkg.json`, hash the artifact beside it, and compare with the lock. + The map is EXHAUSTIVE (one entry per declared dependency, by name): + - `current` — the artifact resolves and hashes to the lock's `integrity` (a different + `version` over the same bytes is still `current`). + - `drifted` — it resolves, but hashes to something else. + - `unresolved` — the check cannot compare: the `path` is not a directory, the manifest is + absent, not JSON or fails its schema, the manifest names an artifact that is missing, + the artifact does not hash to the manifest's own `integrity`, or the dependency is + declared but has no entry in the lock (including no lock file at all). A check that cannot + check must not pass. + + The check is a pure function of the config, the lock and the publisher's directory: no + snapshot and no loader is involved, so a check case carries no `.metaobjects/deps/` tree. + The publisher's directory sits beside the project in `tree` / `treeFiles` (the config's + `path` is `..//metaobjects` and `resolveFrom` is `app`). The status is the contract; + the detail text is not, and differs by port. + ## Pinned artifacts `artifacts/acme-common-v1.json` is the base publisher artifact (`acme::common::Address`, @@ -172,10 +194,13 @@ corpus expectation to update. ## Which arms each port runs -- **TypeScript** — resolution, load-time failure, lock/snapshot integrity. -- **Python** — resolution, load-time failure, lock/snapshot integrity. -- **Java / C# / Kotlin** — Phase 2 (out of scope for this plan; these ports do not read - `dependencies` yet). +- **TypeScript** — resolution, load-time failure, lock/snapshot integrity, and the check arm + (`cli/test/dependency-check-conformance.test.ts`; the resolution arm is + `sdk/test/dependency-conformance.test.ts`). +- **Python** — resolution, load-time failure, lock/snapshot integrity. Not the check arm: Python + has no `deps check` yet, so its runner filters the `expectCheck` cases out. +- **Java / C# / Kotlin** — Phase 2 (these ports do not read `dependencies` yet). Each runs the + resolution arm when it consumes, and the check arm when it gains a check. ## Reference implementation diff --git a/fixtures/dependency-conformance/cases.json b/fixtures/dependency-conformance/cases.json index 93a009db94..0c952e030f 100644 --- a/fixtures/dependency-conformance/cases.json +++ b/fixtures/dependency-conformance/cases.json @@ -1096,6 +1096,472 @@ "expectErrorFiles": [ "dep:acme-common/acme-common.metaobjects.json" ] + }, + { + "name": "check-a-matching-artifact-is-current", + "tree": { + "app/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\"]}}]}}]}}", + "acme-common/metaobjects/metaobjects.pkg.json": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "treeFiles": { + "acme-common/metaobjects/acme-common.metaobjects.json": "artifacts/acme-common-v1.json" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "path": "../acme-common/metaobjects" + } + ] + }, + "lock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "path": "../acme-common/metaobjects" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + }, + "resolveFrom": "app", + "expectCheck": { + "acme-common": "current" + } + }, + { + "name": "check-a-newer-artifact-than-the-lock-has-drifted", + "tree": { + "app/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\"]}}]}}]}}", + "acme-common/metaobjects/metaobjects.pkg.json": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.1.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-fa00b9f3c54a2c302cf269af051589afc0a3217999ac82c54c77e33494638c36\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "treeFiles": { + "acme-common/metaobjects/acme-common.metaobjects.json": "artifacts/acme-common-v1-widened.json" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "path": "../acme-common/metaobjects" + } + ] + }, + "lock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "path": "../acme-common/metaobjects" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + }, + "resolveFrom": "app", + "expectCheck": { + "acme-common": "drifted" + } + }, + { + "name": "check-a-version-bump-with-the-same-bytes-is-current", + "tree": { + "app/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\"]}}]}}]}}", + "acme-common/metaobjects/metaobjects.pkg.json": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.1\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "treeFiles": { + "acme-common/metaobjects/acme-common.metaobjects.json": "artifacts/acme-common-v1.json" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "path": "../acme-common/metaobjects" + } + ] + }, + "lock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "path": "../acme-common/metaobjects" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + }, + "resolveFrom": "app", + "expectCheck": { + "acme-common": "current" + } + }, + { + "name": "check-a-missing-path-is-unresolved", + "tree": { + "app/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\"]}}]}}]}}" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "path": "../acme-common/metaobjects" + } + ] + }, + "lock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "path": "../acme-common/metaobjects" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + }, + "resolveFrom": "app", + "expectCheck": { + "acme-common": "unresolved" + } + }, + { + "name": "check-a-bad-manifest-is-unresolved", + "tree": { + "app/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\"]}}]}}]}}", + "acme-common/metaobjects/metaobjects.pkg.json": "{\"schema_version\": 1, \"name\": \"acme-common\"}\n" + }, + "treeFiles": { + "acme-common/metaobjects/acme-common.metaobjects.json": "artifacts/acme-common-v1.json" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "path": "../acme-common/metaobjects" + } + ] + }, + "lock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "path": "../acme-common/metaobjects" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + }, + "resolveFrom": "app", + "expectCheck": { + "acme-common": "unresolved" + } + }, + { + "name": "check-a-manifest-that-is-not-json-is-unresolved", + "tree": { + "app/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\"]}}]}}]}}", + "acme-common/metaobjects/metaobjects.pkg.json": "not json\n" + }, + "treeFiles": { + "acme-common/metaobjects/acme-common.metaobjects.json": "artifacts/acme-common-v1.json" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "path": "../acme-common/metaobjects" + } + ] + }, + "lock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "path": "../acme-common/metaobjects" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + }, + "resolveFrom": "app", + "expectCheck": { + "acme-common": "unresolved" + } + }, + { + "name": "check-an-artifact-the-manifest-does-not-describe-is-unresolved", + "tree": { + "app/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\"]}}]}}]}}", + "acme-common/metaobjects/metaobjects.pkg.json": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "treeFiles": { + "acme-common/metaobjects/acme-common.metaobjects.json": "artifacts/acme-common-v1-widened.json" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "path": "../acme-common/metaobjects" + } + ] + }, + "lock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "path": "../acme-common/metaobjects" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + }, + "resolveFrom": "app", + "expectCheck": { + "acme-common": "unresolved" + } + }, + { + "name": "check-a-manifest-naming-a-missing-artifact-is-unresolved", + "tree": { + "app/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\"]}}]}}]}}", + "acme-common/metaobjects/metaobjects.pkg.json": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "path": "../acme-common/metaobjects" + } + ] + }, + "lock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "path": "../acme-common/metaobjects" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + }, + "resolveFrom": "app", + "expectCheck": { + "acme-common": "unresolved" + } + }, + { + "name": "check-a-dependency-not-yet-in-the-lock-is-unresolved", + "tree": { + "app/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\"]}}]}}]}}", + "acme-common/metaobjects/metaobjects.pkg.json": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "treeFiles": { + "acme-common/metaobjects/acme-common.metaobjects.json": "artifacts/acme-common-v1.json" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "path": "../acme-common/metaobjects" + } + ] + }, + "lock": { + "schema_version": 1, + "dependencies": {} + }, + "resolveFrom": "app", + "expectCheck": { + "acme-common": "unresolved" + } + }, + { + "name": "check-a-dependency-with-no-lock-file-is-unresolved", + "tree": { + "app/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\"]}}]}}]}}", + "acme-common/metaobjects/metaobjects.pkg.json": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "treeFiles": { + "acme-common/metaobjects/acme-common.metaobjects.json": "artifacts/acme-common-v1.json" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "path": "../acme-common/metaobjects" + } + ] + }, + "resolveFrom": "app", + "expectCheck": { + "acme-common": "unresolved" + } + }, + { + "name": "check-reports-every-dependency-on-its-own", + "tree": { + "app/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\"]}}]}}]}}", + "acme-extra/metaobjects/metaobjects.pkg.json": "{\n \"schema_version\": 1,\n \"name\": \"acme-extra\",\n \"version\": \"2.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-extra.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n", + "acme-common/metaobjects/metaobjects.pkg.json": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.1.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-fa00b9f3c54a2c302cf269af051589afc0a3217999ac82c54c77e33494638c36\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "treeFiles": { + "acme-extra/metaobjects/acme-extra.metaobjects.json": "artifacts/acme-common-v1.json", + "acme-common/metaobjects/acme-common.metaobjects.json": "artifacts/acme-common-v1-widened.json" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-extra", + "path": "../acme-extra/metaobjects" + }, + { + "name": "acme-common", + "path": "../acme-common/metaobjects" + } + ] + }, + "lock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "path": "../acme-common/metaobjects" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + }, + "resolveFrom": "app", + "expectCheck": { + "acme-common": "drifted", + "acme-extra": "unresolved" + } } ] } diff --git a/fixtures/shared-model-conformance/README.md b/fixtures/shared-model-conformance/README.md new file mode 100644 index 0000000000..cdfe8889ae --- /dev/null +++ b/fixtures/shared-model-conformance/README.md @@ -0,0 +1,89 @@ +# shared-model-conformance + +Pins what a publisher writes for FR-023: the shared-model **artifact** and the **manifest** that +pins it (`metaobjects.pkg.json`), byte for byte, and the refusals a publisher raises instead of +writing a model a consumer could not use. Every port's publisher reads THIS file; there is no +per-port fixture. + +A consumer's `meta deps sync` accepts whatever bytes a publisher wrote, as long as the manifest +hashes them. So differing bytes between ports would not break a consumer; they would show up as a +spurious `drifted` when a project changes the port that publishes, and as a `verify --codegen` +that disagrees between ports. This corpus is what keeps that from happening. + +## Shape + +``` +cases.json # { cases: [ { name, input, expectArtifact + expectManifest | expectRefusal } ] } +source/ # the publisher's own metadata files; a case's `files` name these +expected/ # the pinned manifest (and, where new, artifact) for each publish case +``` + +```jsonc +{ "name": "…", // kebab-case, the contract + "input": { + "name": "acme-common", // the dependency key; the artifact is .metaobjects.json + "files": ["source/meta.base.json"], // which of the sources may contribute, corpus-relative + "include": ["acme::common::**"], // the scope grammar of scope-conformance/ + "exclude": ["acme::common::Secret"],// OPTIONAL, applied after include + "version": "1.0.0", // fixed, so the manifest bytes are fixed + "providers": ["secret-handling"] // OPTIONAL: named test providers, see below + }, + "expectArtifact": "expected//.metaobjects.json", + "expectManifest": "expected//metaobjects.pkg.json", + "expectRefusal": { "reason": "…", "pairs": [ { "referrer": "…", "target": "…" } ] } } +``` + +Exactly one of `expectArtifact` + `expectManifest`, `expectRefusal` is present per case. + +- **`expectArtifact`** — corpus-relative path to the exact bytes of the canonical-JSON + `metadata.root` document. `publishes-the-pinned-acme-common-artifact` points at + `../dependency-conformance/artifacts/acme-common-v1.json`, the artifact that corpus pins by + sha256, so the two corpora cannot disagree about it. +- **`expectManifest`** — the exact bytes of `metaobjects.pkg.json` (two-space JSON, trailing + newline). The manifest records the toolchain's `metamodelVersion`; the golden carries the + 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). + - `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 + 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. + + The message text is not the contract; the reason and the pairs are. A port classifies its own + error into a reason. + +## Rules the cases pin + +- `requirement.*` and `template.*` top-level nodes are never published, whatever `include` says + (`requirements-and-templates-are-always-dropped`). +- 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`). +- `files` narrows what may contribute before `include` / `exclude` select + (`files-narrows-what-may-contribute`); `exclude` is applied after `include` + (`exclude-is-applied-after-include`). + +## Test providers + +`secret-handling` registers one common string attribute, `secretHandling`, that no core provider +ships, standing in for vocabulary a consumer's provider adds. It is the one trigger for +`non-core-vocabulary`; a port implements it in its own test code (a provider that registers a common +attribute `secretHandling` of type string, not required). + +## Which arms each port runs + +- **TypeScript** — all of it: select, closure, serialize, core-vocabulary re-load, manifest, and + every refusal (`codegen-ts/test/shared-model-conformance.test.ts`). +- **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. + +## Reference implementation + +`server/typescript/packages/codegen-ts/src/shared-model/build-shared-model.ts` +(`buildSharedModel`, `renderSharedModelFiles`) and `serializeSharedDocument` in +`server/typescript/packages/metadata/src/serializer-json.ts`. diff --git a/fixtures/shared-model-conformance/cases.json b/fixtures/shared-model-conformance/cases.json new file mode 100644 index 0000000000..67bf34569a --- /dev/null +++ b/fixtures/shared-model-conformance/cases.json @@ -0,0 +1,214 @@ +{ + "cases": [ + { + "name": "publishes-the-pinned-acme-common-artifact", + "input": { + "name": "acme-common", + "files": [ + "source/meta.base.json", + "source/meta.customer.json" + ], + "include": [ + "acme::common::**" + ], + "exclude": [ + "acme::common::Secret" + ], + "version": "1.0.0" + }, + "expectArtifact": "../dependency-conformance/artifacts/acme-common-v1.json", + "expectManifest": "expected/acme-common-v1/metaobjects.pkg.json" + }, + { + "name": "the-order-of-files-does-not-change-the-bytes", + "input": { + "name": "acme-common", + "files": [ + "source/meta.customer.json", + "source/meta.base.json" + ], + "include": [ + "acme::common::**" + ], + "exclude": [ + "acme::common::Secret" + ], + "version": "1.0.0" + }, + "expectArtifact": "../dependency-conformance/artifacts/acme-common-v1.json", + "expectManifest": "expected/acme-common-v1/metaobjects.pkg.json" + }, + { + "name": "include-names-the-nodes-to-publish", + "input": { + "name": "acme-common", + "files": [ + "source/meta.base.json", + "source/meta.customer.json" + ], + "include": [ + "acme::common::Address", + "acme::common::Audited" + ], + "version": "1.0.0" + }, + "expectArtifact": "expected/address-audited/acme-common.metaobjects.json", + "expectManifest": "expected/address-audited/metaobjects.pkg.json" + }, + { + "name": "exclude-is-applied-after-include", + "input": { + "name": "acme-common", + "files": [ + "source/meta.base.json", + "source/meta.customer.json" + ], + "include": [ + "acme::common::**" + ], + "exclude": [ + "acme::common::Customer", + "acme::common::Secret" + ], + "version": "1.0.0" + }, + "expectArtifact": "expected/address-audited/acme-common.metaobjects.json", + "expectManifest": "expected/address-audited/metaobjects.pkg.json" + }, + { + "name": "files-narrows-what-may-contribute", + "input": { + "name": "acme-common", + "files": [ + "source/meta.base.json" + ], + "include": [ + "acme::common::**" + ], + "version": "1.0.0" + }, + "expectArtifact": "expected/address-audited/acme-common.metaobjects.json", + "expectManifest": "expected/address-audited/metaobjects.pkg.json" + }, + { + "name": "requirements-and-templates-are-always-dropped", + "input": { + "name": "acme-common", + "files": [ + "source/meta.base.json", + "source/meta.prompts.json" + ], + "include": [ + "acme::common::**" + ], + "version": "0.4.0" + }, + "expectArtifact": "expected/with-payloads/acme-common.metaobjects.json", + "expectManifest": "expected/with-payloads/metaobjects.pkg.json" + }, + { + "name": "a-selection-spanning-two-packages-lists-both-sorted", + "input": { + "name": "acme-platform", + "files": [ + "source/meta.billing.json", + "source/meta.base.json" + ], + "include": [ + "acme::**" + ], + "version": "2.3.1-rc.1" + }, + "expectArtifact": "expected/two-packages/acme-platform.metaobjects.json", + "expectManifest": "expected/two-packages/metaobjects.pkg.json" + }, + { + "name": "an-empty-selection-is-refused", + "input": { + "name": "acme-common", + "files": [ + "source/meta.base.json", + "source/meta.customer.json" + ], + "include": [ + "nothing::**" + ], + "version": "1.0.0" + }, + "expectRefusal": { + "reason": "empty-selection" + } + }, + { + "name": "a-reference-outside-the-selection-is-refused-naming-the-pair", + "input": { + "name": "acme-common", + "files": [ + "source/meta.base.json", + "source/meta.customer.json" + ], + "include": [ + "acme::common::Secret" + ], + "version": "1.0.0" + }, + "expectRefusal": { + "reason": "not-closed", + "pairs": [ + { + "referrer": "acme::common::Secret", + "target": "acme::common::Address" + } + ] + } + }, + { + "name": "an-extends-outside-the-selection-is-refused-naming-every-pair", + "input": { + "name": "acme-platform", + "files": [ + "source/meta.billing.json", + "source/meta.base.json" + ], + "include": [ + "acme::billing::Invoice" + ], + "version": "1.0.0" + }, + "expectRefusal": { + "reason": "not-closed", + "pairs": [ + { + "referrer": "acme::billing::Invoice", + "target": "acme::common::Address" + }, + { + "referrer": "acme::billing::Invoice", + "target": "acme::common::Audited" + } + ] + } + }, + { + "name": "vocabulary-only-a-consumer-provider-registers-is-refused", + "input": { + "name": "acme-common", + "files": [ + "source/meta.base.json", + "source/meta.vault.json" + ], + "include": [ + "acme::common::Address", + "acme::common::Vault" + ], + "version": "1.0.0", + "providers": [ + "secret-handling" + ] + }, + "expectRefusal": { + "reason": "non-core-vocabulary" + } + } + ] +} diff --git a/fixtures/shared-model-conformance/expected/acme-common-v1/metaobjects.pkg.json b/fixtures/shared-model-conformance/expected/acme-common-v1/metaobjects.pkg.json new file mode 100644 index 0000000000..3aa81b6f0b --- /dev/null +++ b/fixtures/shared-model-conformance/expected/acme-common-v1/metaobjects.pkg.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "name": "acme-common", + "version": "1.0.0", + "metamodelVersion": "@@METAMODEL_VERSION@@", + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] +} diff --git a/fixtures/shared-model-conformance/expected/address-audited/acme-common.metaobjects.json b/fixtures/shared-model-conformance/expected/address-audited/acme-common.metaobjects.json new file mode 100644 index 0000000000..b5539b7c68 --- /dev/null +++ b/fixtures/shared-model-conformance/expected/address-audited/acme-common.metaobjects.json @@ -0,0 +1,38 @@ +{ + "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" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/shared-model-conformance/expected/address-audited/metaobjects.pkg.json b/fixtures/shared-model-conformance/expected/address-audited/metaobjects.pkg.json new file mode 100644 index 0000000000..4a30df778b --- /dev/null +++ b/fixtures/shared-model-conformance/expected/address-audited/metaobjects.pkg.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "name": "acme-common", + "version": "1.0.0", + "metamodelVersion": "@@METAMODEL_VERSION@@", + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-08040faf22809a38a621ca47fd2b242f0721328402e3c3976934cef9bbd44e5a", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited" + ] +} diff --git a/fixtures/shared-model-conformance/expected/two-packages/acme-platform.metaobjects.json b/fixtures/shared-model-conformance/expected/two-packages/acme-platform.metaobjects.json new file mode 100644 index 0000000000..ac882e6367 --- /dev/null +++ b/fixtures/shared-model-conformance/expected/two-packages/acme-platform.metaobjects.json @@ -0,0 +1,71 @@ +{ + "metadata.root": { + "children": [ + { + "object.entity": { + "name": "Invoice", + "package": "acme::billing", + "extends": "acme::common::Audited", + "children": [ + { + "source.rdb": { + "@table": "invoices" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.object": { + "name": "billingAddress", + "@objectRef": "acme::common::Address" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "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" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/shared-model-conformance/expected/two-packages/metaobjects.pkg.json b/fixtures/shared-model-conformance/expected/two-packages/metaobjects.pkg.json new file mode 100644 index 0000000000..3c903e85da --- /dev/null +++ b/fixtures/shared-model-conformance/expected/two-packages/metaobjects.pkg.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "name": "acme-platform", + "version": "2.3.1-rc.1", + "metamodelVersion": "@@METAMODEL_VERSION@@", + "artifact": "acme-platform.metaobjects.json", + "integrity": "sha256-5d52232cd7a0ed6475813cdfd62193dca825c744a4495253d783c6ffe0ea5c15", + "packages": [ + "acme::billing", + "acme::common" + ], + "nodes": [ + "acme::billing::Invoice", + "acme::common::Address", + "acme::common::Audited" + ] +} diff --git a/fixtures/shared-model-conformance/expected/with-payloads/acme-common.metaobjects.json b/fixtures/shared-model-conformance/expected/with-payloads/acme-common.metaobjects.json new file mode 100644 index 0000000000..c249b08848 --- /dev/null +++ b/fixtures/shared-model-conformance/expected/with-payloads/acme-common.metaobjects.json @@ -0,0 +1,64 @@ +{ + "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" + } + } + ] + } + }, + { + "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/metaobjects.pkg.json b/fixtures/shared-model-conformance/expected/with-payloads/metaobjects.pkg.json new file mode 100644 index 0000000000..df43a59ae0 --- /dev/null +++ b/fixtures/shared-model-conformance/expected/with-payloads/metaobjects.pkg.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "name": "acme-common", + "version": "0.4.0", + "metamodelVersion": "@@METAMODEL_VERSION@@", + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-eff4c3eb921678078994f8db3a54c3ae484972f79fd80f0b2fb8124ca4b42c37", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::AnswerPayload", + "acme::common::AskPayload", + "acme::common::Audited" + ] +} diff --git a/fixtures/shared-model-conformance/source/meta.base.json b/fixtures/shared-model-conformance/source/meta.base.json new file mode 100644 index 0000000000..745d223c87 --- /dev/null +++ b/fixtures/shared-model-conformance/source/meta.base.json @@ -0,0 +1,37 @@ +{ + "metadata.root": { + "package": "acme::common", + "children": [ + { + "object.value": { + "name": "Address", + "children": [ + { + "field.string": { + "name": "city" + } + }, + { + "field.string": { + "name": "street" + } + } + ] + } + }, + { + "object.entity": { + "name": "Audited", + "abstract": true, + "children": [ + { + "field.timestamp": { + "name": "createdAt" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/shared-model-conformance/source/meta.billing.json b/fixtures/shared-model-conformance/source/meta.billing.json new file mode 100644 index 0000000000..5b15f2b76f --- /dev/null +++ b/fixtures/shared-model-conformance/source/meta.billing.json @@ -0,0 +1,39 @@ +{ + "metadata.root": { + "package": "acme::billing", + "children": [ + { + "object.entity": { + "name": "Invoice", + "extends": "acme::common::Audited", + "children": [ + { + "source.rdb": { + "@table": "invoices" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.object": { + "name": "billingAddress", + "@objectRef": "acme::common::Address" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/shared-model-conformance/source/meta.customer.json b/fixtures/shared-model-conformance/source/meta.customer.json new file mode 100644 index 0000000000..011f8e92ab --- /dev/null +++ b/fixtures/shared-model-conformance/source/meta.customer.json @@ -0,0 +1,78 @@ +{ + "metadata.root": { + "package": "acme::common", + "children": [ + { + "object.entity": { + "name": "Customer", + "children": [ + { + "source.rdb": { + "@table": "customers" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "email", + "@maxLength": 120 + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Secret", + "children": [ + { + "source.rdb": { + "@table": "secrets" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.object": { + "name": "billingAddress", + "@objectRef": "Address" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "requirement.functional": { + "name": "KeepSecretsSafe", + "@level": 5, + "@status": "live", + "@statement": "Every secret's billing address is stored encrypted at rest.", + "@counterexample": "A secret row with a plaintext billing address." + } + } + ] + } +} diff --git a/fixtures/shared-model-conformance/source/meta.prompts.json b/fixtures/shared-model-conformance/source/meta.prompts.json new file mode 100644 index 0000000000..fef1f676c4 --- /dev/null +++ b/fixtures/shared-model-conformance/source/meta.prompts.json @@ -0,0 +1,50 @@ +{ + "metadata.root": { + "package": "acme::common", + "children": [ + { + "object.value": { + "name": "AskPayload", + "children": [ + { + "field.string": { + "name": "question" + } + } + ] + } + }, + { + "object.value": { + "name": "AnswerPayload", + "children": [ + { + "field.string": { + "name": "text" + } + } + ] + } + }, + { + "template.prompt": { + "name": "ask", + "@payloadRef": "AskPayload", + "@responseRef": "AnswerPayload", + "@textRef": "ai/ask", + "@format": "text", + "@promptStyle": "inline" + } + }, + { + "requirement.functional": { + "name": "AnswersAreShort", + "@level": 5, + "@status": "live", + "@statement": "Every answer fits on one line.", + "@counterexample": "An answer that runs to three paragraphs." + } + } + ] + } +} diff --git a/fixtures/shared-model-conformance/source/meta.vault.json b/fixtures/shared-model-conformance/source/meta.vault.json new file mode 100644 index 0000000000..5c7d198a9b --- /dev/null +++ b/fixtures/shared-model-conformance/source/meta.vault.json @@ -0,0 +1,33 @@ +{ + "metadata.root": { + "package": "acme::common", + "children": [ + { + "object.entity": { + "name": "Vault", + "@secretHandling": "vault", + "children": [ + { + "source.rdb": { + "@table": "vaults" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/server/python/tests/conformance/test_dependency_conformance.py b/server/python/tests/conformance/test_dependency_conformance.py index 5402f4412f..e0a7fa72a8 100644 --- a/server/python/tests/conformance/test_dependency_conformance.py +++ b/server/python/tests/conformance/test_dependency_conformance.py @@ -26,7 +26,9 @@ Path(__file__).resolve().parents[4] / "fixtures" / "dependency-conformance" ) _CORPUS = _CORPUS_DIR / "cases.json" -_CASES = json.loads(_CORPUS.read_text())["cases"] +# The resolution arm only: a case carrying `expectCheck` belongs to the check arm, which +# Python does not ship yet (it has no `deps check`; the Phase 2 plan's S2 adds it). +_CASES = [c for c in json.loads(_CORPUS.read_text())["cases"] if "expectCheck" not in c] def test_corpus_is_non_empty() -> None: """A silent zero-case run is a failed gate, not a pass. diff --git a/server/python/tests/conformance/test_shared_model_conformance.py b/server/python/tests/conformance/test_shared_model_conformance.py new file mode 100644 index 0000000000..cfab7a3274 --- /dev/null +++ b/server/python/tests/conformance/test_shared_model_conformance.py @@ -0,0 +1,57 @@ +"""Runs the SERIALIZER arm of the shared publish corpus (FR-023 §4.3) in Python. + +Python ships no publisher yet (the Phase 2 plan defers it until a Python-only publisher +appears), so it does not run selection, the closure check or the refusals. What it does own +is the document's bytes: load a case's ``files``, take exactly the nodes the pinned manifest +lists, and ``serialize_shared_document`` must reproduce the pinned artifact byte for byte, and +the sha256 the manifest records. + +The refusal cases and the manifest's own bytes are the publisher's, and are run by +``codegen-ts/test/shared-model-conformance.test.ts`` until a second publisher exists. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from metaobjects import MetaDataLoader +from metaobjects.serializer_json import serialize_shared_document +from metaobjects.shared.base_types import TYPE_OBJECT + +_CORPUS_DIR = Path(__file__).resolve().parents[4] / "fixtures" / "shared-model-conformance" +_CASES = json.loads((_CORPUS_DIR / "cases.json").read_text())["cases"] +_PUBLISHED = [c for c in _CASES if "expectArtifact" in c] + + +def test_corpus_is_non_empty() -> None: + """A silent zero-case run is a failed gate, not a pass.""" + assert _PUBLISHED + + +@pytest.mark.parametrize("case", _PUBLISHED, ids=[c["name"] for c in _PUBLISHED]) +def test_shared_document_bytes(case: dict) -> None: + manifest = json.loads((_CORPUS_DIR / case["expectManifest"]).read_text()) + expected = (_CORPUS_DIR / case["expectArtifact"]).read_bytes() + + result = MetaDataLoader.from_uris( + [(_CORPUS_DIR / f).as_uri() for f in case["input"]["files"]], + strict=True, + discover_naming=False, + ) + assert not result.errors, [f"{e.code}: {e.message}" for e in result.errors] + + # ADR-0039 sanctioned own: the root has no super chain, so its own children are its children. + selected = [ + c + for c in result.root.own_children() + if c.type == TYPE_OBJECT and c.resolution_key() in manifest["nodes"] + ] + assert sorted(c.resolution_key() for c in selected) == manifest["nodes"] + + document = serialize_shared_document(selected).encode("utf-8") + assert document == expected + assert manifest["integrity"] == "sha256-" + hashlib.sha256(document).hexdigest() diff --git a/server/typescript/packages/cli/src/commands/deps.ts b/server/typescript/packages/cli/src/commands/deps.ts index 4c42487500..49e00bc198 100644 --- a/server/typescript/packages/cli/src/commands/deps.ts +++ b/server/typescript/packages/cli/src/commands/deps.ts @@ -7,8 +7,6 @@ import { DEFAULT_METAOBJECTS_DIR, discoverCollectionRoot, loadConfig, - loadMemory, - resolveCollection, type DependencySpec, type Lock, } from "@metaobjectsdev/sdk"; @@ -16,7 +14,7 @@ import { parseDepsArgs, type DepsFlags } from "../lib/args.js"; import { log } from "../lib/log.js"; import { emitStructured, type OutputFormat } from "../lib/format.js"; import { reportLoadError } from "../lib/load-error.js"; -import { collectionLoadOptions } from "../lib/collection-load-options.js"; +import { DependencyLoadFailure, dependencyOnlyNotice, proveDependencySetLoads, type DependencyLoadProof } from "../lib/dependency-load.js"; import { applySync, backupSyncState, @@ -87,7 +85,7 @@ async function runSync(configDir: string, specs: readonly DependencySpec[], flag // The per-dependency report is printed only once the sync is known good: a // report of "synced" for a sync that is then rolled back would lie. - const printReport = (): void => { + const printReport = (proof?: DependencyLoadProof): void => { if (fmt === "text") { for (const w of plan.warnings) log.info(w); if (applied.report.length === 0) { @@ -95,10 +93,12 @@ async function runSync(configDir: string, specs: readonly DependencySpec[], flag } else { for (const r of applied.report) log.info(r.line); } + if (proof?.note !== undefined) log.info(dependencyOnlyNotice("meta deps sync", proof.note)); } else { emitStructured( { warnings: plan.warnings, + ...(proof?.note !== undefined ? { loadScope: proof.scope, loadNote: proof.note } : {}), dependencies: applied.report.map((r) => ({ name: r.name, action: r.action, message: r.line })), }, fmt, @@ -117,23 +117,23 @@ async function runSync(configDir: string, specs: readonly DependencySpec[], flag // that produces an unloadable model (a collision the plan missed because it // only compares declared node LISTS, a scope that now excludes something // load-bearing, …) fails in THIS command with the loader's own error, - // rather than surfacing on the next unrelated `meta gen`/`verify`. - let collection; + // rather than surfacing on the next unrelated `meta gen`/`verify`. A project + // whose own metadata this toolchain cannot read (a build-tool source + // directory, another port's vocabulary) is proven on its dependencies alone. + let proof: DependencyLoadProof; try { - collection = await resolveCollection(configDir); + proof = await proveDependencySetLoads(configDir); } catch (err) { + if (!(err instanceof DependencyLoadFailure)) throw err; return fail(() => - log.error(`meta deps sync: the updated dependency set does not resolve: ${describeError(err)}`), + err.phase === "resolve" + ? log.error(`meta deps sync: the updated dependency set does not resolve: ${describeError(err.cause)}`) + : reportLoadError(log, "meta deps sync: the updated dependency set does not load", err.cause), ); } - try { - await loadMemory(collection.configDir, collectionLoadOptions(collection)); - } catch (err) { - return fail(() => reportLoadError(log, "meta deps sync: the updated dependency set does not load", err)); - } await backup?.discard(); - printReport(); + printReport(proof); return 0; } @@ -202,12 +202,12 @@ async function runCheck( // Matching hashes only prove the snapshot is the locked one, not that the // model still loads with it. A check that cannot load must not pass. let loadFailure: string | undefined; + let proof: DependencyLoadProof | undefined; if (failing.length === 0) { try { - const collection = await resolveCollection(configDir); - await loadMemory(collection.configDir, collectionLoadOptions(collection)); + proof = await proveDependencySetLoads(configDir); } catch (err) { - loadFailure = describeError(err); + loadFailure = describeError(err instanceof DependencyLoadFailure ? err.cause : err); } } @@ -222,12 +222,14 @@ async function runCheck( if (loadFailure !== undefined) { log.error(`meta deps check: the locked dependency set does not load: ${loadFailure}`); } + if (proof?.note !== undefined) log.info(dependencyOnlyNotice("meta deps check", proof.note)); } else { emitStructured( { dependencies: results.map((r) => ({ ...r, line: formatCheckLine(r) })), ...(failing.length > 0 ? { code: ERR_DEPENDENCY_UPSTREAM_DRIFT } : {}), ...(loadFailure !== undefined ? { loadError: loadFailure } : {}), + ...(proof?.note !== undefined ? { loadScope: proof.scope, loadNote: proof.note } : {}), }, fmt, ); diff --git a/server/typescript/packages/cli/src/lib/dependency-load.ts b/server/typescript/packages/cli/src/lib/dependency-load.ts new file mode 100644 index 0000000000..0f6822abcf --- /dev/null +++ b/server/typescript/packages/cli/src/lib/dependency-load.ts @@ -0,0 +1,134 @@ +// server/typescript/packages/cli/src/lib/dependency-load.ts +// +// FR-023 — the "does the synced dependency set load?" proof `meta deps sync` and +// `meta deps check` both end on (DESIGN §4.1's closing sentence). +// +// The Node `meta deps` commands are the only writers of the lock and the snapshot +// for EVERY port (the Phase 2 plan's decision 2), and they read nothing but +// `.metaobjects/config.json`. A Maven or C# project does not keep its own +// metadata where this toolchain looks (no neutral `sources`, the directory is +// named by the pom or the CLI), and it may use vocabulary only its own port +// registers. Loading the project's own files through THIS toolchain's source +// ladder and provider set therefore fails for reasons that say nothing about the +// dependency set, and it used to fail `sync` after it had already written. +// +// So the proof has two scopes. `full` is the whole collection, exactly as before. +// `dependencies-only` loads just the locked snapshots together (their +// `dep:/` ids, the ownership sets), which still catches a +// collision between two dependencies and an artifact that does not load, and says +// plainly that the port's own `verify` is the full check. Only the two causes +// above fall back; any other failure is a real one and is raised unchanged. +import { join } from "node:path"; +import { + DEFAULT_METAOBJECTS_DIR, + importedNodesOf, + importedPackagesOf, + loadConfig, + loadMemory, + readLock, + resolveCollection, + verifySnapshot, +} from "@metaobjectsdev/sdk"; +import { collectionLoadOptions } from "./collection-load-options.js"; + +/** `resolveCollection`'s code for "no `sources` declared and no default directory". */ +const ERR_COLLECTION_NOT_FOUND = "ERR_COLLECTION_NOT_FOUND"; + +/** The loader codes for vocabulary this toolchain does not register: a type, a subtype + * or (under a strict load) an attribute that only another port's providers declare. */ +const PORT_ONLY_VOCABULARY_CODES: ReadonlySet = new Set([ + "ERR_UNKNOWN_TYPE", + "ERR_UNKNOWN_SUBTYPE", + "ERR_UNKNOWN_ATTR", +]); + +export interface DependencyLoadProof { + /** What was loaded: the whole collection, or only the locked dependency snapshots. */ + readonly scope: "full" | "dependencies-only"; + /** Why the project's own metadata was not part of it; present exactly when `scope` is + * `dependencies-only`. */ + readonly note: string | undefined; +} + +/** A failed proof, tagged with the step that failed so each caller keeps its own wording + * for "does not resolve" and "does not load". */ +export class DependencyLoadFailure extends Error { + constructor( + readonly phase: "resolve" | "load", + override readonly cause: unknown, + ) { + super(cause instanceof Error ? cause.message : String(cause)); + this.name = "DependencyLoadFailure"; + } +} + +function codeOf(err: unknown): string | undefined { + const code = (err as { code?: unknown } | null)?.code; + return typeof code === "string" ? code : undefined; +} + +/** Load only the locked dependency snapshots, together. */ +async function loadDependenciesOnly(configDir: string): Promise { + let resolved; + try { + const cfg = await loadConfig(join(configDir, DEFAULT_METAOBJECTS_DIR)); + resolved = await verifySnapshot(configDir, cfg.dependencies, await readLock(configDir)); + } catch (err) { + throw new DependencyLoadFailure("resolve", err); + } + try { + await loadMemory(configDir, { + files: resolved.map((d) => d.artifactPath), + fileIds: new Map(resolved.map((d) => [d.artifactPath, d.sourceId])), + importedPackages: [...importedPackagesOf(resolved)].sort(), + importedNodes: importedNodesOf(resolved), + }); + } catch (err) { + throw new DependencyLoadFailure("load", err); + } +} + +/** + * Prove the dependency set in `configDir` resolves and loads. + * + * @throws {DependencyLoadFailure} when it does not. + */ +export async function proveDependencySetLoads(configDir: string): Promise { + let collection; + try { + collection = await resolveCollection(configDir); + } catch (err) { + if (codeOf(err) !== ERR_COLLECTION_NOT_FOUND) throw new DependencyLoadFailure("resolve", err); + await loadDependenciesOnly(configDir); + return { + scope: "dependencies-only", + note: + `the project's own metadata is not under a \`sources\` entry in ${DEFAULT_METAOBJECTS_DIR}/config.json ` + + "(a build-tool source directory is not read by this command), so only the locked dependencies " + + "were loaded together.", + }; + } + + try { + await loadMemory(collection.configDir, collectionLoadOptions(collection)); + return { scope: "full", note: undefined }; + } catch (err) { + const code = codeOf(err); + if (code === undefined || !PORT_ONLY_VOCABULARY_CODES.has(code)) { + throw new DependencyLoadFailure("load", err); + } + await loadDependenciesOnly(configDir); + return { + scope: "dependencies-only", + note: + "the project's own metadata uses vocabulary this toolchain does not register " + + `(${code}: ${err instanceof Error ? err.message : String(err)}), so only the locked ` + + "dependencies were loaded together.", + }; + } +} + +/** The line both commands print after a `dependencies-only` proof. */ +export function dependencyOnlyNotice(command: string, note: string): string { + return `${command}: ${note} Run the project's own \`verify\` for the full check.`; +} diff --git a/server/typescript/packages/cli/test/dependency-check-conformance.test.ts b/server/typescript/packages/cli/test/dependency-check-conformance.test.ts new file mode 100644 index 0000000000..79531e7a9e --- /dev/null +++ b/server/typescript/packages/cli/test/dependency-check-conformance.test.ts @@ -0,0 +1,67 @@ +// Runs the `expectCheck` arm of the shared dependency corpus (FR-023) against the TypeScript +// reference implementation of `meta deps check` / `verify --deps`: for each declared dependency, +// resolve its `path`, read and validate the manifest, hash the artifact, and compare with the +// lock — `current`, `drifted` or `unresolved`. The check is a pure function of the config, the +// lock and the publisher's directory, so no loader and no snapshot is involved. +// +// Every port that gains a check runs THIS arm over the same file +// (fixtures/dependency-conformance/, see its README); the resolution arm is run by +// `sdk/test/dependency-conformance.test.ts`. +import { describe, expect, test } from "bun:test"; +import { copyFile, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { DEFAULT_METAOBJECTS_DIR, LOCK_FILE, loadConfig } from "@metaobjectsdev/sdk"; +import { checkDependencies, readLockOrThrow } from "../src/lib/dependency-sync.js"; + +interface Case { + readonly name: string; + readonly tree: Record; + readonly treeFiles?: Record; + readonly config: unknown; + readonly lock?: unknown; + readonly resolveFrom?: string; + readonly expectCheck?: Record; +} + +const CORPUS_DIR = resolve(import.meta.dir, "../../../../../fixtures/dependency-conformance"); + +async function materialize(c: Case): Promise { + const root = await mkdtemp(join(tmpdir(), "mo-dep-check-")); + for (const [rel, content] of Object.entries(c.tree)) { + const abs = join(root, rel); + await mkdir(dirname(abs), { recursive: true }); + await writeFile(abs, content); + } + for (const [rel, corpusRel] of Object.entries(c.treeFiles ?? {})) { + const abs = join(root, rel); + await mkdir(dirname(abs), { recursive: true }); + await copyFile(join(CORPUS_DIR, corpusRel), abs); + } + const resolveDir = resolve(root, c.resolveFrom ?? "."); + const metaobjectsDir = join(resolveDir, DEFAULT_METAOBJECTS_DIR); + await mkdir(metaobjectsDir, { recursive: true }); + await writeFile(join(metaobjectsDir, "config.json"), JSON.stringify(c.config, null, 2)); + if (c.lock !== undefined) await writeFile(join(metaobjectsDir, LOCK_FILE), JSON.stringify(c.lock, null, 2)); + return resolveDir; +} + +const checkCases: Case[] = (JSON.parse(await readFile(join(CORPUS_DIR, "cases.json"), "utf8")).cases as Case[]).filter( + (c) => c.expectCheck !== undefined, +); + +describe("dependency conformance — the check arm", () => { + test("the corpus carries check cases (a silent zero-case run is a failed gate)", () => { + expect(checkCases.length).toBeGreaterThan(0); + }); + + for (const c of checkCases) { + test(c.name, async () => { + const dir = await materialize(c); + const cfg = await loadConfig(join(dir, DEFAULT_METAOBJECTS_DIR)); + const results = await checkDependencies(dir, cfg.dependencies, await readLockOrThrow(dir)); + const got = Object.fromEntries(results.map((r) => [r.name, r.status])); + expect(got).toEqual(c.expectCheck!); + }); + } +}); diff --git a/server/typescript/packages/cli/test/deps-sync.test.ts b/server/typescript/packages/cli/test/deps-sync.test.ts index 8075230b26..100e845d5d 100644 --- a/server/typescript/packages/cli/test/deps-sync.test.ts +++ b/server/typescript/packages/cli/test/deps-sync.test.ts @@ -629,6 +629,66 @@ describe("meta deps sync — a failed post-sync load leaves the previous state i }); }); +describe("meta deps sync — a project whose own metadata this toolchain does not read", () => { + /** A Maven-shaped consumer: the model lives under the build tool's resource directory + * and the neutral config declares no `sources`, so the source ladder finds nothing. */ + function setupMavenShaped(): { consumerRoot: string } { + const { consumerRoot } = setupProject(); + rmSync(join(consumerRoot, "metaobjects"), { recursive: true, force: true }); + const resources = join(consumerRoot, "src", "main", "resources", "metadata"); + mkdirSync(resources, { recursive: true }); + writeFileSync(join(resources, "meta.app.json"), APP, "utf8"); + return { consumerRoot }; + } + + test("a Maven-shaped layout syncs, exits 0, and says the port's own verify is the full check", async () => { + const { consumerRoot } = setupMavenShaped(); + + const exit = await run(["deps", "sync", "--format", "text", "--cwd", consumerRoot]); + expect(exit).toBe(0); + expect(existsSync(join(consumerRoot, ".metaobjects", "deps.lock.json"))).toBe(true); + expect(readFileSync(join(consumerRoot, ".metaobjects", "deps", DEP_NAME, ARTIFACT_BASENAME))).toEqual( + V1_BYTES, + ); + const output = [...out, ...err].join("\n"); + expect(output).toContain("synced"); + expect(output).toContain("only the locked dependencies were loaded together"); + expect(output).toContain("Run the project's own `verify` for the full check."); + expect(output).not.toContain("rolled back"); + }); + + test("a Maven-shaped layout then checks green", async () => { + const { consumerRoot } = setupMavenShaped(); + expect(await run(["deps", "sync", "--format", "text", "--cwd", consumerRoot])).toBe(0); + + out = []; + err = []; + expect(await run(["deps", "check", "--format", "text", "--cwd", consumerRoot])).toBe(0); + expect([...out, ...err].join("\n")).toContain("current"); + }); + + test("a model using vocabulary only another port registers syncs on its dependencies alone", async () => { + const { consumerRoot } = setupProject(); + writeFileSync( + join(consumerRoot, "metaobjects", "meta.portonly.json"), + JSON.stringify({ + "metadata.root": { + package: "app", + children: [{ "object.portonly": { name: "Gadget" } }], + }, + }), + "utf8", + ); + + const exit = await run(["deps", "sync", "--format", "text", "--cwd", consumerRoot]); + expect(exit).toBe(0); + expect(existsSync(join(consumerRoot, ".metaobjects", "deps.lock.json"))).toBe(true); + const output = [...out, ...err].join("\n"); + expect(output).toContain("uses vocabulary this toolchain does not register"); + expect(output).toContain("Run the project's own `verify` for the full check."); + }); +}); + describe("meta deps check — a snapshot that cannot load is not green", () => { test("hashes match the lock but the model no longer loads: exit 1 with the load error", async () => { const { consumerRoot } = setupProject(); diff --git a/server/typescript/packages/codegen-ts/test/shared-model-conformance.test.ts b/server/typescript/packages/codegen-ts/test/shared-model-conformance.test.ts new file mode 100644 index 0000000000..1beba82d42 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/shared-model-conformance.test.ts @@ -0,0 +1,120 @@ +// Runs the shared publish corpus (FR-023 §4.3) against the TypeScript reference implementation: +// a source tree plus `files` / `include` / `exclude` / `version` must produce EXACTLY the pinned +// artifact and manifest bytes, or be refused for the pinned reason. Every port's publisher runs +// the same file (fixtures/shared-model-conformance/, see its README). +// +// The manifest golden carries the token `@@METAMODEL_VERSION@@` where the manifest records the +// toolchain's `metamodelVersion`, so the goldens do not move with it. +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + ATTR_SUBTYPE_STRING, + composeRegistry, + coreProviders, + METAMODEL_VERSION, + type MetaDataTypeProvider, +} from "@metaobjectsdev/metadata"; +import { buildSharedModel, renderSharedModelFiles } from "../src/shared-model/build-shared-model.js"; + +const CORPUS_DIR = resolve(import.meta.dir, "../../../../../fixtures/shared-model-conformance"); +const METAMODEL_VERSION_TOKEN = "@@METAMODEL_VERSION@@"; + +interface Refusal { + readonly reason: "empty-selection" | "not-closed" | "non-core-vocabulary"; + readonly pairs?: readonly { referrer: string; target: string }[]; +} + +interface Case { + readonly name: string; + readonly input: { + readonly name: string; + readonly files: readonly string[]; + readonly include: readonly string[]; + readonly exclude?: readonly string[]; + readonly version: string; + readonly providers?: readonly string[]; + }; + readonly expectArtifact?: string; + readonly expectManifest?: string; + readonly expectRefusal?: Refusal; +} + +/** The corpus's one named test provider: a single common string attr no core provider ships, + * standing in for "vocabulary a consumer's provider adds". */ +const TEST_PROVIDERS: Record = { + "secret-handling": { + id: "test-secret-handling", + dependencies: ["metaobjects-core-types"], + registerTypes(registry) { + registry.registerCommonAttrs([ + { name: "secretHandling", valueType: ATTR_SUBTYPE_STRING, required: false, description: "test-only attr." }, + ]); + }, + }, +}; + +const cases: Case[] = JSON.parse(readFileSync(resolve(CORPUS_DIR, "cases.json"), "utf8")).cases; + +function read(rel: string): string { + return readFileSync(resolve(CORPUS_DIR, rel), "utf8"); +} + +/** Which pinned refusal a thrown error is. `buildSharedModel` throws plain errors, so the + * reason is read off the message. */ +function classify(err: Error): Refusal { + if (/selected no nodes/.test(err.message)) return { reason: "empty-selection" }; + if (/selection is not closed/.test(err.message)) { + const pairs = [...err.message.matchAll(/^ {2}(\S+) → (\S+)$/gm)].map((m) => ({ + referrer: m[1]!, + target: m[2]!, + })); + return { reason: "not-closed", pairs }; + } + if (/needs a provider this toolchain does not ship/.test(err.message)) return { reason: "non-core-vocabulary" }; + throw err; +} + +async function publish(c: Case) { + const providers = (c.input.providers ?? []).map((p) => TEST_PROVIDERS[p]!); + const model = await buildSharedModel({ + name: c.input.name, + files: c.input.files.map((f) => resolve(CORPUS_DIR, f)), + include: c.input.include, + ...(c.input.exclude !== undefined ? { exclude: c.input.exclude } : {}), + ...(providers.length > 0 ? { registry: composeRegistry([...coreProviders, ...providers]) } : {}), + }); + return renderSharedModelFiles(model, c.input.version); +} + +describe("shared-model conformance — the publish corpus", () => { + test("corpus is non-empty (a silent zero-case run is a failed gate)", () => { + expect(cases.length).toBeGreaterThan(0); + expect(cases.some((c) => c.expectRefusal !== undefined)).toBe(true); + expect(cases.some((c) => c.expectArtifact !== undefined)).toBe(true); + }); + + for (const c of cases) { + test(c.name, async () => { + if (c.expectRefusal !== undefined) { + const err = await publish(c).then( + () => undefined, + (e: unknown) => e as Error, + ); + if (err === undefined) throw new Error(`corpus case "${c.name}" published; it should be refused`); + const got = classify(err); + expect(got.reason).toBe(c.expectRefusal.reason); + if (c.expectRefusal.pairs !== undefined) expect(got.pairs).toEqual(c.expectRefusal.pairs); + return; + } + if (c.expectArtifact === undefined || c.expectManifest === undefined) { + throw new Error(`corpus case "${c.name}" has neither expectRefusal nor expectArtifact + expectManifest`); + } + const { artifact, manifest } = await publish(c); + expect(artifact.path).toBe(`${c.input.name}.metaobjects.json`); + expect(artifact.content).toBe(read(c.expectArtifact)); + expect(manifest.path).toBe("metaobjects.pkg.json"); + expect(manifest.content).toBe(read(c.expectManifest).replaceAll(METAMODEL_VERSION_TOKEN, METAMODEL_VERSION)); + }); + } +}); diff --git a/server/typescript/packages/sdk/test/dependency-conformance.test.ts b/server/typescript/packages/sdk/test/dependency-conformance.test.ts index 774234a27f..578be944f4 100644 --- a/server/typescript/packages/sdk/test/dependency-conformance.test.ts +++ b/server/typescript/packages/sdk/test/dependency-conformance.test.ts @@ -58,6 +58,9 @@ interface Case { readonly expectLoadError?: string; readonly expectErrorFiles?: readonly string[]; readonly expectError?: string; + /** The check arm (`expectCheck`): run by `cli/test/dependency-check-conformance.test.ts`, + * because the check lives in the CLI's `dependency-sync` module. */ + readonly expectCheck?: Record; } const CORPUS_DIR = resolve(import.meta.dir, "../../../../../fixtures/dependency-conformance"); @@ -93,7 +96,10 @@ async function materialize(c: Case): Promise<{ root: string; resolveDir: string return { root, resolveDir }; } -const cases: Case[] = JSON.parse(await readFile(CORPUS, "utf8")).cases; +// The resolution arm only: a case carrying `expectCheck` is the check arm's, not this one's. +const cases: Case[] = (JSON.parse(await readFile(CORPUS, "utf8")).cases as Case[]).filter( + (c) => c.expectCheck === undefined, +); describe("dependency conformance", () => { test("corpus is non-empty (a silent zero-case run is a failed gate)", () => { From 7e81547ea1efaa7e948915186bffbd1ddee113f7 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 06:23:53 -0400 Subject: [PATCH 2/7] fix(cli): keep the dependency-only load proof narrow and its failures labelled Fall back only for ERR_UNKNOWN_TYPE/SUBTYPE and only in a project with a config; label resolve failures in deps check; cite the tracking issue, not a plan. --- docs/CONFORMANCE.md | 2 +- docs/features/metadata-dependencies.md | 4 ++-- .../test_dependency_conformance.py | 2 +- .../test_shared_model_conformance.py | 2 +- .../packages/cli/src/commands/deps.ts | 5 ++++- .../packages/cli/src/lib/dependency-load.ts | 21 +++++++++++-------- 6 files changed, 21 insertions(+), 15 deletions(-) diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index a745e1fc1d..1a1a0ff34a 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -459,7 +459,7 @@ above: one committed `cases.json`, no per-port fixture, no ledger. The corpus ha `unresolved` (a missing path, a bad manifest, a hash mismatch, or not yet in the lock). Run by TypeScript only, in `cli/test/dependency-check-conformance.test.ts`; Python has no `deps check` yet and filters these cases out. The arm exists so that each port's check is - written against the same edge cases (the Phase 2 plan's risk 4). + written against the same edge cases (Phase 2, #482). Java, Kotlin and C# have no runner — Phase 1a is TypeScript + Python only; those three ports arrive in Phase 2. diff --git a/docs/features/metadata-dependencies.md b/docs/features/metadata-dependencies.md index 2d8be83c2f..aed03913da 100644 --- a/docs/features/metadata-dependencies.md +++ b/docs/features/metadata-dependencies.md @@ -455,9 +455,9 @@ whose own metadata is in a build-tool directory (`src/main/resources/metadata`, metadata directory) declares no neutral `sources`, and a project may use vocabulary only its own port registers. Neither case can be loaded by the Node toolchain, and neither is a fault in the dependencies. In those two cases (and only those: no `sources` and no default directory, or an -`ERR_UNKNOWN_TYPE` / `ERR_UNKNOWN_SUBTYPE` / `ERR_UNKNOWN_ATTR` from the project's own files) the +`ERR_UNKNOWN_TYPE` / `ERR_UNKNOWN_SUBTYPE` from the project's own files) the proof loads the locked snapshots together instead, prints which it did, and says that the port's -own `verify` is the full check. Any other load failure still fails the command and, for `sync`, +own `verify` is the full check. A misspelt type in a project this toolchain does read takes the same path, which is why the fallback is announced. Any other load failure still fails the command and, for `sync`, rolls the lock and snapshot back. **Packaged transports: Maven and NuGet (design only, not built).** The `path` transport needs the diff --git a/server/python/tests/conformance/test_dependency_conformance.py b/server/python/tests/conformance/test_dependency_conformance.py index e0a7fa72a8..68d17734b3 100644 --- a/server/python/tests/conformance/test_dependency_conformance.py +++ b/server/python/tests/conformance/test_dependency_conformance.py @@ -27,7 +27,7 @@ ) _CORPUS = _CORPUS_DIR / "cases.json" # The resolution arm only: a case carrying `expectCheck` belongs to the check arm, which -# Python does not ship yet (it has no `deps check`; the Phase 2 plan's S2 adds it). +# Python does not ship yet (it has no `deps check` yet; Phase 2, #482, adds it). _CASES = [c for c in json.loads(_CORPUS.read_text())["cases"] if "expectCheck" not in c] def test_corpus_is_non_empty() -> None: diff --git a/server/python/tests/conformance/test_shared_model_conformance.py b/server/python/tests/conformance/test_shared_model_conformance.py index cfab7a3274..2feff2fddc 100644 --- a/server/python/tests/conformance/test_shared_model_conformance.py +++ b/server/python/tests/conformance/test_shared_model_conformance.py @@ -1,6 +1,6 @@ """Runs the SERIALIZER arm of the shared publish corpus (FR-023 §4.3) in Python. -Python ships no publisher yet (the Phase 2 plan defers it until a Python-only publisher +Python ships no publisher yet (Phase 2, #482, defers it until a Python-only publisher appears), so it does not run selection, the closure check or the refusals. What it does own is the document's bytes: load a case's ``files``, take exactly the nodes the pinned manifest lists, and ``serialize_shared_document`` must reproduce the pinned artifact byte for byte, and diff --git a/server/typescript/packages/cli/src/commands/deps.ts b/server/typescript/packages/cli/src/commands/deps.ts index 49e00bc198..6716dc7721 100644 --- a/server/typescript/packages/cli/src/commands/deps.ts +++ b/server/typescript/packages/cli/src/commands/deps.ts @@ -207,7 +207,10 @@ async function runCheck( try { proof = await proveDependencySetLoads(configDir); } catch (err) { - loadFailure = describeError(err instanceof DependencyLoadFailure ? err.cause : err); + loadFailure = + err instanceof DependencyLoadFailure && err.phase === "resolve" + ? `cannot be resolved: ${describeError(err.cause)}` + : describeError(err instanceof DependencyLoadFailure ? err.cause : err); } } diff --git a/server/typescript/packages/cli/src/lib/dependency-load.ts b/server/typescript/packages/cli/src/lib/dependency-load.ts index 0f6822abcf..70cefa99dc 100644 --- a/server/typescript/packages/cli/src/lib/dependency-load.ts +++ b/server/typescript/packages/cli/src/lib/dependency-load.ts @@ -4,7 +4,7 @@ // `meta deps check` both end on (DESIGN §4.1's closing sentence). // // The Node `meta deps` commands are the only writers of the lock and the snapshot -// for EVERY port (the Phase 2 plan's decision 2), and they read nothing but +// for EVERY port (Phase 2, #482), and they read nothing but // `.metaobjects/config.json`. A Maven or C# project does not keep its own // metadata where this toolchain looks (no neutral `sources`, the directory is // named by the pom or the CLI), and it may use vocabulary only its own port @@ -18,6 +18,7 @@ // collision between two dependencies and an artifact that does not load, and says // plainly that the port's own `verify` is the full check. Only the two causes // above fall back; any other failure is a real one and is raised unchanged. +import { existsSync } from "node:fs"; import { join } from "node:path"; import { DEFAULT_METAOBJECTS_DIR, @@ -34,13 +35,11 @@ import { collectionLoadOptions } from "./collection-load-options.js"; /** `resolveCollection`'s code for "no `sources` declared and no default directory". */ const ERR_COLLECTION_NOT_FOUND = "ERR_COLLECTION_NOT_FOUND"; -/** The loader codes for vocabulary this toolchain does not register: a type, a subtype - * or (under a strict load) an attribute that only another port's providers declare. */ -const PORT_ONLY_VOCABULARY_CODES: ReadonlySet = new Set([ - "ERR_UNKNOWN_TYPE", - "ERR_UNKNOWN_SUBTYPE", - "ERR_UNKNOWN_ATTR", -]); +/** The loader codes for vocabulary this toolchain does not register: a type or a subtype + * that only another port's providers declare. (The proof's load is lenient, so an unknown + * attribute loads and never reaches here.) A misspelt type in a project this toolchain + * does read takes the same path, which is why the fallback is announced, not silent. */ +const PORT_ONLY_VOCABULARY_CODES: ReadonlySet = new Set(["ERR_UNKNOWN_TYPE", "ERR_UNKNOWN_SUBTYPE"]); export interface DependencyLoadProof { /** What was loaded: the whole collection, or only the locked dependency snapshots. */ @@ -98,7 +97,11 @@ export async function proveDependencySetLoads(configDir: string): Promise Date: Wed, 28 Oct 2026 06:27:19 -0400 Subject: [PATCH 3/7] refactor(cli): type the dependency-load proof as a union and reuse the verified snapshot --- .../packages/cli/src/commands/deps.ts | 8 +-- .../packages/cli/src/lib/dependency-load.ts | 42 +++++++------ .../packages/cli/test/deps-sync.test.ts | 63 ++++++------------- 3 files changed, 44 insertions(+), 69 deletions(-) diff --git a/server/typescript/packages/cli/src/commands/deps.ts b/server/typescript/packages/cli/src/commands/deps.ts index 6716dc7721..c924fc9762 100644 --- a/server/typescript/packages/cli/src/commands/deps.ts +++ b/server/typescript/packages/cli/src/commands/deps.ts @@ -93,12 +93,12 @@ async function runSync(configDir: string, specs: readonly DependencySpec[], flag } else { for (const r of applied.report) log.info(r.line); } - if (proof?.note !== undefined) log.info(dependencyOnlyNotice("meta deps sync", proof.note)); + if (proof?.scope === "dependencies-only") log.info(dependencyOnlyNotice("meta deps sync", proof.note)); } else { emitStructured( { warnings: plan.warnings, - ...(proof?.note !== undefined ? { loadScope: proof.scope, loadNote: proof.note } : {}), + ...(proof?.scope === "dependencies-only" ? { loadScope: proof.scope, loadNote: proof.note } : {}), dependencies: applied.report.map((r) => ({ name: r.name, action: r.action, message: r.line })), }, fmt, @@ -225,14 +225,14 @@ async function runCheck( if (loadFailure !== undefined) { log.error(`meta deps check: the locked dependency set does not load: ${loadFailure}`); } - if (proof?.note !== undefined) log.info(dependencyOnlyNotice("meta deps check", proof.note)); + if (proof?.scope === "dependencies-only") log.info(dependencyOnlyNotice("meta deps check", proof.note)); } else { emitStructured( { dependencies: results.map((r) => ({ ...r, line: formatCheckLine(r) })), ...(failing.length > 0 ? { code: ERR_DEPENDENCY_UPSTREAM_DRIFT } : {}), ...(loadFailure !== undefined ? { loadError: loadFailure } : {}), - ...(proof?.note !== undefined ? { loadScope: proof.scope, loadNote: proof.note } : {}), + ...(proof?.scope === "dependencies-only" ? { loadScope: proof.scope, loadNote: proof.note } : {}), }, fmt, ); diff --git a/server/typescript/packages/cli/src/lib/dependency-load.ts b/server/typescript/packages/cli/src/lib/dependency-load.ts index 70cefa99dc..40ab0c587b 100644 --- a/server/typescript/packages/cli/src/lib/dependency-load.ts +++ b/server/typescript/packages/cli/src/lib/dependency-load.ts @@ -29,6 +29,7 @@ import { readLock, resolveCollection, verifySnapshot, + type ResolvedDependency, } from "@metaobjectsdev/sdk"; import { collectionLoadOptions } from "./collection-load-options.js"; @@ -41,13 +42,11 @@ const ERR_COLLECTION_NOT_FOUND = "ERR_COLLECTION_NOT_FOUND"; * does read takes the same path, which is why the fallback is announced, not silent. */ const PORT_ONLY_VOCABULARY_CODES: ReadonlySet = new Set(["ERR_UNKNOWN_TYPE", "ERR_UNKNOWN_SUBTYPE"]); -export interface DependencyLoadProof { - /** What was loaded: the whole collection, or only the locked dependency snapshots. */ - readonly scope: "full" | "dependencies-only"; - /** Why the project's own metadata was not part of it; present exactly when `scope` is - * `dependencies-only`. */ - readonly note: string | undefined; -} +/** What the proof loaded. `dependencies-only` carries the note saying why the project's + * own metadata was left out; the note is typed present exactly in that case. */ +export type DependencyLoadProof = + | { readonly scope: "full" } + | { readonly scope: "dependencies-only"; readonly note: string }; /** A failed proof, tagged with the step that failed so each caller keeps its own wording * for "does not resolve" and "does not load". */ @@ -66,15 +65,8 @@ function codeOf(err: unknown): string | undefined { return typeof code === "string" ? code : undefined; } -/** Load only the locked dependency snapshots, together. */ -async function loadDependenciesOnly(configDir: string): Promise { - let resolved; - try { - const cfg = await loadConfig(join(configDir, DEFAULT_METAOBJECTS_DIR)); - resolved = await verifySnapshot(configDir, cfg.dependencies, await readLock(configDir)); - } catch (err) { - throw new DependencyLoadFailure("resolve", err); - } +/** Load only the given locked dependency snapshots, together. */ +async function loadSnapshotsOnly(configDir: string, resolved: readonly ResolvedDependency[]): Promise { try { await loadMemory(configDir, { files: resolved.map((d) => d.artifactPath), @@ -99,10 +91,19 @@ export async function proveDependencySetLoads(configDir: string): Promise — the name filter (FR-023 Phase 1a Task 14, fi ) + "\n"; const SECOND_HASH = sha256Integrity(SECOND_ARTIFACT_CONTENT); - /** A manifest with fully overridable `name`/`artifact`/`packages`/`nodes` — - * unlike the top-level `manifestJson`, which is hardcoded to `acme-common` - * in package `acme::common`. Scoped to this describe block only: the - * colliding-manifest scenario below needs `acme-common`'s OWN manifest to - * (incorrectly) declare `acme::extra`'s package/node, which the shared - * helper cannot express. */ - function genericManifest(opts: { - name: string; - artifact: string; - version: string; - integrity: string; - nodes: string[]; - packages: string[]; - }): string { - return JSON.stringify( - { - schema_version: 1, - name: opts.name, - version: opts.version, - metamodelVersion: METAMODEL_VERSION, - artifact: opts.artifact, - integrity: opts.integrity, - packages: opts.packages, - nodes: opts.nodes, - }, - null, - 2, - ); - } - function setupTwoDependencyProject(): { consumerRoot: string; commonPublisherDir: string; @@ -412,7 +385,7 @@ describe("meta deps sync — the name filter (FR-023 Phase 1a Task 14, fi writeFileSync(join(extraPublisherDir, SECOND_ARTIFACT_BASENAME), SECOND_ARTIFACT_CONTENT, "utf8"); writeFileSync( join(extraPublisherDir, MANIFEST_BASENAME), - genericManifest({ + manifestJson({ name: SECOND_NAME, artifact: SECOND_ARTIFACT_BASENAME, version: "1.0.0", @@ -477,7 +450,7 @@ describe("meta deps sync — the name filter (FR-023 Phase 1a Task 14, fi writeFileSync(join(commonPublisherDir, ARTIFACT_BASENAME), SECOND_ARTIFACT_CONTENT, "utf8"); writeFileSync( join(commonPublisherDir, MANIFEST_BASENAME), - genericManifest({ + manifestJson({ name: DEP_NAME, artifact: ARTIFACT_BASENAME, version: "1.2.0", From 0bffb4ccc67175dc411bb5f838f8cec67d8e0fa4 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 06:31:40 -0400 Subject: [PATCH 4/7] no-mistakes(review): apply reviewer inline fixes --- docs/features/metadata-dependencies.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/metadata-dependencies.md b/docs/features/metadata-dependencies.md index aed03913da..636fda19b3 100644 --- a/docs/features/metadata-dependencies.md +++ b/docs/features/metadata-dependencies.md @@ -468,7 +468,7 @@ carrying the same `metaobjects.pkg.json` and artifact pair that `sharedModelFile single-writer ruling it is Node `meta deps sync` that reads the pair out of the JAR or the package and writes the same snapshot and lock entry a `path` sync writes, so everything downstream (`check`, load, scope, the corpus) is unchanged; nothing in a port's own build resolves a -dependency. This generalizes the resources-JAR pattern described below, and it is a later slice +dependency. This generalizes the resources-JAR pattern described above, and it is a later slice than consume and check. A JVM *classpath read with no committed snapshot* (the legacy behaviour) stays a separate, deferred option. From d9c633ad17bcbb5ed6229e4b6d75cb295ea2ed4e Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 06:35:29 -0400 Subject: [PATCH 5/7] no-mistakes(review): Fall back only when every load error is own port-only vocabulary --- .../packages/cli/src/lib/dependency-load.ts | 35 +++++++++++---- .../packages/cli/test/deps-sync.test.ts | 44 +++++++++++++++++++ server/typescript/packages/sdk/src/index.ts | 2 +- server/typescript/packages/sdk/src/memory.ts | 14 ++++++ 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/server/typescript/packages/cli/src/lib/dependency-load.ts b/server/typescript/packages/cli/src/lib/dependency-load.ts index 40ab0c587b..3cdf2488fc 100644 --- a/server/typescript/packages/cli/src/lib/dependency-load.ts +++ b/server/typescript/packages/cli/src/lib/dependency-load.ts @@ -17,18 +17,21 @@ // `dep:/` ids, the ownership sets), which still catches a // collision between two dependencies and an artifact that does not load, and says // plainly that the port's own `verify` is the full check. Only the two causes -// above fall back; any other failure is a real one and is raised unchanged. +// above fall back, and the second only when EVERY load error is one of them in the +// project's own files; any other failure is a real one and is raised unchanged. import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { DEFAULT_METAOBJECTS_DIR, importedNodesOf, importedPackagesOf, loadConfig, + loadErrors, loadMemory, readLock, resolveCollection, verifySnapshot, + type Collection, type ResolvedDependency, } from "@metaobjectsdev/sdk"; import { collectionLoadOptions } from "./collection-load-options.js"; @@ -65,6 +68,20 @@ function codeOf(err: unknown): string | undefined { return typeof code === "string" ? code : undefined; } +/** The source ids the loader names the project's own files by (a file loads under its + * basename unless the collection gives it an id). */ +function ownSourceIds(collection: Collection): ReadonlySet { + return new Set(collection.ownFiles.map((f) => collection.fileIds.get(f) ?? basename(f))); +} + +/** True when the error is port-only vocabulary and every file it names is the project's own. */ +function isOwnPortOnlyVocabulary(err: unknown, ownIds: ReadonlySet): boolean { + const code = codeOf(err); + if (code === undefined || !PORT_ONLY_VOCABULARY_CODES.has(code)) return false; + const files = (err as { source?: { files?: unknown } }).source?.files; + return Array.isArray(files) && files.length > 0 && files.every((f) => ownIds.has(f as string)); +} + /** Load only the given locked dependency snapshots, together. */ async function loadSnapshotsOnly(configDir: string, resolved: readonly ResolvedDependency[]): Promise { try { @@ -113,21 +130,23 @@ export async function proveDependencySetLoads(configDir: string): Promise !isOwnPortOnlyVocabulary(e, ownIds)); + if (real !== undefined) throw new DependencyLoadFailure("load", real); // The snapshot was already verified by resolveCollection; reuse it. await loadSnapshotsOnly(collection.configDir, collection.dependencies); return { scope: "dependencies-only", note: "the project's own metadata uses vocabulary this toolchain does not register " + - `(${code}: ${err instanceof Error ? err.message : String(err)}), so only the locked ` + + `(${codeOf(err)}: ${err instanceof Error ? err.message : String(err)}), so only the locked ` + "dependencies were loaded together.", }; } diff --git a/server/typescript/packages/cli/test/deps-sync.test.ts b/server/typescript/packages/cli/test/deps-sync.test.ts index dc905ba2b3..412686731b 100644 --- a/server/typescript/packages/cli/test/deps-sync.test.ts +++ b/server/typescript/packages/cli/test/deps-sync.test.ts @@ -660,6 +660,50 @@ describe("meta deps sync — a project whose own metadata this toolchain does no expect(output).toContain("uses vocabulary this toolchain does not register"); expect(output).toContain("Run the project's own `verify` for the full check."); }); + + test("port-only vocabulary does not hide a broken extends onto a dependency node: exit 1, nothing kept", async () => { + const { consumerRoot } = setupProject(); + writeFileSync( + join(consumerRoot, "metaobjects", "meta.portonly.json"), + JSON.stringify({ + "metadata.root": { + package: "app", + children: [{ "object.portonly": { name: "Gadget" } }], + }, + }), + "utf8", + ); + writeFileSync( + join(consumerRoot, "metaobjects", "meta.invoice.json"), + JSON.stringify({ + "metadata.root": { + package: "app", + children: [ + { + "object.entity": { + name: "Invoice", + extends: "acme::common::Removed", + children: [ + { "source.rdb": { "@table": "invoices" } }, + { "field.long": { name: "id" } }, + { "identity.primary": { name: "pk", "@fields": ["id"] } }, + ], + }, + }, + ], + }, + }), + "utf8", + ); + + const exit = await run(["deps", "sync", "--format", "text", "--cwd", consumerRoot]); + expect(exit).toBe(1); + expect(existsSync(join(consumerRoot, ".metaobjects", "deps.lock.json"))).toBe(false); + expect(existsSync(join(consumerRoot, ".metaobjects", "deps", DEP_NAME))).toBe(false); + const output = [...out, ...err].join("\n"); + expect(output).toContain("acme::common::Removed"); + expect(output).not.toContain("only the locked dependencies were loaded together"); + }); }); describe("meta deps check — a snapshot that cannot load is not green", () => { diff --git a/server/typescript/packages/sdk/src/index.ts b/server/typescript/packages/sdk/src/index.ts index 38cb6c45ab..6a8373cd30 100644 --- a/server/typescript/packages/sdk/src/index.ts +++ b/server/typescript/packages/sdk/src/index.ts @@ -99,7 +99,7 @@ export type { ForgeType, ForgeAttr } from "./forge-types.js"; // Memory loader — read a project's resolved metadata into a MetaData tree. // Where those files come from is `resolveCollection`'s decision (below), which // `loadMemory` calls when the caller supplies no explicit file set. -export { loadMemory, unknownAttributeFindings, defaultLoadMemoryProviders } from "./memory.js"; +export { loadErrors, loadMemory, unknownAttributeFindings, defaultLoadMemoryProviders } from "./memory.js"; export type { LoadMemoryOptions } from "./memory.js"; // Default project layout — the DEFAULT value of `sources` (applied by diff --git a/server/typescript/packages/sdk/src/memory.ts b/server/typescript/packages/sdk/src/memory.ts index 3ecbca9b31..8facb118ed 100644 --- a/server/typescript/packages/sdk/src/memory.ts +++ b/server/typescript/packages/sdk/src/memory.ts @@ -193,6 +193,20 @@ export async function loadMemory( return result.root; } +/** + * Every error the same load as {@link loadMemory} records, in loader order, or none when + * it loads — for a caller that must judge the whole set, where `loadMemory` throws only + * the first. The ownership and library refusals `loadMemory` raises after a clean load + * are not included. + */ +export async function loadErrors( + repoRoot: string, + options?: LoadMemoryOptions, +): Promise { + const { result } = await loadCollectionRaw(repoRoot, options, options?.strict === true); + return result.errors; +} + /** The ADR-0009 code a strict load raises for an authored attribute no provider declares. */ const ERR_UNKNOWN_ATTR = "ERR_UNKNOWN_ATTR"; From e2f8319e59eb77bba839521d0704a23da059bcc2 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 08:32:30 -0400 Subject: [PATCH 6/7] no-mistakes(document): Updated fallback-trigger prose in metadata-dependencies.md; other docs already current. --- docs/features/metadata-dependencies.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/features/metadata-dependencies.md b/docs/features/metadata-dependencies.md index 636fda19b3..80c6577b01 100644 --- a/docs/features/metadata-dependencies.md +++ b/docs/features/metadata-dependencies.md @@ -454,10 +454,12 @@ only `.metaobjects/config.json`, and finish with a proof that the dependency set whose own metadata is in a build-tool directory (`src/main/resources/metadata`, a C# project's metadata directory) declares no neutral `sources`, and a project may use vocabulary only its own port registers. Neither case can be loaded by the Node toolchain, and neither is a fault in the -dependencies. In those two cases (and only those: no `sources` and no default directory, or an -`ERR_UNKNOWN_TYPE` / `ERR_UNKNOWN_SUBTYPE` from the project's own files) the +dependencies. In those two cases (and only those: no `sources` and no default directory, or +a load whose every error is an `ERR_UNKNOWN_TYPE` / `ERR_UNKNOWN_SUBTYPE`) the proof loads the locked snapshots together instead, prints which it did, and says that the port's -own `verify` is the full check. A misspelt type in a project this toolchain does read takes the same path, which is why the fallback is announced. Any other load failure still fails the command and, for `sync`, +own `verify` is the full check. A misspelt type in a project this toolchain does read takes the +same path, which is why the fallback is announced. A load that also has any other error, such as +an unresolved `extends` onto a removed dependency node, still fails the command and, for `sync`, rolls the lock and snapshot back. **Packaged transports: Maven and NuGet (design only, not built).** The `path` transport needs the From 509c2aca47dcd34362e14d2922857ae960050c3b Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 08:36:11 -0400 Subject: [PATCH 7/7] chore(site): regenerate site payload for 32 corpora --- examples/showcase/site-payload.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index da87a7f9a6..5adb5fd9d5 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -8,7 +8,7 @@ }, "counts": { "fixtures": 413, - "corpora": 31, + "corpora": 32, "baseTypes": 17 }, "snippets": {