Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/features/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,13 @@ Rules of the contract:
`.gitignore` says which of them it COMMITS.** Narrowing `surfaces` to silence the
gate also stops `meta docs` producing those pages, which is usually not what you
want.
- **Unknown/invalid flag → exit 2** with usage.
- **Unknown/invalid flag → exit 2**, refused by name with that command's valid flags listed
— one wording in every port's CLI (`unknown flag --x for \`meta gen\`. Valid flags: …`).
A numeric flag given a non-number (`--limit abc`) is the same usage error.
- **Exit codes agree across the CLIs** (`meta`, `dotnet meta`, `metaobjects`): `0` success,
including a no-op re-run; `1` a runtime failure, including metadata that does not load,
in every command; `2` a usage error. `--version`, `-v` and `-V` print the bare version
in each.
- **The overlay authoring lint runs on every `meta verify`, not gated on any
subverb** (FR-023) — a top-level `(type, resolutionKey)` declared in two or more
collection files (dependency artifacts included) where more than one declaration
Expand Down
75 changes: 75 additions & 0 deletions server/csharp/MetaObjects.Cli.Tests/CliSurfaceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using Xunit;

namespace MetaObjects.Cli.Tests;

/// <summary>
/// The command-line surface every port's CLI shares with the Node <c>meta</c>: the three
/// version spellings, <c>--help</c> as output rather than an error, and an unknown flag
/// refused by name with the command's valid flags listed (exit 2). <c>gen</c> and
/// <c>docs</c> used to drop an unknown flag and exit 0 as though it had been honoured.
/// Driven through the built assembly, because Program.cs's argument parsing is
/// top-level statements no test can call directly.
/// </summary>
public sealed class CliSurfaceTests : IDisposable
{
private readonly string _tmp = Path.Combine(Path.GetTempPath(), "meta-cli-surface-" + Guid.NewGuid().ToString("N"));

public CliSurfaceTests() => Directory.CreateDirectory(_tmp);

public void Dispose() { try { Directory.Delete(_tmp, recursive: true); } catch { } }

[Theory]
[InlineData("--version")]
[InlineData("-v")]
[InlineData("-V")]
public void A_version_flag_prints_the_bare_version_and_exits_0(string flag)
{
var (exit, stdout, stderr) = CliProcess.Run(_tmp, flag);
Assert.True(exit == 0, $"exit={exit}\nstderr={stderr}");
Assert.Matches(@"^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$", stdout.Trim());
}

[Fact]
public void Help_is_output_on_stdout_and_exits_0()
{
var (exit, stdout, _) = CliProcess.Run(_tmp, "--help");
Assert.Equal(0, exit);
Assert.Contains("usage: dotnet meta <command>", stdout);
// --namespace defaults (GenCommand.DefaultNamespace); the banner must not show it as required.
Assert.Contains("--out <dir> [--namespace <ns>]", stdout);
}

[Theory]
[InlineData("gen", "usage: dotnet meta gen")]
[InlineData("verify", "usage: dotnet meta verify")]
[InlineData("docs", "usage: dotnet meta docs")]
[InlineData("fmt", "usage: dotnet meta fmt")]
[InlineData("eject", "usage: dotnet meta eject")]
public void A_command_help_prints_that_commands_usage_and_exits_0(string command, string expected)
{
var (exit, stdout, _) = CliProcess.Run(_tmp, command, "--help");
Assert.Equal(0, exit);
Assert.Contains(expected, stdout);
}

[Theory]
[InlineData("gen", "--generators", "x", "--out", "o")]
[InlineData("docs", "--out", "o", "x")]
[InlineData("verify", "x")]
[InlineData("fmt")]
[InlineData("eject", "entity")]
public void An_unknown_flag_is_refused_by_name_with_the_valid_flags_listed(string command, params string[] rest)
{
var (exit, _, stderr) = CliProcess.Run(_tmp, [command, .. rest, "--bogus"]);
Assert.Equal(2, exit);
Assert.Contains($"unknown flag --bogus for `dotnet meta {command}`. Valid flags: ", stderr);
}

[Fact]
public void A_value_flag_with_no_value_says_so_rather_than_calling_it_unknown()
{
var (exit, _, stderr) = CliProcess.Run(_tmp, "gen", "x", "--out");
Assert.Equal(2, exit);
Assert.Contains("--out needs a value", stderr);
}
}
15 changes: 14 additions & 1 deletion server/csharp/MetaObjects.Cli.Tests/VerifySubverbTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,18 @@ public void Codegen_without_committed_output_is_exit2()
Assert.NotNull(r.Codegen!.Error);
}

