From dcd655942173622237084d62a8a95485a561b31b Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 01:59:45 -0400 Subject: [PATCH 1/4] feat(csharp): read the generated-code directory from .metaobjects/config.json A plain `dotnet meta verify` skipped the codegen gate unless --out was passed. `csharp.outDir` now names the directory; gen writes there, verify diffs against it, and --out still wins. --- CHANGELOG.md | 14 +++++- .../metaobjects-codegen/references/csharp.md | 2 +- .../references/capability-sheet.md | 2 +- docs/features/cli.md | 8 ++-- docs/ports/csharp.md | 15 +++++- .../MetaObjects.Cli.Tests/CliSurfaceTests.cs | 37 ++++++++++++++- .../OneGeneratorListTests.cs | 40 ++++++++++++++++ server/csharp/MetaObjects.Cli/Program.cs | 6 ++- .../csharp/MetaObjects.Codegen/CodegenCli.cs | 14 ++++++ .../NeutralConfigTests.cs | 46 +++++++++++++++++++ .../MetaObjects/Config/MetadataLocation.cs | 12 +++-- .../MetaObjects/Config/NeutralConfig.cs | 38 ++++++++++++++- server/typescript/packages/sdk/src/config.ts | 6 +++ .../packages/sdk/test/config.test.ts | 9 ++++ 14 files changed, 232 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e77df871..c74c54af0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,17 @@ here.** ### Added +- **C# reads its generated-code directory from `.metaobjects/config.json`, so a plain + `dotnet meta verify` runs the codegen gate.** Set `"csharp": { "outDir": "Generated" }` + (relative to the directory holding `.metaobjects/`). `dotnet meta gen` writes there and + `dotnet meta verify` (bare or `--codegen`) diffs against it, so the two cannot disagree; an + explicit `--out ` still works and wins. Before this a bare `dotnet meta verify` skipped + the codegen gate unless `--out` was passed, and exited 0 on stale generated code. A project + with no setting still gets the skip note, which now names the setting. A present `csharp` + block that is not an object, has an unknown key, or has an empty or non-string `outDir` is + refused. The other ports ignore the block; the TypeScript config reader declares the key so + the shared file stays free of unknown-key warnings. + - **Report export: `GET /?format=csv|json|xml` on a served report, in all five ports.** A new standalone `report-export` catalog generator (importable as shipped, ejectable in every port, drift-checked by `verify --codegen`, never implied by a library) generates one export @@ -47,8 +58,7 @@ here.** subverb still runs only the gates named. A gate the run has no configuration for is skipped with a one-line note instead of failing: `meta verify` without a `metaobjects.config.ts` (a config that exists but fails to load exits 2), `metaobjects verify` with no `metaobjects.config.yaml` and no `--prompts`, `dotnet meta verify` - without `--out ` and `codegen/Generators.cs` (C# has no config-resolved output directory: - pass `--out ` in CI to get the codegen gate), `mvn metaobjects:verify` without + without an output directory (`csharp.outDir`, see Added) and `codegen/Generators.cs`, `mvn metaobjects:verify` without `-Dmeta.verify.templateRoot`. Pinned per port by tests that run a bare verify over drifted input. **Behaviour change:** a CI step that ran a bare `verify` and relied on the narrower default can now go red where it was green, because the gate it never ran now runs. Node diff --git a/agent-context/skills/metaobjects-codegen/references/csharp.md b/agent-context/skills/metaobjects-codegen/references/csharp.md index 6b20b40efd..e782303a8d 100644 --- a/agent-context/skills/metaobjects-codegen/references/csharp.md +++ b/agent-context/skills/metaobjects-codegen/references/csharp.md @@ -91,7 +91,7 @@ build output (`codegen/bin/`, `codegen/obj/`) in `.gitignore`. A bare `dotnet meta verify` runs `--templates` (the FR-004 prompt/template drift gate, see the prompts reference) and `--codegen` (the codegen-output drift gate; -skipped with a note unless `--out ` and `codegen/Generators.cs` are present). +skipped with a note unless an output directory, `csharp.outDir` in `.metaobjects/config.json` or `--out `, and `codegen/Generators.cs` are present). **Schema migration + live-DB drift are NOT `dotnet meta`** — they run through the Node `meta` tool (see the migration reference). diff --git a/agent-context/skills/metaobjects-fit-assessment/references/capability-sheet.md b/agent-context/skills/metaobjects-fit-assessment/references/capability-sheet.md index 2f65477162..5944aa65f3 100644 --- a/agent-context/skills/metaobjects-fit-assessment/references/capability-sheet.md +++ b/agent-context/skills/metaobjects-fit-assessment/references/capability-sheet.md @@ -27,7 +27,7 @@ building block. Every generator is a reference helper an adopter selects, ejects (`server/typescript/packages/codegen-ts/src/db-emitting.ts`). - **The drift gate per port** — regenerate into a temp tree and diff: `meta verify --codegen` (TS); `mvn metaobjects:verify` (Java/Kotlin; `codegen` is the default mode); `dotnet meta - verify --codegen` (C#; bare `verify` runs it when `--out` and `codegen/Generators.cs` are present); + verify --codegen` (C#; bare `verify` runs it when `csharp.outDir` (or `--out`) and `codegen/Generators.cs` are present); `metaobjects verify` (Python; `--codegen` is part of the default). Source: `docs/features/cli.md`. - **Generated code compiles**: a codegen-compile gate compiles every port's emitted model tier with its real compiler in CI (`docs/CONFORMANCE.md`, "Split coverage"). diff --git a/docs/features/cli.md b/docs/features/cli.md index 963644148c..cbea3910a8 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -68,7 +68,8 @@ Rules of the contract: note, never failed: Node `meta` needs a `metaobjects.config.ts` for codegen (a config that exists but fails to load is not a missing config: it exits 2 with the load error); `metaobjects verify` needs a `metaobjects.config.yaml` or `--prompts ` for - templates; `dotnet meta verify` needs `--out ` and `codegen/Generators.cs` + templates; `dotnet meta verify` needs an output directory (`csharp.outDir` in + `.metaobjects/config.json`, or `--out `) and `codegen/Generators.cs` for codegen; `mvn metaobjects:verify` needs `-Dmeta.verify.templateRoot` for templates. Naming a subverb still runs only the gates named. Bare `verify` also prints a one-line note advertising the explicit subverbs. *Before this, a bare @@ -212,8 +213,9 @@ meta`** port likewise ships the codegen-side subverbs: `verify --templates` (its historical template/prompt drift gate), `verify --codegen` (regenerate the configured generators to a temp dir and diff against the committed `--out` tree, never touching it), and a **clean `--db` rejection -(exit 2)** — bare `dotnet meta verify` runs `--templates` and, when `--out` and -`codegen/Generators.cs` are present, `--codegen` (C# has no config-resolved output dir, so a bare run without `--out ` skips the codegen gate with a note — pass `--out` in CI), and prints the subverb note. The **Java/Kotlin `mvn metaobjects:verify`** port expresses the same vocabulary as a +(exit 2)** — bare `dotnet meta verify` runs `--templates` and, when an output +directory (`csharp.outDir` in `.metaobjects/config.json`, which `gen` also writes to; `--out ` wins) and +`codegen/Generators.cs` are present, `--codegen` (a bare run with no output directory skips the codegen gate with a note that names the setting), and prints the subverb note. The **Java/Kotlin `mvn metaobjects:verify`** port expresses the same vocabulary as a `mode` parameter (Maven goals are parameter-driven, not flag-driven). With `mode` unset the goal runs both gates below, skipping the template gate with a note when no `templateRoot` is set. `-Dmeta.verify.mode=codegen` diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index b372d6bbe1..5530e9a232 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -181,8 +181,19 @@ dotnet meta verify ./metaobjects --codegen --out ./Generated is given does not exist. A bare `dotnet meta verify ./metaobjects` runs that templates gate against `prompts/` when it exists, else `templates/`, and fails the same way when neither exists. It also runs the codegen gate when `--out ` and `codegen/Generators.cs` are -present, and skips it with a note otherwise (C# has no config-resolved output directory, so -pass `--out` in CI); name both subverbs to require both. +present, and skips it with a note otherwise; name both subverbs to require both. + +The output directory is a project setting, so `gen` and `verify` cannot disagree about it. +Declare it once in `.metaobjects/config.json`, relative to the directory holding `.metaobjects/`: + +```json +{ "schema_version": 1, "csharp": { "outDir": "Generated" } } +``` + +With that, `dotnet meta gen` writes there, `dotnet meta verify --codegen` diffs against it, and +a plain `dotnet meta verify` in CI runs the codegen gate with no flags. `--out ` still works +and wins over the config. A project that sets neither gets the skip note, which names the setting. +The other ports ignore the `csharp` block. `dotnet meta gen --list` is the catalog, and it shows what each generator requires. `routes` requires `entity`, `db-context` and `filter-allowlist`, because the handlers it emits diff --git a/server/csharp/MetaObjects.Cli.Tests/CliSurfaceTests.cs b/server/csharp/MetaObjects.Cli.Tests/CliSurfaceTests.cs index aaad2fe2af..68892521e2 100644 --- a/server/csharp/MetaObjects.Cli.Tests/CliSurfaceTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/CliSurfaceTests.cs @@ -128,7 +128,8 @@ public void A_bare_verify_skips_the_codegen_gate_with_a_note_when_it_has_no_out_ Directory.CreateDirectory(Path.Combine(_tmp, "prompts")); var (exit, stdout, stderr) = CliProcess.Run(_tmp, "verify", model); Assert.True(exit == 0, $"exit={exit}\nstderr={stderr}"); - Assert.Contains("codegen gate skipped: pass --out ", stdout); + Assert.Contains("codegen gate skipped: set \"csharp\": { \"outDir\"", stdout); + Assert.Contains(".metaobjects/config.json", stdout); } [Fact] @@ -140,4 +141,38 @@ public void A_bare_verify_skips_the_codegen_gate_with_a_note_when_there_is_no_ge Assert.True(exit == 0, $"exit={exit}\nstderr={stderr}"); Assert.Contains("codegen gate skipped: no codegen/Generators.cs", stdout); } + + private void WriteOutDirConfig(string outDir) + { + Directory.CreateDirectory(Path.Combine(_tmp, ".metaobjects")); + File.WriteAllText(Path.Combine(_tmp, ".metaobjects", "config.json"), + "{ \"schema_version\": 1, \"csharp\": { \"outDir\": \"" + outDir + "\" } }"); + } + + [Fact] + public void A_bare_verify_with_a_configured_out_dir_reaches_the_codegen_gate() + { + // No --out: the directory comes from csharp.outDir, so the gate is attempted (and refuses + // here only because the project has a generator list but no codegen project to compile it). + var model = WriteBlogModel(); + Directory.CreateDirectory(Path.Combine(_tmp, "prompts")); + Directory.CreateDirectory(Path.Combine(_tmp, "codegen")); + File.WriteAllText(Path.Combine(_tmp, "codegen", "Generators.cs"), "// list"); + WriteOutDirConfig("generated"); + var (exit, stdout, stderr) = CliProcess.Run(_tmp, "verify", model); + Assert.Equal(2, exit); + Assert.DoesNotContain("codegen gate skipped", stdout); + Assert.Contains("no codegen/Codegen.csproj", stderr); + } + + [Fact] + public void A_bare_verify_with_a_configured_out_dir_and_no_generator_list_says_what_is_missing() + { + var model = WriteBlogModel(); + Directory.CreateDirectory(Path.Combine(_tmp, "prompts")); + WriteOutDirConfig("generated"); + var (exit, stdout, stderr) = CliProcess.Run(_tmp, "verify", model); + Assert.True(exit == 0, $"exit={exit}\nstderr={stderr}"); + Assert.Contains("codegen gate skipped: no codegen/Generators.cs", stdout); + } } diff --git a/server/csharp/MetaObjects.Cli.Tests/OneGeneratorListTests.cs b/server/csharp/MetaObjects.Cli.Tests/OneGeneratorListTests.cs index c6fd092aa4..9d55499853 100644 --- a/server/csharp/MetaObjects.Cli.Tests/OneGeneratorListTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/OneGeneratorListTests.cs @@ -434,6 +434,46 @@ public void A_requirement_tests_entry_with_its_own_directory_is_written_and_chec Assert.Contains("Requirements_acme_Tests.g.cs", drift.Stderr); } + // ---- the project's csharp.outDir drives gen and verify; --out wins --------------------------- + + [Fact] + public void A_configured_out_dir_is_where_gen_writes_and_verify_checks_and_out_overrides_it() + { + Directory.CreateDirectory(Path.Combine(_tmp, ".metaobjects")); + File.WriteAllText(Path.Combine(_tmp, ".metaobjects", "config.json"), + """{ "schema_version": 1, "csharp": { "outDir": "generated" } }"""); + IReadOnlyList declared = [new EntityGenerator()]; + + var gen = OwnedRunner.Run(_tmp, declared, "gen"); + Assert.True(gen.ExitCode == 0, $"exit={gen.ExitCode}\nstdout={gen.Stdout}\nstderr={gen.Stderr}"); + Assert.Equal(["Subscriber.g.cs"], FilesUnder(OutDir)); + + var clean = OwnedRunner.Run(_tmp, declared, "verify", "--codegen"); + Assert.True(clean.ExitCode == 0, $"exit={clean.ExitCode}\nstdout={clean.Stdout}\nstderr={clean.Stderr}"); + + File.AppendAllText(Path.Combine(OutDir, "Subscriber.g.cs"), "// edited\n"); + var drift = OwnedRunner.Run(_tmp, declared, "verify", "--codegen"); + Assert.Equal(1, drift.ExitCode); + Assert.Contains("Subscriber.g.cs", drift.Stderr); + + // --out beats the config: gen writes there and leaves the configured directory alone. + var other = Path.Combine(_tmp, "elsewhere"); + var genOther = OwnedRunner.Run(_tmp, declared, "gen", "--out", other); + Assert.True(genOther.ExitCode == 0, $"exit={genOther.ExitCode}\n{genOther.Stdout}{genOther.Stderr}"); + Assert.Equal(["Subscriber.g.cs"], FilesUnder(other)); + var cleanOther = OwnedRunner.Run(_tmp, declared, "verify", "--codegen", "--out", other); + Assert.True(cleanOther.ExitCode == 0, $"exit={cleanOther.ExitCode}\n{cleanOther.Stdout}{cleanOther.Stderr}"); + } + + [Fact] + public void Without_a_configured_out_dir_or_out_flag_gen_asks_for_the_setting() + { + var gen = OwnedRunner.Run(_tmp, [new EntityGenerator()], "gen"); + Assert.Equal(2, gen.ExitCode); + Assert.Contains("csharp", gen.Stderr); + Assert.Contains("outDir", gen.Stderr); + } + // ---- a narrowed verify checks only what the narrowed run produces --------------------------- [Fact] diff --git a/server/csharp/MetaObjects.Cli/Program.cs b/server/csharp/MetaObjects.Cli/Program.cs index 02297446a0..488bc519c9 100644 --- a/server/csharp/MetaObjects.Cli/Program.cs +++ b/server/csharp/MetaObjects.Cli/Program.cs @@ -41,6 +41,8 @@ " root and must exist; with none it reads\n" + " prompts/ if present, else templates/\n" + " --codegen regen-to-temp vs committed --out\n" + + " (--out defaults to csharp.outDir in\n" + + " .metaobjects/config.json, as for gen)\n" + " --namespace codegen regen namespace; inferred\n" + " from the committed --out when omitted\n" + " --db NOT supported in C# (migrate engine)\n" + @@ -424,6 +426,8 @@ static int RunVerify(string[] rest) // 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); + // --out wins; otherwise the project's declared `csharp.outDir`, the directory gen writes. + outDir ??= resolvedMeta.OutDir; // The templates gate needs a root, and it must exist: a typo'd or absent directory used // to verify clean, because nothing in an empty root can drift. With none named (bare @@ -466,7 +470,7 @@ static int RunVerify(string[] rest) if (bare) { if (outDir is null) - Console.WriteLine("dotnet meta verify — codegen gate skipped: pass --out (the committed generated output to diff against) and have codegen/Generators.cs to run it"); + Console.WriteLine("dotnet meta verify — codegen gate skipped: set \"csharp\": { \"outDir\": \"\" } in .metaobjects/config.json (or pass --out ) naming the committed generated output to diff against, and have codegen/Generators.cs to run it"); else if (!CodegenHandoff.HasGeneratorList(projectRoot)) Console.WriteLine("dotnet meta verify — codegen gate skipped: no codegen/Generators.cs (the generator list) to regenerate from"); else diff --git a/server/csharp/MetaObjects.Codegen/CodegenCli.cs b/server/csharp/MetaObjects.Codegen/CodegenCli.cs index 904ca5bebc..eaa7f3d259 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenCli.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenCli.cs @@ -596,6 +596,14 @@ public static OwnedArgs Parse(string[] rest, string command, string[] valueFlags /// Resolve the metadata location through the same ladder as dotnet meta, /// or print why not. null means the caller exits 2. + /// The project's declared csharp.outDir, or null when it declares none or + /// the location cannot be resolved (the caller then reports usage, which names the setting). + public static string? ConfiguredOutDir(string? metadataDir) + { + try { return MetadataLocation.Resolve(metadataDir, Directory.GetCurrentDirectory()).OutDir; } + catch (Exception e) when (e is MetaModelException or MetadataLocationException) { return null; } + } + private static ResolvedMetadata? ResolveOrReport(string? metadataDir) { try @@ -654,9 +662,13 @@ private static int RunGenArgs(string[] rest, IReadOnlyList declared) } // Usage-first: a missing --out is a plain usage error, and must win over the metadata // ladder's own possible ERR_COLLECTION_NOT_FOUND below. + // An omitted --out falls back to the project's declared `csharp.outDir`, the same + // directory `dotnet meta verify` diffs, so gen and verify cannot disagree. + if (a.OutDir is null && ConfiguredOutDir(a.MetadataDir) is { } configuredOut) a.OutDir = configuredOut; if (a.OutDir is null) { Console.Error.WriteLine("usage: dotnet meta gen [] --out [--namespace ] [--generators ] [...]"); + Console.Error.WriteLine(" (or set \"csharp\": { \"outDir\": \"\" } in .metaobjects/config.json)"); return 2; } if (ResolveOrReport(a.MetadataDir) is not { } meta) return 2; @@ -839,9 +851,11 @@ private static int RunVerifyArgs(string[] rest, IReadOnlyList declar Console.Error.WriteLine(ColumnNamingFlagRemoved(a.ColumnNamingRaw)); return 2; } + if (a.OutDir is null && ConfiguredOutDir(a.MetadataDir) is { } configuredOut) a.OutDir = configuredOut; if (a.OutDir is null) { Console.Error.WriteLine("usage: verify --codegen [] --out [--namespace ] [--generators ]"); + Console.Error.WriteLine(" (or set \"csharp\": { \"outDir\": \"\" } in .metaobjects/config.json)"); return 2; } if (ResolveOrReport(a.MetadataDir) is not { } meta) return 2; diff --git a/server/csharp/MetaObjects.Conformance.Tests/NeutralConfigTests.cs b/server/csharp/MetaObjects.Conformance.Tests/NeutralConfigTests.cs index 81823c55e1..42a997e673 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/NeutralConfigTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/NeutralConfigTests.cs @@ -74,4 +74,50 @@ public void SchemaVersionNonIntegral_RaisesCodedError_NotFormatException() Directory.Delete(dir, recursive: true); } } + + [Fact] + public void CSharpOutDir_IsResolvedAgainstTheDirectoryHoldingTheConfig() + { + var dir = WriteConfig("""{ "schema_version": 1, "csharp": { "outDir": "src/Generated" } }"""); + try + { + Assert.Equal(Path.GetFullPath(Path.Combine(dir, "src", "Generated")), NeutralConfig.Read(dir)!.OutDir); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void CSharpOutDir_IsNullWhenAbsent() + { + var dir = WriteConfig("""{ "schema_version": 1 }"""); + try + { + Assert.Null(NeutralConfig.Read(dir)!.OutDir); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Theory] + [InlineData("""{ "schema_version": 1, "csharp": "src" }""")] + [InlineData("""{ "schema_version": 1, "csharp": { "outDir": "" } }""")] + [InlineData("""{ "schema_version": 1, "csharp": { "outDir": 3 } }""")] + [InlineData("""{ "schema_version": 1, "csharp": { "outdir": "x" } }""")] + public void CSharpBlock_WrongShaped_Raises(string json) + { + var dir = WriteConfig(json); + try + { + Assert.ThrowsAny(() => NeutralConfig.Read(dir)); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } } diff --git a/server/csharp/MetaObjects/Config/MetadataLocation.cs b/server/csharp/MetaObjects/Config/MetadataLocation.cs index 856dccd5fa..9af9afe864 100644 --- a/server/csharp/MetaObjects/Config/MetadataLocation.cs +++ b/server/csharp/MetaObjects/Config/MetadataLocation.cs @@ -30,9 +30,13 @@ namespace MetaObjects.Config; /// ADR-0019 Amendment 2 — the project's wire-format setting from the same file (null: the /// native default). sets it on the loaded root for codegen. /// +/// +/// The generated-code directory the project declared (csharp.outDir in the same +/// file), absolute; null when it declared none. An explicit --out takes precedence. +/// public sealed record ResolvedMetadata( string Directory, IReadOnlyList? Files, IReadOnlyList Libraries, PhysicalNaming Naming, - WireFormat? Wire = null) + WireFormat? Wire = null, string? OutDir = null) { /// Load this metadata, with the project's libraries, and set the loaded /// root's from (ADR-0058) and its @@ -94,7 +98,7 @@ public static ResolvedMetadata Resolve(string? metadataDir, string cwd) var explicitCfg = ConfigFor(metadataDir); var explicitLibs = explicitCfg?.Libraries ?? Array.Empty(); var explicitNaming = explicitCfg?.Naming ?? PhysicalNaming.Default; - return new ResolvedMetadata(metadataDir, null, explicitLibs, explicitNaming, explicitCfg?.Wire); + return new ResolvedMetadata(metadataDir, null, explicitLibs, explicitNaming, explicitCfg?.Wire, explicitCfg?.OutDir); } var cfg = NeutralConfig.Read(cwd); @@ -108,7 +112,7 @@ public static ResolvedMetadata Resolve(string? metadataDir, string cwd) // No declared sources: resolve the DEFAULT directory through the same ladder // the shared conformance corpus gates (ERR_COLLECTION_NOT_FOUND when absent). var defaultFiles = SourceResolver.ResolveCollection(cwd); - return new ResolvedMetadata(Path.Combine(cwd, NeutralConfig.DefaultMetadataDir), defaultFiles, libraries, naming, wire); + return new ResolvedMetadata(Path.Combine(cwd, NeutralConfig.DefaultMetadataDir), defaultFiles, libraries, naming, wire, cfg?.OutDir); } if (specs.Count > 1) @@ -138,6 +142,6 @@ public static ResolvedMetadata Resolve(string? metadataDir, string cwd) "\"sources\" at the file's containing directory."); } - return new ResolvedMetadata(resolved, files, libraries, naming, wire); + return new ResolvedMetadata(resolved, files, libraries, naming, wire, cfg?.OutDir); } } diff --git a/server/csharp/MetaObjects/Config/NeutralConfig.cs b/server/csharp/MetaObjects/Config/NeutralConfig.cs index c5504ba62f..893b8ba8f6 100644 --- a/server/csharp/MetaObjects/Config/NeutralConfig.cs +++ b/server/csharp/MetaObjects/Config/NeutralConfig.cs @@ -19,6 +19,8 @@ // or a non-object `naming` is refused (ERR_BAD_ATTR_VALUE), the same "present but // wrong-shaped raises" rule `sources`/`libraries` already follow. // +// `csharp.outDir` is the C#-scoped generated-code directory; the other ports ignore the block. +// // `wire` (ADR-0019 Amendment 2) is read here too — the output form of a generated REST surface // (`decimals`, `nullMembers`), refused exactly as `naming` is. using System.Text.Json; @@ -60,10 +62,16 @@ public sealed class NeutralConfig /// absent key at its native default. Never null. public WireFormat Wire { get; } + /// The C# port's generated-code directory (`csharp.outDir` in config.json), resolved + /// against the directory holding `.metaobjects/` so it reads the same from any working + /// directory. Null when the key is absent. `--out` on the command line wins over it. + public string? OutDir { get; } + private NeutralConfig( IReadOnlyList> sources, IReadOnlyList libraries, - PhysicalNaming naming, WireFormat wire) + PhysicalNaming naming, WireFormat wire, string? outDir) { + OutDir = outDir; Sources = sources; Libraries = libraries; Naming = naming; @@ -220,8 +228,34 @@ private NeutralConfig( // Unknown top-level keys (including `scope`/`migrate`) are IGNORED by // design — see the file header. - return new NeutralConfig(specs, libraries, new PhysicalNaming(columns, tables), ReadWire(root, path)); + return new NeutralConfig(specs, libraries, new PhysicalNaming(columns, tables), ReadWire(root, path), + ReadOutDir(root, path, configDir)); + } + } + + /// The `csharp` block: the one C#-scoped setting, `outDir`, where `dotnet meta gen` writes + /// and `dotnet meta verify` diffs. The other ports ignore the block, as they ignore any key + /// they do not own. An unknown key, a non-string or empty `outDir`, or a non-object block is + /// refused, the same rule as `naming`. + private static string? ReadOutDir(JsonElement root, string path, string configDir) + { + if (!root.TryGetProperty("csharp", out var block)) return null; + if (block.ValueKind != JsonValueKind.Object) + throw new MetaModelException($"{path}: \"csharp\" must be an object", ErrorCode.ERR_BAD_ATTR_VALUE); + + string? outDir = null; + foreach (var p in block.EnumerateObject()) + { + if (p.Name != "outDir") + throw new MetaModelException( + $"{path}: \"csharp\" has an unknown key \"{p.Name}\" (expected \"outDir\")", + ErrorCode.ERR_BAD_ATTR_VALUE); + if (p.Value.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(p.Value.GetString())) + throw new MetaModelException( + $"{path}: \"csharp.outDir\" must be a non-empty string", ErrorCode.ERR_BAD_ATTR_VALUE); + outDir = Path.GetFullPath(p.Value.GetString()!, Path.GetFullPath(configDir)); } + return outDir; } /// The `wire` block (ADR-0019 Amendment 2): an unknown key, an unknown value, or a diff --git a/server/typescript/packages/sdk/src/config.ts b/server/typescript/packages/sdk/src/config.ts index 1b8f429a91..d77887016b 100644 --- a/server/typescript/packages/sdk/src/config.ts +++ b/server/typescript/packages/sdk/src/config.ts @@ -226,6 +226,12 @@ export const ConfigSchema = z.object({ : iss.code === "invalid_type" ? `"wire" must be an object` : undefined, }) .optional(), + /** + * C#-scoped settings. `outDir` is where `dotnet meta gen` writes and a bare `dotnet meta + * verify` diffs, relative to the directory holding `.metaobjects/`. Only the C# port reads + * it; it is declared here so the shared file validates and this reader does not warn on it. + */ + csharp: z.strictObject({ outDir: z.string().min(1).optional() }).optional(), }).strict(); export type Config = z.infer; diff --git a/server/typescript/packages/sdk/test/config.test.ts b/server/typescript/packages/sdk/test/config.test.ts index 7826d100fa..0a98578b60 100644 --- a/server/typescript/packages/sdk/test/config.test.ts +++ b/server/typescript/packages/sdk/test/config.test.ts @@ -266,3 +266,12 @@ describe("ConfigSchema — dependencies (FR-023)", () => { }); test("dependencies: absent means []", () => { expect(ConfigSchema.parse({ schema_version: 1 }).dependencies).toEqual([]); }); }); + +describe("csharp.outDir (the C# port's generated-code directory)", () => { + test("is a known, validated key", () => { + const ok = ConfigSchema.safeParse({ schema_version: 1, csharp: { outDir: "src/Generated" } }); + expect(ok.success).toBe(true); + expect(ConfigSchema.safeParse({ schema_version: 1, csharp: { outdir: "x" } }).success).toBe(false); + expect(ConfigSchema.safeParse({ schema_version: 1, csharp: { outDir: "" } }).success).toBe(false); + }); +}); From dc0230dea489685a0500ac68e6c1f2dfa417115c Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 02:33:16 -0400 Subject: [PATCH 2/4] no-mistakes(review): apply reviewer inline fixes --- server/csharp/MetaObjects.Codegen/CodegenCli.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/csharp/MetaObjects.Codegen/CodegenCli.cs b/server/csharp/MetaObjects.Codegen/CodegenCli.cs index eaa7f3d259..0f6c4a1127 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenCli.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenCli.cs @@ -594,8 +594,6 @@ public static OwnedArgs Parse(string[] rest, string command, string[] valueFlags } } - /// Resolve the metadata location through the same ladder as dotnet meta, - /// or print why not. null means the caller exits 2. /// The project's declared csharp.outDir, or null when it declares none or /// the location cannot be resolved (the caller then reports usage, which names the setting). public static string? ConfiguredOutDir(string? metadataDir) @@ -604,6 +602,8 @@ public static OwnedArgs Parse(string[] rest, string command, string[] valueFlags catch (Exception e) when (e is MetaModelException or MetadataLocationException) { return null; } } + /// Resolve the metadata location through the same ladder as dotnet meta, + /// or print why not. null means the caller exits 2. private static ResolvedMetadata? ResolveOrReport(string? metadataDir) { try From a6d74e6a0bc2173b218719cf17b055ce0613242b Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 02:35:40 -0400 Subject: [PATCH 3/4] no-mistakes(review): Report malformed config instead of usage in C# gen/verify --- .../csharp/MetaObjects.Codegen/CodegenCli.cs | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/server/csharp/MetaObjects.Codegen/CodegenCli.cs b/server/csharp/MetaObjects.Codegen/CodegenCli.cs index 0f6c4a1127..a9a2f91854 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenCli.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenCli.cs @@ -594,12 +594,26 @@ public static OwnedArgs Parse(string[] rest, string command, string[] valueFlags } } - /// The project's declared csharp.outDir, or null when it declares none or - /// the location cannot be resolved (the caller then reports usage, which names the setting). - public static string? ConfiguredOutDir(string? metadataDir) + /// The project's declared csharp.outDir into , null when + /// it declares none or has no metadata collection yet (the caller then reports usage, which + /// names the setting). Returns false, after printing the error, when the location or config is + /// malformed, so the real cause is shown instead of a usage line. + private static bool TryConfiguredOutDir(string? metadataDir, out string? outDir) { - try { return MetadataLocation.Resolve(metadataDir, Directory.GetCurrentDirectory()).OutDir; } - catch (Exception e) when (e is MetaModelException or MetadataLocationException) { return null; } + outDir = null; + try + { + outDir = MetadataLocation.Resolve(metadataDir, Directory.GetCurrentDirectory()).OutDir; + return true; + } + catch (MetaModelException e) when (e.Code == ErrorCode.ERR_COLLECTION_NOT_FOUND) + { + return true; + } + catch (Exception e) when (e is MetaModelException or MetadataLocationException) + { + return ResolveOrReport(metadataDir) is not null; + } } /// Resolve the metadata location through the same ladder as dotnet meta, @@ -664,7 +678,11 @@ private static int RunGenArgs(string[] rest, IReadOnlyList declared) // ladder's own possible ERR_COLLECTION_NOT_FOUND below. // An omitted --out falls back to the project's declared `csharp.outDir`, the same // directory `dotnet meta verify` diffs, so gen and verify cannot disagree. - if (a.OutDir is null && ConfiguredOutDir(a.MetadataDir) is { } configuredOut) a.OutDir = configuredOut; + if (a.OutDir is null) + { + if (!TryConfiguredOutDir(a.MetadataDir, out var configuredOut)) return 2; + a.OutDir = configuredOut; + } if (a.OutDir is null) { Console.Error.WriteLine("usage: dotnet meta gen [] --out [--namespace ] [--generators ] [...]"); @@ -851,7 +869,11 @@ private static int RunVerifyArgs(string[] rest, IReadOnlyList declar Console.Error.WriteLine(ColumnNamingFlagRemoved(a.ColumnNamingRaw)); return 2; } - if (a.OutDir is null && ConfiguredOutDir(a.MetadataDir) is { } configuredOut) a.OutDir = configuredOut; + if (a.OutDir is null) + { + if (!TryConfiguredOutDir(a.MetadataDir, out var configuredOut)) return 2; + a.OutDir = configuredOut; + } if (a.OutDir is null) { Console.Error.WriteLine("usage: verify --codegen [] --out [--namespace ] [--generators ]"); From 998dee87ac818c67522d25ed58dbf68617d414d0 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 03:12:39 -0400 Subject: [PATCH 4/4] no-mistakes(document): Updated C# verify and outDir docs in docs/features/cli.md and docs/ports/csharp.md. Doc-only change; no code or tests touched. CHANGELOG and agent-context references already matched the new behavior. --- docs/features/cli.md | 7 ++++--- docs/ports/csharp.md | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/features/cli.md b/docs/features/cli.md index cbea3910a8..c964f93e06 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -36,7 +36,7 @@ command surface splits in two: | **Dependency sync** (`deps`) | **Node `meta`** | `meta deps sync \| check \| list` | **any backend** — resolves a declared metadata dependency's `path` transport into a committed snapshot + lock (FR-023), so `sync`/`check`/`list` themselves stay Node-only regardless of your server language. TypeScript and Python then LOAD that snapshot at every rung of their own source ladder — the two ports that implement dependency resolution in Phase 1a; Java, Kotlin, and C# don't read `dependencies` yet (Phase 2). See [`metadata-dependencies.md`](metadata-dependencies.md) | | **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 `--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 | +| C# codegen | `dotnet meta` | `dotnet meta init` / `gen` / `verify --templates` / `verify --codegen` | a .NET tool (`ToolCommandName=dotnet-meta`); invoked `dotnet meta` so it never shadows the Node `meta`; ships the ADR-0021 D2 subverbs (`--db` rejected, exit 2; bare `verify` = `--templates` plus `--codegen` when an output directory (`csharp.outDir` in `.metaobjects/config.json`, or `--out`) and `codegen/Generators.cs` are present). **The generator list is `codegen/Generators.cs`** (ADR-0034 Amendment 9): `dotnet meta init` writes it with `codegen/Program.cs` (two lines that run it) and `codegen/Codegen.csproj`, and `gen` / `verify --codegen` always run it through `dotnet run --project codegen`. With no `codegen/Generators.cs` they exit 2 and write nothing; `--generators` only narrows the list to declared entries. Every entry may set its own output directory (`ApiDocsGenerator { OutDir = ... }`), and `verify --codegen` compares each one. A declarative Mustache template-spec (the cross-port JSON contract shared with Python) is one more entry, `TemplateSpecGenerator { Spec = "", TemplateRoot = "" }`; `gen` no longer reads `--template-spec` or a project-root `template-spec.json`, and a run that finds either with no such entry fails before writing anything; see [declarative template scopes](codegen-concepts.md#declarative-template-scopes). `dotnet meta docs` was removed in 1.1.0: it prints the line that points at [the migration section](migrations/docs-are-generators.md#c) and exits 2 | | Java/Kotlin codegen | Maven plugin | `mvn metaobjects:generate` (`metaobjects:generate`) | Kotlin generators run through the same goal — see below. **No `--template-spec` flag, deliberately** — `` already loads a consumer class from the project classpath, so the declarative surface is `com.metaobjects.generator.template.TemplateScopeGenerator` wired as an ordinary `` with `