Skip to content

feat(cli)!: refuse an argument no command declares - #402

Merged
Reefact merged 1 commit into
mainfrom
claude/anyarray-distinct-nameof-8m5won
Jul 31, 2026
Merged

feat(cli)!: refuse an argument no command declares#402
Reefact merged 1 commit into
mainfrom
claude/anyarray-distinct-nameof-8m5won

Conversation

@Reefact

@Reefact Reefact commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Summary

A mistyped flag was accepted, ignored, and reported as a success:

$ fce config show --verbosee
  before → exit 0, silently ignored
  after  → exit 64
           error: Unknown argument '--verbosee'.
           Run 'fce --help' to see the available commands.

The parser collects an argument it does not recognise into the remaining arguments, and this tool never reads them — grep finds no use of context.Remaining anywhere in FirstClassErrors.Cli. So the leftovers were dropped in silence, and a pipeline asking for something the tool does not do was told it had it.

Rejecting an unknown option is what every comparable tool does. Measured here rather than remembered:

Command Exit
git status --nope 128
ls --nope 2
grep --nope 2
dotnet build --nope 1
fce config show --nope (before) 0

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Refactoring
  • Analyzer / diagnostic change
  • Tests
  • Documentation
  • Build / CI / tooling

Changes

Refused arguments now report as ExitCodes.UsageError (64) — the code ADR-0067's set already carries — naming the offending argument.

The parser's own strict mode says the same thing and is deliberately NOT used. This is the part worth reviewing. In Spectre.Console.Cli 0.55 — the newest published version, so this is not an upgrade away — UseStrictParsing() makes an option declared without a value swallow the internal __default_command token as that value:

$ fce generate --solution           # with UseStrictParsing()
error: GENDOC_SOLUTION_NOT_FOUND: Solution file not found: '…/__default_command'   → exit 1

$ fce catalog diff --fail-on        # with UseStrictParsing()
error: Unknown --fail-on value '__default_command'.                                → exit 1

It trades a silent wrong for a visible one that leaks a parser internal into a user-facing message, and it regresses the missing-value case from a clean 64 to a misleading 1. Refusing the leftovers ourselves — an ICommandInterceptor that rejects anything landing in Remaining — keeps the parser's own diagnosis intact. A test pins that: fce generate --solution still reports "Option 'solution' is defined but no value has been provided" and exits 64.

Behaviour after this change:

Input Exit Message
fce config show 0
fce config show --nope 64 Unknown argument '--nope'.
fce config show extra 64 Unknown command 'extra'.
fce generate --solution 64 Option 'solution' is defined but no value has been provided.
fce frobnicate 64 Unknown command 'frobnicate'.
fce --help 0

Docs — both exit-code tables in the catalog-versioning reference gain "unknown option", English and French.

One suppression. UndeclaredArgumentException is internal, which raises S3871 ("exception types should be public"). Suppressed at the type with a written justification: the rule exists so a caller outside the assembly can catch it, and this assembly is an executable — nothing references it, and the only code that catches it is the exit-code handler a few lines above.

Testing

  • dotnet build FirstClassErrors.sln — succeeded, 0 warnings, 0 errors
  • dotnet test FirstClassErrors.sln — 2206 passed, 0 failed, 0 skipped across the 13 suites (FirstClassErrors.Cli.UnitTests 70 → 72)
  • Analyzer tests pass (FirstClassErrors.Analyzers.UnitTests) — 132 passed, run as part of the solution test above

Every before/after behaviour quoted above was observed on the built binary, including the UseStrictParsing() regression — that path was implemented, measured, and then backed out in favour of the interceptor.

Documentation

  • Public API / error documentation updated
  • README / doc/ updated
  • French translation updated — user-facing behavior changed
  • No documentation change required

CatalogVersioningReference moves in both languages. The CI guide still needs no change: it propagates the code without enumerating the set.

Architecture decisions

  • No architectural decision in this pull request
  • New decision recorded — ADR drafted as Proposed: ADR-____
  • Supersedes an existing ADR — successor proposed, status not flipped: ADR-____
  • ⚠️ Conflicts with an existing ADR — flagged for the maintainer: ADR-____

This lands inside ADR-0067's set rather than extending it — 64 already means "the command line was refused", and this widens what counts as refused without adding a code. Say the word if you read the widening itself as decision-shaped, @Reefact; I did not, because the set and its meanings are unchanged.

A breaking change, flagged as one, and accepted. feat(cli)! with a BREAKING CHANGE: footer, so the cli release train sees a MAJOR. @Reefact accepted the break on the grounds that nothing has shipped yet — which the repository bears out: the only release tag is lib-v0.1.0-preview.1, and the cli train carries no tag at all, so fce has never been published and no consumer exists to break. Independently of that, nothing could have depended on the previous behaviour deliberately, since the arguments were never read.

Related issues

None. Follows #395, #396, #397 and #399.

A mistyped flag was accepted, ignored and reported as a success:
`fce config show --verbosee` exited 0 having done nothing about
verbosity. The parser collects an argument it does not recognise into the
remaining arguments, and this tool never reads them — grep finds no use
of context.Remaining anywhere in the CLI — so the leftovers were dropped
in silence. A pipeline asking for something the tool does not do was told
it had it.

Rejecting an unknown option is what every comparable tool does: git exits
128, ls and grep exit 2, dotnet build exits 1. Tolerating one is the
outlier, and it is the parser's default rather than a choice made here.

Refused arguments now report as the usage error 64 that ADR-0067's set
already carries, naming the offending argument.

The parser's own strict mode says the same thing and is NOT used. In
Spectre.Console.Cli 0.55 — the newest published version, so this is not
an upgrade away — UseStrictParsing makes an option declared without a
value swallow the internal "__default_command" token as that value:
`fce generate --solution` then looks for a file by that name and exits 1,
instead of reporting a usage error. It trades a silent wrong for a
visible one that leaks a parser internal into a user-facing message.
Refusing the leftovers through an interceptor keeps that diagnosis
intact, which a test now pins.

BREAKING CHANGE: a command line carrying an argument no command declares
now exits 64 instead of running and exiting 0. Nothing can depend on the
previous behaviour deliberately — the arguments were never read — so what
breaks is a caller whose invocation is already wrong and silently
ignored, which is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SuSBsGG7wMPMnSeTHKZAom
@Reefact
Reefact merged commit fbf523b into main Jul 31, 2026
29 checks passed
@Reefact
Reefact deleted the claude/anyarray-distinct-nameof-8m5won branch July 31, 2026 09:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants