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`