From d580193bc9705354fa4b0b7cd1cefac6d19f8949 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 5 Nov 2026 08:22:16 -0500 Subject: [PATCH 1/4] feat(java): Spring runtime loads the committed dependency snapshot; verify gains the deps mode FR-023 Phase 2 slices S4 and S5. metaobjects:generate ships the lock and snapshot on the classpath, the Spring application loader loads them with build-time scoping and error codes, and metaobjects:verify -Dmeta.verify.mode=deps runs the shared expectCheck corpus arm. --- CHANGELOG.md | 14 + docs/CONFORMANCE.md | 9 +- docs/features/metadata-dependencies.md | 16 +- docs/ports/java.md | 21 +- fixtures/dependency-conformance/README.md | 10 +- .../spring/MetaDataLoaderConfiguration.java | 73 ++++- ...taLoaderConfigurationDependenciesTest.java | 174 ++++++++++++ .../mojo/MetaDataGeneratorMojo.java | 38 +++ .../metaobjects/mojo/MetaDataVerifyMojo.java | 60 +++- ...aGeneratorMojoDependencyResourcesTest.java | 74 +++++ .../mojo/MetaDataVerifyDepsModeTest.java | 123 ++++++++ .../com/metaobjects/config/Dependencies.java | 265 +++++++++++++++++- .../com/metaobjects/config/DependencySet.java | 33 ++- .../metaobjects/loader/MetaDataLoader.java | 27 +- .../DependencyCheckConformanceTest.java | 146 ++++++++++ .../config/DependencyConformanceTest.java | 2 +- ...endencySnapshotRuntimeConformanceTest.java | 262 +++++++++++++++++ 17 files changed, 1311 insertions(+), 36 deletions(-) create mode 100644 server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderConfigurationDependenciesTest.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataGeneratorMojoDependencyResourcesTest.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyDepsModeTest.java create mode 100644 server/java/metadata/src/test/java/com/metaobjects/config/DependencyCheckConformanceTest.java create mode 100644 server/java/metadata/src/test/java/com/metaobjects/config/DependencySnapshotRuntimeConformanceTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index ea04c89a9..5b79c7ad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,20 @@ loading. A port that reports `1.2` does not know that vocabulary; upgrade every ### Added +- **Java and Kotlin: a Spring app loads a shared model's committed snapshot at runtime, and + `metaobjects:verify` checks dependency drift.** `metaobjects:generate` now ships + `.metaobjects/deps.lock.json` and `.metaobjects/deps/**` as resources (target path `.metaobjects`) + when the project has a lock. When `classpath:.metaobjects/deps.lock.json` exists, the Spring + application loader loads each snapshot before `metaobjects.metadata-sources`, verified as the build + does (`ERR_DEPENDENCY_SNAPSHOT_STALE`, `ERR_DEPENDENCY_PACKAGE_NOT_OWNED`), so an entity that + `extends` an imported node boots; an imported node stays load-only. With no lock and nothing + declared, nothing changes. `mvn metaobjects:verify -Dmeta.verify.mode=deps` re-resolves each + declared `path` dependency, hashes its artifact against the lock and fails with + `ERR_DEPENDENCY_UPSTREAM_DRIFT` when it is `drifted` or `unresolved`, passing the shared + `expectCheck` corpus arm; it is opt-in and not part of the default gate set. New public API: + `Dependencies.check`, `Dependencies.SnapshotReader`, `DependencySet.ofSnapshot` and a + `MetaDataLoader.fromUris` overload that takes a `DependencySet`. See `docs/features/metadata-dependencies.md`. + - **A model explorer site, built by `docsSiteFile()`.** The docs site now shows what the software does and how the model is designed: a main page with count tiles and the feature and quality cards with their roll-ups, a model map with slices (package, requirement subtree, diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 9ca93357a..d9dbba57c 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -49,7 +49,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/provider-composition-conformance/`](../fixtures/provider-composition-conformance/) | 18 (11 error-shape + 4 compose-load + 3 attr-ownership) | ✓ | ✓ | — (JVM registry via Java) | ✓ | ✓ | | [`fixtures/source-resolution-conformance/`](../fixtures/source-resolution-conformance/) | 25 cases | ✓ (reference implementation) | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/scope-conformance/`](../fixtures/scope-conformance/) | 10 cases | ✓ (reference implementation) | ✓ | inherits via Java | ✓ | ✓ | -| [`fixtures/dependency-conformance/`](../fixtures/dependency-conformance/) | 37 cases (26 resolution + 11 check) + 14 transport cases | ✓ (reference implementation; all three arms) | ✓ (resolution arm, all 26; no check, so the 11 check cases are not run) | inherits via Java | ✓ (resolution arm, all 26; no check, so the 11 check cases are not run) | ✓ (resolution arm, all 26 identically; no check yet, so the 11 check cases are not run; the transport arm is Node-only) | +| [`fixtures/dependency-conformance/`](../fixtures/dependency-conformance/) | 37 cases (26 resolution + 11 check) + 14 transport cases | ✓ (reference implementation; all three arms) | ✓ (resolution arm, all 26; check arm, all 11) | inherits via Java | ✓ (resolution arm, all 26; no check, so the 11 check cases are not run) | ✓ (resolution arm, all 26 identically; no check yet, so the 11 check cases are not run; the transport arm is Node-only) | | [`fixtures/shared-model-conformance/`](../fixtures/shared-model-conformance/) | 11 cases (7 publish + 4 refusal) | ✓ (reference implementation) | — (Phase 2) | — (Phase 2) | — (Phase 2) | ✓ (serializer arm: the 7 publish cases' document bytes; no publisher) | | [`fixtures/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) | — | — | — | — | @@ -480,9 +480,10 @@ above: one committed `cases.json`, no per-port fixture, no ledger. The corpus ha 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 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). + by TypeScript, in `cli/test/dependency-check-conformance.test.ts`, and by Java in + `DependencyCheckConformanceTest` (the engine behind `metaobjects:verify -Dmeta.verify.mode=deps`; + Kotlin runs the same goal). Python has no `deps check` yet and filters these cases out. The arm + exists so that each port's check is written against the same edge cases (Phase 2, #482). - **Transport (14 cases, `transport-cases.json`).** The `npm` and `python` transports `meta deps sync` uses to find an installed package: npm packages under a temp `node_modules`, Python packages on a temp import path, the directory each resolves to, the exact lock written, and each refusal. Run by diff --git a/docs/features/metadata-dependencies.md b/docs/features/metadata-dependencies.md index 2e504d276..53cf3613b 100644 --- a/docs/features/metadata-dependencies.md +++ b/docs/features/metadata-dependencies.md @@ -170,7 +170,7 @@ 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, Python's and Java's collection resolvers (Kotlin loads through -Java's) load dependency artifacts from the committed +Java's; a Spring app's runtime loader reads the same snapshot off the classpath) 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 @@ -644,6 +644,15 @@ Two rulings shape it: `.metaobjects/deps.lock.json` is on the classpath, so a Spring app whose entity `extends` a dependency's node does not fail at boot for want of one line of configuration. +**Java and Kotlin: runtime load and `verify` (shipped, S4 and S5).** A Spring app loads the committed +snapshot at runtime: `metaobjects:generate` ships `.metaobjects/deps.lock.json` and +`.metaobjects/deps/**` on the classpath, and the application loader reads them when the lock is +there, with the build-time scoping and error codes. `mvn metaobjects:verify -Dmeta.verify.mode=deps` +is the Java and Kotlin `meta deps check`: it resolves a `path` dependency, hashes its artifact against +the lock and fails with `ERR_DEPENDENCY_UPSTREAM_DRIFT` on `drifted` or `unresolved`. It is opt-in, and +an `npm` or `python` dependency is `unresolved` there (run the Node `meta deps check` for those). See +[the Java port page](../ports/java.md#consuming-a-shared-model-dependencies). + **`meta deps sync` and `check` on a Maven- or C#-shaped project (shipped).** Both commands read only `.metaobjects/config.json`, and finish with a proof that the dependency set loads. A project whose own metadata is in a build-tool directory (`src/main/resources/metadata`, a C# project's @@ -691,7 +700,7 @@ but **not** built yet — do not assume any of the following exists: an imported entity — "what's imported" is a load-time/codegen concept today, not a runtime one. - **Java, Kotlin, Python and C# as publishers** — Java, Kotlin and C# consume (S3 and S6); - publishing is TypeScript only. Spring apps do not yet auto-load the snapshot at runtime (S4). + publishing is TypeScript only. ## Verified by @@ -702,7 +711,8 @@ but **not** built yet — do not assume any of the following exists: 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. + check (`current`, `drifted`, `unresolved`), run by TypeScript and Java (Kotlin through the same + `metaobjects:verify` goal); Python has no check yet. Three artifacts under `fixtures/dependency-conformance/artifacts/` are pinned by sha256 in the README so the corpus itself can never silently drift. Python's runner asserts all 26 resolution cases identically to TypeScript's, with no diff --git a/docs/ports/java.md b/docs/ports/java.md index 05c15aef6..02da98cc6 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -204,8 +204,25 @@ the Maven build reads them offline and refuses a stale or hand-edited snapshot w 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. +[`metadata-dependencies.md`](../features/metadata-dependencies.md). + +**Spring runtime.** `metaobjects:generate` ships `.metaobjects/deps.lock.json` and +`.metaobjects/deps/**` as resources (target path `.metaobjects`) when the project has a lock, and +the Spring application loader reads them off the classpath: when `classpath:.metaobjects/deps.lock.json` +exists, each snapshot loads before `metaobjects.metadata-sources`, so an entity that `extends` an +imported node boots with no extra configuration. It is verified exactly as at build time (hash, +`ERR_DEPENDENCY_SNAPSHOT_STALE`, `ERR_DEPENDENCY_PACKAGE_NOT_OWNED`) and an imported node stays +load-only. When the project's `config.json` is on the classpath too (the file +`metaobjects.physical-naming-config` names, else `classpath:.metaobjects/config.json`), the lock is +also checked against its declared dependencies, and a config that declares dependencies with no lock +fails boot. If the lock is absent and nothing is declared, nothing changes. + +**Dependency drift.** `mvn metaobjects:verify -Dmeta.verify.mode=deps` is `meta deps check`: it +re-resolves each declared dependency's `path`, reads the publisher's `metaobjects.pkg.json`, hashes the +artifact and reports `current`, `drifted` or `unresolved` per dependency; the last two fail the build +with `ERR_DEPENDENCY_UPSTREAM_DRIFT`. It loads no model, writes nothing, and is not part of the default +gate set because it needs the publisher reachable. An `npm` or `python` dependency is found by the Node +tool, so it reports `unresolved` here: run `meta deps check` for it. ### Custom providers (optional) diff --git a/fixtures/dependency-conformance/README.md b/fixtures/dependency-conformance/README.md index 6239a36d3..4655894a9 100644 --- a/fixtures/dependency-conformance/README.md +++ b/fixtures/dependency-conformance/README.md @@ -240,12 +240,16 @@ climbs out of the package, and a Python module that is not a package are has no `deps check` yet, so its runner filters the `expectCheck` cases out. Not the transport arm: Python never syncs. - **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. Not the transport arm: Java never syncs. + (`DependencyConformanceTest`), the check arm (`DependencyCheckConformanceTest`, over + `Dependencies.check`, the engine of `metaobjects:verify -Dmeta.verify.mode=deps`), and the + resolution and load-time arms again through the Spring runtime's classpath reader + (`DependencySnapshotRuntimeConformanceTest`: no file path, the project's own files handed over as + URIs). Not the transport arm: Java never syncs. `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** — inherits via Java: it loads through the same loader and Maven plugin, and checks + through the same `metaobjects:verify` goal. The Kotlin generators are covered by a codegen test that an imported entity emits nothing. - **C#** — the resolution arm: resolution, load-time failure, lock/snapshot integrity, on the neutral source ladder and again through the explicit metadata directory (C# has no native diff --git a/server/java/core-spring/src/main/java/com/metaobjects/spring/MetaDataLoaderConfiguration.java b/server/java/core-spring/src/main/java/com/metaobjects/spring/MetaDataLoaderConfiguration.java index 7dc398945..c97065d3e 100644 --- a/server/java/core-spring/src/main/java/com/metaobjects/spring/MetaDataLoaderConfiguration.java +++ b/server/java/core-spring/src/main/java/com/metaobjects/spring/MetaDataLoaderConfiguration.java @@ -1,5 +1,7 @@ package com.metaobjects.spring; +import com.metaobjects.config.Dependencies; +import com.metaobjects.config.DependencySet; import com.metaobjects.config.NeutralConfig; import com.metaobjects.database.PhysicalNaming; import com.metaobjects.loader.LoaderOptions; @@ -24,9 +26,16 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; +import java.util.Optional; /** * Example Spring configuration showing how to set up MetaDataLoaders as beans. + * + *

