diff --git a/CHANGELOG.md b/CHANGELOG.md index 96e4ca6f32..1636241611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,19 @@ loading. A port that reports `1.2` does not know that vocabulary; upgrade every ### Added +- **`maven` and `nuget` dependency transports (FR-023).** `meta deps sync` and `meta deps check` can + now read a shared model out of a published package that is already in the local cache, so a JVM or + .NET consumer no longer needs the publisher's directory on disk. `{ "name": "x", "maven": + "com.acme:model", "version": "1.0.0" }` reads the jar in the local Maven repository (honouring + `$MAVEN_REPO_LOCAL`, `-Dmaven.repo.local`, `settings.xml`'s ``, then + `~/.m2/repository`); `{ "name": "x", "nuget": "Acme.Model", "version": "1.0.0" }` reads the package + in the NuGet global packages folder (`$NUGET_PACKAGES`, else `~/.nuget/packages`). Both read the + manifest and artifact from the package's `metaobjects/` directory (`dir` changes it), write the same + snapshot and lock entry a `path` sync writes, and refuse a package that is not cached with the + coordinates and the command that fetches it (`mvn dependency:get`, `dotnet restore`); nothing is + downloaded. The config and lock readers in Java, Kotlin, Python and C# accept the new keys, and + their `check` reports such a dependency `unresolved` and names the Node `meta deps check`. + `dependency-conformance` gains 2 resolution cases and 18 transport cases. - **Java, Kotlin and C#: publish a shared model (FR-023).** A `shared-model` generator in every one of the three ports writes the artifact `.metaobjects.json` and the manifest `metaobjects.pkg.json`, byte-identical to the TypeScript `sharedModelFile()` and gated by diff --git a/README.md b/README.md index 4748a8cb90..72ea831217 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; `-Dmeta.verify.mode=deps` checks drift; `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`, `python`, `maven` and `nuget` 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/CONFORMANCE.md b/docs/CONFORMANCE.md index 0a680b69d1..599477efbc 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; check arm, all 11) | inherits via Java | ✓ (resolution arm, all 26; check arm, all 11, as `verify --deps`) | ✓ (resolution arm, all 26 identically; check arm, all 11, as `deps check` and `verify --deps`; the transport arm is Node-only) | +| [`fixtures/dependency-conformance/`](../fixtures/dependency-conformance/) | 39 cases (28 resolution + 11 check) + 32 transport cases | ✓ (reference implementation; all three arms) | ✓ (resolution arm, all 28; check arm, all 11) | inherits via Java | ✓ (resolution arm, all 28; check arm, all 11, as `verify --deps`) | ✓ (resolution arm, all 28 identically; check arm, all 11, as `deps check` and `verify --deps`; the transport arm is Node-only) | | [`fixtures/shared-model-conformance/`](../fixtures/shared-model-conformance/) | 11 cases (7 publish + 4 refusal) | ✓ (reference implementation) | ✓ (all 11; `SharedModelConformanceTest`) | inherits via Java (`KotlinSharedModelGeneratorTest` pins the generator) | ✓ (all 11; `SharedModelConformanceTests`) | ✓ (serializer arm: the 7 publish cases' document bytes; no publisher) | | [`fixtures/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) | — | — | — | — | @@ -463,17 +463,17 @@ also matches before a trailing newline. Kotlin has no runner of its own: it inhe the Java loader. Each of those two guards is pinned by a test beside the corpus, since the corpus asserts matches only and carries no invalid-pattern case. -### `fixtures/dependency-conformance/` (37 cases + 14 transport cases) +### `fixtures/dependency-conformance/` (39 cases + 32 transport cases) -All 37 cases → [features/metadata-dependencies.md](features/metadata-dependencies.md) +All 39 cases → [features/metadata-dependencies.md](features/metadata-dependencies.md) (declaring a dependency, `meta deps sync`, the committed snapshot + lock, default exclusion of imported metadata, overlay/extends across the boundary, every load- and resolution-time failure, and the upstream check). File-shaped like `scope-conformance/` above: one committed `cases.json`, no per-port fixture, no ledger. The corpus has three arms: -- **Resolution (26 cases).** Every case's `expectFiles`/`expectImported`/`expectSelected`/ +- **Resolution (28 cases).** Every case's `expectFiles`/`expectImported`/`expectSelected`/ `expectMigrateGoverned`/`expectLoadError` assertions. **TypeScript (the reference - implementation), Python, Java and C# all run all 26, identically**, with no exemption. The two + implementation), Python, Java and C# all run all 28, identically**, with no exemption. The two overlay cases that need a view child as incidental content use `view.currency` (the one concrete `view.*` subtype registered cross-port), not `view.text` — a prior revision used `view.text`, which Python did not register then, and carried a since-discharged allowlist @@ -486,12 +486,13 @@ above: one committed `cases.json`, no per-port fixture, no ledger. The corpus ha (`metaobjects deps check` and `verify --deps`), and by **C#**, in `DependencyConformanceTests` (`dotnet meta verify --deps`). 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 +- **Transport (32 cases, `transport-cases.json`).** The `npm`, `python`, `maven` and `nuget` transports + `meta deps sync` uses to find an installed package: npm packages under a temp `node_modules`, Python + packages on a temp import path, jars in a temp local Maven repository, packages in a temp NuGet cache, + the directory each resolves to, the exact lock written, and each refusal. Run by Node only, in `cli/test/dependency-transport-conformance.test.ts`, because Node `meta deps sync` is - the only writer of snapshots and the lock. `cases.json` also carries an `npm` and a `python` - dependency loading from their committed snapshot with nothing installed, which both ports run. + the only writer of snapshots and the lock. `cases.json` also carries an `npm`, a `python`, a `maven` and a `nuget` + dependency loading from their committed snapshot with nothing installed, which every port runs. **C#** (S6 of FR-023 Phase 2) runs the resolution arm on two rungs of the source ladder: the neutral one (`Resolve(null, dir)`), and the explicit metadata directory a @@ -640,7 +641,7 @@ JSON and verify payload feeds it unchanged. The fixtures in the nine corpora mapped above (metamodel 449 + yaml 16 + verify 45 + render 18 + persistence 59 + api-contract 121 + source-resolution 25 + scope 10 + -dependency 37) each map to a feature doc, and so do the two requirement corpora, `shared-model-conformance`, the +dependency 39) each map to a feature doc, and so do the two requirement corpora, `shared-model-conformance`, the `cube-model` corpus and `explorer-conformance`, whose case lists live in their own READMEs. None are orphaned today. The remaining corpora in the totals table gate tooling contracts (registry manifests, provider composition, agent context, docs emit) rather than user-facing metamodel behaviour, diff --git a/docs/features/cli.md b/docs/features/cli.md index 608fe007f4..2841806f50 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -34,7 +34,7 @@ command surface splits in two: | **Vocabulary upgrade** (`upgrade`) | **Node `meta`** | `meta upgrade [--to ] [--apply]` | **any backend** — rewrites RETIRED metadata vocabulary (`@violation` → `@counterexample`, `@readOnly` → `@mutability`, dropping `@verifiedBy`) and resolves ATTRIBUTE CONTRADICTIONS (`@fields` beside `@expr` on an index key). Node-only because it edits the metadata documents themselves, which every port shares; a non-TS project runs `npx meta upgrade` against its own `metaobjects/`. **Canonical JSON and YAML alike.** Previews by default. Retirements needing a human decision are refused and the run exits non-zero, so CI cannot record a partial migration as finished | | **Model export** (`export`) | **Node `meta`** | `meta export [--effective] [--out ]` | the loaded model, overlays merged, as one canonical JSON document. `--effective` also writes every inherited field, child and attribute onto the node that inherits it, which is the input the [explorer builder](explorer-data.md) reads. The output is for reading: loading an effective document back would declare each inherited member twice | | **Vocabulary search** (`types`) | **Node `meta`** | `meta types [query]` | **any backend** — apropos/`kubectl explain` over the live metamodel registry (names + descriptions + when-to-use); the vocabulary is cross-port identical (registry-conformance) | -| **Dependency sync** (`deps`) | **Node `meta`** | `meta deps sync \| check \| list` | **any backend** — resolves a declared metadata dependency (`path`, or an installed `npm` / `python` package) into a committed snapshot + lock (FR-023), so `sync`/`check`/`list` themselves stay Node-only regardless of your server language. TypeScript, Python and Java then LOAD that snapshot at every rung of their own source ladder (Kotlin loads through Java's loader and Maven plugin); C# loads the snapshot through `dotnet meta` (Phase 2 S6). On a project whose own metadata Node `meta` cannot read (a Maven `src/main/resources/metadata` directory, a C# metadata directory, or another port's vocabulary), `sync` and `check` prove the locked dependencies load together and say that the port's own `verify` is the full check. See [`metadata-dependencies.md`](metadata-dependencies.md) | +| **Dependency sync** (`deps`) | **Node `meta`** | `meta deps sync \| check \| list` | **any backend** — resolves a declared metadata dependency (`path`, an installed `npm` / `python` package, or a `maven` jar / `nuget` package already in the local Maven repository / NuGet cache; it never downloads) into a committed snapshot + lock (FR-023), so `sync`/`check`/`list` themselves stay Node-only regardless of your server language. TypeScript, Python and Java then LOAD that snapshot at every rung of their own source ladder (Kotlin loads through Java's loader and Maven plugin); C# loads the snapshot through `dotnet meta` (Phase 2 S6). On a project whose own metadata Node `meta` cannot read (a Maven `src/main/resources/metadata` directory, a C# metadata directory, or another port's vocabulary), `sync` and `check` prove the locked dependencies load together and say that the port's own `verify` is the full check. See [`metadata-dependencies.md`](metadata-dependencies.md) | | **Canonical format** (`fmt`) | every port | `meta fmt` / `dotnet meta fmt` / `mvn metaobjects:fmt` / `metaobjects fmt` | (#304) ships in **every** port — the canonical serializer already exists in all five, so formatting is local, not Node-only. `--check` lists non-canonical files and exits non-zero without changing anything. Formats each file STANDALONE — own-mode, declared-here layer only (ADR-0039); an `extends` onto another file's base is preserved as-is, never resolved or erred on; a file declaring `overlay: true` anywhere — whether or not a same-file base exists — is reported as skipped rather than merged (merging would drop the overlay marker, a structural change fmt never makes). A YAML file is always skipped — no canonical YAML emitter exists (ADR-0006). Before writing, every port reloads the whole project with the candidate swapped in and refuses the write unless the reloaded model's canonical form is byte-identical to the untouched baseline — fmt never changes meaning. See [Fixture corpus](#meta-fmt--canonical-formatting-304) below and `fixtures/fmt-conformance/README.md` | | TS codegen | Node `meta` | `meta gen` | TS projects. **No `--template-spec` flag, deliberately** — `metaobjects.config.ts` already takes generator VALUES, so a declarative template generator is declared there (`templateGenerator()`, or `templateSpecToGenerators(parseTemplateSpec(spec))` to reuse a C#/Python spec file). Keeping it in the config is what lets `meta verify --codegen` regenerate WITH it, since that gate re-runs the config's generator list; see [declarative template scopes](codegen-concepts.md#declarative-template-scopes) | | C# codegen | `dotnet meta` | `dotnet meta init` / `gen` / `verify --templates` / `verify --codegen` | a .NET tool (`ToolCommandName=dotnet-meta`); invoked `dotnet meta` so it never shadows the Node `meta`; ships the ADR-0021 D2 subverbs (`--db` rejected, exit 2; bare `verify` = `--templates` plus `--codegen` when an output directory (`csharp.outDir` in `.metaobjects/config.json`, or `--out`) and `codegen/Generators.cs` are present). **The generator list is `codegen/Generators.cs`** (ADR-0034 Amendment 9): `dotnet meta init` writes it with `codegen/Program.cs` (two lines that run it) and `codegen/Codegen.csproj`, and `gen` / `verify --codegen` always run it through `dotnet run --project codegen`. With no `codegen/Generators.cs` they exit 2 and write nothing; `--generators` only narrows the list to declared entries. Every entry may set its own output directory (`ApiDocsGenerator { OutDir = ... }`), and `verify --codegen` compares each one. A declarative Mustache template-spec (the cross-port JSON contract shared with Python) is one more entry, `TemplateSpecGenerator { Spec = "", TemplateRoot = "" }`; `gen` no longer reads `--template-spec` or a project-root `template-spec.json`, and a run that finds either with no such entry fails before writing anything; see [declarative template scopes](codegen-concepts.md#declarative-template-scopes). `dotnet meta docs` was removed in 1.1.0: it prints the line that points at [the migration section](migrations/docs-are-generators.md#c) and exits 2 | @@ -56,7 +56,7 @@ vocabulary everywhere, each port implementing the modes it supports. | `verify --codegen` | **Codegen drift** — regenerate from metadata into a temp dir and fail if it differs from the committed generated output. Catches "metadata changed but `meta gen` wasn't re-run". A hand-edited generated file is NOT drift — `meta gen` three-way-merges hand edits by design, so the gate compares the *generated contribution* against the committed `.gen-state/.hashes.json` rather than the file byte-for-byte. It still LISTS every generated file carrying a hand edit (count + paths, and how to see the edit) as a notice that does not change the exit code; `--forbid-hand-edits` makes any hand edit fail the build, for teams that want generated code untouchable (Node `meta` only). | no | | `verify --templates` | **Template/prompt drift** — `Renderer.verify` checks each `template.*` node's `{{field}}` references against its payload VO (FR-004). A Jinja body (Python and TypeScript, ADR-0061) is checked the same way. | no | | `verify --docs` | **Removed in 1.1.0** (ADR-0034 Amendment 9). It prints where the check went and exits 2: documentation is written by entries in `generators`, so `verify --codegen` checks it with the rest of the declared output. | — | -| `verify --deps` | **Dependency drift** (FR-023) — re-resolves each declared dependency (its `path`, or its installed `npm` / `python` package) and compares its installed artifact's hash against `.metaobjects/deps.lock.json`, same comparison as `meta deps check`. Needs the publisher reachable, so it is never part of the bare-`verify` default. A `drifted` or `unresolved` dependency fails with `ERR_DEPENDENCY_UPSTREAM_DRIFT`. **Node `meta`; Python (`metaobjects verify --deps`, plus `metaobjects deps check`), C# (`dotnet meta verify --deps`), and Java and Kotlin (`-Dmeta.verify.mode=deps`) have the `path`-only counterpart.** See [`metadata-dependencies.md`](metadata-dependencies.md). | no | +| `verify --deps` | **Dependency drift** (FR-023) — re-resolves each declared dependency (its `path`, its installed `npm` / `python` package, or its `maven` / `nuget` package in the local cache) and compares its installed artifact's hash against `.metaobjects/deps.lock.json`, same comparison as `meta deps check`. Needs the publisher reachable, so it is never part of the bare-`verify` default. A `drifted` or `unresolved` dependency fails with `ERR_DEPENDENCY_UPSTREAM_DRIFT`. **Node `meta`; Python (`metaobjects verify --deps`, plus `metaobjects deps check`), C# (`dotnet meta verify --deps`), and Java and Kotlin (`-Dmeta.verify.mode=deps`) have the `path`-only counterpart.** See [`metadata-dependencies.md`](metadata-dependencies.md). | no | Rules of the contract: diff --git a/docs/features/metadata-dependencies.md b/docs/features/metadata-dependencies.md index b7eb6ca81a..aa04b892a9 100644 --- a/docs/features/metadata-dependencies.md +++ b/docs/features/metadata-dependencies.md @@ -8,7 +8,7 @@ alternative: declare a published metadata package as a dependency, `meta deps sy it into a committed snapshot, and build on it the same way you build on any other metadata in your own tree — `extends`, `overlay: true`, and plain FQN references. -**What ships today:** the `path`, `npm` and `python` transports, the +**What ships today:** the `path`, `npm`, `python`, `maven` and `nuget` transports, the generated shared-model artifact, the lock + snapshot, default exclusion from your own codegen/schema/ledger, and the loader errors that fire when upstream moves. See [Deferred](#deferred) for what is designed but not built yet — read that section @@ -47,7 +47,9 @@ going away. "dependencies": [ { "name": "acme-common", "path": "../acme-common/metaobjects" }, { "name": "acme-billing", "npm": "@acme/billing-model", "dir": "metaobjects" }, - { "name": "acme-scheduling", "python": "acme_scheduling", "dir": "metaobjects" } + { "name": "acme-scheduling", "python": "acme_scheduling", "dir": "metaobjects" }, + { "name": "acme-ledger", "maven": "com.acme:ledger-model", "version": "2.3.0" }, + { "name": "acme-invoicing", "nuget": "Acme.Invoicing.Model", "version": "2.3.0" } ] } ``` @@ -56,7 +58,7 @@ A dependency spec carries a `name` (`/^[a-z0-9][a-z0-9._-]*$/`, unique across th array — this is the LOCAL alias your config, lock, and `.metaobjects/deps//` snapshot directory key on) and exactly one transport key. -Three transports locate the publisher's `metaobjects.pkg.json` + artifact (below). +Five transports locate the publisher's `metaobjects.pkg.json` + artifact (below). Each answers one question, *which directory holds the manifest?*, and only `meta deps sync` and `meta deps check` ask it. Every other command loads the committed snapshot. @@ -65,9 +67,15 @@ sync` and `meta deps check` ask it. Every other command loads the committed snap | `path` | `{ "name": "x", "path": "../lib/metaobjects" }` | that directory, relative to the directory holding `.metaobjects/`: a sibling checkout, a git submodule, or any directory your build can reach | | `npm` | `{ "name": "x", "npm": "@acme/model", "dir": "metaobjects" }` | the installed npm package, found from the directory holding `.metaobjects/` the way Node resolves it (`node_modules` up the tree, workspaces and hoisting included), then `dir` inside it | | `python` | `{ "name": "x", "python": "acme_model", "dir": "metaobjects" }` | the installed Python package, as `importlib.util.find_spec("acme_model")` reports it in your project's interpreter, then `dir` inside it | - -`dir` is optional (default: the package root), must stay inside the package, and is -legal only beside `npm` or `python`. `python` takes an importable package name (dotted +| `maven` | `{ "name": "x", "maven": "com.acme:acme-model", "version": "1.0.0" }` | that artifact's jar in your **local Maven repository** (`~/.m2/repository`, or the location below), then the jar's `metaobjects/` resources (`dir` changes the directory) | +| `nuget` | `{ "name": "x", "nuget": "Acme.Model", "version": "1.0.0" }` | that package in the **NuGet global packages folder** (`$NUGET_PACKAGES`, else `~/.nuget/packages`), then its `metaobjects/` content (`dir` changes the directory) | + +`dir` is optional, must stay inside the package, and is legal beside every transport +but `path`. For `npm` and `python` its default is the package root; for `maven` and +`nuget` it is `metaobjects`. `maven` and `nuget` also require `version`: a local Maven +repository and the NuGet cache hold many versions side by side, so the declaration, not a +lock file elsewhere, says which one to read. `maven` is `groupId:artifactId` (the version +goes in `version`, not in the coordinate). `python` takes an importable package name (dotted names such as `acme.model` work), not the distribution name you `pip install`. The `npm` transport reads a `node_modules` tree; a Yarn Plug'n'Play install has none, so set `nodeLinker: node-modules` for that project. @@ -80,6 +88,47 @@ dependency lock records what that version's shared model was when you last synce Because the snapshot is committed, only the machine that runs `sync` needs the package installed. CI, `meta gen`, `meta migrate` and every other port load the snapshot. +**What a Java or .NET publisher packs.** The `shared-model` generator writes +`metaobjects.pkg.json` and `.metaobjects.json` into its output directory; the build +that packages your library puts that directory at `metaobjects/` in the package. In Maven +that is a resource with a target path (`shared +metaobjects`); in a .NET project it is a packed file +(``). `sync` reads the +pair out of the jar (a ZIP, unpacked to a temporary directory) or out of the package +folder the NuGet cache extracted (falling back to the `.nupkg` when only the archive is +there), and writes the same snapshot and lock entry a `path` sync writes. The lock records +`{"maven": "...", "version": "..."}` or `{"nuget": "...", "version": "..."}`. + +**Where the local Maven repository is.** The first of: `$MAVEN_REPO_LOCAL`; +`-Dmaven.repo.local=` in `$MAVEN_ARGS` or `$MAVEN_OPTS`; `` in +`~/.m2/settings.xml` (`${user.home}` and `${env.X}` expanded); `~/.m2/repository`. The +error names which one it used. `nuget` reads `$NUGET_PACKAGES`, else `~/.nuget/packages`; +a `globalPackagesFolder` set only in `NuGet.Config` is not read, so export +`$NUGET_PACKAGES` for it. + +**Neither downloads anything.** A package that is not in the local cache is +`ERR_DEPENDENCY_UNRESOLVED`, naming the artifact and the command that fetches it: + +``` +dependency "acme-billing": the Maven artifact com.acme:billing-model:1.4.0 is not in the local +repository /.m2/repository (the default ~/.m2/repository); expected +/.m2/repository/com/acme/billing-model/1.4.0/billing-model-1.4.0.jar. Fetch it with +`mvn dependency:get -Dartifact=com.acme:billing-model:1.4.0` (add -DremoteRepositories= +for a repository other than Central), then re-run. meta deps sync does not download. + +dependency "acme-billing": the NuGet package Acme.Billing.Model 1.4.0 is not in the global +packages folder /.nuget/packages (the default ~/.nuget/packages); expected +/.nuget/packages/acme.billing.model/1.4.0. Fetch it with `dotnet restore` in a +project that references Acme.Billing.Model 1.4.0 (or `dotnet add package Acme.Billing.Model +--version 1.4.0`), then re-run. meta deps sync does not download. +``` + +A jar or package that is in the cache but ships no `metaobjects/` directory is also +`ERR_DEPENDENCY_UNRESOLVED` (check `dir`, or ask the publisher to pack the output); one +whose directory holds no `metaobjects.pkg.json` is `ERR_DEPENDENCY_MANIFEST_INVALID`. When +the version you declared differs from the version the manifest records, `sync` warns, as it +does for `npm` and `python`. + **Which Python interpreter.** `python` runs one interpreter, the first of: `$METAOBJECTS_PYTHON`; the active virtualenv (`$VIRTUAL_ENV`); a `.venv` beside `.metaobjects/`; `python3` on `PATH` (`python` on Windows). A set `$METAOBJECTS_PYTHON` or `$VIRTUAL_ENV` @@ -471,7 +520,8 @@ verify what an ejected copy published. See A published model travels inside the package that implements it, the way a JVM library's jar once carried its `metadata/*.json`. Consumers install your package with -their package manager and declare an `npm` or `python` dependency on it. Publishing is +their package manager and declare an `npm`, `python`, `maven` or `nuget` dependency on it +(the last two are covered under [Declaring a dependency](#declaring-a-dependency)). Publishing is two steps: generate the shared model into the package, then release the package as you already do. @@ -644,7 +694,7 @@ Tracked in [#482](https://github.com/metaobjectsdev/metaobjects/issues/482). Tod Python, Java, Kotlin and C# consume a dependency (Java and Kotlin shipped in slice S3, C# in S6). A port that does not read the lock fails loudly on a model that references a node a dependency would supply (`ERR_UNRESOLVED_OBJECT_REF`), never silently. Phase 2 adds no new contract. The manifest, the -lock, the committed snapshot and the 26-case resolution corpus are plain files that every port +lock, the committed snapshot and the 28-case resolution corpus are plain files that every port already has the pieces to read; what is missing is each port *loading* the snapshot. Two rulings shape it: @@ -663,7 +713,7 @@ snapshot at runtime: `metaobjects:generate` ships `.metaobjects/deps.lock.json` 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 +an `npm`, `python`, `maven` or `nuget` 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 @@ -679,25 +729,25 @@ same path, which is why the fallback is announced. A load that also has any othe an unresolved `extends` onto a removed dependency node, still fails the command and, for `sync`, rolls the lock and snapshot back. -**Packaged transports: Maven and NuGet (design only, not built).** The `path` transport needs the -publisher's directory on disk. A JVM or .NET consumer that cannot reach it can instead resolve a -published model from the package manager's own store: a Maven artifact (a JAR in the local -repository, or on the classpath) and a NuGet package (in the global packages folder), each -carrying the same `metaobjects.pkg.json` and artifact pair that `sharedModelFile()` writes. Per the -single-writer ruling it is Node `meta deps sync` that reads the pair out of the JAR or the package -and writes the same snapshot and lock entry a `path` sync writes, so everything downstream -(`check`, load, scope, the corpus) is unchanged; nothing in a port's own build resolves a -dependency. This generalizes the resources-JAR pattern described above, and it is a later slice -than consume and check. A JVM *classpath read with no committed snapshot* (the legacy behaviour) -stays a separate, deferred option. +**Packaged transports: Maven and NuGet (shipped).** The `path` transport needs the +publisher's directory on disk. A JVM or .NET consumer that cannot reach it resolves a +published model from the package manager's own store instead: a Maven artifact (a JAR in the +local repository) or a NuGet package (in the global packages folder), each carrying the same +`metaobjects.pkg.json` and artifact pair that `sharedModelFile()` writes (see +[the transports](#declaring-a-dependency) above). Per the single-writer ruling it is Node `meta +deps sync` that reads the pair out of the JAR or the package and writes the same snapshot and +lock entry a `path` sync writes, so everything downstream (`check`, load, scope, the corpus) is +unchanged; nothing in a port's own build resolves a dependency, and the Java, Python and C# +`check` report a `maven` or `nuget` dependency `unresolved` and name the Node `meta deps check`, +as they do for `npm` and `python`. This generalizes the resources-JAR pattern described above. A +JVM *classpath read with no committed snapshot* (the legacy behaviour) stays a separate, deferred +option. ## Deferred Designed (see `docs/superpowers/specs/2026-09-11-fr-023-metadata-dependencies-design.md`) but **not** built yet — do not assume any of the following exists: -- **The `maven` and `nuget` transports** (Phase 2, with the JVM and .NET consumers; see - [Phase 2](#phase-2-java-kotlin-and-c)). - **A local co-development override** (`deps.local.json`) that would point `sync` at an unpublished local checkout without editing the committed config. Today, switch the spec to `path` while co-developing, and back to `npm`/`python` before committing. @@ -713,15 +763,15 @@ 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. - **Python as a publisher** — Java, Kotlin and C# publish (S8 and S9); Python consumes only. -- **`npm` and `python` transports in the Python and C# check.** Both ports' check resolves - `path` only; an `npm` or `python` dependency reports `unresolved` and names the Node `meta deps - check`, because resolving it needs the consumer's installed packages (a check that cannot check - must not pass). +- **`npm`, `python`, `maven` and `nuget` transports in the Python, Java and C# check.** Those + ports' check resolves `path` only; any other dependency reports `unresolved` and names the Node + `meta deps check`, because resolving it needs the consumer's installed packages or local package + caches (a check that cannot check must not pass). ## Verified by -- [`fixtures/dependency-conformance/`](../../fixtures/dependency-conformance/) — 37 - cases. 26 pin resolution (including `npm` and `python` dependencies loading from their +- [`fixtures/dependency-conformance/`](../../fixtures/dependency-conformance/) — 39 + cases. 28 pin resolution (including `npm`, `python`, `maven` and `nuget` dependencies loading from their committed snapshot with nothing installed), the exhaustive `imported`/`inScope`/`inMigrateScope` sets, overlay/extends/reference behavior against a synced snapshot, and every load- and resolution-time error above, run by TypeScript (the reference implementation), @@ -732,14 +782,15 @@ but **not** built yet — do not assume any of the following exists: (`dotnet meta verify --deps`). 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 + Python's runner asserts all 28 resolution cases identically to TypeScript's, with no exemptions — the two overlay cases that need a view child as incidental content use `view.currency` (the one concrete `view.*` subtype registered cross-port, per `fixtures/registry-conformance/expected-registry.json`), not `view.text`, so they exercise the full imported/selected/governed assertions like every - other case. `transport-cases.json` beside it pins the `npm` and `python` transports - themselves (14 cases: packages laid out under a temp `node_modules` and on a temp - Python import path, the directory each resolves to, the exact lock `sync` writes, and + other case. `transport-cases.json` beside it pins the `npm`, `python`, `maven` and `nuget` transports + themselves (32 cases: packages laid out under a temp `node_modules`, on a temp + Python import path, as a jar in a temp local Maven repository and as a package in a temp NuGet + cache, the directory each resolves to, the exact lock `sync` writes, and each refusal), run by Node `meta deps sync`, the only writer. - [`fixtures/shared-model-conformance/`](../../fixtures/shared-model-conformance/) — 11 cases pinning what a publisher writes: the exact artifact and manifest bytes for a source tree and diff --git a/fixtures/dependency-conformance/README.md b/fixtures/dependency-conformance/README.md index 9409cebb90..98dd19ed1a 100644 --- a/fixtures/dependency-conformance/README.md +++ b/fixtures/dependency-conformance/README.md @@ -14,7 +14,7 @@ cases.json # { cases: [ { name, tree, treeFiles?, config, lock?, # expectSelected?, expectMigrateGoverned?, # expectLoadError?, expectErrorFiles?, expectError?, # expectCheck? } ] } -transport-cases.json # the npm / python transport arm (Node `meta deps sync` only — see below) +transport-cases.json # the npm / python / maven / nuget transport arm (Node `meta deps sync` only — see below) README.md artifacts/ # pinned dependency artifacts, referenced by cases via `treeFiles` acme-common-v1.json @@ -197,10 +197,10 @@ corpus expectation to update. `meta deps sync` / `check` find a dependency's manifest directory through its transport (DESIGN §2.1). Node `meta` is the only writer of snapshots and the lock, so only Node runs -this arm; every port then loads the snapshot, and `cases.json` carries an `npm` and a -`python` dependency (`an-npm-dependency-loads-from-its-committed-snapshot`, -`a-python-dependency-loads-from-its-committed-snapshot`) to prove those load with nothing -installed. +this arm; every port then loads the snapshot, and `cases.json` carries an `npm`, a +`python`, a `maven` and a `nuget` dependency (`an-npm-dependency-loads-from-its-committed-snapshot`, +`a-python-dependency-…`, `a-maven-dependency-…`, `a-nuget-dependency-…`) to prove those load with +nothing installed, and that every port's config and lock reader accepts them. ```jsonc { "cases": [ { @@ -208,6 +208,12 @@ installed. "tree": { … }, "treeFiles": { … }, // as in cases.json: what an install leaves on disk "config": { … }, // written to /.metaobjects/config.json "resolveFrom": "app", + "zipFiles": { "": { "": { "text": "…" } | { "file": "artifacts/…" } } }, + // OPTIONAL: archives (a `.jar`, a `.nupkg`) the runner + // writes, each entry inline text or a corpus file + "env": { "MAVEN_REPO_LOCAL": "m2" }, // OPTIONAL: environment variables that name a directory, + // mapped to project-root-relative paths. The runner always + // sets HOME to /home and clears the others "pythonPath": ["site"], // OPTIONAL: project-root-relative directories put on the // interpreter's import path (PYTHONPATH), standing in for // an installed site-packages @@ -220,14 +226,20 @@ installed. Exactly one of `expectLock`, `expectError` per case. An `npm` package is laid out under a `node_modules` directory (beside the config, or hoisted above it); a `python` package under -a `pythonPath` directory. The runner clears `$METAOBJECTS_PYTHON` and `$VIRTUAL_ENV`, so the +a `pythonPath` directory; a `maven` jar under a local repository (`MAVEN_REPO_LOCAL`, a +`settings.xml` under the case's `home/.m2`, or `home/.m2/repository`); a `nuget` package under a +global packages folder (`NUGET_PACKAGES` or `home/.nuget/packages`), either extracted or as the +`.nupkg` alone. The jar and the package hold the pair the S8/S9 publishers write +(`metaobjects.pkg.json` and the artifact, byte-identical across publishers per +`shared-model-conformance`), under `metaobjects/` or the declared `dir`. The runner clears `$METAOBJECTS_PYTHON` and `$VIRTUAL_ENV`, so the interpreter is `python3` on `PATH`; the interpreter ladder itself is a unit test (`cli/test/deps-sync-packages.test.ts`), because it depends on the machine. No case touches a network or a package manager. -The refusals: a package not installed, a `dir` the package does not ship, a `dir` that -climbs out of the package, and a Python module that is not a package are -`ERR_DEPENDENCY_UNRESOLVED`; a `dir` that exists but holds no `metaobjects.pkg.json` is +The refusals: a package not installed (or, for `maven` and `nuget`, not in the local cache), +a `dir` the package does not ship, a `dir` that climbs out of the package, a jar with no model +directory, a `maven` coordinate that carries a version, and a Python module that is not a +package are `ERR_DEPENDENCY_UNRESOLVED`; a `dir` that exists but holds no `metaobjects.pkg.json` is `ERR_DEPENDENCY_MANIFEST_INVALID`. ## Which arms each port runs diff --git a/fixtures/dependency-conformance/cases.json b/fixtures/dependency-conformance/cases.json index ddabba5396..33b77b3083 100644 --- a/fixtures/dependency-conformance/cases.json +++ b/fixtures/dependency-conformance/cases.json @@ -1678,6 +1678,124 @@ "expectMigrateGoverned": [ "app::Order" ] + }, + { + "name": "a-maven-dependency-loads-from-its-committed-snapshot", + "tree": { + "metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}" + }, + "treeFiles": { + ".metaobjects/deps/acme-common/acme-common.metaobjects.json": "artifacts/acme-common-v1.json" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "maven": "com.acme:common-model", + "version": "1.0.0", + "dir": "metaobjects" + } + ] + }, + "lock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "maven": "com.acme:common-model", + "version": "1.0.0", + "dir": "metaobjects" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + }, + "expectFiles": [ + ".metaobjects/deps/acme-common/acme-common.metaobjects.json", + "metaobjects/meta.app.json" + ], + "expectImported": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ], + "expectSelected": [ + "app::Order" + ], + "expectMigrateGoverned": [ + "app::Order" + ] + }, + { + "name": "a-nuget-dependency-loads-from-its-committed-snapshot", + "tree": { + "metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}" + }, + "treeFiles": { + ".metaobjects/deps/acme-common/acme-common.metaobjects.json": "artifacts/acme-common-v1.json" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "nuget": "Acme.Common.Model", + "version": "1.0.0" + } + ] + }, + "lock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "nuget": "Acme.Common.Model", + "version": "1.0.0" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + }, + "expectFiles": [ + ".metaobjects/deps/acme-common/acme-common.metaobjects.json", + "metaobjects/meta.app.json" + ], + "expectImported": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ], + "expectSelected": [ + "app::Order" + ], + "expectMigrateGoverned": [ + "app::Order" + ] } ] } diff --git a/fixtures/dependency-conformance/transport-cases.json b/fixtures/dependency-conformance/transport-cases.json index 31260b9fa1..80a006341e 100644 --- a/fixtures/dependency-conformance/transport-cases.json +++ b/fixtures/dependency-conformance/transport-cases.json @@ -517,6 +517,739 @@ "site" ], "expectError": "ERR_DEPENDENCY_UNRESOLVED" + }, + { + "name": "maven-reads-the-jar-resource-from-the-local-repository", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}" + }, + "zipFiles": { + "m2/repository/com/acme/acme-common-model/1.0.0/acme-common-model-1.0.0.jar": { + "META-INF/MANIFEST.MF": { + "text": "Manifest-Version: 1.0\r\nCreated-By: Maven JAR Plugin\r\n\r\n" + }, + "metaobjects/metaobjects.pkg.json": { + "text": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "metaobjects/acme-common.metaobjects.json": { + "file": "artifacts/acme-common-v1.json" + }, + "com/acme/Model.class": { + "text": "Êþº¾" + } + } + }, + "env": { + "MAVEN_REPO_LOCAL": "m2/repository" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "maven": "com.acme:acme-common-model", + "version": "1.0.0" + } + ] + }, + "resolveFrom": "app", + "expectLock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "maven": "com.acme:acme-common-model", + "version": "1.0.0" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + } + }, + { + "name": "maven-applies-dir-inside-the-jar", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}" + }, + "zipFiles": { + "m2/repository/com/acme/acme-common-model/1.0.0/acme-common-model-1.0.0.jar": { + "META-INF/MANIFEST.MF": { + "text": "Manifest-Version: 1.0\r\nCreated-By: Maven JAR Plugin\r\n\r\n" + }, + "shared-model/metaobjects.pkg.json": { + "text": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "shared-model/acme-common.metaobjects.json": { + "file": "artifacts/acme-common-v1.json" + } + } + }, + "env": { + "MAVEN_REPO_LOCAL": "m2/repository" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "maven": "com.acme:acme-common-model", + "version": "1.0.0", + "dir": "shared-model" + } + ] + }, + "resolveFrom": "app", + "expectLock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "maven": "com.acme:acme-common-model", + "version": "1.0.0", + "dir": "shared-model" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + } + }, + { + "name": "maven-honours-the-local-repository-in-settings-xml", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}", + "home/.m2/settings.xml": "\n \n ${user.home}/cache/m2\n\n" + }, + "zipFiles": { + "home/cache/m2/com/acme/acme-common-model/1.0.0/acme-common-model-1.0.0.jar": { + "metaobjects/metaobjects.pkg.json": { + "text": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "metaobjects/acme-common.metaobjects.json": { + "file": "artifacts/acme-common-v1.json" + } + } + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "maven": "com.acme:acme-common-model", + "version": "1.0.0" + } + ] + }, + "resolveFrom": "app", + "expectLock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "maven": "com.acme:acme-common-model", + "version": "1.0.0" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + } + }, + { + "name": "maven-reads-the-default-local-repository-under-home", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}" + }, + "zipFiles": { + "home/.m2/repository/com/acme/acme-common-model/1.0.0/acme-common-model-1.0.0.jar": { + "metaobjects/metaobjects.pkg.json": { + "text": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "metaobjects/acme-common.metaobjects.json": { + "file": "artifacts/acme-common-v1.json" + } + } + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "maven": "com.acme:acme-common-model", + "version": "1.0.0" + } + ] + }, + "resolveFrom": "app", + "expectLock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "maven": "com.acme:acme-common-model", + "version": "1.0.0" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + } + }, + { + "name": "maven-an-artifact-that-is-not-in-the-local-repository-is-unresolved", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}" + }, + "env": { + "MAVEN_REPO_LOCAL": "m2/repository" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "maven": "com.acme:acme-common-model", + "version": "1.0.0" + } + ] + }, + "resolveFrom": "app", + "expectError": "ERR_DEPENDENCY_UNRESOLVED" + }, + { + "name": "maven-a-coordinate-that-carries-a-version-is-unresolved", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}" + }, + "zipFiles": { + "m2/repository/com/acme/acme-common-model/1.0.0/acme-common-model-1.0.0.jar": { + "metaobjects/metaobjects.pkg.json": { + "text": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "metaobjects/acme-common.metaobjects.json": { + "file": "artifacts/acme-common-v1.json" + } + } + }, + "env": { + "MAVEN_REPO_LOCAL": "m2/repository" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "maven": "com.acme:acme-common-model:1.0.0", + "version": "1.0.0" + } + ] + }, + "resolveFrom": "app", + "expectError": "ERR_DEPENDENCY_UNRESOLVED" + }, + { + "name": "maven-a-jar-without-the-model-directory-is-unresolved", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}" + }, + "zipFiles": { + "m2/repository/com/acme/acme-common-model/1.0.0/acme-common-model-1.0.0.jar": { + "META-INF/MANIFEST.MF": { + "text": "Manifest-Version: 1.0\r\nCreated-By: Maven JAR Plugin\r\n\r\n" + } + } + }, + "env": { + "MAVEN_REPO_LOCAL": "m2/repository" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "maven": "com.acme:acme-common-model", + "version": "1.0.0" + } + ] + }, + "resolveFrom": "app", + "expectError": "ERR_DEPENDENCY_UNRESOLVED" + }, + { + "name": "maven-a-model-directory-without-a-manifest-is-an-invalid-manifest", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}" + }, + "zipFiles": { + "m2/repository/com/acme/acme-common-model/1.0.0/acme-common-model-1.0.0.jar": { + "metaobjects/acme-common.metaobjects.json": { + "file": "artifacts/acme-common-v1.json" + } + } + }, + "env": { + "MAVEN_REPO_LOCAL": "m2/repository" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "maven": "com.acme:acme-common-model", + "version": "1.0.0" + } + ] + }, + "resolveFrom": "app", + "expectError": "ERR_DEPENDENCY_MANIFEST_INVALID" + }, + { + "name": "maven-a-dir-that-climbs-out-of-the-jar-is-unresolved", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}" + }, + "zipFiles": { + "m2/repository/com/acme/acme-common-model/1.0.0/acme-common-model-1.0.0.jar": { + "metaobjects/metaobjects.pkg.json": { + "text": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "metaobjects/acme-common.metaobjects.json": { + "file": "artifacts/acme-common-v1.json" + } + } + }, + "env": { + "MAVEN_REPO_LOCAL": "m2/repository" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "maven": "com.acme:acme-common-model", + "version": "1.0.0", + "dir": "../escape" + } + ] + }, + "resolveFrom": "app", + "expectError": "ERR_DEPENDENCY_UNRESOLVED" + }, + { + "name": "nuget-reads-the-extracted-package-from-the-global-packages-folder", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}", + "nuget/packages/acme.common.model/1.0.0/metaobjects/metaobjects.pkg.json": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n", + "nuget/packages/acme.common.model/1.0.0/acme.common.model.nuspec": "" + }, + "treeFiles": { + "nuget/packages/acme.common.model/1.0.0/metaobjects/acme-common.metaobjects.json": "artifacts/acme-common-v1.json" + }, + "zipFiles": { + "nuget/packages/acme.common.model/1.0.0/acme.common.model.1.0.0.nupkg": { + "Acme.Common.Model.nuspec": { + "text": "" + }, + "metaobjects/metaobjects.pkg.json": { + "text": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "metaobjects/acme-common.metaobjects.json": { + "file": "artifacts/acme-common-v1.json" + } + } + }, + "env": { + "NUGET_PACKAGES": "nuget/packages" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "nuget": "Acme.Common.Model", + "version": "1.0.0" + } + ] + }, + "resolveFrom": "app", + "expectDirs": { + "acme-common": "nuget/packages/acme.common.model/1.0.0/metaobjects" + }, + "expectLock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "nuget": "Acme.Common.Model", + "version": "1.0.0" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + } + }, + { + "name": "nuget-applies-dir-inside-the-package", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}", + "nuget/packages/acme.common.model/1.0.0/content/model/metaobjects.pkg.json": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "treeFiles": { + "nuget/packages/acme.common.model/1.0.0/content/model/acme-common.metaobjects.json": "artifacts/acme-common-v1.json" + }, + "env": { + "NUGET_PACKAGES": "nuget/packages" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "nuget": "Acme.Common.Model", + "version": "1.0.0", + "dir": "content/model" + } + ] + }, + "resolveFrom": "app", + "expectDirs": { + "acme-common": "nuget/packages/acme.common.model/1.0.0/content/model" + }, + "expectLock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "nuget": "Acme.Common.Model", + "version": "1.0.0", + "dir": "content/model" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + } + }, + { + "name": "nuget-finds-the-normalized-version-folder", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}", + "nuget/packages/acme.common.model/1.0.0/metaobjects/metaobjects.pkg.json": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "treeFiles": { + "nuget/packages/acme.common.model/1.0.0/metaobjects/acme-common.metaobjects.json": "artifacts/acme-common-v1.json" + }, + "env": { + "NUGET_PACKAGES": "nuget/packages" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "nuget": "Acme.Common.Model", + "version": "1.0" + } + ] + }, + "resolveFrom": "app", + "expectDirs": { + "acme-common": "nuget/packages/acme.common.model/1.0.0/metaobjects" + }, + "expectLock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "nuget": "Acme.Common.Model", + "version": "1.0" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + } + }, + { + "name": "nuget-reads-the-nupkg-when-the-cache-holds-no-extracted-files", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}" + }, + "zipFiles": { + "nuget/packages/acme.common.model/1.0.0/acme.common.model.1.0.0.nupkg": { + "Acme.Common.Model.nuspec": { + "text": "" + }, + "metaobjects/metaobjects.pkg.json": { + "text": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "metaobjects/acme-common.metaobjects.json": { + "file": "artifacts/acme-common-v1.json" + } + } + }, + "env": { + "NUGET_PACKAGES": "nuget/packages" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "nuget": "Acme.Common.Model", + "version": "1.0.0" + } + ] + }, + "resolveFrom": "app", + "expectLock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "nuget": "Acme.Common.Model", + "version": "1.0.0" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + } + }, + { + "name": "nuget-reads-the-default-global-packages-folder-under-home", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}", + "home/.nuget/packages/acme.common.model/1.0.0/metaobjects/metaobjects.pkg.json": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "treeFiles": { + "home/.nuget/packages/acme.common.model/1.0.0/metaobjects/acme-common.metaobjects.json": "artifacts/acme-common-v1.json" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "nuget": "Acme.Common.Model", + "version": "1.0.0" + } + ] + }, + "resolveFrom": "app", + "expectDirs": { + "acme-common": "home/.nuget/packages/acme.common.model/1.0.0/metaobjects" + }, + "expectLock": { + "schema_version": 1, + "dependencies": { + "acme-common": { + "version": "1.0.0", + "metamodelVersion": "1.0", + "resolvedFrom": { + "nuget": "Acme.Common.Model", + "version": "1.0.0" + }, + "artifact": "acme-common.metaobjects.json", + "integrity": "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d", + "packages": [ + "acme::common" + ], + "nodes": [ + "acme::common::Address", + "acme::common::Audited", + "acme::common::Customer" + ] + } + } + } + }, + { + "name": "nuget-a-package-that-is-not-in-the-cache-is-unresolved", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}" + }, + "env": { + "NUGET_PACKAGES": "nuget/packages" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "nuget": "Acme.Common.Model", + "version": "1.0.0" + } + ] + }, + "resolveFrom": "app", + "expectError": "ERR_DEPENDENCY_UNRESOLVED" + }, + { + "name": "nuget-a-package-without-the-model-directory-is-unresolved", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}", + "nuget/packages/acme.common.model/1.0.0/lib/net8.0/placeholder.txt": "x" + }, + "env": { + "NUGET_PACKAGES": "nuget/packages" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "nuget": "Acme.Common.Model", + "version": "1.0.0" + } + ] + }, + "resolveFrom": "app", + "expectError": "ERR_DEPENDENCY_UNRESOLVED" + }, + { + "name": "nuget-a-model-directory-without-a-manifest-is-an-invalid-manifest", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}", + "nuget/packages/acme.common.model/1.0.0/metaobjects/readme.txt": "x" + }, + "env": { + "NUGET_PACKAGES": "nuget/packages" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "nuget": "Acme.Common.Model", + "version": "1.0.0" + } + ] + }, + "resolveFrom": "app", + "expectError": "ERR_DEPENDENCY_MANIFEST_INVALID" + }, + { + "name": "nuget-a-dir-that-climbs-out-of-the-package-is-unresolved", + "tree": { + "app/metaobjects/meta.app.json": "{\"metadata.root\":{\"package\":\"app\",\"children\":[{\"object.entity\":{\"name\":\"Order\",\"children\":[{\"source.rdb\":{\"@table\":\"orders\"}},{\"field.long\":{\"name\":\"id\"}},{\"identity.primary\":{\"name\":\"pk\",\"@fields\":[\"id\"]}}]}}]}}", + "nuget/packages/acme.common.model/1.0.0/metaobjects/metaobjects.pkg.json": "{\n \"schema_version\": 1,\n \"name\": \"acme-common\",\n \"version\": \"1.0.0\",\n \"metamodelVersion\": \"1.0\",\n \"artifact\": \"acme-common.metaobjects.json\",\n \"integrity\": \"sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d\",\n \"packages\": [\n \"acme::common\"\n ],\n \"nodes\": [\n \"acme::common::Address\",\n \"acme::common::Audited\",\n \"acme::common::Customer\"\n ]\n}\n" + }, + "env": { + "NUGET_PACKAGES": "nuget/packages" + }, + "config": { + "schema_version": 1, + "sources": [], + "dependencies": [ + { + "name": "acme-common", + "nuget": "Acme.Common.Model", + "version": "1.0.0", + "dir": "../escape" + } + ] + }, + "resolveFrom": "app", + "expectError": "ERR_DEPENDENCY_UNRESOLVED" } ] } diff --git a/server/csharp/MetaObjects.Conformance.Tests/DependencyUnitTests.cs b/server/csharp/MetaObjects.Conformance.Tests/DependencyUnitTests.cs index 0240b6697f..b2e76cd9e1 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/DependencyUnitTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/DependencyUnitTests.cs @@ -69,6 +69,19 @@ public void A_valid_dependency_is_read_with_its_transport() new DependencySpec("b.c-d", "npm", "@acme/common", "metaobjects")], cfg.Dependencies); } + [Fact] + public void Maven_and_nuget_dependencies_are_read_with_their_version_and_dir() + { + WriteConfig(""" + { "schema_version": 1, "dependencies": [ + { "name": "a", "maven": "com.acme:model", "version": "1.0.0", "dir": "shared" }, + { "name": "b", "nuget": "Acme.Model", "version": "1.0" } ] } + """); + var cfg = NeutralConfig.Read(_root)!; + Assert.Equal([new DependencySpec("a", "maven", "com.acme:model", "shared", "1.0.0"), + new DependencySpec("b", "nuget", "Acme.Model", null, "1.0")], cfg.Dependencies); + } + [Theory] [InlineData("""[{ "name": "Bad", "path": "x" }]""")] // uppercase name [InlineData("""[{ "name": "a" }]""")] // no transport @@ -78,6 +91,9 @@ public void A_valid_dependency_is_read_with_its_transport() [InlineData("""[{ "name": "a", "path": "" }]""")] // empty location [InlineData("""[{ "name": "a", "path": "x" }, { "name": "a", "python": "y" }]""")] // duplicate name [InlineData("""{ "name": "a", "path": "x" }""")] // not an array + [InlineData("""[{ "name": "a", "maven": "com.acme:model" }]""")] // no version + [InlineData("""[{ "name": "a", "nuget": "Acme.Model", "version": "" }]""")] // empty version + [InlineData("""[{ "name": "a", "npm": "y", "version": "1.0.0" }]""")] // version beside npm public void A_malformed_dependency_declaration_is_refused(string dependenciesJson) => AssertConfigRefused(dependenciesJson); @@ -123,6 +139,28 @@ public void A_well_formed_lock_is_read() Assert.Equal(Hash, entries["a"].Integrity); } + [Theory] + [InlineData("""{ "maven": "com.acme:model", "version": "1.0.0" }""")] + [InlineData("""{ "maven": "com.acme:model", "version": "1.0.0", "dir": "shared" }""")] + [InlineData("""{ "nuget": "Acme.Model", "version": "1.0.0" }""")] + public void A_lock_written_from_a_packaged_transport_is_read(string resolvedFrom) + { + WriteLock(Lock("", resolvedFrom)); + Assert.Equal(Hash, DependencyResolver.ReadLock(_root)!["a"].Integrity); + } + + [Theory] + [InlineData("maven")] + [InlineData("nuget")] + public void Check_reports_a_packaged_transport_unresolved_and_names_the_node_command(string transport) + { + var spec = new DependencySpec("a", transport, "x", null, "1.0.0"); + var result = Assert.Single(DependencyResolver.CheckDependencies(_root, [spec], null)); + Assert.Equal(DependencyCheckStatus.Unresolved, result.Status); + Assert.Contains($"\"{transport}\" transport", result.Detail); + Assert.Contains("meta deps check", result.Detail); + } + [Fact] public void No_lock_file_reads_as_null() => Assert.Null(DependencyResolver.ReadLock(_root)); @@ -131,6 +169,8 @@ public void A_well_formed_lock_is_read() [InlineData("", """{ "path": "../a", "npm": "x" }""", """["acme::common"]""")] // two transports [InlineData("", """{ "path": "../a", "dir": "d" }""", """["acme::common"]""")] // dir beside path [InlineData("", """{ "path": "../a" }""", """["b::x", "a::y"]""")] // unsorted packages + [InlineData("", """{ "maven": "com.acme:model" }""", """["acme::common"]""")] // maven without a version + [InlineData("", """{ "nuget": "Acme.Model", "version": "" }""", """["acme::common"]""")] // empty version public void A_malformed_lock_is_stale_never_trusted(string extras, string resolvedFrom, string packages) { WriteLock(Lock(extras, resolvedFrom, packages)); diff --git a/server/csharp/MetaObjects/Config/Dependencies.cs b/server/csharp/MetaObjects/Config/Dependencies.cs index 9076ca4295..e551482230 100644 --- a/server/csharp/MetaObjects/Config/Dependencies.cs +++ b/server/csharp/MetaObjects/Config/Dependencies.cs @@ -42,10 +42,11 @@ public static class DependencyFiles /// One validated entry of the config's dependencies array. /// The LOCAL alias the config, lock and .metaobjects/deps/<name>/ key on. -/// The one transport key: path, npm or python. +/// The one transport key: path, npm, python, maven or nuget. /// The transport key's value. -/// The optional dir, legal only beside npm / python. -public sealed record DependencySpec(string Name, string Transport, string Location, string? Dir); +/// The optional dir, legal beside any transport but path. +/// The version to look up, required beside maven / nuget and illegal elsewhere. +public sealed record DependencySpec(string Name, string Transport, string Location, string? Dir, string? Version = null); /// One validated entry of the lock's dependencies object. public sealed record DependencyLockEntry( @@ -72,7 +73,10 @@ public static partial class DependencyResolver [GeneratedRegex("^sha256-[0-9a-f]{64}$")] private static partial Regex IntegrityRegex(); - private static readonly string[] TransportKeys = ["path", "npm", "python"]; + private static readonly string[] TransportKeys = ["path", "npm", "python", "maven", "nuget"]; + + /// The packaged transports name a package in a cache that holds many versions, so they carry a version. + private static bool IsVersioned(string transport) => transport is "maven" or "nuget"; private static readonly string[] LockKeys = ["schema_version", "dependencies"]; private static readonly string[] LockEntryKeys = ["version", "metamodelVersion", "artifact", "integrity", "packages", "nodes", "resolvedFrom"]; @@ -85,7 +89,8 @@ public static partial class DependencyResolver /// /// Validate one entry of dependencies: a name, exactly one transport key, an - /// optional dir legal only beside npm / python, and nothing else (a + /// optional dir legal beside any transport but path, a version required beside maven / + /// nuget, and nothing else (a /// mode key included — it was removed). Mirrors the TypeScript DependencySpecSchema. /// internal static DependencySpec ReadSpec(JsonElement dep, string configPath) @@ -101,7 +106,7 @@ internal static DependencySpec ReadSpec(JsonElement dep, string configPath) var name = nameEl.GetString()!; var transports = TransportKeys.Where(k => dep.TryGetProperty(k, out _)).ToList(); - if (transports.Count != 1) throw Fail("exactly one of \"path\" / \"npm\" / \"python\" is required"); + if (transports.Count != 1) throw Fail("exactly one of \"path\" / \"npm\" / \"python\" / \"maven\" / \"nuget\" is required"); var transport = transports[0]; var location = dep.GetProperty(transport); @@ -109,7 +114,8 @@ internal static DependencySpec ReadSpec(JsonElement dep, string configPath) throw Fail($"\"{transport}\" must be a non-empty string"); var allowed = new HashSet(StringComparer.Ordinal) { "name", transport }; - if (transport is "npm" or "python") allowed.Add("dir"); + if (transport != "path") allowed.Add("dir"); + if (IsVersioned(transport)) allowed.Add("version"); var extra = dep.EnumerateObject().Select(p => p.Name).Where(n => !allowed.Contains(n)) .Order(StringComparer.Ordinal).ToList(); if (extra.Count > 0) throw Fail($"unknown key(s): [{string.Join(", ", extra)}]"); @@ -122,7 +128,16 @@ internal static DependencySpec ReadSpec(JsonElement dep, string configPath) dir = dirEl.GetString(); } - return new DependencySpec(name, transport, location.GetString()!, dir); + string? version = null; + if (IsVersioned(transport)) + { + if (!dep.TryGetProperty("version", out var versionEl) || versionEl.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(versionEl.GetString())) + throw Fail($"\"version\" is required beside \"{transport}\" and must be a non-empty string"); + version = versionEl.GetString(); + } + + return new DependencySpec(name, transport, location.GetString()!, dir, version); } // ------------------------------------------------------------------ @@ -167,7 +182,7 @@ private static void ValidateResolvedFrom(JsonElement entry, string context) var transports = TransportKeys.Where(k => rf.TryGetProperty(k, out _)).ToList(); if (transports.Count != 1) - throw Stale($"{context}: \"resolvedFrom\" must have exactly one of \"path\" / \"npm\" / \"python\""); + throw Stale($"{context}: \"resolvedFrom\" must have exactly one of \"path\" / \"npm\" / \"python\" / \"maven\" / \"nuget\""); var transport = transports[0]; var value = rf.GetProperty(transport); @@ -175,7 +190,8 @@ private static void ValidateResolvedFrom(JsonElement entry, string context) throw Stale($"{context}: \"resolvedFrom.{transport}\" must be a non-empty string"); var allowed = new HashSet(StringComparer.Ordinal) { transport }; - if (transport is "npm" or "python") allowed.Add("dir"); + if (transport != "path") allowed.Add("dir"); + if (IsVersioned(transport)) allowed.Add("version"); var extra = rf.EnumerateObject().Select(p => p.Name).Where(n => !allowed.Contains(n)) .Order(StringComparer.Ordinal).ToList(); if (extra.Count > 0) throw Stale($"{context}: \"resolvedFrom\" unknown key(s): [{string.Join(", ", extra)}]"); @@ -183,6 +199,11 @@ private static void ValidateResolvedFrom(JsonElement entry, string context) if (rf.TryGetProperty("dir", out var dir) && (dir.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(dir.GetString()))) throw Stale($"{context}: \"resolvedFrom.dir\" must be a non-empty string"); + + if (IsVersioned(transport) && + (!rf.TryGetProperty("version", out var version) || version.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(version.GetString()))) + throw Stale($"{context}: \"resolvedFrom.version\" is required beside \"{transport}\" and must be a non-empty string"); } /// diff --git a/server/csharp/MetaObjects/Config/DependencyCheck.cs b/server/csharp/MetaObjects/Config/DependencyCheck.cs index 78e0980c1a..71c01fa1fa 100644 --- a/server/csharp/MetaObjects/Config/DependencyCheck.cs +++ b/server/csharp/MetaObjects/Config/DependencyCheck.cs @@ -9,8 +9,8 @@ // loads nothing. The Node `meta deps sync` (the only writer of the lock and the snapshot) is what // repairs a drift this reports. // -// Only the `path` transport is resolved here. An `npm` / `python` dependency is resolved through -// the consumer's installed packages, which only the Node toolchain reads, so this port reports it +// Only the `path` transport is resolved here. An `npm` / `python` / `maven` / `nuget` dependency is +// resolved through installed packages and local package caches, which only the Node toolchain reads, so this port reports it // `Unresolved` and names `meta deps check`: a check that cannot check must not pass. using System.Text.Json; 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 82970b69b9..b7320cb732 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 @@ -79,7 +79,8 @@ private Dependencies() {} private static final Set LOCK_KEYS = Set.of("schema_version", "dependencies"); private static final Set LOCK_ENTRY_KEYS = Set.of( "version", "metamodelVersion", "artifact", "integrity", "packages", "nodes", "resolvedFrom"); - private static final List RESOLVED_FROM_TRANSPORTS = List.of("path", "npm", "python"); + private static final List RESOLVED_FROM_TRANSPORTS = + List.of("path", "npm", "python", "maven", "nuget"); /** One lock entry, validated. */ public record LockEntry(String version, String metamodelVersion, String artifact, String integrity, @@ -297,7 +298,7 @@ private static void validateResolvedFrom(JsonElement raw, String context) { List transports = new ArrayList<>(); for (String t : RESOLVED_FROM_TRANSPORTS) if (obj.has(t)) transports.add(t); if (transports.size() != 1) { - throw lockInvalid(context + ": 'resolvedFrom' must have exactly one of 'path' / 'npm' / 'python'"); + throw lockInvalid(context + ": 'resolvedFrom' must have exactly one of 'path' / 'npm' / 'python' / 'maven' / 'nuget'"); } String transport = transports.get(0); JsonElement value = obj.get(transport); @@ -306,9 +307,19 @@ private static void validateResolvedFrom(JsonElement raw, String context) { } Set allowed = new HashSet<>(Set.of(transport)); if (!transport.equals("path")) allowed.add("dir"); + boolean versioned = transport.equals("maven") || transport.equals("nuget"); + if (versioned) allowed.add("version"); for (String key : obj.keySet()) { if (!allowed.contains(key)) throw lockInvalid(context + ": 'resolvedFrom' unknown key(s): [" + key + "]"); } + if (versioned) { + JsonElement version = obj.get("version"); + if (version == null || !version.isJsonPrimitive() || !version.getAsJsonPrimitive().isString() + || version.getAsString().isBlank()) { + throw lockInvalid(context + ": 'resolvedFrom.version' is required beside '" + transport + + "' and must be a non-empty string"); + } + } if (obj.has("dir")) { JsonElement dir = obj.get("dir"); if (!dir.isJsonPrimitive() || !dir.getAsJsonPrimitive().isString() || dir.getAsString().isBlank()) { @@ -466,7 +477,7 @@ public static String hash8(String integrity) { * 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 + *

