Resolve entered command routes to canonical paths

This commit is contained in:
2026-10-19 17:37:59 +00:00
parent 6ea8c4dd3f
commit 58b71c390f
8 changed files with 399 additions and 5 deletions

8
app.go
View File

@@ -20,8 +20,12 @@ type App struct {
}
type routingSnapshot struct {
metadata *commandmeta.Command
runners map[commandmeta.Key]commandPayload
metadata *commandmeta.Command
runners map[commandmeta.Key]commandPayload
aliases *alias.Resolver
canonical map[string]*canonicalNode
flagAliases map[string]resolvedFlagAliases
discoveryFlags map[string]resolvedFlagAliases
}
type commandNode struct {

View File

@@ -1,6 +1,6 @@
# Application-owned aliases implementation plan
Status: in progress; Stages 1–6 completed.
Status: in progress; Stages 1–7 completed.
## Authority, baseline, and scope
@@ -439,6 +439,13 @@ local workspace.
an explicit longer route while canonical `run full` remains unchanged;
flags/values/space-containing argv tokens never becoming command components.
**Completed:** Routing snapshots now retain canonical path metadata and the
separate command-alias resolver. Execution resolves command prefixes before
parsing, tracks the original input-token origins through alias chains, and
preserves the remaining argv. Canonical operands, flags, terminators, unknown
paths, and alias-only discovery prefixes receive distinct route results.
Verified with root and both integration module suites using the local workspace.
## Stage 8: Integrate parser-aware flag aliasing and short grammar
**Work**

View File

@@ -23,7 +23,8 @@ func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outco
streams = normalizeStreams(streams)
current := newExecution(a, ctx, streams)
routedSnapshot := current.snapshot
parsed := current.parse(args)
current.route = resolveEnteredRoute(args, routedSnapshot)
parsed := current.parse(current.route.args)
if parsed.Failure != nil {
return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path)
}
@@ -51,6 +52,7 @@ type execution struct {
options commandmeta.ParseOptions
snapshot *routingSnapshot
session *internalcobra.Session
route routeResult
}
type commandExecution struct {

View File

@@ -0,0 +1,28 @@
package alias
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
func TestResolveWithProvenanceCarriesInputOriginsThroughChains(t *testing.T) {
resolver, err := NewResolver([]Mapping{
{Source: commandpath.Path{"collections"}, Target: commandpath.Path{"list", "collections"}},
{Source: commandpath.Path{"list", "collections"}, Target: commandpath.Path{"promptkit", "list", "collections"}},
})
if err != nil {
t.Fatal(err)
}
path := commandpath.Path{"collections", "--format", "yaml"}
gotPath, gotOrigins := resolver.ResolveWithProvenance(path)
if want := (commandpath.Path{"promptkit", "list", "collections", "--format", "yaml"}); !reflect.DeepEqual(gotPath, want) {
t.Fatalf("resolved path = %v, want %v", gotPath, want)
}
wantOrigins := [][]int{{0}, {0}, {0}, {1}, {2}}
if !reflect.DeepEqual(gotOrigins, wantOrigins) {
t.Fatalf("origins = %v, want %v", gotOrigins, wantOrigins)
}
}

View File

@@ -132,3 +132,39 @@ func (resolver *Resolver) Resolve(path commandpath.Path) commandpath.Path {
current = next
}
}
// ResolveWithProvenance resolves a command path and carries the input token
// indexes that contributed to each output component. Rewritten target
// components inherit the origins of the matched source prefix.
func (resolver *Resolver) ResolveWithProvenance(path commandpath.Path) (commandpath.Path, [][]int) {
current := path.Clone()
origins := make([][]int, len(path))
for index := range path {
origins[index] = []int{index}
}
for {
next, mapping, matched := resolver.ResolveOnce(current)
if !matched {
return current, origins
}
matchedOrigins := make([]int, 0)
seen := make(map[int]struct{})
for _, componentOrigins := range origins[:len(mapping.Source)] {
for _, origin := range componentOrigins {
if _, exists := seen[origin]; exists {
continue
}
seen[origin] = struct{}{}
matchedOrigins = append(matchedOrigins, origin)
}
}
nextOrigins := make([][]int, 0, len(mapping.Target)+len(origins)-len(mapping.Source))
for range mapping.Target {
nextOrigins = append(nextOrigins, append([]int(nil), matchedOrigins...))
}
nextOrigins = append(nextOrigins, origins[len(mapping.Source):]...)
current = next
origins = nextOrigins
}
}

View File

