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 ## 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 **Evidence:** [resolveEnteredRoute](../../route.go#L25) also uses the complete
operand-count predicate from R-1, but it examines only tokens before the first 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 command and recognizes its applicable aliases. Test canonical and shortcut
forms, and ensure genuinely invalid execution arity still produces usage errors. 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 ## R-3: Preserve exact target lookup for the explicit help command
Status: open. Priority: P2. Category: help grammar regression. 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) resolved := aliases.Resolve(prefix)
trace := traceCanonicalPath(roots, resolved) 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 continue
} }
prefixes[key] = prefix prefixes[key] = prefix

View File

@@ -298,7 +298,7 @@ func validateCommandAliases(rules []RoutingRule, resolver *internalalias.Resolve
if sourceTrace.exact != nil { if sourceTrace.exact != nil {
return fmt.Errorf("command alias source %q conflicts with canonical command %q", record.sourcePath.String(), sourceTrace.exact.path.String()) 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()) 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 { func traceCanonicalPath(roots map[string]*canonicalNode, path commandpath.Path) commandAliasTrace {
var current *canonicalNode var current *canonicalNode
children := roots children := roots
for index, component := range path { for _, component := range path {
next := children[component] next := children[component]
if next != nil { if next != nil {
current = next current = next
children = current.children children = current.children
continue continue
} }
operandCount := len(path) - index if current != nil && current.command.record.payload != nil && current.command.record.payload.hasRun() {
if current != nil && acceptsOperandPrefix(current.command, operandCount) {
return commandAliasTrace{operandOwner: current} return commandAliasTrace{operandOwner: current}
} }
return commandAliasTrace{} return commandAliasTrace{}

View File

@@ -37,22 +37,23 @@ func resolveEnteredRoute(args []string, snapshot *routingSnapshot) routeResult {
case trace.exact != nil: case trace.exact != nil:
result.kind = routeCanonical result.kind = routeCanonical
result.canonicalPath = trace.exact.path.Clone() result.canonicalPath = trace.exact.path.Clone()
case trace.operandOwner != nil: case len(path) == 0:
result.kind = routeCanonical 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 { if result.kind == routeCanonical {
result.enteredPath = enteredEndpoint(path, origins, len(result.canonicalPath)) 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 { if result.kind != routeDiscovery {

View File

@@ -11,6 +11,89 @@ import (
"testing" "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 { func routeTestApp(t *testing.T, extra ...Declaration) *App {
t.Helper() t.Helper()
collections := simpleRunnable("collections") collections := simpleRunnable("collections")