diff --git a/builtins.go b/builtins.go index 538d323..402d3a1 100644 --- a/builtins.go +++ b/builtins.go @@ -30,16 +30,15 @@ type versionDocument struct { } func (a *App) installBuiltins() error { - help := NewCommand(CommandSpec[helpInput]{ + help := captureCommand(CommandSpec[helpInput]{ Name: "help", Summary: "Show help for commands.", Description: "Describe registered commands and their inputs.", Arguments: []Argument[helpInput]{RepeatArgument(ArgumentMeta{Name: "COMMAND", Usage: "registered command path"}, 0, -1, func(value string) (string, error) { return value, nil }, func(input *helpInput, value Supplied[[]string]) { input.Targets = value })}, - Run: func(invocation Invocation, input helpInput) Outcome { - return a.renderHelpTargets(input.Targets.Value, invocation.Streams) - }, + }, func(current commandExecution, input helpInput) Outcome { + return current.renderHelpTargets(input.Targets.Value) }) scopeDefault := "app" formatDefault := "text" diff --git a/command.go b/command.go index d55d159..04bef7a 100644 --- a/command.go +++ b/command.go @@ -119,13 +119,12 @@ type commandPayload interface { hasRun() bool bridgeFlags() []internalcobra.Flag bridgeArguments() []internalcobra.Argument - prepare([][]string, []string, bool) (func() any, error) - checkInput(any) error - run(Invocation, any) Outcome + prepare([][]string, []string, bool) (func(*execution) Outcome, error) } type typedCommand[I any] struct { - spec CommandSpec[I] + spec CommandSpec[I] + privateRun func(commandExecution, I) Outcome } func (c typedCommand[I]) validate() error { @@ -133,7 +132,7 @@ func (c typedCommand[I]) validate() error { } func (c typedCommand[I]) hasRun() bool { - return c.spec.Run != nil + return c.spec.Run != nil || c.privateRun != nil } func (c typedCommand[I]) bridgeFlags() []internalcobra.Flag { @@ -167,36 +166,37 @@ func (c typedCommand[I]) bridgeArguments() []internalcobra.Argument { return arguments } -func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enforceRequired bool) (func() any, error) { +func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enforceRequired bool) (func(*execution) Outcome, error) { prepared, err := prepareInput(c.spec, flagValues, operands, enforceRequired) if err != nil { return nil, err } - return func() any { + return func(current *execution) Outcome { input := prepared.build() - return &input + if c.spec.CheckInput != nil { + if err := c.spec.CheckInput(input); err != nil { + return classifyInputError(err) + } + } + if c.privateRun != nil { + return c.privateRun(current.commandContext(), input) + } + return c.spec.Run(Invocation{Context: current.ctx, Streams: current.streams}, input) }, nil } -func (c typedCommand[I]) run(invocation Invocation, input any) Outcome { - return c.spec.Run(invocation, *input.(*I)) -} - -func (c typedCommand[I]) checkInput(input any) error { - if c.spec.CheckInput == nil { - return nil - } - return c.spec.CheckInput(*input.(*I)) -} - // NewCommand captures a typed command declaration. Construction issues are // retained and reported when the declaration is installed. func NewCommand[I any](spec CommandSpec[I]) Command { + return captureCommand(spec, nil) +} + +func captureCommand[I any](spec CommandSpec[I], privateRun func(commandExecution, I) Outcome) Command { copySpec := spec copySpec.Flags = append([]Flag[I](nil), spec.Flags...) copySpec.Arguments = append([]Argument[I](nil), spec.Arguments...) copySpec.Children = append([]Command(nil), spec.Children...) - payload := typedCommand[I]{spec: copySpec} + payload := typedCommand[I]{spec: copySpec, privateRun: privateRun} record := &commandRecord{ name: spec.Name, summary: spec.Summary, description: spec.Description, children: copySpec.Children, payload: payload, diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index b02a7ef..671d72f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement the optional integration modules and captured registration API -Status: implementation in progress. Stages 1 through 4 are complete; subsequent work follows the ordered roadmap. +Status: implementation in progress. Stages 1 through 5 are complete; subsequent work follows the ordered roadmap. Execute the stages in order. Each stage is sized as a separate Luna coding-agent assignment. This document authorizes no publication or changes to sibling repositories. @@ -289,9 +289,14 @@ operational/usage outcomes respectively. Root tests pass. and writer failures. Backend parse errors become neutral usage diagnostics; public results must not expose Cobra/pflag types. -**Completion evidence:** factories/checks/handlers execute only on the selected -ordinary path; target callbacks remain untouched by help/version. Writer failure -and wrapped wiring classifications retain causes. Stage 7 completes routing. +**Completion evidence — complete:** command payloads now compile into typed +invocation closures without `any` input handoffs. Conversion precedes input +materialization; omitted factories preserve the exact zero value. Public handlers +receive only `Invocation` and their typed input, while help uses a narrow private +presentation context through the same capture/compiler path. The path-name special +case for help is gone. Focused execution, pointer-zero-value, help/version callback +isolation, writer-failure, and wrapped-error tests pass. Stage 7 remains responsible +for routing snapshot/session consolidation. ## Stage 6: Make batch installation the sole registry mutation path diff --git a/execute.go b/execute.go index a7fe518..7e630db 100644 --- a/execute.go +++ b/execute.go @@ -33,23 +33,11 @@ func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outco } return current.present(current.renderHelp(parsed.Path), parsed.Path) } - buildInput, err := command.record.payload.prepare(parsed.FlagValues, parsed.Operands, true) + run, err := command.record.payload.prepare(parsed.FlagValues, parsed.Operands, true) if err != nil { return current.present(Usage(err), parsed.Path) } - input := buildInput() - if err := command.record.payload.checkInput(input); err != nil { - return current.present(classifyInputError(err), parsed.Path) - } - if len(parsed.Path) == 1 && parsed.Path[0] == "help" { - help, ok := input.(*helpInput) - if !ok { - return current.present(Failure(errors.New("built-in help input has an unexpected type")), parsed.Path) - } - return current.present(current.renderHelpTargets(help.Targets.Value), parsed.Path) - } - outcome := command.record.payload.run(Invocation{Context: ctx, Streams: streams}, input) - return current.present(outcome, parsed.Path) + return current.present(run(current), parsed.Path) } type execution struct { @@ -61,6 +49,22 @@ type execution struct { session *internalcobra.Session } +type commandExecution struct { + invocation Invocation + renderHelpTargetsFunc func([]string) Outcome +} + +func (current *execution) commandContext() commandExecution { + return commandExecution{ + invocation: Invocation{Context: current.ctx, Streams: current.streams}, + renderHelpTargetsFunc: current.renderHelpTargets, + } +} + +func (current commandExecution) renderHelpTargets(targets []string) Outcome { + return current.renderHelpTargetsFunc(targets) +} + func newExecution(app *App, ctx context.Context, streams Streams) *execution { current := &execution{ app: app, diff --git a/execute_external_test.go b/execute_external_test.go index aa56428..85b4002 100644 --- a/execute_external_test.go +++ b/execute_external_test.go @@ -60,6 +60,25 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) { } } +func TestOmittedInputFactoryUsesExactPointerZeroValue(t *testing.T) { + command := commandkit.NewCommand(commandkit.CommandSpec[*int]{ + Name: "run", + Run: func(_ commandkit.Invocation, value *int) commandkit.Outcome { + if value != nil { + return commandkit.Failure(errors.New("omitted input factory allocated a pointer")) + } + return commandkit.Success() + }, + }) + app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) + if err != nil { + t.Fatal(err) + } + if outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{}); outcome.Kind != commandkit.OutcomeSuccess { + t.Fatalf("pointer zero value outcome = %#v", outcome) + } +} + func TestExecuteUsesFreshSessionForHelpAfterFlagBearingInvocation(t *testing.T) { type input struct { name commandkit.Supplied[string]