Skip to content

Commit 02ceb75

Browse files
dmealingclaude
andcommitted
fix(csharp): verify --codegen infers the namespace from committed output (avoid spurious drift)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b9953f8 commit 02ceb75

5 files changed

Lines changed: 164 additions & 6 deletions

File tree

server/csharp/MetaObjects.Cli.Tests/VerifySubverbTests.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,68 @@ public void Codegen_without_committed_output_is_exit2()
174174
Assert.NotNull(r.Codegen!.Error);
175175
}
176176

177+
// -------------------- the namespace-inference footgun --------------------
178+
// gen used a CUSTOM namespace; verify --codegen WITHOUT --namespace must infer
179+
// it from the committed output (else every file would spuriously drift on the
180+
// embedded `namespace {ns};`). Build the Options with NO explicit namespace.
181+
182+
/// <summary>Codegen opts with NO explicit namespace (the footgun scenario).</summary>
183+
private VerifyCommand.Options CodegenOptsNoNamespace() =>
184+
new()
185+
{
186+
MetadataDir = MetaDir,
187+
OutDir = OutDir,
188+
Codegen = true,
189+
// Namespace intentionally NOT set + NamespaceExplicit defaults to false.
190+
};
191+
192+
[Fact]
193+
public void Codegen_infers_custom_namespace_from_committed_output_no_flag()
194+
{
195+
File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), EntityMetadata);
196+
// Committed output generated with a CUSTOM namespace.
197+
GenCommand.Run(MetaDir, OutDir, "Acme.Generated");
198+
199+
// verify --codegen WITHOUT --namespace → must infer "Acme.Generated" from the
200+
// committed files and produce a byte-identical regen → exit 0 (no spurious drift).
201+
var r = VerifyCommand.RunSubverbs(CodegenOptsNoNamespace());
202+
Assert.Equal(0, r.ExitCode);
203+
Assert.True(r.Codegen!.Clean, string.Join("; ", r.Codegen!.Lines));
204+
}
205+
206+
[Fact]
207+
public void Codegen_inference_still_detects_real_drift_with_custom_namespace()
208+
{
209+
File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), EntityMetadata);
210+
GenCommand.Run(MetaDir, OutDir, "Acme.Generated");
211+
// A real hand-edit on top of the custom namespace must still be drift.
212+
File.AppendAllText(Path.Combine(OutDir, "Subscriber.g.cs"), "\n// real drift\n");
213+
214+
var r = VerifyCommand.RunSubverbs(CodegenOptsNoNamespace());
215+
Assert.NotEqual(0, r.ExitCode);
216+
Assert.Contains(r.Codegen!.DriftedFiles, f => f.EndsWith("Subscriber.g.cs"));
217+
}
218+
219+
[Fact]
220+
public void Codegen_explicit_namespace_still_wins_over_inference()
221+
{
222+
File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), EntityMetadata);
223+
GenCommand.Run(MetaDir, OutDir, "Acme.Generated");
224+
225+
// Explicit namespace set (matching) → wins, byte-identical regen → exit 0.
226+
var opts = new VerifyCommand.Options
227+
{
228+
MetadataDir = MetaDir,
229+
OutDir = OutDir,
230+
Namespace = "Acme.Generated",
231+
NamespaceExplicit = true,
232+
Codegen = true,
233+
};
234+
var r = VerifyCommand.RunSubverbs(opts);
235+
Assert.Equal(0, r.ExitCode);
236+
Assert.True(r.Codegen!.Clean, string.Join("; ", r.Codegen!.Lines));
237+
}
238+
177239
// -------------------- combining flags aggregates exit --------------------
178240

179241
[Fact]

