diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index fec84c3..5837883 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -251,7 +251,7 @@ value was prohibited. The complete offline root suite passed on Go 1.26.8. ## R-7: Retain entered flag spellings in parser-level usage failures -Status: open. Priority: P3. Category: incomplete diagnostic adaptation. +Status: complete. Priority: P3. Category: incomplete diagnostic adaptation. **Evidence:** Typed flag conversion uses occurrence provenance, but [Session.Parse](../../internal/cobra/parse.go#L89) returns normalized backend @@ -278,6 +278,15 @@ values and invalid help values, including an earlier invalid occurrence followed by a valid overwrite. Assert the identifying fragments and existing causes where supported; no new exact diagnostic format is required. +**Completed:** Missing-value failures now carry the entered long/short spelling +through a neutral adapter record; earlier backend failures retain precedence. +Built-in help conversion uses the failing occurrence's original spelling, +including when a later valid value follows it. Adapter and public-boundary +regressions failed before the fix and now cover alias values, clusters, help +occurrences, entered usage paths, and input-initialization avoidance. Existing +usage classification is unchanged. The complete offline root suite passed on +Go 1.26.8. + ## R-8: Reconcile the completed feature roadmap's historical descriptions Status: open. Priority: P3. Category: documentation drift. diff --git a/internal/cobra/flags.go b/internal/cobra/flags.go index d6c6505..e87fabe 100644 --- a/internal/cobra/flags.go +++ b/internal/cobra/flags.go @@ -1,12 +1,13 @@ package cobra import ( + "fmt" "strings" commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" ) -func interpretFlagTokens(argv []string, flags []commandmeta.Flag, aliases []commandmeta.FlagAlias) ([]string, map[string][]string) { +func interpretFlagTokens(argv []string, flags []commandmeta.Flag, aliases []commandmeta.FlagAlias) ([]string, map[string][]string, *commandmeta.Failure) { canonical := make(map[string]commandmeta.Flag, len(flags)*2+2) help := commandmeta.Flag{Name: "help", Short: "h", Boolean: true} canonical["--help"] = help @@ -61,6 +62,9 @@ func interpretFlagTokens(argv []string, flags []commandmeta.Flag, aliases []comm index += 2 continue } + if !flag.Boolean { + return interpreted, spellings, &commandmeta.Failure{Message: fmt.Sprintf("flag needs an argument: %s", original)} + } interpreted = append(interpreted, "--"+flag.Name) index++ continue @@ -107,8 +111,7 @@ func interpretFlagTokens(argv []string, flags []commandmeta.Flag, aliases []comm consumedNext = true break } - interpreted = append(interpreted, "--"+flag.Name) - break + return interpreted, spellings, &commandmeta.Failure{Message: fmt.Sprintf("flag needs an argument: %s", original)} } if !consumedNext { index++ @@ -118,5 +121,5 @@ func interpretFlagTokens(argv []string, flags []commandmeta.Flag, aliases []comm interpreted = append(interpreted, token) index++ } - return interpreted, spellings + return interpreted, spellings, nil } diff --git a/internal/cobra/flags_test.go b/internal/cobra/flags_test.go index fc0fd0f..8a5695e 100644 --- a/internal/cobra/flags_test.go +++ b/internal/cobra/flags_test.go @@ -2,6 +2,7 @@ package cobra import ( "reflect" + "strings" "testing" commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" @@ -158,3 +159,40 @@ func TestParseFlagAliasOccurrenceOrderAndSpellings(t *testing.T) { t.Fatalf("tag spellings = %#v, want %#v", got, want) } } + +func TestParseFailuresRetainEnteredFlagSpellings(t *testing.T) { + for _, spelling := range []string{"--color", "--colour", "-c", "-C"} { + t.Run("missing/"+spelling, func(t *testing.T) { + result := Parse(flagGrammarTree(), []string{"run", spelling}, flagGrammarOptions()) + if result.Failure == nil || !strings.Contains(result.Failure.Message, ": "+spelling) { + t.Fatalf("missing value failure = %#v, want entered spelling %q", result.Failure, spelling) + } + }) + } + result := Parse(flagGrammarTree(), []string{"run", "-wC"}, flagGrammarOptions()) + if result.Failure == nil || !strings.Contains(result.Failure.Message, ": -C") { + t.Fatalf("cluster missing value failure = %#v", result.Failure) + } + for _, test := range []struct { + args []string + spelling string + }{ + {[]string{"--hlep=bad"}, "--hlep"}, + {[]string{"-h=bad"}, "-h"}, + {[]string{"--help=bad"}, "--help"}, + {[]string{"--hlep=bad", "--help=true"}, "--hlep"}, + {[]string{"-h=bad", "--hlep=true"}, "-h"}, + {[]string{"--help=true", "--hlep=bad"}, "--hlep"}, + } { + t.Run(strings.Join(test.args, " "), func(t *testing.T) { + result := Parse(flagGrammarTree(), append([]string{"run"}, test.args...), flagGrammarOptions()) + if result.Failure == nil || !strings.Contains(result.Failure.Message, "for "+test.spelling) { + t.Fatalf("help failure = %#v, want entered spelling %q", result.Failure, test.spelling) + } + }) + } + result = Parse(flagGrammarTree(), []string{"run", "--unknown", "--colour"}, flagGrammarOptions()) + if result.Failure == nil || !strings.Contains(result.Failure.Message, "unknown") || strings.Contains(result.Failure.Message, "needs an argument") { + t.Fatalf("earlier parser failure = %#v", result.Failure) + } +} diff --git a/internal/cobra/parse.go b/internal/cobra/parse.go index 9d469be..82e55a8 100644 --- a/internal/cobra/parse.go +++ b/internal/cobra/parse.go @@ -77,7 +77,7 @@ func (session *Session) Parse(argv []string) commandmeta.Result { } remaining := append(commandpath.CloneTokens(unconsumed), tail...) - interpreted, spellings := interpretFlagTokens(remaining, selectedNode.Flags, session.flagAliases) + interpreted, spellings, interpretationFailure := interpretFlagTokens(remaining, selectedNode.Flags, session.flagAliases) for _, flag := range selectedNode.Flags { if value := selected.Flags().Lookup(flag.Name); value != nil { value.Value.(*rawValue).queueSpellings(spellings[flag.Name]) @@ -89,6 +89,12 @@ func (session *Session) Parse(argv []string) commandmeta.Result { if err := selected.ParseFlags(interpreted); err != nil { return failure(result, err.Error()) } + // Parse the completed prefix first so an earlier backend failure keeps its + // precedence over a missing value discovered while interpreting aliases. + if interpretationFailure != nil { + result.Failure = interpretationFailure + return result + } operands := selected.Flags().Args() result.Operands = append([]string(nil), operands...) @@ -473,10 +479,14 @@ func (v *rawValue) spellingsCopy() []string { return append([]string(nil), v.spe func (v *rawValue) lastBool() (bool, error) { var value bool - for _, raw := range v.values { + for index, raw := range v.values { parsed, err := strconv.ParseBool(raw) if err != nil { - return false, fmt.Errorf("invalid boolean value %q for --help", raw) + spelling := "--help" + if index < len(v.spellings) { + spelling = v.spellings[index] + } + return false, fmt.Errorf("invalid boolean value %q for %s", raw, spelling) } value = parsed } diff --git a/route_test.go b/route_test.go index f6e3378..3eddc26 100644 --- a/route_test.go +++ b/route_test.go @@ -11,6 +11,46 @@ import ( "testing" ) +func TestParserFailuresRetainAliasUsageAndFlagSpellings(t *testing.T) { + built := 0 + command := NewCommand(CommandSpec[struct{}]{ + Name: "inspect", + NewInput: func() struct{} { built++; return struct{}{} }, + Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "format"}, (*string)(nil), + func(value string) (string, error) { return value, nil }, + func(*struct{}, Supplied[string]) {})}, + Run: func(Invocation, struct{}) Outcome { t.Fatal("invalid input executed"); return Success() }, + }) + app, err := New(Identity{Name: "Demo"}, command, Alias("i", "inspect"), + FlagAliasFor("inspect", "--fmt", "--format"), + FlagAliasFor("inspect", "-F", "--format"), FlagAlias("--hlep", "--help")) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + args []string + spelling string + }{ + {[]string{"i", "--fmt"}, ": --fmt"}, + {[]string{"i", "-F"}, ": -F"}, + {[]string{"i", "--hlep=bad", "--help=true"}, "for --hlep"}, + {[]string{"i", "-h=bad"}, "for -h"}, + } { + t.Run(strings.Join(test.args, " "), func(t *testing.T) { + var diagnostics bytes.Buffer + outcome := app.Execute(context.Background(), test.args, Streams{Out: io.Discard, Err: &diagnostics}) + if outcome.Kind != OutcomeUsage || built != 0 { + t.Fatalf("outcome = %#v, inputs built = %d", outcome, built) + } + for _, fragment := range []string{test.spelling, "Usage: Demo i", "Alias for: Demo inspect"} { + if !strings.Contains(diagnostics.String(), fragment) { + t.Errorf("diagnostics %q omit %q", diagnostics.String(), fragment) + } + } + }) + } +} + func TestCommandContextSurvivesIncompleteAndInvalidOperandCounts(t *testing.T) { for _, spelling := range []string{"run", "r"} { for _, test := range []struct {