Skip to content

Commit ba31b9f

Browse files
dmealingclaude
andcommitted
fix(codegen): C# generators declare what they require; a template spec alone is a selection
Two defects, found while fixing the Python quickstart's routes gap for C#. 1. C# had no `requires`. `--generators entity,routes` emitted handlers that reference AppDbContext and <Entity>FilterAllowlist and did not compile, with no warning. The registry entry gains `Requires`: routes needs entity, db-context and filter-allowlist; db-context needs entity; output-parser, extractor and render-helper need entity (ADR-0056). `gen` warns when one is missing, with the text TS and Python use, and `--list` shows "(requires: ...)". Advisory, as in the other ports. 2. C# gen and Python gen refused a project that selects generators only through a template spec ("no generators selected"), because the empty-selection check ran before the spec was resolved. The documented `gen --template-root` examples failed as printed. C# verify had the mirror defect: it returned clean before discovering the spec, so a spec-only project's drift gate checked nothing. Both checks now run after spec resolution. Python verify already did this. C# `gen` also stops reporting an empty selection as "metadata did not load cleanly"; it is a usage error, exit 2, as in Python. docs/ports/csharp.md's first gen example named no generators and wrote nothing; it now names the suite and says what routes requires. Tests: C# registry (Requires, UnsatisfiedRequires), --list shows requires, and three spec-only tests (gen not refused, verify catches an edited spec file, no selection is still refused); Python spec-only gen and refusal. The spec-only tests fail without the fix. C# CLI 122/0, Codegen 572/0; Python 2299/0; ci-local --quick green. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1mRRB1K2WdYy82kntMQdx
1 parent 8257d3b commit ba31b9f

10 files changed

Lines changed: 190 additions & 31 deletions

File tree

‎docs/ports/csharp.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,18 @@ for a worked example.
8585

8686
```bash
8787
# Generate EF Core entities + AppDbContext + CRUD minimal-API routes
88-
dotnet meta gen ./metadata --out ./Generated --namespace Acme.Blog
88+
dotnet meta gen ./metadata --out ./Generated --namespace Acme.Blog \
89+
--generators entity,db-context,filter-allowlist,routes
8990

9091
# Drift-check templates against payloads (FR-004)
9192
dotnet meta verify ./metadata --templates ./prompts
9293
```
9394

95+
Codegen is opt-in: with no `--generators`, `gen` writes nothing. `dotnet meta gen --list` is
96+
the catalog, and it shows what each generator requires. `routes` requires `entity`,
97+
`db-context` and `filter-allowlist`, because the handlers it emits reference their output;
98+
`gen` warns when one is missing, since the result would not compile.
99+
94100
Schema migrations are owned by the Node `meta` CLI (ADR-0015) — the C# CLI is
95101
`gen` + `verify` only.
96102

‎server/csharp/MetaObjects.Cli.Tests/GenListAndSelectionTests.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ public void ListLines_prints_all_generators_with_stable_names_and_descriptions()
6262
}
6363
}
6464

65+
[Fact]
66+
public void ListLines_shows_what_a_generator_requires()
67+
{
68+
var lines = GenCommand.ListLines(_tmp);
69+
Assert.Contains(lines, l => l.Contains(" routes —") && l.Contains("(requires: entity, db-context, filter-allowlist)"));
70+
Assert.DoesNotContain(lines, l => l.Contains(" entity —") && l.Contains("requires"));
71+
}
72+
6573
[Fact]
6674
public void There_is_no_default_suite()
6775
{

‎server/csharp/MetaObjects.Cli.Tests/VerifyTemplateSpecTests.cs‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,38 @@ public void Verify_codegen_still_catches_an_edited_template_file()
113113
Assert.NotEqual(0, r.ExitCode);
114114
}
115115

