Skip to content

Commit e0fc3f4

Browse files
authored
fix(cli): agent-facing CLI defects in meta, dotnet meta and metaobjects (#409)
Node `meta`: - --format json|toon puts exactly one document on stdout: gen's first-run pointer and migrate's status lines go to stderr, and a dry run carries its SQL in the document's `sql` field (offline, --from-db and d1 paths); baseline, rollback and eject --list emit documents too. - an unknown flag is refused by name with the command's valid flags, in one wording across every command (no more Node parseArgs text). - types --limit refuses non-numbers instead of reading them as unlimited. - init on an initialized project is a no-op (exit 0); --print-only forecasts the root CLAUDE.md/AGENTS.md wiring; the scaffolded meta.common.json is canonical, so a fresh project passes fmt --check. - metadata that does not load exits 1 in every command (gen, docs and migrate exited 2); verify --db reports an unreachable database as a gate that could not run, not as drift. - the offline migrate path infers the dialect from --db, as the help says. - -V prints the version, and a bare version flag is answered before the command graph loads. - the help footer points at metaobjects.dev. C# `dotnet meta`: gen and docs refuse unknown flags (they ran and exited 0), every command uses the same refusal listing valid flags, --version/-v/-V and --help/<command> --help work, the banner no longer shows --namespace as required, and verify --codegen exits 1 (not 2) on metadata that does not load. Python `metaobjects`: --version/-v/-V work, and an unknown flag is refused for the subcommand it was given to, with that subcommand's flags listed. Java and Kotlin ship no command-line tool (Maven goals only), so none of these defect classes apply there.
1 parent 00b7fed commit e0fc3f4

39 files changed

Lines changed: 1015 additions & 165 deletions

‎docs/features/cli.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,13 @@ Rules of the contract:
9696
`.gitignore` says which of them it COMMITS.** Narrowing `surfaces` to silence the
9797
gate also stops `meta docs` producing those pages, which is usually not what you
9898
want.
99-
- **Unknown/invalid flag → exit 2** with usage.
99+
- **Unknown/invalid flag → exit 2**, refused by name with that command's valid flags listed
100+
— one wording in every port's CLI (`unknown flag --x for \`meta gen\`. Valid flags: …`).
101+
A numeric flag given a non-number (`--limit abc`) is the same usage error.
102+
- **Exit codes agree across the CLIs** (`meta`, `dotnet meta`, `metaobjects`): `0` success,
103+
including a no-op re-run; `1` a runtime failure, including metadata that does not load,
104+
in every command; `2` a usage error. `--version`, `-v` and `-V` print the bare version
105+
in each.
100106
- **The overlay authoring lint runs on every `meta verify`, not gated on any
101107
subverb** (FR-023) — a top-level `(type, resolutionKey)` declared in two or more
102108
collection files (dependency artifacts included) where more than one declaration
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
using Xunit;
2+
3+
namespace MetaObjects.Cli.Tests;
4+
5+
/// <summary>
6+
/// The command-line surface every port's CLI shares with the Node <c>meta</c>: the three
7+
/// version spellings, <c>--help</c> as output rather than an error, and an unknown flag
8+
/// refused by name with the command's valid flags listed (exit 2). <c>gen</c> and
9+
/// <c>docs</c> used to drop an unknown flag and exit 0 as though it had been honoured.
10+
/// Driven through the built assembly, because Program.cs's argument parsing is
11+
/// top-level statements no test can call directly.
12+
/// </summary>
13+
public sealed class CliSurfaceTests : IDisposable
14+
{
15+
private readonly string _tmp = Path.Combine(Path.GetTempPath(), "meta-cli-surface-" + Guid.NewGuid().ToString("N"));
16+
17+
public CliSurfaceTests() => Directory.CreateDirectory(_tmp);
18+
19+
public void Dispose() { try { Directory.Delete(_tmp, recursive: true); } catch { } }
20+
21+
[Theory]
22+
[InlineData("--version")]
23+
[InlineData("-v")]
24+
[InlineData("-V")]
25+
public void A_version_flag_prints_the_bare_version_and_exits_0(string flag)
26+
{
27+
var (exit, stdout, stderr) = CliProcess.Run(_tmp, flag);
28+
Assert.True(exit == 0, $"exit={exit}\nstderr={stderr}");
29+
Assert.Matches(@"^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$", stdout.Trim());
30+
}
31+
32+
[Fact]
33+
public void Help_is_output_on_stdout_and_exits_0()
34+
{
35+
var (exit, stdout, _) = CliProcess.Run(_tmp, "--help");
36+
Assert.Equal(0, exit);
37+
Assert.Contains("usage: dotnet meta <command>", stdout);
38+
// --namespace defaults (GenCommand.DefaultNamespace); the banner must not show it as required.
39+
Assert.Contains("--out <dir> [--namespace <ns>]", stdout);
40+
}
41+
42+
[Theory]
43+
[InlineData("gen", "usage: dotnet meta gen")]
44+
[InlineData("verify", "usage: dotnet meta verify")]
45+
[InlineData("docs", "usage: dotnet meta docs")]
46+
[InlineData("fmt", "usage: dotnet meta fmt")]
47+
[InlineData("eject", "usage: dotnet meta eject")]
48+
public void A_command_help_prints_that_commands_usage_and_exits_0(string command, string expected)
49+
{
50+
var (exit, stdout, _) = CliProcess.Run(_tmp, command, "--help");
51+
Assert.Equal(0, exit);
52+
Assert.Contains(expected, stdout);
53+
}
54+
55+
[Theory]
56+
[InlineData("gen", "--generators", "x", "--out", "o")]
57+
[InlineData("docs", "--out", "o", "x")]
58+
[InlineData("verify", "x")]
59+
[InlineData("fmt")]
60+
[InlineData("eject", "entity")]
61+
public void An_unknown_flag_is_refused_by_name_with_the_valid_flags_listed(string command, params string[] rest)
62+
{
63+
var (exit, _, stderr) = CliProcess.Run(_tmp, [command, .. rest, "--bogus"]);
64+
Assert.Equal(2, exit);
65+
Assert.Contains($"unknown flag --bogus for `dotnet meta {command}`. Valid flags: ", stderr);
66+
}
67+
68+
[Fact]
69+
public void A_value_flag_with_no_value_says_so_rather_than_calling_it_unknown()
70+
{
71+
var (exit, _, stderr) = CliProcess.Run(_tmp, "gen", "x", "--out");
72+
Assert.Equal(2, exit);
73+
Assert.Contains("--out needs a value", stderr);
74+
}
75+
}

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,18 @@ public void Codegen_without_committed_output_is_exit2()
201201
Assert.NotNull(r.Codegen!.Error);
202202
}
203203

