Keep command routing independent of operand counts
This commit is contained in:
@@ -66,7 +66,7 @@ longer-shortcut cases and the complete offline root suite passed on Go 1.26.8.
|
||||
|
||||
## R-2: Select canonical routing context before validating operand counts
|
||||
|
||||
Status: open. Priority: P2. Category: unintended parsing restriction and identity.
|
||||
Status: complete. Priority: P2. Category: unintended parsing restriction and identity.
|
||||
|
||||
**Evidence:** [resolveEnteredRoute](../../route.go#L25) also uses the complete
|
||||
operand-count predicate from R-1, but it examines only tokens before the first
|
||||
@@ -100,6 +100,15 @@ entered path `r` and canonical path `run`; partial-operand help describes that
|
||||
command and recognizes its applicable aliases. Test canonical and shortcut
|
||||
forms, and ensure genuinely invalid execution arity still produces usage errors.
|
||||
|
||||
**Completed:** Canonical tracing now selects runnable commands structurally;
|
||||
installation alone applies the operand-prefix capacity check. Runtime routing
|
||||
keeps command identity and applicable flag aliases for partial or excess
|
||||
operands, while input preparation retains arity validation. Regressions cover
|
||||
interspersed canonical/alias flags, incomplete help, invalid arity, path metadata,
|
||||
and discovery beneath a zero-operand runnable command. The excess-operand
|
||||
regression failed before this change; the full offline root suite and the
|
||||
additional discovery check passed on Go 1.26.8.
|
||||
|
||||
## R-3: Preserve exact target lookup for the explicit help command
|
||||
|
||||
Status: open. Priority: P2. Category: help grammar regression.
|
||||
|
||||
@@ -175,7 +175,7 @@ func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode,
|
||||
}
|
||||
resolved := aliases.Resolve(prefix)
|
||||
trace := traceCanonicalPath(roots, resolved)
|
||||
if trace.exact != nil || trace.operandOwner != nil {
|
||||
if trace.exact != nil || (trace.operandOwner != nil && acceptsOperandPrefix(trace.operandOwner.command, len(resolved)-len(trace.operandOwner.path))) {
|
||||
continue
|
||||
}
|
||||
prefixes[key] = prefix
|
||||
|
||||
@@ -298,7 +298,7 @@ func validateCommandAliases(rules []RoutingRule, resolver *internalalias.Resolve
|
||||
if sourceTrace.exact != nil {
|
||||
return fmt.Errorf("command alias source %q conflicts with canonical command %q", record.sourcePath.String(), sourceTrace.exact.path.String())
|
||||
}
|
||||
if sourceTrace.operandOwner != nil {
|
||||
if sourceTrace.operandOwner != nil && acceptsOperandPrefix(sourceTrace.operandOwner.command, len(record.sourcePath)-len(sourceTrace.operandOwner.path)) {
|
||||
return fmt.Errorf("command alias source %q intercepts operands of canonical command %q", record.sourcePath.String(), sourceTrace.operandOwner.path.String())
|
||||
}
|
||||
|
||||
@@ -318,15 +318,14 @@ func validateCommandAliases(rules []RoutingRule, resolver *internalalias.Resolve
|
||||
func traceCanonicalPath(roots map[string]*canonicalNode, path commandpath.Path) commandAliasTrace {
|
||||
var current *canonicalNode
|
||||
children := roots
|
||||
for index, component := range path {
|
||||
for _, component := range path {
|
||||
next := children[component]
|
||||
if next != nil {
|
||||
current = next
|
||||
children = current.children
|
||||
continue
|
||||
}
|
||||
operandCount := len(path) - index
|
||||
if current != nil && acceptsOperandPrefix(current.command, operandCount) {
|
||||
if current != nil && current.command.record.payload != nil && current.command.record.payload.hasRun() {
|
||||
return commandAliasTrace{operandOwner: current}
|
||||
}
|
||||
return commandAliasTrace{}
|
||||
|
||||
25
route.go
25
route.go
@@ -37,22 +37,23 @@ func resolveEnteredRoute(args []string, snapshot *routingSnapshot) routeResult {
|
||||
case trace.exact != nil:
|
||||
result.kind = routeCanonical
|
||||
result.canonicalPath = trace.exact.path.Clone()
|
||||
case trace.operandOwner != nil:
|
||||
case len(path) == 0:
|
||||
result.kind = routeCanonical
|
||||
result.canonicalPath = trace.operandOwner.path.Clone()
|
||||
default:
|
||||
if discovery, ok := findDiscoveryPath(snapshot.discoveryFlags, path); ok && len(discovery) == len(path) {
|
||||
result.kind = routeDiscovery
|
||||
result.discoveryPath = discovery
|
||||
result.enteredPath = discovery.Clone()
|
||||
result.args = commandpath.CloneTokens(trailing)
|
||||
} else if trace.operandOwner != nil {
|
||||
result.kind = routeCanonical
|
||||
result.canonicalPath = trace.operandOwner.path.Clone()
|
||||
} else {
|
||||
result.enteredPath = path.Clone()
|
||||
}
|
||||
}
|
||||
|
||||
if result.kind == routeCanonical {
|
||||
result.enteredPath = enteredEndpoint(path, origins, len(result.canonicalPath))
|
||||
} else if len(path) == 0 {
|
||||
result.kind = routeCanonical
|
||||
} else if discovery, ok := findDiscoveryPath(snapshot.discoveryFlags, path); ok && len(discovery) == len(path) {
|
||||
result.kind = routeDiscovery
|
||||
result.discoveryPath = discovery
|
||||
result.enteredPath = discovery.Clone()
|
||||
result.args = commandpath.CloneTokens(trailing)
|
||||
} else {
|
||||
result.enteredPath = path.Clone()
|
||||
}
|
||||
|
||||
if result.kind != routeDiscovery {
|
||||
|
||||
@@ -11,6 +11,89 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCommandContextSurvivesIncompleteAndInvalidOperandCounts(t *testing.T) {
|
||||
for _, spelling := range []string{"run", "r"} {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
help bool
|
||||
usage bool
|
||||
}{
|
||||
{name: "interspersed alias", args: []string{"first", "--fmt=yaml", "second"}},
|
||||
{name: "interspersed canonical flag", args: []string{"first", "--format=yaml", "second"}},
|
||||
{name: "partial help", args: []string{"first", "--help"}, help: true},
|
||||
{name: "partial help with flag alias", args: []string{"first", "--fmt=yaml", "--help"}, help: true},
|
||||
{name: "excess operands", args: []string{"first", "second", "third", "--fmt=yaml"}, usage: true},
|
||||
{name: "missing operands", args: []string{"first", "--fmt=yaml"}, usage: true},
|
||||
} {
|
||||
t.Run(spelling+"/"+test.name, func(t *testing.T) {
|
||||
type input struct {
|
||||
operands []string
|
||||
format Supplied[string]
|
||||
}
|
||||
built, ran := 0, false
|
||||
command := NewCommand(CommandSpec[input]{
|
||||
Name: "run", NewInput: func() input { built++; return input{} },
|
||||
Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "format"}, (*string)(nil),
|
||||
func(value string) (string, error) { return value, nil },
|
||||
func(target *input, value Supplied[string]) { target.format = value })},
|
||||
Arguments: []Argument[input]{RepeatArgument(ArgumentMeta{Name: "ARG"}, 2, 2,
|
||||
func(value string) (string, error) { return value, nil },
|
||||
func(target *input, value Supplied[[]string]) { target.operands = value.Value })},
|
||||
Run: func(inv Invocation, value input) Outcome {
|
||||
ran = true
|
||||
if inv.EnteredPath() != spelling || inv.CanonicalPath() != "run" {
|
||||
t.Errorf("invocation paths = %q / %q", inv.EnteredPath(), inv.CanonicalPath())
|
||||
}
|
||||
if !reflect.DeepEqual(value.operands, []string{"first", "second"}) || !value.format.Set || value.format.Value != "yaml" {
|
||||
t.Errorf("input = %#v", value)
|
||||
}
|
||||
return Success()
|
||||
},
|
||||
})
|
||||
app, err := New(Identity{Name: "Demo"}, command, Alias("r", "run"), FlagAliasFor("run", "--fmt", "--format"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), append([]string{spelling}, test.args...), Streams{Out: &output, Err: &diagnostics})
|
||||
if test.usage {
|
||||
if outcome.Kind != OutcomeUsage || strings.Contains(diagnostics.String(), "unknown flag") || !strings.Contains(diagnostics.String(), "Demo "+spelling+" ARG") {
|
||||
t.Fatalf("invalid arity outcome = %v, diagnostics %q", outcome.Kind, diagnostics.String())
|
||||
}
|
||||
} else if outcome.Kind != OutcomeSuccess {
|
||||
t.Fatalf("outcome = %#v, diagnostics %q", outcome, diagnostics.String())
|
||||
}
|
||||
if test.help && (!strings.Contains(output.String(), "Demo "+spelling+" ARG") || !strings.Contains(output.String(), "--fmt → --format")) {
|
||||
t.Errorf("command help = %q", output.String())
|
||||
}
|
||||
if test.help || test.usage {
|
||||
if built != 0 || ran {
|
||||
t.Fatal("help or invalid arity initialized input or ran a handler")
|
||||
}
|
||||
} else if built != 1 || !ran {
|
||||
t.Fatal("valid invocation did not run once")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnableCommandCanHaveAliasOnlyDiscoveryPrefixes(t *testing.T) {
|
||||
ran := false
|
||||
app, err := New(Identity{Name: "Demo"},
|
||||
NewCommand(CommandSpec[struct{}]{Name: "box", Run: func(Invocation, struct{}) Outcome { ran = true; return Success() }}),
|
||||
simpleRunnable("inspect"), Alias("box admin pick", "inspect"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"box", "admin", "--help"}, Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != OutcomeSuccess || !strings.Contains(output.String(), "pick → inspect") || ran {
|
||||
t.Fatalf("discovery outcome = %v, help %q, ran %t", outcome.Kind, output.String(), ran)
|
||||
}
|
||||
}
|
||||
|
||||
func routeTestApp(t *testing.T, extra ...Declaration) *App {
|
||||
t.Helper()
|
||||
collections := simpleRunnable("collections")
|
||||
|
||||
Reference in New Issue
Block a user