From db47590ca783c3fe2cc378f079fdf4add21594f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 08:43:51 +0000 Subject: [PATCH] feat(cli): answer a refused command line with a usage exit code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The follow-up ADR-0067 left open. Until now a command line the parser refused exited -1 — a value in no exit-code table, chosen by Spectre.Console.Cli rather than by this repository — and wrote nothing to either stream, so a mistyped command produced no code a pipeline could read and no text a human could. It now exits 64 with a diagnostic on standard error naming what was refused and pointing at --help. 64 is EX_USAGE, the conventional value for a command-line usage error; 2, the other convention, is unavailable because catalog diff already owns it for "changes detected" — the collision ADR-0067 exists to prevent. The code separates what a retry cannot fix from what it might: 64 means the invocation is wrong, 1 means the tool ran and could not finish. The boundary is the parser's. An unknown command, a branch subcommand that does not exist, an option given without its value are refused before any command is constructed and answer 64; a value a command validates itself, such as --fail-on bogus, is that command's own rejection and stays 1. The command tree moves from Program.cs into CliApplication so the parse path can be tested at all: a top-level program's statements are reachable only by launching a process, and these codes are contract. Six tests cover it, one of them pinning the regression itself — a refused command line never again exits -1 silently. The published tables in the catalog-versioning reference gain the code, English and French. The CI guide needed no change: it propagates the code without enumerating the set. Lenient option parsing is untouched. An unknown OPTION such as --nope is still tolerated by the parser rather than refused, so it does not reach 64; making it a usage error means UseStrictParsing, which would break every caller passing an extra argument today. That is a separate decision. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SuSBsGG7wMPMnSeTHKZAom --- .../CliApplicationExitCodeTests.cs | 92 +++++++++++++++++++ FirstClassErrors.Cli/CliApplication.cs | 86 +++++++++++++++++ FirstClassErrors.Cli/ExitCodes.cs | 9 ++ FirstClassErrors.Cli/Program.cs | 37 +------- .../CatalogVersioningReference.en.md | 2 + .../CatalogVersioningReference.fr.md | 2 + 6 files changed, 195 insertions(+), 33 deletions(-) create mode 100644 FirstClassErrors.Cli.UnitTests/CliApplicationExitCodeTests.cs create mode 100644 FirstClassErrors.Cli/CliApplication.cs diff --git a/FirstClassErrors.Cli.UnitTests/CliApplicationExitCodeTests.cs b/FirstClassErrors.Cli.UnitTests/CliApplicationExitCodeTests.cs new file mode 100644 index 00000000..062ac0a7 --- /dev/null +++ b/FirstClassErrors.Cli.UnitTests/CliApplicationExitCodeTests.cs @@ -0,0 +1,92 @@ +#region Usings declarations + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace FirstClassErrors.Cli.UnitTests; + +/// +/// The exit codes the entry point answers with for a command line that never reaches a command. They are part of +/// the tool's published contract (decision: ADR-0067), and the parse path is the one a command's own tests cannot +/// reach — it fails before any command is constructed. +/// +[TestSubject(typeof(CliApplication))] +public sealed class CliApplicationExitCodeTests { + + #region Statics members declarations + + private static (int exitCode, string error) Run(params string[] args) { + TextWriter original = Console.Error; + StringWriter captured = new(); + try { + Console.SetError(captured); + + return (CliApplication.RunAsync(args).GetAwaiter().GetResult(), captured.ToString()); + } finally { + Console.SetError(original); + } + } + + #endregion + + [Fact(DisplayName = "An unknown command is a usage error (64), reported on standard error.")] + public void AnUnknownCommandIsAUsageError() { + // Exercise + (int exitCode, string error) = Run("frobnicate"); + + // Verify: the code says "this invocation is wrong", and the run says so rather than exiting silently. + Check.That(exitCode).IsEqualTo(64); + Check.That(error).Contains("frobnicate"); + Check.That(error).Contains("fce --help"); + } + + [Fact(DisplayName = "An unknown command inside a branch is a usage error (64) too.")] + public void AnUnknownCommandInsideABranchIsAUsageError() { + // Exercise + (int exitCode, string error) = Run("catalog", "frobnicate"); + + // Verify + Check.That(exitCode).IsEqualTo(64); + Check.That(error).Contains("frobnicate"); + } + + [Fact(DisplayName = "An option given without its value is a usage error (64).")] + public void AnOptionWithoutItsValueIsAUsageError() { + // Exercise + (int exitCode, string error) = Run("generate", "--solution"); + + // Verify + Check.That(exitCode).IsEqualTo(64); + Check.That(error).Contains("solution"); + } + + // The regression this pins: before the handler existed, the parser's own failure path returned -1 — a value in no + // exit-code table — and wrote nothing to either stream, so a mistyped command produced nothing at all to read. + [Fact(DisplayName = "A refused command line never exits with the parser's own -1, silently.")] + public void ARefusedCommandLineNeverExitsWithMinusOne() { + // Exercise + (int exitCode, string error) = Run("frobnicate"); + + // Verify + Check.That(exitCode).IsNotEqualTo(-1); + Check.That(error).IsNotEmpty(); + } + + [Fact(DisplayName = "Asking for help is not a usage error: it succeeds (0).")] + public void AskingForHelpSucceeds() { + // Exercise + (int exitCode, string _) = Run("--help"); + + // Verify: --help is what the usage error tells the caller to run, so it must not itself be an error. + Check.That(exitCode).IsEqualTo(0); + } + + [Fact(DisplayName = "The entry point rejects a null argument array.")] + public void RejectsANullArgumentArray() { + Check.ThatCode(() => CliApplication.RunAsync(null!)).Throws(); + } + +} diff --git a/FirstClassErrors.Cli/CliApplication.cs b/FirstClassErrors.Cli/CliApplication.cs new file mode 100644 index 00000000..64ccce7e --- /dev/null +++ b/FirstClassErrors.Cli/CliApplication.cs @@ -0,0 +1,86 @@ +#region Usings declarations + +using Spectre.Console.Cli; + +#endregion + +namespace FirstClassErrors.Cli; + +/// +/// Builds and runs the fce command tree. It exists as a type rather than as the body of +/// Program.cs so the entry point can be exercised by a test: a top-level program's statements are +/// reachable only by launching a process, and the exit code a bad command line produces is part of the tool's +/// published contract (decision: ADR-0067). +/// +/// +/// The command tree's own failures are reported inside each command, which returns +/// after a terse line. What reaches the handler here is what the commands never +/// see: a command line the parser refused, so no command ever ran. +/// +internal static class CliApplication { + + #region Statics members declarations + + /// + /// Runs the command tree over . + /// + /// The process arguments, as given on the command line. + /// The exit code, always one of . + internal static Task RunAsync(string[] args) { + if (args is null) { throw new ArgumentNullException(nameof(args)); } + + CommandApp app = new(); + app.Configure(Configure); + + return app.RunAsync(args); + } + + private static void Configure(IConfigurator config) { + config.SetApplicationName("fce"); + config.SetExceptionHandler(HandleUncaught); + + config.AddCommand("generate") + .WithDescription("Generate error documentation from a solution or from assemblies."); + + config.AddBranch("catalog", catalog => { + catalog.SetDescription("Track the error catalog as a versioned contract (baseline + diff)."); + catalog.AddCommand("update").WithDescription("Create or refresh the catalog baseline (deliberately accept the current contract)."); + catalog.AddCommand("diff").WithDescription("Compare the current catalog against the baseline and report the changes."); + }); + + config.AddBranch("config", configuration => { + configuration.SetDescription("Manage the configuration file (fce.json)."); + configuration.AddCommand("init").WithDescription("Create the configuration file."); + configuration.AddCommand("show").WithDescription("Print the current configuration."); + + configuration.AddBranch("renderer", renderer => { + renderer.SetDescription("Manage the custom renderer libraries referenced by the configuration."); + renderer.AddCommand("add").WithDescription("Register a renderer library."); + renderer.AddCommand("remove").WithDescription("Unregister a renderer library."); + renderer.AddCommand("list").WithDescription("List available renderers (built-in and configured)."); + }); + }); + } + + /// + /// Answers for whatever escapes the command tree, and keeps that answer inside . + /// + /// + /// Without a handler the parser's own failure path returned -1 — a value in no exit-code table, and + /// silent on both streams, so a mistyped command produced nothing at all to read. A wrong command line is a + /// usage error, which names; anything else reaching here is a failure the + /// commands did not catch, and it reports as one rather than borrowing the usage code. + /// + private static int HandleUncaught(Exception exception, ITypeResolver? resolver) { + _ = resolver; + bool usage = exception is CommandParseException or CommandTemplateException or CommandConfigurationException; + + Console.Error.WriteLine($"error: {exception.Message}"); + if (usage) { Console.Error.WriteLine("Run 'fce --help' to see the available commands."); } + + return usage ? ExitCodes.UsageError : ExitCodes.Failure; + } + + #endregion + +} diff --git a/FirstClassErrors.Cli/ExitCodes.cs b/FirstClassErrors.Cli/ExitCodes.cs index 8e84ef0e..6e710691 100644 --- a/FirstClassErrors.Cli/ExitCodes.cs +++ b/FirstClassErrors.Cli/ExitCodes.cs @@ -24,6 +24,15 @@ internal static class ExitCodes { /// internal const int ChangesDetected = 2; + /// + /// The command line could not be parsed: an unknown command, a malformed option, an argument the settings + /// reject. Distinct from so a pipeline can tell "this invocation is wrong" — which a + /// retry will never fix — from "the tool ran and could not finish". 64 is EX_USAGE, the + /// conventional value for a command-line usage error; already owns 2, + /// the other convention for it. + /// + internal const int UsageError = 64; + /// /// The run was cancelled (Ctrl+C). The conventional value for a process killed by a signal is /// 128 + signal, and SIGINT is signal 2 — an abort, not a failure. diff --git a/FirstClassErrors.Cli/Program.cs b/FirstClassErrors.Cli/Program.cs index bba2bda8..04a6f99f 100644 --- a/FirstClassErrors.Cli/Program.cs +++ b/FirstClassErrors.Cli/Program.cs @@ -2,38 +2,9 @@ using FirstClassErrors.Cli; -using Spectre.Console.Cli; - #endregion -CommandApp app = new(); - -app.Configure(config => { - config.SetApplicationName("fce"); - - config.AddCommand("generate") - .WithDescription("Generate error documentation from a solution or from assemblies."); - - config.AddBranch("catalog", catalog => { - catalog.SetDescription("Track the error catalog as a versioned contract (baseline + diff)."); - catalog.AddCommand("update").WithDescription("Create or refresh the catalog baseline (deliberately accept the current contract)."); - catalog.AddCommand("diff").WithDescription("Compare the current catalog against the baseline and report the changes."); - }); - - config.AddBranch("config", configuration => { - configuration.SetDescription("Manage the configuration file (fce.json)."); - configuration.AddCommand("init").WithDescription("Create the configuration file."); - configuration.AddCommand("show").WithDescription("Print the current configuration."); - - configuration.AddBranch("renderer", renderer => { - renderer.SetDescription("Manage the custom renderer libraries referenced by the configuration."); - renderer.AddCommand("add").WithDescription("Register a renderer library."); - renderer.AddCommand("remove").WithDescription("Unregister a renderer library."); - renderer.AddCommand("list").WithDescription("List available renderers (built-in and configured)."); - }); - }); -}); - -// Spectre handles argument parsing, validation errors and --help. Runtime failures are handled inside each command -// so the tool reports them as a terse "error: …" line rather than a stack trace. -return await app.RunAsync(args); +// The command tree, its exception handling and its exit codes live in CliApplication, so a test can reach them +// without launching a process. Spectre handles argument parsing and --help; runtime failures are handled inside +// each command so the tool reports them as a terse "error: …" line rather than a stack trace. +return await CliApplication.RunAsync(args); diff --git a/doc/handwritten/for-users/CatalogVersioningReference.en.md b/doc/handwritten/for-users/CatalogVersioningReference.en.md index e10c5b48..c4e828de 100644 --- a/doc/handwritten/for-users/CatalogVersioningReference.en.md +++ b/doc/handwritten/for-users/CatalogVersioningReference.en.md @@ -43,6 +43,7 @@ Exit codes: | --- | --- | | `0` | Baseline created, already current, or replaced successfully. | | `1` | Execution error or baseline schema newer than the tool. | +| `64` | The command line was refused: unknown command, or an option given without its value. | | `130` | Execution interrupted. | ## `fce catalog diff` @@ -60,6 +61,7 @@ Exit codes: | `0` | No change reaches the threshold selected by `--fail-on`. | | `2` | At least one change reaches that threshold. | | `1` | Execution error: missing baseline, failed extraction, invalid file, and so on. | +| `64` | The command line was refused: unknown command, or an option given without its value. | | `130` | Execution interrupted. | ### Failure policy: `--fail-on` diff --git a/doc/handwritten/for-users/CatalogVersioningReference.fr.md b/doc/handwritten/for-users/CatalogVersioningReference.fr.md index 1e8f4ea9..ee4c0615 100644 --- a/doc/handwritten/for-users/CatalogVersioningReference.fr.md +++ b/doc/handwritten/for-users/CatalogVersioningReference.fr.md @@ -43,6 +43,7 @@ Codes de sortie : | --- | --- | | `0` | Baseline créée, déjà à jour ou remplacée avec succès. | | `1` | Erreur d'exécution ou schéma de baseline plus récent que l'outil. | +| `64` | Ligne de commande refusée : commande inconnue, ou option fournie sans sa valeur. | | `130` | Exécution interrompue. | ## `fce catalog diff` @@ -60,6 +61,7 @@ Codes de sortie : | `0` | Aucun changement n'atteint le seuil défini par `--fail-on`. | | `2` | Au moins un changement atteint ce seuil. | | `1` | Erreur d'exécution : baseline manquante, extraction impossible, fichier invalide, etc. | +| `64` | Ligne de commande refusée : commande inconnue, ou option fournie sans sa valeur. | | `130` | Exécution interrompue. | ### Politique d'échec : `--fail-on`