From bafd3651156e25bcfa8fafba2ec062c2568ee57a Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 20 Oct 2026 01:03:54 +0000 Subject: [PATCH] Discover help routes through intermediate aliases --- docs/roadmap/remediation.md | 14 ++++++- help_view.go | 62 +++------------------------ internal/alias/resolver.go | 14 +++++++ route.go | 6 ++- route_test.go | 84 +++++++++++++++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 59 deletions(-) diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index af7451f..94cfa27 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -1,6 +1,6 @@ # Alias interaction audit remediation -Status: open; three confirmed findings. +Status: in progress; completed findings are marked individually below. ## Review scope and evidence @@ -38,7 +38,7 @@ messages as contracts. ## R-1: Include intermediate alias prefixes in help discovery -Status: open. Priority: P2. Category: alias-chain composition. +Status: complete. Priority: P2. Category: alias-chain composition. **Evidence:** [resolveEnteredRoute](../../route.go#L25) resolves the entered path to its final form, then looks for discovery contexts using only that final path @@ -86,6 +86,16 @@ Cover an overriding mapping and applicable global help aliases while preserving descendant flag exclusion and metadata-only help. Execution and help must agree on the displayed destinations. +**Completed:** Discovery selection now checks intermediate rewrite paths when +the entered and final paths do not supply a context. Canonical and discovery +help share alias listing across the resolution chain and resolve each displayed +suffix through actual execution precedence. New public-boundary regressions +failed before the correction and now cover group/discovery help, expanded +chains, entered overrides, both help forms, global help aliases, descendant +flag exclusion, metadata-only help, and matching execution destinations. The +complete offline root suite passed on Go 1.26.8. No canonical nodes or public +invocation metadata were added. + ## R-2: Retain discovery-prefix context in usage diagnostics Status: open. Priority: P3. Category: presentation consistency. diff --git a/help_view.go b/help_view.go index cccc9af..6011f25 100644 --- a/help_view.go +++ b/help_view.go @@ -16,13 +16,12 @@ func (a *App) helpView(route routeResult, snapshot *routingSnapshot) commandmeta if snapshot == nil { return view } + view.CommandAliases = routeHelpAliases(snapshot, route) if view.Discovery { - view.CommandAliases = discoveryHelpAliases(snapshot, route) view.FlagAliases = snapshot.discoveryFlags[route.discoveryPath.Key()].neutralRecords() return view } view.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords() - view.CommandAliases = routeHelpAliases(snapshot, route) return view } @@ -30,9 +29,12 @@ func clonePath(path commandpath.Path) []string { return append([]string(nil), path...) } -func discoveryHelpAliases(snapshot *routingSnapshot, route routeResult) []commandmeta.CommandAlias { +func routeHelpAliases(snapshot *routingSnapshot, route routeResult) []commandmeta.CommandAlias { + if route.kind != routeDiscovery && len(route.canonicalPath) == 0 { + return append([]commandmeta.CommandAlias(nil), snapshot.commandAliases...) + } bySource := make(map[string]commandmeta.CommandAlias) - for _, prefix := range []commandpath.Path{route.enteredPath, route.discoveryPath} { + for _, prefix := range snapshot.aliases.ResolutionPaths(route.enteredPath) { for _, alias := range snapshot.commandAliases { source := commandpath.FromTokens(alias.Source) if len(source) <= len(prefix) || !source.HasPrefix(prefix) { @@ -58,55 +60,3 @@ func discoveryHelpAliases(snapshot *routingSnapshot, route routeResult) []comman } return result } - -func routeHelpAliases(snapshot *routingSnapshot, route routeResult) []commandmeta.CommandAlias { - if len(route.canonicalPath) == 0 { - return append([]commandmeta.CommandAlias(nil), snapshot.commandAliases...) - } - node := traceCanonicalPath(snapshot.canonical, route.canonicalPath).exact - if node == nil { - return nil - } - - entered := route.enteredPath - canonical := route.canonicalPath - bySource := make(map[string]commandmeta.CommandAlias) - add := func(base commandpath.Path, includeOnlyWhenReached bool) { - for _, alias := range snapshot.commandAliases { - source := commandpath.FromTokens(alias.Source) - if len(source) <= len(base) || !source.HasPrefix(base) { - continue - } - suffix := source[len(base):] - if includeOnlyWhenReached { - enteredSource := append(entered.Clone(), suffix...) - if !snapshot.aliases.Resolve(enteredSource).Equal(commandpath.FromTokens(alias.Target)) { - continue - } - } - key := commandpath.FromTokens(suffix).Key() - if _, exists := bySource[key]; exists { - continue - } - bySource[key] = commandmeta.CommandAlias{ - Source: clonePath(suffix), - Target: append([]string(nil), alias.Target...), - } - } - } - add(entered, false) - if !entered.Equal(canonical) { - add(canonical, true) - } - - keys := make([]string, 0, len(bySource)) - for key := range bySource { - keys = append(keys, key) - } - sort.Strings(keys) - result := make([]commandmeta.CommandAlias, 0, len(keys)) - for _, key := range keys { - result = append(result, bySource[key]) - } - return result -} diff --git a/internal/alias/resolver.go b/internal/alias/resolver.go index 92f5110..959d8a5 100644 --- a/internal/alias/resolver.go +++ b/internal/alias/resolver.go @@ -133,6 +133,20 @@ func (resolver *Resolver) Resolve(path commandpath.Path) commandpath.Path { } } +// ResolutionPaths returns the entered path and each path produced by a +// longest-prefix rewrite. It supports metadata discovery along alias chains. +// Installation validates termination before this method is used. +func (resolver *Resolver) ResolutionPaths(path commandpath.Path) []commandpath.Path { + paths := []commandpath.Path{path.Clone()} + for { + next, _, matched := resolver.ResolveOnce(paths[len(paths)-1]) + if !matched { + return paths + } + paths = append(paths, 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. diff --git a/route.go b/route.go index 7bb9417..4e7b74b 100644 --- a/route.go +++ b/route.go @@ -40,7 +40,11 @@ func resolveEnteredRoute(args []string, snapshot *routingSnapshot) routeResult { case len(path) == 0: result.kind = routeCanonical default: - if discovery, ok := findDiscoveryPath(snapshot.discoveryFlags, resolved, path); ok { + discovery, ok := findDiscoveryPath(snapshot.discoveryFlags, resolved, path) + if !ok && len(snapshot.discoveryFlags) > 0 { + discovery, ok = findDiscoveryPath(snapshot.discoveryFlags, snapshot.aliases.ResolutionPaths(path)...) + } + if ok { result.kind = routeDiscovery result.discoveryPath = discovery result.enteredPath = path.Clone() diff --git a/route_test.go b/route_test.go index 3eddc26..3f17465 100644 --- a/route_test.go +++ b/route_test.go @@ -239,6 +239,90 @@ func TestDiscoveryHelpComposesGroupShortcutsAndOverrides(t *testing.T) { } } +func TestHelpDiscoversRoutesThroughIntermediateAliases(t *testing.T) { + for _, test := range []struct { + name string + nested bool + override bool + expanded bool + }{ + {name: "group"}, + {name: "discovery", nested: true}, + {name: "discovery override", nested: true, override: true}, + {name: "expanded chain", nested: true, expanded: true}, + } { + t.Run(test.name, func(t *testing.T) { + built, runs := 0, 0 + var canonical, entered string + makeCommand := func(name string) Command { + return NewCommand(CommandSpec[struct{}]{ + Name: name, NewInput: func() struct{} { built++; return struct{}{} }, + Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "format"}, (*string)(nil), + func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})}, + Run: func(inv Invocation, _ struct{}) Outcome { + runs++ + entered, canonical = inv.EnteredPath(), inv.CanonicalPath() + return Success() + }, + }) + } + group, target := Group("box", "Box"), "box" + if test.expanded { + group, target = Group("outer", "Outer", group), "outer box" + } + prefix, middle := "p", "q" + if test.nested { + prefix, middle = "p admin", "q admin" + } + declarations := []Declaration{group, makeCommand("inspect"), makeCommand("alternate"), + Alias("p", "q"), Alias("q", target), Alias(middle+" pick", "inspect"), Alias(middle+" other", "inspect"), + FlagAlias("--hlep", "--help"), FlagAliasFor("inspect", "--fmt", "--format")} + wantTarget := "inspect" + if test.override { + declarations = append(declarations, Alias(prefix+" pick", "alternate")) + wantTarget = "alternate" + } + app, err := New(Identity{Name: "Demo"}, declarations...) + if err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + append(strings.Fields(prefix), "--help"), + append([]string{"help"}, strings.Fields(prefix)...), + append(strings.Fields(prefix), "--hlep"), + } { + var output, diagnostics bytes.Buffer + outcome := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostics}) + if outcome.Kind != OutcomeSuccess { + t.Errorf("Execute(%q) = %v, diagnostics %q", args, outcome.Kind, diagnostics.String()) + } + for _, fragment := range []string{"Demo " + prefix, "pick → " + wantTarget, "other → inspect", "--hlep → --help"} { + if !strings.Contains(output.String(), fragment) { + t.Errorf("help %q omits %q", output.String(), fragment) + } + } + if strings.Contains(output.String(), "--fmt") || strings.Contains(output.String(), "--format") || + (test.override && strings.Contains(output.String(), "pick → inspect")) { + t.Errorf("help exposes a descendant flag or overridden destination: %q", output.String()) + } + } + if built != 0 || runs != 0 { + t.Fatalf("help built %d inputs and ran %d handlers", built, runs) + } + for _, suffix := range []string{"pick", "other"} { + outcome := app.Execute(context.Background(), strings.Fields(prefix+" "+suffix), Streams{Out: io.Discard, Err: io.Discard}) + want := "inspect" + if suffix == "pick" { + want = wantTarget + } + if outcome.Kind != OutcomeSuccess || canonical != want || entered != prefix+" "+suffix { + t.Errorf("route %s %s: outcome %v, entered %q, canonical %q", prefix, suffix, outcome.Kind, entered, canonical) + } + } + }) + } +} + func TestDiscoveryHelpThroughExpandedGroupPath(t *testing.T) { app, err := New(Identity{Name: "Demo"}, Group("outer", "Outer", Group("box", "Box")), simpleRunnable("inspect"), Alias("pk", "outer box"), Alias("outer box admin pick", "inspect"))