[Fact]
public void Codegen_with_metadata_that_does_not_load_is_exit1_not_the_usage_exit2()
{
// Metadata that does not load is a runtime failure: exit 1, the code gen, fmt,
// docs and the templates gate already used and every other port uses. It used to
// share exit 2 with "no --out given", a usage error.
File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), """{ "metadata.root": { "children": [ """);
var r = VerifyCommand.RunSubverbs(TemplatesOpts(templates: false, codegen: true));
Assert.Equal(1, r.ExitCode);
Assert.Contains("did not load", r.Codegen!.Error);
}

// -------------------- the namespace-inference footgun --------------------
// gen used a CUSTOM namespace; verify --codegen WITHOUT --namespace must infer
// it from the committed output (else every file would spuriously drift on the
Expand Down Expand Up @@ -363,7 +375,8 @@ public void Codegen_load_failure_names_the_offending_attr_and_node()
]}}
""");
var r = VerifyCommand.RunSubverbs(TemplatesOpts(templates: false, codegen: true));
Assert.Equal(2, r.ExitCode);
// A load failure exits 1 (runtime), as the Node `meta verify` does for the same file.
Assert.Equal(1, r.ExitCode);
Assert.NotNull(r.Codegen?.Error);
Assert.Contains("noSuchAttr", r.Codegen!.Error);
Assert.Contains("Thing", r.Codegen!.Error);
Expand Down
101 changes: 78 additions & 23 deletions server/csharp/MetaObjects.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,24 @@
using MetaObjects.Codegen;
using MetaObjects.Config;

if (args.Length == 0)
// `--version`, `-v` and `-V` print the bare version and exit 0 — the same three spellings
// every port's CLI answers. Checked before anything else so a version probe never parses
// a command.
if (args.Length == 1 && args[0] is "--version" or "-v" or "-V")
{
Console.Error.WriteLine(
Console.WriteLine(EjectCommand.ToolVersion());
return 0;
}

// `--help`/`-h` print the command reference on stdout and exit 0: asked-for help is
// output, not an error. A bare `dotnet meta` is still a usage error (stderr, exit 2).
bool helpRequested = args.Length > 0 && args[0] is "--help" or "-h";
if (args.Length == 0 || helpRequested)
{
(helpRequested ? Console.Out : Console.Error).WriteLine(
"usage: dotnet meta <command> [options]\n" +
" commands:\n" +
" gen <metadataDir> --out <dir> --namespace <ns> [--emit-abstract-shapes]\n" +
" gen <metadataDir> --out <dir> [--namespace <ns>] [--emit-abstract-shapes]\n" +
" [--generators <a,b,c>] [--template-root <dir>]\n" +
" generate EF Core code from metadata\n" +
" gen --list list available generators (stable names) and exit\n" +
Expand All @@ -37,8 +49,18 @@
" see `dotnet meta gen --list`\n" +
" fmt [<metadataDir>] [--check] rewrite metadata into canonical form (#304);\n" +
" --check lists drift, exits non-zero, changes nothing\n" +
" agent-docs see `npx meta agent-docs`");
return 2;
" agent-docs see `npx meta agent-docs`\n" +
" --version, -v, -V print the version\n" +
" <command> --help that command's usage");
return helpRequested ? 0 : 2;
}

// `<command> --help` answers with that command's usage on stdout, exit 0, before the
// command parses anything — so help is never refused as an unknown flag.
if (args.Length > 1 && CommandUsage.TryGetValue(args[0], out var usage) && args[1..].Any(a => a is "--help" or "-h"))
{
Console.WriteLine(usage);
return 0;
}

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

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

// Usage-first — see the identical comment in RunGen above; a missing --out
Expand Down Expand Up @@ -284,12 +310,7 @@ static int RunFmt(string[] rest)
foreach (var a in rest)
{
if (a == "--check") check = true;
else if (a.StartsWith('-'))
{
Console.Error.WriteLine($"dotnet meta fmt: unknown option \"{a}\"");
Console.Error.WriteLine("usage: dotnet meta fmt [<metadataDir>] [--check]");
return 2;
}
else if (a.StartsWith('-')) return RefuseFlag("fmt", a, [], ["--check"]);
else metadataDir ??= a;
}

Expand Down Expand Up @@ -366,12 +387,7 @@ static int RunEject(string[] rest)
if (rest[i] == "--force") force = true;
else if (rest[i] == "--root" && i + 1 < rest.Length) root = rest[++i];
else if (!rest[i].StartsWith('-')) names.Add(rest[i]);
else
{
Console.Error.WriteLine($"dotnet meta eject: unknown option \"{rest[i]}\"");
Console.Error.WriteLine("usage: dotnet meta eject <name>... [--force] [--root <dir>]");
return 2;
}
else return RefuseFlag("eject", rest[i], ["--root"], ["--force"]);
}

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

/// <summary>
/// The one refusal for a flag a command does not accept, in every command — the same
/// shape the Node <c>meta</c> CLI uses. A known value flag with its value missing says
/// so; anything else is named as unknown and the command's valid flags are listed, so the
/// refusal corrects itself in one step. Exit 2 (usage).
/// </summary>
static int RefuseFlag(string command, string flag, string[] valueFlags, string[] boolFlags)
{
if (valueFlags.Contains(flag))
{
Console.Error.WriteLine($"dotnet meta {command}: {flag} needs a value");
return 2;
}
var valid = valueFlags.Concat(boolFlags).Order(StringComparer.Ordinal);
Console.Error.WriteLine(
$"unknown flag {flag} for `dotnet meta {command}`. Valid flags: {string.Join(", ", valid)} " +
"(also accepted everywhere: --help)");
return 2;
}

static int Unknown(string cmd)
{
Console.Error.WriteLine($"dotnet meta: unknown command \"{cmd}\"");
Expand Down Expand Up @@ -495,12 +531,7 @@ static int RunVerify(string[] rest)
// wrong strategy and every one reports spurious drift on an otherwise-clean
// project.
else if (a == "--column-naming" && i + 1 < rest.Length) columnNamingRaw = rest[++i];
else if (a.StartsWith('-'))
{
Console.Error.WriteLine($"dotnet meta verify: unknown option \"{a}\"");
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]");
return 2;
}
else if (a.StartsWith('-')) return RefuseFlag("verify", a, VerifyValueFlags, VerifyBoolFlags);
else if (metadataDir is null) metadataDir = a;
// A second positional is the templates root for a BARE verify
// (`verify <metadataDir> <templatesRoot>`) — keeps the historical default
Expand Down Expand Up @@ -651,3 +682,27 @@ static int RunVerify(string[] rest)
return codegenHandedOff ? Math.Max(result.ExitCode, codegenHandoffExit) : result.ExitCode;
}


/// <summary>The flags each command parses, for <see cref="RefuseFlag"/>. Kept beside the
/// parsers' own branches; a flag added to one must be added here.</summary>
partial class Program
{
static readonly string[] GenValueFlags =
["--out", "--namespace", "--generators", "--template-root", "--template-spec", "--column-naming", "--baseline"];
static readonly string[] GenBoolFlags = ["--list", "--emit-abstract-shapes"];
static readonly string[] DocsValueFlags = ["--out", "--namespace", "--project", "--model-base-url"];
static readonly string[] VerifyValueFlags =
["--prompts", "--out", "--namespace", "--generators", "--template-root", "--column-naming"];
static readonly string[] VerifyBoolFlags = ["--templates", "--codegen", "--db", "--lax", "--no-field-lint"];

/// <summary>Each command's usage, printed by <c>dotnet meta &lt;command&gt; --help</c>.</summary>
static readonly Dictionary<string, string> CommandUsage = new()
{
["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" +
" dotnet meta gen --list",
["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]",
["docs"] = "usage: dotnet meta docs <metadataDir> --out <dir> [--namespace <ns>] [--project <name>] [--model-base-url <url>]",
["fmt"] = "usage: dotnet meta fmt [<metadataDir>] [--check]",
["eject"] = "usage: dotnet meta eject <name>... [--force] [--root <dir>]",
};
}
19 changes: 13 additions & 6 deletions server/csharp/MetaObjects.Cli/VerifyCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,12 @@ public static SubverbResult RunSubverbs(Options opts)
Codegen.CodegenDrift.Result? codegenResult = null;
if (runCodegen)
{
codegenResult = RunCodegenDrift(opts);
// error (nothing to diff against) → exit 2; drift → exit 1; clean → 0.
int codegenExit = codegenResult.Error is not null ? 2 : (codegenResult.Clean ? 0 : 1);
codegenResult = RunCodegenDrift(opts, out var codegenLoadFailed);
// usage error (nothing to diff against) → exit 2; metadata that does not load →
// exit 1, the code every port's gen/verify/fmt uses for it; drift → 1; clean → 0.
int codegenExit = codegenResult.Error is null
? (codegenResult.Clean ? 0 : 1)
: (codegenLoadFailed ? 1 : 2);
exit = Math.Max(exit, codegenExit);
}

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

var load = LoadMetadata(opts);
if (load.Errors.Count > 0)
loadFailed = load.Errors.Count > 0;
if (loadFailed)
return new Codegen.CodegenDrift.Result
{
Clean = false,
Expand Down
46 changes: 45 additions & 1 deletion server/python/src/metaobjects/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2409,6 +2409,7 @@ def _build_parser() -> argparse.ArgumentParser:
"owned by the Node `meta` CLI (ADR-0015) — there is no `migrate` "
"subcommand here."
),
epilog="--version, -v, -V print the version and exit",
)
sub = parser.add_subparsers(dest="command", required=True)

Expand Down Expand Up @@ -2759,10 +2760,53 @@ def _build_parser() -> argparse.ArgumentParser:
return parser


# The three spellings that print the bare version — the same set every port's CLI answers.
VERSION_FLAGS = ("--version", "-v", "-V")


def _refuse_extras(parser: argparse.ArgumentParser, command: str, extras: list[str]) -> int:
"""Refuse what a subcommand did not consume, naming the subcommand and its flags.

