diff --git a/CHANGELOG.md b/CHANGELOG.md index 077baeb9b6..4536e3cf4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ here.** ### Added +- **Java and Kotlin consume a shared model (FR-023 Phase 2).** A project that declares + `dependencies` in `.metaobjects/config.json` loads its committed `.metaobjects/deps/` snapshot + ahead of its own files, in the Maven plugin (every rung of the source ladder) and the Java + loader. The lock is checked as in TypeScript and Python (`ERR_DEPENDENCY_SNAPSHOT_STALE`, + `_METAMODEL_INCOMPATIBLE`, `_NODE_COLLISION`), a local node declared into a dependency's package + fails with `ERR_DEPENDENCY_PACKAGE_NOT_OWNED`, and the Java and Kotlin generators emit nothing for + an imported object unless `scope.include` names its package. Runs all 24 resolution cases of + `fixtures/dependency-conformance/` (the 11 check cases pin the Node `meta deps check`); Kotlin loads through the same loader and adds a codegen test. + Spring runtime auto-load and `verify` drift for dependencies are later slices. - **Java, Kotlin and C# can compile and match `scope` patterns.** `com.metaobjects.config.Scope` (Java; Kotlin inherits it) and `MetaObjects.Config.Scope` (C#) port the scope-pattern grammar from TypeScript (`compilePattern` / `compileScope` / `matchesScope`), and both run the shared diff --git a/README.md b/README.md index be2e979eb3..e6258ac528 100644 --- a/README.md +++ b/README.md @@ -261,8 +261,8 @@ sixth ships two libraries at their own stability labels: `meta eject `. See [`docs/features/libraries.md`](docs/features/libraries.md). **Sharing a model across your own projects** *(the TypeScript toolchain publishes; -TypeScript and Python projects consume, over a `path` dependency such as a sibling -checkout or submodule; Java, Kotlin and C# arrive in Phase 2)* — one project publishes +TypeScript, Python, Java and Kotlin projects consume, over a `path` dependency such as a sibling +checkout or submodule; C# arrives in Phase 2)* — one project publishes part of its model (a common `Customer`, an audited base, a set of enums) with `sharedModelFile()`, and another declares it in `dependencies`, syncs it into a committed, hash-locked snapshot and builds on it with `extends` and references. When the diff --git a/agent-context/skills/metaobjects-codegen/references/dependencies-and-shared-models.md b/agent-context/skills/metaobjects-codegen/references/dependencies-and-shared-models.md index 1417e31e87..a65e90479f 100644 --- a/agent-context/skills/metaobjects-codegen/references/dependencies-and-shared-models.md +++ b/agent-context/skills/metaobjects-codegen/references/dependencies-and-shared-models.md @@ -38,8 +38,8 @@ artifact is a contract whose bytes a cross-port corpus pins and whose hash consumers verify, so everything a consumer checks stays in the package, in the two functions the copy calls (`buildSharedModel`, `renderSharedModelFiles`). Do not replace those calls: an unmodified copy publishes the same bytes as the packaged -generator. Only TypeScript can run it in Phase 1a. **Phase 1a is TypeScript and Python -only, full stop** — TypeScript and Python are the only two ports that can -*consume* a dependency at all today, and only the TypeScript toolchain can -*publish* one; Java, Kotlin, and C# do not read `dependencies` yet (Phase 2). +generator. Only TypeScript can run it in Phase 1a. TypeScript, Python, Java and +Kotlin (through Java's loader and Maven plugin) can *consume* a dependency, and +only the TypeScript toolchain can *publish* one; C# does not read +`dependencies` yet (Phase 2). Full detail: `docs/features/metadata-dependencies.md`. diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 1a1a0ff34a..76313a8017 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -46,7 +46,7 @@ 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/) | 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/dependency-conformance/`](../fixtures/dependency-conformance/) | 35 cases (24 resolution + 11 check) | ✓ (reference implementation; both arms) | ✓ (resolution arm, all 24; no check, so the 11 check cases are not run) | inherits via Java | — (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) | — | — | — | — | @@ -450,19 +450,18 @@ above: one committed `cases.json`, no per-port fixture, no ledger. The corpus ha - **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 + implementation), Python and Java all 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. + for it. Kotlin inherits the Java run through the same loader and Maven plugin. - **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 + by TypeScript only, in `cli/test/dependency-check-conformance.test.ts`; Python and Java have no + `deps check` yet and filter these cases out. The arm exists so that each port's check is 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. +C# has no runner yet; it arrives in a later Phase 2 slice. ### `fixtures/shared-model-conformance/` (11 cases) diff --git a/docs/features/cli.md b/docs/features/cli.md index 7cc095725b..aded5baa1e 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). 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) | +| **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, Python and Java then LOAD that snapshot at every rung of their own source ladder (Kotlin loads through Java's loader and Maven plugin); C# doesn'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 80c6577b01..2589b196b4 100644 --- a/docs/features/metadata-dependencies.md +++ b/docs/features/metadata-dependencies.md @@ -107,8 +107,8 @@ owned packages — with no resolution at all; it just reads ## Every dependency-aware port loads the committed snapshot, never the live path -Once synced, TypeScript's and Python's collection resolvers — the two ports that -implement dependencies in Phase 1a — load dependency artifacts from the committed +Once synced, TypeScript's, Python's and Java's collection resolvers (Kotlin loads through +Java's) load dependency artifacts from the committed snapshot **first**, then your own project files, offline, with no transport involved. At load time it checks the snapshot against the lock and refuses (`ERR_DEPENDENCY_SNAPSHOT_STALE`) when they disagree — a missing lock for a declared @@ -242,8 +242,10 @@ inScope(fqn) = !imported(fqn) || explicitlyIncluded(packageOf(fqn), scope.includ ``` So `scope.include` narrows a Python project's *own* codegen output not at all — it -only ever widens which imported package is let back in. Do not read the TypeScript -formula above as describing both ports. +only ever widens which imported package is let back in. **Java and Kotlin follow Python** +(`DependencySet.inScope`, read by `GeneratorUtil`'s selection points and `RestSurfaceGate`): +the import-exclusion conjunct only, because those generators have never scoped the project's +own objects either. Do not read the TypeScript formula above as describing every port. `migrate`, `verify --db`, and offline generate compose the schema-side twin, which also removes an excluded import from the *expected* schema before drift is computed @@ -390,15 +392,16 @@ publishes the same bytes as the packaged generator, and a test has a consumer sy verify what an ejected copy published. See [own-your-codegen](own-your-codegen.md#shared-model-you-own-the-inputs-the-package-owns-the-artifact). -**Only TypeScript publishes in Phase 1a — and only TypeScript and Python consume.** -TypeScript and Python are the only two ports that can *consume* a dependency at all -today (load its snapshot, extend it, overlay it) — Java, Kotlin, and C# don't read -`dependencies` yet (Phase 2). Of those two, only the TypeScript toolchain can -*publish*: it alone can generate the artifact a publisher ships. A Python project -that wants to publish a shared model runs a TypeScript `sharedModelFile()` -generator against its own metadata the same way any TypeScript consumer would; a -Java, C#, or Kotlin project cannot yet participate as either a publisher or a -consumer. +**Only TypeScript publishes in Phase 1a — TypeScript, Python, Java and Kotlin consume.** +TypeScript, Python, Java and Kotlin can *consume* a dependency (load its snapshot, extend +it, overlay it); C# doesn't read `dependencies` yet (Phase 2). Java reads it at every rung +of the Maven plugin's source ladder (``/``, the neutral config, the default +directory), and Kotlin inherits that through the same plugin and loader, so a Spring +or Kotlin build consumes a shared model with no extra configuration. Only the TypeScript +toolchain can *publish*: it alone can generate the artifact a publisher ships. A Python, +Java or Kotlin project that wants to publish a shared model runs a TypeScript +`sharedModelFile()` generator against its own metadata the same way any TypeScript consumer +would; a C# project cannot yet participate as either a publisher or a consumer. ## Vendoring vs. depending @@ -432,10 +435,10 @@ 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 +Tracked in [#482](https://github.com/metaobjectsdev/metaobjects/issues/482). Today TypeScript, +Python, Java and Kotlin consume a dependency (Java and Kotlin shipped in slice S3); C# ignores 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. @@ -495,17 +498,20 @@ but **not** built in Phase 1a — do not assume any of the following exists: - **A runtime `ObjectManager` scope predicate.** Nothing at runtime refuses to serve an imported entity — "what's imported" is a load-time/codegen concept today, not a runtime one. -- **Java, Kotlin, and C# as dependency consumers or publishers** — Phase 1a is - TypeScript + Python only; the other three ports arrive in Phase 2. +- **C# as a dependency consumer, and Java, Kotlin and C# as publishers** — Java and + Kotlin consume from Phase 2 slice S3; C# arrives in a later slice. Spring apps do not yet + auto-load the snapshot at runtime (S4). ## Verified by - [`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. 11 pin the upstream check (`current`, `drifted`, `unresolved`), run by TypeScript; - Python has no check yet. Three artifacts under `fixtures/dependency-conformance/artifacts/` are + resolution-time error above, run by TypeScript (the reference implementation), + Python and Java (Kotlin loads through the Java loader and Maven plugin, so it inherits the + corpus run and adds a codegen test that an imported entity emits nothing). 11 pin the upstream + check (`current`, `drifted`, `unresolved`), run by TypeScript; Python and Java have 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 24 resolution cases identically to TypeScript's, with no exemptions — the two overlay cases that need a view child as incidental content diff --git a/docs/features/metadata-sources.md b/docs/features/metadata-sources.md index c4754e9839..f6ea3abdb3 100644 --- a/docs/features/metadata-sources.md +++ b/docs/features/metadata-sources.md @@ -57,8 +57,9 @@ sources.** It answers a different question than the ladder above — not "where my metadata live" but "what does my metadata build on that lives elsewhere" — so a TypeScript project using a declared `sources` list and a Python project using its own `metadata:` key both get their declared dependencies resolved the same way, on -top of whichever rung supplied their own files. **TypeScript and Python only, in -Phase 1a** — Java, Kotlin, and C# don't read `dependencies` yet (Phase 2). See +top of whichever rung supplied their own files. **TypeScript, Python, Java and Kotlin** +(Kotlin through Java's loader and Maven plugin) — C# doesn't read `dependencies` yet +(Phase 2). See [`metadata-dependencies.md`](metadata-dependencies.md). A config file that exists but is malformed is an error at its own rung; it never diff --git a/docs/ports/java.md b/docs/ports/java.md index 563466ac1d..b7ccb63af8 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -172,6 +172,20 @@ Drop metadata under `src/main/metaobjects/`, for example `src/main/metaobjects/m }} ``` +### Consuming a shared model (`dependencies`) + +A project that declares `dependencies` in `.metaobjects/config.json` loads the committed +snapshot under `.metaobjects/deps/` before its own files, at every rung of the plugin's source +ladder (``/`` in the pom, the neutral config's `sources`, the default +directory). `meta deps sync` (Node) is the only writer of the snapshot and `deps.lock.json`; +the Maven build reads them offline and refuses a stale or hand-edited snapshot with +`ERR_DEPENDENCY_SNAPSHOT_STALE`. Your own entities can `extends` and reference the imported +ones; the generators emit nothing for an imported object unless `scope.include` names its +package, and a new top-level node declared into a package a dependency owns fails with +`ERR_DEPENDENCY_PACKAGE_NOT_OWNED`. Kotlin builds inherit all of this. See +[`metadata-dependencies.md`](../features/metadata-dependencies.md). Spring runtime +auto-load of the snapshot and `verify` drift for dependencies are separate, later slices. + ### Custom providers (optional) Java uses **SPI auto-discovery** for type providers — drop your provider diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md index a60159907b..668c7a468d 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -268,6 +268,14 @@ A requirement that becomes live adds a failing member (a red test, no compile br **Nothing else to hand over.** Unlike the Java `routes`/`dto`/`repository` output, no Kotlin generator's output imports helper runtime. The generated controller declares its filter parser, constraint mapping and patch handling inline, per file, and the rest imports only core: render and extract, the loader, and `metadata-ktx`. So an ejected Kotlin generator's output depends on nothing you do not own beyond the core. `EjectRuntimeRoundTripTest` in the Maven plugin checks this against every Kotlin generator's real output. `com.metaobjects.generator.kotlin.runtime.M2mJoinResolver` is a helper you may call from hand-written traversal code. No generated file imports it, so eject does not copy it; copy it yourself if you use it and want to own it. +### Consuming a shared model (`dependencies`) + +Kotlin loads through the Java loader and the same Maven plugin, so a `dependencies` entry in +`.metaobjects/config.json` works exactly as described in the [Java port +doc](java.md#consuming-a-shared-model-dependencies): the committed snapshot loads first, your own +entities can extend and reference it, and no Kotlin generator emits a file for an imported +object (a `codegen-kotlin` test pins that) unless `scope.include` names its package. + ### Declarative template-codegen (`TemplateScopeGenerator`) The 17 generators above are a starting point, not the ceiling. When you need a shape diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md index 1417e31e87..a65e90479f 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md @@ -38,8 +38,8 @@ artifact is a contract whose bytes a cross-port corpus pins and whose hash consumers verify, so everything a consumer checks stays in the package, in the two functions the copy calls (`buildSharedModel`, `renderSharedModelFiles`). Do not replace those calls: an unmodified copy publishes the same bytes as the packaged -generator. Only TypeScript can run it in Phase 1a. **Phase 1a is TypeScript and Python -only, full stop** — TypeScript and Python are the only two ports that can -*consume* a dependency at all today, and only the TypeScript toolchain can -*publish* one; Java, Kotlin, and C# do not read `dependencies` yet (Phase 2). +generator. Only TypeScript can run it in Phase 1a. TypeScript, Python, Java and +Kotlin (through Java's loader and Maven plugin) can *consume* a dependency, and +only the TypeScript toolchain can *publish* one; C# does not read +`dependencies` yet (Phase 2). Full detail: `docs/features/metadata-dependencies.md`. diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md index 1417e31e87..a65e90479f 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md @@ -38,8 +38,8 @@ artifact is a contract whose bytes a cross-port corpus pins and whose hash consumers verify, so everything a consumer checks stays in the package, in the two functions the copy calls (`buildSharedModel`, `renderSharedModelFiles`). Do not replace those calls: an unmodified copy publishes the same bytes as the packaged -generator. Only TypeScript can run it in Phase 1a. **Phase 1a is TypeScript and Python -only, full stop** — TypeScript and Python are the only two ports that can -*consume* a dependency at all today, and only the TypeScript toolchain can -*publish* one; Java, Kotlin, and C# do not read `dependencies` yet (Phase 2). +generator. Only TypeScript can run it in Phase 1a. TypeScript, Python, Java and +Kotlin (through Java's loader and Maven plugin) can *consume* a dependency, and +only the TypeScript toolchain can *publish* one; C# does not read +`dependencies` yet (Phase 2). Full detail: `docs/features/metadata-dependencies.md`. diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md index 1417e31e87..a65e90479f 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md @@ -38,8 +38,8 @@ artifact is a contract whose bytes a cross-port corpus pins and whose hash consumers verify, so everything a consumer checks stays in the package, in the two functions the copy calls (`buildSharedModel`, `renderSharedModelFiles`). Do not replace those calls: an unmodified copy publishes the same bytes as the packaged -generator. Only TypeScript can run it in Phase 1a. **Phase 1a is TypeScript and Python -only, full stop** — TypeScript and Python are the only two ports that can -*consume* a dependency at all today, and only the TypeScript toolchain can -*publish* one; Java, Kotlin, and C# do not read `dependencies` yet (Phase 2). +generator. Only TypeScript can run it in Phase 1a. TypeScript, Python, Java and +Kotlin (through Java's loader and Maven plugin) can *consume* a dependency, and +only the TypeScript toolchain can *publish* one; C# does not read +`dependencies` yet (Phase 2). Full detail: `docs/features/metadata-dependencies.md`. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md index 1417e31e87..a65e90479f 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md @@ -38,8 +38,8 @@ artifact is a contract whose bytes a cross-port corpus pins and whose hash consumers verify, so everything a consumer checks stays in the package, in the two functions the copy calls (`buildSharedModel`, `renderSharedModelFiles`). Do not replace those calls: an unmodified copy publishes the same bytes as the packaged -generator. Only TypeScript can run it in Phase 1a. **Phase 1a is TypeScript and Python -only, full stop** — TypeScript and Python are the only two ports that can -*consume* a dependency at all today, and only the TypeScript toolchain can -*publish* one; Java, Kotlin, and C# do not read `dependencies` yet (Phase 2). +generator. Only TypeScript can run it in Phase 1a. TypeScript, Python, Java and +Kotlin (through Java's loader and Maven plugin) can *consume* a dependency, and +only the TypeScript toolchain can *publish* one; C# does not read +`dependencies` yet (Phase 2). Full detail: `docs/features/metadata-dependencies.md`. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md index 1417e31e87..a65e90479f 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/dependencies-and-shared-models.md @@ -38,8 +38,8 @@ artifact is a contract whose bytes a cross-port corpus pins and whose hash consumers verify, so everything a consumer checks stays in the package, in the two functions the copy calls (`buildSharedModel`, `renderSharedModelFiles`). Do not replace those calls: an unmodified copy publishes the same bytes as the packaged -generator. Only TypeScript can run it in Phase 1a. **Phase 1a is TypeScript and Python -only, full stop** — TypeScript and Python are the only two ports that can -*consume* a dependency at all today, and only the TypeScript toolchain can -*publish* one; Java, Kotlin, and C# do not read `dependencies` yet (Phase 2). +generator. Only TypeScript can run it in Phase 1a. TypeScript, Python, Java and +Kotlin (through Java's loader and Maven plugin) can *consume* a dependency, and +only the TypeScript toolchain can *publish* one; C# does not read +`dependencies` yet (Phase 2). Full detail: `docs/features/metadata-dependencies.md`. diff --git a/fixtures/dependency-conformance/README.md b/fixtures/dependency-conformance/README.md index 2d6cf370a9..ca50ff310c 100644 --- a/fixtures/dependency-conformance/README.md +++ b/fixtures/dependency-conformance/README.md @@ -199,8 +199,15 @@ corpus expectation to update. `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. +- **Java** — resolution, load-time failure, lock/snapshot integrity + (`DependencyConformanceTest`). Not the check arm: Java has no `deps check`, so its runner + skips the `expectCheck` cases. `refuseUnownedPackages` runs after the loader's own errors, + as the ordering note above requires. Java's `inScope` is the import-exclusion conjunct only, + like Python's. +- **Kotlin** — inherits via Java: it loads through the same loader and Maven plugin. The + Kotlin generators are covered by a codegen test that an imported entity emits nothing. +- **C#** — Phase 2 (does not read `dependencies` yet). Runs the resolution arm when it consumes, + and the check arm when it gains a check. ## Reference implementation diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/ModelWalk.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/ModelWalk.java index 907fbc5789..b0fa5b1cf9 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/ModelWalk.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/ModelWalk.java @@ -45,7 +45,7 @@ private ModelWalk() {} * contribute fields to what {@code extends} them. */ public static List concreteObjects(MetaDataLoader loader) { List out = new ArrayList<>(); - for (MetaObject o : loader.getMetaObjects()) { + for (MetaObject o : com.metaobjects.generator.util.GeneratorUtil.ownMetaObjects(loader)) { if (!isAbstract(o)) out.add(o); } return out; diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/template/TemplateScopeGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/template/TemplateScopeGenerator.java index ac90389c4f..06ed7635c2 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/template/TemplateScopeGenerator.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/template/TemplateScopeGenerator.java @@ -58,7 +58,7 @@ public void execute(MetaDataLoader loader) { Provider provider = new FilesystemProvider(templatesRoot); File outDir = getOutputDir(); - List objects = loader.getMetaObjects(); + List objects = com.metaobjects.generator.util.GeneratorUtil.ownMetaObjects(loader); List files = TemplateGenerator.generate( scope, template, diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratorUtil.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratorUtil.java index 32989aae1d..56e74ffb59 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratorUtil.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratorUtil.java @@ -15,7 +15,36 @@ public class GeneratorUtil { public static Collection getFilteredMetaData(MetaDataLoader loader, MetaDataFilters filters ) { - return filterMetaData( loader.getChildren(), MetaData.class, filters ); + List own = new ArrayList<>(); + for (MetaData md : loader.getChildren()) { + if (!isImported(md)) own.add(md); + } + return filterMetaData( own, MetaData.class, filters ); + } + + /** + * FR-023 — true for an object a metadata DEPENDENCY owns: its package is one the project's + * dependency snapshot exports and {@code scope.include} does not name. Such an object stays in + * the loaded model (the project's own nodes {@code extends} and reference it), but it is + * another project's to generate: a generator emits nothing for it, exactly as the TypeScript + * and Python selection points do. False for anything that is not an object, and for every tree + * loaded without dependencies. + */ + public static boolean isImported(MetaData md) { + if (!(md instanceof MetaObject)) return false; + MetaDataLoader loader = md.getLoader(); + if (loader == null) return false; + com.metaobjects.config.DependencySet deps = loader.getRoot().getDependencySet(); + return !deps.isEmpty() && !deps.inScope(md.getName()); + } + + /** {@code loader.getMetaObjects()} without the objects a dependency owns ({@link #isImported}). */ + public static List ownMetaObjects(MetaDataLoader loader) { + List out = new ArrayList<>(); + for (MetaObject mo : loader.getMetaObjects()) { + if (!isImported(mo)) out.add(mo); + } + return out; } public static Collection getFilteredMetaData(MetaDataLoader loader, Class clazz, MetaDataFilters filters ) { @@ -25,7 +54,7 @@ public static Collection getFilteredMetaData(MetaDataLoa // through this overload. List generatable = new ArrayList<>(); for (T md : loader.getMetaData( clazz )) { - if (!isReport(md)) generatable.add(md); + if (!isReport(md) && !isImported(md)) generatable.add(md); } return filterMetaData( generatable, clazz, filters ); } diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/RestSurfaceGate.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/RestSurfaceGate.java index bc2c9f1e1c..82232465c9 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/RestSurfaceGate.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/RestSurfaceGate.java @@ -159,6 +159,9 @@ public static boolean isServedReport(MetaObject obj) { * stops rather than emitting a row it cannot bind */ public static MetaObject restShapeOf(MetaObject obj) { + // FR-023: an object a metadata dependency owns is that project's to serve. Every caller is + // an emission loop, so this one test keeps the whole REST tier off an imported object. + if (GeneratorUtil.isImported(obj)) return null; if (obj == null || !MetaObject.SUBTYPE_REPORT.equals(obj.getSubType())) return obj; return isServedReport(obj) ? ReportReadModel.of(obj) : null; } diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt index 692e16ce49..84c6c8e7ae 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt @@ -5,6 +5,7 @@ package com.metaobjects.generator.kotlin // `mvn metaobjects:eject` and its own package is renamed — an explicit import, not // same-package bare-name resolution, is what keeps the ejected copy compiling. import com.metaobjects.config.WireFormat +import com.metaobjects.generator.util.GeneratorUtil import com.metaobjects.generator.kotlin.Fr019SharedEnum import com.metaobjects.generator.kotlin.KotlinEnumEmitter import com.metaobjects.generator.kotlin.KotlinGenUtil @@ -146,7 +147,7 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { */ private fun emitNetJsonSupport(loader: MetaDataLoader, outRoot: Path) { val packages = linkedSetOf() - for (obj in loader.metaObjects) { + for (obj in GeneratorUtil.ownMetaObjects(loader)) { // A report's row carries no deserializer (it is never bound from a request), so // a report is not counted here. if (obj.subType !in EMITTED_SUBTYPES) continue diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt index b407ce10db..93da980132 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt @@ -5,6 +5,7 @@ package com.metaobjects.generator.kotlin // `mvn metaobjects:eject` and its own package is renamed — an explicit import, not // same-package bare-name resolution, is what keeps the ejected copy compiling. import com.metaobjects.config.WireFormat +import com.metaobjects.generator.util.GeneratorUtil import com.metaobjects.generator.kotlin.ExposedApi import com.metaobjects.generator.kotlin.ExposedImports import com.metaobjects.generator.kotlin.Fr019SharedEnum @@ -175,7 +176,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase>() - for (entity in loader.metaObjects) { + for (entity in GeneratorUtil.ownMetaObjects(loader)) { if (entity.subType != MetaObject.SUBTYPE_ENTITY) continue // FR-017 TPH: a subtype shares its base's single table, so its INHERITED to-many // composition is already covered by the base's inverse FK — emitting one per subtype @@ -2204,7 +2205,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase> { val acc = linkedMapOf>() - for (entity in loader.metaObjects) { + for (entity in GeneratorUtil.ownMetaObjects(loader)) { if (entity.subType != MetaObject.SUBTYPE_ENTITY) continue // ADR-0039: identity.reference children are inheritable — RESOLVE via // getIdentities(true); entity.children (own-only) would miss inherited FK refs. diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNamesGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNamesGenerator.kt index 713b2e426d..4e7a84d258 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNamesGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNamesGenerator.kt @@ -99,7 +99,7 @@ open class KotlinNamesGenerator : val strategy = com.metaobjects.database.PhysicalNaming.of(loader.root).columns() // Pass 1 — every object that participates in the database (#248). val emitted = mutableSetOf() - for (entity in loader.metaObjects) { + for (entity in GeneratorUtil.ownMetaObjects(loader)) { // FR-044: a served report (a view-backed one) gets a names artifact built from its // READ MODEL, so the derived columns have constants for hand-written SQL over the // view to use; its Exposed table still binds by literal. A report that is not @@ -118,7 +118,7 @@ open class KotlinNamesGenerator : // what keeps #248 intact. A sourceless object nothing persistable extends — an // `object.value`, say — is not reached, so it acquires no artifact and no phantom // participation. - for (entity in loader.metaObjects) { + for (entity in GeneratorUtil.ownMetaObjects(loader)) { if (entity.name !in emitted) continue var sup = KotlinGenUtil.namesArtifactSuperOf(entity) while (sup != null) { diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt index 8591caaa2f..0fa26f99b0 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt @@ -5,6 +5,7 @@ package com.metaobjects.generator.kotlin // `mvn metaobjects:eject` and its own package is renamed — an explicit import, not // same-package bare-name resolution, is what keeps the ejected copy compiling. import com.metaobjects.generator.kotlin.ExposedApi +import com.metaobjects.generator.util.GeneratorUtil import com.metaobjects.generator.kotlin.ExposedImports import com.metaobjects.generator.kotlin.Fr019SharedEnum import com.metaobjects.generator.kotlin.KotlinGenUtil @@ -92,7 +93,7 @@ open class KotlinRelationsGenerator : MultiFileDirectGeneratorBase() parseArgs() val outRoot = Paths.get(outDir.absolutePath) - for (entity in loader.metaObjects) { + for (entity in GeneratorUtil.ownMetaObjects(loader)) { if (entity.subType != MetaObject.SUBTYPE_ENTITY) continue // Abstract entities are inheritance scaffolding — never emit relation helpers. if (KotlinGenUtil.isAbstractEntity(entity)) continue diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRepositoryGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRepositoryGenerator.kt index 56b7852e51..1c7918f78c 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRepositoryGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRepositoryGenerator.kt @@ -5,6 +5,7 @@ package com.metaobjects.generator.kotlin // `mvn metaobjects:eject` and its own package is renamed — an explicit import, not // same-package bare-name resolution, is what keeps the ejected copy compiling. import com.metaobjects.generator.kotlin.ExposedApi +import com.metaobjects.generator.util.GeneratorUtil import com.metaobjects.generator.kotlin.ExposedImports import com.metaobjects.generator.kotlin.KotlinGenUtil import com.metaobjects.generator.kotlin.KotlinNaming @@ -82,7 +83,7 @@ open class KotlinRepositoryGenerator : MultiFileDirectGeneratorBase( parseArgs() val outRoot = Paths.get(outDir.absolutePath) - for (entity in loader.metaObjects) { + for (entity in GeneratorUtil.ownMetaObjects(loader)) { if (entity.subType != MetaObject.SUBTYPE_ENTITY) continue if (KotlinGenUtil.isAbstractEntity(entity)) continue // TODO(FR-035 Phase 2): a TPH subtype folds into its base's table — the base's diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinStoredProcGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinStoredProcGenerator.kt index 04a6862891..d0930db219 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinStoredProcGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinStoredProcGenerator.kt @@ -5,6 +5,7 @@ package com.metaobjects.generator.kotlin // `mvn metaobjects:eject` and its own package is renamed — an explicit import, not // same-package bare-name resolution, is what keeps the ejected copy compiling. import com.metaobjects.generator.kotlin.ExposedApi +import com.metaobjects.generator.util.GeneratorUtil import com.metaobjects.generator.kotlin.ExposedImports import com.metaobjects.generator.kotlin.KotlinGenUtil import com.metaobjects.generator.kotlin.KotlinNaming @@ -114,7 +115,7 @@ open class KotlinStoredProcGenerator : MultiFileDirectGeneratorBase( } val outRoot = Paths.get(outDir.absolutePath) - for (entity in loader.metaObjects) { + for (entity in GeneratorUtil.ownMetaObjects(loader)) { // FR-024: a stored-proc binding is gated on the SOURCE @kind, not the object // subtype — a proc-backed object.projection (the post-B4b spelling) emits a // callable just like the legacy proc-backed entity did. diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinValidatorGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinValidatorGenerator.kt index 6f81aa9008..90241aade0 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinValidatorGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinValidatorGenerator.kt @@ -5,6 +5,7 @@ package com.metaobjects.generator.kotlin // `mvn metaobjects:eject` and its own package is renamed — an explicit import, not // same-package bare-name resolution, is what keeps the ejected copy compiling. import com.metaobjects.generator.kotlin.ExposedApi +import com.metaobjects.generator.util.GeneratorUtil import com.metaobjects.generator.kotlin.ExposedImports import com.metaobjects.generator.kotlin.KotlinGenUtil import com.metaobjects.generator.kotlin.KotlinTphPlan @@ -51,7 +52,7 @@ open class KotlinValidatorGenerator : MultiFileDirectGeneratorBase() ?: throw GeneratorException("KotlinValidatorGenerator (\"validator\"): packageName is required") val outRoot = Paths.get(outDir.absolutePath) - val entries = loader.metaObjects + val entries = GeneratorUtil.ownMetaObjects(loader) .filter { it.subType == MetaObject.SUBTYPE_ENTITY } // Abstract entities are inheritance scaffolding — never register a validator for them. .filter { !KotlinGenUtil.isAbstractEntity(it) } diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinDependencyImportExclusionTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinDependencyImportExclusionTest.kt new file mode 100644 index 0000000000..b888c8161b --- /dev/null +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinDependencyImportExclusionTest.kt @@ -0,0 +1,96 @@ +package com.metaobjects.generator.kotlin + +import com.google.gson.JsonParser +import com.metaobjects.config.SourceResolver +import com.metaobjects.generator.Generator +import com.metaobjects.generator.util.GeneratedFileWriter +import com.metaobjects.loader.FileSource +import com.metaobjects.loader.LoaderOptions +import com.metaobjects.loader.MetaDataLoader +import com.metaobjects.loader.MetaDataSource +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * FR-023 — Kotlin inherits dependency consumption from the Java loader and Maven plugin; what it + * owns is its generators. An object a metadata dependency owns loads (the project's own nodes + * extend and reference it) but is another project's to generate, so no Kotlin generator may write a + * file or a line for it. The project and its snapshot are the shared + * `fixtures/dependency-conformance` corpus case the other ports load. + */ +class KotlinDependencyImportExclusionTest { + + private val caseName = "a-dependency-adds-its-artifact-to-the-resolved-set" + + private fun corpus(): Path { + var dir: Path? = Paths.get("").toAbsolutePath() + while (dir != null && !Files.isDirectory(dir.resolve("fixtures"))) dir = dir.parent + return dir!!.resolve("fixtures/dependency-conformance") + } + + private fun write(p: Path, bytes: ByteArray) { + Files.createDirectories(p.parent) + Files.write(p, bytes) + } + + @Test fun `no Kotlin generator emits for an imported node`() { + val project = Files.createTempDirectory("kdep-project-") + val outDir = Files.createTempDirectory("kdep-out-") + try { + val case = JsonParser.parseString(Files.readString(corpus().resolve("cases.json"))) + .asJsonObject.getAsJsonArray("cases") + .map { it.asJsonObject }.first { it["name"].asString == caseName } + for ((k, v) in case.getAsJsonObject("tree").entrySet()) { + write(project.resolve(k), v.asString.toByteArray()) + } + for ((k, v) in case.getAsJsonObject("treeFiles").entrySet()) { + write(project.resolve(k), Files.readAllBytes(corpus().resolve(v.asString))) + } + write(project.resolve(".metaobjects/config.json"), case.getAsJsonObject("config").toString().toByteArray()) + write(project.resolve(".metaobjects/deps.lock.json"), case.getAsJsonObject("lock").toString().toByteArray()) + + val collection = SourceResolver.resolveCollectionWithDependencies(project) + val deps = collection.dependencies() + val sources = ArrayList(deps.sources()) + collection.ownFiles().forEach { sources.add(FileSource(it)) } + + val loader = MetaDataLoader(LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "kdep") + loader.init() + loader.setDependencies(deps) + loader.load(sources) + assertTrue(loader.metaObjects.any { it.name == "acme::common::Customer" }, + "the imported model is part of the loaded graph") + + val generators: List = listOf( + KotlinEntityGenerator(), KotlinExposedTableGenerator(), KotlinRelationsGenerator(), + KotlinRepositoryGenerator(), KotlinNamesGenerator(), KotlinFilterAllowlistGenerator(), + KotlinSpringControllerGenerator(), KotlinReportExportGenerator(), + KotlinStoredProcGenerator(), KotlinExtractorGenerator(), + ) + GeneratedFileWriter.beginRun().use { run -> + for (gen in generators) { + run.attributeTo(gen.javaClass.simpleName) + gen.setArgs(mapOf("outputDir" to outDir.toString(), "packageName" to "demo")).execute(loader) + } + } + + val files = Files.walk(outDir).use { s -> s.filter { Files.isRegularFile(it) }.toList() } + assertTrue(files.any { it.fileName.toString().startsWith("Order") }, + "the project's own Order must still generate: $files") + for (f in files) { + val rel = outDir.relativize(f).toString() + assertFalse("acme" in rel || "Customer" in rel || "Address" in rel, + "a file was emitted for an imported node: $rel") + val body = Files.readString(f) + assertFalse("Customer" in body || "acme::common" in body, "$rel mentions an imported node") + } + } finally { + project.toFile().deleteRecursively() + outDir.toFile().deleteRecursively() + } + } +} diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java index 48d7abd9fb..1e7021ce1d 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java @@ -164,7 +164,7 @@ private static boolean isProvided(EnumField decl) { */ public static List collectSharedEnums(MetaDataLoader loader) { Map out = new LinkedHashMap<>(); - for (MetaObject owner : loader.getMetaObjects()) { + for (MetaObject owner : GeneratorUtil.ownMetaObjects(loader)) { if (GeneratorUtil.isAbstract(owner)) continue; String subType = owner.getSubType(); if (!MetaObject.SUBTYPE_ENTITY.equals(subType) diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/LlmTraceHelperGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/LlmTraceHelperGenerator.java index f83c47caed..c4ef5bc5c1 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/LlmTraceHelperGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/LlmTraceHelperGenerator.java @@ -133,7 +133,7 @@ public void execute(MetaDataLoader loader) { // Stable name order — deterministic emission, matching the other generators. java.util.List entities = new java.util.ArrayList<>(); - for (MetaObject mo : loader.getMetaObjects()) { + for (MetaObject mo : GeneratorUtil.ownMetaObjects(loader)) { if (!appliesTo(mo)) continue; entities.add(mo); } diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNamesGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNamesGenerator.java index a4729117ab..fc13918616 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNamesGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNamesGenerator.java @@ -131,7 +131,7 @@ public void execute(MetaDataLoader loader) { String strategy = com.metaobjects.database.PhysicalNaming.of(loader.getRoot()).columns(); // Pass 1 — every object that participates in the database (#248). Set emitted = new HashSet<>(); - for (MetaObject entity : loader.getMetaObjects()) { + for (MetaObject entity : GeneratorUtil.ownMetaObjects(loader)) { // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3). if (GeneratorUtil.isReport(entity)) continue; if (emit(entity, outRoot, strategy, false)) emitted.add(entity.getName()); diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringValueObjectGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringValueObjectGenerator.java index 39dcde2507..f88164d0bb 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringValueObjectGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringValueObjectGenerator.java @@ -114,7 +114,7 @@ public void execute(MetaDataLoader loader) { */ static List valueShapes(MetaDataLoader loader) { List out = new ArrayList<>(); - for (MetaObject obj : loader.getMetaObjects()) { + for (MetaObject obj : GeneratorUtil.ownMetaObjects(loader)) { if (GeneratorUtil.isAbstract(obj)) continue; // #210 — value, or SOURCELESS projection (ADR-0039: a source anywhere in the // extends chain binds it to a backing store, and then its type is the entity diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/DependencyImportExclusionTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/DependencyImportExclusionTest.java new file mode 100644 index 0000000000..107fcaca80 --- /dev/null +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/DependencyImportExclusionTest.java @@ -0,0 +1,129 @@ +package com.metaobjects.generator.spring; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.metaobjects.config.DependencySet; +import com.metaobjects.generator.Generator; +import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator; +import com.metaobjects.generator.direct.object.javacode.JavaObjectCodeGenerator; +import com.metaobjects.generator.util.GeneratedFileWriter; +import com.metaobjects.loader.FileSource; +import com.metaobjects.loader.LoaderOptions; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.loader.MetaDataSource; +import com.metaobjects.registry.SharedRegistryTestBase; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * FR-023 — a node a metadata DEPENDENCY owns is loaded (the project's own nodes extend and + * reference it) but is another project's to generate. This runs every Spring-tier generator over + * a project that imports a shared model through the committed snapshot and asserts none of them + * writes a file, or a line, for an imported node — the Java counterpart of the TypeScript and + * Python selection points. The project and its snapshot come from the shared + * {@code fixtures/dependency-conformance} corpus, so the model is the one the other ports load. + */ +public class DependencyImportExclusionTest extends SharedRegistryTestBase { + + private static final String CASE = "a-dependency-adds-its-artifact-to-the-resolved-set"; + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private static Path corpus() { + Path dir = Paths.get("").toAbsolutePath(); + while (dir != null && !Files.isDirectory(dir.resolve("fixtures"))) dir = dir.getParent(); + return dir.resolve("fixtures/dependency-conformance"); + } + + /** Materialize the corpus case as a project directory; returns the project root. */ + private Path materialize() throws Exception { + JsonObject all = JsonParser.parseString( + Files.readString(corpus().resolve("cases.json"), StandardCharsets.UTF_8)).getAsJsonObject(); + JsonObject c = null; + for (JsonElement el : all.getAsJsonArray("cases")) { + if (CASE.equals(el.getAsJsonObject().get("name").getAsString())) c = el.getAsJsonObject(); + } + assertTrue("corpus case missing: " + CASE, c != null); + Path root = tempFolder.newFolder("project").toPath(); + for (Map.Entry e : c.getAsJsonObject("tree").entrySet()) { + write(root.resolve(e.getKey()), e.getValue().getAsString().getBytes(StandardCharsets.UTF_8)); + } + for (Map.Entry e : c.getAsJsonObject("treeFiles").entrySet()) { + write(root.resolve(e.getKey()), Files.readAllBytes(corpus().resolve(e.getValue().getAsString()))); + } + write(root.resolve(".metaobjects/config.json"), c.getAsJsonObject("config").toString().getBytes(StandardCharsets.UTF_8)); + write(root.resolve(".metaobjects/deps.lock.json"), c.getAsJsonObject("lock").toString().getBytes(StandardCharsets.UTF_8)); + return root; + } + + private static void write(Path p, byte[] bytes) throws Exception { + Files.createDirectories(p.getParent()); + Files.write(p, bytes); + } + + @Test + public void noGeneratorEmitsForAnImportedNode() throws Exception { + Path root = materialize(); + com.metaobjects.config.SourceResolver.ResolvedCollection collection = + com.metaobjects.config.SourceResolver.resolveCollectionWithDependencies(root); + DependencySet deps = collection.dependencies(); + List sources = new ArrayList<>(deps.sources()); + for (Path own : collection.ownFiles()) sources.add(new FileSource(own)); + + MetaDataLoader loader = new MetaDataLoader( + LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "dep-exclusion"); + loader.init(); + loader.setDependencies(deps); + loader.load(sources); + assertTrue("the imported model is part of the loaded graph", + loader.getMetaObjects().stream().anyMatch(o -> o.getName().equals("acme::common::Customer"))); + + Path outDir = tempFolder.newFolder("gen").toPath(); + List generators = List.of( + new JavaObjectCodeGenerator(), new ExtractorCodeGenerator(), + new SpringValueObjectGenerator(), new SpringDtoGenerator(), + new SpringRepositoryGenerator(), new SpringFilterAllowlistGenerator(), + new SpringNamesGenerator(), new SpringControllerGenerator(), + new SpringReportExportGenerator(), new LlmTraceHelperGenerator(), + new SpringOutputParserGenerator(), new SpringOutputPromptGenerator(), + new SpringRenderHelperGenerator()); + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + for (Generator gen : generators) { + run.attributeTo(gen.getClass().getSimpleName()); + gen.setArgs(Map.of("outputDir", outDir.toString(), "type", "class", "flavor", "pojoAware", + "templateRoot", SpringTestFixtures.findCorpusRoot().resolve("canonical/prompts").toString())) + .execute(loader); + } + } + + List files; + try (Stream s = Files.walk(outDir)) { + files = s.filter(Files::isRegularFile).collect(Collectors.toList()); + } + assertTrue("the project's own Order must still generate: " + files, + files.stream().anyMatch(p -> p.getFileName().toString().startsWith("Order"))); + for (Path f : files) { + String rel = outDir.relativize(f).toString(); + assertFalse("a file was emitted for an imported node: " + rel, + rel.contains("acme") || rel.contains("Customer") || rel.contains("Address")); + String body = Files.readString(f, StandardCharsets.UTF_8); + assertFalse(rel + " mentions an imported node", body.contains("Customer") || body.contains("acme::common")); + } + } +} diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java index b2e401b7ff..7551d86102 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java @@ -687,8 +687,14 @@ protected MetaDataLoader createLoader(ClassLoader projectClassLoader) { } } + // FR-023: the project's metadata dependencies. Read at EVERY rung of the ladder above + // (the pom's / only changes where the project's own FILES come from), + // from the directory holding its `.metaobjects/` folder; the committed snapshot loads first. + com.metaobjects.config.DependencySet dependencies = com.metaobjects.config.DependencySet.resolve( + resolveProjectConfigDir(srcDir).orElse(getProjectBaseDir().toPath().toAbsolutePath().normalize())); + MavenLoaderConfiguration.configure(configurable, sourceDir, projectClassLoader, - sources, libraries, loaderArgs(strict)); + sources, libraries, dependencies, loaderArgs(strict)); MetaDataLoader loader = configurable.getLoader(); @@ -708,6 +714,11 @@ protected MetaDataLoader createLoader(ClassLoader projectClassLoader) { // generate and verify goals. failOnLoaderErrors(loader); + // FR-023 §11.5: refuse a new top-level node declared into a package a dependency owns. After + // the loader's own errors, never before (an overlay whose target the upstream removed fails + // first, with its own coded error). + dependencies.refuseUnownedPackages(loader.getRoot()); + return loader; } @@ -934,17 +945,26 @@ protected com.metaobjects.database.PhysicalNaming resolvePhysicalNaming(File src * as {@link #resolvePhysicalNaming(File)} describes; empty when there is none. */ protected java.util.Optional resolveProjectConfig(File srcDir) { + return resolveProjectConfigDir(srcDir).flatMap(com.metaobjects.config.NeutralConfig::read); + } + + /** + * The directory holding the {@code .metaobjects/} folder of the project that holds this goal's + * metadata (FR-023: its config, its dependency lock and its committed snapshot all live there), + * found as {@link #resolvePhysicalNaming(File)} describes; empty when there is none. + */ + protected java.util.Optional resolveProjectConfigDir(File srcDir) { Path basedir = getProjectBaseDir().toPath(); Path location = srcDir != null ? srcDir.toPath() : localSourceLocation(basedir); if (location != null) { for (Path dir = location.toAbsolutePath().normalize(); dir != null; dir = dir.getParent()) { - java.util.Optional cfg = - com.metaobjects.config.NeutralConfig.read(dir); - if (cfg.isPresent()) return cfg; + if (com.metaobjects.config.NeutralConfig.read(dir).isPresent()) return java.util.Optional.of(dir); if (Files.exists(dir.resolve(".git"))) break; } } - return com.metaobjects.config.NeutralConfig.read(basedir.toAbsolutePath().normalize()); + Path base = basedir.toAbsolutePath().normalize(); + return com.metaobjects.config.NeutralConfig.read(base).isPresent() + ? java.util.Optional.of(base) : java.util.Optional.empty(); } /** The first {@code } entry that is an existing local path, as a directory; else null. */ diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MavenLoaderConfiguration.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MavenLoaderConfiguration.java index 9f55e33884..46cbfdb252 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MavenLoaderConfiguration.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MavenLoaderConfiguration.java @@ -62,6 +62,22 @@ public static void configure(LoaderConfigurable configurable, List sources, List libraries, Map globals) { + configure(configurable, sourceDir, classLoader, sources, libraries, null, globals); + } + + /** + * Configure a LoaderConfigurable instance, also loading the project's resolved metadata + * dependencies (FR-023) — their committed snapshot artifacts load FIRST. + * + * @param dependencies The project's resolved dependencies; may be null for none + */ + public static void configure(LoaderConfigurable configurable, + String sourceDir, + ClassLoader classLoader, + List sources, + List libraries, + com.metaobjects.config.DependencySet dependencies, + Map globals) { // Trigger lazy initialization of ServiceLoader type discovery @@ -76,6 +92,7 @@ public static void configure(LoaderConfigurable configurable, .classLoader(classLoader) .sources(sources) .libraries(libraries) + .dependencies(dependencies) .arguments(globals) .build(); diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java index d916dbc481..706d888e45 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java @@ -55,7 +55,9 @@ protected void executeGenerators(MetaDataLoader loader, List generato // only the post-loop registerGeneratedSourceRoots() logic below with no real // metadata — nothing to collide-check in that case. if (loader != null) { - RouteNaming.assertNoCollectionNameCollisions(loader.getMetaObjects()); + // FR-023: an object a dependency owns gets no route here, so it cannot collide with one. + RouteNaming.assertNoCollectionNameCollisions( + com.metaobjects.generator.util.GeneratorUtil.ownMetaObjects(loader)); } // Where this EXECUTION's codegen hash manifest lives: anchored on the module diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MavenLoaderDependenciesTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MavenLoaderDependenciesTest.java new file mode 100644 index 0000000000..12e87fac92 --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MavenLoaderDependenciesTest.java @@ -0,0 +1,125 @@ +package com.metaobjects.mojo; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.metaobjects.MetaDataException; +import com.metaobjects.config.DependencySet; +import com.metaobjects.loader.LoaderOptions; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +/** + * FR-023 — the production build-time path: the same {@code DependencySet.resolve} + + * {@link MavenLoaderConfiguration#configure} + {@link MetaDataLoader#configure} sequence + * {@code AbstractMetaDataMojo.createLoader} runs, over a project that imports a shared model through + * its committed snapshot. The project and snapshot are the shared + * {@code fixtures/dependency-conformance} corpus case; the load must carry the snapshot, scope the + * imported package out, and refuse a stale snapshot. + */ +public class MavenLoaderDependenciesTest { + + private static final String CASE = "a-dependency-adds-its-artifact-to-the-resolved-set"; + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static Path corpus() { + Path dir = Paths.get("").toAbsolutePath(); + while (dir != null && !Files.isDirectory(dir.resolve("fixtures"))) dir = dir.getParent(); + return dir.resolve("fixtures/dependency-conformance"); + } + + private static void write(Path p, byte[] bytes) throws Exception { + Files.createDirectories(p.getParent()); + Files.write(p, bytes); + } + + private Path project(boolean corruptSnapshot) throws Exception { + JsonObject all = JsonParser.parseString( + Files.readString(corpus().resolve("cases.json"), StandardCharsets.UTF_8)).getAsJsonObject(); + JsonObject c = null; + for (JsonElement el : all.getAsJsonArray("cases")) { + if (CASE.equals(el.getAsJsonObject().get("name").getAsString())) c = el.getAsJsonObject(); + } + assertTrue("corpus case missing: " + CASE, c != null); + Path root = tmp.newFolder().toPath(); + for (Map.Entry e : c.getAsJsonObject("tree").entrySet()) { + write(root.resolve(e.getKey()), e.getValue().getAsString().getBytes(StandardCharsets.UTF_8)); + } + for (Map.Entry e : c.getAsJsonObject("treeFiles").entrySet()) { + byte[] bytes = Files.readAllBytes(corpus().resolve(e.getValue().getAsString())); + if (corruptSnapshot) bytes = (new String(bytes, StandardCharsets.UTF_8) + " ").getBytes(StandardCharsets.UTF_8); + write(root.resolve(e.getKey()), bytes); + } + write(root.resolve(".metaobjects/config.json"), c.getAsJsonObject("config").toString().getBytes(StandardCharsets.UTF_8)); + write(root.resolve(".metaobjects/deps.lock.json"), c.getAsJsonObject("lock").toString().getBytes(StandardCharsets.UTF_8)); + return root; + } + + private static MetaDataLoader load(Path root) { + DependencySet dependencies = DependencySet.resolve(root); + MetaDataLoader loader = new MetaDataLoader( + LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "mojo-path"); + MavenLoaderConfiguration.configure(loader, null, MavenLoaderDependenciesTest.class.getClassLoader(), + List.of(root.resolve("metaobjects/meta.app.json").toString()), null, dependencies, Map.of()); + dependencies.refuseUnownedPackages(loader.getRoot()); + return loader; + } + + @Test + public void theConfiguredLoaderCarriesTheSnapshotAndScopesItOut() throws Exception { + MetaDataLoader loader = load(project(false)); + Set names = new TreeSet<>(); + for (MetaObject mo : loader.getMetaObjects()) names.add(mo.getName()); + assertTrue("snapshot loaded: " + names, names.contains("acme::common::Customer")); + assertTrue("own file loaded: " + names, names.contains("app::Order")); + DependencySet deps = loader.getRoot().getDependencySet(); + assertTrue(deps.imported("acme::common::Customer")); + assertTrue(deps.inScope("app::Order")); + assertEquals(false, deps.inScope("acme::common::Customer")); + } + + @Test + public void aTamperedSnapshotIsRefusedAsStale() throws Exception { + MetaDataException e = assertThrows(MetaDataException.class, () -> load(project(true))); + assertEquals("ERR_DEPENDENCY_SNAPSHOT_STALE", e.getCode().map(Enum::name).orElse("")); + } + + @Test + public void anImportedObjectDoesNotCollideWithAnOwnObjectOfTheSameRoute() throws Exception { + Path root = project(false); + write(root.resolve("metaobjects/meta.customer.json"), + ("{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Customer\"," + + "\"children\":[{\"source.rdb\":{\"@table\":\"app_customers\"}},{\"field.long\":{\"name\":\"id\"}}," + + "{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}").getBytes(StandardCharsets.UTF_8)); + DependencySet dependencies = DependencySet.resolve(root); + MetaDataLoader loader = new MetaDataLoader( + LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "collide"); + MavenLoaderConfiguration.configure(loader, null, MavenLoaderDependenciesTest.class.getClassLoader(), + List.of(root.resolve("metaobjects/meta.app.json").toString(), + root.resolve("metaobjects/meta.customer.json").toString()), + null, dependencies, Map.of()); + // Everything loaded collides on the `customers` segment; only what generates must not. + assertThrows(com.metaobjects.generator.GeneratorException.class, + () -> com.metaobjects.generator.util.RouteNaming.assertNoCollectionNameCollisions(loader.getMetaObjects())); + com.metaobjects.generator.util.RouteNaming.assertNoCollectionNameCollisions( + com.metaobjects.generator.util.GeneratorUtil.ownMetaObjects(loader)); + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java b/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java index bb9b7c1e48..b32a3a1745 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java +++ b/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java @@ -345,6 +345,27 @@ public enum ErrorCode { */ ERR_LIBRARY_PACKAGE_NOT_OWNED, + /** FR-023: a declared dependency's transport could not locate a directory holding {@code metaobjects.pkg.json}. Raised by {@code meta deps sync}/{@code check}; registered here so the shared list stays one set. */ + ERR_DEPENDENCY_UNRESOLVED, + + /** FR-023: a dependency's {@code metaobjects.pkg.json} fails its schema, names a different dependency, or points at a missing or hash-mismatched artifact. */ + ERR_DEPENDENCY_MANIFEST_INVALID, + + /** FR-023: the committed snapshot does not match {@code .metaobjects/deps.lock.json} (lock missing, entry missing or extra, artifact missing, or hash mismatch) — run {@code meta deps sync}. */ + ERR_DEPENDENCY_SNAPSHOT_STALE, + + /** FR-023: two dependencies export the same fully-qualified node. */ + ERR_DEPENDENCY_NODE_COLLISION, + + /** FR-023: a local top-level node is declared into a package owned by a dependency. */ + ERR_DEPENDENCY_PACKAGE_NOT_OWNED, + + /** FR-023: a dependency's {@code metamodelVersion} major differs from this toolchain's. */ + ERR_DEPENDENCY_METAMODEL_INCOMPATIBLE, + + /** FR-023: {@code meta deps check} found the installed dependency differs from the committed snapshot. Raised by the check command; registered so the shared list stays one set. */ + ERR_DEPENDENCY_UPSTREAM_DRIFT, + /** * FR-016 / ADR-0018: a {@code source.rdb} declares a kind-aware physical-name * alias ({@code @view} / {@code @materializedView} / {@code @proc} / diff --git a/server/java/metadata/src/main/java/com/metaobjects/MetaRoot.java b/server/java/metadata/src/main/java/com/metaobjects/MetaRoot.java index 10748d3633..ec99e0c8c5 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/MetaRoot.java +++ b/server/java/metadata/src/main/java/com/metaobjects/MetaRoot.java @@ -223,6 +223,25 @@ public void setWireFormat(com.metaobjects.config.WireFormat wireFormat) { this.wireFormat = (wireFormat != null) ? wireFormat : com.metaobjects.config.WireFormat.DEFAULT; } + /** + * FR-023 — what this project's metadata dependencies resolved to (the imported packages and the + * scope predicates). Set by the project-aware load; read by codegen through + * {@link #getDependencySet()} to exclude imported objects. A tree + * loaded with no project keeps the empty set, under which nothing is imported. + */ + private transient com.metaobjects.config.DependencySet dependencySet = + com.metaobjects.config.DependencySet.EMPTY; + + /** The project's resolved dependencies for this tree. Never {@code null}. */ + public com.metaobjects.config.DependencySet getDependencySet() { + return dependencySet; + } + + /** Sets the project's resolved dependencies on this root ({@code null} restores the empty set). */ + public void setDependencySet(com.metaobjects.config.DependencySet dependencySet) { + this.dependencySet = (dependencySet != null) ? dependencySet : com.metaobjects.config.DependencySet.EMPTY; + } + /** * Returns all {@code object} children of this root node. * diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/Dependencies.java b/server/java/metadata/src/main/java/com/metaobjects/config/Dependencies.java new file mode 100644 index 0000000000..fb7cc27ee8 --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/config/Dependencies.java @@ -0,0 +1,399 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.config; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; +import com.metaobjects.ErrorCode; +import com.metaobjects.MetaData; +import com.metaobjects.MetaDataException; +import com.metaobjects.MetaRoot; +import com.metaobjects.object.MetaObject; +import com.metaobjects.registry.RegistryManifest; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * FR-023 — metadata dependencies: the lock/snapshot half a port needs to consume a + * shared model (DESIGN §3, §4.2, §11). Mirrors {@code server/typescript/packages/sdk/src/dependencies.ts} + * and the Python {@code metaobjects/config/dependencies.py}; the shared corpus + * {@code fixtures/dependency-conformance/} pins the behavior. + * + *

