Keep empty and dash operands with runnable parents
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Alias interaction audit remediation
|
||||
|
||||
Status: in progress; completed findings are marked individually below.
|
||||
Status: complete; R-1 through R-3 are addressed and marked individually below.
|
||||
|
||||
## Review scope and evidence
|
||||
|
||||
@@ -140,7 +140,7 @@ passed on Go 1.26.8.
|
||||
|
||||
## R-3: Preserve empty and dash operands before child-name tokens
|
||||
|
||||
Status: open. Priority: P2. Category: inherited parser/routing inconsistency.
|
||||
Status: complete. Priority: P2. Category: inherited parser/routing inconsistency.
|
||||
|
||||
**Evidence:** [Session.Parse](../../internal/cobra/parse.go#L55) gives the entire
|
||||
leading non-option token sequence to Cobra's `Find`. Cobra's command search
|
||||
@@ -186,3 +186,13 @@ values, and expose parent invocation paths. Cover parent-local flag aliases and
|
||||
help on the same inputs, plus genuine child selection and ordinary terminator
|
||||
behavior. Tests should establish CommandKit's boundary, not depend on a specific
|
||||
replacement routing algorithm or backend type.
|
||||
|
||||
**Completed:** The adapter ends command search before empty or single-dash
|
||||
operand tokens, preserving them and subsequent tokens for the parent's ordinary
|
||||
flag/operand parser. All four canonical/alias regressions failed before the fix
|
||||
and now preserve the ordered operands, parent-local flag aliases, and invocation
|
||||
paths. The same cases cover metadata-only parent help and explicit terminators;
|
||||
immediate child selection remains intact. No syntax restriction or public API
|
||||
was added. Final uncached offline suites passed on Go 1.26.8: root with the
|
||||
workspace disabled, plus both integrations and their maintained examples in the
|
||||
existing workspace against the local core.
|
||||
|
||||
@@ -408,7 +408,10 @@ func flagUsage(flag commandmeta.Flag) string {
|
||||
|
||||
func splitRoutingPrefix(argv []string) ([]string, []string) {
|
||||
for index, argument := range argv {
|
||||
if argument == "--" || (strings.HasPrefix(argument, "-") && argument != "-") {
|
||||
// Empty strings and "-" can be operands but never command names.
|
||||
// Keep them out of Find, which could skip them and select a child
|
||||
// from a later operand instead of retaining the runnable parent.
|
||||
if argument == "" || strings.HasPrefix(argument, "-") {
|
||||
return commandpath.CloneTokens(argv[:index]), commandpath.CloneTokens(argv[index:])
|
||||
}
|
||||
}
|
||||
|
||||
77
route_operands_test.go
Normal file
77
route_operands_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunnableParentKeepsNonCommandTokensAsOperands(t *testing.T) {
|
||||
type input struct {
|
||||
words []string
|
||||
format Supplied[string]
|
||||
}
|
||||
var received input
|
||||
var entered, canonical string
|
||||
built, parentRuns, childRuns := 0, 0, 0
|
||||
child := NewCommand(CommandSpec[struct{}]{Name: "child", Run: func(inv Invocation, _ struct{}) Outcome {
|
||||
childRuns++
|
||||
entered, canonical = inv.EnteredPath(), inv.CanonicalPath()
|
||||
return Success()
|
||||
}})
|
||||
parent := NewCommand(CommandSpec[input]{
|
||||
Name: "run", Children: []Command{child}, NewInput: func() input { built++; return input{} },
|
||||
Arguments: []Argument[input]{RepeatArgument(ArgumentMeta{Name: "ARG"}, 0, -1,
|
||||
func(value string) (string, error) { return value, nil },
|
||||
func(target *input, value Supplied[[]string]) { target.words = value.Value })},
|
||||
Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "format", Short: "f"}, (*string)(nil),
|
||||
func(value string) (string, error) { return value, nil },
|
||||
func(target *input, value Supplied[string]) { target.format = value })},
|
||||
Run: func(inv Invocation, value input) Outcome {
|
||||
parentRuns++
|
||||
received, entered, canonical = value, inv.EnteredPath(), inv.CanonicalPath()
|
||||
return Success()
|
||||
},
|
||||
})
|
||||
app, err := New(Identity{Name: "Demo"}, parent, Alias("r", "run"), FlagAliasFor("run", "--fmt", "--format"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, spelling := range []string{"run", "r"} {
|
||||
for _, operand := range []string{"", "-"} {
|
||||
t.Run(spelling+"/"+operand, func(t *testing.T) {
|
||||
beforeBuilt, beforeRuns := built, parentRuns
|
||||
args := []string{spelling, operand, "child", "--fmt=yaml"}
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostics})
|
||||
if outcome.Kind != OutcomeSuccess || built != beforeBuilt+1 || parentRuns != beforeRuns+1 || childRuns != 0 {
|
||||
t.Fatalf("outcome = %#v, builds/runs = %d/%d/%d, diagnostics %q", outcome, built, parentRuns, childRuns, diagnostics.String())
|
||||
}
|
||||
if !reflect.DeepEqual(received.words, []string{operand, "child"}) || received.format != (Supplied[string]{Value: "yaml", Set: true}) ||
|
||||
entered != spelling || canonical != "run" {
|
||||
t.Fatalf("parent input = %#v, paths = %q/%q", received, entered, canonical)
|
||||
}
|
||||
outcome = app.Execute(context.Background(), append(args, "--help"), Streams{Out: &output, Err: &diagnostics})
|
||||
if outcome.Kind != OutcomeSuccess || built != beforeBuilt+1 || parentRuns != beforeRuns+1 ||
|
||||
!strings.Contains(output.String(), "Demo "+spelling+" [ARG...]") || !strings.Contains(output.String(), "--fmt → --format") {
|
||||
t.Fatalf("parent help = %#v, output %q, diagnostics %q", outcome, output.String(), diagnostics.String())
|
||||
}
|
||||
outcome = app.Execute(context.Background(), []string{spelling, "--", operand, "child", "--fmt=yaml"}, Streams{Out: io.Discard, Err: &diagnostics})
|
||||
if outcome.Kind != OutcomeSuccess || !reflect.DeepEqual(received.words, []string{operand, "child", "--fmt=yaml"}) || received.format.Set {
|
||||
t.Fatalf("terminated parent invocation = %#v, input %#v, diagnostics %q", outcome, received, diagnostics.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, spelling := range []string{"run", "r"} {
|
||||
beforeBuilt, beforeRuns, beforeChildren := built, parentRuns, childRuns
|
||||
outcome := app.Execute(context.Background(), []string{spelling, "child"}, Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != OutcomeSuccess || built != beforeBuilt || parentRuns != beforeRuns || childRuns != beforeChildren+1 ||
|
||||
entered != spelling+" child" || canonical != "run child" {
|
||||
t.Fatalf("genuine child selection = %#v, paths %q/%q, builds/runs %d/%d/%d", outcome, entered, canonical, built, parentRuns, childRuns)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user