diff --git a/CHANGELOG.md b/CHANGELOG.md
index 39e77df87..c74c54af0 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 6b20b40ef..e782303a8 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 2f6547716..5944aa65f 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 963644148..c964f93e0 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 `` / `` / `` / `` / `` args (covers BOTH Java and Kotlin); see [declarative template scopes](codegen-concepts.md#declarative-template-scopes). The `generate` and `verify` goals are declared `threadSafe` and support parallel multi-module reactor builds (`mvn -T`) (#233) |
| Java/Kotlin verify | Maven plugin | `mvn metaobjects:verify -Dmeta.verify.mode=codegen\|templates` (`metaobjects:verify`) | parameter-driven ADR-0021 D2 modes (one goal covers BOTH Java + Kotlin): unset (default — `codegen` and `templates`; the template gate is skipped with a note when `templateRoot` is unset) / `codegen` (regen + fail on drift vs committed output, generator-neutral) / `templates` (`{{field}}`↔payload drift via the render `Verify` engine). `db` rejected ("schema verify is the migrate engine, ADR-0015") |
| Python codegen | console-script | `metaobjects gen` / `verify --codegen` / `verify --templates` | `[project.scripts] metaobjects` — **not** `meta` (that's the Node schema CLI); ships the ADR-0021 D2 subverbs (`--db` rejected, exit 2). **The generator list is `metaobjects.config.yaml`** (ADR-0034 Amendment 9, [below](#declarative-config-metaobjectsconfigyaml--python-codegen)): `gen` and `verify --codegen` run each target's `generators` (and its `templateSpec`, the declarative Mustache template-codegen surface shared with C#) into its `outDir`, and with no config they exit 2 and write nothing. `--generators` only narrows the list to declared entries. `metaobjects docs` was removed in 1.1.0: it prints the line that points at [the migration section](migrations/docs-are-generators.md#python) and exits 2 |
@@ -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,10 @@ 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 and `codegen/Generators.cs` are present, `--codegen`. The output directory is
+`csharp.outDir` in `.metaobjects/config.json` (the same setting `gen` writes to); `--out `
+wins over it. With neither, the codegen gate is skipped with a note that names the setting. A bare run 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 b372d6bbe..b21923cdd 100644
--- a/docs/ports/csharp.md
+++ b/docs/ports/csharp.md
@@ -164,7 +164,8 @@ return CodegenCli.Run(args, Generators.All);
```
Replace the empty list `dotnet meta init` wrote with the one above before the first `gen`: with
-an empty list `gen` writes nothing, and `--out` is required.
+an empty list `gen` writes nothing, and it needs an output directory (`--out`, or `csharp.outDir`
+in `.metaobjects/config.json`, below).
```bash
# Run the list: EF Core entities + AppDbContext + CRUD minimal-API routes
@@ -181,8 +182,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 aaad2fe2a..68892521e 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 c6fd092aa..9d5549985 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 02297446a..488bc519c 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 904ca5beb..a9a2f9185 100644
--- a/server/csharp/MetaObjects.Codegen/CodegenCli.cs
+++ b/server/csharp/MetaObjects.Codegen/CodegenCli.cs
@@ -594,6 +594,28 @@ public static OwnedArgs Parse(string[] rest, string command, string[] valueFlags
}
}
+ /// 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)
+ {
+ 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,
/// or print why not. null means the caller exits 2.
private static ResolvedMetadata? ResolveOrReport(string? metadataDir)
@@ -654,9 +676,17 @@ 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)
+ {
+ 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 ] [...]");
+ Console.Error.WriteLine(" (or set \"csharp\": { \"outDir\": \"\" } in .metaobjects/config.json)");
return 2;
}
if (ResolveOrReport(a.MetadataDir) is not { } meta) return 2;
@@ -840,8 +870,14 @@ private static int RunVerifyArgs(string[] rest, IReadOnlyList declar
return 2;
}
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 ]");
+ 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 81823c55e..42a997e67 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 856dccd5f..9af9afe86 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 c5504ba62..893b8ba8f 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 1b8f429a9..d77887016 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 7826d100f..0a98578b6 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);
+ });
+});