go-cli-flag-parser-unknown-flags
Root cause (DF-016). The old hand-rolled parser kept one global flag table and had no notion of which flags belong to which subcommand. Its fatal flaw: a token it didn't recognize was not an error — it fell through into the positional slice, where callers silently mistook it for a contract id/alias. helix invoke --gas 100 --bogus 0xabc parsed with --bogus as a "positional" and 0xabc shifted off the contract-id slot.
The fix: per-subcommand flag whitelists. Each subcommand declares exactly the flags it accepts; any dash-prefixed token absent from that whitelist returns an UnknownFlagError. The same pattern guards contract-id alias resolution (DF-014): unknown flags error before alias lookup, and identifiers are validated (hex id or known alias only).
Code lives in ~/helixflag/ (flags.go, cmd.go, flags_test.go, cmd/main.go). Core of the fix:
// FlagSpec describes a single flag accepted by a subcommand.
type FlagSpec struct {
Name string // canonical long name, e.g. "network"
Aliases []string // short forms, e.g. "n"
HasArg bool // consumes a value?
}
// Spec is a per-subcommand flag whitelist (DF-016).
type Spec struct {
Name string
Flags []FlagSpec
}
// whitelist maps every accepted spelling (long + aliases) to its spec.
func (s *Spec) whitelist() map[string]FlagSpec {
m := make(map[string]FlagSpec, len(s.Flags)*2)
for _, f := range s.Flags {
m[f.Name] = f
for _, a := range f.Aliases { m[a] = f }
}
return m
}
The parser — the critical branch is the whitelist lookup that replaces the old silent fall-through:
func ParseArgs(sub *Spec, args []string) (map[string]string, []string, error) {
vals := make(map[string]string)
var pos []string
allow := sub.whitelist()
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--" { // terminator: rest is positional
pos = append(pos, args[i+1:]...)
break
}
if a == "-" || !strings.HasPrefix(a, "-") {
pos = append(pos, a)
continue
}
name := strings.TrimLeft(a, "-") // split "--name=value"
val, inline := "", false
if eq := strings.Index(name, "="); eq >= 0 {
name, val, inline = name[:eq], name[eq+1:], true
}
spec, ok := allow[name] // ← the whitelist check
if !ok { // ← old parser fell through here silently
return nil, nil, &UnknownFlagError{
Flag: flagLabel(name), Subcommand: sub.Name,
Suggestions: suggest(name, allow), // did-you-mean, edit distance ≤ 2
}
}
switch {
case spec.HasArg && inline: vals[spec.Name] = val
case spec.HasArg: if i+1 >= len(args) { return nil, nil, fmt.Errorf("flag %q requires a value", a) }
i++; vals[spec.Name] = args[i]
case inline: return nil, nil, fmt.Errorf("flag %q does not take a value", a)
default: vals[spec.Name] = "true"
}
}
return vals, pos, nil
}
Registry with per-subcommand isolation (a flag valid on one subcommand is rejected on others):
var Subcommands = []*Spec{
{Name: "deploy", Flags: []FlagSpec{
{Name: "network", Aliases: []string{"n"}, HasArg: true},
{Name: "from", Aliases: []string{"f"}, HasArg: true},
{Name: "amount", Aliases: []string{"a"}, HasArg: true},
{Name: "gas", Aliases: []string{"g"}, HasArg: true},
{Name: "init"}, // bool: deploy only
}},
{Name: "invoke", Flags: []FlagSpec{ /* network, from, amount, gas */ {Name: "raw"} }},
{Name: "query", Flags: []FlagSpec{{Name: "network", Aliases: []string{"n"}, HasArg: true}, {Name: "at", HasArg: true}}},
}
DF-014 — contract-id alias resolution stays strict, so a mistyped flag can never be reinterpreted as an alias:
func (r *AliasRegistry) ResolveContractID(raw string) (string, error) {
if raw == "" { return "", errors.New("missing contract id") }
if strings.HasPrefix(raw, "-") {
return "", fmt.Errorf("invalid contract id %q: identifiers may not begin with '-' (check for a mistyped flag name)", raw)
}
if canonical, ok := r.aliases[raw]; ok { return canonical, nil } // "main" → 0x1f2e…
hexID := strings.TrimPrefix(raw, "0x")
if hexID != "" && isHex(hexID) { return raw, nil }
return "", fmt.Errorf("invalid contract id %q: not a raw hex id and no such alias", raw)
}
// Run ties it together: subcommand dispatch → whitelist parse → alias resolve.
func Run(reg *AliasRegistry, argv []string) (string, map[string]string, error) {
sub := Lookup(argv[0]) // unknown subcommand errors
vals, pos, err := ParseArgs(sub, argv[1:]) // unknown flag errors HERE, first
if err != nil { return "", nil, err }
if len(pos) == 0 { return "", nil, fmt.Errorf("subcommand %q requires a contract id", sub.Name) }
if len(pos) > 1 { return "", nil, fmt.Errorf("subcommand %q accepts exactly one contract id, got %d", sub.Name, len(pos)) }
return reg.ResolveContractID(pos[0]) // …before the alias layer runs (DF-014)
}
LegacyParse is kept in flags.go only to pin the pre-fix bug for regression documentation; it is not used by the CLI.
Verified with `go test -count=1 -v ./...` (30/30 subtests pass), `go vet ./...` clean, `gofmt -l .` empty, plus a runnable demo (`go run ./cmd`): **Before (bug pinned by `TestLegacyParserSwallowsUnknownFlags`):** ``` flags=map[gas:100] positionals=[invoke --bogus 0xabc] -> `--bogus` was silently swallowed as a positional; no error. ``` **After (`go run ./cmd`):** ``` $ helix invoke --gas 100 --amount=5 main → contract id=0x1f2e… flags=map[amount:5 gas:100] $ helix invoke --gas 100 --bogus main → ERROR: unknown flag "--bogus" for subcommand "invoke" $ helix deploy --raw main → ERROR: unknown flag "--raw" for subcommand "deploy" (did you mean --gas, -a?) $ helix invoke --init main → ERROR: unknown flag "--init" for subcommand "invoke" $ helix invoke --netowrk mainnet main → ERROR: unknown flag "--netowrk" … (did you mean --network?) $ helix invoke --amount -5 main → contract id=0x1f2e… flags=map[amount:-5] $ helix invoke -- --gas → ERROR: invalid contract id "--gas": identifiers may not begin with '-' … ``` **Edge cases tested (table-driven, one subtest each):** - Unknown flags never leak into positionals (error returned instead) — the core regression - Unknown long flag, unknown short flag (`-x`), unknown flag with inline value (`--bogus=1`) - Per-subcommand isolation: `--raw` rejected on `deploy`, accepted on `invoke`; `--init` rejected on `invoke` - Flag forms: `--name value`, `--name=value`, short aliases (`-n` ≡ `--network`) - Errors: missing value, bool flag given a value (`--raw=1`), duplicate flag - `--` terminator: `-- --gas 0xabc` → positionals, no flag error; lone `-` is a positional - Dash-prefixed *values* (`--amount -5`) consumed correctly; bare `-5` as a positional is an unknown flag - Typo suggestion via Levenshtein (≤2 edits, sorted, capped at 3) - DF-014: alias → canonical id; raw hex id passthrough; unknown alias error; unknown flag rejected **before** alias lookup; dash-prefixed id rejected even after `--`; zero/multiple/empty contract ids ---
{"model": "deepseek-v4-flash", "problem_class": "go-cli-flag-parser-unknown-flags", "result": "passed", "tests": 30}Root cause (DF-016). The old hand-rolled parser kept one global flag table and had no notion of which flags belong to which subcommand. Its fatal flaw: a token it didn't recognize was not an error — it fell through into the positional slice, where callers silently mistook it for a contract id/alias. helix invoke --gas 100 --bogus 0xabc parsed with --bogus as a "positional" and 0xabc shifted off the contract-id slot.
The fix: per-subcommand flag whitelists. Each subcommand declares exactly the flags it accepts; any dash-prefixed token absent from that whitelist returns an UnknownFlagError. The same pattern guards contract-id alias resolution (DF-014): unknown flags error before alias lookup, and identifiers are validated (hex id or known alias only).
Code lives in ~/helixflag/ (flags.go, cmd.go, flags_test.go, cmd/main.go). Core of the fix:
// FlagSpec describes a single flag accepted by a subcommand.
type FlagSpec struct {
Name string // canonical long name, e.g. "network"
Aliases []string // short forms, e.g. "n"
HasArg bool // consumes a value?
}
// Spec is a per-subcommand flag whitelist (DF-016).
type Spec struct {
Name string
Flags []FlagSpec
}
// whitelist maps every accepted spelling (long + aliases) to its spec.
func (s *Spec) whitelist() map[string]FlagSpec {
m := make(map[string]FlagSpec, len(s.Flags)*2)
for _, f := range s.Flags {
m[f.Name] = f
for _, a := range f.Aliases { m[a] = f }
}
return m
}
The parser — the critical branch is the whitelist lookup that replaces the old silent fall-through:
func ParseArgs(sub *Spec, args []string) (map[string]string, []string, error) {
vals := make(map[string]string)
var pos []string
allow := sub.whitelist()
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--" { // terminator: rest is positional
pos = append(pos, args[i+1:]...)
break
}
if a == "-" || !strings.HasPrefix(a, "-") {
pos = append(pos, a)
continue
}
name := strings.TrimLeft(a, "-") // split "--name=value"
val, inline := "", false
if eq := strings.Index(name, "="); eq >= 0 {
name, val, inline = name[:eq], name[eq+1:], true
}
spec, ok := allow[name] // ← the whitelist check
if !ok { // ← old parser fell through here silently
return nil, nil, &UnknownFlagError{
Flag: flagLabel(name), Subcommand: sub.Name,
Suggestions: suggest(name, allow), // did-you-mean, edit distance ≤ 2
}
}
switch {
case spec.HasArg && inline: vals[spec.Name] = val
case spec.HasArg: if i+1 >= len(args) { return nil, nil, fmt.Errorf("flag %q requires a value", a) }
i++; vals[spec.Name] = args[i]
case inline: return nil, nil, fmt.Errorf("flag %q does not take a value", a)
default: vals[spec.Name] = "true"
}
}
return vals, pos, nil
}
Registry with per-subcommand isolation (a flag valid on one subcommand is rejected on others):
var Subcommands = []*Spec{
{Name: "deploy", Flags: []FlagSpec{
{Name: "network", Aliases: []string{"n"}, HasArg: true},
{Name: "from", Aliases: []string{"f"}, HasArg: true},
{Name: "amount", Aliases: []string{"a"}, HasArg: true},
{Name: "gas", Aliases: []string{"g"}, HasArg: true},
{Name: "init"}, // bool: deploy only
}},
{Name: "invoke", Flags: []FlagSpec{ /* network, from, amount, gas */ {Name: "raw"} }},
{Name: "query", Flags: []FlagSpec{{Name: "network", Aliases: []string{"n"}, HasArg: true}, {Name: "at", HasArg: true}}},
}
DF-014 — contract-id alias resolution stays strict, so a mistyped flag can never be reinterpreted as an alias:
func (r *AliasRegistry) ResolveContractID(raw string) (string, error) {
if raw == "" { return "", errors.New("missing contract id") }
if strings.HasPrefix(raw, "-") {
return "", fmt.Errorf("invalid contract id %q: identifiers may not begin with '-' (check for a mistyped flag name)", raw)
}
if canonical, ok := r.aliases[raw]; ok { return canonical, nil } // "main" → 0x1f2e…
hexID := strings.TrimPrefix(raw, "0x")
if hexID != "" && isHex(hexID) { return raw, nil }
return "", fmt.Errorf("invalid contract id %q: not a raw hex id and no such alias", raw)
}
// Run ties it together: subcommand dispatch → whitelist parse → alias resolve.
func Run(reg *AliasRegistry, argv []string) (string, map[string]string, error) {
sub := Lookup(argv[0]) // unknown subcommand errors
vals, pos, err := ParseArgs(sub, argv[1:]) // unknown flag errors HERE, first
if err != nil { return "", nil, err }
if len(pos) == 0 { return "", nil, fmt.Errorf("subcommand %q requires a contract id", sub.Name) }
if len(pos) > 1 { return "", nil, fmt.Errorf("subcommand %q accepts exactly one contract id, got %d", sub.Name, len(pos)) }
return reg.ResolveContractID(pos[0]) // …before the alias layer runs (DF-014)
}
LegacyParse is kept in flags.go only to pin the pre-fix bug for regression documentation; it is not used by the CLI.
Verified with `go test -count=1 -v ./...` (30/30 subtests pass), `go vet ./...` clean, `gofmt -l .` empty, plus a runnable demo (`go run ./cmd`): **Before (bug pinned by `TestLegacyParserSwallowsUnknownFlags`):** ``` flags=map[gas:100] positionals=[invoke --bogus 0xabc] -> `--bogus` was silently swallowed as a positional; no error. ``` **After (`go run ./cmd`):** ``` $ helix invoke --gas 100 --amount=5 main → contract id=0x1f2e… flags=map[amount:5 gas:100] $ helix invoke --gas 100 --bogus main → ERROR: unknown flag "--bogus" for subcommand "invoke" $ helix deploy --raw main → ERROR: unknown flag "--raw" for subcommand "deploy" (did you mean --gas, -a?) $ helix invoke --init main → ERROR: unknown flag "--init" for subcommand "invoke" $ helix invoke --netowrk mainnet main → ERROR: unknown flag "--netowrk" … (did you mean --network?) $ helix invoke --amount -5 main → contract id=0x1f2e… flags=map[amount:-5] $ helix invoke -- --gas → ERROR: invalid contract id "--gas": identifiers may not begin with '-' … ``` **Edge cases tested (table-driven, one subtest each):** - Unknown flags never leak into positionals (error returned instead) — the core regression - Unknown long flag, unknown short flag (`-x`), unknown flag with inline value (`--bogus=1`) - Per-subcommand isolation: `--raw` rejected on `deploy`, accepted on `invoke`; `--init` rejected on `invoke` - Flag forms: `--name value`, `--name=value`, short aliases (`-n` ≡ `--network`) - Errors: missing value, bool flag given a value (`--raw=1`), duplicate flag - `--` terminator: `-- --gas 0xabc` → positionals, no flag error; lone `-` is a positional - Dash-prefixed *values* (`--amount -5`) consumed correctly; bare `-5` as a positional is an unknown flag - Typo suggestion via Levenshtein (≤2 edits, sorted, capped at 3) - DF-014: alias → canonical id; raw hex id passthrough; unknown alias error; unknown flag rejected **before** alias lookup; dash-prefixed id rejected even after `--`; zero/multiple/empty contract ids ---
{"model": "deepseek-v4-flash", "problem_class": "go-cli-flag-parser-unknown-flags", "result": "passed", "tests": 30}