204+
[Fact]
205+
public void Codegen_with_metadata_that_does_not_load_is_exit1_not_the_usage_exit2()
206+
{
207+
// Metadata that does not load is a runtime failure: exit 1, the code gen, fmt,
208+
// docs and the templates gate already used and every other port uses. It used to
209+
// share exit 2 with "no --out given", a usage error.
210+
File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), """{ "metadata.root": { "children": [ """);
211+
var r = VerifyCommand.RunSubverbs(TemplatesOpts(templates: false, codegen: true));
212+
Assert.Equal(1, r.ExitCode);
213+
Assert.Contains("did not load", r.Codegen!.Error);
214+
}
215+
204216
// -------------------- the namespace-inference footgun --------------------
205217
// gen used a CUSTOM namespace; verify --codegen WITHOUT --namespace must infer
206218
// it from the committed output (else every file would spuriously drift on the
@@ -363,7 +375,8 @@ public void Codegen_load_failure_names_the_offending_attr_and_node()
363375
]}}
364376
""");
365377
var r = VerifyCommand.RunSubverbs(TemplatesOpts(templates: false, codegen: true));
366-
Assert.Equal(2, r.ExitCode);
378+
// A load failure exits 1 (runtime), as the Node `meta verify` does for the same file.
379+
Assert.Equal(1, r.ExitCode);
367380
Assert.NotNull(r.Codegen?.Error);
368381
Assert.Contains("noSuchAttr", r.Codegen!.Error);
369382
Assert.Contains("Thing", r.Codegen!.Error);

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

Lines changed: 78 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,24 @@
1010
using MetaObjects.Codegen;
1111
using MetaObjects.Config;
1212

13-
if (args.Length == 0)
13+
// `--version`, `-v` and `-V` print the bare version and exit 0 — the same three spellings
14+
// every port's CLI answers. Checked before anything else so a version probe never parses
15+
// a command.
16+
if (args.Length == 1 && args[0] is "--version" or "-v" or "-V")
1417
{
15-
Console.Error.WriteLine(
18+
Console.WriteLine(EjectCommand.ToolVersion());
19+
return 0;
20+
}
21+
22+
// `--help`/`-h` print the command reference on stdout and exit 0: asked-for help is
23+
// output, not an error. A bare `dotnet meta` is still a usage error (stderr, exit 2).
24+
bool helpRequested = args.Length > 0 && args[0] is "--help" or "-h";
25+
if (args.Length == 0 || helpRequested)
26+
{
27+
(helpRequested ? Console.Out : Console.Error).WriteLine(
1628
"usage: dotnet meta <command> [options]\n" +
1729
" commands:\n" +
18-
" gen <metadataDir> --out <dir> --namespace <ns> [--emit-abstract-shapes]\n" +
30+
" gen <metadataDir> --out <dir> [--namespace <ns>] [--emit-abstract-shapes]\n" +
1931
" [--generators <a,b,c>] [--template-root <dir>]\n" +
2032
" generate EF Core code from metadata\n" +
2133
" gen --list list available generators (stable names) and exit\n" +
@@ -37,8 +49,18 @@
3749
" see `dotnet meta gen --list`\n" +
3850
" fmt [<metadataDir>] [--check] rewrite metadata into canonical form (#304);\n" +
3951
" --check lists drift, exits non-zero, changes nothing\n" +
40-
" agent-docs see `npx meta agent-docs`");
41-
return 2;
52+
" agent-docs see `npx meta agent-docs`\n" +
53+
" --version, -v, -V print the version\n" +
54+
" <command> --help that command's usage");
55+
return helpRequested ? 0 : 2;
56+
}
57+
58+
// `<command> --help` answers with that command's usage on stdout, exit 0, before the
59+
// command parses anything — so help is never refused as an unknown flag.
60+
if (args.Length > 1 && CommandUsage.TryGetValue(args[0], out var usage) && args[1..].Any(a => a is "--help" or "-h"))
61+
{
62+
Console.WriteLine(usage);
63+
return 0;
4264
}
4365

4466
return args[0] switch
@@ -77,6 +99,9 @@ static int RunGen(string[] rest)
7799
else if (rest[i] == "--baseline" && i + 1 < rest.Length) baseline = rest[++i];
78100
else if (rest[i].StartsWith("--baseline=", StringComparison.Ordinal)) baseline = rest[i]["--baseline=".Length..];
79101
else if (!rest[i].StartsWith('-')) metadataDir ??= rest[i];
102+
// An unrecognised flag used to be dropped here: `gen ... --bogus` generated and
103+
// exited 0, as if the flag had been honoured.
104+
else return RefuseFlag("gen", rest[i], GenValueFlags, GenBoolFlags);
80105
}
81106

82107
// How a field with NO explicit `@column` becomes a physical column name. The
@@ -229,6 +254,7 @@ static int RunDocs(string[] rest)
229254
else if (rest[i] == "--project" && i + 1 < rest.Length) project = rest[++i];
230255
else if (rest[i] == "--model-base-url" && i + 1 < rest.Length) modelBaseUrl = rest[++i];
231256
else if (!rest[i].StartsWith('-')) metadataDir ??= rest[i];
257+
else return RefuseFlag("docs", rest[i], DocsValueFlags, []);
232258
}
233259

234260
// Usage-first — see the identical comment in RunGen above; a missing --out
@@ -284,12 +310,7 @@ static int RunFmt(string[] rest)
284310
foreach (var a in rest)
285311
{
286312
if (a == "--check") check = true;
287-
else if (a.StartsWith('-'))
288-
{
289-
Console.Error.WriteLine($"dotnet meta fmt: unknown option \"{a}\"");
290-
Console.Error.WriteLine("usage: dotnet meta fmt [<metadataDir>] [--check]");
291-
return 2;
292-
}
313+
else if (a.StartsWith('-')) return RefuseFlag("fmt", a, [], ["--check"]);
293314
else metadataDir ??= a;
294315
}
295316

@@ -366,12 +387,7 @@ static int RunEject(string[] rest)
366387
if (rest[i] == "--force") force = true;
367388
else if (rest[i] == "--root" && i + 1 < rest.Length) root = rest[++i];
368389
else if (!rest[i].StartsWith('-')) names.Add(rest[i]);
369-
else
370-
{
371-
Console.Error.WriteLine($"dotnet meta eject: unknown option \"{rest[i]}\"");
372-
Console.Error.WriteLine("usage: dotnet meta eject <name>... [--force] [--root <dir>]");
373-
return 2;
374-
}
390+
else return RefuseFlag("eject", rest[i], ["--root"], ["--force"]);
375391
}
376392

377393
var result = EjectCommand.Run(names, root ?? Directory.GetCurrentDirectory(), force);
@@ -430,6 +446,26 @@ static ResolvedMetadata ResolveMetadataDirOrExit(string? metadataDir)
430446
}
431447
}
432448

449+
/// <summary>
450+
/// The one refusal for a flag a command does not accept, in every command — the same
451+
/// shape the Node <c>meta</c> CLI uses. A known value flag with its value missing says
452+
/// so; anything else is named as unknown and the command's valid flags are listed, so the
453+
/// refusal corrects itself in one step. Exit 2 (usage).
454+
/// </summary>
455+
static int RefuseFlag(string command, string flag, string[] valueFlags, string[] boolFlags)
456+
{
457+
if (valueFlags.Contains(flag))
458+
{
459+
Console.Error.WriteLine($"dotnet meta {command}: {flag} needs a value");
460+
return 2;
461+
}
462+
var valid = valueFlags.Concat(boolFlags).Order(StringComparer.Ordinal);
463+
Console.Error.WriteLine(
464+
$"unknown flag {flag} for `dotnet meta {command}`. Valid flags: {string.Join(", ", valid)} " +
465+
"(also accepted everywhere: --help)");
466+
return 2;
467+
}
468+
433469
static int Unknown(string cmd)
434470
{
435471
Console.Error.WriteLine($"dotnet meta: unknown command \"{cmd}\"");
@@ -495,12 +531,7 @@ static int RunVerify(string[] rest)
495531
// wrong strategy and every one reports spurious drift on an otherwise-clean
496532
// project.
497533
else if (a == "--column-naming" && i + 1 < rest.Length) columnNamingRaw = rest[++i];
498-
else if (a.StartsWith('-'))
499-
{
500-
Console.Error.WriteLine($"dotnet meta verify: unknown option \"{a}\"");
501-
Console.Error.WriteLine("usage: dotnet meta verify <metadataDir> [--templates [--prompts <dir>]] [--codegen --out <dir> [--namespace <ns>] [--column-naming literal|snake_case|kebab-case]] [--db] [--lax] [--no-field-lint]");
502-
return 2;
503-
}
534+
else if (a.StartsWith('-')) return RefuseFlag("verify", a, VerifyValueFlags, VerifyBoolFlags);
504535
else if (metadataDir is null) metadataDir = a;
505536
// A second positional is the templates root for a BARE verify
506537
// (`verify <metadataDir> <templatesRoot>`) — keeps the historical default
@@ -651,3 +682,27 @@ static int RunVerify(string[] rest)
651682
return codegenHandedOff ? Math.Max(result.ExitCode, codegenHandoffExit) : result.ExitCode;
652683
}
653684

685+
686+
/// <summary>The flags each command parses, for <see cref="RefuseFlag"/>. Kept beside the
687+
/// parsers' own branches; a flag added to one must be added here.</summary>
688+
partial class Program
689+
{
690+
static readonly string[] GenValueFlags =
691+
["--out", "--namespace", "--generators", "--template-root", "--template-spec", "--column-naming", "--baseline"];
692+
static readonly string[] GenBoolFlags = ["--list", "--emit-abstract-shapes"];
693+
static readonly string[] DocsValueFlags = ["--out", "--namespace", "--project", "--model-base-url"];
694+
static readonly string[] VerifyValueFlags =
695+
["--prompts", "--out", "--namespace", "--generators", "--template-root", "--column-naming"];
696+
static readonly string[] VerifyBoolFlags = ["--templates", "--codegen", "--db", "--lax", "--no-field-lint"];
697+
698+
/// <summary>Each command's usage, printed by <c>dotnet meta &lt;command&gt; --help</c>.</summary>
699+
static readonly Dictionary<string, string> CommandUsage = new()
700+
{
701+
["gen"] = "usage: dotnet meta gen <metadataDir> --out <dir> [--namespace <ns>] [--generators <a,b,c>] [--template-root <dir>] [--template-spec <json>] [--emit-abstract-shapes] [--column-naming literal|snake_case|kebab-case] [--baseline default|adopt]\n" +
702+
" dotnet meta gen --list",
703+
["verify"] = "usage: dotnet meta verify <metadataDir> [--templates [--prompts <dir>]] [--codegen --out <dir> [--namespace <ns>] [--generators <a,b,c>] [--template-root <dir>] [--column-naming literal|snake_case|kebab-case]] [--db] [--lax] [--no-field-lint]",
704+
["docs"] = "usage: dotnet meta docs <metadataDir> --out <dir> [--namespace <ns>] [--project <name>] [--model-base-url <url>]",
705+
["fmt"] = "usage: dotnet meta fmt [<metadataDir>] [--check]",
706+
["eject"] = "usage: dotnet meta eject <name>... [--force] [--root <dir>]",
707+
};
708+
}

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

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,12 @@ public static SubverbResult RunSubverbs(Options opts)
197197
Codegen.CodegenDrift.Result? codegenResult = null;
198198
if (runCodegen)
199199
{
200-
codegenResult = RunCodegenDrift(opts);
201-
// error (nothing to diff against) → exit 2; drift → exit 1; clean → 0.
202-
int codegenExit = codegenResult.Error is not null ? 2 : (codegenResult.Clean ? 0 : 1);
200+
codegenResult = RunCodegenDrift(opts, out var codegenLoadFailed);
201+
// usage error (nothing to diff against) → exit 2; metadata that does not load →
202+
// exit 1, the code every port's gen/verify/fmt uses for it; drift → 1; clean → 0.
203+
int codegenExit = codegenResult.Error is null
204+
? (codegenResult.Clean ? 0 : 1)
205+
: (codegenLoadFailed ? 1 : 2);
203206
exit = Math.Max(exit, codegenExit);
204207
}
205208

@@ -237,10 +240,13 @@ private static LoadResult LoadMetadata(Options opts) => opts.MetadataFiles is {
237240
/// Run the codegen-drift gate: load metadata, resolve the generator suite (default
238241
/// or the <c>--generators</c> selection), and diff a fresh regen against the
239242
/// committed <c>--out</c> dir. Loader / unknown-generator problems surface as a
240-
/// drift <see cref="Codegen.CodegenDrift.Result.Error"/> (exit 2), never a throw.
243+
/// drift <see cref="Codegen.CodegenDrift.Result.Error"/>, never a throw;
244+
/// <paramref name="loadFailed"/> says the metadata itself did not load (exit 1, not
245+
/// the usage exit 2 a missing <c>--out</c> or an unknown generator gets).
241246
/// </summary>
242-
private static Codegen.CodegenDrift.Result RunCodegenDrift(Options opts)
247+
private static Codegen.CodegenDrift.Result RunCodegenDrift(Options opts, out bool loadFailed)
243248
{
249+
loadFailed = false;
244250
if (opts.OutDir is null)
245251
return new Codegen.CodegenDrift.Result
246252
{
@@ -250,7 +256,8 @@ private static Codegen.CodegenDrift.Result RunCodegenDrift(Options opts)
250256
};
251257

252258
var load = LoadMetadata(opts);
253-
if (load.Errors.Count > 0)
259+
loadFailed = load.Errors.Count > 0;
260+
if (loadFailed)
254261
return new Codegen.CodegenDrift.Result
255262
{
256263
Clean = false,

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

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2409,6 +2409,7 @@ def _build_parser() -> argparse.ArgumentParser:
24092409
"owned by the Node `meta` CLI (ADR-0015) — there is no `migrate` "
24102410
"subcommand here."
24112411
),
2412+
epilog="--version, -v, -V print the version and exit",
24122413
)
24132414
sub = parser.add_subparsers(dest="command", required=True)
24142415

@@ -2759,10 +2760,53 @@ def _build_parser() -> argparse.ArgumentParser:
27592760
return parser
27602761

27612762

2763+
# The three spellings that print the bare version — the same set every port's CLI answers.
2764+
VERSION_FLAGS = ("--version", "-v", "-V")
2765+
2766+
2767+
def _refuse_extras(parser: argparse.ArgumentParser, command: str, extras: list[str]) -> int:
2768+
"""Refuse what a subcommand did not consume, naming the subcommand and its flags.
2769+
2770+
argparse attributes a subcommand's unknown flag to the TOP-LEVEL parser: it printed
2771+
``usage: metaobjects [-h] {gen,docs,...}`` and ``unrecognized arguments: --x``, which
2772+
names neither the command nor a flag that would have worked. This is the one wording
2773+
every port's CLI uses for it, and it exits 2.
2774+
"""
2775+
subparser = next(
2776+
(
2777+
action.choices.get(command)
2778+
for action in parser._actions # noqa: SLF001 — argparse exposes no public accessor
2779+
if isinstance(action, argparse._SubParsersAction) # noqa: SLF001
2780+
),
2781+
None,
2782+
)
2783+
first = extras[0]
2784+
if not first.startswith("-") or subparser is None:
2785+
print(f"unexpected argument {first!r} for `metaobjects {command}`", file=sys.stderr)
2786+
return 2
2787+
valid = sorted(
2788+
", ".join(action.option_strings)
2789+
for action in subparser._actions # noqa: SLF001
2790+
if action.option_strings and "--help" not in action.option_strings
2791+
)
2792+
print(
2793+
f"unknown flag {first} for `metaobjects {command}`. Valid flags: {', '.join(valid)} "
2794+
"(also accepted everywhere: --help)",
2795+
file=sys.stderr,
2796+
)
2797+
return 2
2798+
2799+
27622800
def main(argv: list[str] | None = None) -> int:
27632801
"""Entry point. Returns the process exit code (does not call ``sys.exit``)."""
2802+
raw = sys.argv[1:] if argv is None else argv
2803+
if len(raw) == 1 and raw[0] in VERSION_FLAGS:
2804+
print(installed_metaobjects_version())
2805+
return 0
27642806
parser = _build_parser()
2765-
args = parser.parse_args(argv)
2807+
args, extras = parser.parse_known_args(raw)
2808+
if extras:
2809+
return _refuse_extras(parser, args.command, extras)
27662810
return int(args.func(args))
27672811

27682812

0 commit comments

Comments
 (0)