When {@code classpath:.metaobjects/deps.lock.json} is present (shipped by {@code metaobjects:generate}), + * the application loader also loads the committed dependency snapshot under + * {@code classpath:.metaobjects/deps/} before the configured sources, verified as the build-time path is + * (FR-023).

* *

Spring Boot applications can use this pattern to automatically configure * MetaDataLoaders that will be discovered and registered by {@link MetaDataAutoConfiguration}.

@@ -132,20 +141,70 @@ public MetaDataLoader applicationMetaDataLoader() throws Exception { // ADR-0058: the naming is resolved BEFORE the load call so the loader's own // "no config found" WARN fires only when neither this property, the default // classpath:.metaobjects/config.json, nor the file: walk found a project config. - return MetaDataLoader.fromUris("applicationLoader", sourceURIs, opts, resolvePhysicalNaming()); + NeutralConfig projectConfig = readProjectConfig(); + PhysicalNaming naming = projectConfig == null ? null : projectConfig.getPhysicalNaming(); + return MetaDataLoader.fromUris("applicationLoader", sourceURIs, opts, naming, + resolveDependencies(projectConfig)); + } + + /** Where the committed dependency lock is read from (FR-023). Its presence is what turns the load on. */ + static final String DEPENDENCY_LOCK_LOCATION = "classpath:.metaobjects/" + Dependencies.LOCK_FILE; + + /** Where a dependency's committed snapshot artifact is read from, beside the lock. */ + static final String DEPENDENCY_SNAPSHOT_LOCATION = "classpath:.metaobjects/" + Dependencies.DEPS_DIR + "/"; + + /** + * FR-023 — the project's metadata dependencies, from the committed snapshot on the classpath + * ({@code metaobjects:generate} ships {@code .metaobjects/deps.lock.json} and + * {@code .metaobjects/deps/**} as resources). The lock is the trigger: with no lock on the classpath + * and no dependency declared in {@code projectConfig}, nothing is loaded and nothing changes. Verified + * exactly as at build time — same hash check, same error codes, same imported-is-load-only scoping — + * so an app whose entity {@code extends} an imported node boots, and a stale or missing snapshot + * fails at boot rather than as an unresolved {@code extends}. + */ + DependencySet resolveDependencies(NeutralConfig projectConfig) throws IOException { + Resource lockResource = locate(DEPENDENCY_LOCK_LOCATION); + Optional> lock = Optional.empty(); + if (lockResource.exists()) { + String text; + try (InputStream in = lockResource.getInputStream()) { + text = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + lock = Optional.of(Dependencies.parseLock(text, DEPENDENCY_LOCK_LOCATION)); + } + return DependencySet.ofSnapshot(Optional.ofNullable(projectConfig), lock, new Dependencies.SnapshotReader() { + @Override + public byte[] read(String name, String artifact) throws IOException { + Resource resource = locate(location(name, artifact)); + if (!resource.exists()) throw new IOException(location(name, artifact) + " does not exist"); + try (InputStream in = resource.getInputStream()) { + return in.readAllBytes(); + } + } + + @Override + public String describe(String name, String artifact) { + return location(name, artifact); + } + + private String location(String name, String artifact) { + return DEPENDENCY_SNAPSHOT_LOCATION + name + "/" + artifact; + } + }); } /** The default location read when {@code metaobjects.physical-naming-config} is unset. */ static final String DEFAULT_PHYSICAL_NAMING_CONFIG = "classpath:.metaobjects/config.json"; /** - * ADR-0058 — the project's physical-naming rules from {@link #physicalNamingConfig} + * ADR-0058 — the project's {@code .metaobjects/config.json} from {@link #physicalNamingConfig} * (a {@code classpath:}/{@code file:}/URL location, or a plain file path), else from - * {@value #DEFAULT_PHYSICAL_NAMING_CONFIG} when that resource exists, else {@code null} - * (the loader then walks up from a {@code file:} source and warns once if it finds - * nothing). A configured location that does not exist is an error, never a silent default. + * {@value #DEFAULT_PHYSICAL_NAMING_CONFIG} when that resource exists, else {@code null} (the loader + * then walks up from a {@code file:} source and warns once if it finds nothing). A configured + * location that does not exist is an error, never a silent default. Its {@code naming} is the + * physical-naming rules; its {@code dependencies} are what the lock on the classpath is checked against. */ - private PhysicalNaming resolvePhysicalNaming() throws IOException { + private NeutralConfig readProjectConfig() throws IOException { String location = physicalNamingConfig; boolean explicit = location != null && !location.isBlank(); if (!explicit) location = DEFAULT_PHYSICAL_NAMING_CONFIG; @@ -161,7 +220,7 @@ private PhysicalNaming resolvePhysicalNaming() throws IOException { try (InputStream in = resource.getInputStream()) { content = new String(in.readAllBytes(), StandardCharsets.UTF_8); } - return NeutralConfig.parse(content, location).getPhysicalNaming(); + return NeutralConfig.parse(content, location); } /** A Spring location when it has a scheme, otherwise a file-system path. */ diff --git a/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderConfigurationDependenciesTest.java b/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderConfigurationDependenciesTest.java new file mode 100644 index 000000000..ce583cca3 --- /dev/null +++ b/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderConfigurationDependenciesTest.java @@ -0,0 +1,174 @@ +package com.metaobjects.spring; + +import com.metaobjects.ErrorCode; +import com.metaobjects.MetaDataException; +import com.metaobjects.loader.MetaDataLoader; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * FR-023 S4 — a Spring app loads the committed dependency snapshot at runtime. {@code metaobjects:generate} + * ships {@code .metaobjects/deps.lock.json} and {@code .metaobjects/deps/**} on the classpath; the lock's + * presence is the trigger, and the load is verified exactly as the build-time path is (hash, lock/config + * agreement, ownership) with the same error codes. + * + *

The classpath here is a temporary directory laid out like an app's jar (the snapshot comes from the + * shared {@code fixtures/dependency-conformance} artifact), put on the thread context class loader the + * resource resolution reads.

+ */ +public class MetaDataLoaderConfigurationDependenciesTest { + + private static final String LOCK = """ + {"schema_version":1,"dependencies":{"acme-common":{"version":"1.0.0","metamodelVersion":"1.0", + "resolvedFrom":{"path":"../acme-common/metaobjects"},"artifact":"acme-common.metaobjects.json", + "integrity":"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages":["acme::common"],"nodes":["acme::common::Address","acme::common::Audited","acme::common::Customer"]}}} + """; + private static final String CONFIG = """ + {"schema_version":1,"sources":[],"dependencies":[{"name":"acme-common","path":"../acme-common/metaobjects"}]} + """; + private static final String VIP = """ + {"metadata.root":{"package":"app","children":[{"object.entity":{"name":"Vip","extends":"acme::common::Audited", + "children":[{"source.rdb":{"@table":"vips"}},{"field.long":{"name":"id"}}, + {"identity.primary":{"name":"pk","@fields":["id"]}}]}}]}} + """; + private static final String INTRUDER = """ + {"metadata.root":{"package":"acme::common","children":[{"object.value":{"name":"Note", + "children":[{"field.string":{"name":"text"}}]}}]}} + """; + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + MetaDataAutoConfiguration.class, ConfigurationPropertiesAutoConfiguration.class)); + + private static Path artifact() { + Path dir = Paths.get("").toAbsolutePath(); + while (dir != null && !Files.isDirectory(dir.resolve("fixtures"))) dir = dir.getParent(); + return dir.resolve("fixtures/dependency-conformance/artifacts/acme-common-v1.json"); + } + + private static void write(Path path, String content) throws Exception { + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8); + } + + /** An app's classpath root: its own metadata under {@code metadata/}, and what the build shipped. */ + private Path app(String ownMetadata, boolean lock, boolean snapshot, boolean config) throws Exception { + Path root = tmp.newFolder().toPath(); + write(root.resolve("metadata/app.json"), ownMetadata); + if (lock) write(root.resolve(".metaobjects/deps.lock.json"), LOCK); + if (snapshot) { + Path target = root.resolve(".metaobjects/deps/acme-common/acme-common.metaobjects.json"); + Files.createDirectories(target.getParent()); + Files.copy(artifact(), target); + } + if (config) write(root.resolve(".metaobjects/config.json"), CONFIG); + return root; + } + + private void run(Path root, Consumer assertions) + throws Exception { + ClassLoader previous = Thread.currentThread().getContextClassLoader(); + try (URLClassLoader cl = new URLClassLoader(new URL[]{root.toUri().toURL()}, previous)) { + Thread.currentThread().setContextClassLoader(cl); + runner.withPropertyValues("metaobjects.metadata-sources=" + root.resolve("metadata/app.json").toUri()) + .run(assertions::accept); + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + } + + private static ErrorCode codeOf(Throwable failure) { + for (Throwable t = failure; t != null; t = t.getCause()) { + if (t instanceof MetaDataException e && e.getCode().isPresent()) return e.getCode().get(); + } + return null; + } + + @Test + public void anEntityExtendingAnImportedNodeBootsFromTheClasspathSnapshot() throws Exception { + run(app(VIP, true, true, true), ctx -> { + assertThat(ctx).hasNotFailed(); + MetaDataLoader loader = ctx.getBean(MetaDataLoader.class); + // The snapshot loaded FIRST, so the project's own entity could extend it... + assertThat(loader.getRoot().getDependencySet().imported("acme::common::Customer")).isTrue(); + assertThat(loader.getRoot().getDependencySet().imported("app::Vip")).isFalse(); + // ...and imported is load-only: it is not this project's to generate. + assertThat(loader.getRoot().getDependencySet().inScope("acme::common::Customer")).isFalse(); + assertThat(loader.getRoot().getDependencySet().inScope("app::Vip")).isTrue(); + }); + } + + @Test + public void theLockAloneIsEnoughWhenNoConfigIsOnTheClasspath() throws Exception { + run(app(VIP, true, true, false), ctx -> { + assertThat(ctx).hasNotFailed(); + assertThat(ctx.getBean(MetaDataLoader.class).getRoot().getDependencySet().isEmpty()).isFalse(); + }); + } + + @Test + public void withNoLockOnTheClasspathNothingChanges() throws Exception { + String plain = """ + {"metadata.root":{"package":"app","children":[{"object.entity":{"name":"Order","children":[ + {"source.rdb":{"@table":"orders"}},{"field.long":{"name":"id"}}, + {"identity.primary":{"name":"pk","@fields":["id"]}}]}}]}} + """; + run(app(plain, false, false, false), ctx -> { + assertThat(ctx).hasNotFailed(); + assertThat(ctx.getBean(MetaDataLoader.class).getRoot().getDependencySet().isEmpty()).isTrue(); + }); + } + + @Test + public void aSnapshotThatNoLongerMatchesTheLockFailsBootAsStale() throws Exception { + Path root = app(VIP, true, true, true); + Files.writeString(root.resolve(".metaobjects/deps/acme-common/acme-common.metaobjects.json"), + Files.readString(artifact()) + " ", StandardCharsets.UTF_8); + run(root, ctx -> { + assertThat(ctx).hasFailed(); + assertThat(codeOf(ctx.getStartupFailure())).isEqualTo(ErrorCode.ERR_DEPENDENCY_SNAPSHOT_STALE); + }); + } + + @Test + public void aMissingSnapshotFailsBootAsStale() throws Exception { + run(app(VIP, true, false, true), ctx -> { + assertThat(ctx).hasFailed(); + assertThat(codeOf(ctx.getStartupFailure())).isEqualTo(ErrorCode.ERR_DEPENDENCY_SNAPSHOT_STALE); + }); + } + + @Test + public void aConfigDeclaringDependenciesWithNoLockFailsBootAsStale() throws Exception { + run(app(VIP, false, false, true), ctx -> { + assertThat(ctx).hasFailed(); + assertThat(codeOf(ctx.getStartupFailure())).isEqualTo(ErrorCode.ERR_DEPENDENCY_SNAPSHOT_STALE); + }); + } + + @Test + public void aNodeDeclaredIntoADependencysPackageIsRefused() throws Exception { + run(app(INTRUDER, true, true, true), ctx -> { + assertThat(ctx).hasFailed(); + assertThat(codeOf(ctx.getStartupFailure())).isEqualTo(ErrorCode.ERR_DEPENDENCY_PACKAGE_NOT_OWNED); + }); + } +} 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 a0338bf32..28ecaad53 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 @@ -1,6 +1,7 @@ package com.metaobjects.mojo; import com.metaobjects.MetaDataException; +import com.metaobjects.config.Dependencies; import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorBase; import com.metaobjects.generator.util.GeneratedFileWriter; @@ -8,6 +9,7 @@ import com.metaobjects.generator.util.OrphanSweep; import com.metaobjects.generator.util.RouteNaming; import com.metaobjects.loader.MetaDataLoader; +import org.apache.maven.model.Resource; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; @@ -127,8 +129,44 @@ protected void executeGenerators(MetaDataLoader loader, List generato } registerGeneratedSourceRoots(); + if (loader != null) { + registerDependencySnapshotResources(resolveProjectConfigDir(getSourceDir()) + .orElse(getProjectBaseDir().toPath().toAbsolutePath().normalize())); + } + } + + /** + * FR-023 — ship the committed dependency snapshot on the module's classpath. A Spring app loads + * its imported nodes at runtime from {@code .metaobjects/deps.lock.json} and + * {@code .metaobjects/deps/**}; neither lives under {@code src/main/resources}, so without this the + * app would boot with an unresolved {@code extends}. Adds them as a resource directory (target path + * {@code .metaobjects}) when the project has a lock and nothing already ships it. + * + * @param configDir the directory holding the project's {@code .metaobjects/} folder + */ + void registerDependencySnapshotResources(Path configDir) { + if (project == null) return; + Path metaobjects = configDir.resolve(DEPENDENCY_RESOURCE_TARGET); + if (!Files.isRegularFile(metaobjects.resolve(Dependencies.LOCK_FILE))) return; + String directory = metaobjects.toAbsolutePath().normalize().toString(); + List existing = project.getResources(); + if (existing != null) { + for (Resource r : existing) { + if (directory.equals(r.getDirectory()) && DEPENDENCY_RESOURCE_TARGET.equals(r.getTargetPath())) return; + } + } + Resource snapshot = new Resource(); + snapshot.setDirectory(directory); + snapshot.setTargetPath(DEPENDENCY_RESOURCE_TARGET); + snapshot.setIncludes(List.of(Dependencies.LOCK_FILE, Dependencies.DEPS_DIR + "/**")); + project.addResource(snapshot); + getLog().info("MetaData Mojo > Shipping the dependency lock and snapshot (" + directory + + ") on the classpath at " + DEPENDENCY_RESOURCE_TARGET + "/"); } + /** Where the snapshot lands on the classpath, and the folder name it is read from. */ + static final String DEPENDENCY_RESOURCE_TARGET = ".metaobjects"; + /** * One advisory line when an owned generator copy under {@code codegen/} is identical to the * shipped generator. Never changes the build outcome: a failed comparison says nothing. diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java index 2b85c1dac..23030af96 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java @@ -1,5 +1,8 @@ package com.metaobjects.mojo; +import com.metaobjects.MetaDataException; +import com.metaobjects.config.Dependencies; +import com.metaobjects.config.NeutralConfig; import com.metaobjects.config.WireFormat; import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorBase; @@ -36,6 +39,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.TreeSet; @@ -71,6 +75,12 @@ * shared {@link TemplateVerify} helper (which reuses the render * {@link com.metaobjects.render.Verify} engine). Covers BOTH Java and Kotlin * generation, since they share this one goal. + *
  • {@code deps} — FR-023 dependency drift: re-resolve each declared dependency's {@code path} and + * compare the publisher's artifact with {@code .metaobjects/deps.lock.json} + * ({@code current} / {@code drifted} / {@code unresolved}; the last two fail with + * {@code ERR_DEPENDENCY_UPSTREAM_DRIFT}). Opt-in, never part of the default set: it needs the + * publisher reachable, which CI may not have. It loads no model and writes nothing. Covers Java and + * Kotlin alike, and is the counterpart of {@code meta deps check} / {@code verify --deps}.
  • *
  • {@code db} — NOT supported by the Maven port; schema drift is the migrate engine * (Node-owned, ADR-0015). A {@code db} value fails clearly.
  • * @@ -111,6 +121,7 @@ private String genGoal() { static final String MODE_CODEGEN = "codegen"; static final String MODE_TEMPLATES = "templates"; static final String MODE_DB = "db"; + static final String MODE_DEPS = "deps"; /** * Which verify mode to run (ADR-0021 D2). Unset (the default) runs the cross-port default @@ -188,20 +199,65 @@ public void execute() throws MojoExecutionException, MojoFailureException { verifyTemplates(); return; } + if (MODE_DEPS.equalsIgnoreCase(m)) { + verifyDeps(); + return; + } if (MODE_DB.equalsIgnoreCase(m)) { throw new MojoFailureException( "meta:verify mode 'db' is not supported by the Maven port; schema verify is the " - + "migrate engine (Node-owned, ADR-0015). Use mode 'codegen' or 'templates'."); + + "migrate engine (Node-owned, ADR-0015). Use mode 'codegen', 'templates' or 'deps'."); } if (!MODE_CODEGEN.equalsIgnoreCase(m)) { throw new MojoFailureException( - "meta:verify: unknown mode '" + mode + "'. Valid modes: 'codegen', 'templates' (or unset for both)."); + "meta:verify: unknown mode '" + mode + "'. Valid modes: 'codegen', 'templates' (or unset for both), " + + "and 'deps' (dependency drift, opt-in)."); } refuseSharedGeneratorDirs(); verifyCodegen(); } + /** + * FR-023 — the {@code deps} mode. Re-resolves each dependency the project's + * {@code .metaobjects/config.json} declares and compares the publisher's artifact with the lock, + * reporting one line per dependency. A pure function of the config, the lock and the publisher's + * directory (see {@link Dependencies#check}): no snapshot is read and no model is loaded. + */ + private void verifyDeps() throws MojoFailureException { + Path configDir = resolveProjectConfigDir(getSourceDir()) + .orElse(getProjectBaseDir().toPath().toAbsolutePath().normalize()); + List> specs; + Optional> lock; + try { + specs = NeutralConfig.read(configDir).map(NeutralConfig::getDependencies).orElse(List.of()); + if (specs.isEmpty()) { + getLog().info(PREFIX + "deps: no dependencies declared \u2014 nothing to check."); + return; + } + lock = Dependencies.readLock(configDir); + } catch (MetaDataException e) { + throw new MojoFailureException(PREFIX + "deps: " + e.getMessage(), e); + } + + List results = Dependencies.check(configDir, specs, lock); + int failing = 0; + for (Dependencies.CheckResult r : results) { + if (r.status() == Dependencies.CheckStatus.CURRENT) { + getLog().info(PREFIX + "deps: " + r.line()); + } else { + failing++; + getLog().error(PREFIX + "deps: " + r.line()); + } + } + if (failing > 0) { + throw new MojoFailureException(PREFIX + "deps: " + failing + " of " + results.size() + " dependenc" + + (results.size() == 1 ? "y" : "ies") + " drifted or unresolved (" + + Dependencies.UPSTREAM_DRIFT + ")."); + } + getLog().info(PREFIX + "deps: every dependency's installed artifact matches the lock."); + } + /** Set once the field lint and requirement gate have run, so a two-gate run reports them once. */ private boolean advisoriesDone; diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataGeneratorMojoDependencyResourcesTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataGeneratorMojoDependencyResourcesTest.java new file mode 100644 index 000000000..73d1cffb8 --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataGeneratorMojoDependencyResourcesTest.java @@ -0,0 +1,74 @@ +package com.metaobjects.mojo; + +import org.apache.maven.model.Resource; +import org.apache.maven.project.MavenProject; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertEquals; + +/** + * FR-023 S4 — {@code metaobjects:generate} ships the committed dependency lock and snapshot on the module's + * classpath, so the Spring runtime can load them (see {@code MetaDataLoaderConfigurationDependenciesTest}). + */ +public class MetaDataGeneratorMojoDependencyResourcesTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private MetaDataGeneratorMojo mojo; + private MavenProject project; + + @Before + public void setUp() { + mojo = new MetaDataGeneratorMojo(); + project = Mockito.mock(MavenProject.class); + mojo.project = project; + } + + @Test + public void aProjectWithALockShipsItAndItsSnapshotUnderDotMetaobjects() throws Exception { + Path configDir = tmp.newFolder().toPath(); + Files.createDirectories(configDir.resolve(".metaobjects")); + Files.writeString(configDir.resolve(".metaobjects/deps.lock.json"), "{}"); + + mojo.registerDependencySnapshotResources(configDir); + + ArgumentCaptor added = ArgumentCaptor.forClass(Resource.class); + Mockito.verify(project).addResource(added.capture()); + Resource r = added.getValue(); + assertEquals(configDir.resolve(".metaobjects").toAbsolutePath().normalize().toString(), r.getDirectory()); + assertEquals(".metaobjects", r.getTargetPath()); + assertEquals(List.of("deps.lock.json", "deps/**"), r.getIncludes()); + } + + @Test + public void aProjectWithNoLockShipsNothing() throws Exception { + mojo.registerDependencySnapshotResources(tmp.newFolder().toPath()); + + Mockito.verify(project, Mockito.never()).addResource(Mockito.any()); + } + + @Test + public void runningTwiceDoesNotAddTheResourceTwice() throws Exception { + Path configDir = tmp.newFolder().toPath(); + Files.createDirectories(configDir.resolve(".metaobjects")); + Files.writeString(configDir.resolve(".metaobjects/deps.lock.json"), "{}"); + Resource already = new Resource(); + already.setDirectory(configDir.resolve(".metaobjects").toAbsolutePath().normalize().toString()); + already.setTargetPath(".metaobjects"); + Mockito.when(project.getResources()).thenReturn(List.of(already)); + + mojo.registerDependencySnapshotResources(configDir); + + Mockito.verify(project, Mockito.never()).addResource(Mockito.any()); + } +} diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyDepsModeTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyDepsModeTest.java new file mode 100644 index 000000000..67ab07996 --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyDepsModeTest.java @@ -0,0 +1,123 @@ +package com.metaobjects.mojo; + +import com.metaobjects.config.Dependencies; +import org.apache.maven.plugin.MojoFailureException; +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.Collections; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * FR-023 S5 — {@code metaobjects:verify -Dmeta.verify.mode=deps}: the Java/Kotlin counterpart of + * {@code meta deps check} / {@code verify --deps}. The shared corpus arm + * ({@code DependencyCheckConformanceTest}) pins the statuses; these tests pin the goal around it: mode + * dispatch, the report, and what fails the build. + */ +public class MetaDataVerifyDepsModeTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static Path artifact(String name) { + Path dir = Paths.get("").toAbsolutePath(); + while (dir != null && !Files.isDirectory(dir.resolve("fixtures"))) dir = dir.getParent(); + return dir.resolve("fixtures/dependency-conformance/artifacts/" + name); + } + + private static void write(Path path, String content) throws Exception { + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8); + } + + /** A project beside its publisher: the lock pins {@code lockedArtifact}, the publisher ships {@code publishedArtifact}. */ + private Path project(String lockedArtifact, String publishedArtifact, boolean withLock) throws Exception { + Path root = tmp.newFolder().toPath(); + byte[] locked = Files.readAllBytes(artifact(lockedArtifact)); + byte[] published = Files.readAllBytes(artifact(publishedArtifact)); + Path project = root.resolve("app"); + write(project.resolve("metaobjects/meta.app.json"), "{\"metadata.root\":{\"package\":\"app\",\"children\":[]}}"); + write(project.resolve(".metaobjects/config.json"), + "{\"schema_version\":1,\"sources\":[],\"dependencies\":[{\"name\":\"acme-common\",\"path\":\"../acme-common/metaobjects\"}]}"); + if (withLock) { + Path snapshot = project.resolve(".metaobjects/deps/acme-common/acme-common.metaobjects.json"); + Files.createDirectories(snapshot.getParent()); + Files.write(snapshot, locked); + write(project.resolve(".metaobjects/deps.lock.json"), + "{\"schema_version\":1,\"dependencies\":{\"acme-common\":{\"version\":\"1.0.0\",\"metamodelVersion\":\"1.0\"," + + "\"resolvedFrom\":{\"path\":\"../acme-common/metaobjects\"},\"artifact\":\"acme-common.metaobjects.json\"," + + "\"integrity\":\"" + Dependencies.sha256Integrity(locked) + "\",\"packages\":[\"acme::common\"]," + + "\"nodes\":[\"acme::common::Address\",\"acme::common::Audited\",\"acme::common::Customer\"]}}}"); + } + Path publisher = root.resolve("acme-common/metaobjects"); + Files.createDirectories(publisher); + Files.write(publisher.resolve("acme-common.metaobjects.json"), published); + write(publisher.resolve("metaobjects.pkg.json"), + "{\"schema_version\":1,\"name\":\"acme-common\",\"version\":\"1.1.0\",\"metamodelVersion\":\"1.0\"," + + "\"artifact\":\"acme-common.metaobjects.json\",\"integrity\":\"" + Dependencies.sha256Integrity(published) + + "\",\"packages\":[\"acme::common\"]," + + "\"nodes\":[\"acme::common::Address\",\"acme::common::Audited\",\"acme::common::Customer\"]}"); + return project; + } + + private static MetaDataVerifyMojo mojo(Path project) { + MetaDataVerifyMojo verify = new MetaDataVerifyMojo(); + verify.setLoader(LoaderParam.builder("verify-deps-test") + .withClassname("com.metaobjects.loader.MetaDataLoader") + .withSourceDir(project.resolve("metaobjects").toString()) + .build()); + verify.setGenerators(Collections.emptyList()); + verify.setGlobals(Collections.emptyMap()); + verify.setMode("deps"); + return verify; + } + + @Test + public void aMatchingPublisherPasses() throws Exception { + mojo(project("acme-common-v1.json", "acme-common-v1.json", true)).execute(); + } + + @Test + public void aPublisherThatMovedPastTheLockFailsAsDrifted() throws Exception { + try { + mojo(project("acme-common-v1.json", "acme-common-v1-widened.json", true)).execute(); + fail("expected the drifted dependency to fail the build"); + } catch (MojoFailureException e) { + assertTrue(e.getMessage(), e.getMessage().contains("ERR_DEPENDENCY_UPSTREAM_DRIFT")); + assertTrue(e.getMessage(), e.getMessage().contains("1 of 1 dependency")); + } + } + + @Test + public void aDependencyNotYetInTheLockFailsAsUnresolved() throws Exception { + try { + mojo(project("acme-common-v1.json", "acme-common-v1.json", false)).execute(); + fail("a check that cannot check must not pass"); + } catch (MojoFailureException e) { + assertTrue(e.getMessage(), e.getMessage().contains("ERR_DEPENDENCY_UPSTREAM_DRIFT")); + } + } + + @Test + public void aProjectDeclaringNothingHasNothingToCheck() throws Exception { + Path project = tmp.newFolder().toPath(); + Files.createDirectories(project.resolve("metaobjects")); + write(project.resolve(".metaobjects/config.json"), "{\"schema_version\":1,\"sources\":[]}"); + mojo(project).execute(); + } + + @Test + public void depsIsOptInAndNotPartOfTheDefaultGateSet() throws Exception { + // A drifted dependency does not fail the default set: it needs the publisher reachable, which CI may not have. + MetaDataVerifyMojo verify = mojo(project("acme-common-v1.json", "acme-common-v1-widened.json", true)); + verify.setMode(""); + verify.execute(); + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/Dependencies.java b/server/java/metadata/src/main/java/com/metaobjects/config/Dependencies.java index fb7cc27ee..ad1ba8954 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/config/Dependencies.java +++ b/server/java/metadata/src/main/java/com/metaobjects/config/Dependencies.java @@ -85,9 +85,50 @@ private Dependencies() {} 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. */ + /** + * A resolved dependency, ready to load: where its artifact is, the id it loads under, and what it + * exports. A snapshot read from a directory carries {@code artifactPath}; one read from the + * classpath (the Spring runtime, no project directory) has no path and carries the verified bytes + * as {@code content} instead. + */ public record ResolvedDependency(String name, String version, List packages, - List nodes, Path artifactPath, String sourceId) {} + List nodes, Path artifactPath, String sourceId, String content) {} + + /** + * Where a committed snapshot artifact is read from: the project directory at build time, the + * classpath at runtime. {@link #read} returns the artifact's bytes, or throws {@link IOException} + * when there are none; {@link #describe} names the artifact's location for a message. + */ + public interface SnapshotReader { + byte[] read(String name, String artifact) throws IOException; + + String describe(String name, String artifact); + + /** The artifact's file, when it is one; {@code null} for a reader with no file (the classpath). */ + default Path path(String name, String artifact) { + return null; + } + } + + /** The snapshot as {@code /.metaobjects/deps//}. */ + public static SnapshotReader directoryReader(Path configDir) { + return new SnapshotReader() { + @Override + public Path path(String name, String artifact) { + return configDir.resolve(METAOBJECTS_DIR).resolve(DEPS_DIR).resolve(name).resolve(artifact); + } + + @Override + public byte[] read(String name, String artifact) throws IOException { + return Files.readAllBytes(path(name, artifact)); + } + + @Override + public String describe(String name, String artifact) { + return path(name, artifact).toString(); + } + }; + } /** {@code dep:/} — a dependency artifact's source id. */ public static String sourceId(String name, String artifact) { @@ -130,13 +171,24 @@ private static MetaDataException lockInvalid(String message) { public static Optional> readLock(Path configDir) { Path path = configDir.resolve(METAOBJECTS_DIR).resolve(LOCK_FILE); if (!Files.isRegularFile(path)) return Optional.empty(); - JsonElement raw; + String text; try { - raw = JsonParser.parseString(Files.readString(path, StandardCharsets.UTF_8)); - } catch (IOException | JsonSyntaxException e) { + text = Files.readString(path, StandardCharsets.UTF_8); + } catch (IOException e) { throw lockInvalid(path + " exists but could not be read as JSON: " + e.getMessage()); } - return Optional.of(validateLock(raw)); + return Optional.of(parseLock(text, path.toString())); + } + + /** Parse and validate a lock's text; {@code origin} names where it came from in the error. */ + public static Map parseLock(String text, String origin) { + JsonElement raw; + try { + raw = JsonParser.parseString(text); + } catch (JsonSyntaxException e) { + throw lockInvalid(origin + " exists but could not be read as JSON: " + e.getMessage()); + } + return validateLock(raw); } /** Validate a parsed lock (DESIGN §3.3); returns its entries keyed by dependency name, in key order. */ @@ -286,6 +338,16 @@ public static List verifySnapshot(Path configDir, List> lock) { List declared = new ArrayList<>(); for (Map spec : specs) declared.add(spec.get("name")); + return verifySnapshot(declared, lock, directoryReader(configDir)); + } + + /** + * {@link #verifySnapshot(Path, List, Optional)} over any {@link SnapshotReader}. {@code declared} is + * the dependency names the project's config declares. A reader with no file path (the classpath) has its + * verified bytes ride on each {@link ResolvedDependency}, which has nothing to re-read them from. + */ + public static List verifySnapshot(List declared, Optional> lock, + SnapshotReader reader) { if (lock.isEmpty()) { if (declared.isEmpty()) return List.of(); @@ -310,18 +372,17 @@ public static List verifySnapshot(Path configDir, List verifySnapshot(Path configDir, List verifySnapshot(Path configDir, List MANIFEST_KEYS = Set.of( + "schema_version", "name", "version", "metamodelVersion", "artifact", "integrity", "packages", "nodes"); + + /** What a check found for one dependency. */ + public enum CheckStatus { CURRENT, DRIFTED, UNRESOLVED } + + /** + * One declared dependency's check result. {@code lock*} are {@code null} exactly when the lock has no + * entry; {@code installed*} exactly when resolution failed. {@code detail} is the reason, for + * {@link CheckStatus#UNRESOLVED} only. + */ + public record CheckResult(String name, CheckStatus status, String lockVersion, String lockIntegrity, + String installedVersion, String installedIntegrity, String detail) { + + /** {@code : current|drifted|unresolved — …}, the line {@code meta deps check} prints. */ + public String line() { + String lockDesc = (lockVersion == null ? "(none)" : lockVersion) + " (" + + (lockIntegrity == null ? "n/a" : hash8(lockIntegrity)) + ")"; + String installedDesc = (installedVersion == null ? "(none)" : installedVersion) + " (" + + (installedIntegrity == null ? "n/a" : hash8(installedIntegrity)) + ")"; + return switch (status) { + case CURRENT -> name + ": current \u2014 " + lockDesc; + case DRIFTED -> name + ": drifted \u2014 lock " + lockDesc + ", installed " + installedDesc + + "; run meta deps sync and review the artifact diff"; + case UNRESOLVED -> name + ": unresolved \u2014 " + + (detail == null ? "could not resolve the dependency" : detail) + + "; run meta deps sync once the dependency is reachable"; + }; + } + } + + /** The first 8 hex digits of an integrity value, as the Node tool prints it. */ + public static String hash8(String integrity) { + String hex = integrity.startsWith(INTEGRITY_PREFIX) ? integrity.substring(INTEGRITY_PREFIX.length()) : integrity; + return hex.substring(0, Math.min(8, hex.length())); + } + + /** + * Re-resolve each declared dependency and compare its publisher's artifact with the lock. A pure + * function of the config, the lock and the publisher's directory: it reads no snapshot and loads no + * model, and writes nothing. Reference: {@code cli/src/lib/dependency-sync.ts} {@code checkDependencies}. + * + *

    Only the {@code path} transport is resolved here: an {@code npm} or {@code python} dependency + * is found by the Node tool, so it reports {@link CheckStatus#UNRESOLVED} and names the command to + * run. A check that cannot check must not pass. Results are in dependency-NAME order. + * + * @param configDir the directory holding the project's {@code .metaobjects/} folder; a {@code path} + * is resolved against it + */ + public static List check(Path configDir, List> specs, + Optional> lock) { + Map entries = lock.orElse(Map.of()); + List> sorted = new ArrayList<>(specs); + sorted.sort((a, b) -> a.get("name").compareTo(b.get("name"))); + List results = new ArrayList<>(); + for (Map spec : sorted) { + String name = spec.get("name"); + LockEntry lockEntry = entries.get(name); + try { + Path dir = resolveDependencyDir(configDir, spec); + JsonObject manifest = readManifest(dir); + String artifact = manifest.get("artifact").getAsString(); + byte[] bytes = readArtifact(dir.resolve(artifact), artifact); + // Re-hash the bytes actually on disk; never take the manifest's own integrity on faith. + String installed = sha256Integrity(bytes); + String version = manifest.get("version").getAsString(); + if (lockEntry == null) { + results.add(new CheckResult(name, CheckStatus.UNRESOLVED, null, null, version, installed, + "declared, and resolves, but is not in the lock yet")); + continue; + } + results.add(new CheckResult(name, + installed.equals(lockEntry.integrity()) ? CheckStatus.CURRENT : CheckStatus.DRIFTED, + lockEntry.version(), lockEntry.integrity(), version, installed, null)); + } catch (CheckUnresolved e) { + results.add(new CheckResult(name, CheckStatus.UNRESOLVED, + lockEntry == null ? null : lockEntry.version(), + lockEntry == null ? null : lockEntry.integrity(), null, null, e.getMessage())); + } + } + return results; + } + + /** A dependency this check could not read; carries the reason. */ + private static final class CheckUnresolved extends RuntimeException { + CheckUnresolved(String message) { + super(message, null, false, false); + } + } + + private static Path resolveDependencyDir(Path configDir, Map spec) { + if (!spec.containsKey("path")) { + String transport = spec.containsKey("npm") ? "npm" : "python"; + throw new CheckUnresolved("the \"" + transport + "\" transport is resolved by the Node tool, not the Maven " + + "plugin; run `meta deps check`"); + } + Path dir = configDir.resolve(spec.get("path")).normalize(); + if (!Files.isDirectory(dir)) { + throw new CheckUnresolved("the \"path\" transport resolved to " + dir + ", but no directory exists there"); + } + return dir; + } + + private static byte[] readArtifact(Path path, String artifact) { + try { + return Files.readAllBytes(path); + } catch (IOException e) { + throw new CheckUnresolved("the manifest names artifact \"" + artifact + "\", but it is missing at " + path); + } + } + + /** Read and validate {@code

    /metaobjects.pkg.json} and confirm the artifact beside it hashes to its integrity. */ + private static JsonObject readManifest(Path dir) { + Path manifestPath = dir.resolve(MANIFEST_FILE); + String text; + try { + text = Files.readString(manifestPath, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new CheckUnresolved("no " + MANIFEST_FILE + " found at " + manifestPath); + } + JsonElement raw; + try { + raw = JsonParser.parseString(text); + } catch (JsonSyntaxException e) { + throw new CheckUnresolved(manifestPath + " is not valid JSON: " + e.getMessage()); + } + JsonObject obj; + try { + obj = validateManifest(raw); + } catch (MetaDataException e) { + throw new CheckUnresolved(manifestPath + " failed schema validation \u2014 " + e.getMessage()); + } + String artifact = obj.get("artifact").getAsString(); + byte[] bytes = readArtifact(dir.resolve(artifact), artifact); + String actual = sha256Integrity(bytes); + if (!actual.equals(obj.get("integrity").getAsString())) { + throw new CheckUnresolved(dir.resolve(artifact) + " hashes to " + actual + ", but the manifest records " + + obj.get("integrity").getAsString()); + } + return obj; + } + + /** The manifest schema (DESIGN §3.2): the same strict shape the Node tool validates. */ + private static JsonObject validateManifest(JsonElement raw) { + String context = "manifest"; + if (!raw.isJsonObject()) throw lockInvalid("must be an object"); + JsonObject obj = raw.getAsJsonObject(); + for (String key : obj.keySet()) { + if (!MANIFEST_KEYS.contains(key)) throw lockInvalid("unknown key(s): [" + key + "]"); + } + JsonElement version = obj.get("schema_version"); + if (version == null || !version.isJsonPrimitive() || !version.getAsJsonPrimitive().isNumber() + || version.getAsDouble() != 1) { + throw lockInvalid("'schema_version' must be 1"); + } + JsonElement name = obj.get("name"); + if (name == null || !name.isJsonPrimitive() || !name.getAsJsonPrimitive().isString() + || !NAME.matcher(name.getAsString()).matches()) { + throw lockInvalid("'name' must match ^[a-z0-9][a-z0-9._-]*$"); + } + nonEmptyString(obj, "version", context); + metamodelVersion(obj, context); + artifact(obj, context); + integrity(obj, context); + sortedStrings(obj, "packages", context); + sortedStrings(obj, "nodes", context); + return obj; + } + // --------------------------------------------------------------------------------------- // Scope diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/DependencySet.java b/server/java/metadata/src/main/java/com/metaobjects/config/DependencySet.java index cefa4dc6b..93b735747 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/config/DependencySet.java +++ b/server/java/metadata/src/main/java/com/metaobjects/config/DependencySet.java @@ -17,6 +17,7 @@ import com.metaobjects.MetaRoot; import com.metaobjects.loader.FileSource; +import com.metaobjects.loader.InMemoryStringSource; import com.metaobjects.loader.MetaDataSource; import java.nio.file.Path; @@ -90,6 +91,32 @@ public static DependencySet of(Path configDir, Optional config) { return new DependencySet(resolved, include, migrate); } + /** + * Resolve the dependencies of a runtime that has no project directory — a Spring app whose + * {@code .metaobjects/} folder is on the classpath. The lock and the snapshot are read through + * {@code reader}; {@code config} is the project's {@code config.json} when that is on the classpath + * too. With it, the declared dependencies are checked against the lock exactly as at build time + * ({@link Dependencies#verifySnapshot}); without it the lock is the only record of what was declared. + * + * @param lock the parsed lock, empty when none is on the classpath + */ + public static DependencySet ofSnapshot(Optional config, + Optional> lock, + Dependencies.SnapshotReader reader) { + List declared = new ArrayList<>(); + if (config.isPresent()) { + for (java.util.Map spec : config.get().getDependencies()) declared.add(spec.get("name")); + } else if (lock.isPresent()) { + declared.addAll(lock.get().keySet()); + } + List include = config.map(NeutralConfig::getScopeInclude).orElse(List.of()); + List migrate = config.map(NeutralConfig::getMigrateScope).orElse(null); + List resolved = declared.isEmpty() && lock.isEmpty() + ? List.of() + : Dependencies.verifySnapshot(declared, lock, reader); + return new DependencySet(resolved, include, migrate); + } + /** The resolved dependencies, in dependency-NAME order. */ public List dependencies() { return dependencies; @@ -112,7 +139,11 @@ public Set 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())); + for (Dependencies.ResolvedDependency d : dependencies) { + out.add(d.content() != null + ? new InMemoryStringSource(d.content(), d.sourceId()) + : new FileSource(d.artifactPath(), d.sourceId())); + } return out; } 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 b1e04a4cb..1423f8d3b 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 @@ -16,6 +16,7 @@ package com.metaobjects.loader; import com.metaobjects.MetaData; +import com.metaobjects.ErrorCode; import com.metaobjects.MetaDataException; import com.metaobjects.MetaDataNotFoundException; import com.metaobjects.MetaRoot; @@ -822,14 +823,32 @@ private static com.metaobjects.database.PhysicalNaming discoverNamingOrWarn(Stri */ public static MetaDataLoader fromUris(String name, List uris, LoaderOptions opts, com.metaobjects.database.PhysicalNaming physicalNaming) { + return fromUris(name, uris, opts, physicalNaming, com.metaobjects.config.DependencySet.EMPTY); + } + + /** + * Like {@link #fromUris(String, List, LoaderOptions, com.metaobjects.database.PhysicalNaming)}, for a + * project that imports a shared model (FR-023): the {@code dependencies}' snapshot artifacts load + * FIRST, ahead of {@code uris}, in the same batch the build-time path uses; the set is attached to the + * root, and a new top-level node declared into a package a dependency owns is refused + * ({@code ERR_DEPENDENCY_PACKAGE_NOT_OWNED}) after the loader's own errors. + */ + public static MetaDataLoader fromUris(String name, List uris, LoaderOptions opts, + com.metaobjects.database.PhysicalNaming physicalNaming, + com.metaobjects.config.DependencySet dependencies) { MetaDataLoader loader = (opts == null) ? createManual(false, name) : new MetaDataLoader(opts, SUBTYPE_MANUAL, name); try { loader.init(); - List sources = new ArrayList<>(uris.size()); + // Not setDependencies(): that is read by init(), which has already run. The snapshot + // sources join this batch instead, and the root carries the set for codegen and runtime. + loader.getRoot().setDependencySet(dependencies); + List sources = new ArrayList<>(dependencies.sources()); for (URI uri : uris) sources.add(new UriSource(uri)); loader.load(sources); + // After the loader's own errors, never before: a strict load records them rather than throwing. + if (loader.getErrors().isEmpty()) dependencies.refuseUnownedPackages(loader.getRoot()); loader.register(); com.metaobjects.database.PhysicalNaming naming = physicalNaming != null ? physicalNaming : discoverNamingOrWarn(name, uris); @@ -841,6 +860,12 @@ public static MetaDataLoader fromUris(String name, List uris, LoaderOptions .discoverEnumDefaults(uris).orElse(com.metaobjects.config.EnumDefaults.DEFAULT)); } catch (MetaDataLoadingException e) { throw e; + } catch (MetaDataException e) { + // The ownership refusal keeps its own coded error, as it does on the build-time path. + if (e.getCode().filter(ErrorCode.ERR_DEPENDENCY_PACKAGE_NOT_OWNED::equals).isPresent()) throw e; + throw new MetaDataLoadingException( + "Failed to load from URIs", name, + loader.getLoadingState().getCurrentPhase(), 0, e); } catch (Exception e) { throw new MetaDataLoadingException( "Failed to load from URIs", name, diff --git a/server/java/metadata/src/test/java/com/metaobjects/config/DependencyCheckConformanceTest.java b/server/java/metadata/src/test/java/com/metaobjects/config/DependencyCheckConformanceTest.java new file mode 100644 index 000000000..5a33f0439 --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/config/DependencyCheckConformanceTest.java @@ -0,0 +1,146 @@ +/* + * 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 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.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Runs the {@code expectCheck} arm of the shared FR-023 corpus against this port's + * {@link Dependencies#check} (the engine behind {@code metaobjects:verify -Dmeta.verify.mode=deps}; + * Kotlin runs the same goal). The reference is {@code cli/src/lib/dependency-sync.ts} + * {@code checkDependencies}. The status is the contract; the detail text is not. + */ +@RunWith(Parameterized.class) +public class DependencyCheckConformanceTest { + + private record Case(String name, Map tree, Map treeFiles, + JsonObject config, JsonObject lock, String resolveFrom, + Map expectCheck) {} + + @Parameters(name = "{0}") + public static Collection cases() throws IOException { + String content = Files.readString( + DependencyConformanceTest.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(); + if (!c.has("expectCheck")) continue; + String name = c.get("name").getAsString(); + Map expected = new LinkedHashMap<>(); + for (Map.Entry e : c.getAsJsonObject("expectCheck").entrySet()) { + expected.put(e.getKey(), e.getValue().getAsString()); + } + rows.add(new Object[]{name, new Case( + name, strings(c, "tree"), strings(c, "treeFiles"), + c.get("config").isJsonNull() ? null : c.getAsJsonObject("config"), + c.has("lock") ? c.getAsJsonObject("lock") : null, + c.has("resolveFrom") ? c.get("resolveFrom").getAsString() : ".", + expected)}); + } + return rows; + } + + private static Map strings(JsonObject c, String key) { + Map out = new LinkedHashMap<>(); + if (c.has(key)) { + for (Map.Entry e : c.getAsJsonObject(key).entrySet()) { + out.put(e.getKey(), e.getValue().getAsString()); + } + } + return out; + } + + @Parameter(0) + public String caseName; + + @Parameter(1) + public Case testCase; + + private static void write(Path path, byte[] bytes) throws IOException { + Files.createDirectories(path.getParent()); + Files.write(path, bytes); + } + + private static void delete(Path root) throws IOException { + try (Stream walk = Files.walk(root)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); + } + } + + @Test + public void reportsTheStatusTheReferenceReports() throws IOException { + Path root = Files.createTempDirectory("mo-dep-check-").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(DependencyConformanceTest.corpusDir().resolve(e.getValue()))); + } + Path configDir = root.resolve(testCase.resolveFrom()).normalize(); + Path dotMo = configDir.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)); + } + + List> specs = NeutralConfig.read(configDir) + .map(NeutralConfig::getDependencies).orElse(List.of()); + List results = Dependencies.check(configDir, specs, Dependencies.readLock(configDir)); + + Map actual = new LinkedHashMap<>(); + for (Dependencies.CheckResult r : results) actual.put(r.name(), r.status().name().toLowerCase()); + assertEquals(testCase.expectCheck(), actual); + } finally { + delete(root); + } + } + + @Test + public void theCorpusHasCheckCases() throws IOException { + assertTrue(cases().size() > 0); + } +} 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 index d959a75be..40bf6f1ca 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/config/DependencyConformanceTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/config/DependencyConformanceTest.java @@ -117,7 +117,7 @@ public static Collection cases() throws IOException { 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. + // arm run here has nothing to assert on them; DependencyCheckConformanceTest runs them. if (c.has("expectCheck")) continue; if (!c.has("config")) { throw new IllegalStateException("corpus case \"" + name + "\" has no \"config\" key"); diff --git a/server/java/metadata/src/test/java/com/metaobjects/config/DependencySnapshotRuntimeConformanceTest.java b/server/java/metadata/src/test/java/com/metaobjects/config/DependencySnapshotRuntimeConformanceTest.java new file mode 100644 index 000000000..d1a8f527a --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/config/DependencySnapshotRuntimeConformanceTest.java @@ -0,0 +1,262 @@ +/* + * 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.LoaderOptions; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; +import com.metaobjects.source.ErrorSource; +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.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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; + +/** + * FR-023 — the RUNTIME path (a Spring app, no project directory: lock and snapshot are read through a + * {@link Dependencies.SnapshotReader} that has no file path, and the project's own files load through + * {@link MetaDataLoader#fromUris}) must agree with the build-time path on the shared + * {@code fixtures/dependency-conformance/} corpus: the same imported / selected / migrate-governed sets, + * the same load-time failures and source ids, the same resolution errors. "Imported is load-only" at + * runtime is exactly this agreement. + */ +@RunWith(Parameterized.class) +public class DependencySnapshotRuntimeConformanceTest { + + private record Case(String name, Map tree, Map treeFiles, + JsonObject config, JsonObject lock, String resolveFrom, + List expectImported, List expectSelected, + List expectMigrateGoverned, String expectLoadError, + List expectErrorFiles, String expectError) {} + + @Parameters(name = "{0}") + public static Collection cases() throws IOException { + String content = Files.readString( + DependencyConformanceTest.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(); + if (c.has("expectCheck")) continue; + String name = c.get("name").getAsString(); + rows.add(new Object[]{name, new Case( + name, strings(c, "tree"), strings(c, "treeFiles"), + c.get("config").isJsonNull() ? null : c.getAsJsonObject("config"), + c.has("lock") ? c.getAsJsonObject("lock") : null, + c.has("resolveFrom") ? c.get("resolveFrom").getAsString() : ".", + list(c, "expectImported"), list(c, "expectSelected"), list(c, "expectMigrateGoverned"), + c.has("expectLoadError") ? c.get("expectLoadError").getAsString() : null, + list(c, "expectErrorFiles"), + c.has("expectError") ? c.get("expectError").getAsString() : null)}); + } + return rows; + } + + private static Map strings(JsonObject c, String key) { + Map out = new 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 list(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; + } + + @Parameter(0) + public String caseName; + + @Parameter(1) + public Case testCase; + + private static void write(Path path, byte[] bytes) throws IOException { + Files.createDirectories(path.getParent()); + Files.write(path, bytes); + } + + private static void delete(Path root) throws IOException { + try (Stream walk = Files.walk(root)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); + } + } + + /** What a Spring classpath reader does: bytes by location, no file path to hand back. */ + private static Dependencies.SnapshotReader classpathLike(Path dir) { + return new Dependencies.SnapshotReader() { + @Override + public byte[] read(String name, String artifact) throws IOException { + return Files.readAllBytes(dir.resolve(".metaobjects/deps").resolve(name).resolve(artifact)); + } + + @Override + public String describe(String name, String artifact) { + return "classpath:.metaobjects/deps/" + name + "/" + artifact; + } + }; + } + + private static Optional> lockOf(Path dir) throws IOException { + Path lock = dir.resolve(".metaobjects/deps.lock.json"); + return Files.isRegularFile(lock) + ? Optional.of(Dependencies.parseLock(Files.readString(lock, StandardCharsets.UTF_8), "classpath lock")) + : Optional.empty(); + } + + private static DependencySet resolve(Path dir) throws IOException { + return DependencySet.ofSnapshot(NeutralConfig.read(dir), lockOf(dir), classpathLike(dir)); + } + + 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 == null ? List.of() : loader.getErrors(); + if (!recorded.isEmpty()) return recorded.get(0); + return thrown == null ? null : new MetaDataException(String.valueOf(thrown.getMessage())); + } + + private static List files(ErrorSource source) { + return source instanceof com.metaobjects.source.JsonSource s ? s.files() + : source instanceof com.metaobjects.source.YamlSource s ? s.files() + : source instanceof com.metaobjects.source.MergedSource s ? s.files() + : source instanceof com.metaobjects.source.ResolvedSource s ? s.files() : List.of(); + } + + @Test + public void agreesWithTheBuildTimePath() throws IOException { + Path root = Files.createTempDirectory("mo-dep-runtime-").toAbsolutePath().normalize(); + try { + for (Map.Entry e : testCase.tree().entrySet()) { + write(root.resolve(e.getKey()), e.getValue().getBytes(StandardCharsets.UTF_8)); + } + for (Map.Entry e : testCase.treeFiles().entrySet()) { + write(root.resolve(e.getKey()), + Files.readAllBytes(DependencyConformanceTest.corpusDir().resolve(e.getValue()))); + } + Path dir = root.resolve(testCase.resolveFrom()).normalize(); + Files.createDirectories(dir.resolve(".metaobjects")); + if (testCase.config() != null) { + write(dir.resolve(".metaobjects/config.json"), testCase.config().toString().getBytes(StandardCharsets.UTF_8)); + } + if (testCase.lock() != null) { + write(dir.resolve(".metaobjects/deps.lock.json"), testCase.lock().toString().getBytes(StandardCharsets.UTF_8)); + } + + if (testCase.expectError() != null) { + try { + resolve(dir); + fail("expected " + testCase.expectError() + " but resolution succeeded"); + } catch (MetaDataException e) { + assertEquals(testCase.expectError(), e.getCode().map(ErrorCode::name).orElse("")); + } + return; + } + + DependencySet deps = resolve(dir); + // Which files are the project's OWN is a build-time question already pinned by the resolution + // arm; the runtime is handed them as URIs, as a Spring app's metadata-sources are. + List own = new ArrayList<>(); + for (Path f : SourceResolver.resolveCollectionWithDependencies(dir).ownFiles()) own.add(f.toUri()); + + MetaDataLoader loader = null; + Throwable thrown = null; + try { + loader = MetaDataLoader.fromUris("runtime", own, LoaderOptions.create(false, false, true), + com.metaobjects.database.PhysicalNaming.DEFAULT, deps); + } catch (RuntimeException e) { + thrown = e; + } + MetaDataException error = firstError(loader, thrown); + + 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()); + assertEquals("the root must carry the set codegen and the runtime read", deps, loader.getRoot().getDependencySet()); + + 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) { + 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); + } +} From 6b92cf6b21dcfb205f305367f1006386719e833c Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 5 Nov 2026 08:48:05 -0500 Subject: [PATCH 2/4] no-mistakes(review): Load snapshot for deps-only Spring apps; single artifact read --- .../spring/MetaDataLoaderConfiguration.java | 10 ++++++---- ...DataLoaderConfigurationDependenciesTest.java | 17 +++++++++++++++++ .../com/metaobjects/config/Dependencies.java | 15 ++++++++------- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/server/java/core-spring/src/main/java/com/metaobjects/spring/MetaDataLoaderConfiguration.java b/server/java/core-spring/src/main/java/com/metaobjects/spring/MetaDataLoaderConfiguration.java index c97065d3e..dd6da48f2 100644 --- a/server/java/core-spring/src/main/java/com/metaobjects/spring/MetaDataLoaderConfiguration.java +++ b/server/java/core-spring/src/main/java/com/metaobjects/spring/MetaDataLoaderConfiguration.java @@ -120,8 +120,11 @@ public MetaDataLoader applicationMetaDataLoader() throws Exception { metadataSources = discoverDefaultMetadataSources(); } - if (metadataSources.isEmpty()) { - // No metadata sources configured - return null so it's optional + NeutralConfig projectConfig = readProjectConfig(); + DependencySet dependencies = resolveDependencies(projectConfig); + + if (metadataSources.isEmpty() && dependencies.isEmpty()) { + // No metadata sources and no dependencies - return null so it's optional return null; } @@ -141,10 +144,9 @@ public MetaDataLoader applicationMetaDataLoader() throws Exception { // ADR-0058: the naming is resolved BEFORE the load call so the loader's own // "no config found" WARN fires only when neither this property, the default // classpath:.metaobjects/config.json, nor the file: walk found a project config. - NeutralConfig projectConfig = readProjectConfig(); PhysicalNaming naming = projectConfig == null ? null : projectConfig.getPhysicalNaming(); return MetaDataLoader.fromUris("applicationLoader", sourceURIs, opts, naming, - resolveDependencies(projectConfig)); + dependencies); } /** Where the committed dependency lock is read from (FR-023). Its presence is what turns the load on. */ diff --git a/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderConfigurationDependenciesTest.java b/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderConfigurationDependenciesTest.java index ce583cca3..85fda73fc 100644 --- a/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderConfigurationDependenciesTest.java +++ b/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderConfigurationDependenciesTest.java @@ -124,6 +124,23 @@ public void theLockAloneIsEnoughWhenNoConfigIsOnTheClasspath() throws Exception }); } + @Test + public void anAppWithNoOwnMetadataStillLoadsTheClasspathSnapshot() throws Exception { + Path root = app(VIP, true, true, true); + Files.delete(root.resolve("metadata/app.json")); + ClassLoader previous = Thread.currentThread().getContextClassLoader(); + try (URLClassLoader cl = new URLClassLoader(new URL[]{root.toUri().toURL()}, previous)) { + Thread.currentThread().setContextClassLoader(cl); + runner.run(ctx -> { + assertThat(ctx).hasNotFailed(); + MetaDataLoader loader = ctx.getBean(MetaDataLoader.class); + assertThat(loader.getRoot().getDependencySet().imported("acme::common::Customer")).isTrue(); + }); + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + } + @Test public void withNoLockOnTheClasspathNothingChanges() throws Exception { String plain = """ diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/Dependencies.java b/server/java/metadata/src/main/java/com/metaobjects/config/Dependencies.java index ad1ba8954..82970b69b 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/config/Dependencies.java +++ b/server/java/metadata/src/main/java/com/metaobjects/config/Dependencies.java @@ -484,11 +484,9 @@ public static List check(Path configDir, List> LockEntry lockEntry = entries.get(name); try { Path dir = resolveDependencyDir(configDir, spec); - JsonObject manifest = readManifest(dir); - String artifact = manifest.get("artifact").getAsString(); - byte[] bytes = readArtifact(dir.resolve(artifact), artifact); - // Re-hash the bytes actually on disk; never take the manifest's own integrity on faith. - String installed = sha256Integrity(bytes); + ManifestRead read = readManifest(dir); + JsonObject manifest = read.manifest(); + String installed = read.installedIntegrity(); String version = manifest.get("version").getAsString(); if (lockEntry == null) { results.add(new CheckResult(name, CheckStatus.UNRESOLVED, null, null, version, installed, @@ -507,6 +505,9 @@ public static List check(Path configDir, List> return results; } + /** A validated manifest plus the integrity of the artifact bytes read from disk (never the manifest's own claim). */ + private record ManifestRead(JsonObject manifest, String installedIntegrity) {} + /** A dependency this check could not read; carries the reason. */ private static final class CheckUnresolved extends RuntimeException { CheckUnresolved(String message) { @@ -536,7 +537,7 @@ private static byte[] readArtifact(Path path, String artifact) { } /** Read and validate {@code /metaobjects.pkg.json} and confirm the artifact beside it hashes to its integrity. */ - private static JsonObject readManifest(Path dir) { + private static ManifestRead readManifest(Path dir) { Path manifestPath = dir.resolve(MANIFEST_FILE); String text; try { @@ -563,7 +564,7 @@ private static JsonObject readManifest(Path dir) { throw new CheckUnresolved(dir.resolve(artifact) + " hashes to " + actual + ", but the manifest records " + obj.get("integrity").getAsString()); } - return obj; + return new ManifestRead(obj, actual); } /** The manifest schema (DESIGN §3.2): the same strict shape the Node tool validates. */ From 282c8475b6abf1d147c73fba3696ccaaa1531459 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 5 Nov 2026 10:36:44 -0500 Subject: [PATCH 3/4] no-mistakes(test): Add stale-lock Spring test and Node-synced snapshot e2e test --- ...taLoaderConfigurationDependenciesTest.java | 14 ++++ .../NodeSyncedDependencySnapshotTest.java | 83 +++++++++++++++++++ .../app/.metaobjects/config.json | 5 ++ .../app/.metaobjects/deps.lock.json | 22 +++++ .../acme-common/acme-common.metaobjects.json | 70 ++++++++++++++++ .../node-deps-sync/app/metadata/app.json | 3 + .../acme-common/acme-common.metaobjects.json | 70 ++++++++++++++++ .../acme-common/metaobjects.pkg.json | 10 +++ 8 files changed, 277 insertions(+) create mode 100644 server/java/core-spring/src/test/java/com/metaobjects/spring/NodeSyncedDependencySnapshotTest.java create mode 100644 server/java/core-spring/src/test/resources/node-deps-sync/app/.metaobjects/config.json create mode 100644 server/java/core-spring/src/test/resources/node-deps-sync/app/.metaobjects/deps.lock.json create mode 100644 server/java/core-spring/src/test/resources/node-deps-sync/app/.metaobjects/deps/acme-common/acme-common.metaobjects.json create mode 100644 server/java/core-spring/src/test/resources/node-deps-sync/app/metadata/app.json create mode 100644 server/java/core-spring/src/test/resources/node-deps-sync/publisher/acme-common/acme-common.metaobjects.json create mode 100644 server/java/core-spring/src/test/resources/node-deps-sync/publisher/acme-common/metaobjects.pkg.json diff --git a/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderConfigurationDependenciesTest.java b/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderConfigurationDependenciesTest.java index 85fda73fc..832e025cf 100644 --- a/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderConfigurationDependenciesTest.java +++ b/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderConfigurationDependenciesTest.java @@ -181,6 +181,20 @@ public void aConfigDeclaringDependenciesWithNoLockFailsBootAsStale() throws Exce }); } + @Test + public void aLockedDependencyTheClasspathConfigNoLongerDeclaresFailsBootAsStale() throws Exception { + // The config on the classpath is authoritative for WHAT is declared: a lock entry it does not + // declare is stale, even though the snapshot itself still hashes to the lock. + Path root = app(VIP, true, true, false); + write(root.resolve(".metaobjects/config.json"), """ + {"schema_version":1,"sources":[],"dependencies":[]} + """); + run(root, ctx -> { + assertThat(ctx).hasFailed(); + assertThat(codeOf(ctx.getStartupFailure())).isEqualTo(ErrorCode.ERR_DEPENDENCY_SNAPSHOT_STALE); + }); + } + @Test public void aNodeDeclaredIntoADependencysPackageIsRefused() throws Exception { run(app(INTRUDER, true, true, true), ctx -> { diff --git a/server/java/core-spring/src/test/java/com/metaobjects/spring/NodeSyncedDependencySnapshotTest.java b/server/java/core-spring/src/test/java/com/metaobjects/spring/NodeSyncedDependencySnapshotTest.java new file mode 100644 index 000000000..7f380340e --- /dev/null +++ b/server/java/core-spring/src/test/java/com/metaobjects/spring/NodeSyncedDependencySnapshotTest.java @@ -0,0 +1,83 @@ +package com.metaobjects.spring; + +import com.metaobjects.config.Dependencies; +import com.metaobjects.config.NeutralConfig; +import com.metaobjects.loader.MetaDataLoader; +import org.junit.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * FR-023 S4/S5 end to end against what the Node toolchain actually writes. The Node {@code meta deps sync} + * is the only writer of the lock and the snapshot (recorded decision), so the Java side must read ITS + * output, not a lock this repo hand-wrote to match the Java reader. + * + *

    Fixture {@code node-deps-sync/} (test resources): {@code publisher/acme-common/} is a {@code path} + * dependency — the shared corpus artifact {@code fixtures/dependency-conformance/artifacts/acme-common-v1.json} + * plus its {@code metaobjects.pkg.json}. {@code app/.metaobjects/deps.lock.json} and + * {@code app/.metaobjects/deps/**} are the unedited output of running, in {@code app/}, + * {@code bun /server/typescript/packages/cli/bin/meta.ts deps sync}. Regenerate them the same + * way; never edit them by hand. The fixture sits under a subdirectory so its lock is not on the test + * classpath of every other test in this module.

    + */ +public class NodeSyncedDependencySnapshotTest { + + private static Path app() throws Exception { + URL config = NodeSyncedDependencySnapshotTest.class.getClassLoader() + .getResource("node-deps-sync/app/.metaobjects/config.json"); + assertThat(config).as("node-deps-sync fixture on the test classpath").isNotNull(); + return Paths.get(config.toURI()).getParent().getParent(); + } + + @Test + public void theDepsCheckReportsTheNodeSyncedLockCurrent() throws Exception { + Path app = app(); + NeutralConfig config = NeutralConfig.read(app).orElseThrow(); + Optional> lock = Dependencies.readLock(app); + assertThat(lock).isPresent(); + + List results = Dependencies.check(app, config.getDependencies(), lock); + + // Oracle: the Node `meta deps check` over this same tree reports `acme-common: current — 1.0.0 (10fbf886)`, + // and 10fbf886… is the sha256 of the corpus artifact the publisher ships. + assertThat(results).hasSize(1); + assertThat(results.get(0).status()).isEqualTo(Dependencies.CheckStatus.CURRENT); + assertThat(results.get(0).line()).isEqualTo("acme-common: current — 1.0.0 (10fbf886)"); + } + + @Test + public void aSpringAppBootsFromTheNodeSyncedSnapshot() throws Exception { + Path app = app(); + ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + MetaDataAutoConfiguration.class, ConfigurationPropertiesAutoConfiguration.class)); + ClassLoader previous = Thread.currentThread().getContextClassLoader(); + try (URLClassLoader cl = new URLClassLoader(new URL[]{app.toUri().toURL()}, previous)) { + Thread.currentThread().setContextClassLoader(cl); + runner.withPropertyValues("metaobjects.metadata-sources=" + app.resolve("metadata/app.json").toUri()) + .run(ctx -> { + assertThat(ctx).hasNotFailed(); + MetaDataLoader loader = ctx.getBean(MetaDataLoader.class); + // app::Vip extends acme::common::Audited, which only the snapshot declares: the load + // resolving it is the proof the snapshot was read, and read first. + assertThat(loader.getMetaObjectByName("app::Vip")).isNotNull(); + assertThat(loader.getRoot().getDependencySet().imported("acme::common::Audited")).isTrue(); + assertThat(loader.getRoot().getDependencySet().inScope("acme::common::Audited")).isFalse(); + assertThat(loader.getRoot().getDependencySet().inScope("app::Vip")).isTrue(); + }); + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + } +} diff --git a/server/java/core-spring/src/test/resources/node-deps-sync/app/.metaobjects/config.json b/server/java/core-spring/src/test/resources/node-deps-sync/app/.metaobjects/config.json new file mode 100644 index 000000000..10d81dfd6 --- /dev/null +++ b/server/java/core-spring/src/test/resources/node-deps-sync/app/.metaobjects/config.json @@ -0,0 +1,5 @@ +{ + "schema_version": 1, + "sources": [{ "path": "metadata" }], + "dependencies": [{ "name": "acme-common", "path": "../publisher/acme-common" }] +} diff --git a/server/java/core-spring/src/test/resources/node-deps-sync/app/.metaobjects/deps.lock.json b/server/java/core-spring/src/test/resources/node-deps-sync/app/.metaobjects/deps.lock.json new file mode 100644 index 000000000..4b2810081 --- /dev/null +++ b/server/java/core-spring/src/test/resources/node-deps-sync/app/.metaobjects/deps.lock.json @@ -0,0 +1,22 @@ +{ + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ], + "resolvedFrom": { + "path": "../publisher/acme-common" + } + } + } +} diff --git a/server/java/core-spring/src/test/resources/node-deps-sync/app/.metaobjects/deps/acme-common/acme-common.metaobjects.json b/server/java/core-spring/src/test/resources/node-deps-sync/app/.metaobjects/deps/acme-common/acme-common.metaobjects.json new file mode 100644 index 000000000..72f2dd9ae --- /dev/null +++ b/server/java/core-spring/src/test/resources/node-deps-sync/app/.metaobjects/deps/acme-common/acme-common.metaobjects.json @@ -0,0 +1,70 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Address", + "package": "acme::common", + "children": [ + { + "field.string": { + "name": "city" + } + }, + { + "field.string": { + "name": "street" + } + } + ] + } + }, + { + "object.entity": { + "name": "Audited", + "package": "acme::common", + "abstract": true, + "children": [ + { + "field.timestamp": { + "name": "createdAt" + } + } + ] + } + }, + { + "object.entity": { + "name": "Customer", + "package": "acme::common", + "children": [ + { + "source.rdb": { + "@table": "customers" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "email", + "@maxLength": 120 + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/server/java/core-spring/src/test/resources/node-deps-sync/app/metadata/app.json b/server/java/core-spring/src/test/resources/node-deps-sync/app/metadata/app.json new file mode 100644 index 000000000..b894de250 --- /dev/null +++ b/server/java/core-spring/src/test/resources/node-deps-sync/app/metadata/app.json @@ -0,0 +1,3 @@ +{"metadata.root":{"package":"app","children":[{"object.entity":{"name":"Vip","extends":"acme::common::Audited", +"children":[{"source.rdb":{"@table":"vips"}},{"field.long":{"name":"id"}}, +{"identity.primary":{"name":"pk","@fields":["id"]}}]}}]}} diff --git a/server/java/core-spring/src/test/resources/node-deps-sync/publisher/acme-common/acme-common.metaobjects.json b/server/java/core-spring/src/test/resources/node-deps-sync/publisher/acme-common/acme-common.metaobjects.json new file mode 100644 index 000000000..72f2dd9ae --- /dev/null +++ b/server/java/core-spring/src/test/resources/node-deps-sync/publisher/acme-common/acme-common.metaobjects.json @@ -0,0 +1,70 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Address", + "package": "acme::common", + "children": [ + { + "field.string": { + "name": "city" + } + }, + { + "field.string": { + "name": "street" + } + } + ] + } + }, + { + "object.entity": { + "name": "Audited", + "package": "acme::common", + "abstract": true, + "children": [ + { + "field.timestamp": { + "name": "createdAt" + } + } + ] + } + }, + { + "object.entity": { + "name": "Customer", + "package": "acme::common", + "children": [ + { + "source.rdb": { + "@table": "customers" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "email", + "@maxLength": 120 + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/server/java/core-spring/src/test/resources/node-deps-sync/publisher/acme-common/metaobjects.pkg.json b/server/java/core-spring/src/test/resources/node-deps-sync/publisher/acme-common/metaobjects.pkg.json new file mode 100644 index 000000000..12f5d4bc4 --- /dev/null +++ b/server/java/core-spring/src/test/resources/node-deps-sync/publisher/acme-common/metaobjects.pkg.json @@ -0,0 +1,10 @@ +{ + "schema_version": 1, + "name": "acme-common", + "version": "1.0.0", + "metamodelVersion": "1.0", + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": ["acme::common"], + "nodes": ["acme::common::Address", "acme::common::Audited", "acme::common::Customer"] +} From 55fc7aeace1377e60f5dd56e33ee75e18dd5aa6c Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 5 Nov 2026 11:02:57 -0500 Subject: [PATCH 4/4] no-mistakes(document): Docs updated for Java/Kotlin deps verify mode and Spring snapshot load; one gap left in agent-context skills. --- README.md | 2 +- docs/features/cli.md | 6 +++--- docs/features/metadata-dependencies.md | 3 ++- docs/features/migrations-and-drift.md | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a045a7ee4..a5749c8e2 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ first-week wedge plan — and `meta init` picks up from there. | Capability requirements (`requirement.*`) | `meta verify` gate + authoring lint; `requirementTests()` stubs | `mvn metaobjects:verify` gate; JUnit Jupiter `requirement-tests` | Gate via the Java Maven goal; JUnit Jupiter `requirement-tests` | `dotnet meta verify` gate; xUnit `requirement-tests` | `metaobjects verify` gate; pytest `requirement-tests` | | Reporting vocabulary (`dimension` / `measure` / `segment` / `object.report`, FR-044) | Registered; a view-backed report becomes a SQL view in `meta migrate`, is read by `ObjectManager`, and gets generated read-only list routes and a TanStack list hook (no grid or form); the `cube-model` generator writes the vocabulary as Cube data-model files | Registered; OMDB reads a view-backed report; generates a read-only Spring controller, DTO, repository and filter allowlist | Registered (via Java); generates an Exposed table object, a row data class, a filter allowlist and a read-only Spring controller per view-backed report | Registered; generates a keyless EF Core row type, read-only routes and a filter allowlist per view-backed report | Registered; `ObjectManager` reads a view-backed report; generates a Pydantic row model, a read-only FastAPI router and a filter allowlist | | Libraries (`libraries: [...]`) | Yes | Yes | Yes (via Java) | Yes | Yes | -| Metadata dependencies (`dependencies`) | Yes (`meta deps sync`: `path`, `npm` and `python` transports) | Yes (loads the synced snapshot; `meta deps sync` is Node) | Yes (via Java) | Yes (loads the synced snapshot) | Yes (loads the synced snapshot) | +| Metadata dependencies (`dependencies`) | Yes (`meta deps sync`: `path`, `npm` and `python` transports) | Yes (loads the synced snapshot; `-Dmeta.verify.mode=deps` checks drift; `meta deps sync` is Node) | Yes (via Java) | Yes (loads the synced snapshot) | Yes (loads the synced snapshot) | | Runtime metadata (ObjectManager-style) | Yes (`runtime-ts`) | Yes (OMDB) | Yes (via Java OMDB + Exposed) | Roadmap | Yes (ObjectManager) | | React / Angular UI client (browser) | React: **published** (`@metaobjectsdev/react` + `@metaobjectsdev/tanstack`), codegen + runtime. Angular 18: **source-only by decision** ([ADR-0048](spec/decisions/ADR-0048-angular-tier-source-only.md)) — `@metaobjectsdev/angular` + `@metaobjectsdev/codegen-ts-angular` build in-repo on their own `0.6.x` line but are deliberately not on npm (`npm i @metaobjectsdev/angular` will 404) until they meet the ADR's promotion bar. Consume them from source. | Consumes TS client via REST | Consumes TS client via REST | Consumes TS client via REST | Consumes TS client via REST | | Own a generator (`eject`) | Yes (`meta eject`) | Yes (`mvn metaobjects:eject`) | Yes (`mvn metaobjects:eject`) | Yes (`dotnet meta eject`) | Yes (`metaobjects eject`) | diff --git a/docs/features/cli.md b/docs/features/cli.md index f69c34380..b4484b963 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -39,7 +39,7 @@ command surface splits in two: | 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 | | Java/Kotlin codegen | Maven plugin | `mvn metaobjects:generate` (`metaobjects:generate`) | Kotlin generators run through the same goal — see below. **No `--template-spec` flag, deliberately** — `` already loads a consumer class from the project classpath, so the declarative surface is `com.metaobjects.generator.template.TemplateScopeGenerator` wired as an ordinary `` with `