Keep command routing independent of operand counts

This commit is contained in:
2026-10-20 00:22:25 +00:00
parent ccf8acd861
commit 7dad20680e
5 changed files with 110 additions and 18 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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{}

View File

@@ -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 {

View File

@@ -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")