server/csharp/MetaObjects.Cli/Program.cs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@
1717
" [--generators <a,b,c>] [--template-root <dir>]\n" +
1818
" generate EF Core code from metadata\n" +
1919
" gen --list list available generators (stable names) and exit\n" +
20-
" verify <metadataDir> [--templates <root>] [--codegen --out <dir>] [--db]\n" +
20+
" verify <metadataDir> [--templates <root>] [--codegen --out <dir> [--namespace <ns>]] [--db]\n" +
2121
" drift gates (ADR-0021 D2 subverbs):\n" +
2222
" --templates template/prompt drift (default)\n" +
2323
" --codegen regen-to-temp vs committed --out\n" +
24+
" --namespace codegen regen namespace; inferred\n" +
25+
" from the committed --out when omitted\n" +
2426
" --db NOT supported in C# (migrate engine)");
2527
return 2;
2628
}
@@ -99,6 +101,7 @@ static int RunVerify(string[] rest)
99101
string? templatesRoot = null;
100102
string? outDir = null;
101103
string ns = "Generated";
104+
bool nsExplicit = false;
102105
string? generatorsCsv = null;
103106
string? templateRoot = null;
104107
bool templates = false, codegen = false, db = false;
@@ -115,13 +118,13 @@ static int RunVerify(string[] rest)
115118
else if (a == "--codegen") codegen = true;
116119
else if (a == "--db") db = true;
117120
else if (a == "--out" && i + 1 < rest.Length) outDir = rest[++i];
118-
else if (a == "--namespace" && i + 1 < rest.Length) ns = rest[++i];
121+
else if (a == "--namespace" && i + 1 < rest.Length) { ns = rest[++i]; nsExplicit = true; }
119122
else if (a == "--generators" && i + 1 < rest.Length) generatorsCsv = rest[++i];
120123
else if (a == "--template-root" && i + 1 < rest.Length) templateRoot = rest[++i];
121124
else if (a.StartsWith('-'))
122125
{
123126
Console.Error.WriteLine($"dotnet meta verify: unknown option \"{a}\"");
124-
Console.Error.WriteLine("usage: dotnet meta verify <metadataDir> [--templates <root>] [--codegen --out <dir>] [--db]");
127+
Console.Error.WriteLine("usage: dotnet meta verify <metadataDir> [--templates <root>] [--codegen --out <dir> [--namespace <ns>]] [--db]");
125128
return 2;
126129
}
127130
else if (metadataDir is null) metadataDir = a;
@@ -133,7 +136,7 @@ static int RunVerify(string[] rest)
133136

134137
if (metadataDir is null)
135138
{
136-
Console.Error.WriteLine("usage: dotnet meta verify <metadataDir> [--templates <root>] [--codegen --out <dir>] [--db]");
139+
Console.Error.WriteLine("usage: dotnet meta verify <metadataDir> [--templates <root>] [--codegen --out <dir> [--namespace <ns>]] [--db]");
137140
return 2;
138141
}
139142

@@ -143,7 +146,8 @@ static int RunVerify(string[] rest)
143146
if (wantsTemplates && templatesRoot is null)
144147
{
145148
Console.Error.WriteLine("usage: dotnet meta verify <metadataDir> --templates <templatesRoot>");
146-
Console.Error.WriteLine(" (the templates gate is the default; pass --codegen --out <dir> for codegen drift)");
149+
Console.Error.WriteLine(" (the templates gate is the default; pass --codegen --out <dir> for codegen drift —");
150+
Console.Error.WriteLine(" --namespace is inferred from the committed output when omitted)");
147151
return 2;
148152
}
149153

@@ -156,6 +160,7 @@ static int RunVerify(string[] rest)
156160
TemplatesRoot = templatesRoot,
157161
OutDir = outDir,
158162
Namespace = ns,
163+
NamespaceExplicit = nsExplicit,
159164
Generators = generators,
160165
TemplateRoot = templateRoot,
161166
Templates = templates,

server/csharp/MetaObjects.Cli/VerifyCommand.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ public sealed record Options
7474
public string? OutDir { get; init; }
7575
/// <summary>Code-gen namespace (only affects the temp regen; default mirrors `gen`).</summary>
7676
public string Namespace { get; init; } = "Generated";
77+
/// <summary>True when the user explicitly passed <c>--namespace</c>. When false and
78+
/// <c>--codegen</c> runs, the namespace is inferred from the committed output so a
79+
/// regen matches output generated with any namespace (avoids spurious drift).</summary>
80+
public bool NamespaceExplicit { get; init; }
7781
/// <summary>Optional generator selection for the <c>--codegen</c> regen (else the default suite).</summary>
7882
public IReadOnlyList<string>? Generators { get; init; }
7983
/// <summary>Template root for render-helper/template generators in the codegen regen.</summary>
@@ -199,7 +203,16 @@ private static Codegen.CodegenDrift.Result RunCodegenDrift(Options opts)
199203
return new Codegen.CodegenDrift.Result { Clean = false, Error = $"verify --codegen: {ex.Message}" };
200204
}
201205

202-
var config = new GenConfig { OutDir = opts.OutDir, Namespace = opts.Namespace };
206+
// The C# namespace is embedded in every generated file (`namespace {ns};`).
207+
// If the user did NOT pass --namespace, infer it from the committed output so
208+
// the regen matches output produced with any namespace (otherwise every file
209+
// would spuriously drift). An explicit --namespace always wins (back-compat /
210+
// override); inference falling back to null leaves the default in place.
211+
var ns = opts.Namespace;
212+
if (!opts.NamespaceExplicit)
213+
ns = Codegen.CodegenDrift.InferNamespace(opts.OutDir) ?? opts.Namespace;
214+
215+
var config = new GenConfig { OutDir = opts.OutDir, Namespace = ns };
203216
return Codegen.CodegenDrift.Compute(config, load.Root, generators);
204217
}
205218

server/csharp/MetaObjects.Codegen.Tests/CodegenDriftTests.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,48 @@ public void Drift_run_never_writes_to_the_real_out_dir()
138138
Assert.NotEqual(before, afterMutation);
139139
}
140140