@@ -26,7 +26,14 @@ func (a *App) routeSnapshot() *routingSnapshot {
func (a *App) buildRoutingSnapshot() *routingSnapshot {
root := &commandmeta.Command{Name: a.identity.Name}
snapshot := &routingSnapshot{metadata: root, runners: make(map[commandmeta.Key]commandPayload)}
snapshot := &routingSnapshot{
metadata: root,
runners: make(map[commandmeta.Key]commandPayload),
aliases: a.aliases,
canonical: buildCanonicalTree(a.commands),
flagAliases: a.flagAliases,
discoveryFlags: a.discoveryFlags,
}
names := make([]string, 0, len(a.commands))
for name := range a.commands {
names = append(names, name)

96
route.go Normal file
View File

@@ -0,0 +1,96 @@
package commandkit
import (
"strings"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
type routeKind uint8
const (
routeUnknown routeKind = iota
routeCanonical
routeDiscovery
)
type routeResult struct {
kind routeKind
args []string
enteredPath commandpath.Path
canonicalPath commandpath.Path
discoveryPath commandpath.Path
}
func resolveEnteredRoute(args []string, snapshot *routingSnapshot) routeResult {
path, trailing := commandPathTokens(args)
result := routeResult{kind: routeUnknown}
if snapshot == nil {
result.args = append(append([]string(nil), path...), trailing...)
result.enteredPath = path.Clone()
return result
}
resolved, origins := snapshot.aliases.ResolveWithProvenance(path)
trace := traceCanonicalPath(snapshot.canonical, resolved)
switch {
case trace.exact != nil:
result.kind = routeCanonical
result.canonicalPath = trace.exact.path.Clone()
case trace.operandOwner != nil:
result.kind = routeCanonical
result.canonicalPath = trace.operandOwner.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 {
result.kind = routeDiscovery
result.discoveryPath = discovery
result.enteredPath = discovery.Clone()
} else {
result.enteredPath = path.Clone()
}
result.args = make([]string, 0, len(resolved)+len(trailing))
result.args = append(result.args, resolved...)
result.args = append(result.args, trailing...)
return result
}
func commandPathTokens(args []string) (commandpath.Path, []string) {
end := len(args)
for index, token := range args {
if token == "--" || (token != "-" && strings.HasPrefix(token, "-")) {
end = index
break
}
}
path := make(commandpath.Path, end)
copy(path, args[:end])
return path, append([]string(nil), args[end:]...)
}
func enteredEndpoint(path commandpath.Path, origins [][]int, canonicalLength int) commandpath.Path {
inputLength := 0
for _, componentOrigins := range origins[:canonicalLength] {
for _, origin := range componentOrigins {
if origin+1 > inputLength {
inputLength = origin + 1
}
}
}
return path[:inputLength].Clone()
}
func findDiscoveryPath(contexts map[string]resolvedFlagAliases, path commandpath.Path) (commandpath.Path, bool) {
for length := len(path); length > 0; length-- {
prefix := path[:length]
if _, exists := contexts[prefix.Key()]; exists {
return prefix.Clone(), true
}
}
return nil, false
}

214
route_test.go Normal file
View File

@@ -0,0 +1,214 @@
package commandkit
import (
"bytes"
"context"
"io"
"reflect"
"testing"
)
func routeTestApp(t *testing.T, extra ...Declaration) *App {
t.Helper()
collections := simpleRunnable("collections")
list := Group("list", "List", collections)
testCommand := runnableWithOperands("test", 0, 1)
promptkit := Group("promptkit", "PromptKit", list, testCommand)
run := runnableWithOperands("run", 0, 1)
inspect := runnableWithOperands("inspect", 1, 1)
declarations := []Declaration{
promptkit,
run,
inspect,
Alias("collections", "list collections"),
Alias("list collections", "promptkit list collections"),
Alias("pk", "promptkit"),
Alias("promptkit list bundles", "promptkit list collections"),
Alias("smoke", "promptkit test"),
Alias("r", "run"),
Alias("r full", "inspect"),
}
declarations = append(declarations, extra...)
app, err := New(Identity{Name: "Example"}, declarations...)
if err != nil {
t.Fatal(err)
}
return app
}
func TestEnteredRoutesResolveChainsAndPreserveTokenOrigins(t *testing.T) {
app := routeTestApp(t)
snapshot := app.routeSnapshot()
for _, test := range []struct {
name string
args []string
entered string
canonical string
wantArgs []string
wantKind routeKind
}{
{
name: "command alias chain",
args: []string{"collections", "--tag", "first"},
entered: "collections",
canonical: "promptkit list collections",
wantArgs: []string{"promptkit", "list", "collections", "--tag", "first"},
wantKind: routeCanonical,
},
{
name: "group alias composition",
args: []string{"pk", "list", "bundles"},
entered: "pk list bundles",
canonical: "promptkit list collections",
wantArgs: []string{"promptkit", "list", "collections"},
wantKind: routeCanonical,
},
{
name: "smoke operand",
args: []string{"smoke", "all"},
entered: "smoke",
canonical: "promptkit test",
wantArgs: []string{"promptkit", "test", "all"},
wantKind: routeCanonical,
},
{
name: "explicit longer alternate spelling",
args: []string{"r", "full"},
entered: "r full",
canonical: "inspect",
wantArgs: []string{"inspect"},
wantKind: routeCanonical,
},
{
name: "canonical runnable operand",
args: []string{"run", "full"},
entered: "run",
canonical: "run",
wantArgs: []string{"run", "full"},
wantKind: routeCanonical,
},
{
name: "single dash remains an operand",
args: []string{"run", "-"},
entered: "run",
canonical: "run",
wantArgs: []string{"run", "-"},
wantKind: routeCanonical,
},
{
name: "unknown path",
args: []string{"missing", "route"},
entered: "missing route",
wantArgs: []string{"missing", "route"},
wantKind: routeUnknown,
},
{
name: "space-containing token is not split",
args: []string{"collections list"},
entered: "collections list",
wantArgs: []string{"collections list"},
wantKind: routeUnknown,
},
{
name: "flag values and spaced tokens",
args: []string{"collections", "--message", "--hlep", "--text=two words"},
entered: "collections",
canonical: "promptkit list collections",
wantArgs: []string{"promptkit", "list", "collections", "--message", "--hlep", "--text=two words"},
wantKind: routeCanonical,
},
{
name: "terminator stops command matching",
args: []string{"collections", "--", "r", "full"},
entered: "collections",
canonical: "promptkit list collections",
wantArgs: []string{"promptkit", "list", "collections", "--", "r", "full"},
wantKind: routeCanonical,
},
} {
t.Run(test.name, func(t *testing.T) {
got := resolveEnteredRoute(test.args, snapshot)
if got.kind != test.wantKind {
t.Fatalf("route kind = %d, want %d", got.kind, test.wantKind)
}
if got.enteredPath.String() != test.entered {
t.Errorf("entered path = %q, want %q", got.enteredPath.String(), test.entered)
}
if got.canonicalPath.String() != test.canonical {
t.Errorf("canonical path = %q, want %q", got.canonicalPath.String(), test.canonical)
}
if !reflect.DeepEqual(got.args, test.wantArgs) {
t.Errorf("routed args = %q, want %q", got.args, test.wantArgs)
}
})
}
}
func TestLongerRouteSelectionDoesNotFallBackAfterTargetParsingFails(t *testing.T) {
app := routeTestApp(t)
result := resolveEnteredRoute([]string{"r", "full"}, app.routeSnapshot())
if got, want := result.canonicalPath.String(), "inspect"; got != want {
t.Fatalf("canonical path = %q, want %q", got, want)
}
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"r", "full"}, Streams{Out: io.Discard, Err: &diagnostics})
if outcome.Kind != OutcomeUsage {
t.Fatalf("outcome kind = %v, want usage; diagnostics: %s", outcome.Kind, diagnostics.String())
}
}
func TestEnteredAliasCanSelectExplicitLongerMapping(t *testing.T) {
app := routeTestApp(t, Alias("pk list bundles", "inspect"))
result := resolveEnteredRoute([]string{"pk", "list", "bundles"}, app.routeSnapshot())
if got, want := result.canonicalPath.String(), "inspect"; got != want {
t.Fatalf("canonical path = %q, want %q", got, want)
}
if got, want := result.enteredPath.String(), "pk list bundles"; got != want {
t.Fatalf("entered path = %q, want %q", got, want)
}
}
func TestExecuteDispatchesEnteredAliasToCanonicalRunner(t *testing.T) {
runs := 0
command := NewCommand(CommandSpec[struct{}]{
Name: "canonical", Summary: "Canonical",
Run: func(Invocation, struct{}) Outcome {
runs++
return Success()
},
})
app, err := New(Identity{Name: "Example"}, command, Alias("shortcut", "canonical"))
if err != nil {
t.Fatal(err)
}
outcome := app.Execute(context.Background(), []string{"shortcut"}, Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != OutcomeSuccess || runs != 1 {
t.Fatalf("alias execution = (%v, runs %d), want success and one canonical run", outcome.Kind, runs)
}
}
func TestAliasOnlyPrefixProducesDiscoveryRoute(t *testing.T) {
app, err := New(Identity{Name: "Example"},
Group("promptkit", "PromptKit", Group("list", "List", simpleRunnable("collections"))),
Alias("list collections", "promptkit list collections"),
)
if err != nil {
t.Fatal(err)
}
result := resolveEnteredRoute([]string{"list", "--help"}, app.routeSnapshot())
if result.kind != routeDiscovery {
t.Fatalf("route kind = %d, want discovery", result.kind)
}
if got, want := result.discoveryPath.String(), "list"; got != want {
t.Fatalf("discovery path = %q, want %q", got, want)
}
if got, want := result.enteredPath.String(), "list"; got != want {
t.Fatalf("entered path = %q, want %q", got, want)
}
if !reflect.DeepEqual(result.args, []string{"list", "--help"}) {
t.Fatalf("discovery args = %q, want [list --help]", result.args)
}
}