This port reads the lock and the committed snapshot and never writes either: + * the Node {@code meta deps sync} is the only writer (recorded decision), so there is no lock + * byte contract here. + */ +public final class Dependencies { + + private Dependencies() {} + + /** Directory under {@code .metaobjects/} holding the synced snapshot, one subdirectory per dependency. */ + public static final String DEPS_DIR = "deps"; + + /** {@code meta deps sync}'s output — the only writer (DESIGN §3.3). */ + public static final String LOCK_FILE = "deps.lock.json"; + + /** Suffix of a dependency's canonical-JSON artifact file (DESIGN §3.5). */ + public static final String ARTIFACT_SUFFIX = ".metaobjects.json"; + + /** Prefix of a dependency artifact's source id: {@code dep:/} (DESIGN §2.3). */ + public static final String SOURCE_ID_PREFIX = "dep:"; + + /** Prefix of the {@code integrity} value: {@code sha256-} plus lowercase hex of the artifact bytes. */ + public static final String INTEGRITY_PREFIX = "sha256-"; + + private static final String METAOBJECTS_DIR = ".metaobjects"; + + private static final Pattern NAME = Pattern.compile("^[a-z0-9][a-z0-9._-]*$"); + private static final Pattern METAMODEL_VERSION = Pattern.compile("^\\d+\\.\\d+$"); + private static final Pattern INTEGRITY = Pattern.compile("^sha256-[0-9a-f]{64}$"); + + private static final Set LOCK_KEYS = Set.of("schema_version", "dependencies"); + private static final Set LOCK_ENTRY_KEYS = Set.of( + "version", "metamodelVersion", "artifact", "integrity", "packages", "nodes", "resolvedFrom"); + private static final List RESOLVED_FROM_TRANSPORTS = List.of("path", "npm", "python"); + + /** One lock entry, validated. */ + public record LockEntry(String version, String metamodelVersion, String artifact, String integrity, + List packages, List nodes) {} + + /** A resolved dependency, ready to load: its artifact path, the id it loads under, and what it exports. */ + public record ResolvedDependency(String name, String version, List packages, + List nodes, Path artifactPath, String sourceId) {} + + /** {@code dep:/} — a dependency artifact's source id. */ + public static String sourceId(String name, String artifact) { + return SOURCE_ID_PREFIX + name + "/" + artifact; + } + + /** {@code sha256-} plus lowercase hex sha256 of {@code data}. */ + public static String sha256Integrity(byte[] data) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(data); + StringBuilder sb = new StringBuilder(INTEGRITY_PREFIX); + for (byte b : digest) sb.append(String.format("%02x", b)); + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is required by the Java platform", e); + } + } + + /** The package half of a resolution key: {@code a::b::C} to {@code a::b}; a root-level {@code C} to {@code ""}. */ + public static String packageOf(String fqn) { + int idx = fqn.lastIndexOf(MetaData.PKG_SEPARATOR); + return idx == -1 ? "" : fqn.substring(0, idx); + } + + // --------------------------------------------------------------------------------------- + // Lock + + private static MetaDataException stale(String detail) { + return new MetaDataException(detail + "; run `meta deps sync`", ErrorCode.ERR_DEPENDENCY_SNAPSHOT_STALE); + } + + private static MetaDataException lockInvalid(String message) { + return new MetaDataException(message, ErrorCode.ERR_DEPENDENCY_SNAPSHOT_STALE); + } + + /** + * Read {@code /.metaobjects/deps.lock.json}, if present. A project declaring no + * dependencies has no lock, which is not an error; a present but malformed file raises. + */ + public static Optional> readLock(Path configDir) { + Path path = configDir.resolve(METAOBJECTS_DIR).resolve(LOCK_FILE); + if (!Files.isRegularFile(path)) return Optional.empty(); + JsonElement raw; + try { + raw = JsonParser.parseString(Files.readString(path, StandardCharsets.UTF_8)); + } catch (IOException | JsonSyntaxException e) { + throw lockInvalid(path + " exists but could not be read as JSON: " + e.getMessage()); + } + return Optional.of(validateLock(raw)); + } + + /** Validate a parsed lock (DESIGN §3.3); returns its entries keyed by dependency name, in key order. */ + public static Map validateLock(JsonElement raw) { + String context = LOCK_FILE; + if (!raw.isJsonObject()) throw lockInvalid(context + ": must be an object"); + JsonObject obj = raw.getAsJsonObject(); + for (String key : obj.keySet()) { + if (!LOCK_KEYS.contains(key)) throw lockInvalid(context + ": unknown key(s): [" + key + "]"); + } + JsonElement version = obj.get("schema_version"); + if (version == null || !version.isJsonPrimitive() || !version.getAsJsonPrimitive().isNumber() + || version.getAsDouble() != 1) { + throw lockInvalid(context + ": 'schema_version' must be 1"); + } + JsonElement deps = obj.get("dependencies"); + if (deps == null || !deps.isJsonObject()) throw lockInvalid(context + ": 'dependencies' must be an object"); + + Map out = new java.util.LinkedHashMap<>(); + String previous = null; + for (Map.Entry e : deps.getAsJsonObject().entrySet()) { + String name = e.getKey(); + if (previous != null && previous.compareTo(name) >= 0) { + throw lockInvalid(context + ": dependency keys must be sorted"); + } + previous = name; + String entryContext = context + " (" + name + ")"; + if (!e.getValue().isJsonObject()) throw lockInvalid(entryContext + ": must be an object"); + JsonObject entry = e.getValue().getAsJsonObject(); + for (String key : entry.keySet()) { + if (!LOCK_ENTRY_KEYS.contains(key)) { + throw lockInvalid(entryContext + ": unknown key(s): [" + key + "]"); + } + } + validateResolvedFrom(entry.get("resolvedFrom"), entryContext); + out.put(name, new LockEntry( + nonEmptyString(entry, "version", entryContext), + metamodelVersion(entry, entryContext), + artifact(entry, entryContext), + integrity(entry, entryContext), + sortedStrings(entry, "packages", entryContext), + sortedStrings(entry, "nodes", entryContext))); + } + return out; + } + + private static String nonEmptyString(JsonObject obj, String field, String context) { + JsonElement v = obj.get(field); + if (v == null || !v.isJsonPrimitive() || !v.getAsJsonPrimitive().isString() || v.getAsString().isEmpty()) { + throw lockInvalid(context + ": '" + field + "' must be a non-empty string"); + } + return v.getAsString(); + } + + private static String metamodelVersion(JsonObject obj, String context) { + JsonElement v = obj.get("metamodelVersion"); + if (v == null || !v.isJsonPrimitive() || !v.getAsJsonPrimitive().isString() + || !METAMODEL_VERSION.matcher(v.getAsString()).matches()) { + throw lockInvalid(context + ": 'metamodelVersion' must match ^\\d+\\.\\d+$"); + } + return v.getAsString(); + } + + private static String artifact(JsonObject obj, String context) { + JsonElement v = obj.get("artifact"); + if (v == null || !v.isJsonPrimitive() || !v.getAsJsonPrimitive().isString() + || !v.getAsString().endsWith(ARTIFACT_SUFFIX)) { + throw lockInvalid(context + ": 'artifact' must end with '" + ARTIFACT_SUFFIX + "'"); + } + return v.getAsString(); + } + + private static String integrity(JsonObject obj, String context) { + JsonElement v = obj.get("integrity"); + if (v == null || !v.isJsonPrimitive() || !v.getAsJsonPrimitive().isString() + || !INTEGRITY.matcher(v.getAsString()).matches()) { + throw lockInvalid(context + ": 'integrity' must match ^sha256-[0-9a-f]{64}$"); + } + return v.getAsString(); + } + + private static List sortedStrings(JsonObject obj, String field, String context) { + JsonElement v = obj.get(field); + if (v == null || !v.isJsonArray()) { + throw lockInvalid(context + ": '" + field + "' must be an array of non-empty strings"); + } + List out = new ArrayList<>(); + for (JsonElement el : v.getAsJsonArray()) { + if (!el.isJsonPrimitive() || !el.getAsJsonPrimitive().isString() || el.getAsString().isEmpty()) { + throw lockInvalid(context + ": '" + field + "' must be an array of non-empty strings"); + } + out.add(el.getAsString()); + } + for (int i = 0; i + 1 < out.size(); i++) { + if (out.get(i).compareTo(out.get(i + 1)) >= 0) { + throw lockInvalid(context + ": '" + field + "' must be sorted"); + } + } + return out; + } + + /** Exactly one of {@code path}/{@code npm}/{@code python}; {@code dir} only beside {@code npm}/{@code python}. */ + private static void validateResolvedFrom(JsonElement raw, String context) { + if (raw == null || !raw.isJsonObject()) throw lockInvalid(context + ": 'resolvedFrom' must be an object"); + JsonObject obj = raw.getAsJsonObject(); + List transports = new ArrayList<>(); + for (String t : RESOLVED_FROM_TRANSPORTS) if (obj.has(t)) transports.add(t); + if (transports.size() != 1) { + throw lockInvalid(context + ": 'resolvedFrom' must have exactly one of 'path' / 'npm' / 'python'"); + } + String transport = transports.get(0); + JsonElement value = obj.get(transport); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || value.getAsString().isBlank()) { + throw lockInvalid(context + ": 'resolvedFrom." + transport + "' must be a non-empty string"); + } + Set allowed = new HashSet<>(Set.of(transport)); + if (!transport.equals("path")) allowed.add("dir"); + for (String key : obj.keySet()) { + if (!allowed.contains(key)) throw lockInvalid(context + ": 'resolvedFrom' unknown key(s): [" + key + "]"); + } + if (obj.has("dir")) { + JsonElement dir = obj.get("dir"); + if (!dir.isJsonPrimitive() || !dir.getAsJsonPrimitive().isString() || dir.getAsString().isBlank()) { + throw lockInvalid(context + ": 'resolvedFrom.dir' must be a non-empty string"); + } + } + } + + // --------------------------------------------------------------------------------------- + // Snapshot + + private static String metamodelMajor(String version) { + return version.split("\\.")[0]; + } + + /** + * Verify a project's committed snapshot against its lock and resolve the dependencies the load + * will read FIRST (DESIGN §4.2 steps 2-3). Result order is dependency NAME order, never the + * config's declaration order. A project with no dependencies and no lock resolves to {@code []} + * without touching the filesystem. + * + *

Two failures are not staleness and get their own codes: a dependency published against a + * different metamodel MAJOR ({@code ERR_DEPENDENCY_METAMODEL_INCOMPATIBLE}) and two + * dependencies exporting one node ({@code ERR_DEPENDENCY_NODE_COLLISION}). + */ + public static List verifySnapshot(Path configDir, List> specs, + Optional> lock) { + List declared = new ArrayList<>(); + for (Map spec : specs) declared.add(spec.get("name")); + + if (lock.isEmpty()) { + if (declared.isEmpty()) return List.of(); + throw stale(declared.size() + (declared.size() == 1 ? " dependency is" : " dependencies are") + + " declared (" + String.join(", ", declared) + ") but there is no " + + METAOBJECTS_DIR + "/" + LOCK_FILE); + } + + Map entries = lock.get(); + Set declaredNames = new HashSet<>(declared); + for (String name : entries.keySet()) { + if (!declaredNames.contains(name)) { + throw stale("" + METAOBJECTS_DIR + "/" + LOCK_FILE + " locks dependency \"" + name + + "\", which " + METAOBJECTS_DIR + "/config.json no longer declares"); + } + } + + List resolved = new ArrayList<>(); + for (String name : new java.util.TreeSet<>(declaredNames)) { + LockEntry entry = entries.get(name); + if (entry == null) { + throw stale("dependency \"" + name + "\" is declared but " + METAOBJECTS_DIR + "/" + LOCK_FILE + + " has no entry for it"); + } + Path artifactPath = configDir.resolve(METAOBJECTS_DIR).resolve(DEPS_DIR).resolve(name) + .resolve(entry.artifact()); + byte[] data; + try { + data = Files.readAllBytes(artifactPath); + } catch (IOException e) { + throw stale("the committed snapshot for \"" + name + "\" is missing (expected " + artifactPath + ")"); + } + String actual = sha256Integrity(data); + if (!actual.equals(entry.integrity())) { + throw stale("the committed snapshot for \"" + name + "\" does not match the lock — " + + artifactPath + " hashes to " + actual + ", the lock records " + entry.integrity()); + } + if (!metamodelMajor(entry.metamodelVersion()).equals(metamodelMajor(RegistryManifest.METAMODEL_VERSION))) { + throw new MetaDataException("dependency \"" + name + "\" was published against metamodel " + + entry.metamodelVersion() + "; this toolchain speaks " + RegistryManifest.METAMODEL_VERSION + + ". A different metamodel MAJOR is a different metadata contract — upgrade the " + + "toolchain, or use a release of \"" + name + "\" built against it.", + ErrorCode.ERR_DEPENDENCY_METAMODEL_INCOMPATIBLE); + } + resolved.add(new ResolvedDependency(name, entry.version(), entry.packages(), entry.nodes(), + artifactPath, sourceId(name, entry.artifact()))); + } + + // Collision is checked across the WHOLE resolved set, so the error names the two + // dependencies in name order however the config declared them. + Map owner = new HashMap<>(); + for (ResolvedDependency dep : resolved) { + for (String node : dep.nodes()) { + String prior = owner.get(node); + if (prior != null) { + throw new MetaDataException("dependencies \"" + prior + "\" and \"" + dep.name() + + "\" both export \"" + node + "\" — one fully-qualified node cannot come from two " + + "places, and whichever loaded second would silently win", + ErrorCode.ERR_DEPENDENCY_NODE_COLLISION); + } + owner.put(node, dep.name()); + } + } + return resolved; + } + + // --------------------------------------------------------------------------------------- + // Scope + + /** + * Does some pattern in {@code patterns} name {@code pkg} LITERALLY (DESIGN §11.1 item 2)? Drop + * the pattern's final segment — which names the node — and what remains must be wildcard-free + * and equal to {@code pkg}. So {@code acme::common::**} and {@code acme::common::Address} both + * name {@code acme::common}; {@code acme::**} and {@code **} reach its nodes but name nothing. + */ + public static boolean explicitlyIncludes(List patterns, String pkg) { + if (patterns == null || patterns.isEmpty() || pkg.isEmpty()) return false; + for (String pattern : patterns) { + String named = packageOf(pattern); + if (!named.isEmpty() && !named.contains("*") && named.equals(pkg)) return true; + } + return false; + } + + /** + * FR-023 §11.5 — a consumer may not declare a NEW top-level node into a package one of its + * dependencies owns. Imported-ness is decided by PACKAGE, so such a node would otherwise be + * excluded from this project's own codegen with no output and no error. An overlay of the + * dependency's own node is untouched: its key is in {@code importedNodes}. Called AFTER the + * loader's own errors, never before (an overlay whose target the upstream removed fails first, + * with its own coded error). + */ + public static void refuseUnownedPackages(MetaRoot root, Set importedPackages, Set importedNodes) { + if (importedPackages == null || importedPackages.isEmpty()) return; + // ADR-0039 SANCTIONED own-accessor case: a root-level scan. MetaRoot has no super, so own + // and effective children are the same set, and the question is "what did this tree declare + // at the top level", which is the own layer by definition. + for (MetaObject node : root.getChildren(MetaObject.class, false)) { + String key = node.getName(); + String pkg = packageOf(key); + if (!importedPackages.contains(pkg) || importedNodes.contains(key)) continue; + throw new MetaDataException("\"" + key + "\" is declared here, but the package \"" + pkg + + "\" belongs to a metadata dependency this project imports, and \"" + key + + "\" is not one of the nodes that dependency exports. Declare it in a package this project " + + "owns and 'extends' the dependency's node if it needs its shape; if it was meant to AMEND " + + "the dependency's node, give it that node's name and 'overlay: true'; if this project " + + "really does own \"" + pkg + "\", name it in 'scope.include' and stop importing it.", + ErrorCode.ERR_DEPENDENCY_PACKAGE_NOT_OWNED); + } + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/DependencySet.java b/server/java/metadata/src/main/java/com/metaobjects/config/DependencySet.java new file mode 100644 index 0000000000..cefa4dc6b5 --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/config/DependencySet.java @@ -0,0 +1,151 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.config; + +import com.metaobjects.MetaRoot; +import com.metaobjects.loader.FileSource; +import com.metaobjects.loader.MetaDataSource; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; + +/** + * FR-023 — what a project's dependencies resolved to: the snapshot artifacts that load FIRST, and + * the predicates every action surface reads to decide whether an imported object is excluded + * (DESIGN §11.1 item 2). The Java/Kotlin counterpart of Python's {@code Collection}. + * + *

{@link #inScope} is the Python shape — the import-exclusion half only. Java never applied + * {@code scope} to its OWN generated output, so it is not invented here (the corpus cannot tell the + * two shapes apart; see the dependency-conformance README, "Cross-port caveat"). + * + *

An empty set (no dependencies, no lock) is the untouched project: nothing is imported, every + * object is in scope and {@link #inMigrateScope()} is empty when no {@code migrate.scope} is declared. + */ +public final class DependencySet { + + /** The set of a project that declares no dependencies. */ + public static final DependencySet EMPTY = + new DependencySet(List.of(), List.of(), null); + + private final List dependencies; + private final List scopeInclude; + private final List migrateScope; + private final Set importedPackages; + private final Set importedNodes; + private final Scope.Compiled declaredMigrateScope; + + private DependencySet(List dependencies, List scopeInclude, + List migrateScope) { + this.dependencies = List.copyOf(dependencies); + this.scopeInclude = List.copyOf(scopeInclude); + this.migrateScope = migrateScope == null ? null : List.copyOf(migrateScope); + Set packages = new HashSet<>(); + Set nodes = new HashSet<>(); + for (Dependencies.ResolvedDependency d : this.dependencies) { + packages.addAll(d.packages()); + nodes.addAll(d.nodes()); + } + this.importedPackages = Set.copyOf(packages); + this.importedNodes = Set.copyOf(nodes); + this.declaredMigrateScope = this.migrateScope == null ? null : Scope.compileScope(this.migrateScope, null); + } + + /** + * Resolve the dependencies of the project whose {@code .metaobjects/} folder is under + * {@code configDir}: read its neutral config, read and validate the lock, verify the committed + * snapshot against it. Read at EVERY rung of the source ladder — a pom's {@code } + * changes where the project's own FILES come from, not where its dependencies are declared. + */ + public static DependencySet resolve(Path configDir) { + return of(configDir, NeutralConfig.read(configDir)); + } + + /** {@link #resolve(Path)} for a caller that has already read the neutral config. */ + public static DependencySet of(Path configDir, Optional config) { + List> specs = config.map(NeutralConfig::getDependencies).orElse(List.of()); + List include = config.map(NeutralConfig::getScopeInclude).orElse(List.of()); + List migrate = config.map(NeutralConfig::getMigrateScope).orElse(null); + Optional> lock = Dependencies.readLock(configDir); + List resolved = specs.isEmpty() && lock.isEmpty() + ? List.of() + : Dependencies.verifySnapshot(configDir, specs, lock); + return new DependencySet(resolved, include, migrate); + } + + /** The resolved dependencies, in dependency-NAME order. */ + public List dependencies() { + return dependencies; + } + + public boolean isEmpty() { + return dependencies.isEmpty(); + } + + /** THE exclusion key (DESIGN §11.5): the union of every dependency's {@code packages}. */ + public Set importedPackages() { + return importedPackages; + } + + /** The union of every dependency's {@code nodes}; read only by the ownership refusal. */ + public Set importedNodes() { + return importedNodes; + } + + /** The artifacts as load sources, in dependency-NAME order, each under its {@code dep:/} id. */ + public List sources() { + List out = new ArrayList<>(dependencies.size()); + for (Dependencies.ResolvedDependency d : dependencies) out.add(new FileSource(d.artifactPath(), d.sourceId())); + return out; + } + + /** Is {@code fqn} in a package one of this project's dependencies owns? */ + public boolean imported(String fqn) { + return importedPackages.contains(Dependencies.packageOf(fqn)); + } + + /** + * Output filter for codegen and {@code verify --codegen}: an object this project imports is not + * its own to generate, unless {@code scope.include} NAMES its package. + */ + public boolean inScope(String fqn) { + String pkg = Dependencies.packageOf(fqn); + if (!importedPackages.contains(pkg)) return true; + return Dependencies.explicitlyIncludes(scopeInclude, pkg); + } + + /** + * The {@code migrate.scope}-governed predicate, or empty when the project declares no + * {@code migrate.scope} AND imports nothing (empty means "govern everything loaded"). + */ + public Optional> inMigrateScope() { + if (migrateScope == null && dependencies.isEmpty()) return Optional.empty(); + return Optional.of(fqn -> { + if (declaredMigrateScope != null && !Scope.matchesScope(fqn, declaredMigrateScope)) return false; + String pkg = Dependencies.packageOf(fqn); + return !importedPackages.contains(pkg) || Dependencies.explicitlyIncludes(migrateScope, pkg); + }); + } + + /** The post-load ownership refusal (DESIGN §11.5); call AFTER the loader's own errors. */ + public void refuseUnownedPackages(MetaRoot root) { + Dependencies.refuseUnownedPackages(root, importedPackages, importedNodes); + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java b/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java index 64e29d5dc6..7c30658a46 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java +++ b/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java @@ -40,13 +40,16 @@ /** * The port-neutral subset of {@code .metaobjects/config.json}. * - *

Reads only {@code schema_version}, {@code sources}, {@code naming} (ADR-0058, the - * project's physical-naming rules) and {@code wire} (ADR-0019 Amendment 2, the output form of a + *

Reads only {@code schema_version}, {@code sources}, {@code dependencies} (FR-023, the + * declared cross-repo metadata dependencies), {@code scope.include} and {@code migrate.scope} + * (read only for the dependency-exclusion rules), {@code naming} (ADR-0058, the project's + * physical-naming rules) and {@code wire} (ADR-0019 Amendment 2, the output form of a * generated REST surface). The file also carries TypeScript-owned keys - * ({@code pending_in_git}, {@code confidence_thresholds}, {@code extract}, {@code migrate}, - * {@code scope}); those are IGNORED rather than modeled, so a new TS-only key never becomes - * a four-port change. {@code scope} in particular is entirely out of scope for this reader - * — see {@code docs/superpowers/specs/2026-08-19-cross-port-metadata-sources-design.md} §4. + * ({@code pending_in_git}, {@code confidence_thresholds}, {@code extract}, the rest of + * {@code migrate} and {@code scope}); those are IGNORED rather than modeled, so a new TS-only + * key never becomes a five-port change. Like Python, this port never applies {@code scope} to + * its OWN generated output — {@code scope.include} is read for the explicit-include rule alone + * ({@link Dependencies#explicitlyIncludes}). */ public final class NeutralConfig { @@ -61,12 +64,19 @@ public final class NeutralConfig { private static final int SUPPORTED_SCHEMA_VERSION = 1; private final List> sources; + private final List> dependencies; + private final List scopeInclude; + private final List migrateScope; private final PhysicalNaming physicalNaming; private final WireFormat wireFormat; - private NeutralConfig(List> sources, PhysicalNaming physicalNaming, - WireFormat wireFormat) { + private NeutralConfig(List> sources, List> dependencies, + List scopeInclude, List migrateScope, + PhysicalNaming physicalNaming, WireFormat wireFormat) { this.sources = List.copyOf(sources); + this.dependencies = List.copyOf(dependencies); + this.scopeInclude = List.copyOf(scopeInclude); + this.migrateScope = migrateScope == null ? null : List.copyOf(migrateScope); this.physicalNaming = physicalNaming; this.wireFormat = wireFormat; } @@ -80,6 +90,34 @@ public List> getSources() { return sources; } + /** + * FR-023 — the declared {@code dependencies}, each already validated (DESIGN §3.1): a + * {@code name}, exactly one transport key ({@code path} | {@code npm} | {@code python}), + * and {@code dir} only beside {@code npm}/{@code python}. Empty when the key is absent. + * Resolving a transport to bytes is the Node {@code meta deps sync}'s job; this port reads + * the committed snapshot instead. + */ + public List> getDependencies() { + return dependencies; + } + + /** + * FR-023 §11.1 item 2 — the declared {@code scope.include} patterns, read only for the + * explicit-include rule. Empty when {@code scope} or {@code scope.include} is absent. + */ + public List getScopeInclude() { + return scopeInclude; + } + + /** + * FR-023 §11.1 item 2 — the declared {@code migrate.scope} patterns, or {@code null} when + * the key is absent (distinct from an empty list, which is a declared scope that matches + * nothing). + */ + public List getMigrateScope() { + return migrateScope; + } + /** * ADR-0058 — the project's physical-naming rules ({@code naming} in this file), each * absent rule at its default. Never {@code null}. @@ -239,6 +277,10 @@ public static NeutralConfig parse(String content, String origin) { } } + List> dependencies = parseDependencies(root.get("dependencies"), origin); + List scopeInclude = parseScopeInclude(root.get("scope"), origin); + List migrateScope = parseMigrateScope(root.get("migrate"), origin); + // ADR-0058 — the project's physical-naming rules. An unknown key, an unknown value, // or a non-object `naming` is refused (never silently defaulted — a typo would // otherwise bind a whole schema to the wrong names and report success). @@ -284,9 +326,104 @@ public static NeutralConfig parse(String content, String origin) { WireFormat wireFormat = parseWire(root.get("wire"), origin); - // Unknown top-level keys (including `scope`/`migrate`) are IGNORED by - // design — see the class javadoc. - return new NeutralConfig(specs, physicalNaming, wireFormat); + // Unknown top-level keys, and every key of `scope`/`migrate` but the two read above, + // are IGNORED by design — see the class javadoc. + return new NeutralConfig(specs, dependencies, scopeInclude, migrateScope, physicalNaming, + wireFormat); + } + + /** Mirrors the TS {@code DependencyName} regex (DESIGN §3.1). */ + private static final java.util.regex.Pattern DEPENDENCY_NAME = + java.util.regex.Pattern.compile("^[a-z0-9][a-z0-9._-]*$"); + + private static final List DEPENDENCY_TRANSPORTS = List.of("path", "npm", "python"); + + private static MetaDataException bad(String message) { + return new MetaDataException(message, ErrorCode.ERR_BAD_ATTR_VALUE); + } + + /** + * FR-023 — validate the {@code dependencies} array exactly as the TS + * {@code DependencySpecSchema} and Python {@code _validate_dependency_spec} do. {@code mode} is + * removed (§11.3) and so is an ordinary unknown key. + */ + private static List> parseDependencies(JsonElement raw, String origin) { + List> out = new ArrayList<>(); + if (raw == null) return out; + if (!raw.isJsonArray()) throw bad(origin + ": \"dependencies\" must be an array"); + java.util.Set names = new java.util.HashSet<>(); + for (JsonElement el : raw.getAsJsonArray()) { + String ctx = origin + ": invalid \"dependencies\" entry ("; + if (!el.isJsonObject()) throw bad(ctx + "must be an object)"); + JsonObject dep = el.getAsJsonObject(); + + JsonElement nameEl = dep.get("name"); + if (nameEl == null || !nameEl.isJsonPrimitive() || !nameEl.getAsJsonPrimitive().isString() + || !DEPENDENCY_NAME.matcher(nameEl.getAsString()).matches()) { + throw bad(ctx + "'name' must match ^[a-z0-9][a-z0-9._-]*$)"); + } + List transports = new ArrayList<>(); + for (String t : DEPENDENCY_TRANSPORTS) if (dep.has(t)) transports.add(t); + if (transports.size() != 1) { + throw bad(ctx + "exactly one of 'path' / 'npm' / 'python' is required)"); + } + String transport = transports.get(0); + Map spec = new LinkedHashMap<>(); + spec.put("name", nameEl.getAsString()); + spec.put(transport, nonEmptyString(dep.get(transport), ctx + "'" + transport + "' must be a non-empty string)")); + + java.util.Set allowed = new java.util.HashSet<>(List.of("name", transport)); + if (!transport.equals("path")) allowed.add("dir"); + List extra = new ArrayList<>(); + for (String k : dep.keySet()) if (!allowed.contains(k)) extra.add(k); + if (!extra.isEmpty()) { + java.util.Collections.sort(extra); + throw bad(ctx + "unknown key(s): " + extra + ")"); + } + if (dep.has("dir")) { + spec.put("dir", nonEmptyString(dep.get("dir"), ctx + "'dir' must be a non-empty string)")); + } + if (!names.add(spec.get("name"))) { + throw bad(origin + ": \"dependencies\" names must be unique"); + } + out.add(spec); + } + return out; + } + + private static String nonEmptyString(JsonElement el, String message) { + if (el == null || !el.isJsonPrimitive() || !el.getAsJsonPrimitive().isString() + || el.getAsString().strip().isEmpty()) { + throw bad(message); + } + return el.getAsString(); + } + + private static List stringArray(JsonElement el, String field, String origin) { + if (!el.isJsonArray()) throw bad(origin + ": \"" + field + "\" must be an array of non-empty strings"); + List out = new ArrayList<>(); + for (JsonElement v : el.getAsJsonArray()) { + out.add(nonEmptyString(v, origin + ": \"" + field + "\" must be an array of non-empty strings")); + } + return out; + } + + /** {@code scope.include}; every other key inside {@code scope} is ignored. */ + private static List parseScopeInclude(JsonElement scope, String origin) { + if (scope == null || scope.isJsonNull()) return List.of(); + if (!scope.isJsonObject()) throw bad(origin + ": \"scope\" must be an object"); + JsonElement include = scope.getAsJsonObject().get("include"); + if (include == null || include.isJsonNull()) return List.of(); + return stringArray(include, "scope.include", origin); + } + + /** {@code migrate.scope}, or {@code null} when absent; every other {@code migrate} key is ignored. */ + private static List parseMigrateScope(JsonElement migrate, String origin) { + if (migrate == null || migrate.isJsonNull()) return null; + if (!migrate.isJsonObject()) throw bad(origin + ": \"migrate\" must be an object"); + JsonElement scope = migrate.getAsJsonObject().get("scope"); + if (scope == null || scope.isJsonNull()) return null; + return stringArray(scope, "migrate.scope", origin); } /** diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/SourceResolver.java b/server/java/metadata/src/main/java/com/metaobjects/config/SourceResolver.java index ee31355afe..b9985dbc8a 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/config/SourceResolver.java +++ b/server/java/metadata/src/main/java/com/metaobjects/config/SourceResolver.java @@ -155,6 +155,40 @@ public static List resolveCollection(Path root) { return resolveSources(base, specs); } + /** + * FR-023 — the resolved collection: the project's own files plus the dependency snapshot that + * leads the load. + * + * @param ownFiles the project's own metadata files, as {@link #resolveCollection} returns them + * @param dependencies the verified dependencies and the predicates over them; {@link DependencySet#EMPTY} + * when the project declares none + */ + public record ResolvedCollection(List ownFiles, DependencySet dependencies) { + + /** + * Every file the load reads, in order: dependency artifacts (in dependency-NAME order) + * first, then the project's own files. Equal to {@link #ownFiles()} when nothing is imported. + */ + public List files() { + List out = new ArrayList<>(); + for (Dependencies.ResolvedDependency d : dependencies.dependencies()) out.add(d.artifactPath()); + out.addAll(ownFiles); + return out; + } + } + + /** + * {@link #resolveCollection(Path)} plus the project's metadata dependencies (FR-023): the + * neutral config's {@code dependencies} are read and the committed snapshot is verified against + * {@code .metaobjects/deps.lock.json}, offline. A project declaring none resolves exactly as + * {@link #resolveCollection(Path)} does. + */ + public static ResolvedCollection resolveCollectionWithDependencies(Path root) { + Path base = root.toAbsolutePath().normalize(); + List own = resolveCollection(base); + return new ResolvedCollection(own, DependencySet.resolve(base)); + } + /** * ADR-0058 — the project's physical-naming rules declared in {@code root}'s * {@code .metaobjects/config.json}, or {@link PhysicalNaming#DEFAULT} when the file is diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/LoaderConfigurable.java b/server/java/metadata/src/main/java/com/metaobjects/loader/LoaderConfigurable.java index 3d2966d1a2..9c6fcbe8c0 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/LoaderConfigurable.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/LoaderConfigurable.java @@ -69,5 +69,17 @@ interface LoaderConfiguration { default List getLibraries() { return java.util.Collections.emptyList(); } + + /** + * FR-023 — the project's resolved metadata dependencies. Their committed snapshot artifacts + * are loaded FIRST, ahead of {@link #getSources()}, each under its {@code dep:/} + * source id. A {@code default} for the same reason as {@link #getLibraries()}: this interface + * is the build-tool seam and an implementor outside this repo must keep compiling. + * + * @return the resolved dependencies; never null, empty when the project declares none + */ + default com.metaobjects.config.DependencySet getDependencies() { + return com.metaobjects.config.DependencySet.EMPTY; + } } } \ No newline at end of file diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/LoaderConfigurationBuilder.java b/server/java/metadata/src/main/java/com/metaobjects/loader/LoaderConfigurationBuilder.java index 6d6873285a..d9f46e54eb 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/LoaderConfigurationBuilder.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/LoaderConfigurationBuilder.java @@ -32,6 +32,7 @@ public class LoaderConfigurationBuilder { private List sources = new ArrayList<>(); private List libraries = new ArrayList<>(); private Map arguments = new HashMap<>(); + private com.metaobjects.config.DependencySet dependencies = com.metaobjects.config.DependencySet.EMPTY; public LoaderConfigurationBuilder() { } @@ -72,6 +73,18 @@ public LoaderConfigurationBuilder libraries(List libraries) { return this; } + /** + * FR-023 — the project's resolved metadata dependencies, whose snapshot artifacts load first — + * see {@link LoaderConfigurable.LoaderConfiguration#getDependencies()}. + * + * @param dependencies the resolved set; null restores the empty set + * @return this builder + */ + public LoaderConfigurationBuilder dependencies(com.metaobjects.config.DependencySet dependencies) { + this.dependencies = dependencies == null ? com.metaobjects.config.DependencySet.EMPTY : dependencies; + return this; + } + public LoaderConfigurationBuilder argument(String key, String value) { this.arguments.put(key, value); return this; @@ -98,7 +111,7 @@ public LoaderConfigurationBuilder strict(boolean strict) { public LoaderConfigurable.LoaderConfiguration build() { return new LoaderConfigurationImpl(sourceDir, classLoader, new ArrayList<>(sources), - new ArrayList<>(libraries), new HashMap<>(arguments)); + new ArrayList<>(libraries), new HashMap<>(arguments), dependencies); } /** @@ -110,15 +123,18 @@ private static class LoaderConfigurationImpl implements LoaderConfigurable.Loade private final List sources; private final List libraries; private final Map arguments; + private final com.metaobjects.config.DependencySet dependencies; public LoaderConfigurationImpl(String sourceDir, ClassLoader classLoader, List sources, List libraries, - Map arguments) { + Map arguments, + com.metaobjects.config.DependencySet dependencies) { this.sourceDir = sourceDir; this.classLoader = classLoader; this.sources = sources; this.libraries = libraries; this.arguments = arguments; + this.dependencies = dependencies; } @Override @@ -141,6 +157,11 @@ public List getLibraries() { return libraries; } + @Override + public com.metaobjects.config.DependencySet getDependencies() { + return dependencies; + } + @Override public Map getArguments() { return arguments; diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java b/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java index 7c49c22772..28b6c0326b 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java @@ -140,6 +140,10 @@ public class MetaDataLoader implements LoaderConfigurable { // in its model. private List libraries = null; + // FR-023: the project's resolved metadata dependencies. Their snapshot artifacts load before + // the project's own sources. Empty (nothing imported) by default. + private com.metaobjects.config.DependencySet dependencies = com.metaobjects.config.DependencySet.EMPTY; + // Validation-phase warnings accumulator. // // Mirrors the TS/C#/Python warning surfaces (a per-load list of human-readable @@ -1383,6 +1387,9 @@ public void configure(LoaderConfiguration config) { setLibraries(config.getLibraries()); } + // FR-023: the snapshot artifacts load FIRST, in the same batch as the sources. + setDependencies(config.getDependencies()); + processArguments(config.getArguments()); init(); } @@ -1586,15 +1593,36 @@ public List getLibraries() { } private void loadSourceURIsIfPresent() { - if (sourceURIs == null || sourceURIs.isEmpty()) return; - - List sources = new ArrayList<>(); - for (URI uri : sourceURIs) { - sources.add(new UriSource(uri)); + boolean hasSources = sourceURIs != null && !sourceURIs.isEmpty(); + if (!hasSources && dependencies.isEmpty()) return; + + // FR-023: a dependency's snapshot artifacts LEAD the batch, in dependency-name order, so + // the project's own files overlay and extend them. They share the batch rather than loading + // before it, because each load() resets the recorded-error accumulators and an error in the + // snapshot must survive to be reported. + List sources = new ArrayList<>(dependencies.sources()); + if (hasSources) { + for (URI uri : sourceURIs) { + sources.add(new UriSource(uri)); + } } load(sources); } + /** + * FR-023 — the project's resolved metadata dependencies, attached to the root so codegen can + * exclude what they own. Must be set BEFORE {@code init()}: the snapshot artifacts are read + * once during initialization, ahead of the sources. + * + * @param dependencies the resolved set; {@code null} restores the empty set + * @return this loader, for chaining + */ + public MetaDataLoader setDependencies(com.metaobjects.config.DependencySet dependencies) { + this.dependencies = (dependencies == null) ? com.metaobjects.config.DependencySet.EMPTY : dependencies; + getRoot().setDependencySet(this.dependencies); + return this; + } + private void validateAndTransitionToInitializing() { if (!loadingState.tryTransition(LoadingState.Phase.UNINITIALIZED, LoadingState.Phase.INITIALIZING)) { LoadingState.Phase currentPhase = loadingState.getCurrentPhase(); diff --git a/server/java/metadata/src/test/java/com/metaobjects/config/DependencyConformanceTest.java b/server/java/metadata/src/test/java/com/metaobjects/config/DependencyConformanceTest.java new file mode 100644 index 0000000000..d959a75bea --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/config/DependencyConformanceTest.java @@ -0,0 +1,286 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.config; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.metaobjects.ErrorCode; +import com.metaobjects.MetaDataException; +import com.metaobjects.loader.FileSource; +import com.metaobjects.loader.LoaderOptions; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.loader.MetaDataSource; +import com.metaobjects.object.MetaObject; +import com.metaobjects.source.ErrorSource; +import com.metaobjects.source.JsonSource; +import com.metaobjects.source.MergedSource; +import com.metaobjects.source.ResolvedSource; +import com.metaobjects.source.YamlSource; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Runs the shared FR-023 dependency corpus against this port (Java; Kotlin loads through the same + * loader and Maven plugin). Reads the single committed + * {@code fixtures/dependency-conformance/cases.json} — no per-port fixture. The reference is + * {@code sdk/src/collection.ts} ({@code resolveCollection}) and {@code sdk/src/memory.ts} + * ({@code loadMemory}); {@code SourceResolver.resolveCollectionWithDependencies} and + * {@code DependencySet.refuseUnownedPackages} are this port's counterparts. + * + *

Arms: resolution ({@code expectFiles}/{@code expectError}), the imported / selected / + * migrate-governed sets over every loaded top-level object, and load-time failure + * ({@code expectLoadError}, {@code expectErrorFiles}). The load runs the loader's own errors FIRST + * and the package-ownership refusal after them — the README's "Ordering note". + */ +@RunWith(Parameterized.class) +public class DependencyConformanceTest { + + private record Case(String name, Map tree, Map treeFiles, + JsonObject config, JsonObject lock, String resolveFrom, + List expectFiles, List expectImported, + List expectSelected, List expectMigrateGoverned, + String expectLoadError, List expectErrorFiles, String expectError) {} + + static Path corpusDir() { + Path dir = Paths.get("").toAbsolutePath(); + while (dir != null && !Files.isDirectory(dir.resolve("fixtures"))) dir = dir.getParent(); + assertNotNull("could not locate the repository fixtures/ directory", dir); + return dir.resolve("fixtures/dependency-conformance"); + } + + private static Map stringMap(JsonObject c, String key) { + Map out = new java.util.LinkedHashMap<>(); + if (c.has(key)) { + for (Map.Entry e : c.getAsJsonObject(key).entrySet()) { + out.put(e.getKey(), e.getValue().getAsString()); + } + } + return out; + } + + private static List stringList(JsonObject c, String key) { + if (!c.has(key)) return null; + List out = new ArrayList<>(); + for (JsonElement el : c.getAsJsonArray(key)) out.add(el.getAsString()); + return out; + } + + private static String optString(JsonObject c, String key) { + return c.has(key) ? c.get(key).getAsString() : null; + } + + @Parameters(name = "{0}") + public static Collection cases() throws IOException { + String content = Files.readString(corpusDir().resolve("cases.json"), StandardCharsets.UTF_8); + JsonArray arr = JsonParser.parseString(content).getAsJsonObject().getAsJsonArray("cases"); + List rows = new ArrayList<>(); + for (JsonElement el : arr) { + JsonObject c = el.getAsJsonObject(); + String name = c.get("name").getAsString(); + // Check-arm cases (`expectCheck`) pin the Node `meta deps check` verdict; the resolution + // arm run here has nothing to assert on them, and Java has no check of its own. + if (c.has("expectCheck")) continue; + if (!c.has("config")) { + throw new IllegalStateException("corpus case \"" + name + "\" has no \"config\" key"); + } + JsonElement cfg = c.get("config"); + rows.add(new Object[]{name, new Case( + name, stringMap(c, "tree"), stringMap(c, "treeFiles"), + cfg.isJsonNull() ? null : cfg.getAsJsonObject(), + c.has("lock") ? c.getAsJsonObject("lock") : null, + c.has("resolveFrom") ? c.get("resolveFrom").getAsString() : ".", + stringList(c, "expectFiles"), stringList(c, "expectImported"), + stringList(c, "expectSelected"), stringList(c, "expectMigrateGoverned"), + optString(c, "expectLoadError"), stringList(c, "expectErrorFiles"), + optString(c, "expectError"))}); + } + return rows; + } + + @Parameter(0) + public String caseName; + + @Parameter(1) + public Case testCase; + + private static void delete(Path root) throws IOException { + try (Stream walk = Files.walk(root)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); + } + } + + private static void write(Path path, byte[] bytes) throws IOException { + Files.createDirectories(path.getParent()); + Files.write(path, bytes); + } + + private static List files(ErrorSource source) { + if (source instanceof JsonSource s) return s.files(); + if (source instanceof YamlSource s) return s.files(); + if (source instanceof MergedSource s) return s.files(); + if (source instanceof ResolvedSource s) return s.files(); + return List.of(); + } + + /** First error the load produced: thrown, or recorded by the strict loader. */ + private static MetaDataException firstError(MetaDataLoader loader, Throwable thrown) { + for (Throwable t = thrown; t != null; t = t.getCause()) { + if (t instanceof MetaDataException e && e.getCode().isPresent()) return e; + } + List recorded = loader.getErrors(); + if (recorded != null && !recorded.isEmpty()) return recorded.get(0); + return thrown == null ? null : new MetaDataException(String.valueOf(thrown.getMessage())); + } + + @Test + public void resolvesLoadsAndScopesLikeTheReference() throws IOException { + Path root = Files.createTempDirectory("mo-dep-conformance-").toAbsolutePath().normalize(); + try { + for (Map.Entry e : testCase.tree().entrySet()) { + write(root.resolve(e.getKey()), e.getValue().getBytes(StandardCharsets.UTF_8)); + } + // Byte-for-byte: a pinned hash must never be re-encoded on the way in. + for (Map.Entry e : testCase.treeFiles().entrySet()) { + write(root.resolve(e.getKey()), Files.readAllBytes(corpusDir().resolve(e.getValue()))); + } + Path invokeDir = root.resolve(testCase.resolveFrom()).normalize(); + Files.createDirectories(invokeDir); + Path dotMo = invokeDir.resolve(".metaobjects"); + Files.createDirectories(dotMo); + if (testCase.config() != null) { + write(dotMo.resolve("config.json"), testCase.config().toString().getBytes(StandardCharsets.UTF_8)); + } + if (testCase.lock() != null) { + write(dotMo.resolve("deps.lock.json"), testCase.lock().toString().getBytes(StandardCharsets.UTF_8)); + } + + if (testCase.expectError() != null) { + try { + SourceResolver.resolveCollectionWithDependencies(invokeDir); + fail("expected " + testCase.expectError() + " but resolution succeeded"); + } catch (MetaDataException e) { + assertEquals(testCase.expectError(), e.getCode().map(ErrorCode::name).orElse("")); + } + return; + } + + SourceResolver.ResolvedCollection collection = + SourceResolver.resolveCollectionWithDependencies(invokeDir); + + Set actual = new TreeSet<>(); + for (Path f : collection.files()) { + actual.add(root.relativize(f).toString().replace(java.io.File.separatorChar, '/')); + } + assertEquals(new TreeSet<>(testCase.expectFiles()), actual); + + boolean needsLoad = testCase.expectImported() != null || testCase.expectSelected() != null + || testCase.expectMigrateGoverned() != null || testCase.expectLoadError() != null; + if (!needsLoad) return; + + DependencySet deps = collection.dependencies(); + List sources = new ArrayList<>(deps.sources()); + for (Path own : collection.ownFiles()) sources.add(new FileSource(own)); + + MetaDataLoader loader = new MetaDataLoader( + LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "dep-conformance"); + loader.init(); + Throwable thrown = null; + try { + loader.load(sources); + } catch (RuntimeException e) { + thrown = e; + } + MetaDataException error = firstError(loader, thrown); + + if (error == null) { + // The loader's own errors come first; only a clean load reaches the ownership walk. + try { + deps.refuseUnownedPackages(loader.getRoot()); + } catch (MetaDataException e) { + error = e; + } + } + + if (testCase.expectLoadError() != null) { + assertNotNull("expected " + testCase.expectLoadError() + " but the load succeeded", error); + assertEquals(testCase.expectLoadError(), error.getCode().map(ErrorCode::name).orElse("")); + if (testCase.expectErrorFiles() != null) { + ErrorSource envelope = error.getEnvelope().orElse(null); + assertNotNull("the error carries no source envelope", envelope); + List files = files(envelope); + assertFalse("the error names no file", files.isEmpty()); + assertEquals(testCase.expectErrorFiles(), List.of(files.get(0))); + } + return; + } + if (error != null) throw error; + + Set loaded = new TreeSet<>(); + for (MetaObject mo : loader.getRoot().getChildren(MetaObject.class, false)) loaded.add(mo.getName()); + + if (testCase.expectImported() != null) { + assertEquals(new TreeSet<>(testCase.expectImported()), filter(loaded, deps::imported)); + } + if (testCase.expectSelected() != null) { + assertEquals(new TreeSet<>(testCase.expectSelected()), filter(loaded, deps::inScope)); + } + if (testCase.expectMigrateGoverned() != null) { + // An absent predicate governs everything loaded. + Predicate governed = deps.inMigrateScope().orElse(fqn -> true); + assertEquals(new TreeSet<>(testCase.expectMigrateGoverned()), filter(loaded, governed)); + } + } finally { + delete(root); + } + } + + private static Set filter(Set all, Predicate keep) { + Set out = new TreeSet<>(); + for (String s : all) if (keep.test(s)) out.add(s); + return out; + } + + @Test + public void theCorpusIsNotEmpty() throws IOException { + assertTrue(cases().size() > 0); + } +} diff --git a/server/java/metadata/src/test/java/com/metaobjects/config/NeutralConfigTest.java b/server/java/metadata/src/test/java/com/metaobjects/config/NeutralConfigTest.java index d14478ce4c..ebe62498c3 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/config/NeutralConfigTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/config/NeutralConfigTest.java @@ -108,4 +108,47 @@ public void anUnknownTopLevelKeyIsIgnoredNotRefused() { "{\"schema_version\":1,\"someFutureKey\":{\"x\":1},\"naming\":{\"columns\":\"literal\"}}", "test"); assertEquals("literal", cfg.getPhysicalNaming().columns()); } + + // FR-023 — `dependencies`, `scope.include` and `migrate.scope` are validated as in TS/Python. + + private static NeutralConfig dep(String dependenciesJson) { + return NeutralConfig.parse("{\"schema_version\":1,\"dependencies\":" + dependenciesJson + "}", "test"); + } + + private static void assertBadAttr(String dependenciesJson) { + MetaDataException e = assertThrows(MetaDataException.class, () -> dep(dependenciesJson)); + assertEquals(ErrorCode.ERR_BAD_ATTR_VALUE, e.getCode().orElse(null)); + } + + @Test + public void readsADeclaredPathDependency() { + NeutralConfig cfg = dep("[{\"name\":\"acme-common\",\"path\":\"../acme/metaobjects\"}]"); + assertEquals(1, cfg.getDependencies().size()); + assertEquals("acme-common", cfg.getDependencies().get(0).get("name")); + } + + @Test + public void refusesABadDependencyName() { + assertBadAttr("[{\"name\":\"Acme Common\",\"path\":\"../a\"}]"); + } + + @Test + public void refusesADependencyWithTwoTransports() { + assertBadAttr("[{\"name\":\"a\",\"path\":\"../a\",\"npm\":\"@x/y\"}]"); + } + + @Test + public void refusesADuplicateDependencyName() { + assertBadAttr("[{\"name\":\"a\",\"path\":\"../a\"},{\"name\":\"a\",\"path\":\"../b\"}]"); + } + + @Test + public void refusesAnUnknownDependencyKey() { + assertBadAttr("[{\"name\":\"a\",\"path\":\"../a\",\"bogus\":1}]"); + } + + @Test + public void refusesDirBesideAPathTransport() { + assertBadAttr("[{\"name\":\"a\",\"path\":\"../a\",\"dir\":\"x\"}]"); + } } diff --git a/spec/roadmap.md b/spec/roadmap.md index 909158241b..ac5e78e0c8 100644 --- a/spec/roadmap.md +++ b/spec/roadmap.md @@ -142,7 +142,7 @@ under **Shipped**; planned FRs under **Planned** + the **Release plan**. ✅ shi | FR-020 | `@inheritance` joined strategy | 📋 | 1.x | [#6](https://github.com/metaobjectsdev/metaobjects/issues/6) | | FR-021 | api metadata + contract projections | 📋 | 1.3 | [#7](https://github.com/metaobjectsdev/metaobjects/issues/7) | | FR-022 | Contract emitters (JSON Schema/OpenAPI/protobuf) | 📋 | 1.3 | [#8](https://github.com/metaobjectsdev/metaobjects/issues/8) | -| FR-023 | Metadata dependencies (cross-repo sharing: generated shared-model artifact, `meta deps sync`, committed snapshot + lock, default exclusion of imported metadata, consumer extend/overlay across the boundary) | 🟢 Phase 1a (TS + Python) **shipped** per DESIGN §11 — the usage-aware breaking-change classifier and the `npm`/`python` transports are deferred (§11.4); Java/Kotlin/C# consumers are Phase 2 | 1.1 | [#9](https://github.com/metaobjectsdev/metaobjects/issues/9) | +| FR-023 | Metadata dependencies (cross-repo sharing: generated shared-model artifact, `meta deps sync`, committed snapshot + lock, default exclusion of imported metadata, consumer extend/overlay across the boundary) | 🟢 Phase 1a (TS + Python) **shipped** per DESIGN §11, Java/Kotlin consume (Phase 2 S3) — the usage-aware breaking-change classifier and the `npm`/`python` transports are deferred (§11.4); C# consumer is Phase 2 | 1.1 | [#9](https://github.com/metaobjectsdev/metaobjects/issues/9) | | FR-024 | Entity surfaces (projection/value + declared API) | 🟢 taxonomy + B3/B4a/B4b/B5/B6 validation parity + projection codegen **shipped 5 ports (1.0)**; declared-API surface (`api.operational`/`operation`/`binding.rest`) **deferred to a later 1.x** (not in 1.1.0; foundation for FR-034) | 1.0 · 1.x (later) | [#10](https://github.com/metaobjectsdev/metaobjects/issues/10) | | FR-025 | Cross-port package-binding codegen config | 📋 | 1.1 | [#11](https://github.com/metaobjectsdev/metaobjects/issues/11) | | FR-026 | Forms completeness (edit forms + view parity) | 🟢 edit forms + view-kind control dispatch **shipped**; renderer parity / validator wiring / `layout.form` **deferred to a later 1.x** (not in 1.1.0) | 1.x (later) | [#12](https://github.com/metaobjectsdev/metaobjects/issues/12) | @@ -414,8 +414,8 @@ _(FR-032 was developed under the working number "FR-026" — see commit history; `.metaobjects/config.json`, `meta deps sync` copies the artifact into a committed snapshot + `.metaobjects/deps.lock.json`, and TypeScript's and Python's collection resolvers — the two ports that consume dependencies in Phase 1a — load the - snapshot at every rung of their own source ladder (Java, Kotlin, and C# don't - read `dependencies` yet). Imported metadata is load-only by default — + snapshot at every rung of their own source ladder (Java and Kotlin followed in + Phase 2 slice S3; C# doesn't read `dependencies` yet). Imported metadata is load-only by default — excluded from codegen, schema, and the requirements ledger unless the consumer's own `scope.include` / `migrate.scope` names the package — with a loud refusal (`ERR_DEPENDENCY_PACKAGE_NOT_OWNED`) rather than a silent drop when a consumer @@ -692,7 +692,7 @@ the breaking half at `1.1`. #### 1.x / later - **FR-023 — metadata dependencies, remaining phases** (Phase 1a — TS + Python — - shipped; Phase 1b bindings/docs-badge, and Phase 2 — Java/Kotlin/C# + the + shipped, Java/Kotlin consume (Phase 2 S3); Phase 1b bindings/docs-badge, and the rest of Phase 2 — C# + the `npm`/`python`/`maven`/`nuget` transports — remain). - **FR-020 — `@inheritance` joined strategy** (additive; pairs with a persistence release). - **MCP exposure** (if not pulled earlier — see below).