116+
// A spec IS a selection. Both commands used to test for an empty --generators BEFORE
117+
// resolving the spec: gen refused a spec-only project, and verify reported it clean
118+
// without checking anything.
119+
[Fact]
120+
public void Gen_with_only_a_spec_is_not_refused()
121+
{
122+
var outcome = GenCommand.Run(MetaDir, OutDir, "Acme.Generated", emitAbstractShapes: false,
123+
generatorNames: null, templateRoot: TemplateRoot);
124+
Assert.True(outcome.Ok, string.Join("; ", outcome.LoadErrors));
125+
Assert.True(File.Exists(Path.Combine(OutDir, "Widget.summary.txt")));
126+
}
127+
128+
[Fact]
129+
public void Verify_with_only_a_spec_still_catches_an_edited_template_file()
130+
{
131+
Assert.True(GenCommand.Run(MetaDir, OutDir, "Acme.Generated", emitAbstractShapes: false,
132+
generatorNames: null, templateRoot: TemplateRoot).Ok);
133+
File.WriteAllText(Path.Combine(OutDir, "Widget.summary.txt"), "tampered\n");
134+
135+
var r = VerifyCommand.RunSubverbs(CodegenOpts() with { Generators = null });
136+
Assert.NotEqual(0, r.ExitCode);
137+
}
138+
139+
[Fact]
140+
public void Gen_with_neither_generators_nor_a_spec_is_still_refused()
141+
{
142+
File.Delete(DiscoveredSpec);
143+
var outcome = GenCommand.Run(MetaDir, OutDir, "Acme.Generated", emitAbstractShapes: false,
144+
generatorNames: null, templateRoot: TemplateRoot);
145+
Assert.Equal([GenCommand.NoGeneratorsSelected], outcome.LoadErrors);
146+
}
147+
116148
[Fact]
117149
public void No_spec_file_leaves_behaviour_unchanged()
118150
{

‎server/csharp/MetaObjects.Cli/GenCommand.cs‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ public sealed record Outcome(IReadOnlyList<string> LoadErrors, CodegenRunner.Run
4646

4747
/// <summary>
4848
/// Run codegen selecting generators by stable name. There is no default suite:
49-
/// a null or empty <paramref name="generatorNames"/> generates nothing and reports
50-
/// <see cref="NoGeneratorsSelected"/> — ADR-0034 Amendment 2 made codegen opt-in.
49+
/// a null or empty <paramref name="generatorNames"/> with no template spec either
50+
/// generates nothing and reports <see cref="NoGeneratorsSelected"/> — ADR-0034
51+
/// Amendment 2 made codegen opt-in. A template spec alone is a selection.
5152
/// An unknown name (or a render-helper selected without a
5253
/// <paramref name="templateRoot"/>) surfaces as a load-style error in the
5354
/// returned <see cref="Outcome"/> rather than throwing.
@@ -133,11 +134,7 @@ public static Outcome Run(
133134
if (loadErrors.Count > 0)
134135
return new Outcome(loadErrors, null);
135136

136-
// No default suite — see NoGeneratorsSelected. A caller that names none gets a
137-
// usage error and an empty out dir, never a shape this CLI picked.
138-
if (generatorNames is not { Count: > 0 })
139-
return new Outcome([NoGeneratorsSelected], null);
140-
var names = generatorNames;
137+
var names = generatorNames ?? [];
141138
List<IGenerator> generators;
142139
try
143140
{
@@ -158,6 +155,13 @@ public static Outcome Run(
158155
return new Outcome([ex.Message], null);
159156
}
160157

158+
// No default suite — see NoGeneratorsSelected. A caller that selects nothing gets a
159+
// usage error and an empty out dir, never a shape this CLI picked. A template spec
160+
// (flag or the conventional template-spec.json) IS a selection, so this runs after
161+
// spec resolution: it used to run before, and refused a spec-only project.
162+
if (generators.Count == 0)
163+
return new Outcome([NoGeneratorsSelected], null);
164+
161165
// Build-config + CodegenRunner.Run + hash-manifest / overwrite-policy / merge, and
162166
// the RenderException / ArgumentException-to-clean-error translation, all live in
163167
// ONE place now (ADR-0034 Amendment 3 / the eject design doc's C# section):
@@ -192,7 +196,8 @@ public static IReadOnlyList<string> ListLines(string cwd) =>
192196
{
193197
var owned = OwnedCopy.Status(cwd, e);
194198
var ownedMark = owned is not null ? $" [owned — {owned}]" : "";
195-
return $" {e.Name} — {e.Description}" + (e.Note is not null ? $" [{e.Note}]" : "") + ownedMark;
199+
var requires = e.Requires.Count > 0 ? $" (requires: {string.Join(", ", e.Requires)})" : "";
200+
return $" {e.Name} — {e.Description}" + requires + (e.Note is not null ? $" [{e.Note}]" : "") + ownedMark;
196201
})
197202
.ToList();
198203
}

‎server/csharp/MetaObjects.Cli/Program.cs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,22 @@ static int RunGen(string[] rest)
165165
// branches now build a LoadResult up front and share the single
166166
// GenCommand.Run(LoadResult, ...) overload, exactly as VerifyCommand.LoadMetadata
167167
// already does.
168+
// Advisory, before the run: a generator selected without the ones its output
169+
// references still generates, but the result will not compile.
170+
if (generatorNames is { Length: > 0 } selectedNames)
171+
foreach (var w in GeneratorRegistry.UnsatisfiedRequires(selectedNames))
172+
Console.Error.WriteLine($"warning: {w}");
173+
168174
var load = resolvedMeta.Load();
169175
var outcome = GenCommand.Run(
170176
load, outDir, ns, emitAbstractShapes, generatorNames, templateRoot, templateSpecPath, projectRoot,
171177
columnNaming, baseline);
178+
if (outcome.LoadErrors is [GenCommand.NoGeneratorsSelected])
179+
{
180+
// A usage error, not a load failure: say so, and exit 2 as Python does.
181+
Console.Error.WriteLine($"error: {GenCommand.NoGeneratorsSelected}");
182+
return 2;
183+
}
172184
if (!outcome.Ok)
173185
{
174186
foreach (var e in outcome.LoadErrors) Console.Error.WriteLine($" load error: {e}");

‎server/csharp/MetaObjects.Cli/VerifyCommand.cs‎

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -258,22 +258,7 @@ private static Codegen.CodegenDrift.Result RunCodegenDrift(Options opts)
258258
string.Join("; ", load.Errors.Select(e => e.Code + ": " + e.Message)) + ").",
259259
};
260260

261-
// `verify --codegen` re-runs the SELECTION and compares. With no default suite
262-
// there is nothing to compare against, so it says so rather than silently
263-
// checking nine artifacts this project never generates — which would convict
264-
// every one of them as missing.
265-
if (opts.Generators is not { Count: > 0 })
266-
return new Codegen.CodegenDrift.Result
267-
{
268-
Clean = true,
269-
Lines =
270-
[
271-
"verify --codegen: no generators selected, so there is no generated " +
272-
"output to check. Pass --generators <a,b,c> (dotnet meta gen --list " +
273-
"is the catalog).",
274-
],
275-
};
276-
var names = opts.Generators;
261+
var names = opts.Generators ?? [];
277262
IReadOnlyList<IGenerator> generators;
278263
try
279264
{
@@ -302,6 +287,23 @@ private static Codegen.CodegenDrift.Result RunCodegenDrift(Options opts)
302287
return new Codegen.CodegenDrift.Result { Clean = false, Error = $"verify --codegen: {ex.Message}" };
303288
}
304289

290+
// `verify --codegen` re-runs the SELECTION and compares. With no default suite and
291+
// nothing selected there is nothing to compare against, so it says so rather than
292+
// convicting every committed file as missing. A discovered template-spec.json IS a
293+
// selection: this check used to run before discovery, so a project generating only
294+
// from a spec passed verify without anything being checked.
295+
if (generators.Count == 0)
296+
return new Codegen.CodegenDrift.Result
297+
{
298+
Clean = true,
299+
Lines =
300+
[
301+
"verify --codegen: no generators selected, so there is no generated " +
302+
"output to check. Pass --generators <a,b,c> (dotnet meta gen --list " +
303+
"is the catalog).",
304+
],
305+
};
306+
305307
// The C# namespace is embedded in every generated file (`namespace {ns};`).
306308
// If the user did NOT pass --namespace, infer it from the committed output so
307309
// the regen matches output produced with any namespace (otherwise every file

‎server/csharp/MetaObjects.Codegen.Tests/GeneratorRegistryTests.cs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,34 @@ public void Render_helper_respects_supplied_template_root()
104104
}
105105
finally { Directory.Delete(root, recursive: true); }
106106
}
107+
108+
// A generator whose output imports another generator's output declares it, and
109+
// `gen` warns when the dependency is not selected (advisory: an adopter may keep a
110+
// hand-written file at that path). Mirrors TS warnUnsatisfiedRequires and Python's
111+
// unsatisfied_requires. Before this, `--generators entity,routes` emitted routes that
112+
// reference <Entity>FilterAllowlist and AppDbContext and did not compile.
113+
[Fact]
114+
public void Routes_declares_the_modules_its_output_references()
115+
{
116+
Assert.Equal(["entity", "db-context", "filter-allowlist"], GeneratorRegistry.Get("routes")!.Requires);
117+
Assert.Equal(["entity"], GeneratorRegistry.Get("db-context")!.Requires);
118+
foreach (var name in new[] { "output-parser", "extractor", "render-helper" })
119+
Assert.Equal(["entity"], GeneratorRegistry.Get(name)!.Requires);
120+
Assert.Empty(GeneratorRegistry.Get("entity")!.Requires);
121+
}
122+
123+
[Fact]
124+
public void UnsatisfiedRequires_warns_once_per_generator_naming_what_is_missing()
125+
{
126+
var warnings = GeneratorRegistry.UnsatisfiedRequires(["entity", "routes"]);
127+
var w = Assert.Single(warnings);
128+
Assert.StartsWith("\"routes\" is selected but \"db-context\", \"filter-allowlist\" are not.", w);
129+
}
130+
131+
[Fact]
132+
public void UnsatisfiedRequires_is_silent_when_every_dependency_is_selected()
133+
{
134+
Assert.Empty(GeneratorRegistry.UnsatisfiedRequires(
135+
["entity", "db-context", "filter-allowlist", "routes", "names"]));
136+
}
107137
}

