From 413e8bd46bf48598cc558f06e95fb821e00f2ff0 Mon Sep 17 00:00:00 2001 From: Pedro Castro Date: Sat, 7 Nov 2026 14:50:37 -0300 Subject: [PATCH 1/2] Reject unknown CLI commands when help is requested Why: Argument normalization discarded unknown command names when help was requested, so misspelled commands printed root help and exited with 0. Representation modified (Cli.normalize_argv routing): Before (text): rescript invalid --help -> rescript --help=plain After (text): rescript invalid --help -> rescript help invalid Diagnostic modified (unknown command help): Before (text): rescript invalid --help Root help on stdout; exit status 0. After (text): rescript invalid --help rescript: unknown command "invalid" Exit status 2. Signed-off-by: Pedro Castro --- changes/unknown-command-help.fix.md | 1 + rewatch-ocaml/cli.ml | 12 ++++++++++-- rewatch-ocaml/tests/check_command_validation.sh | 4 ++++ tests/rewatch_ounit_tests/cli_tests.ml | 15 +++++++++++---- 4 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 changes/unknown-command-help.fix.md diff --git a/changes/unknown-command-help.fix.md b/changes/unknown-command-help.fix.md new file mode 100644 index 00000000000..dec3215f09f --- /dev/null +++ b/changes/unknown-command-help.fix.md @@ -0,0 +1 @@ +- Report a usage error and exit with code 2 for unknown CLI commands even when help is requested. diff --git a/rewatch-ocaml/cli.ml b/rewatch-ocaml/cli.ml index e5b148af3d9..04738f45c41 100644 --- a/rewatch-ocaml/cli.ml +++ b/rewatch-ocaml/cli.ml @@ -289,7 +289,8 @@ exception Version Cmdliner otherwise treats them as unknown commands. Move global options behind the selected command and expand short help/version clusters because Cmdliner's standard display options only provide long names. A root display - request takes precedence over otherwise invalid implicit-build arguments. *) + request takes precedence over otherwise invalid implicit-build options, but + a positional help topic must name a known command. *) let normalize_argv argv = let is_short_global_cluster argument = let length = String.length argument in @@ -365,7 +366,14 @@ let normalize_argv argv = executable :: command :: (globals @ rest) | None -> let globals, others = partition_implicit arguments in - if + let has_help_topic = + match others with + | topic :: _ -> not (String.starts_with ~prefix:"-" topic) + | [] -> false + in + if List.exists requests_help globals && has_help_topic then + executable :: "help" :: others + else if List.exists (fun argument -> requests_help argument || requests_version argument) diff --git a/rewatch-ocaml/tests/check_command_validation.sh b/rewatch-ocaml/tests/check_command_validation.sh index f1a01a56bad..7325d135a9e 100755 --- a/rewatch-ocaml/tests/check_command_validation.sh +++ b/rewatch-ocaml/tests/check_command_validation.sh @@ -581,6 +581,10 @@ run_build_output_case quiet-warning 0 \ "$root/rewatch-ocaml/tests/warning-replay" quiet run_case build-subcommand-version accept build --version +run_case unknown-command-help exit2 invalid --help +require_error_contains unknown-command-help 'unknown command "invalid"' +run_case unknown-command-short-help exit2 invalid -h +run_case unknown-command-formatted-help exit2 --help=plain invalid run_case clustered-global-version accept -vV build run_case clustered-global-verbosity accept -vvvvv build "$project" run_case clustered-global-version-before-help accept -Vh build diff --git a/tests/rewatch_ounit_tests/cli_tests.ml b/tests/rewatch_ounit_tests/cli_tests.ml index b9d2ca45249..e142d24ef9e 100644 --- a/tests/rewatch_ounit_tests/cli_tests.ml +++ b/tests/rewatch_ounit_tests/cli_tests.ml @@ -49,11 +49,18 @@ let tests = ((build_options ["--"; "-v"]).folder = "-v") "double dash preserves an option-looking folder"; check - (shows_help ["some-folder"; "--help"]) - "implicit command folder help uses global help"; + (rejects ["invalid"; "--help"]) + "an unknown command with help is rejected"; check - (shows_help ["some-folder"; "-h"]) - "short implicit command help uses global help"; + (rejects ["invalid"; "-h"]) + "an unknown command with short help is rejected"; + check + (rejects ["--help=plain"; "invalid"]) + "leading formatted help rejects unknown commands"; + check + (rejects ["invalid"; "-Vh"]) + "help in a version cluster rejects unknown commands"; + check (shows_help ["--help"]) "root help remains accepted"; check (shows_help ["build"; "--help"]) "explicit build displays command help"; check (shows_help ["build"; "-h"]) "explicit build accepts short command help"; check From 6be17f530d0c01b375e2593212c184889828036a Mon Sep 17 00:00:00 2001 From: Pedro Castro Date: Sat, 7 Nov 2026 15:09:12 -0300 Subject: [PATCH 2/2] Reject unknown CLI commands when version is requested Why: The help fix still let version requests discard unknown subcommands and exit successfully. Validate command topics for both display flags. Representation modified (Cli.normalize_argv routing): Before (text): rescript unkdnow --version -> rescript --version After (text): rescript unkdnow --version -> rescript help unkdnow Diagnostic modified (unknown command version): Before (text): rescript unkdnow --version rescript 13.0.0-alpha.8 Exit status 0. After (text): rescript unkdnow --version rescript: unknown command "unkdnow" Exit status 2. Signed-off-by: Pedro Castro --- changes/unknown-command-help.fix.md | 2 +- rewatch-ocaml/cli.ml | 13 +++++++------ rewatch-ocaml/tests/check_command_validation.sh | 5 +++++ tests/rewatch_ounit_tests/cli_tests.ml | 13 +++++++++++-- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/changes/unknown-command-help.fix.md b/changes/unknown-command-help.fix.md index dec3215f09f..2a5b861603e 100644 --- a/changes/unknown-command-help.fix.md +++ b/changes/unknown-command-help.fix.md @@ -1 +1 @@ -- Report a usage error and exit with code 2 for unknown CLI commands even when help is requested. +- Report a usage error and exit with code 2 for unknown CLI commands even when help or version is requested. diff --git a/rewatch-ocaml/cli.ml b/rewatch-ocaml/cli.ml index 04738f45c41..b82bd7cc635 100644 --- a/rewatch-ocaml/cli.ml +++ b/rewatch-ocaml/cli.ml @@ -290,7 +290,7 @@ exception Version behind the selected command and expand short help/version clusters because Cmdliner's standard display options only provide long names. A root display request takes precedence over otherwise invalid implicit-build options, but - a positional help topic must name a known command. *) + a positional command must be valid even when help or version is requested. *) let normalize_argv argv = let is_short_global_cluster argument = let length = String.length argument in @@ -366,19 +366,20 @@ let normalize_argv argv = executable :: command :: (globals @ rest) | None -> let globals, others = partition_implicit arguments in - let has_help_topic = + let has_command_topic = match others with | topic :: _ -> not (String.starts_with ~prefix:"-" topic) | [] -> false in - if List.exists requests_help globals && has_help_topic then - executable :: "help" :: others - else if + let requests_display = List.exists (fun argument -> requests_help argument || requests_version argument) globals - then executable :: globals + in + if requests_display && has_command_topic then + executable :: "help" :: others + else if requests_display then executable :: globals else executable :: "build" :: (globals @ others) in Array.of_list (normalize_display_options routed) diff --git a/rewatch-ocaml/tests/check_command_validation.sh b/rewatch-ocaml/tests/check_command_validation.sh index 7325d135a9e..5ccf9dc7aed 100755 --- a/rewatch-ocaml/tests/check_command_validation.sh +++ b/rewatch-ocaml/tests/check_command_validation.sh @@ -585,6 +585,11 @@ run_case unknown-command-help exit2 invalid --help require_error_contains unknown-command-help 'unknown command "invalid"' run_case unknown-command-short-help exit2 invalid -h run_case unknown-command-formatted-help exit2 --help=plain invalid +run_case unknown-command-version exit2 unkdnow --version +require_error_contains unknown-command-version 'unknown command "unkdnow"' +run_case unknown-command-short-version exit2 unkdnow -V +run_case unknown-command-leading-version exit2 --version unkdnow +run_case unknown-command-clustered-version exit2 -vV unkdnow run_case clustered-global-version accept -vV build run_case clustered-global-verbosity accept -vvvvv build "$project" run_case clustered-global-version-before-help accept -Vh build diff --git a/tests/rewatch_ounit_tests/cli_tests.ml b/tests/rewatch_ounit_tests/cli_tests.ml index e142d24ef9e..3e588740931 100644 --- a/tests/rewatch_ounit_tests/cli_tests.ml +++ b/tests/rewatch_ounit_tests/cli_tests.ml @@ -95,8 +95,17 @@ let tests = (shows_version ["-V"; "build"]) "a leading short version flag has global precedence"; check - (shows_version ["some-folder"; "-V"]) - "implicit build extracts a trailing global version flag"; + (rejects ["unkdnow"; "--version"]) + "an unknown command with version is rejected"; + check + (rejects ["unkdnow"; "-V"]) + "an unknown command with short version is rejected"; + check + (rejects ["--version"; "unkdnow"]) + "leading version rejects unknown commands"; + check + (rejects ["-vV"; "unkdnow"]) + "version in a verbosity cluster rejects unknown commands"; check (shows_version ["--version"]) "the long global version flag is accepted"; check (shows_version ["build"; "-V"])