diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 816e90b..113cbdd 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -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. diff --git a/flag_aliases.go b/flag_aliases.go index d87841a..5d51377 100644 --- a/flag_aliases.go +++ b/flag_aliases.go @@ -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 diff --git a/registry.go b/registry.go index ae22277..556c24e 100644 --- a/registry.go +++ b/registry.go @@ -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{} diff --git a/route.go b/route.go index 71e4b66..35e545c 100644 --- a/route.go +++ b/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 { diff --git a/route_test.go b/route_test.go index f2c868e..ffbe81c 100644 --- a/route_test.go +++ b/route_test.go @@ -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")