141+
// -------------------- namespace inference from committed output ----------
142+
143+
[Fact]
144+
public void InferNamespace_reads_file_scoped_namespace()
145+
{
146+
Gen(); // writes files with `namespace Acme.Generated;`
147+
Assert.Equal("Acme.Generated", CodegenDrift.InferNamespace(OutDir));
148+
}
149+
150+
[Fact]
151+
public void InferNamespace_reads_block_scoped_namespace()
152+
{
153+
Directory.CreateDirectory(OutDir);
154+
File.WriteAllText(Path.Combine(OutDir, "Block.g.cs"),
155+
"// <auto-generated/>\nnamespace Acme.Block\n{\n public class Foo { }\n}\n");
156+
Assert.Equal("Acme.Block", CodegenDrift.InferNamespace(OutDir));
157+
}
158+
159+
[Fact]
160+
public void InferNamespace_picks_the_first_sorted_file()
161+
{
162+
Directory.CreateDirectory(OutDir);
163+
File.WriteAllText(Path.Combine(OutDir, "Zeta.g.cs"), "namespace Zeta.Ns;\n");
164+
File.WriteAllText(Path.Combine(OutDir, "Alpha.g.cs"), "namespace Alpha.Ns;\n");
165+
Assert.Equal("Alpha.Ns", CodegenDrift.InferNamespace(OutDir));
166+
}
167+
168+
[Fact]
169+
public void InferNamespace_returns_null_for_missing_dir()
170+
{
171+
Assert.Null(CodegenDrift.InferNamespace(Path.Combine(_tmp, "does-not-exist")));
172+
}
173+
174+
[Fact]
175+
public void InferNamespace_returns_null_when_no_cs_has_a_namespace()
176+
{
177+
Directory.CreateDirectory(OutDir);
178+
File.WriteAllText(Path.Combine(OutDir, "NoNs.g.cs"), "// top-level statements only\nvar x = 1;\n");
179+
File.WriteAllText(Path.Combine(OutDir, "notes.txt"), "namespace NotCSharp;\n");
180+
Assert.Null(CodegenDrift.InferNamespace(OutDir));
181+
}
182+
141183
private static Dictionary<string, string> SnapshotDir(string dir)
142184
{
143185
var snap = new Dictionary<string, string>(StringComparer.Ordinal);

server/csharp/MetaObjects.Codegen/CodegenDrift.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,49 @@
1111
// SAFETY: the regen is written ONLY under a fresh temp dir; the real OutDir is
1212
// read but never written. On any outcome the temp tree is removed.
1313

14+
using System.Text.RegularExpressions;
1415
using MetaObjects.Meta;
1516

1617
namespace MetaObjects.Codegen;
1718

1819
/// <summary>Regenerate-to-temp + diff vs committed output (ADR-0021 D2).</summary>
1920
public static class CodegenDrift
2021
{
22+
// Matches the FIRST top-level namespace declaration in a C# source file:
23+
// file-scoped `namespace X.Y;` or block `namespace X.Y {` / `namespace X.Y`.
24+
// Multiline so `^` anchors each line; we scan line-leading declarations only.
25+
private static readonly Regex NamespaceDecl =
26+
new(@"^\s*namespace\s+([A-Za-z_][\w.]*)\s*;?", RegexOptions.Multiline | RegexOptions.Compiled);
27+
28+
/// <summary>
29+
/// Infer the C# namespace embedded in the committed generated output under
30+
/// <paramref name="outDir"/>. Scans the FIRST <c>.cs</c> file (ordinal-sorted)
31+
/// that carries a top-level <c>namespace</c> declaration and returns it.
32+
/// Returns <c>null</c> when the dir is missing or no <c>.cs</c> file declares a
33+
/// namespace — the caller then falls back to its default (there is nothing
34+
/// meaningful to diff against in that case anyway). Used by
35+
/// <c>verify --codegen</c> so a regen matches output produced with ANY namespace
36+
/// when <c>--namespace</c> is omitted.
37+
/// </summary>
38+
public static string? InferNamespace(string outDir)
39+
{
40+
if (!Directory.Exists(outDir)) return null;
41+
42+
var csFiles = Directory.EnumerateFiles(outDir, "*.cs", SearchOption.AllDirectories)
43+
.OrderBy(p => p.Replace(Path.DirectorySeparatorChar, '/'), StringComparer.Ordinal);
44+
45+
foreach (var file in csFiles)
46+
{
47+
string text;
48+
try { text = File.ReadAllText(file); }
49+
catch { continue; }
50+
51+
var m = NamespaceDecl.Match(text);
52+
if (m.Success) return m.Groups[1].Value;
53+
}
54+
return null;
55+
}
56+
2157
/// <summary>The outcome of a codegen-drift computation (pure; no console I/O).</summary>
2258
public sealed record Result
2359
{

0 commit comments

Comments
 (0)