From 451a0c483faf96764af8278f26bf2aff6569934e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 19 Oct 2026 19:33:49 +0000 Subject: [PATCH] Expose canonical paths on command invocations --- command.go | 2 +- docs/roadmap/implementation.md | 9 +++- execute.go | 11 +++- identity.go | 11 ++++ route_test.go | 98 ++++++++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 3 deletions(-) diff --git a/command.go b/command.go index 795972c..8ce5788 100644 --- a/command.go +++ b/command.go @@ -232,7 +232,7 @@ func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enfor if c.privateRun != nil { return c.privateRun(current.commandContext(), input) } - return c.spec.Run(Invocation{Context: current.ctx, Streams: current.streams}, input) + return c.spec.Run(current.invocation(), input) }, nil } diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c03352a..750c3ec 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Application-owned aliases implementation plan -Status: in progress; Stages 1–8 completed. +Status: in progress; Stages 1–9 completed. ## Authority, baseline, and scope @@ -495,6 +495,13 @@ and canonical forms reach the same binding/callback once; repeated calls and declaration reuse do not leak invocation values; zero Invocation accessors are empty. Existing cancellation and domain outcome checks remain green. +**Completed:** Invocation now retains the entered and canonical route paths +alongside the caller's context and streams. Public typed handlers and private +callbacks receive the same resolved identity. Tests cover canonical and alias +calls with operands, repeated calls, declaration reuse across Apps, private +callback plumbing, and zero-value accessors. Verified with the root, ckconfig, +and ckprompt suites under the local workspace. + ## Stage 10: Add alias-aware help and discovery views **Work** diff --git a/execute.go b/execute.go index a5d3e51..fb19d5a 100644 --- a/execute.go +++ b/execute.go @@ -62,11 +62,20 @@ type commandExecution struct { func (current *execution) commandContext() commandExecution { return commandExecution{ - invocation: Invocation{Context: current.ctx, Streams: current.streams}, + invocation: current.invocation(), renderHelpTargetsFunc: current.renderHelpTargets, } } +func (current *execution) invocation() Invocation { + return Invocation{ + Context: current.ctx, + Streams: current.streams, + enteredPath: current.route.enteredPath.String(), + canonicalPath: current.route.canonicalPath.String(), + } +} + func (current commandExecution) renderHelpTargets(targets []string) Outcome { return current.renderHelpTargetsFunc(targets) } diff --git a/identity.go b/identity.go index 23eece8..57eebd0 100644 --- a/identity.go +++ b/identity.go @@ -34,4 +34,15 @@ type Invocation struct { Context context.Context // Streams are the caller-selected output destinations. Streams Streams + + enteredPath string + canonicalPath string } + +// EnteredPath returns the command path used to invoke the selected command. +// It is empty for an Invocation that was not created by command execution. +func (inv Invocation) EnteredPath() string { return inv.enteredPath } + +// CanonicalPath returns the selected command's canonical path. +// It is empty for an Invocation that was not created by command execution. +func (inv Invocation) CanonicalPath() string { return inv.canonicalPath } diff --git a/route_test.go b/route_test.go index aba4af7..09c4f8b 100644 --- a/route_test.go +++ b/route_test.go @@ -190,6 +190,104 @@ func TestExecuteDispatchesEnteredAliasToCanonicalRunner(t *testing.T) { } } +func TestInvocationReportsEnteredAndCanonicalPathsPerCall(t *testing.T) { + type input struct { + label Supplied[string] + values Supplied[[]string] + } + type observed struct { + entered string + canonical string + label string + values []string + } + var calls []observed + command := NewCommand(CommandSpec[input]{ + Name: "run", + Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "label"}, (*string)(nil), + func(value string) (string, error) { return value, nil }, + func(target *input, label Supplied[string]) { target.label = label }, + )}, + Arguments: []Argument[input]{RepeatArgument(ArgumentMeta{Name: "value"}, 0, -1, + func(value string) (string, error) { return value, nil }, + func(target *input, values Supplied[[]string]) { target.values = values }, + )}, + Run: func(inv Invocation, value input) Outcome { + calls = append(calls, observed{ + entered: inv.EnteredPath(), + canonical: inv.CanonicalPath(), + label: value.label.Value, + values: append([]string(nil), value.values.Value...), + }) + return Success() + }, + }) + shortcut := Alias("shortcut", "workspace run") + app, err := New(Identity{Name: "Example"}, Group("workspace", "Workspace", command), shortcut) + if err != nil { + t.Fatal(err) + } + + for _, args := range [][]string{{"workspace", "run", "--label=first", "first"}, {"shortcut", "--label=second", "second"}, {"workspace", "run"}} { + if outcome := app.Execute(context.Background(), args, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeSuccess { + t.Fatalf("Execute(%q) = %v, want success", args, outcome.Kind) + } + } + if len(calls) != 3 { + t.Fatalf("handler calls = %d, want 3", len(calls)) + } + want := []observed{ + {entered: "workspace run", canonical: "workspace run", label: "first", values: []string{"first"}}, + {entered: "shortcut", canonical: "workspace run", label: "second", values: []string{"second"}}, + {entered: "workspace run", canonical: "workspace run"}, + } + if !reflect.DeepEqual(calls, want) { + t.Fatalf("handler invocations = %#v, want %#v", calls, want) + } + + secondApp, err := New(Identity{Name: "Another"}, Group("workspace", "Workspace", command), shortcut) + if err != nil { + t.Fatal(err) + } + if outcome := secondApp.Execute(context.Background(), []string{"shortcut"}, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeSuccess { + t.Fatalf("reused declaration execution = %v, want success", outcome.Kind) + } + if got := calls[3]; got.entered != "shortcut" || got.canonical != "workspace run" || len(got.values) != 0 { + t.Fatalf("reused declaration invocation = %#v", got) + } + + var zero Invocation + if zero.EnteredPath() != "" || zero.CanonicalPath() != "" { + t.Fatalf("zero Invocation paths = (%q, %q), want empty paths", zero.EnteredPath(), zero.CanonicalPath()) + } +} + +func TestPrivateCommandCallbacksReceiveResolvedInvocationPaths(t *testing.T) { + var calls []Invocation + command := Command{record: &commandRecord{ + name: "inspect", + payload: typedCommand[struct{}]{ + spec: CommandSpec[struct{}]{Name: "inspect"}, + privateRun: func(exec commandExecution, _ struct{}) Outcome { + calls = append(calls, exec.invocation) + return Success() + }, + }, + }} + app, err := New(Identity{Name: "Example"}, command, Alias("shortcut", "inspect")) + if err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"inspect"}, {"shortcut"}} { + if outcome := app.Execute(context.Background(), args, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeSuccess { + t.Fatalf("Execute(%q) = %v, want success", args, outcome.Kind) + } + } + if len(calls) != 2 || calls[0].EnteredPath() != "inspect" || calls[0].CanonicalPath() != "inspect" || calls[1].EnteredPath() != "shortcut" || calls[1].CanonicalPath() != "inspect" { + t.Fatalf("private callback invocations = %#v", calls) + } +} + func TestExecuteAppliesCanonicalCommandFlagAliasesAfterRouting(t *testing.T) { type input struct{ color string } var got string