Only the {@code path} transport is resolved here: an {@code npm}, {@code python}, {@code maven} or {@code nuget} 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. * @@ -517,7 +528,8 @@ private static final class CheckUnresolved extends RuntimeException { private static Path resolveDependencyDir(Path configDir, Map spec) { if (!spec.containsKey("path")) { - String transport = spec.containsKey("npm") ? "npm" : "python"; + String transport = List.of("npm", "python", "maven", "nuget").stream() + .filter(spec::containsKey).findFirst().orElse("npm"); throw new CheckUnresolved("the \"" + transport + "\" transport is resolved by the Node tool, not the Maven " + "plugin; run `meta deps check`"); } diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java b/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java index f6d0282e6a..7dd784ab73 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java +++ b/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java @@ -93,8 +93,9 @@ public List> getSources() { /** * FR-023 — the declared {@code dependencies}, each already validated (DESIGN §3.1): a - * {@code name}, exactly one transport key ({@code path} | {@code npm} | {@code python}), - * and {@code dir} only beside {@code npm}/{@code python}. Empty when the key is absent. + * {@code name}, exactly one transport key ({@code path} | {@code npm} | {@code python} | + * {@code maven} | {@code nuget}), a {@code version} beside {@code maven}/{@code nuget}, and + * {@code dir} beside any transport but {@code path}. Empty when the key is absent. * Resolving a transport to bytes is the Node {@code meta deps sync}'s job; this port reads * the committed snapshot instead. */ @@ -375,7 +376,12 @@ private static EnumDefaults parseEnums(JsonElement enums, String origin) { private static final java.util.regex.Pattern DEPENDENCY_NAME = java.util.regex.Pattern.compile("^[a-z0-9][a-z0-9._-]*$"); - private static final List DEPENDENCY_TRANSPORTS = List.of("path", "npm", "python"); + private static final List DEPENDENCY_TRANSPORTS = List.of("path", "npm", "python", "maven", "nuget"); + + /** The packaged transports name a package in a cache that holds many versions, so they carry a {@code version}. */ + private static boolean isVersioned(String transport) { + return transport.equals("maven") || transport.equals("nuget"); + } private static MetaDataException bad(String message) { return new MetaDataException(message, ErrorCode.ERR_BAD_ATTR_VALUE); @@ -404,7 +410,7 @@ private static List> parseDependencies(JsonElement raw, Stri List transports = new ArrayList<>(); for (String t : DEPENDENCY_TRANSPORTS) if (dep.has(t)) transports.add(t); if (transports.size() != 1) { - throw bad(ctx + "exactly one of 'path' / 'npm' / 'python' is required)"); + throw bad(ctx + "exactly one of 'path' / 'npm' / 'python' / 'maven' / 'nuget' is required)"); } String transport = transports.get(0); Map spec = new LinkedHashMap<>(); @@ -413,12 +419,17 @@ private static List> parseDependencies(JsonElement raw, Stri java.util.Set allowed = new java.util.HashSet<>(List.of("name", transport)); if (!transport.equals("path")) allowed.add("dir"); + if (isVersioned(transport)) allowed.add("version"); List extra = new ArrayList<>(); for (String k : dep.keySet()) if (!allowed.contains(k)) extra.add(k); if (!extra.isEmpty()) { java.util.Collections.sort(extra); throw bad(ctx + "unknown key(s): " + extra + ")"); } + if (isVersioned(transport)) { + spec.put("version", nonEmptyString(dep.get("version"), + ctx + "'version' is required beside '" + transport + "' and must be a non-empty string)")); + } if (dep.has("dir")) { spec.put("dir", nonEmptyString(dep.get("dir"), ctx + "'dir' must be a non-empty string)")); } diff --git a/server/java/metadata/src/test/java/com/metaobjects/config/NeutralConfigTest.java b/server/java/metadata/src/test/java/com/metaobjects/config/NeutralConfigTest.java index ebe62498c3..3094c57ccc 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/config/NeutralConfigTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/config/NeutralConfigTest.java @@ -151,4 +151,26 @@ public void refusesAnUnknownDependencyKey() { public void refusesDirBesideAPathTransport() { assertBadAttr("[{\"name\":\"a\",\"path\":\"../a\",\"dir\":\"x\"}]"); } + + @Test + public void readsMavenAndNugetDependenciesWithTheirVersionAndDir() { + NeutralConfig cfg = dep("[{\"name\":\"a\",\"maven\":\"com.acme:model\",\"version\":\"1.0.0\",\"dir\":\"shared\"}," + + "{\"name\":\"b\",\"nuget\":\"Acme.Model\",\"version\":\"1.0\"}]"); + assertEquals("com.acme:model", cfg.getDependencies().get(0).get("maven")); + assertEquals("1.0.0", cfg.getDependencies().get(0).get("version")); + assertEquals("shared", cfg.getDependencies().get(0).get("dir")); + assertEquals("Acme.Model", cfg.getDependencies().get(1).get("nuget")); + assertEquals("1.0", cfg.getDependencies().get(1).get("version")); + } + + @Test + public void refusesAPackagedTransportWithoutAVersion() { + assertBadAttr("[{\"name\":\"a\",\"maven\":\"com.acme:model\"}]"); + assertBadAttr("[{\"name\":\"a\",\"nuget\":\"Acme.Model\",\"version\":\"\"}]"); + } + + @Test + public void refusesAVersionBesideAnotherTransport() { + assertBadAttr("[{\"name\":\"a\",\"npm\":\"@x/y\",\"version\":\"1.0.0\"}]"); + } } diff --git a/server/java/metadata/src/test/java/com/metaobjects/config/PackagedTransportDependenciesTest.java b/server/java/metadata/src/test/java/com/metaobjects/config/PackagedTransportDependenciesTest.java new file mode 100644 index 0000000000..b43d1a6f46 --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/config/PackagedTransportDependenciesTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.config; + +import com.metaobjects.ErrorCode; +import com.metaobjects.MetaDataException; +import org.junit.Test; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +/** + * FR-023 Phase 2: a lock written by Node {@code meta deps sync} from a {@code maven} or {@code nuget} + * dependency records {@code resolvedFrom} with the package and the version looked up. This port reads + * that lock, and its check reports such a dependency {@code unresolved} and names the Node command, + * because resolving it needs the machine's local Maven repository or NuGet cache. + */ +public class PackagedTransportDependenciesTest { + + private static final String INTEGRITY = "sha256-10fbf886e22faceca32c56e5e647c3ff1c82f503e638cba3bd3aa9390f7c409d"; + + private static String lockWith(String resolvedFrom) { + return "{\"schema_version\":1,\"dependencies\":{\"acme-common\":{\"version\":\"1.0.0\"," + + "\"metamodelVersion\":\"1.0\",\"resolvedFrom\":" + resolvedFrom + "," + + "\"artifact\":\"acme-common.metaobjects.json\",\"integrity\":\"" + INTEGRITY + "\"," + + "\"packages\":[\"acme::common\"],\"nodes\":[\"acme::common::Customer\"]}}}"; + } + + @Test + public void readsALockWhoseResolvedFromIsAMavenOrNugetPackage() { + assertEquals(1, Dependencies.parseLock( + lockWith("{\"maven\":\"com.acme:model\",\"version\":\"1.0.0\"}"), "test").size()); + assertEquals(1, Dependencies.parseLock( + lockWith("{\"maven\":\"com.acme:model\",\"version\":\"1.0.0\",\"dir\":\"shared\"}"), "test").size()); + assertEquals(1, Dependencies.parseLock( + lockWith("{\"nuget\":\"Acme.Model\",\"version\":\"1.0.0\"}"), "test").size()); + } + + @Test + public void refusesAPackagedResolvedFromWithoutItsVersion() { + for (String bad : List.of("{\"maven\":\"com.acme:model\"}", "{\"nuget\":\"Acme.Model\",\"version\":\"\"}", + "{\"npm\":\"@x/y\",\"version\":\"1.0.0\"}")) { + MetaDataException e = assertThrows(MetaDataException.class, () -> Dependencies.parseLock(lockWith(bad), "test")); + assertEquals(ErrorCode.ERR_DEPENDENCY_SNAPSHOT_STALE, e.getCode().orElse(null)); + } + } + + @Test + public void checkReportsAPackagedDependencyUnresolvedAndNamesTheNodeCommand() { + Map lock = Dependencies.parseLock( + lockWith("{\"maven\":\"com.acme:model\",\"version\":\"1.0.0\"}"), "test"); + for (String key : List.of("maven", "nuget")) { + Map spec = Map.of("name", "acme-common", key, "x", "version", "1.0.0"); + List results = + Dependencies.check(Path.of("."), List.of(spec), Optional.of(lock)); + assertEquals(1, results.size()); + assertEquals(Dependencies.CheckStatus.UNRESOLVED, results.get(0).status()); + assertTrue(results.get(0).detail().contains("\"" + key + "\" transport")); + assertTrue(results.get(0).detail().contains("meta deps check")); + } + } +} diff --git a/server/python/src/metaobjects/config/dependencies.py b/server/python/src/metaobjects/config/dependencies.py index 039baabc84..c48fca7563 100644 --- a/server/python/src/metaobjects/config/dependencies.py +++ b/server/python/src/metaobjects/config/dependencies.py @@ -94,7 +94,7 @@ } #: The one transport key a lock entry's `resolvedFrom` carries. -_RESOLVED_FROM_TRANSPORT_KEYS = ("path", "npm", "python") +_RESOLVED_FROM_TRANSPORT_KEYS = ("path", "npm", "python", "maven", "nuget") def _is_sorted(values: list[str]) -> bool: @@ -193,7 +193,7 @@ def _validate_resolved_from(obj: object, code: ErrorCode, context: str) -> dict[ transports = [k for k in _RESOLVED_FROM_TRANSPORT_KEYS if k in obj] if len(transports) != 1: raise ParseError( - f"{context}: 'resolvedFrom' must have exactly one of 'path' / 'npm' / 'python'", + f"{context}: 'resolvedFrom' must have exactly one of 'path' / 'npm' / 'python' / 'maven' / 'nuget'", code=code, ) transport = transports[0] @@ -205,8 +205,10 @@ def _validate_resolved_from(obj: object, code: ErrorCode, context: str) -> dict[ ) allowed_keys = {transport} - if transport in ("npm", "python"): + if transport != "path": allowed_keys.add("dir") + if transport in ("maven", "nuget"): + allowed_keys.add("version") extra_keys = set(obj.keys()) - allowed_keys if extra_keys: raise ParseError( @@ -214,6 +216,15 @@ def _validate_resolved_from(obj: object, code: ErrorCode, context: str) -> dict[ ) result: dict[str, str] = {transport: transport_value} + if transport in ("maven", "nuget"): + version = obj.get("version") + if not isinstance(version, str) or not version.strip(): + raise ParseError( + f"{context}: 'resolvedFrom.version' is required beside '{transport}' " + "and must be a non-empty string", + code=code, + ) + result["version"] = version if "dir" in obj: dir_value = obj["dir"] if not isinstance(dir_value, str) or not dir_value.strip(): diff --git a/server/python/src/metaobjects/config/dependency_check.py b/server/python/src/metaobjects/config/dependency_check.py index 0ad42dbaf9..637d2f8842 100644 --- a/server/python/src/metaobjects/config/dependency_check.py +++ b/server/python/src/metaobjects/config/dependency_check.py @@ -10,8 +10,9 @@ nothing and needs no loader: ``sync`` (Node ``meta deps sync``, the only writer of the lock and the snapshot) is what repairs a drift this reports. -Only the ``path`` transport is resolved here. An ``npm`` / ``python`` dependency is resolved by -the Node toolchain (it needs the consumer's installed packages), so the Python check reports it +Only the ``path`` transport is resolved here. An ``npm`` / ``python`` / ``maven`` / ``nuget`` +dependency is resolved by the Node toolchain (it needs the consumer's installed packages or local +package caches), so the Python check reports it ``unresolved`` and names ``meta deps check``: a check that cannot check must not pass. """ from __future__ import annotations @@ -63,7 +64,7 @@ class _Unresolved(Exception): def _resolve_dir(config_dir: Path, spec: dict[str, str]) -> Path: if "path" not in spec: - transport = "npm" if "npm" in spec else "python" + transport = next(k for k in ("npm", "python", "maven", "nuget") if k in spec) raise _Unresolved( f'the "{transport}" transport is resolved by the Node toolchain; ' "run `meta deps check` to check this dependency" diff --git a/server/python/src/metaobjects/config/neutral_config.py b/server/python/src/metaobjects/config/neutral_config.py index cb0e043240..add024e002 100644 --- a/server/python/src/metaobjects/config/neutral_config.py +++ b/server/python/src/metaobjects/config/neutral_config.py @@ -21,7 +21,11 @@ _DEPENDENCY_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$") #: The one transport key a dependency spec may carry. -_DEPENDENCY_TRANSPORT_KEYS = ("path", "npm", "python") +_DEPENDENCY_TRANSPORT_KEYS = ("path", "npm", "python", "maven", "nuget") + +#: The packaged transports name a package in a local cache that holds many versions, so they +#: also carry the `version` to look up. +_VERSIONED_TRANSPORTS = ("maven", "nuget") @dataclass(frozen=True) @@ -32,8 +36,8 @@ class NeutralConfig: sources: list[dict[str, str]] #: Declared metadata dependencies (FR-023) — each dict already validated: - #: `name`, exactly one transport key (`path` / `npm` / `python`), optional - #: `dir` only beside `npm`/`python`. + #: `name`, exactly one transport key (`path` / `npm` / `python` / `maven` / + #: `nuget`), a `version` beside `maven`/`nuget`, optional `dir` beside any but `path`. dependencies: list[dict[str, str]] #: FR-023 §11.1 item 2 — the user's declared `scope.include` patterns, READ @@ -400,7 +404,7 @@ def fail(reason: str) -> ParseError: transports = [k for k in _DEPENDENCY_TRANSPORT_KEYS if k in dep] if len(transports) != 1: - raise fail("exactly one of 'path' / 'npm' / 'python' is required") + raise fail("exactly one of 'path' / 'npm' / 'python' / 'maven' / 'nuget' is required") transport = transports[0] transport_value = dep[transport] @@ -408,14 +412,22 @@ def fail(reason: str) -> ParseError: raise fail(f"'{transport}' must be a non-empty string") allowed_keys = {"name", transport} - if transport in ("npm", "python"): + if transport != "path": allowed_keys.add("dir") + if transport in _VERSIONED_TRANSPORTS: + allowed_keys.add("version") extra_keys = set(dep.keys()) - allowed_keys if extra_keys: raise fail(f"unknown key(s): {sorted(extra_keys)}") result: dict[str, str] = {"name": name, transport: transport_value} + if transport in _VERSIONED_TRANSPORTS: + version = dep.get("version") + if not isinstance(version, str) or not version.strip(): + raise fail(f"'version' is required beside '{transport}' and must be a non-empty string") + result["version"] = version + if "dir" in dep: dir_value = dep["dir"] if not isinstance(dir_value, str) or not dir_value.strip(): diff --git a/server/python/tests/codegen/test_cli_deps_check.py b/server/python/tests/codegen/test_cli_deps_check.py index b5c4aa8438..e7885bdb0a 100644 --- a/server/python/tests/codegen/test_cli_deps_check.py +++ b/server/python/tests/codegen/test_cli_deps_check.py @@ -112,8 +112,8 @@ def test_deps_check_with_nothing_declared_exits_zero( def test_deps_check_reports_a_non_path_transport_as_unresolved( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - """The npm / python transports resolve through the installed packages, which only the Node - toolchain reads: a check that cannot check must not pass.""" + """The npm / python / maven / nuget transports resolve through installed packages and local + package caches, which only the Node toolchain reads: a check that cannot check must not pass.""" (tmp_path / ".metaobjects").mkdir() (tmp_path / ".metaobjects" / "config.json").write_text( json.dumps({"schema_version": 1, "dependencies": [{"name": "acme-common", "npm": "@acme/common"}]}) @@ -123,6 +123,26 @@ def test_deps_check_reports_a_non_path_transport_as_unresolved( assert "unresolved" in err and "meta deps check" in err +@pytest.mark.parametrize( + "spec", + [ + {"name": "acme-common", "maven": "com.acme:common", "version": "1.0.0"}, + {"name": "acme-common", "nuget": "Acme.Common", "version": "1.0.0"}, + ], +) +def test_deps_check_reports_a_packaged_transport_as_unresolved( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], spec: dict +) -> None: + (tmp_path / ".metaobjects").mkdir() + (tmp_path / ".metaobjects" / "config.json").write_text( + json.dumps({"schema_version": 1, "dependencies": [spec]}) + ) + assert _run(tmp_path, ["deps", "check"], monkeypatch) == 1 + err = capsys.readouterr().err + kind = "maven" if "maven" in spec else "nuget" + assert f'the "{kind}" transport' in err and "meta deps check" in err + + def test_deps_check_fails_when_the_locked_snapshot_is_missing( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/server/python/tests/config/test_dependencies.py b/server/python/tests/config/test_dependencies.py index c1301b6202..e18d4fcd29 100644 --- a/server/python/tests/config/test_dependencies.py +++ b/server/python/tests/config/test_dependencies.py @@ -97,6 +97,36 @@ def test_validate_lock_rejects_two_transports() -> None: assert e.value.code == ErrorCode.ERR_DEPENDENCY_SNAPSHOT_STALE +@pytest.mark.parametrize( + "resolved_from", + [ + {"maven": "com.acme:model", "version": "1.0.0"}, + {"maven": "com.acme:model", "version": "1.0.0", "dir": "shared"}, + {"nuget": "Acme.Model", "version": "1.0.0"}, + ], +) +def test_validate_lock_accepts_packaged_transports(resolved_from: dict) -> None: + entry = _entry() + entry["resolvedFrom"] = resolved_from + validate_lock({"schema_version": 1, "dependencies": {"a": entry}}) + + +@pytest.mark.parametrize( + "resolved_from", + [ + {"maven": "com.acme:model"}, + {"nuget": "Acme.Model", "version": ""}, + {"npm": "y", "version": "1.0.0"}, + ], +) +def test_validate_lock_rejects_a_packaged_transport_without_its_version(resolved_from: dict) -> None: + entry = _entry() + entry["resolvedFrom"] = resolved_from + with pytest.raises(ParseError) as e: + validate_lock({"schema_version": 1, "dependencies": {"a": entry}}) + assert e.value.code == ErrorCode.ERR_DEPENDENCY_SNAPSHOT_STALE + + def test_validate_lock_rejects_a_mode_key() -> None: entry = _entry() entry["mode"] = "own" diff --git a/server/python/tests/config/test_neutral_config.py b/server/python/tests/config/test_neutral_config.py index a1aa8447bf..e1cbd4f24d 100644 --- a/server/python/tests/config/test_neutral_config.py +++ b/server/python/tests/config/test_neutral_config.py @@ -114,6 +114,21 @@ def test_dependencies_parse(tmp_path: Path) -> None: assert cfg.dependencies == [{"name": "acme-common", "path": "../lib"}] +@pytest.mark.parametrize( + "spec", + [ + {"name": "a", "maven": "com.acme:model", "version": "1.0.0"}, + {"name": "a", "maven": "com.acme:model", "version": "1.0.0", "dir": "shared"}, + {"name": "a", "nuget": "Acme.Model", "version": "1.0"}, + ], +) +def test_packaged_dependencies_parse(tmp_path: Path, spec: dict) -> None: + _write_config(tmp_path, {"schema_version": 1, "sources": [], "dependencies": [spec]}) + cfg = read_neutral_config(tmp_path) + assert cfg is not None + assert cfg.dependencies == [spec] + + def test_dependencies_absent_is_empty(tmp_path: Path) -> None: _write_config(tmp_path, {"schema_version": 1, "sources": []}) cfg = read_neutral_config(tmp_path) @@ -132,6 +147,12 @@ def test_dependencies_absent_is_empty(tmp_path: Path) -> None: [{"name": "a", "path": "x"}, {"name": "a", "npm": "y"}], [{"name": "a", "path": "x", "dir": "y"}], [{"name": "a", "path": "x", "pathh": "t"}], + [{"name": "a", "maven": "com.acme:model"}], + [{"name": "a", "nuget": "Acme.Model"}], + [{"name": "a", "maven": "com.acme:model", "version": ""}], + [{"name": "a", "maven": "com.acme:model", "nuget": "Acme.Model", "version": "1.0.0"}], + [{"name": "a", "npm": "y", "version": "1.0.0"}], + [{"name": "a", "nuget": "Acme.Model", "version": "1.0.0", "classifier": "x"}], ], ) def test_dependencies_shape_errors(tmp_path: Path, bad: object) -> None: diff --git a/server/typescript/packages/cli/src/index.ts b/server/typescript/packages/cli/src/index.ts index e501e8bdcf..12db733d39 100644 --- a/server/typescript/packages/cli/src/index.ts +++ b/server/typescript/packages/cli/src/index.ts @@ -5,6 +5,7 @@ import { resolveFormat, isValidFormat, VALID_FORMATS, type OutputFormat } from " import { resolveCollection } from "@metaobjectsdev/sdk"; import { GENERATOR_HELP } from "./commands/generator-help.js"; import { describeError } from "./lib/error-text.js"; +import { PACKAGED_MODEL_DIR } from "./lib/dependency-transports.js"; export { defineConfig } from "@metaobjectsdev/codegen-ts"; export type { MetaobjectsGenConfig } from "@metaobjectsdev/codegen-ts"; @@ -284,8 +285,8 @@ emit for your model. deps: `meta deps — sync a declared metadata dependency's committed snapshot USAGE: - meta deps sync [...] Resolve each declared dependency (path, npm or python - transport), validate its manifest + artifact, copy it into .metaobjects/deps//, + meta deps sync [...] Resolve each declared dependency (path, npm, python, maven + or nuget transport), validate its manifest + artifact, copy it into .metaobjects/deps//, and pin it in .metaobjects/deps.lock.json. Naming one or more s syncs only those; bare 'sync' syncs everything declared. A dependency removed from config is PRUNED from @@ -307,8 +308,14 @@ Transports: "path" is a directory relative to the config's directory; "npm" is a npm package, resolved from the config's directory the way Node resolves it; "python" is an installed Python package, found with importlib.util.find_spec by the project's interpreter ($METAOBJECTS_PYTHON, else $VIRTUAL_ENV, else ./.venv, else python3 — the interpreter used is -printed). "dir" names the shared-model directory inside an npm or python package (default: -the package root). Install the package with your package manager first; sync copies its +printed); "maven" is a "groupId:artifactId" jar read from the local Maven repository +($MAVEN_REPO_LOCAL, else -Dmaven.repo.local in $MAVEN_ARGS / $MAVEN_OPTS, else + in ~/.m2/settings.xml, else ~/.m2/repository); "nuget" is a package id +read from the NuGet global packages folder ($NUGET_PACKAGES, else ~/.nuget/packages). +"maven" and "nuget" REQUIRE "version" (illegal beside the other transports) and are never +downloaded: fetch first (mvn dependency:get, dotnet restore). "dir" names the shared-model +directory inside the package (default: the package root for npm and python, "${PACKAGED_MODEL_DIR}" +for maven and nuget). Install the package with your package manager first; sync copies its shared model into the committed snapshot, so nothing else needs it installed. The lock is the ONLY thing 'meta deps sync' writes to besides the snapshot directory — diff --git a/server/typescript/packages/cli/src/lib/dependency-sync.ts b/server/typescript/packages/cli/src/lib/dependency-sync.ts index e55102508e..ddc2acf556 100644 --- a/server/typescript/packages/cli/src/lib/dependency-sync.ts +++ b/server/typescript/packages/cli/src/lib/dependency-sync.ts @@ -39,7 +39,7 @@ import { type LockEntry, } from "@metaobjectsdev/sdk"; import { describeError } from "./error-text.js"; -import { resolveTransport } from "./dependency-transports.js"; +import { normalizeNuGetVersion, PACKAGED_MODEL_DIR, resolveTransport } from "./dependency-transports.js"; /** `dependency-sync.ts`'s own `ParseError` factory for every * `ERR_DEPENDENCY_MANIFEST_INVALID` case (DESIGN §4.1 step 2 and step 4): @@ -77,22 +77,34 @@ function sameSortedArray(a: readonly string[], b: readonly string[]): boolean { } /** - * DESIGN §4.1 step 1 — resolve a dependency spec's transport (`path`, `npm` or - * `python`) to the directory that should hold its `metaobjects.pkg.json`. The + * DESIGN §4.1 step 1 — resolve a dependency spec's transport (`path`, `npm`, + * `python`, `maven` or `nuget`) to the directory that should hold its `metaobjects.pkg.json`. The * transports live in `dependency-transports.ts`; a failure is * `ERR_DEPENDENCY_UNRESOLVED` naming what was tried and the fix. The manifest * read is a separate step (`readManifestDir`) that only runs once a directory * is in hand. */ -export function resolveDependencyDir(configDir: string, spec: DependencySpec): string { - return resolveTransport(configDir, spec).dir; +export function resolveDependencyDir( + configDir: string, + spec: DependencySpec, + env: NodeJS.ProcessEnv = process.env, +): string { + return resolveTransport(configDir, spec, env).dir; } /** The fix for an installed package that holds no manifest where `dir` points. */ function missingManifestFix(spec: DependencySpec | undefined): string { if (spec === undefined || "path" in spec) return ""; - const shipped = "npm" in spec ? `listed in package.json "files"` : "included as package data"; - const where = spec.dir === undefined ? "the package root" : `"${spec.dir}"`; + const packaged = "maven" in spec || "nuget" in spec; + const shipped = + "npm" in spec + ? `listed in package.json "files"` + : "maven" in spec + ? "packaged as a jar resource" + : "nuget" in spec + ? "packed into the package" + : "included as package data"; + const where = spec.dir === undefined ? (packaged ? `"${PACKAGED_MODEL_DIR}"` : "the package root") : `"${spec.dir}"`; return ( `. The package is installed, but ${where} holds no shared model: set "dir" in ` + `.metaobjects/config.json to the directory the publisher's sharedModelFile() output ships in, ` + @@ -251,6 +263,14 @@ export async function standaloneLoadCheck(manifest: DependencyManifest, artifact function resolvedFromOf(spec: DependencySpec): LockEntry["resolvedFrom"] { if ("path" in spec) return { path: spec.path }; if ("npm" in spec) return spec.dir === undefined ? { npm: spec.npm } : { npm: spec.npm, dir: spec.dir }; + if ("maven" in spec) { + const base = { maven: spec.maven, version: spec.version }; + return spec.dir === undefined ? base : { ...base, dir: spec.dir }; + } + if ("nuget" in spec) { + const base = { nuget: spec.nuget, version: spec.version }; + return spec.dir === undefined ? base : { ...base, dir: spec.dir }; + } return spec.dir === undefined ? { python: spec.python } : { python: spec.python, dir: spec.dir }; } @@ -313,11 +333,22 @@ export function packageVersionWarning( const same = "python" in spec ? pep440Equal(packageVersion, manifestVersion) - : packageVersion === manifestVersion; + : "nuget" in spec + ? normalizeNuGetVersion(packageVersion) === normalizeNuGetVersion(manifestVersion) + : packageVersion === manifestVersion; if (same) return undefined; - const pkg = "npm" in spec ? `npm package "${spec.npm}"` : "python" in spec ? `Python package "${spec.python}"` : "package"; + const pkg = + "npm" in spec + ? `npm package "${spec.npm}"` + : "python" in spec + ? `Python package "${spec.python}"` + : "maven" in spec + ? `Maven artifact ${spec.maven}` + : "nuget" in spec + ? `NuGet package ${spec.nuget}` + : "package"; return ( - `dependency "${name}": the installed ${pkg} is ${packageVersion}, but its shared-model manifest ` + + `dependency "${name}": the ${"maven" in spec || "nuget" in spec ? "declared" : "installed"} ${pkg} is ${packageVersion}, but its shared-model manifest ` + `records ${manifestVersion} — the publisher likely released without regenerating the artifact. ` + `The lock records ${manifestVersion}.` ); @@ -672,6 +703,7 @@ export async function checkDependencies( configDir: string, specs: readonly DependencySpec[], lock: Lock | undefined, + env: NodeJS.ProcessEnv = process.env, ): Promise { const entries = lock?.dependencies ?? {}; const sorted = [...specs].sort((a, b) => dependencyName(a).localeCompare(dependencyName(b))); @@ -682,7 +714,7 @@ export async function checkDependencies( const lockEntry = entries[name]; try { - const dir = resolveDependencyDir(configDir, spec); + const dir = resolveDependencyDir(configDir, spec, env); const { manifest, artifactContent } = await readManifestDir(dir, name, spec); // Never trust the manifest's own `integrity` field for the comparison — // re-hash the bytes actually sitting on disk right now. diff --git a/server/typescript/packages/cli/src/lib/dependency-transports.ts b/server/typescript/packages/cli/src/lib/dependency-transports.ts index a6230c3112..6a09fb9468 100644 --- a/server/typescript/packages/cli/src/lib/dependency-transports.ts +++ b/server/typescript/packages/cli/src/lib/dependency-transports.ts @@ -10,16 +10,27 @@ // from the consumer's config directory, as Node does), then `dir`. // python — the installed package's directory, as the consumer's own Python // interpreter's `importlib.util.find_spec` reports it, then `dir`. +// maven — a `groupId:artifactId` at `version` in the LOCAL Maven repository +// (`~/.m2/repository` layout, the location overridable): the jar's +// `dir` resource (default `metaobjects`), unpacked to a temp directory. +// nuget — a package id at `version` in the NuGet global packages folder +// (`$NUGET_PACKAGES` or `~/.nuget/packages`): its `dir` content +// (default `metaobjects`). +// +// `maven` and `nuget` never download. A package missing from the local cache is +// refused with the command that fetches it (`mvn dependency:get`, `dotnet restore`). // // A refusal names what was tried and the fix. All are `ERR_DEPENDENCY_UNRESOLVED` // (the package, the interpreter or the directory could not be found); a // directory that exists but holds no manifest is the manifest read's // `ERR_DEPENDENCY_MANIFEST_INVALID`, not this module's. import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; -import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path"; import { codeSource, ParseError } from "@metaobjectsdev/metadata"; import { dependencyName, type DependencySpec } from "@metaobjectsdev/sdk"; +import { readZipEntries } from "./zip-read.js"; /** Where a transport found the publisher's manifest directory. */ export interface ResolvedTransport { @@ -37,6 +48,16 @@ export interface ResolvedTransport { export const METAOBJECTS_PYTHON_ENV = "METAOBJECTS_PYTHON"; const VIRTUAL_ENV_ENV = "VIRTUAL_ENV"; +/** Where a Maven jar / NuGet package carries the shared model, unless `dir` says otherwise. */ +export const PACKAGED_MODEL_DIR = "metaobjects"; + +/** The env keys the maven and nuget transports read for an overridden cache. */ +export const MAVEN_REPO_LOCAL_ENV = "MAVEN_REPO_LOCAL"; +export const NUGET_PACKAGES_ENV = "NUGET_PACKAGES"; + +const MAVEN_COORDINATE = /^[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+$/; +const PACKAGE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/; +const NUGET_ID = /^[A-Za-z0-9_.-]+$/; const NPM_PACKAGE_NAME = /^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/; const PYTHON_MODULE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/; const PYTHON_TIMEOUT_MS = 60_000; @@ -266,6 +287,206 @@ function resolvePython( }; } +// --------------------------------------------------------------------------- +// maven / nuget — read a published package out of the machine's local cache. +// --------------------------------------------------------------------------- + +function homeDir(env: NodeJS.ProcessEnv): string { + const home = env.HOME ?? env.USERPROFILE; + return home !== undefined && home !== "" ? home : homedir(); +} + +/** The directories a packaged model is unpacked into; removed when the process exits. */ +const unpackedDirs: string[] = []; +let cleanupRegistered = false; + +function newUnpackDir(): string { + const dir = mkdtempSync(join(tmpdir(), "mo-dep-pkg-")); + unpackedDirs.push(dir); + if (!cleanupRegistered) { + cleanupRegistered = true; + process.once("exit", () => { + for (const d of unpackedDirs) rmSync(d, { recursive: true, force: true }); + }); + } + return dir; +} + +/** `dir` for a packaged model: the default `metaobjects`, normalized and held inside the package as + * {@link applyDir}'s `dir` is (`./shared` and `a/../shared` are `shared`). */ +function packagedDir(name: string, kind: string, dir: string | undefined): string { + const chosen = dir ?? PACKAGED_MODEL_DIR; + const normalized = posix.normalize(chosen.split("\\").join("/")).replace(/\/+$/, ""); + if ( + isAbsolute(chosen) || + posix.isAbsolute(normalized) || + normalized === "." || + normalized === ".." || + normalized.startsWith("../") + ) { + unresolved(name, `"dir": "${chosen}" must be a path inside the ${kind} package (relative, no "..")`); + } + return normalized; +} + +/** Writes every entry of `zip` under `

/` to a fresh temp directory (with `/` stripped); + * returns it, or `undefined` when the archive has nothing there. */ +function unpackZipDir(zip: string, dir: string): string | undefined { + const prefix = `${dir}/`; + const entries = readZipEntries(zip, prefix); + if (entries.length === 0) return undefined; + const target = newUnpackDir(); + for (const entry of entries) { + const dest = join(target, ...entry.name.slice(prefix.length).split("/")); + mkdirSync(dirname(dest), { recursive: true }); + writeFileSync(dest, entry.data); + } + return target; +} + +function readZipOrRefuse(name: string, what: string, zip: string, dir: string): string { + let unpacked: string | undefined; + try { + unpacked = unpackZipDir(zip, dir); + } catch (err) { + unresolved(name, `${what} (${zip}) could not be read: ${err instanceof Error ? err.message : String(err)}`); + } + if (unpacked === undefined) { + unresolved( + name, + `${what} (${zip}) has no "${dir}/" directory. Check "dir" in .metaobjects/config.json, or ask the ` + + `publisher to ship the shared-model output (metaobjects.pkg.json and the artifact) under "${dir}/" in the package.`, + ); + } + return unpacked; +} + +/** `-Dmaven.repo.local=` out of a `MAVEN_ARGS` / `MAVEN_OPTS` style string. */ +function repoLocalFromOptions(options: string | undefined): string | undefined { + if (options === undefined) return undefined; + const m = /(?:^|\s)-Dmaven\.repo\.local=(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(options); + return m?.[1] ?? m?.[2] ?? m?.[3]; +} + +/** `` out of a Maven `settings.xml` (comments ignored); `${user.home}` / `${env.X}` expanded. */ +function repoLocalFromSettings(file: string, home: string, env: NodeJS.ProcessEnv): string | undefined { + if (!existsSync(file)) return undefined; + const xml = readFileSync(file, "utf8").replace(//g, ""); + const raw = /\s*([^<]*?)\s*<\/localRepository>/.exec(xml)?.[1]; + if (raw === undefined || raw === "") return undefined; + return raw + .replace(/\$\{user\.home\}/g, home) + .replace(/\$\{env\.([^}]+)\}/g, (_, key: string) => env[key] ?? ""); +} + +/** The local Maven repository and how it was chosen — Maven's own ladder, with `$MAVEN_REPO_LOCAL` + * first: that, `-Dmaven.repo.local` in `$MAVEN_ARGS` / `$MAVEN_OPTS`, `` in + * `~/.m2/settings.xml`, then `~/.m2/repository`. */ +export function mavenLocalRepository(env: NodeJS.ProcessEnv): { readonly dir: string; readonly from: string } { + const home = homeDir(env); + const explicit = env[MAVEN_REPO_LOCAL_ENV]; + if (explicit !== undefined && explicit !== "") return { dir: resolve(explicit), from: `$${MAVEN_REPO_LOCAL_ENV}` }; + for (const key of ["MAVEN_ARGS", "MAVEN_OPTS"]) { + const fromOptions = repoLocalFromOptions(env[key]); + if (fromOptions !== undefined) return { dir: resolve(fromOptions), from: `-Dmaven.repo.local in $${key}` }; + } + const settings = join(home, ".m2", "settings.xml"); + const fromSettings = repoLocalFromSettings(settings, home, env); + if (fromSettings !== undefined) return { dir: resolve(join(home, ".m2"), fromSettings), from: settings }; + return { dir: join(home, ".m2", "repository"), from: "the default ~/.m2/repository" }; +} + +function resolveMaven( + name: string, + coordinate: string, + version: string, + dir: string | undefined, + env: NodeJS.ProcessEnv, +): ResolvedTransport { + if (!MAVEN_COORDINATE.test(coordinate)) { + unresolved( + name, + `"maven": "${coordinate}" is not a groupId:artifactId pair (for example com.acme:acme-model); ` + + `the version goes in "version", not in this value`, + ); + } + if (!PACKAGE_VERSION.test(version)) unresolved(name, `"version": "${version}" is not a Maven version`); + const inside = packagedDir(name, "Maven", dir); + const [groupId, artifactId] = coordinate.split(":") as [string, string]; + const repo = mavenLocalRepository(env); + const jar = join(repo.dir, ...groupId.split("."), artifactId, version, `${artifactId}-${version}.jar`); + if (!existsSync(jar)) { + unresolved( + name, + `the Maven artifact ${coordinate}:${version} is not in the local repository ${repo.dir} (${repo.from}); ` + + `expected ${jar}. Fetch it with \`mvn dependency:get -Dartifact=${coordinate}:${version}\` ` + + `(add -DremoteRepositories= for a repository other than Central), then re-run. ` + + `meta deps sync does not download.`, + ); + } + const unpacked = readZipOrRefuse(name, `the jar of ${coordinate}:${version}`, jar, inside); + return { dir: unpacked, packageVersion: version, note: `maven: ${jar}` }; +} + +/** NuGet's normalized version (lower case; four-part `1.0.0.0` → `1.0.0`; `1.0` → `1.0.0`; the + * `+build` metadata dropped) — the form the global packages folder names its directories by. */ +export function normalizeNuGetVersion(version: string): string { + const lower = version.trim().toLowerCase().replace(/\+.*$/, ""); + const dash = lower.indexOf("-"); + const core = dash < 0 ? lower : lower.slice(0, dash); + const pre = dash < 0 ? "" : lower.slice(dash); + const parts = core.split("."); + if (parts.length > 4 || !parts.every((p) => /^\d+$/.test(p))) return lower; + const numbers = parts.map((p) => String(Number(p))); + while (numbers.length < 3) numbers.push("0"); + if (numbers.length === 4 && numbers[3] === "0") numbers.pop(); + return numbers.join(".") + pre; +} + +/** The NuGet global packages folder and how it was chosen: `$NUGET_PACKAGES`, else `~/.nuget/packages`. */ +export function nugetPackagesFolder(env: NodeJS.ProcessEnv): { readonly dir: string; readonly from: string } { + const explicit = env[NUGET_PACKAGES_ENV]; + if (explicit !== undefined && explicit !== "") return { dir: resolve(explicit), from: `$${NUGET_PACKAGES_ENV}` }; + return { dir: join(homeDir(env), ".nuget", "packages"), from: "the default ~/.nuget/packages" }; +} + +function resolveNuget( + name: string, + id: string, + version: string, + dir: string | undefined, + env: NodeJS.ProcessEnv, +): ResolvedTransport { + if (!NUGET_ID.test(id)) unresolved(name, `"nuget": "${id}" is not a NuGet package id`); + if (!PACKAGE_VERSION.test(version)) unresolved(name, `"version": "${version}" is not a NuGet version`); + const inside = packagedDir(name, "NuGet", dir); + const cache = nugetPackagesFolder(env); + const normalized = normalizeNuGetVersion(version); + const folder = join(cache.dir, id.toLowerCase(), normalized); + if (!isDirectory(folder)) { + unresolved( + name, + `the NuGet package ${id} ${version} is not in the global packages folder ${cache.dir} (${cache.from}); ` + + `expected ${folder}. Fetch it with \`dotnet restore\` in a project that references ${id} ${version} ` + + `(or \`dotnet add package ${id} --version ${version}\`), then re-run. meta deps sync does not download.`, + ); + } + const extracted = join(folder, ...inside.split("/")); + if (isDirectory(extracted)) return { dir: extracted, packageVersion: version }; + // A cache that kept only the archive: read the shared model out of it. + const nupkg = join(folder, `${id.toLowerCase()}.${normalized}.nupkg`); + if (existsSync(nupkg)) { + const unpacked = readZipOrRefuse(name, `the package ${id} ${version}`, nupkg, inside); + return { dir: unpacked, packageVersion: version, note: `nuget: ${nupkg}` }; + } + unresolved( + name, + `the NuGet package ${id} ${version} is in the cache at ${folder}, but has no "${inside}" directory. ` + + `Check "dir" in .metaobjects/config.json, or ask the publisher to pack the shared-model output ` + + `(metaobjects.pkg.json and the artifact) under "${inside}/" in the package.`, + ); +} + /** * DESIGN §4.1 step 1 — resolve a dependency spec's transport to the directory * that should hold its `metaobjects.pkg.json`. Throws `ERR_DEPENDENCY_UNRESOLVED` @@ -285,5 +506,7 @@ export function resolveTransport( return { dir }; } if ("npm" in spec) return resolveNpm(configDir, name, spec.npm, spec.dir); + if ("maven" in spec) return resolveMaven(name, spec.maven, spec.version, spec.dir, env); + if ("nuget" in spec) return resolveNuget(name, spec.nuget, spec.version, spec.dir, env); return resolvePython(configDir, name, spec.python, spec.dir, env); } diff --git a/server/typescript/packages/cli/src/lib/zip-read.ts b/server/typescript/packages/cli/src/lib/zip-read.ts new file mode 100644 index 0000000000..038cfdc7c2 --- /dev/null +++ b/server/typescript/packages/cli/src/lib/zip-read.ts @@ -0,0 +1,78 @@ +// server/typescript/packages/cli/src/lib/zip-read.ts +// +// A minimal, read-only ZIP reader for the `maven` and `nuget` dependency +// transports: a JAR and a `.nupkg` are both plain ZIP archives, and `meta deps +// sync` only ever needs a handful of small files out of one. It reads the central +// directory, supports the two methods every archiver emits (stored and deflate), +// and refuses everything else (ZIP64, encryption, other compression) by name +// rather than guess. No dependency: `node:zlib` does the inflating. +import { readFileSync } from "node:fs"; +import { inflateRawSync } from "node:zlib"; + +const EOCD_SIGNATURE = 0x06054b50; +const CENTRAL_SIGNATURE = 0x02014b50; +const LOCAL_SIGNATURE = 0x04034b50; +const EOCD_MIN = 22; +const MAX_COMMENT = 0xffff; +const METHOD_STORED = 0; +const METHOD_DEFLATE = 8; +const FLAG_ENCRYPTED = 0x1; + +/** One file inside an archive: its name (forward slashes) and uncompressed bytes. */ +export interface ZipEntry { + readonly name: string; + readonly data: Buffer; +} + +/** The archive is not one this reader understands, or is damaged. */ +export class ZipError extends Error {} + +function findEndOfCentralDirectory(buf: Buffer): number { + const lowest = Math.max(0, buf.length - EOCD_MIN - MAX_COMMENT); + for (let i = buf.length - EOCD_MIN; i >= lowest; i--) { + if (buf.readUInt32LE(i) === EOCD_SIGNATURE) return i; + } + throw new ZipError("not a ZIP archive (no end-of-central-directory record)"); +} + +/** + * Every file entry whose name starts with `prefix` (use `""` for all), read from + * the archive at `path`. Directory entries are skipped. Entry names are returned + * as stored; a name that is absolute, climbs out with `..`, or holds a `\\` or `:` + * (a separator or drive on Windows) is refused, since a caller will write them + * under a directory of its own. + */ +export function readZipEntries(path: string, prefix = ""): ZipEntry[] { + const buf = readFileSync(path); + const eocd = findEndOfCentralDirectory(buf); + const total = buf.readUInt16LE(eocd + 10); + let offset = buf.readUInt32LE(eocd + 16); + if (total === 0xffff || offset === 0xffffffff) throw new ZipError("ZIP64 archives are not supported"); + + const out: ZipEntry[] = []; + for (let i = 0; i < total; i++) { + if (buf.readUInt32LE(offset) !== CENTRAL_SIGNATURE) throw new ZipError("corrupt central directory"); + const flags = buf.readUInt16LE(offset + 8); + const method = buf.readUInt16LE(offset + 10); + const compressedSize = buf.readUInt32LE(offset + 20); + const nameLength = buf.readUInt16LE(offset + 28); + const extraLength = buf.readUInt16LE(offset + 30); + const commentLength = buf.readUInt16LE(offset + 32); + const localOffset = buf.readUInt32LE(offset + 42); + const name = buf.toString("utf8", offset + 46, offset + 46 + nameLength); + offset += 46 + nameLength + extraLength + commentLength; + + if (name.endsWith("/") || !name.startsWith(prefix)) continue; + if (name.startsWith("/") || /[\\:]/.test(name) || name.split("/").includes("..")) { + throw new ZipError(`entry "${name}" would escape the extraction directory`); + } + if ((flags & FLAG_ENCRYPTED) !== 0) throw new ZipError(`entry "${name}" is encrypted`); + if (buf.readUInt32LE(localOffset) !== LOCAL_SIGNATURE) throw new ZipError(`corrupt local header for "${name}"`); + const dataStart = localOffset + 30 + buf.readUInt16LE(localOffset + 26) + buf.readUInt16LE(localOffset + 28); + const raw = buf.subarray(dataStart, dataStart + compressedSize); + if (method === METHOD_STORED) out.push({ name, data: Buffer.from(raw) }); + else if (method === METHOD_DEFLATE) out.push({ name, data: inflateRawSync(raw) }); + else throw new ZipError(`entry "${name}" uses compression method ${method}, which is not supported`); + } + return out; +} diff --git a/server/typescript/packages/cli/test/dependency-transport-conformance.test.ts b/server/typescript/packages/cli/test/dependency-transport-conformance.test.ts index 89d5ec9247..5db53d7105 100644 --- a/server/typescript/packages/cli/test/dependency-transport-conformance.test.ts +++ b/server/typescript/packages/cli/test/dependency-transport-conformance.test.ts @@ -1,7 +1,8 @@ // Runs fixtures/dependency-conformance/transport-cases.json (FR-023 DESIGN §2.1) // against `meta deps sync`'s transports: an `npm` package installed under a temp -// `node_modules`, a `python` package on a temp import path. No network, no -// package manager — each case lays out what an install would leave on disk. +// `node_modules`, a `python` package on a temp import path, a `maven` jar in a temp +// local repository, a `nuget` package in a temp global packages folder. No network, +// no package manager — each case lays out what an install would leave on disk. // // Node `meta deps sync` is the only writer of snapshots and the lock, so only // this runner executes the transport arm; every port then loads the snapshot @@ -13,12 +14,22 @@ import { tmpdir } from "node:os"; import { delimiter, dirname, join, relative, resolve } from "node:path"; import { DEFAULT_METAOBJECTS_DIR, LOCK_FILE, dependencyName, loadConfig } from "@metaobjectsdev/sdk"; import { applySync, planSync } from "../src/lib/dependency-sync.js"; -import { METAOBJECTS_PYTHON_ENV, resolveTransport } from "../src/lib/dependency-transports.js"; +import { + MAVEN_REPO_LOCAL_ENV, + METAOBJECTS_PYTHON_ENV, + NUGET_PACKAGES_ENV, + resolveTransport, +} from "../src/lib/dependency-transports.js"; +import { writeZip } from "./zip-write.js"; interface Case { readonly name: string; readonly tree: Record; readonly treeFiles?: Record; + /** Archives (a `.jar`, a `.nupkg`): path → entry name → inline `text` or a corpus-relative `file`. */ + readonly zipFiles?: Record>; + /** Environment variables that name a directory: variable → project-root-relative directory. */ + readonly env?: Record; readonly config: unknown; readonly resolveFrom?: string; readonly pythonPath?: readonly string[]; @@ -50,27 +61,43 @@ async function materialize(c: Case): Promise<{ root: string; configDir: string } await mkdir(dirname(abs), { recursive: true }); await copyFile(join(CORPUS_DIR, corpusRel), abs); } + for (const [rel, entries] of Object.entries(c.zipFiles ?? {})) { + const abs = join(root, rel); + await mkdir(dirname(abs), { recursive: true }); + const files = await Promise.all( + Object.entries(entries).map(async ([name, source]) => ({ + name, + data: source.file !== undefined ? await readFile(join(CORPUS_DIR, source.file)) : Buffer.from(source.text ?? ""), + })), + ); + await writeFile(abs, writeZip(files)); + } const configDir = resolve(root, c.resolveFrom ?? "."); await mkdir(join(configDir, DEFAULT_METAOBJECTS_DIR), { recursive: true }); await writeFile(join(configDir, DEFAULT_METAOBJECTS_DIR, "config.json"), JSON.stringify(c.config, null, 2)); return { root, configDir }; } -/** The case's import path stands in for an installed site-packages; the host's - * own virtualenv and interpreter override are cleared so the result does not - * depend on the machine running the corpus. */ +/** The case's import path stands in for an installed site-packages, and its `home` directory for + * the user's home; the host's own virtualenv, interpreter override and Maven / NuGet cache + * overrides are cleared so the result does not depend on the machine running the corpus. */ function caseEnv(root: string, c: Case): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env }; - delete env[METAOBJECTS_PYTHON_ENV]; - delete env.VIRTUAL_ENV; + for (const key of [METAOBJECTS_PYTHON_ENV, "VIRTUAL_ENV", MAVEN_REPO_LOCAL_ENV, NUGET_PACKAGES_ENV, "MAVEN_ARGS", "MAVEN_OPTS"]) { + delete env[key]; + } + env.HOME = join(root, "home"); + delete env.USERPROFILE; + for (const [key, rel] of Object.entries(c.env ?? {})) env[key] = join(root, rel); env.PYTHONPATH = (c.pythonPath ?? []).map((p) => join(root, p)).join(delimiter); return env; } describe("dependency conformance — the transport arm", () => { - test("the corpus carries npm and python transport cases (a silent zero-case run is a failed gate)", () => { - expect(cases.some((c) => JSON.stringify(c.config).includes('"npm"'))).toBe(true); - expect(cases.some((c) => JSON.stringify(c.config).includes('"python"'))).toBe(true); + test("the corpus carries every transport (a silent zero-case run is a failed gate)", () => { + for (const transport of ["npm", "python", "maven", "nuget"]) { + expect(cases.some((c) => JSON.stringify(c.config).includes(`"${transport}"`))).toBe(true); + } }); for (const c of cases) { diff --git a/server/typescript/packages/cli/test/deps-packaged-transports.test.ts b/server/typescript/packages/cli/test/deps-packaged-transports.test.ts new file mode 100644 index 0000000000..cba541cf38 --- /dev/null +++ b/server/typescript/packages/cli/test/deps-packaged-transports.test.ts @@ -0,0 +1,315 @@ +// FR-023 Phase 2 — the `maven` and `nuget` transports of `meta deps sync` / `check`. +// +// The conformance arm (`dependency-transport-conformance.test.ts`) pins what `sync` writes and how +// each refusal is coded. This file covers what the corpus cannot: `check` against a jar and a +// package whose publisher has moved (or that was never fetched), the message a missing package +// carries (the coordinates and the command that fetches it), the Maven local-repository ladder, +// the NuGet version normalization, and the ZIP reader's refusals. +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { METAMODEL_VERSION } from "@metaobjectsdev/metadata"; +import { sha256Integrity, type DependencySpec, type Lock } from "@metaobjectsdev/sdk"; +import { checkDependencies, packageVersionWarning, planSync } from "../src/lib/dependency-sync.js"; +import { + mavenLocalRepository, + normalizeNuGetVersion, + nugetPackagesFolder, + resolveTransport, +} from "../src/lib/dependency-transports.js"; +import { readZipEntries, ZipError } from "../src/lib/zip-read.js"; +import { writeZip } from "./zip-write.js"; + +const ARTIFACTS = resolve(import.meta.dirname, "../../../../../fixtures/dependency-conformance/artifacts"); +const V1 = readFileSync(join(ARTIFACTS, "acme-common-v1.json")); +const WIDENED = readFileSync(join(ARTIFACTS, "acme-common-v1-widened.json")); +const NODES = ["acme::common::Address", "acme::common::Audited", "acme::common::Customer"]; +const MANIFEST = "metaobjects.pkg.json"; +const ARTIFACT = "acme-common.metaobjects.json"; + +const roots: string[] = []; +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "mo-packaged-")); + roots.push(root); + return root; +} +afterAll(() => { + for (const r of roots) rmSync(r, { recursive: true, force: true }); +}); + +function manifestFor(artifact: Buffer, version = "1.0.0"): string { + return JSON.stringify({ + schema_version: 1, + name: "acme-common", + version, + metamodelVersion: METAMODEL_VERSION, + artifact: ARTIFACT, + integrity: sha256Integrity(artifact), + packages: ["acme::common"], + nodes: NODES, + }); +} + +/** What the S8 publisher leaves in `outputDir` — the manifest and the artifact — packaged as jar resources. */ +function jar(artifact: Buffer, version = "1.0.0"): Buffer { + return writeZip([ + { name: "META-INF/MANIFEST.MF", data: "Manifest-Version: 1.0\r\n" }, + { name: `metaobjects/${MANIFEST}`, data: manifestFor(artifact, version) }, + { name: `metaobjects/${ARTIFACT}`, data: artifact }, + ]); +} + +function put(path: string, data: Buffer | string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, data); +} + +function lockOf(spec: DependencySpec, artifact: Buffer): Lock { + const { name: _name, ...resolvedFrom } = spec; + return { + schema_version: 1, + dependencies: { + "acme-common": { + version: "1.0.0", + metamodelVersion: METAMODEL_VERSION, + artifact: ARTIFACT, + integrity: sha256Integrity(artifact), + packages: ["acme::common"], + nodes: NODES, + resolvedFrom, + }, + }, + } as Lock; +} + +const MAVEN: DependencySpec = { name: "acme-common", maven: "com.acme:acme-common-model", version: "1.0.0" }; +const NUGET: DependencySpec = { name: "acme-common", nuget: "Acme.Common.Model", version: "1.0.0" }; + +function mavenEnv(root: string): NodeJS.ProcessEnv { + return { HOME: join(root, "home"), MAVEN_REPO_LOCAL: join(root, "repo") }; +} +const jarPath = (root: string): string => + join(root, "repo", "com", "acme", "acme-common-model", "1.0.0", "acme-common-model-1.0.0.jar"); + +function nugetEnv(root: string): NodeJS.ProcessEnv { + return { HOME: join(root, "home"), NUGET_PACKAGES: join(root, "pkgs") }; +} +const nugetDir = (root: string): string => join(root, "pkgs", "acme.common.model", "1.0.0"); + +describe("maven transport — check", () => { + test("a jar whose model still hashes to the lock is current", async () => { + const root = tempRoot(); + put(jarPath(root), jar(V1)); + const [r] = await checkDependencies(root, [MAVEN], lockOf(MAVEN, V1), mavenEnv(root)); + expect(r?.status).toBe("current"); + }); + + test("a republished jar whose model changed is drifted", async () => { + const root = tempRoot(); + put(jarPath(root), jar(WIDENED)); + const [r] = await checkDependencies(root, [MAVEN], lockOf(MAVEN, V1), mavenEnv(root)); + expect(r?.status).toBe("drifted"); + expect(r?.installedIntegrity).toBe(sha256Integrity(WIDENED)); + }); + + test("a jar that is not in the local repository is unresolved and names the coordinates and the fetch command", async () => { + const root = tempRoot(); + const [r] = await checkDependencies(root, [MAVEN], lockOf(MAVEN, V1), mavenEnv(root)); + expect(r?.status).toBe("unresolved"); + expect(r?.detail).toContain("com.acme:acme-common-model:1.0.0"); + expect(r?.detail).toContain("mvn dependency:get -Dartifact=com.acme:acme-common-model:1.0.0"); + expect(r?.detail).toContain("does not download"); + }); + + test("sync refuses with ERR_DEPENDENCY_UNRESOLVED for the same missing jar and writes nothing", async () => { + const root = tempRoot(); + await expect(planSync(root, [MAVEN], undefined, [], mavenEnv(root))).rejects.toMatchObject({ + code: "ERR_DEPENDENCY_UNRESOLVED", + }); + }); + + test("a different version in the declaration is looked up as its own jar", async () => { + const root = tempRoot(); + put(jarPath(root), jar(V1)); + const bumped: DependencySpec = { ...MAVEN, version: "2.0.0" } as DependencySpec; + const [r] = await checkDependencies(root, [bumped], lockOf(MAVEN, V1), mavenEnv(root)); + expect(r?.status).toBe("unresolved"); + expect(r?.detail).toContain("com.acme:acme-common-model:2.0.0"); + }); + + test("a jar whose manifest records another version than the declaration warns", async () => { + const root = tempRoot(); + put(jarPath(root), jar(V1, "0.9.0")); + const plan = await planSync(root, [MAVEN], undefined, [], mavenEnv(root)); + expect(plan.warnings.join("\n")).toContain("declared Maven artifact com.acme:acme-common-model is 1.0.0"); + expect(plan.warnings.join("\n")).toContain("records 0.9.0"); + }); +}); + +describe("maven transport — the local repository", () => { + test("$MAVEN_REPO_LOCAL wins, then -Dmaven.repo.local in MAVEN_ARGS and MAVEN_OPTS, then settings.xml, then ~/.m2/repository", () => { + const home = tempRoot(); + expect(mavenLocalRepository({ HOME: home }).dir).toBe(join(home, ".m2", "repository")); + + put(join(home, ".m2", "settings.xml"), "${user.home}/elsewhere"); + expect(mavenLocalRepository({ HOME: home }).dir).toBe(join(home, "elsewhere")); + + expect(mavenLocalRepository({ HOME: home, MAVEN_OPTS: "-Xmx1g -Dmaven.repo.local=/opt/opts" }).dir).toBe("/opt/opts"); + expect( + mavenLocalRepository({ HOME: home, MAVEN_OPTS: "-Dmaven.repo.local=/opt/opts", MAVEN_ARGS: '-B -Dmaven.repo.local="/opt/args"' }).dir, + ).toBe("/opt/args"); + expect( + mavenLocalRepository({ HOME: home, MAVEN_ARGS: "-Dmaven.repo.local=/opt/args", MAVEN_REPO_LOCAL: "/opt/env" }), + ).toEqual({ dir: "/opt/env", from: "$MAVEN_REPO_LOCAL" }); + }); + + test("a settings.xml localRepository that is commented out is ignored", () => { + const home = tempRoot(); + put(join(home, ".m2", "settings.xml"), ""); + expect(mavenLocalRepository({ HOME: home }).dir).toBe(join(home, ".m2", "repository")); + }); + + test("a group id with dots is a directory per segment", () => { + const root = tempRoot(); + put(jarPath(root), jar(V1)); + expect(resolveTransport(root, MAVEN, mavenEnv(root)).packageVersion).toBe("1.0.0"); + }); + + test("a dir written as ./shared or a/../shared names the jar's shared/ folder; one that climbs out is refused", () => { + const root = tempRoot(); + put( + jarPath(root), + writeZip([ + { name: `shared/${MANIFEST}`, data: manifestFor(V1) }, + { name: `shared/${ARTIFACT}`, data: V1 }, + ]), + ); + for (const dir of ["./shared", "a/../shared", "shared/"]) { + const resolved = resolveTransport(root, { ...MAVEN, dir } as DependencySpec, mavenEnv(root)); + expect(readFileSync(join(resolved.dir, ARTIFACT))).toEqual(V1); + } + for (const dir of ["shared/../..", "../shared", ".", "/shared"]) { + expect(() => resolveTransport(root, { ...MAVEN, dir } as DependencySpec, mavenEnv(root))).toThrow("inside the Maven package"); + } + }); + + test("a coordinate that carries a version, or a version with a path separator, is refused", () => { + const root = tempRoot(); + const env = mavenEnv(root); + expect(() => resolveTransport(root, { ...MAVEN, maven: "com.acme:acme-common-model:1.0.0" } as DependencySpec, env)).toThrow( + "groupId:artifactId", + ); + expect(() => resolveTransport(root, { ...MAVEN, version: "../1.0.0" } as DependencySpec, env)).toThrow("not a Maven version"); + }); +}); + +describe("nuget transport — check", () => { + function extractedPackage(root: string, artifact: Buffer, version = "1.0.0"): void { + put(join(nugetDir(root), "metaobjects", MANIFEST), manifestFor(artifact, version)); + put(join(nugetDir(root), "metaobjects", ARTIFACT), artifact); + } + + test("a package whose model still hashes to the lock is current", async () => { + const root = tempRoot(); + extractedPackage(root, V1); + const [r] = await checkDependencies(root, [NUGET], lockOf(NUGET, V1), nugetEnv(root)); + expect(r?.status).toBe("current"); + }); + + test("a republished package whose model changed is drifted", async () => { + const root = tempRoot(); + extractedPackage(root, WIDENED); + const [r] = await checkDependencies(root, [NUGET], lockOf(NUGET, V1), nugetEnv(root)); + expect(r?.status).toBe("drifted"); + }); + + test("a package that is not in the cache is unresolved and names the id, version and fetch command", async () => { + const root = tempRoot(); + const [r] = await checkDependencies(root, [NUGET], lockOf(NUGET, V1), nugetEnv(root)); + expect(r?.status).toBe("unresolved"); + expect(r?.detail).toContain("Acme.Common.Model 1.0.0"); + expect(r?.detail).toContain("dotnet restore"); + expect(r?.detail).toContain("does not download"); + }); + + test("a package the cache holds only as a .nupkg is read from the archive", async () => { + const root = tempRoot(); + put(join(nugetDir(root), "acme.common.model.1.0.0.nupkg"), jar(V1)); + const [r] = await checkDependencies(root, [NUGET], lockOf(NUGET, V1), nugetEnv(root)); + expect(r?.status).toBe("current"); + }); + + test("a version differing only by trailing zeros or case matches the manifest's", () => { + expect(packageVersionWarning("acme-common", NUGET, "1.0", "1.0.0")).toBeUndefined(); + expect(packageVersionWarning("acme-common", NUGET, "1.0.0-RC.1", "1.0.0-rc.1")).toBeUndefined(); + expect(packageVersionWarning("acme-common", NUGET, "1.0.1", "1.0.0")).toContain("NuGet package Acme.Common.Model"); + }); + + test("normalizeNuGetVersion follows NuGet's folder naming", () => { + expect(normalizeNuGetVersion("1.0")).toBe("1.0.0"); + expect(normalizeNuGetVersion("1.0.0.0")).toBe("1.0.0"); + expect(normalizeNuGetVersion("1.0.0.1")).toBe("1.0.0.1"); + expect(normalizeNuGetVersion("01.2.3")).toBe("1.2.3"); + expect(normalizeNuGetVersion("1.0.0-Beta.2+build5")).toBe("1.0.0-beta.2"); + }); + + test("$NUGET_PACKAGES wins over the default folder under HOME", () => { + expect(nugetPackagesFolder({ HOME: "/h", NUGET_PACKAGES: "/n" }).dir).toBe("/n"); + expect(nugetPackagesFolder({ HOME: "/h" }).dir).toBe(join("/h", ".nuget", "packages")); + }); +}); + +describe("zip reader", () => { + test("reads stored and deflated entries under a prefix and skips directories", () => { + const root = tempRoot(); + const path = join(root, "a.zip"); + put( + path, + writeZip([ + { name: "metaobjects/", data: "" }, + { name: "metaobjects/a.json", data: '{"a":1}' }, + { name: "metaobjects/b.bin", data: Buffer.from([1, 2, 3]), stored: true }, + { name: "other/c.json", data: "{}" }, + ]), + ); + const entries = readZipEntries(path, "metaobjects/"); + expect(entries.map((e) => e.name)).toEqual(["metaobjects/a.json", "metaobjects/b.bin"]); + expect(entries[0]?.data.toString()).toBe('{"a":1}'); + expect([...(entries[1]?.data ?? [])]).toEqual([1, 2, 3]); + }); + + test("refuses a file that is not a ZIP and an entry that climbs out of the extraction directory", () => { + const root = tempRoot(); + put(join(root, "no.zip"), "plain text, long enough to scan for a record but holding none"); + expect(() => readZipEntries(join(root, "no.zip"))).toThrow(ZipError); + put(join(root, "slip.zip"), writeZip([{ name: "metaobjects/../../escape.json", data: "{}" }])); + expect(() => readZipEntries(join(root, "slip.zip"), "metaobjects/")).toThrow("escape"); + }); + + test("refuses an entry whose name holds a backslash or a drive colon, which Windows reads as a path", () => { + const root = tempRoot(); + put(join(root, "back.zip"), writeZip([{ name: "metaobjects/..\\..\\escape.json", data: "{}" }])); + expect(() => readZipEntries(join(root, "back.zip"), "metaobjects/")).toThrow("escape"); + put(join(root, "drive.zip"), writeZip([{ name: "metaobjects/C:/escape.json", data: "{}" }])); + expect(() => readZipEntries(join(root, "drive.zip"), "metaobjects/")).toThrow("escape"); + }); + + // Oracle independent of zip-write.ts: test/fixtures/real-archives/model.jar was built by the JDK 21 + // `jar` tool (`jar --create --file model.jar metaobjects other`). Every file entry carries the + // data-descriptor flag (0x08) with zeroed sizes in its local header, and the first local header holds + // the 0xCAFE extra field, so a reader that trusted local-header sizes would read empty entries. + test("reads a jar built by the JDK jar tool (data descriptors, zeroed local sizes, 0xCAFE extra)", () => { + const entries = readZipEntries(join(import.meta.dirname, "fixtures", "real-archives", "model.jar"), "metaobjects/"); + expect(entries.map((e) => [e.name, e.data.toString()])).toEqual([ + ["metaobjects/metaobjects.pkg.json", '{"name":"acme-common","note":"built by the JDK jar tool"}\n'], + ["metaobjects/model.json", '{"metadata.root":{"package":"acme::common"}}\n'], + ]); + }); + + test("a damaged jar is unresolved, naming the jar", () => { + const root = tempRoot(); + put(jarPath(root), "not a jar"); + expect(() => resolveTransport(root, MAVEN, mavenEnv(root))).toThrow("could not be read"); + }); +}); diff --git a/server/typescript/packages/cli/test/fixtures/real-archives/model.jar b/server/typescript/packages/cli/test/fixtures/real-archives/model.jar new file mode 100644 index 0000000000..38cdff6c02 Binary files /dev/null and b/server/typescript/packages/cli/test/fixtures/real-archives/model.jar differ diff --git a/server/typescript/packages/cli/test/zip-write.ts b/server/typescript/packages/cli/test/zip-write.ts new file mode 100644 index 0000000000..955291a426 --- /dev/null +++ b/server/typescript/packages/cli/test/zip-write.ts @@ -0,0 +1,74 @@ +// Test-only ZIP writer: lays out the archive a Maven build (`.jar`) or `dotnet pack` (`.nupkg`) +// would produce, so the maven / nuget transport tests read a real archive with no JDK or .NET SDK. +// Entries are deflated, except `.class` files, which are stored — both methods the reader supports. +import { deflateRawSync } from "node:zlib"; + +export interface ZipFileInput { + readonly name: string; + readonly data: Buffer | string; + /** Store without compression (default: deflate). */ + readonly stored?: boolean; +} + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buf: Buffer): number { + let c = 0xffffffff; + for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 0xff]! ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +export function writeZip(files: readonly ZipFileInput[]): Buffer { + const locals: Buffer[] = []; + const centrals: Buffer[] = []; + let offset = 0; + for (const file of files) { + const data = typeof file.data === "string" ? Buffer.from(file.data) : file.data; + const stored = file.stored === true || file.name.endsWith(".class"); + const body = stored ? data : deflateRawSync(data); + const name = Buffer.from(file.name); + const crc = crc32(data); + const method = stored ? 0 : 8; + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0800, 6); + local.writeUInt16LE(method, 8); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(body.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(name.length, 26); + locals.push(local, name, body); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(method, 10); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(body.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt32LE(offset, 42); + centrals.push(central, name); + offset += local.length + name.length + body.length; + } + const centralSize = centrals.reduce((n, b) => n + b.length, 0); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(centralSize, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, ...centrals, end]); +} diff --git a/server/typescript/packages/sdk/src/dependencies.ts b/server/typescript/packages/sdk/src/dependencies.ts index a559e0633e..86a8097685 100644 --- a/server/typescript/packages/sdk/src/dependencies.ts +++ b/server/typescript/packages/sdk/src/dependencies.ts @@ -39,9 +39,13 @@ export const INTEGRITY_PREFIX = "sha256-"; /** * A declared dependency: a `name` and exactly one transport (`path` | `npm` | - * `python`). `npm`/`python` optionally carry `dir` — the subdirectory under - * the resolved package holding `metaobjects.pkg.json`; `path` never does, - * since the path itself already names that directory. + * `python` | `maven` | `nuget`). Every transport but `path` optionally carries + * `dir` — the subdirectory under the resolved package holding + * `metaobjects.pkg.json`; `path` never does, since the path itself already + * names that directory. `maven` (`groupId:artifactId`) and `nuget` (the package + * id) also carry the `version` to look up: unlike an npm or Python install, the + * local Maven repository and the NuGet cache hold many versions side by side, so + * the declaration is what picks one. * * Mirrors the hand-written union below in `DependencySpecSchema` — the same * two-direction parity guard `SourceSpec`/`SourceSpecSchema` carries (see @@ -51,6 +55,8 @@ export type DependencySpec = { readonly name: string } & ( | { readonly path: string } | { readonly npm: string; readonly dir?: string | undefined } | { readonly python: string; readonly dir?: string | undefined } + | { readonly maven: string; readonly version: string; readonly dir?: string | undefined } + | { readonly nuget: string; readonly version: string; readonly dir?: string | undefined } ); /** `/^[a-z0-9][a-z0-9._-]*$/` — lowercase, digits, `.`/`_`/`-`, starting with @@ -65,8 +71,8 @@ const DependencyName = z.string().regex(/^[a-z0-9][a-z0-9._-]*$/); * `config.ts`: a config schema that silently strips an unknown key would let * `{ name: "a", path: "x", pathh: "typo" }` parse clean instead of erroring * on the typo. A `z.union` (rather than a discriminated union) because the - * three arms share no single literal discriminator key — the transport key - * itself (`path`/`npm`/`python`) is what distinguishes them, and that is + * five arms share no single literal discriminator key — the transport key + * itself (`path`/`npm`/`python`/`maven`/`nuget`) is what distinguishes them, and that is * exactly what the "two transports in one spec" / "no transport" refusals * below exercise: neither shape matches any arm, so the union fails closed. * `mode` is REMOVED (FR-023 §11.3 — superseded by explicit `scope.include`). @@ -75,9 +81,15 @@ export const DependencySpecSchema = z.union([ z.object({ name: DependencyName, path: z.string().min(1) }).strict(), z.object({ name: DependencyName, npm: z.string().min(1), dir: z.string().min(1).optional() }).strict(), z.object({ name: DependencyName, python: z.string().min(1), dir: z.string().min(1).optional() }).strict(), + z + .object({ name: DependencyName, maven: z.string().min(1), version: z.string().min(1), dir: z.string().min(1).optional() }) + .strict(), + z + .object({ name: DependencyName, nuget: z.string().min(1), version: z.string().min(1), dir: z.string().min(1).optional() }) + .strict(), ]); -/** The declared name of a dependency spec — `name` is common to all three +/** The declared name of a dependency spec — `name` is common to all five * transport arms, so this needs no narrowing. Exists so callers never * inline `.name` and so later tasks (manifest/lock keying) have one place * this projection is defined. */ @@ -162,13 +174,15 @@ export type DependencyManifest = z.infer; * The one transport a lock entry's `resolvedFrom` carries — the declared * spec's transport, verbatim, minus `name` (a lock entry is already keyed by * name under `LockSchema.dependencies`). Mirrors `DependencySpecSchema`'s - * three arms exactly, `name` aside, including the `.strict()` on every arm: + * five arms exactly, `name` aside, including the `.strict()` on every arm: * `{ path: "x", npm: "y" }` must match no arm, never silently pick one. */ const ResolvedFromSchema = z.union([ z.object({ path: z.string().min(1) }).strict(), z.object({ npm: z.string().min(1), dir: z.string().min(1).optional() }).strict(), z.object({ python: z.string().min(1), dir: z.string().min(1).optional() }).strict(), + z.object({ maven: z.string().min(1), version: z.string().min(1), dir: z.string().min(1).optional() }).strict(), + z.object({ nuget: z.string().min(1), version: z.string().min(1), dir: z.string().min(1).optional() }).strict(), ]); /** diff --git a/server/typescript/packages/sdk/test/no-hardcoded-metadata-dir.test.ts b/server/typescript/packages/sdk/test/no-hardcoded-metadata-dir.test.ts index afe7f857af..3a5666aae9 100644 --- a/server/typescript/packages/sdk/test/no-hardcoded-metadata-dir.test.ts +++ b/server/typescript/packages/sdk/test/no-hardcoded-metadata-dir.test.ts @@ -92,6 +92,10 @@ const ALLOWED: ReadonlyMap = new Map([ "cli/src/commands/init.ts", "the scaffolder WRITING the default layout — creating that directory, never assuming one exists", ], + [ + "cli/src/lib/dependency-transports.ts", + "PACKAGED_MODEL_DIR — the resource folder INSIDE a Maven jar / NuGet package where a published model sits (the maven and nuget transports' default `dir`). It is a packaging convention shared with the publishers, not this project's metadata collection, which stays resolveCollection's to name", + ], ]); // --------------------------------------------------------------------------- diff --git a/spec/roadmap.md b/spec/roadmap.md index 1a5276800b..7e28f6c0e5 100644 --- a/spec/roadmap.md +++ b/spec/roadmap.md @@ -142,7 +142,7 @@ under **Shipped**; planned FRs under **Planned** + the **Release plan**. ✅ shi | FR-020 | `@inheritance` joined strategy | 📋 | 1.x | [#6](https://github.com/metaobjectsdev/metaobjects/issues/6) | | FR-021 | api metadata + contract projections | 📋 | 1.3 | [#7](https://github.com/metaobjectsdev/metaobjects/issues/7) | | FR-022 | Contract emitters (JSON Schema/OpenAPI/protobuf) | 🔵 JSON Schema and OpenAPI 1.2.1 · protobuf 1.3 | 1.2.1 | [#8](https://github.com/metaobjectsdev/metaobjects/issues/8) | -| FR-023 | Metadata dependencies (cross-repo sharing: generated shared-model artifact, `meta deps sync`, committed snapshot + lock, default exclusion of imported metadata, consumer extend/overlay across the boundary) | 🟢 Phase 1a (TS + Python) **shipped** per DESIGN §11, Java/Kotlin consume (Phase 2 S3) — the usage-aware breaking-change classifier and the `npm`/`python` transports are deferred (§11.4); C# consumer shipped in Phase 2 S6 | 1.1 | [#9](https://github.com/metaobjectsdev/metaobjects/issues/9) | +| FR-023 | Metadata dependencies (cross-repo sharing: generated shared-model artifact, `meta deps sync`, committed snapshot + lock, default exclusion of imported metadata, consumer extend/overlay across the boundary) | 🟢 Phase 1a (TS + Python) **shipped** per DESIGN §11, Java/Kotlin consume (Phase 2 S3) — the usage-aware breaking-change classifier and the packaged transports (`npm`/`python`/`maven`/`nuget`) in the Python, Java and C# check are deferred (§11.4); C# consumer shipped in Phase 2 S6 | 1.1 | [#9](https://github.com/metaobjectsdev/metaobjects/issues/9) | | FR-024 | Entity surfaces (projection/value + declared API) | 🟢 taxonomy + B3/B4a/B4b/B5/B6 validation parity + projection codegen **shipped 5 ports (1.0)**; declared-API surface (`api.operational`/`operation`/`binding.rest`) **deferred to a later 1.x** (not in 1.1.0; foundation for FR-034) | 1.0 · 1.x (later) | [#10](https://github.com/metaobjectsdev/metaobjects/issues/10) | | FR-025 | Cross-port package-binding codegen config | 📋 | 1.1 | [#11](https://github.com/metaobjectsdev/metaobjects/issues/11) | | FR-026 | Forms completeness (edit forms + view parity) | 🟢 edit forms + view-kind control dispatch **shipped**; renderer parity / validator wiring / `layout.form` **deferred to a later 1.x** (not in 1.1.0) | 1.x (later) | [#12](https://github.com/metaobjectsdev/metaobjects/issues/12) | @@ -697,8 +697,8 @@ the breaking half at `1.1`. #### 1.x / later - **FR-023 — metadata dependencies, remaining phases** (Phase 1a — TS + Python — - shipped, Java/Kotlin consume (Phase 2 S3); Phase 1b bindings/docs-badge, and the rest of Phase 2 — C# + the - `npm`/`python`/`maven`/`nuget` transports — remain). + shipped, Java/Kotlin consume (Phase 2 S3); Phase 1b bindings/docs-badge, and the rest of Phase 2 — per-port sync + remains; the `maven`/`nuget` transports in Node `meta deps` are unreleased). - **FR-020 — `@inheritance` joined strategy** (additive; pairs with a persistence release). - **MCP exposure** (if not pulled earlier — see below). - **Database-source metadata loader** (currently Future).