argparse attributes a subcommand's unknown flag to the TOP-LEVEL parser: it printed
``usage: metaobjects [-h] {gen,docs,...}`` and ``unrecognized arguments: --x``, which
names neither the command nor a flag that would have worked. This is the one wording
every port's CLI uses for it, and it exits 2.
"""
subparser = next(
(
action.choices.get(command)
for action in parser._actions # noqa: SLF001 — argparse exposes no public accessor
if isinstance(action, argparse._SubParsersAction) # noqa: SLF001
),
None,
)
first = extras[0]
if not first.startswith("-") or subparser is None:
print(f"unexpected argument {first!r} for `metaobjects {command}`", file=sys.stderr)
return 2
valid = sorted(
", ".join(action.option_strings)
for action in subparser._actions # noqa: SLF001
if action.option_strings and "--help" not in action.option_strings
)
print(
f"unknown flag {first} for `metaobjects {command}`. Valid flags: {', '.join(valid)} "
"(also accepted everywhere: --help)",
file=sys.stderr,
)
return 2


def main(argv: list[str] | None = None) -> int:
"""Entry point. Returns the process exit code (does not call ``sys.exit``)."""
raw = sys.argv[1:] if argv is None else argv
if len(raw) == 1 and raw[0] in VERSION_FLAGS:
print(installed_metaobjects_version())
return 0
parser = _build_parser()
args = parser.parse_args(argv)
args, extras = parser.parse_known_args(raw)
if extras:
return _refuse_extras(parser, args.command, extras)
return int(args.func(args))


Expand Down
Loading
Loading