Skip to content

Commit 1f8f1d4

Browse files
dmealingclaude
andcommitted
Merge: verify subverb fan-out to C#/Python/Java/Kotlin (ADR-0021 D2)
Brings the unified verify contract (--db/--codegen/--templates) to the non-TS ports, so the explicit subverbs mean the same thing everywhere: - C# dotnet meta verify: --templates (was the bare behavior) + NEW --codegen (regen-to-temp + diff vs committed --out, writes only to temp; namespace inferred from the committed output so a custom --namespace doesn't false-drift); --db rejected (exit 2). - Python metaobjects verify: --codegen (was the bare behavior) + NEW --templates (render verify, reusing the render-helper field-tree derivation so it agrees with the build-time gate); --db rejected. - Java/Kotlin Maven meta:verify: NEW mode=codegen|templates parameter (one goal covers both ports); templates via a Maven-free TemplateVerify helper reusing the render Verify engine; mode=codegen default byte-identical. Explicit subverbs are the consistent cross-port contract; bare verify keeps each port's historical default (back-compat). --db stays TS-only (migrate engine). No generated-output change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents c2eb915 + 02ceb75 commit 1f8f1d4

14 files changed

Lines changed: 1966 additions & 41 deletions

File tree

docs/features/cli.md

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ command surface splits in two:
2626
| **Codegen drift** (`verify --codegen`) | **Node `meta`** | `meta verify --codegen` | TS reference (ADR-0021 D2) — regen-to-temp + diff committed output |
2727
| **Template/prompt drift** (`verify --templates`) | **Node `meta`** | `meta verify --templates` | TS reference (ADR-0021 D2) — `{{field}}`↔payload; the bare-`verify` default |
2828
| TS codegen | Node `meta` | `meta gen` | TS projects |
29-
| C# codegen | `dotnet meta` | `dotnet meta gen` / `verify` | a .NET tool (`ToolCommandName=dotnet-meta`); invoked `dotnet meta` so it never shadows the Node `meta` |
29+
| C# codegen | `dotnet meta` | `dotnet meta 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`) |
3030
| Java/Kotlin codegen | Maven plugin | `mvn metaobjects:generate` (`meta:gen`) | Kotlin generators run through the same goal — see below |
31-
| Java/Kotlin codegen drift | Maven plugin | `mvn metaobjects:verify` (`meta:verify`) | regenerate + fail on drift vs committed output (generator-neutral) |
32-
| Python codegen | console-script | `metaobjects gen` / `verify` | `[project.scripts] metaobjects`**not** `meta` (that's the Node schema CLI) |
31+
| Java/Kotlin verify | Maven plugin | `mvn metaobjects:verify -Dmeta.verify.mode=codegen\|templates` (`meta:verify`) | parameter-driven ADR-0021 D2 modes (one goal covers BOTH Java + Kotlin): `codegen` (default, back-compat — 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") |
32+
| 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) |
3333

3434
## `verify` is one verb with explicit subverbs (ADR-0021 D2)
3535

@@ -50,21 +50,37 @@ Rules of the contract:
5050
- **Combinations aggregate.** Pass any mix (`verify --db --codegen --templates`);
5151
each selected mode runs and the **exit code is non-zero if *any* mode reports
5252
drift**.
53-
- **Bare `verify` = `--templates`** (documented back-compat default). It also
54-
prints a one-line note advertising the explicit subverbs.
53+
- **Bare `verify` = the port's documented back-compat default.** TS/C# default to
54+
`--templates`; Java/Python default to `--codegen`. In every case bare `verify`
55+
also prints a one-line note advertising the explicit subverbs.
5556
- **`--codegen` needs to know where the committed output lives.** It diffs
5657
against the configured `outDir` (and any per-target `outDir`) from
5758
`metaobjects.config.ts`. With no config it errors clearly (exit 2) rather than
5859
silently passing — there is nothing to diff against.
5960
- **Unknown/invalid flag → exit 2** with usage.
6061

6162
**Port status (staged per ADR-0021):** the **TypeScript Node `meta` is the
62-
reference** and implements all three subverbs today. The other ports (C#
63-
`dotnet meta`, Java/Kotlin `mvn meta:verify`, Python `metaobjects`) converge on
64-
the same subverb vocabulary in staged, conformance-gated follow-on slices; each
65-
keeps its current behavior as the bare-`verify` default until it adopts the
66-
explicit subverbs. (Schema `--db` remains Node-only by the ADR-0015 design — see
67-
below.)
63+
reference** and implements all three subverbs today. **Python `metaobjects`
64+
ships the subverbs**: `verify --codegen` (regen-to-temp + diff vs `--out`, the
65+
historical default), `verify --templates` (each `template.*` node's `{{field}}`
66+
↔ payload-VO field tree via the render `verify()` gate, resolving refs through a
67+
filesystem provider rooted at `--templates-root`), and `verify --db` which is
68+
**cleanly rejected with exit 2** ("schema verify is the migrate engine,
69+
ADR-0015"). Bare `verify` stays `--codegen` for back-compat. The **C# `dotnet
70+
meta`** port likewise ships the codegen-side subverbs: `verify --templates` (its
71+
historical template/prompt drift gate, the C# back-compat default), `verify
72+
--codegen` (regenerate the default generator suite to a temp dir and diff against
73+
the committed `--out` tree, never touching it), and a **clean `--db` rejection
74+
(exit 2)** — bare `dotnet meta verify` keeps `--templates` and prints the subverb
75+
note. The **Java/Kotlin `mvn meta:verify`** port expresses the same vocabulary as a
76+
`mode` parameter (Maven goals are parameter-driven, not flag-driven): `-Dmeta.verify.mode=codegen`
77+
(default, byte-identical to the historical goal — regen-to-temp + diff vs committed
78+
output) and `-Dmeta.verify.mode=templates` (each `template.*` node's `{{field}}`↔payload-VO
79+
field tree via the render `Verify` engine, resolving refs through a filesystem provider rooted
80+
at `-Dmeta.verify.templateRoot`). The one goal covers BOTH Java (`codegen-spring`) and Kotlin
81+
(`codegen-kotlin`) since they share it. `mode=db` is **cleanly rejected** ("schema verify is the
82+
migrate engine, ADR-0015"); an unknown mode fails listing the valid ones. (Schema `--db` remains
83+
Node-only by the ADR-0015 design — see below.)
6884

6985
## Schema is Node-only — by design
7086

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
using MetaObjects.Cli;
2+
using Xunit;
3+
4+
namespace MetaObjects.Cli.Tests;
5+
6+
/// <summary>
7+
/// ADR-0021 D2 fan-out — `dotnet meta verify` explicit subverbs in the C# port.
8+
/// --templates : the historical template/prompt drift gate (unchanged behavior).
9+
/// --codegen : regenerate-to-temp + diff against the committed --out dir.
10+
/// --db : NOT supported in C# (schema verify is the migrate engine) → exit 2.
11+
/// A bare `verify` keeps the historical default = templates (back-compat).
12+
/// Combining flags runs each + aggregates the exit code (max; non-zero on any drift).
13+
///
14+
/// These drive the pure-logic dispatch <see cref="VerifyCommand.RunSubverbs"/>, which
15+
/// returns a structured outcome (per-mode results + aggregate exit) with no console I/O.
16+
/// </summary>
17+
public sealed class VerifySubverbTests : IDisposable
18+
{
19+
private readonly string _tmp = Path.Combine(Path.GetTempPath(), "meta-verify-sub-" + Guid.NewGuid().ToString("N"));
20+
private string MetaDir => Path.Combine(_tmp, "metaobjects");
21+
private string TplDir => Path.Combine(_tmp, "templates");
22+
private string OutDir => Path.Combine(_tmp, "generated");
23+
24+
// template.prompt metadata (for the --templates path).
25+
private const string TemplateMetadata = """
26+
{ "metadata.root": { "package": "acme::ai", "children": [
27+
{ "object.value": { "name": "Payload", "children": [ { "field.string": { "name": "name" } } ] } },
28+
{ "template.prompt": { "name": "greeting", "@payloadRef": "Payload", "@textRef": "t/main", "@format": "text" } }
29+
]}}
30+
""";
31+
32+
// object.entity metadata (for the --codegen path).
33+
private const string EntityMetadata = """
34+
{ "metadata.root": { "package": "acme", "children": [
35+
{ "object.entity": { "name": "Subscriber", "children": [
36+
{ "source.rdb": { "@table": "subscribers" } },
37+
{ "field.long": { "name": "id" } },
38+
{ "field.string": { "name": "email", "@required": true } },
39+
{ "identity.primary": { "@fields": "id" } }
40+
]}}
41+
]}}
42+
""";
43+
44+
public VerifySubverbTests()
45+
{
46+
Directory.CreateDirectory(MetaDir);
47+
Directory.CreateDirectory(TplDir);
48+
File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), TemplateMetadata);
49+
}
50+
51+
public void Dispose() { try { Directory.Delete(_tmp, recursive: true); } catch { } }
52+
53+
private void WriteTemplate(string body)
54+
{
55+
var dir = Path.Combine(TplDir, "t");
56+
Directory.CreateDirectory(dir);
57+
File.WriteAllText(Path.Combine(dir, "main.mustache"), body);
58+
}
59+
60+
private VerifyCommand.Options TemplatesOpts(bool templates = true, bool codegen = false, bool db = false) =>
61+
new()
62+
{
63+
MetadataDir = MetaDir,
64+
TemplatesRoot = TplDir,
65+
OutDir = OutDir,
66+
// Match the namespace the committed output was generated with, so a
67+
// clean regen is byte-identical (namespace is embedded in the output).
68+
Namespace = "Acme.Generated",
69+
Templates = templates,
70+
Codegen = codegen,
71+
Db = db,
72+
};
73+
74+
// -------------------- --templates (existing behavior under the flag) -----
75+
76+
[Fact]
77+
public void Templates_clean_is_exit0()
78+
{
79+
WriteTemplate("Hi {{name}}.");
80+
var r = VerifyCommand.RunSubverbs(TemplatesOpts());
81+
Assert.Equal(0, r.ExitCode);
82+
Assert.True(r.RanTemplates);
83+
Assert.False(r.RanCodegen);
84+
}
85+
86+
[Fact]
87+
public void Templates_drift_is_nonzero()
88+
{
89+
WriteTemplate("Hi {{name}}, you have {{notAField}}.");
90+
var r = VerifyCommand.RunSubverbs(TemplatesOpts());
91+
Assert.NotEqual(0, r.ExitCode);
92+
}
93+
94+
// -------------------- bare verify = templates + note (back-compat) -------
95+
96+
[Fact]
97+
public void Bare_verify_defaults_to_templates_and_emits_the_subverb_note()
98+
{
99+
WriteTemplate("Hi {{name}}.");
100+
// No explicit subverb selected.
101+
var opts = new VerifyCommand.Options
102+
{
103+
MetadataDir = MetaDir,
104+
TemplatesRoot = TplDir,
105+
OutDir = OutDir,
106+
Templates = false,
107+
Codegen = false,
108+
Db = false,
109+
};
110+
var r = VerifyCommand.RunSubverbs(opts);
111+
Assert.Equal(0, r.ExitCode);
112+
Assert.True(r.RanTemplates); // defaulted to templates
113+
Assert.True(r.EmittedDefaultNote); // one-line note that subverbs exist
114+
}
115+
116+
// -------------------- --db rejected in C# --------------------------------
117+
118+
[Fact]
119+
public void Db_subverb_is_rejected_exit2_with_message()
120+
{
121+
var r = VerifyCommand.RunSubverbs(TemplatesOpts(templates: false, db: true));
122+
Assert.Equal(2, r.ExitCode);
123+
Assert.NotNull(r.DbRejectionMessage);
124+
Assert.Contains("migrate", r.DbRejectionMessage!, StringComparison.OrdinalIgnoreCase);
125+
}
126+
127+
// -------------------- --codegen -----------------------------------------
128+
129+
[Fact]
130+
public void Codegen_clean_committed_output_is_exit0()
131+
{
132+
File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), EntityMetadata);
133+
GenCommand.Run(MetaDir, OutDir, "Acme.Generated");
134+
135+
var r = VerifyCommand.RunSubverbs(TemplatesOpts(templates: false, codegen: true));
136+
Assert.Equal(0, r.ExitCode);
137+
Assert.True(r.RanCodegen);
138+
Assert.NotNull(r.Codegen);
139+
Assert.True(r.Codegen!.Clean);
140+
}
141+
142+
[Fact]
143+
public void Codegen_mutated_committed_file_is_nonzero_and_named()
144+
{
145+
File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), EntityMetadata);
146+
GenCommand.Run(MetaDir, OutDir, "Acme.Generated");
147+
File.AppendAllText(Path.Combine(OutDir, "Subscriber.g.cs"), "\n// drift\n");
148+
149+
var r = VerifyCommand.RunSubverbs(TemplatesOpts(templates: false, codegen: true));
150+
Assert.NotEqual(0, r.ExitCode);
151+
Assert.Contains(r.Codegen!.DriftedFiles, f => f.EndsWith("Subscriber.g.cs"));
152+
}
153+
154+
[Fact]
155+
public void Codegen_does_not_touch_the_real_out_dir_on_drift()
156+
{
157+
File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), EntityMetadata);
158+
GenCommand.Run(MetaDir, OutDir, "Acme.Generated");
159+
File.AppendAllText(Path.Combine(OutDir, "Subscriber.g.cs"), "\n// drift\n");
160+
161+
var before = SnapshotDir(OutDir);
162+
VerifyCommand.RunSubverbs(TemplatesOpts(templates: false, codegen: true));
163+
var after = SnapshotDir(OutDir);
164+
Assert.Equal(before, after);
165+
}
166+
167+
[Fact]
168+
public void Codegen_without_committed_output_is_exit2()
169+
{
170+
File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), EntityMetadata);
171+
// Never ran gen → OutDir absent → nothing to diff against → exit 2.
172+
var r = VerifyCommand.RunSubverbs(TemplatesOpts(templates: false, codegen: true));
173+
Assert.Equal(2, r.ExitCode);
174+
Assert.NotNull(r.Codegen!.Error);
175+
}
176+
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+
239+
// -------------------- combining flags aggregates exit --------------------
240+
241+
[Fact]
242+
public void Combining_templates_and_codegen_aggregates_max_exit()
243+
{
244+
// Templates clean (exit 0) but codegen has no committed output (exit 2).
245+
WriteTemplate("Hi {{name}}.");
246+
var r = VerifyCommand.RunSubverbs(TemplatesOpts(templates: true, codegen: true));
247+
Assert.True(r.RanTemplates);
248+
Assert.True(r.RanCodegen);
249+
Assert.Equal(2, r.ExitCode); // max(0, 2)
250+
}
251+
252+
private static Dictionary<string, string> SnapshotDir(string dir)
253+
{
254+
var snap = new Dictionary<string, string>(StringComparer.Ordinal);
255+
if (!Directory.Exists(dir)) return snap;
256+
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
257+
snap[Path.GetRelativePath(dir, f)] = File.ReadAllText(f);
258+
return snap;
259+
}
260+
}

0 commit comments

Comments
 (0)