diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b79c7ad0..5cfed6ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,19 @@ loading. A port that reports `1.2` does not know that vocabulary; upgrade every `Dependencies.check`, `Dependencies.SnapshotReader`, `DependencySet.ofSnapshot` and a `MetaDataLoader.fromUris` overload that takes a `DependencySet`. See `docs/features/metadata-dependencies.md`. +- **Python and C# check a shared model against its publisher (FR-023 Phase 2).** `metaobjects deps + check`, `metaobjects verify --deps` (Python) and `dotnet meta verify --deps` (C#) re-resolve each + declared dependency's `path`, read the publisher's `metaobjects.pkg.json`, hash the artifact and + compare with `deps.lock.json`, with the same three states as `meta deps check` (`current`, + `drifted`, `unresolved`) and the same `ERR_DEPENDENCY_UPSTREAM_DRIFT` exit for anything else. + Both run the 11 `expectCheck` cases of `fixtures/dependency-conformance/`. The check writes + nothing, is never part of a bare `verify`, and resolves the `path` transport only: an `npm` or + `python` dependency reports `unresolved` and names the Node `meta deps check`. In C# it runs + before the metadata loads, so a dependency declared but not yet synced is reported `unresolved` + first (the check exits 1); the load that follows still refuses the missing or stale snapshot with + `ERR_DEPENDENCY_SNAPSHOT_STALE` and exit 2. The Node `meta deps sync` stays the only writer of the lock and the + snapshot. See [docs/features/metadata-dependencies.md](docs/features/metadata-dependencies.md). + - **A model explorer site, built by `docsSiteFile()`.** The docs site now shows what the software does and how the model is designed: a main page with count tiles and the feature and quality cards with their roll-ups, a model map with slices (package, requirement subtree, diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index d9dbba57c..87ac9847c 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; no check, so the 11 check cases are not run) | ✓ (resolution arm, all 26 identically; no check yet, so the 11 check cases are not run; the transport arm is Node-only) | +| [`fixtures/dependency-conformance/`](../fixtures/dependency-conformance/) | 37 cases (26 resolution + 11 check) + 14 transport cases | ✓ (reference implementation; all three arms) | ✓ (resolution arm, all 26; check arm, all 11) | inherits via Java | ✓ (resolution arm, all 26; 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/shared-model-conformance/`](../fixtures/shared-model-conformance/) | 11 cases (7 publish + 4 refusal) | ✓ (reference implementation) | — (Phase 2) | — (Phase 2) | — (Phase 2) | ✓ (serializer arm: the 7 publish cases' document bytes; no publisher) | | [`fixtures/agent-context-conformance/`](../fixtures/agent-context-conformance/) | 4 | ✓ (the emitter is TS-owned) | — | — | — | — | | [`fixtures/metamodel-docs/`](../fixtures/metamodel-docs/) | 1 | ✓ (docs emit is TS-owned) | — | — | — | — | @@ -480,10 +480,12 @@ above: one committed `cases.json`, no per-port fixture, no ledger. The corpus ha for it. Kotlin inherits the Java run through the same loader and Maven plugin. - **Check (11 cases, `expectCheck`).** For each declared dependency, `current`, `drifted` or `unresolved` (a missing path, a bad manifest, a hash mismatch, or not yet in the lock). Run - by TypeScript, in `cli/test/dependency-check-conformance.test.ts`, and by Java in + by TypeScript, in `cli/test/dependency-check-conformance.test.ts`, by Java in `DependencyCheckConformanceTest` (the engine behind `metaobjects:verify -Dmeta.verify.mode=deps`; - Kotlin runs the same goal). Python has no `deps check` yet and filters these cases out. The arm - exists so that each port's check is written against the same edge cases (Phase 2, #482). + Kotlin runs the same goal), by **Python**, in `tests/conformance/test_dependency_conformance.py` + (`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 @@ -494,8 +496,8 @@ above: one committed `cases.json`, no per-port fixture, no ledger. The corpus ha **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 `dotnet meta gen ` takes, where the case has one. Its runner is -`DependencyConformanceTests`; it skips the 11 check cases (C# has no `deps check` yet) and the -transport cases (C# never syncs). +`DependencyConformanceTests`; it runs the 11 check cases through `DependencyResolver.CheckDependencies` +(S7) and skips the transport cases (C# never syncs). ### `fixtures/shared-model-conformance/` (11 cases) diff --git a/docs/features/cli.md b/docs/features/cli.md index b4484b963..608fe007f 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -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`; Java and Kotlin have the `path`-only counterpart `-Dmeta.verify.mode=deps`.** See [`metadata-dependencies.md`](metadata-dependencies.md). | no | +| `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 | Rules of the contract: diff --git a/docs/features/metadata-dependencies.md b/docs/features/metadata-dependencies.md index ea9e18505..7bde769f9 100644 --- a/docs/features/metadata-dependencies.md +++ b/docs/features/metadata-dependencies.md @@ -375,6 +375,16 @@ classifies those as breaking automatically — see [Deferred](#deferred). ## What fails at `meta deps check` / `verify --deps` +The check ships in TypeScript (`meta deps check`, `meta verify --deps`), Python (`metaobjects deps +check`, `metaobjects verify --deps`) and C# (`dotnet meta verify --deps`), against the same corpus +and with the same three states and the same `ERR_DEPENDENCY_UPSTREAM_DRIFT`. The Python and C# +check resolve the `path` transport only (see [Deferred](#deferred)). Python's `deps check`, like +TypeScript's, also loads the locked snapshots together once the hashes match; `verify --deps` is +the comparison alone in all three. Run `verify --deps` before `verify`'s own load: C# runs it +first, so a dependency that is declared but not yet synced is reported as `unresolved` (the +check exits 1). The load that follows still refuses the missing or stale snapshot with +`ERR_DEPENDENCY_SNAPSHOT_STALE` and exit 2. + `verify --deps` runs `meta deps check`'s comparison as one of `verify`'s gated subverbs (never part of the bare default — it needs the publisher reachable, its `path` or its installed package, which CI checking out only your own repo may not @@ -702,6 +712,10 @@ but **not** built yet — do not assume any of the following exists: runtime one. - **Java, Kotlin, Python and C# as publishers** — Java, Kotlin and C# consume (S3 and S6); publishing is TypeScript 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). ## Verified by @@ -712,8 +726,9 @@ but **not** built yet — do not assume any of the following exists: resolution-time error above, run by TypeScript (the reference implementation), Python and Java (Kotlin loads through the Java loader and Maven plugin, so it inherits the corpus run and adds a codegen test that an imported entity emits nothing). 11 pin the upstream - check (`current`, `drifted`, `unresolved`), run by TypeScript and Java (Kotlin through the same - `metaobjects:verify` goal); Python has no check yet. + check (`current`, `drifted`, `unresolved`), run by TypeScript, Java (Kotlin through the same + `metaobjects:verify` goal), Python (`metaobjects deps check`, `verify --deps`) and C# + (`dotnet meta verify --deps`). Three artifacts under `fixtures/dependency-conformance/artifacts/` are pinned by sha256 in the README so the corpus itself can never silently drift. Python's runner asserts all 26 resolution cases identically to TypeScript's, with no diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index 0c1de34c9..f8406b5c7 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -118,6 +118,16 @@ own metadata can `extends`, overlay or reference its nodes, but an imported node does and does not do is in [Metadata dependencies](../features/metadata-dependencies.md). +`dotnet meta verify --deps` asks whether the publisher has moved since you synced: it re-resolves +each dependency's `path`, reads the publisher's `metaobjects.pkg.json`, hashes the artifact beside +it and compares with the lock. `current`, `drifted` and `unresolved` (no directory, a bad +manifest, a hash that does not match, or no lock entry yet) are the same states and the same +`ERR_DEPENDENCY_UPSTREAM_DRIFT` exit as `meta deps check`. The check runs before the metadata +loads, so a dependency not yet synced is reported `unresolved` first; the load that follows still +refuses the missing or stale snapshot with `ERR_DEPENDENCY_SNAPSHOT_STALE` and exit 2. It never writes anything, it is never +part of a bare `verify`, and an `npm` or `python` dependency is reported `unresolved` (run the Node +`meta deps check` for those). + ### Custom providers (optional) When the project needs a subtype or an attribute the core does not ship, write a public class diff --git a/docs/ports/python.md b/docs/ports/python.md index ff6fdeb94..39af4b6f8 100644 --- a/docs/ports/python.md +++ b/docs/ports/python.md @@ -153,6 +153,17 @@ metaobjects gen # runs every target's generators into its outDir metaobjects verify # drift gate; bare verify runs --codegen, which regenerates the same list, and --templates ``` +A model shared from another repository (`dependencies` in `.metaobjects/config.json`, synced by +the Node `meta deps sync`) asks one more question: has the publisher moved since you synced? +`metaobjects deps check` and `metaobjects verify --deps` re-resolve each dependency's `path`, hash +the artifact beside its `metaobjects.pkg.json` and compare with `.metaobjects/deps.lock.json`. +Each dependency is `current`, `drifted` or `unresolved` (no directory, a bad manifest, a hash that +does not match, or no lock entry yet); anything but `current` exits 1 with +`ERR_DEPENDENCY_UPSTREAM_DRIFT`, and a lock that cannot be read exits 2. `deps check` also loads +the locked snapshots together once the hashes match. Neither writes anything, `--deps` is never +part of a bare `verify`, and an `npm` or `python` dependency reports `unresolved` (run the Node +`meta deps check` for those). See [Metadata dependencies](../features/metadata-dependencies.md). + `verify --codegen` regenerates exactly the list `gen` runs and compares, so the two can never disagree about what was generated. A target whose `generators` is empty writes nothing and says so. With no `metaobjects.config.yaml` at all, `gen` and `verify --codegen` diff --git a/fixtures/dependency-conformance/README.md b/fixtures/dependency-conformance/README.md index 4655894a9..9409cebb9 100644 --- a/fixtures/dependency-conformance/README.md +++ b/fixtures/dependency-conformance/README.md @@ -236,9 +236,11 @@ climbs out of the package, and a Python module that is not a package are (`sdk/test/dependency-conformance.test.ts`), the check arm (`cli/test/dependency-check-conformance.test.ts`), and the transport arm (`cli/test/dependency-transport-conformance.test.ts`). -- **Python** — resolution, load-time failure, lock/snapshot integrity. Not the check arm: Python - has no `deps check` yet, so its runner filters the `expectCheck` cases out. Not the transport - arm: Python never syncs. +- **Python** — resolution, load-time failure, lock/snapshot integrity, and the check arm + (`metaobjects deps check` and `metaobjects verify --deps`, + `tests/conformance/test_dependency_conformance.py`). Its check resolves the `path` transport + only; an `npm` / `python` dependency reports `unresolved` and points at the Node `meta deps + check`, which the corpus does not exercise. Not the transport arm: Python never syncs. - **Java** — resolution, load-time failure, lock/snapshot integrity (`DependencyConformanceTest`), the check arm (`DependencyCheckConformanceTest`, over `Dependencies.check`, the engine of `metaobjects:verify -Dmeta.verify.mode=deps`), and the @@ -254,9 +256,9 @@ climbs out of the package, and a Python module that is not a package are - **C#** — the resolution arm: resolution, load-time failure, lock/snapshot integrity, on the neutral source ladder and again through the explicit metadata directory (C# has no native config surface, so `.metaobjects/config.json` is where a dependency is declared at every - rung). `inScope` is Python's formula, not TypeScript's. Not the check arm: C# has no - `deps check` yet (that is `verify --deps`), so its runner skips the `expectCheck` cases. Not the - transport arm: C# never syncs. The runner is + rung). `inScope` is Python's formula, not TypeScript's. Also the check arm + (`dotnet meta verify --deps`, through `DependencyResolver.CheckDependencies`), which like + Python's resolves the `path` transport only. Not the transport arm: C# never syncs. The runner is `server/csharp/MetaObjects.Conformance.Tests/DependencyConformanceTests.cs`. ## Reference implementation diff --git a/server/csharp/MetaObjects.Cli.Tests/DependencyCliTests.cs b/server/csharp/MetaObjects.Cli.Tests/DependencyCliTests.cs index e316c9ba3..dae8de4d0 100644 --- a/server/csharp/MetaObjects.Cli.Tests/DependencyCliTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/DependencyCliTests.cs @@ -95,4 +95,89 @@ public void A_stale_snapshot_fails_the_command_and_names_the_fix() Assert.Contains("ERR_DEPENDENCY_SNAPSHOT_STALE", stdout + stderr); Assert.Contains("meta deps sync", stdout + stderr); } + + // -- verify --deps (FR-023 Phase 2): the upstream check. The statuses themselves are the corpus's + // `expectCheck` arm; these pin the CLI around it. -- + + /// One check case of the corpus, laid out under the temp root; returns its project directory. + private string CheckProject(string caseName, bool withSnapshot) + { + using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(CorpusDir(), "cases.json"))); + var c = doc.RootElement.GetProperty("cases").EnumerateArray() + .Single(x => x.GetProperty("name").GetString() == caseName); + foreach (var f in c.GetProperty("tree").EnumerateObject()) Write(f.Name, f.Value.GetString()!); + if (c.TryGetProperty("treeFiles", out var treeFiles)) + foreach (var f in treeFiles.EnumerateObject()) + { + var dest = Path.Combine(_root, f.Name); + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + File.Copy(Path.Combine(CorpusDir(), f.Value.GetString()!), dest); + } + var project = Path.Combine(_root, c.GetProperty("resolveFrom").GetString()!); + Directory.CreateDirectory(Path.Combine(project, ".metaobjects")); + File.WriteAllText(Path.Combine(project, ".metaobjects", "config.json"), c.GetProperty("config").GetRawText()); + if (c.TryGetProperty("lock", out var lockJson)) + File.WriteAllText(Path.Combine(project, ".metaobjects", "deps.lock.json"), lockJson.GetRawText()); + if (withSnapshot) + { + var snapshot = Path.Combine(project, ".metaobjects", "deps", "acme-common", "acme-common.metaobjects.json"); + Directory.CreateDirectory(Path.GetDirectoryName(snapshot)!); + File.Copy(Path.Combine(CorpusDir(), "artifacts", "acme-common-v1.json"), snapshot); + } + return project; + } + + [Fact] + public void Verify_deps_passes_when_the_dependency_is_current() + { + var project = CheckProject("check-a-matching-artifact-is-current", withSnapshot: true); + var (exit, stdout, stderr) = CliProcess.Run(project, "verify", "--deps", "--no-field-lint"); + Assert.True(exit == 0, stdout + stderr); + Assert.Contains("acme-common: current — 1.0.0 (10fbf886)", stdout); + Assert.Contains("every dependency's installed artifact matches the lock", stdout); + } + + [Fact] + public void Verify_deps_fails_on_drift_and_names_the_code() + { + var project = CheckProject("check-a-newer-artifact-than-the-lock-has-drifted", withSnapshot: true); + var (exit, stdout, stderr) = CliProcess.Run(project, "verify", "--deps", "--no-field-lint"); + Assert.NotEqual(0, exit); + Assert.Contains("acme-common: drifted — lock 1.0.0 (10fbf886), installed 1.1.0 (fa00b9f3)", stderr); + Assert.Contains("run meta deps sync", stderr); + Assert.Contains("ERR_DEPENDENCY_UPSTREAM_DRIFT", stderr); + } + + [Fact] + public void Verify_deps_reports_a_dependency_not_yet_synced_as_unresolved_then_the_load_refuses_it() + { + // No lock file at all: the check says so in its own words first; the metadata load that + // follows then refuses the missing snapshot and the run exits 2. + var project = CheckProject("check-a-dependency-with-no-lock-file-is-unresolved", withSnapshot: false); + var (exit, stdout, stderr) = CliProcess.Run(project, "verify", "--deps", "--no-field-lint"); + Assert.Equal(2, exit); + Assert.Contains("acme-common: unresolved", stderr); + Assert.Contains("ERR_DEPENDENCY_UPSTREAM_DRIFT", stderr); + Assert.Contains("ERR_DEPENDENCY_SNAPSHOT_STALE", stderr); + } + + [Fact] + public void A_bare_verify_does_not_run_the_dependency_check() + { + var project = CheckProject("check-a-newer-artifact-than-the-lock-has-drifted", withSnapshot: true); + Directory.CreateDirectory(Path.Combine(project, "templates")); + var (_, stdout, stderr) = CliProcess.Run(project, "verify", "--no-field-lint"); + Assert.DoesNotContain("drifted", stdout + stderr); + Assert.DoesNotContain("ERR_DEPENDENCY_UPSTREAM_DRIFT", stdout + stderr); + } + + [Fact] + public void Verify_deps_with_nothing_declared_passes() + { + Write(".metaobjects/config.json", "{\"schema_version\":1}"); + Write("metaobjects/meta.app.json", """{"metadata.root":{"package":"app","children":[]}}"""); + var (exit, stdout, stderr) = CliProcess.Run(_root, "verify", "--deps", "--no-field-lint"); + Assert.True(exit == 0, stdout + stderr); + Assert.Contains("no dependencies declared", stdout); + } } diff --git a/server/csharp/MetaObjects.Cli/Program.cs b/server/csharp/MetaObjects.Cli/Program.cs index a1a5c8fc2..cbae532ed 100644 --- a/server/csharp/MetaObjects.Cli/Program.cs +++ b/server/csharp/MetaObjects.Cli/Program.cs @@ -37,7 +37,7 @@ " --strict rejects an unknown @attr before writing\n" + " (as verify does, ADR-0023; default is a warning)\n" + " gen --list list available generators (stable names) and exit\n" + - " verify [--templates [--prompts ]] [--codegen --out [--namespace ]] [--db] [--lax]\n" + + " verify [--templates [--prompts ]] [--codegen --out [--namespace ]] [--deps] [--db] [--lax]\n" + " drift gates (ADR-0021 D2 subverbs):\n" + " --templates template/prompt drift (default); the\n" + " directory is --prompts or the inline\n" + @@ -48,6 +48,8 @@ " .metaobjects/config.json, as for gen)\n" + " --namespace codegen regen namespace (default: csharp.namespace\n" + " in .metaobjects/config.json, else the metadata package)\n" + + " --deps dependency drift: each declared dependency's\n" + + " artifact vs .metaobjects/deps.lock.json\n" + " --db NOT supported in C# (migrate engine)\n" + " --lax load lax (legacy); strict-by-default\n" + " rejects an unregistered @attr (ADR-0023)\n" + @@ -384,7 +386,7 @@ static int RunVerify(string[] rest) string? generatorsCsv = null; string? templateRoot = null; string? columnNamingRaw = null; - bool templates = false, codegen = false, db = false, lax = false, noFieldLint = false, requireImplementers = false; + bool templates = false, codegen = false, deps = false, db = false, lax = false, noFieldLint = false, requireImplementers = false; for (int i = 0; i < rest.Length; i++) { @@ -405,6 +407,9 @@ static int RunVerify(string[] rest) // spelling is deprecated in 1.1 and removed no earlier than a major. else if (a == "--prompts" && i + 1 < rest.Length) templatesRoot = rest[++i]; else if (a == "--codegen") codegen = true; + // FR-023 dependency drift: opt-in, never part of a bare verify (it needs each publisher's + // directory reachable, which CI may not have). + else if (a == "--deps") deps = true; else if (a == "--db") db = true; // --lax (#96 / ADR-0023): restore the legacy open-attr load. verify is // strict-by-default — an undeclared/typo'd own @attr is ERR_UNKNOWN_ATTR. @@ -437,6 +442,12 @@ static int RunVerify(string[] rest) return 2; } + // The dependency gate runs BEFORE the metadata resolves: resolving verifies the committed snapshot + // against the lock and refuses a stale one, and a drift or a not-yet-synced dependency is exactly + // what this gate exists to report in its own words. It returns here, then the run goes on; the + // load that follows still refuses a missing or stale snapshot (ERR_DEPENDENCY_SNAPSHOT_STALE, exit 2). + var depsExit = deps ? RunDepsGate(metadataDir) : 0; + // Rung 1 (explicit positional) is honored as-is; an omitted metadataDir // falls back to the port-neutral .metaobjects/config.json ladder. var resolvedMeta = ResolveMetadataDirOrExit(metadataDir); @@ -447,7 +458,7 @@ static int RunVerify(string[] rest) // to verify clean, because nothing in an empty root can drift. With none named (bare // verify, or a bare --templates) it reads the same default `gen` does: prompts/ when that // directory exists, else templates/ — and neither existing fails the same way. - var bare = !templates && !codegen && !db; + var bare = !templates && !codegen && !deps && !db; var wantsTemplates = templates || bare; if (wantsTemplates) { @@ -540,6 +551,7 @@ static int RunVerify(string[] rest) TemplateRoot = templateRoot, Templates = templates, Bare = bare, + Deps = deps, Codegen = codegen && !codegenHandedOff, CodegenHandedOff = codegenHandedOff, Db = db, @@ -614,7 +626,51 @@ static int RunVerify(string[] rest) // fold its exit code into the aggregate the same way every other subverb does — max, // non-zero on any drift. var gatesExit = codegenHandedOff ? Math.Max(result.ExitCode, codegenHandoffExit) : result.ExitCode; - return Math.Max(gatesExit, requirementExit); + return Math.Max(Math.Max(gatesExit, requirementExit), depsExit); +} + +// `dotnet meta verify --deps` (FR-023): re-resolve each declared dependency's `path` and compare its +// artifact's hash with .metaobjects/deps.lock.json. Read-only. 0 current; 1 drifted or unresolved +// (ERR_DEPENDENCY_UPSTREAM_DRIFT); 2 when the config or the lock cannot be read. +static int RunDepsGate(string? metadataDir) +{ + const string command = "dotnet meta verify --deps"; + IReadOnlyList specs; + IReadOnlyDictionary? lockEntries; + string configDir; + try + { + var cwd = Directory.GetCurrentDirectory(); + var cfg = MetadataLocation.ProjectConfig(metadataDir, cwd); + configDir = cfg?.ConfigDir ?? cwd; + specs = cfg?.Dependencies ?? []; + lockEntries = specs.Count == 0 ? null : DependencyResolver.ReadLock(configDir); + } + catch (MetaObjects.MetaModelException e) + { + Console.Error.WriteLine($"{command}: {e.Code}: {e.Message}"); + return 2; + } + + if (specs.Count == 0) + { + Console.WriteLine($"{command}: no dependencies declared — nothing to check."); + return 0; + } + + var results = DependencyResolver.CheckDependencies(configDir, specs, lockEntries); + var failing = results.Count(r => r.Status != DependencyCheckStatus.Current); + foreach (var r in results) + (r.Status == DependencyCheckStatus.Current ? Console.Out : Console.Error).WriteLine(DependencyResolver.FormatCheckLine(r)); + if (failing > 0) + { + Console.Error.WriteLine( + $"{command}: {failing} of {results.Count} dependenc{(results.Count == 1 ? "y" : "ies")} drifted or unresolved " + + $"({MetaObjects.ErrorCode.ERR_DEPENDENCY_UPSTREAM_DRIFT})."); + return 1; + } + Console.WriteLine($"{command}: every dependency's installed artifact matches the lock."); + return 0; } @@ -627,7 +683,7 @@ partial class Program static readonly string[] GenBoolFlags = ["--list", "--emit-abstract-shapes", "--strict"]; static readonly string[] VerifyValueFlags = ["--prompts", "--out", "--namespace", "--generators", "--template-root", "--column-naming"]; - static readonly string[] VerifyBoolFlags = ["--templates", "--codegen", "--db", "--lax", "--no-field-lint", "--require-implementers"]; + static readonly string[] VerifyBoolFlags = ["--templates", "--codegen", "--deps", "--db", "--lax", "--no-field-lint", "--require-implementers"]; /// Each command's usage, printed by dotnet meta <command> --help. static readonly Dictionary CommandUsage = new() @@ -642,7 +698,7 @@ partial class Program " column/table naming: \"naming\" in .metaobjects/config.json (ADR-0058), not a flag here", ["init"] = "usage: dotnet meta init [--root ] [--with-docs]\n" + " --with-docs lists the api-docs generator in codegen/Generators.cs (written to docs/csharp)", - ["verify"] = "usage: dotnet meta verify [--templates [--prompts ]] [--codegen --out [--namespace ] [--generators ] [--template-root ]] [--db] [--lax] [--no-field-lint] [--require-implementers]", + ["verify"] = "usage: dotnet meta verify [--templates [--prompts ]] [--codegen --out [--namespace ] [--generators ] [--template-root ]] [--deps] [--db] [--lax] [--no-field-lint] [--require-implementers]", ["fmt"] = "usage: dotnet meta fmt [] [--check]", ["eject"] = "usage: dotnet meta eject ... [--force] [--root ]\n" + " dotnet meta eject --undo [...] [--force] [--root ]\n" + diff --git a/server/csharp/MetaObjects.Cli/VerifyCommand.cs b/server/csharp/MetaObjects.Cli/VerifyCommand.cs index ce820159e..81dcb12fa 100644 --- a/server/csharp/MetaObjects.Cli/VerifyCommand.cs +++ b/server/csharp/MetaObjects.Cli/VerifyCommand.cs @@ -158,12 +158,15 @@ public sealed record Options public bool CodegenHandedOff { get; init; } /// True when no explicit subverb flag was passed (bare verify). - public bool NoExplicitSubverb => Bare || (!Templates && !Codegen && !Db && !CodegenHandedOff); + public bool NoExplicitSubverb => Bare || (!Templates && !Codegen && !Deps && !Db && !CodegenHandedOff); /// True when the user named no subverb. A bare verify runs the codegen gate too, in /// the owned runner or here, so / may be /// set without the run having stopped being bare. public bool Bare { get; init; } + + /// --deps was named: the dependency gate ran in Program.cs, so this is not a bare verify. + public bool Deps { get; init; } } /// The aggregate outcome of a subverb dispatch (pure; no console I/O). diff --git a/server/csharp/MetaObjects.Conformance.Tests/DependencyConformanceTests.cs b/server/csharp/MetaObjects.Conformance.Tests/DependencyConformanceTests.cs index 6889324f0..3965bcf06 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/DependencyConformanceTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/DependencyConformanceTests.cs @@ -21,7 +21,8 @@ namespace MetaObjects.Conformance.Tests; public class DependencyConformanceTests : IDisposable { private static readonly string CorpusDir = FindCorpusDir(); - private static readonly JsonElement AllCases = LoadCases(); + private static readonly JsonElement AllCases = LoadCases(check: false); + private static readonly JsonElement CheckCases = LoadCases(check: true); private readonly string _tmp = Directory.CreateTempSubdirectory("mo-dep-conformance-").FullName; @@ -39,13 +40,13 @@ private static string FindCorpusDir() return Path.Combine(dir!.FullName, "fixtures", "dependency-conformance"); } - private static JsonElement LoadCases() + private static JsonElement LoadCases(bool check) { using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(FindCorpusDir(), "cases.json"))); - // The corpus has two arms. The resolution arm is this runner's; the check arm (`expectCheck`) is - // `deps check`'s, which this port does not have yet (that is `verify --deps`). + // The corpus has two arms, both run here: resolution (`CorpusCaseMatchesTheReference`) and the check + // (`expectCheck`, `CheckCaseMatchesTheReference` — what `verify --deps` reports). var resolution = doc.RootElement.GetProperty("cases").EnumerateArray() - .Where(c => !c.TryGetProperty("expectCheck", out _)).ToList(); + .Where(c => c.TryGetProperty("expectCheck", out _) == check).ToList(); return JsonSerializer.SerializeToElement(resolution); } @@ -56,6 +57,31 @@ public static TheoryData CaseNames() return data; } + public static TheoryData CheckCaseNames() + { + var data = new TheoryData(); + foreach (var c in CheckCases.EnumerateArray()) data.Add(c.GetProperty("name").GetString()!); + return data; + } + + [Fact] + public void CheckCorpusIsNotEmpty() => Assert.NotEmpty(CheckCaseNames()); + + /// The check arm: the status of every declared dependency, by name. A pure function of the + /// config, the lock and the publisher's directory — no loader, no snapshot. + [Theory] + [MemberData(nameof(CheckCaseNames))] + public void CheckCaseMatchesTheReference(string name) + { + var c = CheckCases.EnumerateArray().Single(x => x.GetProperty("name").GetString() == name); + var resolveDir = Materialize(c); + var cfg = NeutralConfig.Read(resolveDir); + Assert.NotNull(cfg); + var results = DependencyResolver.CheckDependencies(resolveDir, cfg!.Dependencies, DependencyResolver.ReadLock(resolveDir)); + var expected = c.GetProperty("expectCheck").EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString()!); + Assert.Equal(expected.OrderBy(kv => kv.Key), results.ToDictionary(r => r.Name, r => r.StatusName).OrderBy(kv => kv.Key)); + } + [Fact] public void CorpusIsNotEmpty() => // A parametrized theory over an empty list collects zero tests and passes; this makes a corpus diff --git a/server/csharp/MetaObjects/Config/DependencyCheck.cs b/server/csharp/MetaObjects/Config/DependencyCheck.cs new file mode 100644 index 000000000..78e0980c1 --- /dev/null +++ b/server/csharp/MetaObjects/Config/DependencyCheck.cs @@ -0,0 +1,222 @@ +// FR-023 Phase 2 — the upstream dependency check behind `dotnet meta verify --deps`. +// +// For each declared dependency: resolve its `path`, read and validate the publisher's +// `metaobjects.pkg.json`, hash the artifact beside it, and compare with the lock. Mirrors the +// TypeScript `checkDependencies` (cli/src/lib/dependency-sync.ts) and the Python +// `check_dependencies`, and is gated by the `expectCheck` arm of fixtures/dependency-conformance/. +// +// A pure function of the config, the lock and the publisher's directory: it writes nothing and +// 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 +// `Unresolved` and names `meta deps check`: a check that cannot check must not pass. +using System.Text.Json; + +namespace MetaObjects.Config; + +/// The verdict for one declared dependency. +public enum DependencyCheckStatus +{ + /// It resolves and hashes to the lock's integrity (a new version over the same bytes is still current). + Current, + + /// It resolves, but hashes to something other than the lock records. + Drifted, + + /// The check cannot compare: no directory, no or invalid manifest, a missing or mismatching artifact, or no lock entry. + Unresolved, +} + +/// One declared dependency's check result. Lock* are null exactly when the lock has no entry for it; Installed* are null exactly when resolution failed; Detail is the reason, for Unresolved only. +public sealed record DependencyCheckResult( + string Name, DependencyCheckStatus Status, string? LockVersion, string? LockIntegrity, + string? InstalledVersion, string? InstalledIntegrity, string? Detail) +{ + /// The lowercase status name the corpus and the report use. + public string StatusName => Status.ToString().ToLowerInvariant(); +} + +public static partial class DependencyResolver +{ + private static readonly string[] ManifestKeys = + ["schema_version", "name", "version", "metamodelVersion", "artifact", "integrity", "packages", "nodes"]; + + /// The publisher's manifest file, beside its artifact. + public const string ManifestFile = "metaobjects.pkg.json"; + + private sealed class UnresolvedException(string message) : Exception(message); + + private static UnresolvedException ManifestInvalid(string detail) => new(detail); + + /// The first 8 hex digits of an sha256-<hex> integrity, the report's short form. + public static string Hash8(string integrity) => + integrity.Length >= DependencyFiles.IntegrityPrefix.Length + 8 + ? integrity.Substring(DependencyFiles.IntegrityPrefix.Length, 8) + : integrity; + + /// + /// Validate a parsed metaobjects.pkg.json (DESIGN §3.2): the known keys only, a + /// schema_version of 1, a valid name, version, metamodelVersion, + /// artifact and integrity, and sorted packages / nodes. Returns the artifact + /// file name and its recorded integrity and version; anything else is a reason string. + /// + private static (string Version, string Artifact, string Integrity) ValidateManifest(JsonElement root, string manifestPath) + { + UnresolvedException Fail(string reason) => + ManifestInvalid($"{manifestPath} failed schema validation — {reason}"); + + if (root.ValueKind != JsonValueKind.Object) throw Fail("must be an object"); + + var extra = root.EnumerateObject().Select(p => p.Name).Where(n => !ManifestKeys.Contains(n)) + .Order(StringComparer.Ordinal).ToList(); + if (extra.Count > 0) throw Fail($"unknown key(s): [{string.Join(", ", extra)}]"); + + if (!root.TryGetProperty("schema_version", out var sv) || sv.ValueKind != JsonValueKind.Number || + !sv.TryGetDouble(out var schemaVersion) || schemaVersion != 1) + throw Fail("\"schema_version\" must be 1"); + + string Str(string field, string what) + { + if (!root.TryGetProperty(field, out var el) || el.ValueKind != JsonValueKind.String || + string.IsNullOrEmpty(el.GetString())) + throw Fail($"\"{field}\" must be {what}"); + return el.GetString()!; + } + + if (!NameRe.IsMatch(Str("name", "a string matching ^[a-z0-9][a-z0-9._-]*$"))) + throw Fail("\"name\" must match ^[a-z0-9][a-z0-9._-]*$"); + var version = Str("version", "a non-empty string"); + if (!MetamodelVersionRe.IsMatch(Str("metamodelVersion", "a string matching ^\\d+\\.\\d+$"))) + throw Fail("\"metamodelVersion\" must match ^\\d+\\.\\d+$"); + var artifact = Str("artifact", $"a string ending with '{DependencyFiles.ArtifactSuffix}'"); + if (!artifact.EndsWith(DependencyFiles.ArtifactSuffix, StringComparison.Ordinal)) + throw Fail($"\"artifact\" must end with '{DependencyFiles.ArtifactSuffix}'"); + var integrity = Str("integrity", "a string matching ^sha256-[0-9a-f]{64}$"); + if (!IntegrityRe.IsMatch(integrity)) throw Fail("\"integrity\" must match ^sha256-[0-9a-f]{64}$"); + + foreach (var field in new[] { "packages", "nodes" }) + { + if (!root.TryGetProperty(field, out var el) || el.ValueKind != JsonValueKind.Array || + el.EnumerateArray().Any(v => v.ValueKind != JsonValueKind.String || v.GetString()!.Length == 0)) + throw Fail($"\"{field}\" must be an array of non-empty strings"); + if (!IsStrictlyAscending(el.EnumerateArray().Select(v => v.GetString()!).ToList())) + throw Fail($"\"{field}\" must be sorted"); + } + + return (version, artifact, integrity); + } + + /// + /// <dir>/metaobjects.pkg.json, strict, plus the artifact bytes beside it, verified to hash + /// to the manifest's own integrity (a publisher who bypassed sharedModelFile() is caught here). + /// + private static (string Version, byte[] Artifact) ReadManifestDir(string dir) + { + var manifestPath = Path.Combine(dir, ManifestFile); + string raw; + try + { + raw = File.ReadAllText(manifestPath); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + throw ManifestInvalid($"no {ManifestFile} found at {manifestPath}"); + } + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(raw); + } + catch (JsonException e) + { + throw ManifestInvalid($"{manifestPath} is not valid JSON: {e.Message}"); + } + + using (doc) + { + var (version, artifact, integrity) = ValidateManifest(doc.RootElement, manifestPath); + var artifactPath = Path.Combine(dir, artifact); + byte[] data; + try + { + data = File.ReadAllBytes(artifactPath); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + throw ManifestInvalid($"the manifest names artifact \"{artifact}\", but it is missing at {artifactPath}"); + } + + var actual = Sha256Integrity(data); + if (actual != integrity) + throw ManifestInvalid($"{artifactPath} hashes to {actual}, but the manifest records {integrity}"); + return (version, data); + } + } + + private static string ResolveDependencyDir(string configDir, DependencySpec spec) + { + if (spec.Transport != "path") + throw new UnresolvedException( + $"the \"{spec.Transport}\" transport is resolved by the Node toolchain; " + + "run `meta deps check` to check this dependency"); + var target = Path.GetFullPath(Path.Combine(configDir, spec.Location)); + if (!Directory.Exists(target)) + throw new UnresolvedException($"the \"path\" transport resolved to {target}, but no directory exists there"); + return target; + } + + /// + /// Resolve each declared dependency and compare its artifact's TRUE hash (re-hashed from the bytes + /// on disk, never the manifest's own field taken on faith) with the lock's. Read-only. One result + /// per declared dependency, in name order. A null (no lock file) makes + /// every dependency Unresolved: there is nothing to compare against. + /// + public static IReadOnlyList CheckDependencies( + string configDir, IReadOnlyList specs, + IReadOnlyDictionary? lockEntries) + { + var results = new List(); + foreach (var spec in specs.OrderBy(s => s.Name, StringComparer.Ordinal)) + { + DependencyLockEntry? entry = null; + lockEntries?.TryGetValue(spec.Name, out entry); + try + { + var (version, data) = ReadManifestDir(ResolveDependencyDir(configDir, spec)); + var installed = Sha256Integrity(data); + if (entry is null) + results.Add(new DependencyCheckResult( + spec.Name, DependencyCheckStatus.Unresolved, null, null, version, installed, + "declared, and resolves, but is not in the lock yet")); + else + results.Add(new DependencyCheckResult( + spec.Name, + installed == entry.Integrity ? DependencyCheckStatus.Current : DependencyCheckStatus.Drifted, + entry.Version, entry.Integrity, version, installed, null)); + } + catch (UnresolvedException e) + { + results.Add(new DependencyCheckResult( + spec.Name, DependencyCheckStatus.Unresolved, entry?.Version, entry?.Integrity, null, null, e.Message)); + } + } + return results; + } + + /// One report line per dependency (the same shape as the TypeScript formatCheckLine). + public static string FormatCheckLine(DependencyCheckResult r) + { + var lockDesc = $"{r.LockVersion ?? "(none)"} ({(r.LockIntegrity is null ? "n/a" : Hash8(r.LockIntegrity))})"; + var installedDesc = $"{r.InstalledVersion ?? "(none)"} ({(r.InstalledIntegrity is null ? "n/a" : Hash8(r.InstalledIntegrity))})"; + return r.Status switch + { + DependencyCheckStatus.Current => $"{r.Name}: current — {lockDesc}", + DependencyCheckStatus.Drifted => + $"{r.Name}: drifted — lock {lockDesc}, installed {installedDesc}; run meta deps sync and review the artifact diff", + _ => $"{r.Name}: unresolved — {r.Detail ?? "could not resolve the dependency"}; run meta deps sync once the dependency is reachable", + }; + } +} diff --git a/server/csharp/MetaObjects/Config/MetadataLocation.cs b/server/csharp/MetaObjects/Config/MetadataLocation.cs index cdb83c612..61478aa95 100644 --- a/server/csharp/MetaObjects/Config/MetadataLocation.cs +++ b/server/csharp/MetaObjects/Config/MetadataLocation.cs @@ -117,6 +117,15 @@ public static string ProjectRootFor(string metadataDir) => return null; } + /// + /// The port-neutral config that declares a run's dependencies: that of the project holding an + /// explicit , else 's. Null when there is none, which + /// declares no dependencies. Unlike it verifies no snapshot, so the upstream check + /// can report on a project whose snapshot is stale or not yet synced. + /// + public static NeutralConfig? ProjectConfig(string? metadataDir, string cwd) => + metadataDir is not null ? ConfigFor(metadataDir) : NeutralConfig.Read(cwd); + /// /// Resolve where metadata lives. An explicit is used as /// given; otherwise 's .metaobjects/config.json diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index 7dd23b66c..3bb49c808 100644 --- a/server/python/src/metaobjects/cli.py +++ b/server/python/src/metaobjects/cli.py @@ -16,7 +16,7 @@ so ``--generators render-helper`` read a hardcoded ``templates`` whatever this flag said. - metaobjects verify [--codegen] [--templates] [--db URL] ... + metaobjects verify [--codegen] [--templates] [--deps] [--db URL] ... Drift gate with explicit subverbs (ADR-0021 D2 — one verify vocabulary across ports): @@ -34,6 +34,11 @@ node's refs via a filesystem provider rooted at ``templates`` in ``metaobjects.config.yaml`` (else ``--prompts``), over the config's ``metadata``. + --deps dependency drift (FR-023): re-resolve each declared + dependency's ``path`` and compare its artifact's hash with + ``.metaobjects/deps.lock.json`` (``metaobjects deps check`` + is the same comparison plus a load of the locked snapshots). + Opt-in: never part of a bare ``verify``. --db URL REJECTED in the Python port (exit 2): schema verify is the migrate engine (ADR-0015), not this CLI. @@ -70,7 +75,14 @@ refuse_library_package_misuse, refuse_unowned_packages, ) -from metaobjects.config.neutral_config import read_neutral_config +from metaobjects.config.dependencies import read_lock +from metaobjects.config.dependency_check import ( + check_dependencies, + format_check_line, + prove_locked_set_loads, + summary_line, +) +from metaobjects.config.neutral_config import find_project_dir, read_neutral_config from metaobjects.field_lint import lint_duplicate_fields, lint_reference_fields from metaobjects.loader.meta_data_loader import LoadResult from metaobjects.loader.sources import FileSource @@ -2781,6 +2793,64 @@ def _verify_requirements(args: argparse.Namespace, model: "_VerifyModel | None") return 0 +def _deps_project_root(args: argparse.Namespace) -> Path: + """The project whose ``.metaobjects/config.json`` declares the dependencies: the one holding + the metadata the run names (````, else the config's ``metadata``), else the + nearest project at or above the working directory. The same root ``_load_verify_model`` + reads the lock from.""" + metadata_dir = getattr(args, "metadata_dir", None) + if metadata_dir: + return project_root_for(metadata_dir) + config_path = _find_config(args) + if config_path is not None: + try: + return project_root_for(load_project_config(config_path).metadata_dir()) + except ConfigError: + pass + return find_project_dir(Path.cwd()) or Path.cwd().resolve() + + +def _run_deps_check(root: Path, command: str, *, prove_load: bool) -> int: + """The check both ``deps check`` and ``verify --deps`` run: 0 current, 1 drifted or + unresolved (``ERR_DEPENDENCY_UPSTREAM_DRIFT``), 2 when the config or lock cannot be read.""" + try: + neutral = read_neutral_config(root) + specs = neutral.dependencies if neutral is not None else [] + lock = read_lock(root) + except ParseError as exc: + print(f"{command}: {exc.code}: {exc}", file=sys.stderr) + return 2 + if not specs: + print(f"{command}: no dependencies declared — nothing to check.") + return 0 + + results = check_dependencies(root, specs, lock) + failing = [r for r in results if r.status != "current"] + for r in results: + print(format_check_line(r), file=sys.stderr if r.status != "current" else sys.stdout) + if failing: + print(summary_line(command, len(failing), len(results)), file=sys.stderr) + return 1 + if prove_load: + # Matching hashes only prove the snapshot is the locked one, not that it still loads. + failure = prove_locked_set_loads(root, specs) + if failure is not None: + print(f"{command}: the locked dependency set does not load: {failure}", file=sys.stderr) + return 1 + if command == "metaobjects verify --deps": + print("verify --deps: every dependency's installed artifact matches the lock.") + return 0 + + +def _verify_deps(args: argparse.Namespace) -> int: + return _run_deps_check(_deps_project_root(args), "metaobjects verify --deps", prove_load=False) + + +def _cmd_deps(args: argparse.Namespace) -> int: + root = find_project_dir(Path.cwd()) or Path.cwd().resolve() + return _run_deps_check(root, "metaobjects deps check", prove_load=True) + + def _cmd_verify(args: argparse.Namespace) -> int: """Subverb dispatch (ADR-0021 D2). Run each requested mode; aggregate exit = max (non-zero if ANY mode drifts). Bare ``verify`` (no subverb) runs the @@ -2798,13 +2868,14 @@ def _cmd_verify(args: argparse.Namespace) -> int: run_db = args.db is not None run_templates = bool(args.templates) run_codegen = bool(args.codegen) - any_explicit = run_db or run_templates or run_codegen + run_deps = bool(args.deps) + any_explicit = run_db or run_templates or run_codegen or run_deps if not any_explicit: print( "metaobjects verify — running --codegen --templates (default). Explicit " "subverbs: --codegen (codegen drift), --templates (template/prompt " - "drift), --db (schema drift — not supported in Python).", + "drift), --deps (dependency drift), --db (schema drift — not supported in Python).", file=sys.stderr, ) run_codegen = True @@ -2830,6 +2901,8 @@ def _cmd_verify(args: argparse.Namespace) -> int: exit_code = max(exit_code, _verify_codegen(args)) if run_templates: exit_code = max(exit_code, _verify_templates(args)) + if run_deps: + exit_code = max(exit_code, _verify_deps(args)) # One load for both passes that read the model. The requirement gate runs on EVERY verify. load_failure: list[str] = [] model = _load_verify_model(args, load_failure) @@ -2987,7 +3060,7 @@ def _build_parser() -> argparse.ArgumentParser: verify = sub.add_parser( "verify", help=( - "drift gate — explicit subverbs --codegen / --templates / --db " + "drift gate — explicit subverbs --codegen / --templates / --deps / --db " "(ADR-0021 D2); bare verify runs --codegen and --templates" ), ) @@ -3025,6 +3098,15 @@ def _build_parser() -> argparse.ArgumentParser: "metaobjects.config.yaml, or takes with --prompts" ), ) + verify.add_argument( + "--deps", + action="store_true", + help=( + "dependency drift (FR-023): re-resolve each declared dependency's `path` and " + "compare its artifact's hash with .metaobjects/deps.lock.json. Opt-in, never part " + "of bare verify: it needs the publisher's directory reachable" + ), + ) verify.add_argument( "--db", default=None, @@ -3175,6 +3257,23 @@ def _build_parser() -> argparse.ArgumentParser: init_p.add_argument("--root", default=None, help="project root (default: cwd)") init_p.set_defaults(func=_cmd_init) + deps_p = sub.add_parser( + "deps", + help=( + "metadata dependencies (FR-023): `deps check` compares each declared dependency's " + "publisher artifact with the lock. `deps sync` is the Node `meta` CLI's job" + ), + ) + deps_sub = deps_p.add_subparsers(dest="deps_command", required=True) + deps_check = deps_sub.add_parser( + "check", + help=( + "exit 1 when a declared dependency has drifted from, or cannot be compared with, " + "the lock (ERR_DEPENDENCY_UPSTREAM_DRIFT). Read-only" + ), + ) + deps_check.set_defaults(func=_cmd_deps) + agent_docs = sub.add_parser( "agent-docs", help=( diff --git a/server/python/src/metaobjects/config/dependency_check.py b/server/python/src/metaobjects/config/dependency_check.py new file mode 100644 index 000000000..0ad42dbaf --- /dev/null +++ b/server/python/src/metaobjects/config/dependency_check.py @@ -0,0 +1,218 @@ +"""FR-023 Phase 2 — the upstream dependency check: ``metaobjects deps check`` and +``metaobjects verify --deps``. + +For each declared dependency: resolve its ``path``, read and validate the publisher's +``metaobjects.pkg.json``, hash the artifact beside it, and compare with the lock. Mirrors the +TypeScript ``checkDependencies`` (``cli/src/lib/dependency-sync.ts``) and is gated by the +``expectCheck`` arm of ``fixtures/dependency-conformance/``. + +The check is a pure function of the config, the lock and the publisher's directory. It writes +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 +``unresolved`` and names ``meta deps check``: a check that cannot check must not pass. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +from metaobjects.config.dependencies import ( + INTEGRITY_PREFIX, + MANIFEST_FILE, + read_lock, + sha256_integrity, + validate_manifest, + verify_snapshot, +) +from metaobjects.errors import ErrorCode, ParseError + +#: The one code every ``drifted`` or ``unresolved`` result is reported under. +ERR_DEPENDENCY_UPSTREAM_DRIFT = ErrorCode.ERR_DEPENDENCY_UPSTREAM_DRIFT + +CheckStatus = Literal["current", "drifted", "unresolved"] + + +@dataclass(frozen=True) +class DependencyCheckResult: + """One declared dependency's verdict. ``lock_*`` are ``None`` exactly when the lock has no + entry for it; ``installed_*`` are ``None`` exactly when resolution itself failed; ``detail`` + is the reason, for ``unresolved`` only.""" + + name: str + status: CheckStatus + lock_version: str | None + lock_integrity: str | None + installed_version: str | None + installed_integrity: str | None + detail: str | None + + +def hash8(integrity: str) -> str: + """The first 8 hex digits of an ``sha256-`` integrity, the report's short form.""" + return integrity[len(INTEGRITY_PREFIX) : len(INTEGRITY_PREFIX) + 8] + + +class _Unresolved(Exception): + """A dependency whose publisher directory could not be read, with the reason.""" + + +def _resolve_dir(config_dir: Path, spec: dict[str, str]) -> Path: + if "path" not in spec: + transport = "npm" if "npm" in spec else "python" + raise _Unresolved( + f'the "{transport}" transport is resolved by the Node toolchain; ' + "run `meta deps check` to check this dependency" + ) + target = (config_dir / spec["path"]).resolve() + if not target.is_dir(): + raise _Unresolved(f'the "path" transport resolved to {target}, but no directory exists there') + return target + + +def _read_manifest(directory: Path) -> tuple[dict[str, Any], bytes]: + """``/metaobjects.pkg.json``, strict, plus the artifact bytes beside it, verified to + hash to the manifest's own ``integrity`` (a publisher who bypassed ``sharedModelFile()`` is + caught here).""" + manifest_path = directory / MANIFEST_FILE + try: + raw = manifest_path.read_bytes() + except OSError: + raise _Unresolved(f"no {MANIFEST_FILE} found at {manifest_path}") from None + try: + parsed = json.loads(raw.decode("utf-8")) + except ValueError as exc: + raise _Unresolved(f"{manifest_path} is not valid JSON: {exc}") from None + try: + manifest = validate_manifest(parsed) + except ParseError as exc: + raise _Unresolved(f"{manifest_path} failed schema validation — {exc}") from None + + artifact_path = directory / manifest["artifact"] + try: + data = artifact_path.read_bytes() + except OSError: + raise _Unresolved( + f'the manifest names artifact "{manifest["artifact"]}", but it is missing at {artifact_path}' + ) from None + actual = sha256_integrity(data) + if actual != manifest["integrity"]: + raise _Unresolved( + f"{artifact_path} hashes to {actual}, but the manifest records {manifest['integrity']}" + ) + return manifest, data + + +def check_dependencies( + config_dir: Path, + specs: list[dict[str, str]], + lock: dict[str, Any] | None, +) -> list[DependencyCheckResult]: + """Resolve each declared dependency and compare its artifact's TRUE hash (re-hashed from the + bytes on disk, never the manifest's own field taken on faith) with the lock's. Read-only. + One result per declared dependency, in name order. + + ``lock`` is the already-validated dict :func:`read_lock` returns, or ``None`` for no lock + file, which makes every dependency ``unresolved`` ("nothing to compare against"). + """ + entries: dict[str, Any] = lock["dependencies"] if lock is not None else {} + results: list[DependencyCheckResult] = [] + for spec in sorted(specs, key=lambda s: s["name"]): + name = spec["name"] + entry = entries.get(name) + lock_version = entry["version"] if entry is not None else None + lock_integrity = entry["integrity"] if entry is not None else None + try: + manifest, data = _read_manifest(_resolve_dir(config_dir, spec)) + except _Unresolved as exc: + results.append( + DependencyCheckResult( + name, "unresolved", lock_version, lock_integrity, None, None, str(exc) + ) + ) + continue + installed = sha256_integrity(data) + if entry is None: + results.append( + DependencyCheckResult( + name, + "unresolved", + None, + None, + manifest["version"], + installed, + "declared, and resolves, but is not in the lock yet", + ) + ) + continue + results.append( + DependencyCheckResult( + name, + "current" if installed == lock_integrity else "drifted", + lock_version, + lock_integrity, + manifest["version"], + installed, + None, + ) + ) + return results + + +def format_check_line(result: DependencyCheckResult) -> str: + """One report line per dependency (the same shape as the TypeScript ``formatCheckLine``).""" + lock_desc = ( + f"{result.lock_version or '(none)'} " + f"({hash8(result.lock_integrity) if result.lock_integrity else 'n/a'})" + ) + installed_desc = ( + f"{result.installed_version or '(none)'} " + f"({hash8(result.installed_integrity) if result.installed_integrity else 'n/a'})" + ) + if result.status == "current": + return f"{result.name}: current — {lock_desc}" + if result.status == "drifted": + return ( + f"{result.name}: drifted — lock {lock_desc}, installed {installed_desc}; " + "run meta deps sync and review the artifact diff" + ) + return ( + f"{result.name}: unresolved — {result.detail or 'could not resolve the dependency'}; " + "run meta deps sync once the dependency is reachable" + ) + + +def summary_line(command: str, failing: int, total: int) -> str: + """The failure summary both commands print, naming the one code.""" + plural = "y" if total == 1 else "ies" + return ( + f"{command}: {failing} of {total} dependenc{plural} drifted or unresolved " + f"({ERR_DEPENDENCY_UPSTREAM_DRIFT.value})." + ) + + +def prove_locked_set_loads(config_dir: Path, specs: list[dict[str, str]]) -> str | None: + """Matching hashes only prove the snapshot is the locked one, not that it still loads. Verify + the snapshot against the lock (a stale one is ``ERR_DEPENDENCY_SNAPSHOT_STALE``) and load the + locked artifacts together, so a collision between two dependencies or an artifact that does not + load is reported. The project's own metadata is not loaded here (``gen`` / ``verify`` are the + full check). Returns ``None`` when it loads, else the reason.""" + from metaobjects import MetaDataLoader + from metaobjects.loader.sources import FileSource + + try: + resolved = verify_snapshot(config_dir, specs, read_lock(config_dir)) + except ParseError as exc: + return f"{exc.code}: {exc}" + if not resolved: + return None + result = MetaDataLoader().load( + [FileSource(Path(d.artifact_path), id=d.source_id) for d in resolved] + ) + if result.errors: + return "; ".join(f"{e.code}: {e.message}" for e in result.errors) + return None diff --git a/server/python/tests/codegen/test_cli_deps_check.py b/server/python/tests/codegen/test_cli_deps_check.py new file mode 100644 index 000000000..b5c4aa843 --- /dev/null +++ b/server/python/tests/codegen/test_cli_deps_check.py @@ -0,0 +1,165 @@ +"""FR-023 Phase 2 (S2) — the Python CLI's `deps check` and `verify --deps`. + +The three states themselves are the shared corpus's `expectCheck` arm +(`tests/conformance/test_dependency_conformance.py`); these tests pin the CLI around it: exit +codes, the named error code, that `--deps` is opt-in, and that a check never writes. +""" +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest + +from metaobjects.cli import main + +_CORPUS = Path(__file__).resolve().parents[4] / "fixtures" / "dependency-conformance" +_CASES = {c["name"]: c for c in json.loads((_CORPUS / "cases.json").read_text())["cases"]} + + +def _materialize(name: str, root: Path) -> Path: + """Write one corpus case's tree under *root*; returns the project directory.""" + case = _CASES[name] + for rel, content in case["tree"].items(): + p = root / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content) + for rel, corpus_rel in case.get("treeFiles", {}).items(): + p = root / rel + p.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(_CORPUS / corpus_rel, p) + project = root / case.get("resolveFrom", ".") + meta = project / ".metaobjects" + meta.mkdir(parents=True, exist_ok=True) + (meta / "config.json").write_text(json.dumps(case["config"], indent=2)) + if "lock" in case: + (meta / "deps.lock.json").write_text(json.dumps(case["lock"], indent=2)) + return project + + +def _sync_snapshot(project: Path) -> None: + """Place the committed snapshot `meta deps sync` would have written for the v1 artifact.""" + dest = project / ".metaobjects" / "deps" / "acme-common" + dest.mkdir(parents=True) + shutil.copyfile(_CORPUS / "artifacts" / "acme-common-v1.json", dest / "acme-common.metaobjects.json") + + +def _run(project: Path, argv: list[str], monkeypatch: pytest.MonkeyPatch) -> int: + monkeypatch.chdir(project) + return main(argv) + + +def test_deps_check_current_exits_zero( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + project = _materialize("check-a-matching-artifact-is-current", tmp_path) + _sync_snapshot(project) + assert _run(project, ["deps", "check"], monkeypatch) == 0 + out = capsys.readouterr().out + assert "acme-common: current — 1.0.0 (10fbf886)" in out + + +def test_deps_check_drifted_exits_one_and_names_the_code( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + project = _materialize("check-a-newer-artifact-than-the-lock-has-drifted", tmp_path) + assert _run(project, ["deps", "check"], monkeypatch) == 1 + err = capsys.readouterr().err + assert "acme-common: drifted — lock 1.0.0 (10fbf886), installed 1.1.0 (fa00b9f3)" in err + assert "run meta deps sync" in err + assert "ERR_DEPENDENCY_UPSTREAM_DRIFT" in err + + +def test_deps_check_unresolved_exits_one( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + project = _materialize("check-a-missing-path-is-unresolved", tmp_path) + assert _run(project, ["deps", "check"], monkeypatch) == 1 + err = capsys.readouterr().err + assert "acme-common: unresolved" in err + assert "ERR_DEPENDENCY_UPSTREAM_DRIFT" in err + + +def test_deps_check_never_writes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + project = _materialize("check-a-newer-artifact-than-the-lock-has-drifted", tmp_path) + before = sorted(p.relative_to(tmp_path).as_posix() for p in tmp_path.rglob("*") if p.is_file()) + lock_before = (project / ".metaobjects" / "deps.lock.json").read_bytes() + _run(project, ["deps", "check"], monkeypatch) + assert sorted(p.relative_to(tmp_path).as_posix() for p in tmp_path.rglob("*") if p.is_file()) == before + assert (project / ".metaobjects" / "deps.lock.json").read_bytes() == lock_before + + +def test_deps_check_a_corrupt_lock_exits_two( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + project = _materialize("check-a-matching-artifact-is-current", tmp_path) + _sync_snapshot(project) + (project / ".metaobjects" / "deps.lock.json").write_text("not json") + assert _run(project, ["deps", "check"], monkeypatch) == 2 + assert "ERR_DEPENDENCY_SNAPSHOT_STALE" in capsys.readouterr().err + + +def test_deps_check_with_nothing_declared_exits_zero( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + (tmp_path / ".metaobjects").mkdir() + (tmp_path / ".metaobjects" / "config.json").write_text('{"schema_version": 1}') + assert _run(tmp_path, ["deps", "check"], monkeypatch) == 0 + assert "no dependencies declared" in capsys.readouterr().out + + +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.""" + (tmp_path / ".metaobjects").mkdir() + (tmp_path / ".metaobjects" / "config.json").write_text( + json.dumps({"schema_version": 1, "dependencies": [{"name": "acme-common", "npm": "@acme/common"}]}) + ) + assert _run(tmp_path, ["deps", "check"], monkeypatch) == 1 + err = capsys.readouterr().err + assert "unresolved" 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: + """Hashes match the lock but no snapshot was ever synced: the set cannot load.""" + project = _materialize("check-a-matching-artifact-is-current", tmp_path) + assert _run(project, ["deps", "check"], monkeypatch) == 1 + assert "does not load" in capsys.readouterr().err + + +def test_verify_deps_passes_when_current( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """`verify --deps` is the hash comparison (the same as TS's gate). Every `verify` also loads + the model, which needs the synced snapshot.""" + project = _materialize("check-a-matching-artifact-is-current", tmp_path) + _sync_snapshot(project) + assert _run(project, ["verify", "--deps"], monkeypatch) == 0 + assert "every dependency's installed artifact matches the lock" in capsys.readouterr().out + + +def test_verify_deps_drift_exits_one( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + project = _materialize("check-a-newer-artifact-than-the-lock-has-drifted", tmp_path) + assert _run(project, ["verify", "--deps"], monkeypatch) == 1 + assert "ERR_DEPENDENCY_UPSTREAM_DRIFT" in capsys.readouterr().err + + +def test_deps_check_a_non_utf8_manifest_is_unresolved_not_a_crash( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + project = _materialize("check-a-matching-artifact-is-current", tmp_path) + manifests = list(tmp_path.rglob("metaobjects.pkg.json")) + assert manifests + for manifest in manifests: + manifest.write_bytes(b"\xff\xfe\x00not utf-8") + assert _run(project, ["deps", "check"], monkeypatch) == 1 + err = capsys.readouterr().err + assert "acme-common: unresolved" in err + assert "is not valid JSON" in err diff --git a/server/python/tests/conformance/test_dependency_conformance.py b/server/python/tests/conformance/test_dependency_conformance.py index 68d17734b..197a06b1a 100644 --- a/server/python/tests/conformance/test_dependency_conformance.py +++ b/server/python/tests/conformance/test_dependency_conformance.py @@ -17,7 +17,9 @@ import pytest from metaobjects.cli import _load_collection_result -from metaobjects.config.dependencies import LOCK_FILE +from metaobjects.config.dependencies import LOCK_FILE, read_lock +from metaobjects.config.dependency_check import check_dependencies +from metaobjects.config.neutral_config import read_neutral_config from metaobjects.config.source_resolver import resolve_collection_full from metaobjects.errors import ParseError from metaobjects.shared.base_types import TYPE_OBJECT @@ -26,9 +28,11 @@ Path(__file__).resolve().parents[4] / "fixtures" / "dependency-conformance" ) _CORPUS = _CORPUS_DIR / "cases.json" -# The resolution arm only: a case carrying `expectCheck` belongs to the check arm, which -# Python does not ship yet (it has no `deps check` yet; Phase 2, #482, adds it). -_CASES = [c for c in json.loads(_CORPUS.read_text())["cases"] if "expectCheck" not in c] +# The resolution arm: a case carrying `expectCheck` belongs to the check arm, run at the end +# of this file. +_ALL_CASES = json.loads(_CORPUS.read_text())["cases"] +_CASES = [c for c in _ALL_CASES if "expectCheck" not in c] +_CHECK_CASES = [c for c in _ALL_CASES if "expectCheck" in c] def test_corpus_is_non_empty() -> None: """A silent zero-case run is a failed gate, not a pass. @@ -150,3 +154,19 @@ def test_dependency_conformance_case(case: dict, tmp_path: Path) -> None: (first.source,) if first.source else () ) assert list(files) == case["expectErrorFiles"] + + +def test_check_corpus_is_non_empty() -> None: + """A silent zero-case run is a failed gate (same guard as the resolution arm).""" + assert len(_CHECK_CASES) > 0 + + +@pytest.mark.parametrize("case", _CHECK_CASES, ids=[c["name"] for c in _CHECK_CASES]) +def test_dependency_check_case(case: dict, tmp_path: Path) -> None: + """The `expectCheck` arm: the status of every declared dependency, by name. The check is a + pure function of the config, the lock and the publisher's directory — no loader, no snapshot.""" + resolve_dir = _materialize(case, tmp_path) + cfg = read_neutral_config(resolve_dir) + assert cfg is not None + results = check_dependencies(resolve_dir, cfg.dependencies, read_lock(resolve_dir)) + assert {r.name: r.status for r in results} == case["expectCheck"]