Add invocation coordination and diagnostics

This commit is contained in:
2026-10-07 13:30:24 +00:00
parent f206c531f1
commit c3448a2ebe
5 changed files with 462 additions and 3 deletions

View File

@@ -99,6 +99,7 @@ type commandPayload interface {
hasRun() bool hasRun() bool
bridgeFlags() []internalcobra.Flag bridgeFlags() []internalcobra.Flag
prepare([][]string, []string, bool) (func() any, error) prepare([][]string, []string, bool) (func() any, error)
checkInput(any) error
run(Invocation, any) Outcome run(Invocation, any) Outcome
} }
@@ -146,6 +147,13 @@ func (c typedCommand[I]) run(invocation Invocation, input any) Outcome {
return c.spec.Run(invocation, *input.(*I)) 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 validates and creates a typed command declaration. It retains // NewCommand validates and creates a typed command declaration. It retains
// callbacks without invoking them and copies the declarative flag and argument // callbacks without invoking them and copies the declarative flag and argument
// slices. // slices.

View File

@@ -3,7 +3,8 @@
CommandKit currently contains adopted repository policies, accepted architectural CommandKit currently contains adopted repository policies, accepted architectural
decisions, implementation plans, identity and outcome primitives, typed command decisions, implementation plans, identity and outcome primitives, typed command
declarations, an instance-owned registry, and a Cobra-backed routing and parsing declarations, an instance-owned registry, and a Cobra-backed routing and parsing
adapter. Public invocation coordination is not implemented yet. The adapter, plus public `App.Execute` coordination for custom commands. Generated
help/version rendering and standard command families remain pending. The
[README](../README.md) provides project orientation; the [README](../README.md) provides project orientation; the
[documentation policy](policy/documentation.md#canonical-ownership) assigns [documentation policy](policy/documentation.md#canonical-ownership) assigns
canonical documentation owners. canonical documentation owners.

View File

@@ -1,6 +1,6 @@
# Initial CommandKit implementation plan # Initial CommandKit implementation plan
Status: implementation underway; Stages 1-4 complete and Stages 5-14 pending. Status: implementation underway; Stages 1-5 complete and Stages 6-14 pending.
Date: 2026-10-07. Date: 2026-10-07.
@@ -346,7 +346,7 @@ only the internal adapter and not Cobra or pflag.
## Stage 5: implement invocation and diagnostic delivery ## Stage 5: implement invocation and diagnostic delivery
**Status:** pending. **Prerequisite:** Stage 4. **Status:** complete. **Prerequisite:** Stage 4.
Add `func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outcome`. Add `func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outcome`.
It consumes argv without the executable name. Use the supplied nonnil context; It consumes argv without the executable name. Use the supplied nonnil context;
@@ -393,6 +393,12 @@ warning-only success; diagnostic failure after operation failure retains both;
report failure marks incomplete delivery; no duplicate root diagnostics; no process report failure marks incomplete delivery; no duplicate root diagnostics; no process
exit. Include a consecutive run that omits previously supplied flags/operands. exit. Include a consecutive run that omits previously supplied flags/operands.
**Completion evidence:** external-package tests exercise real parsing and dispatch,
typed state freshness, usage and input-check causes, domain outcomes, warning-only
success, completed diagnostic suppression, checked short writes, and combined
operation/delivery failures. `go test ./...` passes. Report-writing implementations
will set `ReportIncomplete` when introduced with the standard reports.
## Stage 6: complete help and version ## Stage 6: complete help and version
**Status:** pending. **Prerequisite:** Stage 5. **Status:** pending. **Prerequisite:** Stage 5.

180
execute.go Normal file
View File

@@ -0,0 +1,180 @@
package commandkit
import (
"context"
"errors"
"fmt"
"io"
"strings"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// Execute parses and dispatches one invocation. args excludes the executable
// name. CommandKit writes diagnostics to streams.Err and leaves process exit to
// the caller. A nil context returns an operational failure, and nil streams are
// replaced with io.Discard.
func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outcome {
if ctx == nil {
return a.present(Failure(errors.New("invocation context must not be nil")), nil, streams)
}
streams = normalizeStreams(streams)
parsed := a.parse(args, internalcobra.ParseOptions{
Context: ctx,
Out: streams.Out,
Err: streams.Err,
})
if parsed.Failure != nil {
return a.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path, streams)
}
command, ok := a.commandAt(parsed.Path)
if !ok || command.payload == nil {
// Help rendering is completed in Stage 6. The dispatch boundary already
// resolves metadata requests without constructing handler input.
return Success()
}
if parsed.HelpRequested || !command.payload.hasRun() {
if _, err := command.payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil {
return a.present(Usage(err), parsed.Path, streams)
}
return Success()
}
buildInput, err := command.payload.prepare(parsed.FlagValues, parsed.Operands, true)
if err != nil {
return a.present(Usage(err), parsed.Path, streams)
}
input := buildInput()
if err := command.payload.checkInput(input); err != nil {
return a.present(Usage(err), parsed.Path, streams)
}
outcome := command.payload.run(Invocation{Context: ctx, Streams: streams}, input)
return a.present(outcome, parsed.Path, streams)
}
func normalizeStreams(streams Streams) Streams {
if streams.Out == nil {
streams.Out = io.Discard
}
if streams.Err == nil {
streams.Err = io.Discard
}
return streams
}
func (a *App) commandAt(path []string) (Command, bool) {
if a == nil {
return Command{}, false
}
if len(path) == 0 {
return Command{}, true
}
command, ok := a.commands[path[0]]
if !ok || command.reserved {
return Command{}, false
}
current := command.command
for _, name := range path[1:] {
found := false
for _, child := range current.children {
if child.name == name {
current = child
found = true
break
}
}
if !found {
return Command{}, false
}
}
return current, true
}
func (a *App) present(outcome Outcome, path []string, streams Streams) Outcome {
streams = normalizeStreams(streams)
if outcome.DiagnosticDelivery == DeliveryComplete {
return outcome
}
diagnostics := append([]Diagnostic(nil), outcome.Diagnostics...)
if outcome.Cause != nil && !hasErrorDiagnostic(diagnostics) {
diagnostics = append(diagnostics, MessageDiagnostic(configkit.SeverityError, outcome.Cause.Error()))
}
if outcome.Kind == OutcomeUsage {
diagnostics = append(diagnostics, MessageDiagnostic(configkit.SeverityError, a.usageLine(path)))
}
outcome.Diagnostics = diagnostics
if len(diagnostics) == 0 {
return outcome
}
for _, diagnostic := range diagnostics {
if diagnostic.Write == nil {
outcome = deliveryFailure(outcome, errors.New("diagnostic has no writer"))
return outcome
}
writer := &checkedWriter{writer: streams.Err}
writeErr := diagnostic.Write(writer)
if writer.failure != nil {
if writeErr == nil {
writeErr = writer.failure
} else if !errors.Is(writeErr, writer.failure) {
writeErr = errors.Join(writeErr, writer.failure)
}
}
if writeErr != nil {
return deliveryFailure(outcome, writeErr)
}
}
outcome.DiagnosticDelivery = DeliveryComplete
return outcome
}
func (a *App) usageLine(path []string) string {
name := "command"
if a != nil && a.identity.Name != "" {
name = a.identity.Name
}
parts := append([]string{name}, path...)
if len(path) == 0 {
return fmt.Sprintf("Usage: %s [command]", name)
}
return fmt.Sprintf("Usage: %s [flags]", strings.Join(parts, " "))
}
func hasErrorDiagnostic(diagnostics []Diagnostic) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Severity == configkit.SeverityError {
return true
}
}
return false
}
func deliveryFailure(outcome Outcome, err error) Outcome {
if outcome.DeliveryError == nil {
outcome.DeliveryError = err
} else {
outcome.DeliveryError = errors.Join(outcome.DeliveryError, err)
}
outcome.DiagnosticDelivery = DeliveryIncomplete
if outcome.Kind != OutcomeDomain {
outcome.Kind = OutcomeOperational
outcome.Status = 1
}
return outcome
}
type checkedWriter struct {
writer io.Writer
failure error
}
func (w *checkedWriter) Write(data []byte) (int, error) {
n, err := w.writer.Write(data)
if err == nil && n < len(data) {
err = io.ErrShortWrite
}
if err != nil && w.failure == nil {
w.failure = err
}
return n, err
}

