fix: a CLI flag rejects unknown spec keys and accepts extra long spellings - #71
Conversation
…lings A flag spec key the parser never reads used to pass silently, so a typo'd `requird:` or an imagined `aliases:` was indistinguishable from a working spec until that exact flag was exercised. `command/2` now raises at build time, naming the bad key and listing the valid ones, alongside the guards already there for `--help`/`-h`, `:required` + `:default`, and a `:default` outside `:values`. `:aliases` now exists for real: a list of extra long forms accepted for the same flag, so a flag can be renamed without breaking callers. `:long` stays canonical and is the only spelling shown in usage lines, help, and `describe/1`. Aliases are validated like `:long` — long-form shape, not `--help`, and no collision with any other flag's long form or alias.
davydog187
left a comment
There was a problem hiding this comment.
Reviewed with two lenses — a correctness/completeness lens on the new validators, and a test-adequacy lens (does the suite pin the new behaviour, per the #70 "enumerate, don't grow by example" lesson). Every claim below was reproduced against beca739 in a detached worktree; three candidate findings were dismissed and are listed at the bottom.
The core of the PR is good: :aliases works end to end, :long stays canonical in help/usage/errors, and the unknown-key guard closes the headline half of #65. Five things worth a look, none of them blockers.
1. The collision guard does not enforce the invariant its own comment states (:long vs :long, :short vs :short)
major — lib/just_bash/cli.ex:998-1017
The comment introducing validate_alias_collisions!/2 justifies the guard with:
Two flags claiming the same long form would resolve to whichever the parser indexed last, silently binding the wrong flag.
But the fold seeds taken with every flag's :long and then walks only the aliases. The exact case the comment names — two flags whose :long collide — is still unchecked, and build_flag_maps/1 (arg_parser.ex:113) is a plain Map.put reduce, so last-write-wins is live. The result is now inconsistently policed: writing the duplicate as aliases: raises at build time, writing the identical duplicate as long: builds clean.
Reproduced on the PR branch:
CLI.command("go",
flags: [a: [type: :string, long: "--dup"], b: [type: :string, long: "--dup"]],
run: ...)$ probe go --dup x
exit=0 stdout="a=nil b=\"x\"\n" # :a silently never receives its value
Same for shorts — a: [type: :boolean, short: "-x"], b: [type: :boolean, short: "-x"] then probe go -x gives a=false b=true.
Fix: the PR already builds the fold that closes this. Seed taken with [], run the :long values through do_validate_alias_collisions!/3 first (with a message that says "duplicate long form" rather than "alias … collides"), then the aliases. A parallel pass over :short is the same shape.
2. The unknown-key guard stops at flag specs — command/2/new/2 options and :args maps still silently drop typos
major — lib/just_bash/cli.ex:334-345 and lib/just_bash/cli.ex:1069-1076
Issue #65's framing is "a typo'd or imagined spec key is indistinguishable from a working one". The PR fixes that inside a flag spec, but the same silent drop is untouched one level up and one level down, and the @flag_spec_keys idiom applies verbatim to both. Both instances are pre-existing, not regressions — flagging them because the PR is the natural place to finish the thought.
(a) command/2 / new/2 option lists. Options are read with a fixed set of Keyword.get/3 calls and leftovers are never checked. The sharpest instance is :visible?, which the moduledoc documents as the authorization mechanism ("the node is absent for that caller"). Drop the question mark and the predicate is discarded with no diagnostic:
CLI.command("admin",
visible: fn _bash -> false end, # note: no `?`
run: fn inv -> {Command.ok("SECRET\n"), inv.bash} end)$ probe admin
exit=0 stdout="SECRET\n" # intended-hidden node is routable for every caller
flgs:/exmaples:/comand: are the same shape — flgs: [target_on: [type: :string]] builds a flagless command, and probe go --target-on 1 then fails with unknown option: --target-on.
(b) :args positional specs. normalize_arg!/2 rebuilds each map from Map.get(spec, :doc) / :required / :variadic and discards everything else, so a misspelled :required defaults the positional to optional with no build-time signal:
args: [%{name: :path, requird: true, doc: "path"}]$ probe go # no positional supplied
exit=0 stdout="path=nil\n" # expected: exit 2, "missing required argument: path"
Fix: the same two lines in each place — an @command_opt_keys / @arg_spec_keys allowlist checked against Keyword.keys(opts) / Map.keys(spec).
3. @flag_spec_keys has no enumerated positive test — :transform is unprotected (mutation-verified)
major — lib/just_bash/cli.ex:145-155
The allowlist is now load-bearing in the strict direction: any key missing from it turns a spec that works today into a build-time ArgumentError. Nothing in the suite pins the list to the set of keys ArgParser actually reads — the positive side is covered only incidentally, by whichever keys other tests happen to use.
Verified by mutation on the PR branch: deleting :transform, from line 153 leaves the whole suite green —
2 doctests, 62 properties, 4774 tests, 0 failures
— yet :transform is a real, working key through this path (CLI.command("go", flags: [n: [type: :string, transform: &String.upcase/1]], ...) then probe go --n hi → exit=0, "n=HI\n"). With the key omitted, that same downstream CLI raises command "go" flag :n: unknown flag option :transform at build time, breaking every caller that uses transform, with green CI. (The only existing transform coverage is test/commands/arg_parser_test.exs:183, which goes through ArgParser.parse/3 directly and never touches the CLI builder.)
This is the #70 lesson applied to something that is literally a list to enumerate.
Fix: for key <- @flag_spec_keys, do: test that CLI.command/2 accepts a spec carrying it — about nine lines, and it makes the allowlist self-checking. A companion test that one spec carrying all nine keys at once builds would close it too.
4. An alias containing = passes the new validator but can never be matched
minor — lib/just_bash/cli.ex:975-990
validate_flag_alias!/3 rejects "-t", "target-date", "--" and "--help", but accepts any other ---prefixed string. The long-flag clause in ArgParser.parse_loop/5 (arg_parser.ex:161-166) splits the token on = before consulting long_map, so a registered alias whose text contains = is unreachable — the lookup key is only the part before the first =.
flags: [target_on: [type: :string, aliases: ["--target=date"]]]$ probe go --target=date 1 exit=2 "unknown option: --target=date"
$ probe go --target=date=1 exit=2 "unknown option: --target=date=1"
Builds clean, does nothing — the failure mode this PR exists to eliminate, reintroduced inside the new validator. Low likelihood, but it costs one clause.
Fix: add String.contains?(form, "=") to the rejection cond. ("--=" falls out of the same check.)
5. A duplicated valid key raises a self-contradictory message
minor — lib/just_bash/cli.ex:951-960
Keyword.keys(spec) -- @flag_spec_keys uses list subtraction, which removes only one occurrence of each right-hand element. A keyword list may legally repeat a key, so a duplicated valid key survives the subtraction and is reported as unknown:
CLI.command("x", flags: [n: [type: :integer, type: :string]], run: ...)raised: command "x" flag :n: unknown flag option :type; valid options are
[:type, :short, :long, :aliases, :default, :required, :values, :transform, :doc]
The message calls :type unknown and lists :type as valid in the same sentence. Raising is the right outcome here (Keyword access silently returns the first value and drops the second — exactly the silent-drop class this PR targets), but the diagnosis sends the author hunting a typo that isn't there.
Fix: Enum.uniq(Keyword.keys(spec)) -- @flag_spec_keys for the unknown-key check, plus a separate clause that names duplicates as duplicates.
Considered and dismissed (1 of 6 candidate findings)
ArgParser now documents :aliases but still ignores unknown flag-spec keys when used directly. The claim: ArgParser.parse(["--loud"], verbose: [type: :boolean, long: "--verbose", aliasses: ["--loud"]]) returns {:error, "unknown option: --loud\n"} with no build-time signal, so the newly-advertised key can be misspelled through the other public entry point. Reproduced — but dismissed as a layering preference rather than a defect. ArgParser is the low-level per-invocation parser used by builtins (curl.ex:31 and friends); spec validation deliberately lives in the CLI builder, which runs once at build time. Moving it into parse/3 would put an allowlist walk on every command invocation to catch a typo in a spec that is a compile-time literal in the same repo, already covered by that builtin's own tests. The finding itself only claims the guard "arguably belongs" there. No traced cost.
Also merged rather than dropped: the two lenses independently reported the :long vs :long collision gap (kept as finding 1, with the better-argued phrasing and the :short case folded in), and independently reported the command/2 option-list and :args unknown-key drops (merged into finding 2 — same root cause, same fix idiom).
`validate_alias_collisions!/2`'s comment justified the guard with "two flags
claiming the same long form would resolve to whichever the parser indexed
last, silently binding the wrong flag" — but the fold seeded `taken` with the
long forms and walked only the aliases, so the case the comment named went
unchecked. `ArgParser.build_flag_maps/1` is a last-write-wins `Map.put`
reduce, so the collision was live:
flags: [a: [type: :string, long: "--dup"],
b: [type: :string, long: "--dup"]]
built clean, and `probe go --dup x` exited 0 with `a=nil b="x"` — the first
flag silently never received its value. Duplicate `:short` was the same
(`probe go -x` gave `a=false b=true`). The identical duplicate written as
`aliases:` already raised, so the rule was policed inconsistently.
The fold now seeds empty and runs the long forms through first, then the
aliases (so a long/alias collision is still reported against the alias, the
form that moved), and a second pass covers short forms. Messages name which
kind of spelling collided.
Claude-Session: https://claude.ai/code/session_013UQWCDMJdSCZhJVkeFpN8z
…chable
`validate_flag_alias!/3` accepted any `--`-prefixed string other than `"--"`
and `"--help"`, but `ArgParser.parse_loop/5` splits a long token on its first
`=` before consulting the long-form map. An alias containing `=` was therefore
registered and unreachable:
flags: [target_on: [type: :string, aliases: ["--target=date"]]]
built clean, and both `probe go --target=date 1` and `probe go --target=date=1`
exited 2 with "unknown option" — the silent-drop failure this guard exists to
prevent, reintroduced inside the guard. `:long` had the same hole, with the
dead spelling also printed in the usage line.
Both now raise at build time and say why. `"--="` falls out of the same check.
Claude-Session: https://claude.ai/code/session_013UQWCDMJdSCZhJVkeFpN8z
…nknown
`Keyword.keys(spec) -- @flag_spec_keys` removes only one occurrence per
right-hand element, so a keyword list that legally repeats a *valid* key
survived the subtraction and was reported as unknown:
flags: [n: [type: :integer, type: :string]]
#=> unknown flag option :type; valid options are [:type, ...]
which calls `:type` unknown and lists it as valid in the same sentence,
sending the author hunting a typo that isn't there. Raising is still the right
outcome — `Keyword` access returns the first value and silently drops the
second, the same silent-drop class the key allowlist targets — so the check
now de-duplicates before subtracting and reports duplicates as duplicates.
The check moves into a shared `validate_spec_keys!/4` so the next spec to grow
a key allowlist gets both diagnoses for free.
Claude-Session: https://claude.ai/code/session_013UQWCDMJdSCZhJVkeFpN8z
…keys Deliberate scope extension beyond the flag spec. Issue #65's framing is that a typo'd or imagined spec key is indistinguishable from a working one; the allowlist introduced for flag specs closed that inside a flag and left the same silent drop one level up and one level down. Both instances are pre-existing rather than introduced by this PR. `command/2` and `new/2` read their options with a fixed set of `Keyword.get/3` calls and never look at the leftovers. The sharpest case is `:visible?`, the documented authorization mechanism: CLI.command("admin", visible: fn _bash -> false end, run: ...) built clean with the `?` dropped, and `probe admin` exited 0 printing SECRET for every caller — an intended-hidden node routable by anyone. `flgs:` built a flagless command whose flags then failed as unknown options. `normalize_arg!/2` rebuilt each positional from `:doc`/`:required`/`:variadic` and discarded the rest, so `%{name: :path, requird: true}` built clean and `probe go` exited 0 with `path=nil` instead of exit 2 and a missing-required- argument error. All three now go through the same `validate_spec_keys!/4` as flag specs, so they get the duplicate-key diagnosis too. Claude-Session: https://claude.ai/code/session_013UQWCDMJdSCZhJVkeFpN8z
The allowlists are load-bearing in the strict direction: any key missing from one turns a spec that works today into a build-time `ArgumentError` for every downstream caller. Nothing pinned them to the keys the parser and builder actually read. Mutation-verified before this commit: deleting `:transform,` from `@flag_spec_keys` left the whole suite green (2 doctests, 62 properties, 4787 tests, 0 failures), even though `:transform` works through `CLI.command/2` and, with the key gone, that same CLI raises at build time. The only prior `transform` coverage went through `ArgParser.parse/3` directly and never touched the builder. Each allowlist is now enumerated twice over: every key is exercised positively (flag-spec keys one at a time and all at once; command options split across a leaf and a group, since some are leaf-only and some group-only; CLI options and positional keys all at once), and the builder's own "valid options are" list is asserted to equal the enumeration, so a key added or dropped on either side goes red. Re-running the `:transform` deletion now fails 3 tests; dropping `:visible?` from the command options fails 2; dropping `:variadic` from the positional keys fails 3. Claude-Session: https://claude.ai/code/session_013UQWCDMJdSCZhJVkeFpN8z
|
All five findings addressed; none disputed. Every repro was re-run before touching code (all reproduced exactly as reported, including the
1 — collision guard (
|
| mutation | result |
|---|---|
drop :transform from @flag_spec_keys |
3 failures |
drop :visible? from @command_opt_keys |
2 failures |
drop :variadic from @arg_spec_keys |
3 failures |
4 — = in a long form or alias (a26c4d8)
Reproduced, and the :long hole the review asked about is real too — long: "--target=date" builds clean, is unreachable, and the dead spelling is printed in the usage line. Both now raise:
raised: command "go" flag :target_on: alias "--target=date" cannot contain "=" — the parser splits a long flag on "=" before matching it
raised: command "go" flag :target_on: long form "--target=date" cannot contain "=" — the parser splits a long flag on "=" before matching it
"--=" falls out of the same check. Kept scope tight: this adds no ---prefix requirement to :long, only the = rejection.
5 — duplicate-key diagnosis (884231e)
Reproduced verbatim. Enum.uniq/1 before the subtraction, plus a dedicated duplicate clause:
raised: command "x" flag :n: duplicate flag option :type
Gates
$ mix compile --warnings-as-errors --force
Compiling 168 files (.ex)
Generated just_bash app # exit 0
$ mix format --check-formatted # exit 0
$ mix credo --strict
5133 mods/funs, found 1 refactoring opportunity.
# the pre-existing intentional test fixture finding: test/support/banned_fixture_apply.ex:4
$ mix test
2 doctests, 62 properties, 4796 tests, 0 failures (5 excluded)
# baseline was 4774; +22 new tests, no regressions
$ mix dialyzer
Total errors: 13, Skipped: 13, Unnecessary Skips: 0
done (passed successfully)
Verification of review fixesIndependently re-ran every repro at Repro harness: a
Gates — re-run here, at
|
Closes #65
Two related gaps in
JustBash.CLIflag specs: an unrecognised spec key was silently accepted and never read, and a flag could only ever have one long form.What was broken
normalize_flags!/2filled in:longand handed the spec tovalidate_flag_spec!/3, which checked only:long/:short,:required+:default, and:default∈:values. Every other key passed straight through, unread.:aliasesis an especially easy key to reach for because it already exists one level up onCLI.new/2— and the library agreed with you right up until runtime.Against
82ee9e5, the issue'sAliasProberepro:aliases:did nothing,totally_bogus_key: 123did nothing, and neither produced a peep at build time.What the fix does
(a) Unknown flag-spec keys raise at build time.
validate_flag_spec!/3now checks the spec's keys against the full set —:type,:short,:long,:aliases,:default,:required,:values,:transform,:doc— and raises naming the offender and listing the valid options. This joins the guards already there (--help/-hreserved,:required+:default,:defaultoutside:values), whose comment already says these are guards that "can't drift into runtime surprises".(b)
:aliaseson a flag now works. A list of extra long spellings accepted by the parser, for symmetry withCLI.new/2's tool-level:aliases.JustBash.Commands.ArgParser.build_flag_maps/1registers each alias in the same long-form map as:long, so an alias parses identically — including the--flag=valueform, booleans, and satisfyingrequired: true.:longstays canonical: it is the only spelling shown in usage lines,--help, error messages, anddescribe/1. Aliases are accepted, not advertised.Alias validation mirrors
:long: the list must be a list of strings, each must be a long flag form starting with--(so"-t","target-date", and a bare"--"all raise),--helpis reserved, and no alias may collide with another flag's long form or alias (nor with its own flag's:long) — a collision would otherwise resolve to whichever flag the parser indexed last and silently bind the wrong one.After the fix, the issue's repro:
The moduledoc's flag-key list, build-time-guard list, and a new "Flag aliases" section document the behaviour.
New tests
Written first, watched fail (11 failures against the unfixed tree), then fixed.
test/cli/builder_test.exs— build-time guards:totally_bogus_key) and on a misspelling (requird):aliaseslist and keeps:longcanonical:aliasesis not a list of strings (bare string; atom entry)"target-date","-t","--")--helptest/cli/routing_test.exs— end-to-end through the shell, the issue'sprobe goCLI:--target-date=1works; an alias works on a boolean flag--target-dat) still errors at exit 2--helpand the error usage line show only--target-on, never--target-dateor--loudtest/commands/arg_parser_test.exs— parser level: canonical form, alias,--flag=valuealias, alias satisfyingrequired: true, alias setting a boolean, and the missing-required error still naming the canonical--target-onrather than an alias.Gates
All five run on the final tree.
(the sole credo finding is the pre-existing intentional test fixture, untouched by this branch)
Review round 2
Five confirmed review findings addressed on top of the original branch (see the reply comment for the repro-by-repro walkthrough). Two of them change what this PR claims to do:
The collision guard now covers
:longand:short, not just aliases. Its comment said "two flags claiming the same long form would resolve to whichever the parser indexed last, silently binding the wrong flag", but the fold seededtakenwith the long forms and walked only the aliases, so the case the comment named went unchecked.long: "--dup"on two flags built clean and bound the wrong one. Both long forms and short forms are now checked.Scope extension:
command/2,new/2, and:argsentries also reject unknown and repeated keys. The flag-spec allowlist closed the silent drop inside a flag and left the identical drop one level up and one level down. Both are pre-existing rather than introduced here, but they are the same class the issue was filed about —visible:for:visible?discarded an authorization predicate and left an intended-hidden node routable by every caller, andrequird:on a positional silently made a required argument optional. All four allowlists share onevalidate_spec_keys!/4.Also in this round: a
:longform or alias containing=is rejected (the parser splits on=before matching, so such a spelling was registered and unreachable); a duplicated valid key is now diagnosed as a duplicate instead of being reported as unknown in a message that listed it as valid; and each of the four allowlists gained enumerated positive tests pinned to the builder's own "valid options are" output — deleting:transformfrom the flag list previously left the whole suite green.The gate output below is from the original branch tip; the round-2 gates (
4796 tests, 0 failures) are in the reply comment.Scope
Both halves the issue describes are delivered. Nothing left out. The issue floated
deprecated_long:(accepted, hidden, warns on stderr) as an alternative shape;:aliaseswas chosen per the issue's own preference for symmetry withCLI.new/2, and no stderr deprecation warning is emitted.