‎server/csharp/MetaObjects.Codegen/GeneratorRegistry.cs‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ public sealed record GeneratorRegistryEntry
100100
/// its own to own — every other Native entry names its file here.
101101
/// </summary>
102102
public string? SourceFileName { get; init; }
103+
/// <summary>
104+
/// Stable names of the generators whose output this one's output references. A run
105+
/// that selects this entry without them gets a warning from <c>gen</c> (see
106+
/// <see cref="GeneratorRegistry.UnsatisfiedRequires"/>) and <c>--list</c> shows them.
107+
/// Advisory, never an error: an adopter may keep a hand-written file at that path.
108+
/// </summary>
109+
public IReadOnlyList<string> Requires { get; init; } = [];
103110
}
104111

105112
/// <summary>
@@ -147,6 +154,8 @@ private static IGenerator RenderHelper(GeneratorBuildContext ctx) =>
147154
Layer = GeneratorLayer.Persistence,
148155
Factory = _ => new DbContextGenerator(),
149156
SourceFileName = "DbContextGenerator.cs",
157+
// DbSet<Entity> for every entity class EntityGenerator emits.
158+
Requires = ["entity"],
150159
},
151160
["routes"] = new()
152161
{
@@ -156,6 +165,9 @@ private static IGenerator RenderHelper(GeneratorBuildContext ctx) =>
156165
Layer = GeneratorLayer.Api,
157166
Factory = _ => new RoutesGenerator(),
158167
SourceFileName = "RoutesGenerator.cs",
168+
// The handlers take AppDbContext, read <Entity>FilterAllowlist and bind the
169+
// entity classes; without any of the three the output does not compile.
170+
Requires = ["entity", "db-context", "filter-allowlist"],
159171
},
160172
["output-parser"] = new()
161173
{
@@ -166,6 +178,8 @@ private static IGenerator RenderHelper(GeneratorBuildContext ctx) =>
166178
Factory = _ => new OutputParserGenerator(),
167179
Note = "Needs `entity` in the same run: it references each value object's own POCO (ADR-0056).",
168180
SourceFileName = "OutputParserGenerator.cs",
181+
// ADR-0056: the value object's own POCO, which EntityGenerator emits.
182+
Requires = ["entity"],
169183
},
170184
["extractor"] = new()
171185
{
@@ -176,6 +190,8 @@ private static IGenerator RenderHelper(GeneratorBuildContext ctx) =>
176190
Factory = _ => new ExtractorGenerator(),
177191
Note = "Needs `entity` in the same run: it references each value object's own POCO (ADR-0056).",
178192
SourceFileName = "ExtractorGenerator.cs",
193+
// ADR-0056: the value object's own POCO, which EntityGenerator emits.
194+
Requires = ["entity"],
179195
},
180196
["output-prompt"] = new()
181197
{
@@ -196,6 +212,8 @@ private static IGenerator RenderHelper(GeneratorBuildContext ctx) =>
196212
Options = "template-root (required when selected)",
197213
Note = "Needs `entity` in the same run: it references each value object's own POCO (ADR-0056).",
198214
SourceFileName = "RenderHelperGenerator.cs",
215+
// ADR-0056: the value object's own POCO, which EntityGenerator emits.
216+
Requires = ["entity"],
199217
},
200218
["filter-allowlist"] = new()
201219
{
@@ -271,6 +289,31 @@ public static bool IncludesNames(IReadOnlyList<string> names) =>
271289
/// Build the generators for the given stable names, in the order requested.
272290
/// Throws <see cref="ArgumentException"/> naming the first unknown id.
273291
/// </summary>
292+
/// <summary>
293+
/// One warning per selected generator whose <see cref="GeneratorRegistryEntry.Requires"/>
294+
/// are not all selected too. Advisory, never an error. Mirrors TS
295+
/// <c>warnUnsatisfiedRequires</c> and Python <c>unsatisfied_requires</c>.
296+
/// </summary>
297+
public static IReadOnlyList<string> UnsatisfiedRequires(IReadOnlyList<string> names)
298+
{
299+
var selected = new HashSet<string>(names, StringComparer.Ordinal);
300+
var warnings = new List<string>();
301+
foreach (var name in names)
302+
{
303+
var entry = Get(name);
304+
if (entry is null) continue;
305+
var missing = entry.Requires.Where(d => !selected.Contains(d)).ToList();
306+
if (missing.Count == 0) continue;
307+
var listed = string.Join(", ", missing.Select(m => $"\"{m}\""));
308+
warnings.Add(
309+
$"\"{name}\" is selected but {listed} {(missing.Count == 1 ? "is" : "are")} not. " +
310+
$"The code \"{name}\" emits references what {listed} would have emitted, so it " +
311+
$"will not compile. Add {listed} to --generators, or keep your own hand-written " +
312+
"files at those paths.");
313+
}
314+
return warnings;
315+
}
316+
274317
public static IReadOnlyList<IGenerator> Resolve(IEnumerable<string> names, GeneratorBuildContext? ctx = null)
275318
{
276319
var build = ctx ?? new GeneratorBuildContext();

‎server/python/src/metaobjects/cli.py‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,11 +1069,6 @@ def _cmd_gen(args: argparse.Namespace) -> int:
10691069
for msg in gen_errors:
10701070
print(f" {msg}", file=sys.stderr)
10711071
return 1
1072-
else:
1073-
# Codegen is opt-in: refuse here, at the door, rather than letting the run reach
1074-
# the generator loop and fail with an empty out dir half-created.
1075-
print(f"error: {NO_GENERATORS_SELECTED}", file=sys.stderr)
1076-
return 2
10771072

10781073
# SP-1: declarative Mustache generators from a JSON template-spec. Their output
10791074
# is format-agnostic (text/markdown/csv/json/xml/html), so they run as a SECOND,
@@ -1088,6 +1083,14 @@ def _cmd_gen(args: argparse.Namespace) -> int:
10881083
print(spec_err, file=sys.stderr)
10891084
return 1
10901085

1086+
# Codegen is opt-in: refuse at the door when nothing is selected, rather than letting
1087+
# the run reach the generator loop and fail with an empty out dir half-created. A
1088+
# template spec IS a selection (flag or the conventional template-spec.json), so a
1089+
# project that generates only from one is not refused; verify already agreed.
1090+
if not generators and not spec_gens:
1091+
print(f"error: {NO_GENERATORS_SELECTED}", file=sys.stderr)
1092+
return 2
1093+
10911094
entities = _parse_entities(getattr(args, "entities", None))
10921095
providers, providers_ok = _providers_from_args(args)
10931096
if not providers_ok:
@@ -1112,7 +1115,7 @@ def _cmd_gen(args: argparse.Namespace) -> int:
11121115
root, args.out, generators, entities,
11131116
gen_state_dir=gen_state, column_naming=column_naming,
11141117
project_root=gen_project_root, baseline=baseline, refused_out=refused,
1115-
)
1118+
) if generators else []
11161119
except ValueError as exc:
11171120
# A generator refused the model — a run_gen output collision, or
11181121
# `render-helper`'s build-time drift gate. The targets path below has caught

‎server/python/tests/codegen/test_cli.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,24 @@ def test_gen_auto_discovers_template_spec(tmp_path: Path) -> None:
199199
assert (out / "_model.txt").exists()
200200

201201

202+
def test_gen_with_only_a_template_spec_is_not_refused(tmp_path: Path) -> None:
203+
"""A template spec is a selection. A project that generates only from one used to be
204+
refused with "no generators selected", while verify already honoured it."""
205+
root = _spec_project(tmp_path)
206+
out = root / "out"
207+
rc = main(["gen", str(root / "meta"), "--out", str(out), "--templates", str(root / "templates")])
208+
assert rc == 0
209+
assert (out / "Product.txt").exists()
210+
assert not (out / "__init__.py").exists(), "no Python suite ran, so no package init"
211+
212+
213+
def test_gen_with_neither_generators_nor_spec_is_refused(tmp_path: Path, capsys) -> None:
214+
root = _spec_project(tmp_path, spec_name=None)
215+
rc = main(["gen", str(root / "meta"), "--out", str(root / "out")])
216+
assert rc == 2
217+
assert "no generators selected" in capsys.readouterr().err
218+
219+
202220
def test_gen_without_spec_file_emits_no_template_output(tmp_path: Path) -> None:
203221
"""No spec file ⇒ today's behaviour exactly: the default suite only."""
204222
root = _spec_project(tmp_path, spec_name=None)

0 commit comments

Comments
 (0)