264
execute_external_test.go Normal file
View File

@@ -0,0 +1,264 @@
package commandkit_test
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
type input struct {
name commandkit.Supplied[string]
ids commandkit.Supplied[[]string]
}
var inputs []input
var contexts []context.Context
var streams []commandkit.Streams
ctx := context.WithValue(context.Background(), struct{}{}, "invocation")
var out, diagnostic bytes.Buffer
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
Name: "run",
Flags: []commandkit.Flag[input]{
commandkit.ValueFlag(commandkit.FlagMeta{Name: "name"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[string]) { target.name = value }),
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "id"}, nil, func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[[]string]) { target.ids = value }),
},
Run: func(invocation commandkit.Invocation, value input) commandkit.Outcome {
inputs = append(inputs, value)
contexts = append(contexts, invocation.Context)
streams = append(streams, invocation.Streams)
return commandkit.Success()
},
})
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
first := app.Execute(ctx, []string{"run", "--name=first", "--id=a,b", "--id=c"}, commandkit.Streams{Out: &out, Err: &diagnostic})
second := app.Execute(ctx, []string{"run"}, commandkit.Streams{Out: &out, Err: &diagnostic})
third := app.Execute(ctx, []string{"run"}, commandkit.Streams{})
if first.Kind != commandkit.OutcomeSuccess || second.Kind != commandkit.OutcomeSuccess || third.Kind != commandkit.OutcomeSuccess {
t.Fatalf("outcomes = %#v, %#v, and %#v", first, second, third)
}
if len(inputs) != 3 || inputs[0].name.Value != "first" || !inputs[0].name.Set || len(inputs[0].ids.Value) != 2 || inputs[0].ids.Value[0] != "a,b" || inputs[0].ids.Value[1] != "c" {
t.Fatalf("first input = %#v", inputs)
}
if inputs[1].name.Set || inputs[1].name.Value != "" || inputs[1].ids.Set {
t.Fatalf("second invocation inherited state: %#v", inputs[1])
}
if streams[2].Out != io.Discard || streams[2].Err != io.Discard {
t.Fatalf("nil streams were not replaced with io.Discard: %#v", streams[2])
}
if contexts[0] != ctx || streams[0].Out != &out || streams[0].Err != &diagnostic {
t.Fatalf("handler invocation context or streams were not forwarded")
}
}
func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
type input struct{ count commandkit.Supplied[int] }
parseFailure := errors.New("bad count")
checkFailure := errors.New("incompatible inputs")
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
Name: "run",
Flags: []commandkit.Flag[input]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "count", Required: true}, nil, func(value string) (int, error) {
if value == "bad" {
return 0, parseFailure
}
return 0, nil
}, func(target *input, value commandkit.Supplied[int]) { target.count = value })},
Arguments: []commandkit.Argument[input]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "id"}, true, func(value string) (string, error) { return value, nil }, func(*input, commandkit.Supplied[string]) {})},
CheckInput: func(input) error { return checkFailure },
Run: func(commandkit.Invocation, input) commandkit.Outcome { return commandkit.Success() },
})
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
var diagnostic bytes.Buffer
missing := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
if missing.Kind != commandkit.OutcomeUsage || missing.Status != 2 || !strings.Contains(diagnostic.String(), "Usage: example run") {
t.Fatalf("missing required input = %#v, diagnostics %q", missing, diagnostic.String())
}
malformed := app.Execute(context.Background(), []string{"run", "--count=bad", "id"}, commandkit.Streams{Err: &diagnostic})
if malformed.Kind != commandkit.OutcomeUsage || !errors.Is(malformed.Cause, parseFailure) {
t.Fatalf("parser failure = %#v", malformed)
}
checked := app.Execute(context.Background(), []string{"run", "--count=1", "id"}, commandkit.Streams{Err: &diagnostic})
if checked.Kind != commandkit.OutcomeUsage || !errors.Is(checked.Cause, checkFailure) {
t.Fatalf("input check failure = %#v", checked)
}
}
func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
domainCause := errors.New("domain rejected the request")
warning := commandkit.MessageDiagnostic(configkit.SeverityWarning, "review needed")
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "run",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
outcome := commandkit.Domain("sample", 2, domainCause)
outcome.Diagnostics = []commandkit.Diagnostic{warning}
return outcome
},
})
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
var diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeDomain || outcome.Domain != "sample" || outcome.Status != 2 || !errors.Is(outcome.Cause, domainCause) {
t.Fatalf("domain outcome changed: %#v", outcome)
}
if diagnostic.String() != "review needed\ndomain rejected the request\n" || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
t.Fatalf("warning delivery = %q, %#v", diagnostic.String(), outcome)
}
warningOnly, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "warn",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
result := commandkit.Success()
result.Diagnostics = []commandkit.Diagnostic{warning}
return result
},
})
if err != nil {
t.Fatal(err)
}
app, err = commandkit.New(commandkit.Identity{Name: "example"}, warningOnly)
if err != nil {
t.Fatal(err)
}
diagnostic.Reset()
outcome = app.Execute(context.Background(), []string{"warn"}, commandkit.Streams{Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeSuccess || diagnostic.String() != "review needed\n" {
t.Fatalf("warning-only outcome = %#v, diagnostics %q", outcome, diagnostic.String())
}
}
func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T) {
cause := errors.New("already shown")
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "run",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
outcome := commandkit.Domain("sample", 2, cause)
outcome.Diagnostics = []commandkit.Diagnostic{{Severity: configkit.SeverityError, Write: func(writer io.Writer) error {
_, err := io.WriteString(writer, "presented\n")
return err
}}}
return outcome
},
})
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
var diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeDomain || diagnostic.String() != "presented\n" {
t.Fatalf("presented cause was duplicated: %#v, %q", outcome, diagnostic.String())
}
diagnostic.Reset()
completed := commandkit.Domain("sample", 2, cause)
completed.DiagnosticDelivery = commandkit.DeliveryComplete
command, err = commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "done", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return completed }})
if err != nil {
t.Fatal(err)
}
app, err = commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
outcome = app.Execute(context.Background(), []string{"done"}, commandkit.Streams{Err: &diagnostic})
if diagnostic.Len() != 0 || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
t.Fatalf("completed diagnostics were repeated: %#v, %q", outcome, diagnostic.String())
}
}
func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
operationFailure := context.Canceled
writeFailure := errors.New("writer failed")
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "run",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
outcome := commandkit.Failure(operationFailure)
outcome.ReportIncomplete = true
return outcome
},
})
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: failingWriter{err: writeFailure}})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, operationFailure) || !errors.Is(outcome.DeliveryError, writeFailure) {
t.Fatalf("combined operation and delivery failure = %#v", outcome)
}
short := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: shortWriter{}})
if !errors.Is(short.DeliveryError, io.ErrShortWrite) || short.DiagnosticDelivery != commandkit.DeliveryIncomplete {
t.Fatalf("short write was not detected: %#v", short)
}
}
func TestExecuteHelpSkipsRequiredInputsAndRejectsNilContext(t *testing.T) {
type input struct{ id commandkit.Supplied[string] }
factoryCalls, checkCalls, runCalls := 0, 0, 0
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
Name: "run",
NewInput: func() input {
factoryCalls++
return input{}
},
Arguments: []commandkit.Argument[input]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "id"}, true, func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[string]) { target.id = value })},
CheckInput: func(input) error { checkCalls++; return nil },
Run: func(commandkit.Invocation, input) commandkit.Outcome { runCalls++; return commandkit.Success() },
})
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
help := app.Execute(context.Background(), []string{"run", "--help"}, commandkit.Streams{})
if help.Kind != commandkit.OutcomeSuccess || factoryCalls != 0 || checkCalls != 0 || runCalls != 0 {
t.Fatalf("help request invoked execution path: %#v calls=%d/%d/%d", help, factoryCalls, checkCalls, runCalls)
}
invalid := app.Execute(nil, []string{"run"}, commandkit.Streams{})
if invalid.Kind != commandkit.OutcomeOperational || invalid.Status != 1 || invalid.Cause == nil {
t.Fatalf("nil context outcome = %#v", invalid)
}
}
type failingWriter struct{ err error }
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
type shortWriter struct{}
func (shortWriter) Write(data []byte) (int, error) {
if len(data) == 0 {
return 0, nil
}
return len(data) - 1, nil
}