diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 113cbdd..378f388 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -111,7 +111,7 @@ 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. +Status: complete. Priority: P2. Category: help grammar regression. **Evidence:** [renderHelpTargets](../../execute.go#L155) reuses execution lookup and accepts `routeCanonical` results even when remaining target tokens are @@ -137,6 +137,13 @@ requires operands. targets fail with usage status 2; exact aliases, group shortcuts, and discovery prefixes still render help without application initialization. +**Completed:** Explicit help now requires all resolved target tokens to form +the selected path; execution operands no longer disappear into a help view. +New public-boundary regressions failed before the fix and now reject canonical +and aliased operand-bearing targets while preserving exact command/group aliases, +discovery help, and ordinary execution-form help without input initialization. +The complete offline root suite passed on Go 1.26.8. + ## R-4: Resolve discovery-help contexts through group shortcuts Status: open. Priority: P2. Category: incomplete alias composition. diff --git a/execute.go b/execute.go index 259ae8d..75c0f2e 100644 --- a/execute.go +++ b/execute.go @@ -161,7 +161,9 @@ func (current *execution) renderHelpTargets(targets []string) Outcome { return Failure(errors.New("cannot render help without command metadata")) } route := resolveEnteredRoute(targets, snapshot) - if route.kind == routeUnknown { + // Explicit help targets are paths, not command invocations with operands. + // Discovery routes have no canonical path and retain only trailing options. + if route.kind == routeUnknown || len(route.args) != len(route.canonicalPath) { session := internalcobra.Prepare(snapshot.metadata, current.options) return current.app.renderHelpOutcomeWithSession(targets, current.streams, session) } diff --git a/route_test.go b/route_test.go index ffbe81c..ddde153 100644 --- a/route_test.go +++ b/route_test.go @@ -94,6 +94,44 @@ func TestRunnableCommandCanHaveAliasOnlyDiscoveryPrefixes(t *testing.T) { } } +func TestExplicitHelpRequiresAnExactResolvedTarget(t *testing.T) { + initialized := false + run := NewCommand(CommandSpec[struct{}]{ + Name: "run", NewInput: func() struct{} { initialized = true; return struct{}{} }, + Arguments: []Argument[struct{}]{RepeatArgument(ArgumentMeta{Name: "ARG"}, 2, 2, + func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[[]string]) {})}, + Run: func(Invocation, struct{}) Outcome { t.Error("help ran the command"); return Success() }, + }) + app, err := New(Identity{Name: "Demo"}, run, Group("box", "Box", simpleRunnable("show")), + Alias("r", "run"), Alias("b", "box"), Alias("list show", "box show")) + if err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"help", "run", "first", "second"}, {"help", "r", "first"}, + {"help", "r", "first", "second"}, {"help", "r", "first", "second", "third"}, + } { + var output, diagnostics bytes.Buffer + outcome := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostics}) + if outcome.Kind != OutcomeUsage || output.Len() != 0 { + t.Errorf("Execute(%q) = %v, output %q, diagnostics %q", args, outcome.Kind, output.String(), diagnostics.String()) + } + } + for _, args := range [][]string{ + {"help", "run"}, {"help", "r"}, {"help", "box"}, {"help", "b"}, + {"help", "list"}, {"r", "first", "second", "--help"}, + } { + var output, diagnostics bytes.Buffer + outcome := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostics}) + if outcome.Kind != OutcomeSuccess || !strings.Contains(output.String(), "Usage:") { + t.Errorf("Execute(%q) = %v, output %q, diagnostics %q", args, outcome.Kind, output.String(), diagnostics.String()) + } + } + if initialized { + t.Fatal("help initialized command input") + } +} + func routeTestApp(t *testing.T, extra ...Declaration) *App { t.Helper() collections := simpleRunnable("collections")