Compare commits
144 Commits
integratio
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| e256eb1cc0 | |||
| 881dae27c4 | |||
| c8d659dfe5 | |||
| d7cbaed3ed | |||
| 289a171e97 | |||
| 2cde616ce5 | |||
| 42d1774019 | |||
| bdc3fd8fa3 | |||
| eb974b6d29 | |||
| 0dc4ac41f0 | |||
| 129f95be0f | |||
| 87eb84ccb4 | |||
| 0fc00039bd | |||
| 4d92ee4ee6 | |||
| 7eac3fea65 | |||
| 12a6fa66fa | |||
| 98e7e99333 | |||
| 124a9fa57f | |||
| 75c0d5bce2 | |||
| 007eae4ed0 | |||
| 48fd2dc7bc | |||
| b94ed6665b | |||
| 7344994198 | |||
| cb40f1d9f8 | |||
| 3d396b1a66 | |||
| 09bb51d1c3 | |||
| a74fcb2004 | |||
| 3934038b98 | |||
| 0acbbf45ad | |||
| f4ed4c4839 | |||
| 5227049de6 | |||
| 8964ad02ad | |||
| f79f446726 | |||
| b83c0a0dfc | |||
| f39f27b5cd | |||
| 8b48115328 | |||
| c205903900 | |||
| c87bba3d2e | |||
| d3d40acea0 | |||
| 0450ecfc16 | |||
| 96250d1887 | |||
| d1f31d2931 | |||
| 4c47a0ca0b | |||
| 8bb5383169 | |||
| fc2f6d5346 | |||
| 0bed76a6f7 | |||
| 1d193c87fe | |||
| 10a799a2b7 | |||
| 230187296f | |||
| 473fb027a1 | |||
| 135d8da2b9 | |||
| de2266af75 | |||
| 13a4d4eacd | |||
| 215fc34d51 | |||
| 3338b902a2 | |||
| 41c6ca8070 | |||
| ee76b0f5f1 | |||
| 1e532cdfa5 | |||
| 7c035ccc54 | |||
| 96aa6b5ca0 | |||
| a6c0fb5b0d | |||
| 6264a87183 | |||
| b6f22462d3 | |||
| 401232a1b0 | |||
| 4ace616bc2 | |||
| 7c010e945a | |||
| 7fc89768ef | |||
| 37dfea5b25 | |||
| c0e0010683 | |||
| 5f37aac70f | |||
| 9a4d1db955 | |||
| f3e9326544 | |||
| 7a1067d387 | |||
| 49a10690c6 | |||
| df21b75f9c | |||
| f3bd6dc2ae | |||
| 6ba9338c0a | |||
| 5df88a6d94 | |||
| f5a5bbac58 | |||
| e56537690b | |||
| 2a1096bd1f | |||
| 1d29cdd029 | |||
| e48187d822 | |||
| e951a48902 | |||
| 976ed89403 | |||
| a5a92c7f08 | |||
| 514c4d47bb | |||
| ec29d658b7 | |||
| 085c296d89 | |||
| 0869b17b43 | |||
| 810333e376 | |||
| 2e337155ce | |||
| fa0aeace36 | |||
| 09d4d7992a | |||
| c70e29d3fd | |||
| e47ae799d7 | |||
| 5c26d80228 | |||
| bafd365115 | |||
| 4ddf960836 | |||
| 08ad7427e2 | |||
| f42aefe02d | |||
| 520d7e125b | |||
| eb92f7be7f | |||
| 744b8135f2 | |||
| c231a41665 | |||
| fa09edf8cc | |||
| 7dad20680e | |||
| ccf8acd861 | |||
| c225a66c97 | |||
| a54a9354b1 | |||
| 42d9989533 | |||
| 333a630cf1 | |||
| 666c006a89 | |||
| c3bb3941d3 | |||
| f34ab12ca5 | |||
| 1fa026e5bd | |||
| a504efbad7 | |||
| 451a0c483f | |||
| 75fc45f072 | |||
| 58b71c390f | |||
| 6ea8c4dd3f | |||
| 2ab87e9333 | |||
| 7a4efddab8 | |||
| 854426a0c9 | |||
| 80d4d6e1bd | |||
| b3fb795224 | |||
| d7cc12a347 | |||
| f3b8a129d9 | |||
| fae8c4dafa | |||
| ca85d5b012 | |||
| 93ee328779 | |||
| c6c4b01902 | |||
| 55330d2817 | |||
| fbef0f2ecc | |||
| 573a22a38e | |||
| f526198059 | |||
| 4b53c96cf3 | |||
| 50c54c229e | |||
| 0dfeac3022 | |||
| 8d5966a868 | |||
| a4cf55f7a5 | |||
| 1f3d1594c9 | |||
| ae6af1dc65 | |||
| df6ed89a97 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -31,7 +31,7 @@ go.work.sum
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# Local ReleaseKit bootstrap executable
|
||||
# Local ReleaseKit credentials
|
||||
/.releasekit/
|
||||
|
||||
# KDE directory preferences
|
||||
|
||||
19
README.md
19
README.md
@@ -8,24 +8,29 @@ modules, so applications choose which upstream libraries they add.
|
||||
The core captures reusable command declarations, validates installation batches
|
||||
before changing an application's registry, converts typed inputs before
|
||||
constructing them, and returns outcomes without exiting the process. Its
|
||||
[consumer guide](docs/consumers/guide.md) covers declaration and invocation.
|
||||
[consumer guide](docs/consumers/guide.md) covers declaration, invocation
|
||||
reporting, and migration. Each handler receives one invocation reporter for
|
||||
substantive output, categorized messages, and terminal explanations; `Execute`
|
||||
returns delivery observations with the outcome. The [CLI reference](docs/cli.md)
|
||||
defines the shared visibility policy.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Package | Provides |
|
||||
| --- | --- | --- |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit` | `commandkit` | Typed commands, bindings, execution, outcomes, help, and version. |
|
||||
| `go.maximumdirect.net/commandkit` | `commandkit` | Typed commands, bindings, execution reporting and outcomes, help, and version. |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig` | `ckconfig` | ConfigKit-backed `config` scopes, including configuration source evidence. |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt` | `ckprompt` | PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, concrete request validation, and native smoke tests. |
|
||||
|
||||
Importing an integration does not install commands. Pass its declaration to
|
||||
`commandkit.New` or `App.Register` to install it. The integrations depend on the
|
||||
core and their selected upstream libraries; the core does not depend on either
|
||||
integration.
|
||||
Importing an integration does not install commands. The integrations currently
|
||||
use the old core module identity and cannot be composed with the new core path.
|
||||
The planned matched releases are `go.maximumdirect.net/ckconfig v0.2.0` and
|
||||
`go.maximumdirect.net/ckprompt v0.2.0`, using core v0.3.0. See the
|
||||
[consumer guide](docs/consumers/guide.md#module-selection) for migration details.
|
||||
|
||||
## Examples
|
||||
|
||||
- [Core-only example](examples/core/main.go)
|
||||
- [Core-only example](examples/core/README.md)
|
||||
- [ConfigKit example](integrations/ckconfig/examples/simple/README.md)
|
||||
- [Composed ConfigKit and PromptKit example](integrations/ckprompt/examples/composed/README.md)
|
||||
|
||||
|
||||
45
app.go
45
app.go
@@ -3,38 +3,59 @@ package commandkit
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
||||
"go.maximumdirect.net/commandkit/internal/alias"
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
)
|
||||
|
||||
// App owns one command registry and its application identity. Each App has an
|
||||
// independent registry; command declarations may be reused across Apps.
|
||||
type App struct {
|
||||
identity Identity
|
||||
commands map[string]*commandNode
|
||||
snapshot *routingSnapshot
|
||||
identity Identity
|
||||
commands map[string]*commandNode
|
||||
routingRules []RoutingRule
|
||||
aliases *alias.Resolver
|
||||
flagAliases map[string]resolvedFlagAliases
|
||||
discoveryFlags map[string]resolvedFlagAliases
|
||||
snapshot *routingSnapshot
|
||||
}
|
||||
|
||||
type routingSnapshot struct {
|
||||
metadata *commandmeta.Command
|
||||
runners map[commandmeta.Key]commandPayload
|
||||
metadata *commandmeta.Command
|
||||
runners map[commandmeta.Key]commandPayload
|
||||
aliases *alias.Resolver
|
||||
canonical map[string]*canonicalNode
|
||||
commandAliases []commandmeta.CommandAlias
|
||||
flagAliases map[string]resolvedFlagAliases
|
||||
discoveryFlags map[string]resolvedFlagAliases
|
||||
completion bool
|
||||
}
|
||||
|
||||
type commandNode struct {
|
||||
command Command
|
||||
}
|
||||
|
||||
// New creates an application with the supplied identity and initial commands.
|
||||
// New creates an application with the supplied identity and initial canonical
|
||||
// commands and routing rules. Use []Declaration to expand a mixed declaration
|
||||
// collection; []Command cannot be expanded as ...Declaration without
|
||||
// converting its elements.
|
||||
//
|
||||
// The identity name must not be empty. New installs its generated help and
|
||||
// version commands and caller declarations as one batch; an invalid declaration
|
||||
// returns an error without exposing an App. The root help and version names are
|
||||
// reserved for generated commands.
|
||||
func New(identity Identity, commands ...Command) (*App, error) {
|
||||
// version commands and caller declarations as one batch; an invalid, nil, or
|
||||
// zero declaration returns an error without exposing an App. Factory issues
|
||||
// retained in Command and RoutingRule values are reported during installation.
|
||||
// Identical duplicate routing mappings are accepted; conflicting mappings
|
||||
// fail the batch. The root help and version names are reserved for generated
|
||||
// commands.
|
||||
func New(identity Identity, declarations ...Declaration) (*App, error) {
|
||||
if identity.Name == "" {
|
||||
return nil, fmt.Errorf("application identity name must not be empty")
|
||||
}
|
||||
|
||||
app := &App{identity: identity, commands: make(map[string]*commandNode)}
|
||||
all := append(app.builtinCommands(), commands...)
|
||||
builtins := app.builtinCommands()
|
||||
all := make([]Declaration, 0, len(builtins)+len(declarations))
|
||||
all = append(all, builtins...)
|
||||
all = append(all, declarations...)
|
||||
if err := app.installBatch(all...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
118
binding.go
118
binding.go
@@ -1,8 +1,11 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
)
|
||||
|
||||
// ValueFlag declares a singleton flag with a typed parser, optional default,
|
||||
@@ -22,10 +25,10 @@ func ValueFlag[I, V any](meta FlagMeta, defaultValue *V, parse func(string) (V,
|
||||
binding.prepare = func(values []string) (preparedValue[I], error) {
|
||||
value := fallback
|
||||
supplied := len(values) > 0
|
||||
for _, raw := range values {
|
||||
for index, raw := range values {
|
||||
parsed, err := parse(raw)
|
||||
if err != nil {
|
||||
return preparedValue[I]{}, err
|
||||
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
|
||||
}
|
||||
value = parsed
|
||||
}
|
||||
@@ -47,10 +50,10 @@ func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[boo
|
||||
binding.prepare = func(values []string) (preparedValue[I], error) {
|
||||
value := defaultValue
|
||||
supplied := len(values) > 0
|
||||
for _, raw := range values {
|
||||
for index, raw := range values {
|
||||
parsed, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return preparedValue[I]{}, err
|
||||
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
|
||||
}
|
||||
value = parsed
|
||||
}
|
||||
@@ -76,10 +79,10 @@ func RepeatFlag[I, V any](meta FlagMeta, defaults []V, parse func(string) (V, er
|
||||
supplied := len(values) > 0
|
||||
if supplied {
|
||||
parsedValues = make([]V, 0, len(values))
|
||||
for _, raw := range values {
|
||||
for index, raw := range values {
|
||||
parsed, err := parse(raw)
|
||||
if err != nil {
|
||||
return preparedValue[I]{}, err
|
||||
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
|
||||
}
|
||||
parsedValues = append(parsedValues, parsed)
|
||||
}
|
||||
@@ -158,6 +161,28 @@ type preparedInput[I any] struct {
|
||||
values []preparedValue[I]
|
||||
}
|
||||
|
||||
type flagOccurrenceError struct {
|
||||
index int
|
||||
cause error
|
||||
}
|
||||
|
||||
func (err *flagOccurrenceError) Error() string { return err.cause.Error() }
|
||||
|
||||
func (err *flagOccurrenceError) Unwrap() error { return err.cause }
|
||||
|
||||
type flagConversionError struct {
|
||||
binding string
|
||||
spelling string
|
||||
occurrence int
|
||||
cause error
|
||||
}
|
||||
|
||||
func (err *flagConversionError) Error() string {
|
||||
return fmt.Sprintf("flag %s occurrence %d: %v", err.spelling, err.occurrence, err.cause)
|
||||
}
|
||||
|
||||
func (err *flagConversionError) Unwrap() error { return err.cause }
|
||||
|
||||
func (p preparedInput[I]) build() I {
|
||||
var input I
|
||||
if p.newInput != nil {
|
||||
@@ -173,15 +198,36 @@ func (p preparedInput[I]) build() I {
|
||||
// creates an input value. When enforceRequired is false, missing required flags
|
||||
// and operands are allowed so metadata help can validate supplied syntax.
|
||||
func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) {
|
||||
return prepareInputWithSpellings(spec, flagValues, nil, operands, enforceRequired)
|
||||
}
|
||||
|
||||
func prepareInputWithSpellings[I any](spec CommandSpec[I], flagValues, flagSpellings [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) {
|
||||
if len(flagValues) != len(spec.Flags) {
|
||||
return preparedInput[I]{}, fmt.Errorf("received %d flag value sets for %d bindings", len(flagValues), len(spec.Flags))
|
||||
}
|
||||
if flagSpellings == nil {
|
||||
flagSpellings = make([][]string, len(spec.Flags))
|
||||
}
|
||||
if len(flagSpellings) != len(spec.Flags) {
|
||||
return preparedInput[I]{}, fmt.Errorf("received %d flag spelling sets for %d bindings", len(flagSpellings), len(spec.Flags))
|
||||
}
|
||||
preparedFlags := make([]preparedValue[I], len(spec.Flags))
|
||||
missingRequired := ""
|
||||
for i, flag := range spec.Flags {
|
||||
binding := flag.binding
|
||||
value, err := binding.prepare(flagValues[i])
|
||||
if err != nil {
|
||||
var occurrenceErr *flagOccurrenceError
|
||||
if errors.As(err, &occurrenceErr) {
|
||||
spelling := "--" + binding.meta.Name
|
||||
if occurrenceErr.index >= 0 && occurrenceErr.index < len(flagSpellings[i]) && flagSpellings[i][occurrenceErr.index] != "" {
|
||||
spelling = flagSpellings[i][occurrenceErr.index]
|
||||
}
|
||||
return preparedInput[I]{}, &flagConversionError{
|
||||
binding: binding.meta.Name, spelling: spelling,
|
||||
occurrence: occurrenceErr.index + 1, cause: occurrenceErr.cause,
|
||||
}
|
||||
}
|
||||
return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err)
|
||||
}
|
||||
preparedFlags[i] = value
|
||||
@@ -211,60 +257,76 @@ func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []
|
||||
func newBindingIdentity() *bindingIdentity { return &bindingIdentity{marker: 1} }
|
||||
|
||||
func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) {
|
||||
arguments := make([]commandmeta.Argument, len(definitions))
|
||||
for i, definition := range definitions {
|
||||
arguments[i] = commandmeta.Argument{Name: definition.binding.meta.Name, Min: definition.binding.min, Max: definition.binding.max}
|
||||
}
|
||||
counts, err := allocateOperandCounts(len(operands), arguments, enforceRequired)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allocated := make([][]string, len(definitions))
|
||||
position := 0
|
||||
for i, count := range counts {
|
||||
allocated[i] = operands[position : position+count]
|
||||
position += count
|
||||
}
|
||||
return allocated, nil
|
||||
}
|
||||
|
||||
func allocateOperandCounts(total int, definitions []commandmeta.Argument, enforceRequired bool) ([]int, error) {
|
||||
for _, argument := range definitions {
|
||||
binding := argument.binding
|
||||
if binding.min < 0 || binding.max < -1 || (binding.max >= 0 && binding.min > binding.max) {
|
||||
return nil, fmt.Errorf("argument %q has invalid bounds %d..%d", binding.meta.Name, binding.min, binding.max)
|
||||
if argument.Min < 0 || argument.Max < -1 || (argument.Max >= 0 && argument.Min > argument.Max) {
|
||||
return nil, fmt.Errorf("argument %q has invalid bounds %d..%d", argument.Name, argument.Min, argument.Max)
|
||||
}
|
||||
}
|
||||
if enforceRequired {
|
||||
minimum := 0
|
||||
for _, argument := range definitions {
|
||||
if argument.binding.min > len(operands)-minimum {
|
||||
if argument.Min > total-minimum {
|
||||
return nil, fmt.Errorf("not enough positional arguments")
|
||||
}
|
||||
minimum += argument.binding.min
|
||||
minimum += argument.Min
|
||||
}
|
||||
}
|
||||
|
||||
suffixMinimum := make([]int, len(definitions)+1)
|
||||
for i := len(definitions) - 1; i >= 0; i-- {
|
||||
minimum := definitions[i].binding.min
|
||||
remaining := len(operands) - suffixMinimum[i+1]
|
||||
minimum := definitions[i].Min
|
||||
remaining := total - suffixMinimum[i+1]
|
||||
if minimum > remaining {
|
||||
suffixMinimum[i] = len(operands)
|
||||
suffixMinimum[i] = total
|
||||
} else {
|
||||
suffixMinimum[i] = suffixMinimum[i+1] + minimum
|
||||
}
|
||||
}
|
||||
|
||||
allocated := make([][]string, len(definitions))
|
||||
counts := make([]int, len(definitions))
|
||||
position := 0
|
||||
for i, argument := range definitions {
|
||||
binding := argument.binding
|
||||
remaining := len(operands) - position
|
||||
remaining := total - position
|
||||
available := remaining - suffixMinimum[i+1]
|
||||
if available < 0 {
|
||||
available = 0
|
||||
}
|
||||
count := available
|
||||
if binding.max >= 0 && count > binding.max {
|
||||
count = binding.max
|
||||
if argument.Max >= 0 && count > argument.Max {
|
||||
count = argument.Max
|
||||
}
|
||||
if !enforceRequired && count < binding.min && remaining > 0 {
|
||||
if !enforceRequired && count < argument.Min && remaining > 0 {
|
||||
count = remaining
|
||||
if binding.max >= 0 && count > binding.max {
|
||||
count = binding.max
|
||||
if argument.Max >= 0 && count > argument.Max {
|
||||
count = argument.Max
|
||||
}
|
||||
}
|
||||
if enforceRequired && count < binding.min {
|
||||
return nil, fmt.Errorf("not enough values for argument %q", binding.meta.Name)
|
||||
if enforceRequired && count < argument.Min {
|
||||
return nil, fmt.Errorf("not enough values for argument %q", argument.Name)
|
||||
}
|
||||
allocated[i] = operands[position : position+count]
|
||||
counts[i] = count
|
||||
position += count
|
||||
}
|
||||
if position < len(operands) {
|
||||
return nil, fmt.Errorf("received %d extra positional arguments", len(operands)-position)
|
||||
if position < total {
|
||||
return nil, fmt.Errorf("received %d extra positional arguments", total-position)
|
||||
}
|
||||
return allocated, nil
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
@@ -245,3 +247,90 @@ func TestIncompletePreparationSkipsMissingRequirementsAndInputFactory(t *testing
|
||||
t.Fatalf("preparation invoked NewInput %d times", factoryCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteBindsMixedFlagSpellingsWithFreshPresence(t *testing.T) {
|
||||
type input struct {
|
||||
count Supplied[int]
|
||||
enabled Supplied[bool]
|
||||
message Supplied[string]
|
||||
tags Supplied[[]string]
|
||||
required Supplied[string]
|
||||
instance int
|
||||
}
|
||||
type observed struct {
|
||||
count Supplied[int]
|
||||
enabled Supplied[bool]
|
||||
message Supplied[string]
|
||||
tags Supplied[[]string]
|
||||
required Supplied[string]
|
||||
instance int
|
||||
}
|
||||
defaultCount := 9
|
||||
defaultMessage := "fallback"
|
||||
defaultTags := []string{"base"}
|
||||
countParses, inputFactories := 0, 0
|
||||
var calls []observed
|
||||
command := NewCommand(CommandSpec[input]{
|
||||
Name: "run",
|
||||
NewInput: func() input {
|
||||
inputFactories++
|
||||
return input{instance: inputFactories}
|
||||
},
|
||||
Flags: []Flag[input]{
|
||||
ValueFlag(FlagMeta{Name: "count"}, &defaultCount, func(value string) (int, error) {
|
||||
countParses++
|
||||
return strconv.Atoi(value)
|
||||
}, func(target *input, value Supplied[int]) { target.count = value }),
|
||||
BoolFlag(FlagMeta{Name: "enabled"}, true, func(target *input, value Supplied[bool]) { target.enabled = value }),
|
||||
ValueFlag(FlagMeta{Name: "message"}, &defaultMessage, func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[string]) { target.message = value }),
|
||||
RepeatFlag(FlagMeta{Name: "tag"}, defaultTags, func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[[]string]) { target.tags = value }),
|
||||
ValueFlag(FlagMeta{Name: "required", Required: true}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[string]) { target.required = value }),
|
||||
},
|
||||
Run: func(_ Invocation, value input) Outcome {
|
||||
value.tags.Value = append([]string(nil), value.tags.Value...)
|
||||
calls = append(calls, observed(value))
|
||||
return Success()
|
||||
},
|
||||
})
|
||||
app, err := New(Identity{Name: "Example"}, command,
|
||||
Alias("shortcut", "run"),
|
||||
FlagAliasFor("run", "--number", "--count"),
|
||||
FlagAliasFor("run", "--active", "--enabled"),
|
||||
FlagAliasFor("run", "--text", "--message"),
|
||||
FlagAliasFor("run", "--label", "--tag"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"shortcut", "--number=0", "--count=5", "--active=false", "--enabled=true", "--text=", "--message=last", "--label=a", "--tag=b", "--required=first"},
|
||||
{"run", "--number=0", "--active=false", "--text=", "--label=c", "--required=second"},
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
if countParses != 3 {
|
||||
t.Fatalf("count conversions = %d, want every one of three occurrences", countParses)
|
||||
}
|
||||
if len(calls) != 2 || calls[0].count != (Supplied[int]{Value: 5, Set: true}) || calls[0].enabled != (Supplied[bool]{Value: true, Set: true}) || calls[0].message != (Supplied[string]{Value: "last", Set: true}) || !reflect.DeepEqual(calls[0].tags, Supplied[[]string]{Value: []string{"a", "b"}, Set: true}) || calls[0].required != (Supplied[string]{Value: "first", Set: true}) {
|
||||
t.Fatalf("first mixed-spelling invocation = %#v", calls)
|
||||
}
|
||||
if calls[1].count != (Supplied[int]{Value: 0, Set: true}) || calls[1].enabled != (Supplied[bool]{Value: false, Set: true}) || calls[1].message != (Supplied[string]{Value: "", Set: true}) || !reflect.DeepEqual(calls[1].tags, Supplied[[]string]{Value: []string{"c"}, Set: true}) || calls[1].required != (Supplied[string]{Value: "second", Set: true}) || calls[0].instance == calls[1].instance {
|
||||
t.Fatalf("second mixed-spelling invocation = %#v", calls[1])
|
||||
}
|
||||
|
||||
if outcome := app.Execute(context.Background(), []string{"shortcut", "--count=7"}, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeUsage {
|
||||
t.Fatalf("missing required flag outcome = %#v, want usage", outcome)
|
||||
}
|
||||
if inputFactories != 2 || len(calls) != 2 {
|
||||
t.Fatalf("missing required flag created or ran input: factories=%d calls=%d", inputFactories, len(calls))
|
||||
}
|
||||
if outcome := app.Execute(context.Background(), []string{"shortcut", "--required=defaults"}, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeSuccess {
|
||||
t.Fatalf("defaulted invocation = %#v, want success", outcome)
|
||||
}
|
||||
if len(calls) != 3 || calls[2].count != (Supplied[int]{Value: 9}) || calls[2].enabled != (Supplied[bool]{Value: true}) || calls[2].message != (Supplied[string]{Value: "fallback"}) || !reflect.DeepEqual(calls[2].tags, Supplied[[]string]{Value: []string{"base"}}) || calls[2].required != (Supplied[string]{Value: "defaults", Set: true}) || calls[2].instance == calls[1].instance {
|
||||
t.Fatalf("omitted values reused prior invocation state: %#v", calls[2])
|
||||
}
|
||||
}
|
||||
|
||||
96
builtins.go
96
builtins.go
@@ -1,18 +1,16 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime/debug"
|
||||
|
||||
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
||||
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
const commandKitModulePath = "gitea.maximumdirect.net/eric/CommandKit"
|
||||
const commandKitModulePath = "go.maximumdirect.net/commandkit"
|
||||
|
||||
type helpInput struct {
|
||||
Targets Supplied[[]string]
|
||||
@@ -29,7 +27,7 @@ type versionDocument struct {
|
||||
Revision string `yaml:"revision"`
|
||||
}
|
||||
|
||||
func (a *App) builtinCommands() []Command {
|
||||
func (a *App) builtinCommands() []Declaration {
|
||||
help := captureCommand(CommandSpec[helpInput]{
|
||||
Name: "help",
|
||||
Summary: "Show help for commands.",
|
||||
@@ -47,8 +45,8 @@ func (a *App) builtinCommands() []Command {
|
||||
Summary: "Show application or CommandKit version.",
|
||||
Description: "Report identity and build metadata without loading application configuration.",
|
||||
Flags: []Flag[versionInput]{
|
||||
ValueFlag(FlagMeta{Name: "scope", Usage: "version subject"}, &scopeDefault, parseVersionScope, func(input *versionInput, value Supplied[string]) { input.Scope = value.Value }),
|
||||
ValueFlag(FlagMeta{Name: "format", Usage: "output format"}, &formatDefault, parseVersionFormat, func(input *versionInput, value Supplied[string]) { input.Format = value.Value }),
|
||||
ValueFlag(FlagMeta{Name: "scope", Usage: "version subject", Completion: CompleteChoices(CompletionChoice{Value: "app"}, CompletionChoice{Value: "commandkit"})}, &scopeDefault, parseVersionScope, func(input *versionInput, value Supplied[string]) { input.Scope = value.Value }),
|
||||
ValueFlag(FlagMeta{Name: "format", Usage: "output format", Completion: CompleteChoices(CompletionChoice{Value: "text"}, CompletionChoice{Value: "yaml"})}, &formatDefault, parseVersionFormat, func(input *versionInput, value Supplied[string]) { input.Format = value.Value }),
|
||||
},
|
||||
Run: func(invocation Invocation, input versionInput) Outcome {
|
||||
identity := a.identity
|
||||
@@ -58,10 +56,10 @@ func (a *App) builtinCommands() []Command {
|
||||
if identity.Version == "" {
|
||||
identity.Version = "dev"
|
||||
}
|
||||
return renderVersionOutcome(identity, input.Format, invocation.Streams)
|
||||
return renderVersionOutcome(identity, input.Format, invocation.Reporter())
|
||||
},
|
||||
})
|
||||
return []Command{help, version}
|
||||
return []Declaration{help, version}
|
||||
}
|
||||
|
||||
func parseVersionScope(value string) (string, error) {
|
||||
@@ -78,61 +76,41 @@ func parseVersionFormat(value string) (string, error) {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (a *App) renderHelpOutcome(path []string, streams Streams) Outcome {
|
||||
session := internalcobra.Prepare(a.routeSnapshot().metadata, commandmeta.ParseOptions{})
|
||||
return a.renderHelpOutcomeWithSession(path, streams, session)
|
||||
}
|
||||
|
||||
func (a *App) renderHelpTargets(targets []string, streams Streams) Outcome {
|
||||
session := internalcobra.Prepare(a.routeSnapshot().metadata, commandmeta.ParseOptions{})
|
||||
return a.renderHelpTargetsWithSession(targets, streams, session)
|
||||
}
|
||||
|
||||
func (a *App) renderHelpTargetsWithSession(targets []string, streams Streams, session *internalcobra.Session) Outcome {
|
||||
return a.renderHelpOutcomeWithSession(targets, streams, session)
|
||||
}
|
||||
|
||||
func (a *App) renderHelpOutcomeWithSession(path []string, streams Streams, session *internalcobra.Session) Outcome {
|
||||
content, failure := session.RenderHelp(path)
|
||||
func (a *App) renderHelpViewOutcomeWithSession(view commandmeta.HelpView, reporter *Reporter, session *internalcobra.Session) Outcome {
|
||||
content, failure := session.RenderHelpView(view)
|
||||
if failure != nil {
|
||||
if failure.Usage {
|
||||
return Usage(errors.New(failure.Message))
|
||||
return Usage(failure.Message)
|
||||
}
|
||||
return Failure(errors.New(failure.Message))
|
||||
}
|
||||
if err := writeReport(streams.Out, []byte(content)); err != nil {
|
||||
return incompleteReport(err)
|
||||
return Failure(failure.Message)
|
||||
}
|
||||
_ = reporter.RenderOutput(func(w io.Writer) error {
|
||||
_, err := io.WriteString(w, content)
|
||||
return err
|
||||
})
|
||||
return Success()
|
||||
}
|
||||
|
||||
func renderVersionOutcome(identity Identity, format string, streams Streams) Outcome {
|
||||
var content []byte
|
||||
func renderVersionOutcome(identity Identity, format string, reporter *Reporter) Outcome {
|
||||
if identity.Version == "" {
|
||||
identity.Version = "dev"
|
||||
}
|
||||
switch format {
|
||||
case "text":
|
||||
name := identity.Name
|
||||
if identity.Version == "" {
|
||||
identity.Version = "dev"
|
||||
}
|
||||
line := name + " " + identity.Version
|
||||
line := identity.Name + " " + identity.Version
|
||||
if identity.Revision != "" {
|
||||
line += " (" + identity.Revision + ")"
|
||||
}
|
||||
content = []byte(line + "\n")
|
||||
_ = reporter.RenderOutput(func(w io.Writer) error {
|
||||
_, err := io.WriteString(w, line+"\n")
|
||||
return err
|
||||
})
|
||||
case "yaml":
|
||||
if identity.Version == "" {
|
||||
identity.Version = "dev"
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
if err := encodeYAML(&buffer, versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision}); err != nil {
|
||||
return Failure(err)
|
||||
}
|
||||
content = buffer.Bytes()
|
||||
_ = reporter.RenderOutput(func(w io.Writer) error {
|
||||
return encodeYAML(w, versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision})
|
||||
})
|
||||
default:
|
||||
return Usage(fmt.Errorf("unsupported version format %q", format))
|
||||
}
|
||||
if err := writeReport(streams.Out, content); err != nil {
|
||||
return incompleteReport(err)
|
||||
return Usage(fmt.Sprintf("unsupported version format %q", format))
|
||||
}
|
||||
return Success()
|
||||
}
|
||||
@@ -143,22 +121,6 @@ func encodeYAML(writer io.Writer, document any) error {
|
||||
return encoder.Encode(document)
|
||||
}
|
||||
|
||||
func writeReport(writer io.Writer, content []byte) error {
|
||||
checked := &checkedWriter{writer: writer}
|
||||
_, err := checked.Write(content)
|
||||
if err == nil {
|
||||
err = checked.failure
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func incompleteReport(err error) Outcome {
|
||||
outcome := Failure(err)
|
||||
outcome.ReportIncomplete = true
|
||||
outcome.DeliveryError = err
|
||||
return outcome
|
||||
}
|
||||
|
||||
func readBuildInfo() *debug.BuildInfo {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
|
||||
110
builtins_reporting_test.go
Normal file
110
builtins_reporting_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func builtinReportingTestApp(t *testing.T) *App {
|
||||
t.Helper()
|
||||
command := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||
})
|
||||
app, err := New(Identity{Name: "Example", Version: "1.2.3"}, command,
|
||||
Group("promptkit", "PromptKit", Group("list", "List", simpleRunnable("collections"))),
|
||||
Alias("r", "run"), Alias("list collections", "promptkit list collections"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func TestHelpAndVersionUseManagedOutput(t *testing.T) {
|
||||
app := builtinReportingTestApp(t)
|
||||
for _, args := range [][]string{{"help", "--quiet"}, {"version", "--quiet"}, {"list", "--help", "--quiet"}} {
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostics})
|
||||
if outcome.Kind != OutcomeSuccess || outcome.Status != 0 {
|
||||
t.Errorf("Execute(%q) = %#v", args, outcome)
|
||||
}
|
||||
if output.Len() != 0 || diagnostics.Len() != 0 {
|
||||
t.Errorf("Execute(%q) wrote output %q or diagnostics %q", args, output.String(), diagnostics.String())
|
||||
}
|
||||
if outcome.Presentation.Output.State != DeliverySuppressed {
|
||||
t.Errorf("Execute(%q) output presentation = %#v", args, outcome.Presentation.Output)
|
||||
}
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{args: []string{"help", "run"}, want: "run"},
|
||||
{args: []string{"help", "r"}, want: "Alias for: Example run"},
|
||||
{args: []string{"list", "--help"}, want: "Reporting Flags"},
|
||||
{args: []string{"version"}, want: "Example 1.2.3"},
|
||||
{args: []string{"version", "--format=yaml"}, want: "version: 1.2.3"},
|
||||
} {
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), test.args, Streams{Out: &output, Err: &diagnostics})
|
||||
if outcome.Kind != OutcomeSuccess || !strings.Contains(output.String(), test.want) {
|
||||
t.Errorf("Execute(%q) = %#v, output %q", test.args, outcome, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpAndVersionDestinationFailuresUseReporterObservations(t *testing.T) {
|
||||
app := builtinReportingTestApp(t)
|
||||
writeErr := errors.New("help/version destination failed")
|
||||
for _, args := range [][]string{{"help", "run"}, {"version"}} {
|
||||
var diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), args, Streams{
|
||||
Out: executeReportingFailureWriter{err: writeErr}, Err: &diagnostics,
|
||||
})
|
||||
if outcome.Kind != OutcomeOperational || outcome.Status != 1 {
|
||||
t.Errorf("Execute(%q) = %#v", args, outcome)
|
||||
}
|
||||
if !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Presentation.Output.State != DeliveryIncomplete {
|
||||
t.Errorf("Execute(%q) presentation = %#v", args, outcome.Presentation)
|
||||
}
|
||||
if !strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output") {
|
||||
t.Errorf("Execute(%q) diagnostics = %q", args, diagnostics.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidHelpAndVersionRequestsRemainUsageOutcomes(t *testing.T) {
|
||||
app := builtinReportingTestApp(t)
|
||||
for _, args := range [][]string{{"help", "unknown"}, {"version", "--scope=unknown"}, {"version", "--format=unknown"}} {
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostics})
|
||||
if outcome.Kind != OutcomeUsage || outcome.Status != 2 {
|
||||
t.Errorf("Execute(%q) = %#v", args, outcome)
|
||||
}
|
||||
if !strings.Contains(diagnostics.String(), "Usage: Example") {
|
||||
t.Errorf("Execute(%q) diagnostics = %q", args, diagnostics.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuietBuiltinRenderingSuppressesDestinationWrites(t *testing.T) {
|
||||
app := builtinReportingTestApp(t)
|
||||
|
||||
// A quiet successful run must not touch its supplied result destination.
|
||||
var diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"version", "--quiet"}, Streams{
|
||||
Out: executeReportingFailureWriter{err: io.ErrClosedPipe}, Err: &diagnostics,
|
||||
})
|
||||
if outcome.Kind != OutcomeSuccess || outcome.Presentation.Output.State != DeliverySuppressed {
|
||||
t.Fatalf("quiet version outcome = %#v", outcome)
|
||||
}
|
||||
if diagnostics.Len() != 0 {
|
||||
t.Fatalf("quiet version diagnostics = %q", diagnostics.String())
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ func TestCommandKitBuildIdentityProjectsEffectiveModuleVersion(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "main module tag",
|
||||
info: &debug.BuildInfo{Main: debug.Module{Path: commandKitModulePath, Version: "v1.2.3"}},
|
||||
info: &debug.BuildInfo{Main: debug.Module{Path: "go.maximumdirect.net/commandkit", Version: "v1.2.3"}},
|
||||
want: "v1.2.3",
|
||||
},
|
||||
{
|
||||
|
||||
221
command.go
221
command.go
@@ -6,15 +6,66 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||
)
|
||||
|
||||
// Command is an opaque, reusable declaration captured by NewCommand, Group,
|
||||
// RootGroup, or an integration. Capture copies declaration slices but retains
|
||||
// callback and caller-owned object references. Installation validates the
|
||||
// declaration before changing an App registry.
|
||||
// Command is an opaque, reusable declaration of canonical commands and groups.
|
||||
// NewCommand, Group, RootGroup, and integration command factories return
|
||||
// Command values. Capture copies declaration slices but retains callback and
|
||||
// caller-owned object references. Installation validates the declaration
|
||||
// before changing an App registry.
|
||||
type Command struct{ record *commandRecord }
|
||||
|
||||
// Declaration is a captured canonical command or application routing rule
|
||||
// accepted by New and App.Register. Use []Declaration when collecting both
|
||||
// kinds. Group and RootGroup children remain Commands; install routing rules
|
||||
// alongside the completed command tree through New or Register. Only
|
||||
// CommandKit declaration types implement Declaration.
|
||||
//
|
||||
// A zero Command, zero RoutingRule, nil declaration, or typed nil declaration
|
||||
// pointer is invalid. Use NoCommand to represent an intentionally absent
|
||||
// optional command.
|
||||
type Declaration interface {
|
||||
commandkitDeclaration()
|
||||
}
|
||||
|
||||
func (Command) commandkitDeclaration() {}
|
||||
|
||||
// RoutingRule is an opaque, reusable command or flag alias declaration. Alias,
|
||||
// FlagAlias, and FlagAliasFor capture routing rules eagerly and retain any
|
||||
// construction issues for New or App.Register to report. Identical duplicate
|
||||
// mappings are accepted; competing targets for the same source are errors.
|
||||
type RoutingRule struct{ record *routingRuleRecord }
|
||||
|
||||
func (RoutingRule) commandkitDeclaration() {}
|
||||
|
||||
type routingRuleKind uint8
|
||||
|
||||
const (
|
||||
commandAliasRule routingRuleKind = iota + 1
|
||||
globalFlagAliasRule
|
||||
scopedFlagAliasRule
|
||||
)
|
||||
|
||||
type capturedFlagSpelling struct {
|
||||
name string
|
||||
short bool
|
||||
}
|
||||
|
||||
type routingRuleRecord struct {
|
||||
kind routingRuleKind
|
||||
sourcePath commandpath.Path
|
||||
targetPath commandpath.Path
|
||||
scopePath commandpath.Path
|
||||
sourceFlag capturedFlagSpelling
|
||||
targetFlag capturedFlagSpelling
|
||||
sourceText string
|
||||
targetText string
|
||||
scopeText string
|
||||
issues []error
|
||||
}
|
||||
|
||||
type commandRecord struct {
|
||||
name string
|
||||
summary string
|
||||
@@ -23,6 +74,7 @@ type commandRecord struct {
|
||||
payload commandPayload
|
||||
issues []error
|
||||
rootOnly bool
|
||||
completion bool
|
||||
absent bool
|
||||
invalid bool
|
||||
}
|
||||
@@ -47,6 +99,8 @@ type FlagMeta struct {
|
||||
Usage string
|
||||
// Required reports whether execution requires the flag to be supplied.
|
||||
Required bool
|
||||
// Completion describes optional value suggestions.
|
||||
Completion CompletionHint
|
||||
}
|
||||
|
||||
// ArgumentMeta describes a positional command argument. Its name and usage are
|
||||
@@ -56,6 +110,8 @@ type ArgumentMeta struct {
|
||||
Name string
|
||||
// Usage briefly describes the argument.
|
||||
Usage string
|
||||
// Completion describes optional value suggestions.
|
||||
Completion CompletionHint
|
||||
}
|
||||
|
||||
// Flag represents a typed binding for a command flag. Copies retain binding
|
||||
@@ -130,7 +186,7 @@ type commandPayload interface {
|
||||
hasRun() bool
|
||||
bridgeFlags() []commandmeta.Flag
|
||||
bridgeArguments() []commandmeta.Argument
|
||||
prepare([][]string, []string, bool) (func(*execution) Outcome, error)
|
||||
prepare([][]string, [][]string, []string, bool) (func(*execution) Outcome, error)
|
||||
}
|
||||
|
||||
type typedCommand[I any] struct {
|
||||
@@ -151,13 +207,14 @@ func (c typedCommand[I]) bridgeFlags() []commandmeta.Flag {
|
||||
for index, flag := range c.spec.Flags {
|
||||
binding := flag.binding
|
||||
flags[index] = commandmeta.Flag{
|
||||
Name: binding.meta.Name,
|
||||
Short: binding.meta.Short,
|
||||
Usage: binding.meta.Usage,
|
||||
Default: binding.defaultText,
|
||||
Boolean: binding.boolean,
|
||||
Repeated: binding.repeated,
|
||||
Required: binding.meta.Required,
|
||||
Name: binding.meta.Name,
|
||||
Short: binding.meta.Short,
|
||||
Usage: binding.meta.Usage,
|
||||
Default: binding.defaultText,
|
||||
Boolean: binding.boolean,
|
||||
Repeated: binding.repeated,
|
||||
Required: binding.meta.Required,
|
||||
Completion: bridgeCompletion(binding.meta.Completion),
|
||||
}
|
||||
}
|
||||
return flags
|
||||
@@ -168,17 +225,18 @@ func (c typedCommand[I]) bridgeArguments() []commandmeta.Argument {
|
||||
for index, argument := range c.spec.Arguments {
|
||||
binding := argument.binding
|
||||
arguments[index] = commandmeta.Argument{
|
||||
Name: binding.meta.Name,
|
||||
Usage: binding.meta.Usage,
|
||||
Min: binding.min,
|
||||
Max: binding.max,
|
||||
Name: binding.meta.Name,
|
||||
Usage: binding.meta.Usage,
|
||||
Min: binding.min,
|
||||
Max: binding.max,
|
||||
Completion: bridgeCompletion(binding.meta.Completion),
|
||||
}
|
||||
}
|
||||
return arguments
|
||||
}
|
||||
|
||||
func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enforceRequired bool) (func(*execution) Outcome, error) {
|
||||
prepared, err := prepareInput(c.spec, flagValues, operands, enforceRequired)
|
||||
func (c typedCommand[I]) prepare(flagValues, flagSpellings [][]string, operands []string, enforceRequired bool) (func(*execution) Outcome, error) {
|
||||
prepared, err := prepareInputWithSpellings(c.spec, flagValues, flagSpellings, operands, enforceRequired)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -192,13 +250,15 @@ 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
|
||||
}
|
||||
|
||||
// NewCommand captures a typed command declaration without constructing its
|
||||
// input or running callbacks. Construction issues are retained and reported
|
||||
// when the declaration is installed. The returned declaration can be reused.
|
||||
// NewCommand captures a typed canonical command declaration without
|
||||
// constructing its input or running callbacks. It copies the spec's flags,
|
||||
// arguments, and children slices. Construction issues are retained and
|
||||
// reported when the declaration is installed; NewCommand itself does not
|
||||
// return an error. The returned declaration can be reused across Apps.
|
||||
func NewCommand[I any](spec CommandSpec[I]) Command {
|
||||
return captureCommand(spec, nil)
|
||||
}
|
||||
@@ -220,25 +280,29 @@ func captureCommand[I any](spec CommandSpec[I], privateRun func(commandExecution
|
||||
return Command{record: record}
|
||||
}
|
||||
|
||||
// Group captures a non-runnable command group and a copy of its relative child
|
||||
// slice. Child declarations remain reusable and are not recursively copied.
|
||||
// Group captures a non-runnable canonical command group and a copy of its
|
||||
// relative Command child slice. Child declarations remain reusable and are not
|
||||
// recursively copied. Routing rules cannot be children; install them alongside
|
||||
// the root command tree through New or App.Register.
|
||||
func Group(name, summary string, children ...Command) Command {
|
||||
record := &commandRecord{name: name, summary: summary, children: append([]Command(nil), children...)}
|
||||
captureComposition(record)
|
||||
return Command{record: record}
|
||||
}
|
||||
|
||||
// RootGroup captures a group that may only be installed at the application root.
|
||||
// Installation rejects it below any other command, including through wrappers.
|
||||
// RootGroup captures a group that may only be installed at the application
|
||||
// root. Installation rejects it below any other command, including through
|
||||
// wrappers. Its children are canonical Commands; install routing rules at the
|
||||
// application root separately.
|
||||
func RootGroup(name, summary string, children ...Command) Command {
|
||||
command := Group(name, summary, children...)
|
||||
command.record.rootOnly = true
|
||||
return command
|
||||
}
|
||||
|
||||
// WithError returns a new declaration that also retains a non-nil err for
|
||||
// installation. Earlier declaration and child errors remain available, and
|
||||
// wrapped error identity is preserved. A nil err returns command unchanged.
|
||||
// WithError returns a new Command declaration that also retains a non-nil err
|
||||
// for installation. Earlier declaration and child errors remain available,
|
||||
// and wrapped error identity is preserved. A nil err returns command unchanged.
|
||||
func WithError(command Command, err error) Command {
|
||||
if err == nil {
|
||||
return command
|
||||
@@ -254,11 +318,91 @@ func WithError(command Command, err error) Command {
|
||||
return Command{record: &record}
|
||||
}
|
||||
|
||||
// NoCommand explicitly represents an absent optional declaration. Installation
|
||||
// skips it, but reports errors attached to it; a zero Command is invalid and is
|
||||
// not an absence marker.
|
||||
// NoCommand explicitly represents an absent optional command declaration.
|
||||
// Installation skips it, but reports errors attached to it; a zero Command is
|
||||
// invalid and is not an absence marker. Routing rules have no absence marker.
|
||||
func NoCommand() Command { return Command{record: &commandRecord{absent: true}} }
|
||||
|
||||
// Alias captures a command-path alias from path to target. Paths are
|
||||
// whitespace-separated sequences of command names relative to the application
|
||||
// root. The target must resolve to a canonical command or group; aliases do not
|
||||
// add operands or transform flag values. Construction issues are retained for
|
||||
// New or App.Register to report. Identical mappings are accepted, while
|
||||
// different targets for the same source path are installation errors.
|
||||
func Alias(path, target string) RoutingRule {
|
||||
record := &routingRuleRecord{
|
||||
kind: commandAliasRule,
|
||||
sourceText: path,
|
||||
targetText: target,
|
||||
}
|
||||
record.sourcePath = captureRoutingPath("source", path, &record.issues)
|
||||
record.targetPath = captureRoutingPath("target", target, &record.issues)
|
||||
return RoutingRule{record: record}
|
||||
}
|
||||
|
||||
// FlagAlias captures an application-wide flag spelling alias. Spellings must
|
||||
// be one long flag name or one individual short spelling. The mapping applies
|
||||
// wherever its canonical target flag is available and contributes nothing on
|
||||
// commands without that flag. Identical mappings are accepted; different
|
||||
// targets for the same source spelling in this scope are installation errors.
|
||||
func FlagAlias(spelling, target string) RoutingRule {
|
||||
record := &routingRuleRecord{
|
||||
kind: globalFlagAliasRule,
|
||||
sourceText: spelling,
|
||||
targetText: target,
|
||||
}
|
||||
record.sourceFlag = captureFlagSpelling("source", spelling, &record.issues)
|
||||
record.targetFlag = captureFlagSpelling("target", target, &record.issues)
|
||||
return RoutingRule{record: record}
|
||||
}
|
||||
|
||||
// FlagAliasFor captures a flag spelling alias scoped to a canonical command
|
||||
// path, not an alias path. The command and canonical target flag must be
|
||||
// available in the complete installation. Scoped mappings take precedence over
|
||||
// application-wide mappings with the same source spelling. Identical mappings
|
||||
// are accepted; competing targets in the same scope are installation errors.
|
||||
func FlagAliasFor(commandPath, spelling, target string) RoutingRule {
|
||||
record := &routingRuleRecord{
|
||||
kind: scopedFlagAliasRule,
|
||||
sourceText: spelling,
|
||||
targetText: target,
|
||||
scopeText: commandPath,
|
||||
}
|
||||
record.scopePath = captureRoutingPath("command scope", commandPath, &record.issues)
|
||||
record.sourceFlag = captureFlagSpelling("source", spelling, &record.issues)
|
||||
record.targetFlag = captureFlagSpelling("target", target, &record.issues)
|
||||
return RoutingRule{record: record}
|
||||
}
|
||||
|
||||
func captureRoutingPath(label, value string, issues *[]error) commandpath.Path {
|
||||
path, err := commandpath.ParseDeclaration(value)
|
||||
if err != nil {
|
||||
*issues = append(*issues, fmt.Errorf("invalid %s path: %w", label, err))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func captureFlagSpelling(label, value string, issues *[]error) capturedFlagSpelling {
|
||||
spelling, err := parseFlagSpelling(value)
|
||||
if err != nil {
|
||||
*issues = append(*issues, fmt.Errorf("invalid %s flag spelling %q: %w", label, value, err))
|
||||
}
|
||||
return spelling
|
||||
}
|
||||
|
||||
func parseFlagSpelling(value string) (capturedFlagSpelling, error) {
|
||||
if strings.HasPrefix(value, "--") {
|
||||
name := value[2:]
|
||||
if validFlagName(name) {
|
||||
return capturedFlagSpelling{name: name}, nil
|
||||
}
|
||||
}
|
||||
if len(value) == 2 && value[0] == '-' && value[1] != '-' {
|
||||
return capturedFlagSpelling{name: value[1:], short: true}, nil
|
||||
}
|
||||
return capturedFlagSpelling{}, fmt.Errorf("must be a long flag or one-byte short flag spelling")
|
||||
}
|
||||
|
||||
func captureComposition(record *commandRecord) {
|
||||
if err := validateCommandName(record.name); err != nil {
|
||||
record.issues = append(record.issues, err)
|
||||
@@ -297,7 +441,7 @@ func collectDeclarationIssues(command Command, parents []string, root bool, issu
|
||||
}
|
||||
record := command.record
|
||||
path := append(append([]string(nil), parents...), record.name)
|
||||
label := strings.Join(path, " ")
|
||||
label := commandpath.FromTokens(path).String()
|
||||
for _, issue := range record.issues {
|
||||
if label != "" {
|
||||
*issues = append(*issues, fmt.Errorf("command %q: %w", label, issue))
|
||||
@@ -320,11 +464,7 @@ func collectDeclarationIssues(command Command, parents []string, root bool, issu
|
||||
}
|
||||
|
||||
func validateCommandName(name string) error {
|
||||
fields := strings.Fields(name)
|
||||
if len(fields) != 1 || fields[0] != name || strings.HasPrefix(name, "-") || name == "--" {
|
||||
return fmt.Errorf("command name %q must be one non-option token", name)
|
||||
}
|
||||
return nil
|
||||
return commandpath.ValidateComponent(name)
|
||||
}
|
||||
|
||||
func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
|
||||
@@ -338,6 +478,9 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
|
||||
if binding.meta.Name == "help" {
|
||||
return fmt.Errorf("flag name %q is reserved for help", binding.meta.Name)
|
||||
}
|
||||
if commandmeta.IsReportingFlag(binding.meta.Name) {
|
||||
return fmt.Errorf("flag name %q is reserved for reporting", binding.meta.Name)
|
||||
}
|
||||
if _, exists := flagNames[binding.meta.Name]; exists {
|
||||
return fmt.Errorf("duplicate flag name %q", binding.meta.Name)
|
||||
}
|
||||
|
||||
119
completion.go
Normal file
119
completion.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
)
|
||||
|
||||
// Completion declares opt-in shell script generation for the executable
|
||||
// basename used to invoke the application. Install it at the application root.
|
||||
// Generated shell identifiers support letters, digits, underscores, dots,
|
||||
// hyphens, and colons; a leading hyphen is not supported.
|
||||
func Completion(executable string) Command {
|
||||
children := make([]Command, 0, 3)
|
||||
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||
shell := shell
|
||||
children = append(children, NewCommand(CommandSpec[struct{}]{
|
||||
Name: shell, Summary: "Generate " + shell + " completion script.",
|
||||
Run: func(invocation Invocation, _ struct{}) Outcome {
|
||||
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
|
||||
return internalcobra.CompletionScript(writer, executable, shell)
|
||||
})
|
||||
return Success()
|
||||
},
|
||||
}))
|
||||
}
|
||||
command := RootGroup("completion", "Generate shell completion scripts.", children...)
|
||||
command.record.completion = true
|
||||
if !validCompletionExecutable(executable) {
|
||||
return WithError(command, fmt.Errorf("unsupported completion executable basename %q", executable))
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
// Cobra emits the basename into shell identifiers and unquoted command words.
|
||||
func validCompletionExecutable(name string) bool {
|
||||
if name == "" || name == "." || name == ".." || strings.HasPrefix(name, "-") {
|
||||
return false
|
||||
}
|
||||
for _, ch := range name {
|
||||
if !(unicode.IsLetter(ch) || unicode.IsDigit(ch) || ch == '_' || ch == '-' || ch == ':' || ch == '.') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CompletionChoice is a literal suggested value and its optional description.
|
||||
type CompletionChoice struct {
|
||||
// Value is inserted as data; it is not interpreted as shell source.
|
||||
Value string
|
||||
// Description is optional context displayed by shells that support it.
|
||||
Description string
|
||||
}
|
||||
|
||||
// CompletionHint selects suggestions for a flag or positional value. Its zero
|
||||
// value requests no value suggestions; hints do not constrain accepted input.
|
||||
type CompletionHint struct {
|
||||
kind completionHintKind
|
||||
choices *completionChoicesData
|
||||
}
|
||||
|
||||
// Keep the public metadata structs comparable without exposing mutable choices.
|
||||
type completionChoicesData struct {
|
||||
values []CompletionChoice
|
||||
}
|
||||
|
||||
type completionHintKind uint8
|
||||
|
||||
const (
|
||||
completionNone completionHintKind = iota
|
||||
completionChoices
|
||||
completionFiles
|
||||
completionDirectories
|
||||
)
|
||||
|
||||
// CompleteChoices suggests literal values matching the current value prefix.
|
||||
// The supplied slice is copied. An empty list produces no suggestions. Empty
|
||||
// values and values containing a newline, tab, or NUL cannot be presented as
|
||||
// suggestions, but remain valid input if the command's converter accepts them.
|
||||
func CompleteChoices(choices ...CompletionChoice) CompletionHint {
|
||||
hint := CompletionHint{kind: completionChoices}
|
||||
if len(choices) != 0 {
|
||||
hint.choices = &completionChoicesData{values: append([]CompletionChoice(nil), choices...)}
|
||||
}
|
||||
return hint
|
||||
}
|
||||
|
||||
// CompleteFiles requests shell filename completion.
|
||||
func CompleteFiles() CompletionHint { return CompletionHint{kind: completionFiles} }
|
||||
|
||||
// CompleteDirectories requests shell directory completion.
|
||||
func CompleteDirectories() CompletionHint { return CompletionHint{kind: completionDirectories} }
|
||||
|
||||
// NoCompletion requests no value suggestions.
|
||||
func NoCompletion() CompletionHint { return CompletionHint{} }
|
||||
|
||||
func bridgeCompletion(hint CompletionHint) commandmeta.CompletionHint {
|
||||
result := commandmeta.CompletionHint{}
|
||||
switch hint.kind {
|
||||
case completionChoices:
|
||||
result.Kind = commandmeta.CompletionChoices
|
||||
if hint.choices == nil {
|
||||
break
|
||||
}
|
||||
for _, choice := range hint.choices.values {
|
||||
result.Choices = append(result.Choices, commandmeta.CompletionChoice{Value: choice.Value, Description: choice.Description})
|
||||
}
|
||||
case completionFiles:
|
||||
result.Kind = commandmeta.CompletionFiles
|
||||
case completionDirectories:
|
||||
result.Kind = commandmeta.CompletionDirectories
|
||||
}
|
||||
return result
|
||||
}
|
||||
78
completion_context.go
Normal file
78
completion_context.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||
)
|
||||
|
||||
type completionContext struct {
|
||||
route routeResult
|
||||
command *commandmeta.Command
|
||||
aliases []commandmeta.FlagAlias
|
||||
completed []string
|
||||
tail []string
|
||||
current string
|
||||
commandPosition bool
|
||||
}
|
||||
|
||||
func resolveCompletionContext(snapshot *routingSnapshot, completed []string, current string) completionContext {
|
||||
context := completionContext{completed: append([]string(nil), completed...), current: current}
|
||||
if snapshot == nil {
|
||||
return context
|
||||
}
|
||||
context.route = resolveEnteredRoute(completed, snapshot)
|
||||
switch context.route.kind {
|
||||
case routeCanonical:
|
||||
context.command = completionCommand(snapshot, context.route.canonicalPath)
|
||||
if context.command == nil || len(context.route.args) < len(context.route.canonicalPath) {
|
||||
context.command = nil
|
||||
return context
|
||||
}
|
||||
context.tail = append([]string(nil), context.route.args[len(context.route.canonicalPath):]...)
|
||||
context.aliases = snapshot.flagAliases[context.route.canonicalPath.Key()].lookupRecords()
|
||||
context.commandPosition = len(context.tail) == 0
|
||||
case routeDiscovery:
|
||||
context.command = &commandmeta.Command{CoreFlags: commandmeta.ReportingFlags()}
|
||||
context.tail = append([]string(nil), context.route.args...)
|
||||
context.aliases = snapshot.discoveryFlags[context.route.discoveryPath.Key()].lookupRecords()
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
func completionCommand(snapshot *routingSnapshot, path commandpath.Path) *commandmeta.Command {
|
||||
if len(path) == 0 {
|
||||
return snapshot.metadata
|
||||
}
|
||||
children := snapshot.canonical
|
||||
var node *canonicalNode
|
||||
for _, name := range path {
|
||||
node = children[name]
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
children = node.children
|
||||
}
|
||||
return node.metadata
|
||||
}
|
||||
|
||||
func completionChildren(snapshot *routingSnapshot, context completionContext) []commandmeta.CompletionChoice {
|
||||
if snapshot == nil || context.command == nil || !context.commandPosition {
|
||||
return nil
|
||||
}
|
||||
var choices []commandmeta.CompletionChoice
|
||||
for _, child := range context.command.Children {
|
||||
if !strings.HasPrefix(child.Name, context.current) || (len(context.route.canonicalPath) == 0 && child.Name == internalcobra.CompletionQueryToken) {
|
||||
continue
|
||||
}
|
||||
entered := append(append([]string(nil), context.completed...), child.Name)
|
||||
route := resolveEnteredRoute(entered, snapshot)
|
||||
intended := append(context.route.canonicalPath.Clone(), child.Name)
|
||||
if route.kind == routeCanonical && route.canonicalPath.Equal(intended) {
|
||||
choices = append(choices, commandmeta.CompletionChoice{Value: child.Name, Description: child.Summary})
|
||||
}
|
||||
}
|
||||
return choices
|
||||
}
|
||||
89
completion_context_test.go
Normal file
89
completion_context_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
)
|
||||
|
||||
func TestCompletionCommandContext(t *testing.T) {
|
||||
parse := func(value string) (string, error) { return value, nil }
|
||||
mixed := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "mixed", Children: []Command{simpleRunnable("child")},
|
||||
Arguments: []Argument[struct{}]{ValueArgument(ArgumentMeta{Name: "item"}, false, parse, func(*struct{}, Supplied[string]) {})},
|
||||
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||
})
|
||||
app, err := New(Identity{Name: "example"},
|
||||
Group("workspace", "", Group("list", "", simpleRunnable("shadow"), simpleRunnable("show"))),
|
||||
simpleRunnable("other"), mixed, Completion("example"),
|
||||
Alias("ws", "workspace"), Alias("w", "ws"), Alias("ws list shadow", "other"),
|
||||
Alias("legacy inspect", "workspace list"), FlagAlias("--silent", "--quiet"),
|
||||
FlagAliasFor("workspace list", "--detail", "--verbose"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := app.routeSnapshot()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
completed []string
|
||||
current string
|
||||
path string
|
||||
position bool
|
||||
choices []string
|
||||
kind routeKind
|
||||
}{
|
||||
{"root", nil, "work", "", true, []string{"workspace"}, routeCanonical},
|
||||
{"group", []string{"workspace"}, "li", "workspace", true, []string{"list"}, routeCanonical},
|
||||
{"entered alias", []string{"ws", "list"}, "sh", "workspace list", true, []string{"show"}, routeCanonical},
|
||||
{"alias chain", []string{"w", "list"}, "sh", "workspace list", true, []string{"show"}, routeCanonical},
|
||||
{"discovery", []string{"legacy"}, "in", "", false, nil, routeDiscovery},
|
||||
{"complete alias", []string{"legacy", "inspect"}, "sh", "workspace list", true, []string{"shadow", "show"}, routeCanonical},
|
||||
{"runnable child", []string{"mixed"}, "ch", "mixed", true, []string{"child"}, routeCanonical},
|
||||
{"unmatched child", []string{"mixed"}, "other", "mixed", true, nil, routeCanonical},
|
||||
{"runnable operand", []string{"mixed", "value"}, "ch", "mixed", false, nil, routeCanonical},
|
||||
{"flag ends path", []string{"mixed", "--quiet"}, "ch", "mixed", false, nil, routeCanonical},
|
||||
{"flag before child", []string{"workspace", "--quiet"}, "li", "workspace", false, nil, routeCanonical},
|
||||
{"unknown", []string{"missing"}, "x", "", false, nil, routeUnknown},
|
||||
{"hidden endpoint", nil, "__comp", "", true, nil, routeCanonical},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
original := append([]string(nil), tc.completed...)
|
||||
context := resolveCompletionContext(snapshot, tc.completed, tc.current)
|
||||
if context.route.kind != tc.kind || context.route.canonicalPath.String() != tc.path || context.commandPosition != tc.position || !reflect.DeepEqual(tc.completed, original) {
|
||||
t.Fatalf("context=%#v completed=%q", context, tc.completed)
|
||||
}
|
||||
var names []string
|
||||
for _, choice := range completionChildren(snapshot, context) {
|
||||
names = append(names, choice.Value)
|
||||
}
|
||||
if !reflect.DeepEqual(names, tc.choices) {
|
||||
t.Fatalf("children=%q, want %q", names, tc.choices)
|
||||
}
|
||||
if tc.kind == routeDiscovery && !reflect.DeepEqual(context.aliases, []commandmeta.FlagAlias{{Source: "--silent", Target: "--quiet"}}) {
|
||||
t.Fatalf("discovery aliases=%#v", context.aliases)
|
||||
}
|
||||
if (tc.name == "runnable child" || tc.name == "unmatched child") && (context.command == nil || len(context.command.Arguments) != 1) {
|
||||
t.Fatalf("runnable operand metadata missing: %#v", context.command)
|
||||
}
|
||||
if tc.name == "entered alias" {
|
||||
actual := slices.Clone(context.aliases)
|
||||
expected := []commandmeta.FlagAlias{{Source: "--detail", Target: "--verbose"}, {Source: "--silent", Target: "--quiet"}}
|
||||
compare := func(a, b commandmeta.FlagAlias) int {
|
||||
if result := cmp.Compare(a.Source, b.Source); result != 0 {
|
||||
return result
|
||||
}
|
||||
return cmp.Compare(a.Target, b.Target)
|
||||
}
|
||||
slices.SortFunc(actual, compare)
|
||||
slices.SortFunc(expected, compare)
|
||||
if !slices.Equal(actual, expected) {
|
||||
t.Fatalf("canonical aliases=%#v, want %#v", actual, expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
217
completion_flags.go
Normal file
217
completion_flags.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
)
|
||||
|
||||
type completionTargetKind uint8
|
||||
|
||||
const (
|
||||
completionTargetNone completionTargetKind = iota
|
||||
completionTargetCommands
|
||||
completionTargetFlags
|
||||
completionTargetValue
|
||||
completionTargetOperand
|
||||
)
|
||||
|
||||
type completionTarget struct {
|
||||
kind completionTargetKind
|
||||
choices []commandmeta.CompletionChoice
|
||||
hint commandmeta.CompletionHint
|
||||
prefix string
|
||||
valuePrefix string
|
||||
}
|
||||
|
||||
type completionFlagState struct {
|
||||
supplied map[string]bool
|
||||
operands []string
|
||||
pending *commandmeta.Flag
|
||||
ended bool
|
||||
invalid bool
|
||||
}
|
||||
|
||||
func completionFlags(context completionContext) []commandmeta.Flag {
|
||||
flags := make([]commandmeta.Flag, 0, len(context.command.Flags)+len(context.command.CoreFlags)+1)
|
||||
flags = append(flags, commandmeta.HelpFlag())
|
||||
flags = append(flags, context.command.CoreFlags...)
|
||||
flags = append(flags, context.command.Flags...)
|
||||
return flags
|
||||
}
|
||||
|
||||
func walkCompletionFlags(tokens []string, lookup commandmeta.FlagLookup) completionFlagState {
|
||||
state := completionFlagState{supplied: make(map[string]bool)}
|
||||
for index := 0; index < len(tokens); index++ {
|
||||
token := tokens[index]
|
||||
if state.ended {
|
||||
state.operands = append(state.operands, token)
|
||||
continue
|
||||
}
|
||||
switch commandmeta.ClassifyFlagToken(token) {
|
||||
case commandmeta.TerminatorToken:
|
||||
state.ended = true
|
||||
case commandmeta.OperandToken:
|
||||
state.operands = append(state.operands, token)
|
||||
case commandmeta.LongFlagToken:
|
||||
spelling, _, attached := commandmeta.SplitLongFlag(token)
|
||||
flag, ok := lookup.Find(spelling)
|
||||
if !ok {
|
||||
state.invalid = true
|
||||
return state
|
||||
}
|
||||
state.supplied[flag.Name] = true
|
||||
if !flag.Boolean && !attached {
|
||||
if index+1 == len(tokens) {
|
||||
state.pending = &flag
|
||||
return state
|
||||
}
|
||||
index++
|
||||
}
|
||||
case commandmeta.ShortFlagToken:
|
||||
for offset := 1; offset < len(token); offset++ {
|
||||
flag, ok := lookup.Find("-" + token[offset:offset+1])
|
||||
if !ok {
|
||||
state.invalid = true
|
||||
return state
|
||||
}
|
||||
state.supplied[flag.Name] = true
|
||||
suffix := token[offset+1:]
|
||||
if flag.Boolean {
|
||||
if strings.HasPrefix(suffix, "=") {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if suffix == "" {
|
||||
if index+1 == len(tokens) {
|
||||
state.pending = &flag
|
||||
return state
|
||||
}
|
||||
index++
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func completionTargetFor(snapshot *routingSnapshot, context completionContext) completionTarget {
|
||||
if context.command == nil {
|
||||
return completionTarget{}
|
||||
}
|
||||
flags := completionFlags(context)
|
||||
lookup := commandmeta.NewFlagLookup(flags, context.aliases)
|
||||
state := walkCompletionFlags(context.tail, lookup)
|
||||
if state.invalid {
|
||||
return completionTarget{}
|
||||
}
|
||||
if state.pending != nil {
|
||||
return completionTarget{kind: completionTargetValue, hint: state.pending.Completion, valuePrefix: context.current}
|
||||
}
|
||||
if !state.ended && context.commandPosition {
|
||||
if children := completionChildren(snapshot, context); len(children) > 0 {
|
||||
return completionTarget{kind: completionTargetCommands, choices: children}
|
||||
}
|
||||
}
|
||||
if !state.ended && strings.HasPrefix(context.current, "-") {
|
||||
return completionOptionTarget(context.current, flags, lookup, state.supplied)
|
||||
}
|
||||
if hint, ok := completionOperandHint(context.command.Arguments, len(state.operands)); ok {
|
||||
return completionTarget{kind: completionTargetOperand, hint: hint, valuePrefix: context.current}
|
||||
}
|
||||
if !state.ended && context.current == "" {
|
||||
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, state.supplied, "")}
|
||||
}
|
||||
return completionTarget{}
|
||||
}
|
||||
|
||||
func completionOptionTarget(current string, flags []commandmeta.Flag, lookup commandmeta.FlagLookup, supplied map[string]bool) completionTarget {
|
||||
if current == "-" {
|
||||
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)}
|
||||
}
|
||||
if strings.HasPrefix(current, "--") {
|
||||
spelling, value, attached := commandmeta.SplitLongFlag(current)
|
||||
if attached {
|
||||
if flag, ok := lookup.Find(spelling); ok {
|
||||
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: spelling + "=", valuePrefix: value}
|
||||
}
|
||||
return completionTarget{}
|
||||
}
|
||||
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)}
|
||||
}
|
||||
used := make(map[string]bool, len(supplied))
|
||||
for name, value := range supplied {
|
||||
used[name] = value
|
||||
}
|
||||
for offset := 1; offset < len(current); offset++ {
|
||||
flag, ok := lookup.Find("-" + current[offset:offset+1])
|
||||
if !ok {
|
||||
return completionTarget{}
|
||||
}
|
||||
used[flag.Name] = true
|
||||
suffix := current[offset+1:]
|
||||
if flag.Boolean {
|
||||
if strings.HasPrefix(suffix, "=") {
|
||||
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: current[:offset+2], valuePrefix: suffix[1:]}
|
||||
}
|
||||
continue
|
||||
}
|
||||
prefix := current[:offset+1]
|
||||
if strings.HasPrefix(suffix, "=") {
|
||||
prefix += "="
|
||||
suffix = suffix[1:]
|
||||
}
|
||||
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: prefix, valuePrefix: suffix}
|
||||
}
|
||||
var choices []commandmeta.CompletionChoice
|
||||
for _, flag := range flags {
|
||||
if flag.Short != "" && completionFlagEligible(flag, used) {
|
||||
choices = append(choices, commandmeta.CompletionChoice{Value: current + flag.Short, Description: flag.Usage})
|
||||
}
|
||||
}
|
||||
return completionTarget{kind: completionTargetFlags, choices: choices}
|
||||
}
|
||||
|
||||
func completionFlagNames(flags []commandmeta.Flag, supplied map[string]bool, prefix string) []commandmeta.CompletionChoice {
|
||||
var choices []commandmeta.CompletionChoice
|
||||
for _, flag := range flags {
|
||||
if !completionFlagEligible(flag, supplied) {
|
||||
continue
|
||||
}
|
||||
for _, spelling := range []string{"--" + flag.Name, shortCompletionSpelling(flag.Short)} {
|
||||
if spelling != "" && strings.HasPrefix(spelling, prefix) {
|
||||
choices = append(choices, commandmeta.CompletionChoice{Value: spelling, Description: flag.Usage})
|
||||
}
|
||||
}
|
||||
}
|
||||
return choices
|
||||
}
|
||||
|
||||
func completionFlagEligible(flag commandmeta.Flag, supplied map[string]bool) bool {
|
||||
return !supplied[flag.Name] || flag.Repeated || commandmeta.IsReportingFlag(flag.Name)
|
||||
}
|
||||
|
||||
func shortCompletionSpelling(short string) string {
|
||||
if short == "" {
|
||||
return ""
|
||||
}
|
||||
return "-" + short
|
||||
}
|
||||
|
||||
func completionOperandHint(arguments []commandmeta.Argument, completed int) (commandmeta.CompletionHint, bool) {
|
||||
total := completed + 1
|
||||
counts, err := allocateOperandCounts(total, arguments, false)
|
||||
if err != nil {
|
||||
return commandmeta.CompletionHint{}, false
|
||||
}
|
||||
position := 0
|
||||
for index, count := range counts {
|
||||
position += count
|
||||
if position >= total {
|
||||
return arguments[index].Completion, true
|
||||
}
|
||||
}
|
||||
return commandmeta.CompletionHint{}, false
|
||||
}
|
||||
146
completion_flags_test.go
Normal file
146
completion_flags_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
)
|
||||
|
||||
func TestCompletionFlagAndValuePositions(t *testing.T) {
|
||||
parse := func(value string) (string, error) { return value, nil }
|
||||
choice := CompleteChoices(CompletionChoice{Value: "blue"}, CompletionChoice{Value: "green"})
|
||||
command := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Flags: []Flag[struct{}]{
|
||||
ValueFlag(FlagMeta{Name: "color", Short: "c", Completion: choice}, (*string)(nil), parse, func(*struct{}, Supplied[string]) {}),
|
||||
RepeatFlag(FlagMeta{Name: "tag", Short: "t", Completion: choice}, nil, parse, func(*struct{}, Supplied[[]string]) {}),
|
||||
BoolFlag(FlagMeta{Name: "switch", Short: "s"}, false, func(*struct{}, Supplied[bool]) {}),
|
||||
},
|
||||
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||
})
|
||||
app, err := New(Identity{Name: "example"}, command,
|
||||
FlagAliasFor("run", "--colour", "--color"), FlagAliasFor("run", "-C", "--color"), FlagAlias("-v", "--verbose"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := app.routeSnapshot()
|
||||
for _, tc := range []struct {
|
||||
name, current, prefix, value string
|
||||
completed []string
|
||||
kind completionTargetKind
|
||||
hint commandmeta.CompletionKind
|
||||
}{
|
||||
{"pending takes option", "--quiet", "", "--quiet", []string{"run", "--color"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||
{"terminator consumed as value", "-x", "", "-x", []string{"run", "--color", "--", "--tag"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||
{"repeated singleton value", "--color=gr", "--color=", "gr", []string{"run", "--color=blue"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||
{"long alias value", "--colour=bl", "--colour=", "bl", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||
{"long empty value", "--color=", "--color=", "", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||
{"short alias value", "-sCbl", "-sC", "bl", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||
{"short equals", "-sC=bl", "-sC=", "bl", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||
{"reporting boolean", "--verbose=f", "--verbose=", "f", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||
{"short boolean alias", "-v=f", "-v=", "f", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||
{"application boolean", "--switch=f", "--switch=", "f", []string{"run"}, completionTargetValue, commandmeta.CompletionNone},
|
||||
{"scoped alias unavailable at root", "--colour=bl", "", "", nil, completionTargetNone, commandmeta.CompletionNone},
|
||||
{"unknown completed", "--c", "", "", []string{"run", "--unknown"}, completionTargetNone, commandmeta.CompletionNone},
|
||||
{"unknown attached", "--unknown=x", "", "", []string{"run"}, completionTargetNone, commandmeta.CompletionNone},
|
||||
{"terminator", "--color", "", "", []string{"run", "--"}, completionTargetNone, commandmeta.CompletionNone},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
target := completionTargetFor(snapshot, resolveCompletionContext(snapshot, tc.completed, tc.current))
|
||||
if target.kind != tc.kind || target.hint.Kind != tc.hint || target.prefix != tc.prefix || target.valuePrefix != tc.value {
|
||||
t.Fatalf("target=%#v", target)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
context := resolveCompletionContext(snapshot, []string{"run", "--colour=blue", "--tag=one", "--verbose"}, "--")
|
||||
target := completionTargetFor(snapshot, context)
|
||||
names := completionChoiceNames(target.choices)
|
||||
if containsCompletionName(names, "--color") || !containsCompletionName(names, "--tag") || !containsCompletionName(names, "--verbose") {
|
||||
t.Fatalf("flag suggestions=%q", names)
|
||||
}
|
||||
cluster := completionTargetFor(snapshot, resolveCompletionContext(snapshot, []string{"run"}, "-sv"))
|
||||
if cluster.kind != completionTargetFlags || !containsCompletionName(completionChoiceNames(cluster.choices), "-svc") || containsCompletionName(completionChoiceNames(cluster.choices), "-svs") {
|
||||
t.Fatalf("short cluster=%#v", cluster)
|
||||
}
|
||||
dash := completionTargetFor(snapshot, resolveCompletionContext(snapshot, []string{"run"}, "-"))
|
||||
if !containsCompletionName(completionChoiceNames(dash.choices), "--color") || !containsCompletionName(completionChoiceNames(dash.choices), "-c") {
|
||||
t.Fatalf("dash suggestions=%#v", dash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionOperandAllocation(t *testing.T) {
|
||||
arguments := []commandmeta.Argument{
|
||||
{Name: "optional", Min: 0, Max: 1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionFiles}},
|
||||
{Name: "repeated", Min: 0, Max: -1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionDirectories}},
|
||||
{Name: "required", Min: 1, Max: 1, Completion: commandmeta.BooleanCompletion()},
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
completed int
|
||||
want commandmeta.CompletionKind
|
||||
}{
|
||||
{0, commandmeta.CompletionChoices},
|
||||
{1, commandmeta.CompletionChoices},
|
||||
{2, commandmeta.CompletionChoices},
|
||||
} {
|
||||
hint, ok := completionOperandHint(arguments, tc.completed)
|
||||
if !ok || hint.Kind != tc.want {
|
||||
t.Fatalf("%d operands: %#v, %v", tc.completed, hint, ok)
|
||||
}
|
||||
}
|
||||
bounded := []commandmeta.Argument{{Name: "only", Max: 1, Completion: commandmeta.BooleanCompletion()}}
|
||||
if _, ok := completionOperandHint(bounded, 1); ok {
|
||||
t.Fatal("excess operand received a hint")
|
||||
}
|
||||
trailing := []commandmeta.Argument{{Name: "first", Min: 1, Max: 1, Completion: commandmeta.BooleanCompletion()}, {Name: "rest", Min: 0, Max: -1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionDirectories}}}
|
||||
if hint, ok := completionOperandHint(trailing, 1); !ok || hint.Kind != commandmeta.CompletionDirectories {
|
||||
t.Fatalf("repeated trailing operand: %#v, %v", hint, ok)
|
||||
}
|
||||
if got, want := completionChoiceNames(commandmeta.BooleanCompletion().Choices), []string{"true", "false"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("boolean choices=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionOperandAndFlagContexts(t *testing.T) {
|
||||
command := &commandmeta.Command{
|
||||
Arguments: []commandmeta.Argument{{Name: "item", Min: 0, Max: 1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionFiles}}},
|
||||
Flags: []commandmeta.Flag{{Name: "name", Completion: commandmeta.BooleanCompletion()}},
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, current string
|
||||
tail []string
|
||||
kind completionTargetKind
|
||||
hint commandmeta.CompletionKind
|
||||
}{
|
||||
{"empty chooses operand", "", nil, completionTargetOperand, commandmeta.CompletionFiles},
|
||||
{"dash chooses flag", "--n", nil, completionTargetFlags, commandmeta.CompletionNone},
|
||||
{"after terminator", "--n", []string{"--"}, completionTargetOperand, commandmeta.CompletionFiles},
|
||||
{"interspersed flag value", "value", []string{"--name", "--quiet"}, completionTargetOperand, commandmeta.CompletionFiles},
|
||||
{"excess operand", "value", []string{"already"}, completionTargetNone, commandmeta.CompletionNone},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
target := completionTargetFor(nil, completionContext{command: command, tail: tc.tail, current: tc.current})
|
||||
if target.kind != tc.kind || target.hint.Kind != tc.hint {
|
||||
t.Fatalf("target=%#v", target)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func completionChoiceNames(choices []commandmeta.CompletionChoice) []string {
|
||||
var names []string
|
||||
for _, choice := range choices {
|
||||
names = append(names, choice.Value)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func containsCompletionName(names []string, want string) bool {
|
||||
for _, name := range names {
|
||||
if name == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
108
completion_install_test.go
Normal file
108
completion_install_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompletionDeclarationGeneratesScripts(t *testing.T) {
|
||||
for _, name := range []string{"app", "my-app", "tool.exe", "café"} {
|
||||
if !validCompletionExecutable(name) {
|
||||
t.Fatalf("supported executable rejected: %q", name)
|
||||
}
|
||||
}
|
||||
app, err := New(Identity{Name: "Human Readable Name"}, Completion("my-app"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !app.routeSnapshot().completion {
|
||||
t.Fatal("completion not present in snapshot")
|
||||
}
|
||||
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"completion", shell}, Streams{Out: &output, Err: &diagnostics})
|
||||
if outcome.Status != 0 || !strings.Contains(output.String(), "my-app") || strings.Contains(output.String(), "Human Readable Name") {
|
||||
t.Fatalf("%s outcome=%#v output=%q diagnostics=%q", shell, outcome, output.String(), diagnostics.String())
|
||||
}
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"completion", shell, "--quiet"}, Streams{Out: &output, Err: &diagnostics})
|
||||
if outcome.Status != 0 || output.Len() != 0 {
|
||||
t.Fatalf("quiet %s outcome=%#v output=%q", shell, outcome, output.String())
|
||||
}
|
||||
}
|
||||
var help bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"--help"}, Streams{Out: &help})
|
||||
if outcome.Status != 0 || !strings.Contains(help.String(), "completion") || strings.Contains(help.String(), "__complete") {
|
||||
t.Fatalf("help outcome=%#v output=%q", outcome, help.String())
|
||||
}
|
||||
writeErr := errors.New("destination failed")
|
||||
outcome = app.Execute(context.Background(), []string{"completion", "bash"}, Streams{Out: completionErrorWriter{writeErr}, Err: io.Discard})
|
||||
if outcome.Status != 1 || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) {
|
||||
t.Fatalf("script delivery failure: %#v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
type completionErrorWriter struct{ err error }
|
||||
|
||||
func (w completionErrorWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||
|
||||
func TestCompletionInstallationCollisionsAndRollback(t *testing.T) {
|
||||
run := NewCommand(CommandSpec[struct{}]{Name: "run", Run: func(Invocation, struct{}) Outcome { return Success() }})
|
||||
query := NewCommand(CommandSpec[struct{}]{Name: "__complete", Run: func(Invocation, struct{}) Outcome { return Success() }})
|
||||
for _, declarations := range [][]Declaration{
|
||||
{Completion("app"), query},
|
||||
{query, Completion("app")},
|
||||
{Completion("app"), run, Alias("__complete nested", "run")},
|
||||
{run, Alias("__complete nested", "run"), Completion("app")},
|
||||
{Completion("app"), Group("completion", "collision")},
|
||||
{Group("outer", "", Completion("app"))},
|
||||
{Completion("dir/app")},
|
||||
{Completion("two words")},
|
||||
} {
|
||||
if _, err := New(Identity{Name: "example"}, declarations...); err == nil {
|
||||
t.Fatalf("accepted conflicting declarations: %#v", declarations)
|
||||
}
|
||||
}
|
||||
app, err := New(Identity{Name: "example"}, run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := app.routeSnapshot()
|
||||
if err := app.Register(Completion("app"), query); err == nil || app.routeSnapshot() != before {
|
||||
t.Fatalf("failed batch changed snapshot: %v", err)
|
||||
}
|
||||
if err := app.Register(Completion("app")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if app.routeSnapshot() == before || !app.routeSnapshot().completion {
|
||||
t.Fatal("late completion installation did not refresh snapshot")
|
||||
}
|
||||
if err := app.Register(query); err == nil {
|
||||
t.Fatal("query command accepted after completion installation")
|
||||
}
|
||||
if err := app.Register(Alias("__complete nested", "run")); err == nil {
|
||||
t.Fatal("query alias accepted after completion installation")
|
||||
}
|
||||
|
||||
withoutCompletion, err := New(Identity{Name: "example"}, query)
|
||||
if err != nil || withoutCompletion.routeSnapshot().completion {
|
||||
t.Fatalf("ordinary query-named command: %v", err)
|
||||
}
|
||||
if outcome := withoutCompletion.Execute(context.Background(), []string{"__complete"}, Streams{}); outcome.Status != 0 {
|
||||
t.Fatalf("ordinary command outcome: %#v", outcome)
|
||||
}
|
||||
if err := withoutCompletion.Register(Completion("app")); err == nil || withoutCompletion.routeSnapshot().completion {
|
||||
t.Fatal("completion accepted an existing query command")
|
||||
}
|
||||
aliased, err := New(Identity{Name: "example"}, run, Alias("__complete nested", "run"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := aliased.Register(Completion("app")); err == nil || aliased.routeSnapshot().completion {
|
||||
t.Fatal("completion accepted an existing query alias")
|
||||
}
|
||||
}
|
||||
53
completion_query.go
Normal file
53
completion_query.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
)
|
||||
|
||||
func completeRequest(snapshot *routingSnapshot, words []string) (commandmeta.CompletionResult, error) {
|
||||
if len(words) == 0 {
|
||||
return commandmeta.CompletionResult{}, errors.New("completion request has no current word")
|
||||
}
|
||||
context := resolveCompletionContext(snapshot, words[:len(words)-1], words[len(words)-1])
|
||||
target := completionTargetFor(snapshot, context)
|
||||
result := commandmeta.CompletionResult{Prefix: target.prefix}
|
||||
switch target.kind {
|
||||
case completionTargetCommands, completionTargetFlags:
|
||||
result.Kind = commandmeta.CompletionChoices
|
||||
result.Choices = target.choices
|
||||
case completionTargetValue, completionTargetOperand:
|
||||
result.Kind = target.hint.Kind
|
||||
if target.hint.Kind == commandmeta.CompletionChoices {
|
||||
for _, choice := range target.hint.Choices {
|
||||
if strings.HasPrefix(choice.Value, target.valuePrefix) {
|
||||
result.Choices = append(result.Choices, choice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (current *execution) complete(words []string) Outcome {
|
||||
result, err := completeRequest(current.snapshot, words)
|
||||
var reply string
|
||||
if err == nil {
|
||||
reply, err = internalcobra.CompletionReply(result)
|
||||
}
|
||||
if err != nil {
|
||||
reply = internalcobra.CompletionErrorReply()
|
||||
}
|
||||
_ = current.reporter.RenderOutput(func(writer io.Writer) error {
|
||||
_, writeErr := io.WriteString(writer, reply)
|
||||
return writeErr
|
||||
})
|
||||
if err != nil {
|
||||
return current.reporter.finalize(Failure("Could not complete the shell request.").WithCause(err), nil)
|
||||
}
|
||||
return current.reporter.finalize(Success(), nil)
|
||||
}
|
||||
121
completion_query_test.go
Normal file
121
completion_query_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
)
|
||||
|
||||
func TestCompletionQueryDispatch(t *testing.T) {
|
||||
var factories, converters, handlers int
|
||||
command := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "run", Summary: "Run a command.",
|
||||
NewInput: func() struct{} { factories++; return struct{}{} },
|
||||
Flags: []Flag[struct{}]{
|
||||
ValueFlag(FlagMeta{Name: "color", Short: "c", Completion: CompleteChoices(CompletionChoice{Value: "blue", Description: "cool\ncolor"}, CompletionChoice{Value: "green"}, CompletionChoice{Value: "foo=bar"})}, (*string)(nil),
|
||||
func(value string) (string, error) { converters++; return value, nil }, func(*struct{}, Supplied[string]) {}),
|
||||
ValueFlag(FlagMeta{Name: "file", Short: "f", Completion: CompleteFiles()}, (*string)(nil),
|
||||
func(value string) (string, error) { converters++; return value, nil }, func(*struct{}, Supplied[string]) {}),
|
||||
},
|
||||
Run: func(Invocation, struct{}) Outcome { handlers++; return Success() },
|
||||
})
|
||||
app, err := New(Identity{Name: "example"}, command, Completion("example"), Alias("shortcut", "run"), FlagAliasFor("run", "--colour", "--color"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
emptyReply, err := internalcobra.CompletionReply(commandmeta.CompletionResult{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
empty bool
|
||||
}{
|
||||
{"empty word", []string{"__complete", ""}, "run\tRun a command.", false},
|
||||
{"alias value", []string{"__complete", "shortcut", "--colour=bl"}, "blue\tcool color\n", false},
|
||||
{"quiet is data", []string{"__complete", "run", "--quiet", "--color=bl"}, "blue\tcool color\n", false},
|
||||
{"debug is data", []string{"__complete", "run", "--debug", "--color=bl"}, "blue\tcool color\n", false},
|
||||
{"short attached choice", []string{"__complete", "run", "-cfoo=ba"}, "foo=bar\n", false},
|
||||
{"unknown route", []string{"__complete", "missing", ""}, emptyReply, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var output, diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), tc.args, Streams{Out: &output, Err: &diagnostic})
|
||||
if outcome.Status != 0 || diagnostic.Len() != 0 || (tc.empty && output.String() != tc.want) || (!tc.empty && !strings.Contains(output.String(), tc.want)) {
|
||||
t.Fatalf("outcome=%#v output=%q diagnostics=%q", outcome, output.String(), diagnostic.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
fileReply, err := internalcobra.CompletionReply(commandmeta.CompletionResult{Kind: commandmeta.CompletionFiles, Prefix: "-f"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fileOutput bytes.Buffer
|
||||
fileOutcome := app.Execute(context.Background(), []string{"__complete", "run", "-f./part"}, Streams{Out: &fileOutput})
|
||||
if fileOutcome.Status != 0 || fileOutput.String() != fileReply {
|
||||
t.Fatalf("file query outcome=%#v reply=%q", fileOutcome, fileOutput.String())
|
||||
}
|
||||
if factories != 0 || converters != 0 || handlers != 0 {
|
||||
t.Fatalf("query invoked application callbacks: %d %d %d", factories, converters, handlers)
|
||||
}
|
||||
if err := app.Register(simpleRunnable("later")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if outcome := app.Execute(context.Background(), []string{"__complete", "lat"}, Streams{Out: &output}); outcome.Status != 0 || !strings.Contains(output.String(), "later") {
|
||||
t.Fatalf("later command outcome=%#v reply=%q", outcome, output.String())
|
||||
}
|
||||
output.Reset()
|
||||
if outcome := app.Execute(context.Background(), []string{"__complete", "later", "--qui"}, Streams{Out: &output}); outcome.Status != 0 || !strings.Contains(output.String(), "--quiet") {
|
||||
t.Fatalf("later command flag outcome=%#v reply=%q", outcome, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionQueryErrorsAndDelivery(t *testing.T) {
|
||||
app, err := New(Identity{Name: "example"}, Completion("example"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"__complete"}, Streams{Out: &output, Err: &diagnostics})
|
||||
if outcome.Status != 1 || outcome.Cause == nil || output.String() != internalcobra.CompletionErrorReply() || diagnostics.Len() == 0 {
|
||||
t.Fatalf("malformed query outcome=%#v reply=%q diagnostics=%q", outcome, output.String(), diagnostics.String())
|
||||
}
|
||||
writeErr := errors.New("reply destination failed")
|
||||
writer := &completionCountingWriter{err: writeErr}
|
||||
outcome = app.Execute(context.Background(), []string{"__complete", ""}, Streams{Out: writer, Err: io.Discard})
|
||||
if outcome.Status != 1 || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || writer.calls != 1 {
|
||||
t.Fatalf("reply write failure: %#v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
type completionCountingWriter struct {
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (writer *completionCountingWriter) Write([]byte) (int, error) {
|
||||
writer.calls++
|
||||
return 0, writer.err
|
||||
}
|
||||
|
||||
func TestCompletionQueryDiscovery(t *testing.T) {
|
||||
app, err := New(Identity{Name: "example"}, Completion("example"),
|
||||
Group("workspace", "", simpleRunnable("inspect")), Alias("legacy inspect", "workspace inspect"), FlagAlias("--silent", "--quiet"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"__complete", "legacy", "--"}, Streams{Out: &output})
|
||||
if outcome.Status != 0 || !strings.Contains(output.String(), "--quiet") || strings.Contains(output.String(), "inspect") || strings.Contains(output.String(), "--silent") {
|
||||
t.Fatalf("discovery outcome=%#v reply=%q", outcome, output.String())
|
||||
}
|
||||
}
|
||||
93
completion_shell_test.go
Normal file
93
completion_shell_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompletionConsumerInShells(t *testing.T) {
|
||||
if os.Getenv("COMMANDKIT_COMPLETION_SHELLS") != "1" {
|
||||
t.Skip("set COMMANDKIT_COMPLETION_SHELLS=1 to run shell completion checks")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
app := filepath.Join(dir, "probe")
|
||||
build := exec.Command("go", "build", "-o", app, "./testdata/completion")
|
||||
if output, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build completion fixture: %v\n%s", err, output)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(dir, "partial dir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "partial file"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := []struct {
|
||||
name, line string
|
||||
want []string
|
||||
directory bool
|
||||
}{
|
||||
{"canonical command", "probe ru", []string{"run"}, false},
|
||||
{"entered alias", "probe shortcut --format=ya", []string{"shortcut", "--format=yaml"}, false},
|
||||
{"choice", "probe run --format=te", []string{"run", "--format=text"}, false},
|
||||
{"no file fallback", `probe run --format=./partial\ f`, []string{"run", "--format=./partial f"}, false},
|
||||
{"separated file", `probe run --config ./partial\ f`, []string{"run", "--config", "./partial file"}, false},
|
||||
{"attached file", `probe run --config=./partial\ f`, []string{"run", "--config=./partial file"}, false},
|
||||
{"short attached file", `probe run -c./partial\ f`, []string{"run", "-c./partial file"}, false},
|
||||
{"short cluster equals file", `probe run -sc=./partial\ f`, []string{"run", "-sc=./partial file"}, false},
|
||||
{"separated directory", "probe run --directory ./partial", []string{"run", "--directory", "./partial dir"}, true},
|
||||
{"attached directory", "probe run --directory=./partial", []string{"run", "--directory=./partial dir"}, true},
|
||||
{"empty operand", "probe run ", []string{"run", "alpha"}, false},
|
||||
{"leading dash", "probe run --for", []string{"run", "--format"}, false},
|
||||
}
|
||||
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||
generator := exec.Command(app, "completion", shell)
|
||||
script, err := generator.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("generate %s script: %v\n%s", shell, err, script)
|
||||
}
|
||||
scriptPath := filepath.Join(dir, shell+".completion")
|
||||
if err := os.WriteFile(scriptPath, script, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(shell+"/"+tc.name, func(t *testing.T) {
|
||||
log := filepath.Join(dir, "log")
|
||||
if err := os.WriteFile(log, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, tc.line)
|
||||
terminal.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_LOG="+log, "PROBE_DIR="+dir)
|
||||
if output, err := terminal.CombinedOutput(); err != nil {
|
||||
t.Fatalf("shell completion: %v\n%s", err, output)
|
||||
}
|
||||
content, err := os.ReadFile(log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
if err := json.Unmarshal(content, &got); err != nil {
|
||||
t.Fatalf("fixture output %q: %v", content, err)
|
||||
}
|
||||
if tc.directory && len(got) > 0 {
|
||||
got[len(got)-1] = strings.TrimSuffix(got[len(got)-1], "/")
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("edited arguments=%q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run(shell+"/descriptions", func(t *testing.T) {
|
||||
terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, "probe run --format=")
|
||||
terminal.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_LOG="+filepath.Join(dir, "log"), "PROBE_DIR="+dir, "PROBE_LIST=1")
|
||||
output, err := terminal.CombinedOutput()
|
||||
if err != nil || !strings.Contains(string(output), "Readable text") || !strings.Contains(string(output), "YAML output") {
|
||||
t.Fatalf("completion descriptions: %v\n%s", err, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
81
completion_test.go
Normal file
81
completion_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
)
|
||||
|
||||
// Existing consumer code can compare these metadata values or use them as keys.
|
||||
var (
|
||||
_ map[FlagMeta]struct{}
|
||||
_ map[ArgumentMeta]struct{}
|
||||
_ = (FlagMeta{}) == (FlagMeta{})
|
||||
_ = (ArgumentMeta{}) == (ArgumentMeta{})
|
||||
)
|
||||
|
||||
func TestCompletionHintsFollowCapturedBindings(t *testing.T) {
|
||||
type inner struct{ values Supplied[[]string] }
|
||||
type outer struct{ flags FlagValues[inner] }
|
||||
choices := []CompletionChoice{{Value: "alpha", Description: "first"}}
|
||||
hint := CompleteChoices(choices...)
|
||||
choices[0] = CompletionChoice{Value: "changed"}
|
||||
parse := func(value string) (string, error) { return value, nil }
|
||||
repeated := RepeatFlag(FlagMeta{Name: "tag", Completion: hint}, nil, parse,
|
||||
func(input *inner, value Supplied[[]string]) { input.values = value })
|
||||
captured := CaptureFlags([]Flag[inner]{repeated}, func(input *outer) *FlagValues[inner] { return &input.flags })
|
||||
command := NewCommand(CommandSpec[outer]{
|
||||
Name: "run",
|
||||
Flags: append(captured,
|
||||
ValueFlag(FlagMeta{Name: "file", Completion: CompleteFiles()}, (*string)(nil), parse, func(*outer, Supplied[string]) {}),
|
||||
BoolFlag(FlagMeta{Name: "enabled"}, false, func(*outer, Supplied[bool]) {}),
|
||||
ValueFlag(FlagMeta{Name: "empty", Completion: CompleteChoices()}, (*string)(nil), parse, func(*outer, Supplied[string]) {})),
|
||||
Arguments: []Argument[outer]{
|
||||
ValueArgument(ArgumentMeta{Name: "directory", Completion: CompleteDirectories()}, false, parse, func(*outer, Supplied[string]) {}),
|
||||
RepeatArgument(ArgumentMeta{Name: "values", Completion: hint}, 0, -1, parse, func(*outer, Supplied[[]string]) {}),
|
||||
},
|
||||
})
|
||||
flags := command.record.payload.bridgeFlags()
|
||||
arguments := command.record.payload.bridgeArguments()
|
||||
want := commandmeta.CompletionHint{Kind: commandmeta.CompletionChoices, Choices: []commandmeta.CompletionChoice{{Value: "alpha", Description: "first"}}}
|
||||
if !flags[0].Repeated || !reflect.DeepEqual(flags[0].Completion, want) || !reflect.DeepEqual(arguments[1].Completion, want) {
|
||||
t.Fatalf("captured hints: flags=%#v arguments=%#v", flags, arguments)
|
||||
}
|
||||
if flags[1].Completion.Kind != commandmeta.CompletionFiles || flags[2].Completion.Kind != commandmeta.CompletionNone || flags[3].Completion.Kind != commandmeta.CompletionChoices || len(flags[3].Completion.Choices) != 0 || arguments[0].Completion.Kind != commandmeta.CompletionDirectories {
|
||||
t.Fatalf("hint strategies: flags=%#v arguments=%#v", flags, arguments)
|
||||
}
|
||||
flags[0].Completion.Choices[0].Value = "mutated"
|
||||
if got := command.record.payload.bridgeFlags()[0].Completion; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("bridged choices changed captured hint: %#v", got)
|
||||
}
|
||||
if got := bridgeCompletion(NoCompletion()); got.Kind != commandmeta.CompletionNone || !reflect.DeepEqual(got, bridgeCompletion(CompletionHint{})) {
|
||||
t.Fatalf("no completion: %#v", got)
|
||||
}
|
||||
for _, flag := range append(commandmeta.ReportingFlags(), commandmeta.HelpFlag()) {
|
||||
if !reflect.DeepEqual(flag.Completion.Choices, []commandmeta.CompletionChoice{{Value: "true"}, {Value: "false"}}) {
|
||||
t.Fatalf("reporting flag choices: %#v", flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionChoicesDoNotRestrictExecution(t *testing.T) {
|
||||
type input struct{ mode Supplied[string] }
|
||||
var received input
|
||||
command := NewCommand(CommandSpec[input]{
|
||||
Name: "run",
|
||||
Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "mode", Completion: CompleteChoices(CompletionChoice{Value: "suggested"})}, (*string)(nil),
|
||||
func(value string) (string, error) { return value, nil },
|
||||
func(i *input, value Supplied[string]) { i.mode = value })},
|
||||
Run: func(_ Invocation, value input) Outcome { received = value; return Success() },
|
||||
})
|
||||
app, err := New(Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outcome := app.Execute(context.Background(), []string{"run", "--mode=other"}, Streams{})
|
||||
if outcome.Status != 0 || !received.mode.Set || received.mode.Value != "other" {
|
||||
t.Fatalf("outcome=%#v input=%#v", outcome, received)
|
||||
}
|
||||
}
|
||||
118
debug_events_test.go
Normal file
118
debug_events_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDebugEventsFollowModeAndFinalizationOrder(t *testing.T) {
|
||||
cause := errors.New("private operation cause")
|
||||
command := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(inv Invocation, _ struct{}) Outcome {
|
||||
if err := inv.Reporter().AtEnd().Info("deferred summary"); err != nil {
|
||||
t.Errorf("queue summary: %v", err)
|
||||
}
|
||||
return Failure("operation failed").WithCause(cause)
|
||||
},
|
||||
})
|
||||
app, err := New(Identity{Name: "Example"}, command, Alias("shortcut", "run"), FlagAlias("--trace", "--debug"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"shortcut", "--verbose", "--trace"}, Streams{Err: &diagnostics})
|
||||
if outcome.Kind != OutcomeOperational || !errors.Is(outcome.Cause, cause) {
|
||||
t.Fatalf("outcome = %#v", outcome)
|
||||
}
|
||||
got := diagnostics.String()
|
||||
for _, fragment := range []string{
|
||||
"resolved command alias \"shortcut\" to \"run\"",
|
||||
"selected debug reporting mode using --trace (alias for --debug)",
|
||||
"deferred summary",
|
||||
"handler completed for \"run\" with operational outcome and status 1",
|
||||
"ERROR: operation failed",
|
||||
} {
|
||||
if !strings.Contains(got, fragment) {
|
||||
t.Errorf("diagnostics %q omit %q", got, fragment)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, cause.Error()) {
|
||||
t.Fatalf("diagnostics exposed the operation cause: %q", got)
|
||||
}
|
||||
ordered := []string{
|
||||
"resolved command alias",
|
||||
"selected debug reporting mode",
|
||||
"deferred summary",
|
||||
"handler completed",
|
||||
"ERROR: operation failed",
|
||||
}
|
||||
previous := -1
|
||||
for _, fragment := range ordered {
|
||||
index := strings.Index(got, fragment)
|
||||
if index <= previous {
|
||||
t.Fatalf("diagnostic event %q out of order in %q", fragment, got)
|
||||
}
|
||||
previous = index
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugEventsRespectFinalQuietSelection(t *testing.T) {
|
||||
command := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||
})
|
||||
app, err := New(Identity{Name: "Example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"run", "--debug", "--quiet"}, Streams{Err: &diagnostics})
|
||||
if outcome.Kind != OutcomeSuccess || diagnostics.Len() != 0 {
|
||||
t.Fatalf("outcome = %#v, diagnostics = %q", outcome, diagnostics.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugCompletionRequiresAHandler(t *testing.T) {
|
||||
command := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||
})
|
||||
app, err := New(Identity{Name: "Example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var diagnostics bytes.Buffer
|
||||
app.Execute(context.Background(), []string{"unknown", "--debug"}, Streams{Err: &diagnostics})
|
||||
if strings.Contains(diagnostics.String(), "handler completed") {
|
||||
t.Fatalf("no-handler diagnostics include completion: %q", diagnostics.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugDeliveryFailureAffectsInvocationOutcome(t *testing.T) {
|
||||
ran := false
|
||||
command := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(Invocation, struct{}) Outcome {
|
||||
ran = true
|
||||
return Success()
|
||||
},
|
||||
})
|
||||
app, err := New(Identity{Name: "Example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeErr := errors.New("debug destination failed")
|
||||
outcome := app.Execute(context.Background(), []string{"run", "--debug"}, Streams{
|
||||
Err: executeReportingFailureWriter{err: writeErr},
|
||||
})
|
||||
if !ran {
|
||||
t.Fatal("debug delivery failure prevented handler execution")
|
||||
}
|
||||
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Presentation.Diagnostics.WriteError, writeErr) {
|
||||
t.Fatalf("outcome = %#v", outcome)
|
||||
}
|
||||
}
|
||||
241
declaration_external_test.go
Normal file
241
declaration_external_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package commandkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
)
|
||||
|
||||
func TestPublicInstallationAcceptsMixedDeclarations(t *testing.T) {
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "show",
|
||||
Summary: "Show a value.",
|
||||
Flags: []commandkit.Flag[struct{}]{
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*struct{}, commandkit.Supplied[string]) {}),
|
||||
},
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() },
|
||||
})
|
||||
declarations := []commandkit.Declaration{
|
||||
command,
|
||||
commandkit.Alias("inspect", "show"),
|
||||
commandkit.FlagAlias("--fmt", "--format"),
|
||||
commandkit.FlagAliasFor("show", "-f", "--format"),
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "Example"}, declarations...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if app == nil {
|
||||
t.Fatal("New() returned a nil app")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicAliasesPreserveCommandsAcrossDeclarationOrders(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
rulesFirst bool
|
||||
}{
|
||||
{name: "commands first"},
|
||||
{name: "routing rules first", rulesFirst: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
type runInput struct{ words commandkit.Supplied[[]string] }
|
||||
type inspectInput struct{ id commandkit.Supplied[string] }
|
||||
type showInput struct {
|
||||
profile commandkit.Supplied[string]
|
||||
format commandkit.Supplied[string]
|
||||
}
|
||||
var runParses, inspectParses, optionalParses int
|
||||
var runValues [][]string
|
||||
var inspectValues []string
|
||||
var showValues []string
|
||||
run := commandkit.NewCommand(commandkit.CommandSpec[runInput]{
|
||||
Name: "run",
|
||||
Arguments: []commandkit.Argument[runInput]{commandkit.RepeatArgument(commandkit.ArgumentMeta{Name: "words"}, 0, -1,
|
||||
func(value string) (string, error) { runParses++; return value, nil },
|
||||
func(target *runInput, value commandkit.Supplied[[]string]) { target.words = value },
|
||||
)},
|
||||
Run: func(_ commandkit.Invocation, value runInput) commandkit.Outcome {
|
||||
runValues = append(runValues, append([]string(nil), value.words.Value...))
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
inspect := commandkit.NewCommand(commandkit.CommandSpec[inspectInput]{
|
||||
Name: "inspect",
|
||||
Arguments: []commandkit.Argument[inspectInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "id"}, true,
|
||||
func(value string) (string, error) { inspectParses++; return value, nil },
|
||||
func(target *inspectInput, value commandkit.Supplied[string]) { target.id = value },
|
||||
)},
|
||||
Run: func(_ commandkit.Invocation, value inspectInput) commandkit.Outcome {
|
||||
inspectValues = append(inspectValues, value.id.Value)
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
show := commandkit.NewCommand(commandkit.CommandSpec[showInput]{
|
||||
Name: "show",
|
||||
Arguments: []commandkit.Argument[showInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "profile"}, false,
|
||||
func(value string) (string, error) { optionalParses++; return value, nil },
|
||||
func(target *showInput, value commandkit.Supplied[string]) { target.profile = value },
|
||||
)},
|
||||
Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "format"}, (*string)(nil),
|
||||
func(value string) (string, error) { return value, nil },
|
||||
func(target *showInput, value commandkit.Supplied[string]) { target.format = value },
|
||||
)},
|
||||
Run: func(_ commandkit.Invocation, value showInput) commandkit.Outcome {
|
||||
showValues = append(showValues, value.profile.Value+":"+value.format.Value)
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
commands := []commandkit.Declaration{run, inspect, show}
|
||||
rules := []commandkit.Declaration{
|
||||
commandkit.Alias("r", "run"),
|
||||
commandkit.Alias("r full", "inspect"),
|
||||
commandkit.Alias("inspect-shortcut", "inspect"),
|
||||
commandkit.Alias("i", "inspect-shortcut"),
|
||||
commandkit.FlagAlias("--fmt", "--format"),
|
||||
}
|
||||
declarations := append([]commandkit.Declaration(nil), commands...)
|
||||
if test.rulesFirst {
|
||||
declarations = append(append([]commandkit.Declaration(nil), rules...), commands...)
|
||||
} else {
|
||||
declarations = append(declarations, rules...)
|
||||
}
|
||||
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "Example"}, declarations...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runParses != 0 || inspectParses != 0 || optionalParses != 0 {
|
||||
t.Fatalf("installation invoked operand converters: %d/%d/%d", runParses, inspectParses, optionalParses)
|
||||
}
|
||||
for _, args := range [][]string{{"run", "full"}, {"r", "full", "item-1"}, {"i", "item-2"}, {"show", "profile", "--fmt=json"}} {
|
||||
if outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("Execute(%q) = %#v, want success", args, outcome)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(runValues, [][]string{{"full"}}) {
|
||||
t.Fatalf("canonical run operands = %#v, want [[full]]", runValues)
|
||||
}
|
||||
if !reflect.DeepEqual(inspectValues, []string{"item-1", "item-2"}) {
|
||||
t.Fatalf("routed required operands = %#v", inspectValues)
|
||||
}
|
||||
if !reflect.DeepEqual(showValues, []string{"profile:json"}) || optionalParses != 1 {
|
||||
t.Fatalf("optional operand/flag values = %#v, converter calls %d", showValues, optionalParses)
|
||||
}
|
||||
|
||||
parsesBeforeReuse := [3]int{runParses, inspectParses, optionalParses}
|
||||
secondApp, err := commandkit.New(commandkit.Identity{Name: "Second"}, declarations...)
|
||||
if err != nil {
|
||||
t.Fatalf("reusing declarations: %v", err)
|
||||
}
|
||||
if got := [3]int{runParses, inspectParses, optionalParses}; got != parsesBeforeReuse {
|
||||
t.Fatalf("reinstalling declarations invoked operand converters: %v -> %v", parsesBeforeReuse, got)
|
||||
}
|
||||
if outcome := secondApp.Execute(context.Background(), []string{"run", "again"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("reused declaration execution = %#v", outcome)
|
||||
}
|
||||
if !reflect.DeepEqual(runValues, [][]string{{"full"}, {"again"}}) {
|
||||
t.Fatalf("reused command declaration values = %#v", runValues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRootGroupRegistrationAndFailedBatchRecovery(t *testing.T) {
|
||||
type showInput struct{ format commandkit.Supplied[string] }
|
||||
var formatParses, lateRuns int
|
||||
var formats []string
|
||||
show := commandkit.NewCommand(commandkit.CommandSpec[showInput]{
|
||||
Name: "show",
|
||||
Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "format"}, (*string)(nil),
|
||||
func(value string) (string, error) { formatParses++; return value, nil },
|
||||
func(target *showInput, value commandkit.Supplied[string]) { target.format = value },
|
||||
)},
|
||||
Run: func(_ commandkit.Invocation, value showInput) commandkit.Outcome {
|
||||
formats = append(formats, value.format.Value)
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
family := commandkit.RootGroup("config", "Configuration", show)
|
||||
constructionFailure := errors.New("family construction failed")
|
||||
if _, err := commandkit.New(commandkit.Identity{Name: "Broken"}, commandkit.WithError(family, fmt.Errorf("adapter: %w", constructionFailure))); !errors.Is(err, constructionFailure) {
|
||||
t.Fatalf("New() error = %v, want retained family construction failure", err)
|
||||
}
|
||||
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "Example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var before bytes.Buffer
|
||||
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &before, Err: io.Discard})
|
||||
if strings.Contains(before.String(), "config") {
|
||||
t.Fatalf("root help showed an unregistered family: %q", before.String())
|
||||
}
|
||||
if err := app.Register(family,
|
||||
commandkit.Alias("cfg-show", "config show"),
|
||||
commandkit.Alias("cs", "cfg-show"),
|
||||
commandkit.FlagAlias("--fmt", "--format"),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if formatParses != 0 {
|
||||
t.Fatalf("registration invoked flag converter %d times", formatParses)
|
||||
}
|
||||
var after bytes.Buffer
|
||||
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &after, Err: io.Discard})
|
||||
for _, fragment := range []string{"config", "Aliases:", "cfg-show", "cs"} {
|
||||
if !strings.Contains(after.String(), fragment) {
|
||||
t.Errorf("updated root help %q does not contain %q", after.String(), fragment)
|
||||
}
|
||||
}
|
||||
if outcome := app.Execute(context.Background(), []string{"cs", "--fmt=yaml"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("registered alias execution = %#v", outcome)
|
||||
}
|
||||
if !reflect.DeepEqual(formats, []string{"yaml"}) || formatParses != 1 {
|
||||
t.Fatalf("registered family values = %#v, converter calls %d", formats, formatParses)
|
||||
}
|
||||
if err := app.Register(commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "cfg-show", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})); err == nil {
|
||||
t.Fatal("registration accepted a canonical command that conflicts with an existing alias")
|
||||
}
|
||||
var afterConflict bytes.Buffer
|
||||
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &afterConflict, Err: io.Discard})
|
||||
if !strings.Contains(afterConflict.String(), "cfg-show") {
|
||||
t.Fatalf("failed relationship validation changed registered aliases: %q", afterConflict.String())
|
||||
}
|
||||
|
||||
late := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "late", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
lateRuns++
|
||||
return commandkit.Success()
|
||||
}})
|
||||
registrationFailure := errors.New("retained registration failure")
|
||||
err = app.Register(commandkit.WithError(late, fmt.Errorf("family: %w", registrationFailure)))
|
||||
if !errors.Is(err, registrationFailure) {
|
||||
t.Fatalf("Register() error = %v, want retained failure", err)
|
||||
}
|
||||
if err := app.Register(late, commandkit.Alias("late-shortcut", "missing")); err == nil {
|
||||
t.Fatal("registration accepted a batch whose alias target is absent")
|
||||
}
|
||||
var afterFailure bytes.Buffer
|
||||
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &afterFailure, Err: io.Discard})
|
||||
if strings.Contains(afterFailure.String(), "late-shortcut") || strings.Contains(afterFailure.String(), " late ") {
|
||||
t.Fatalf("failed batch changed root help: %q", afterFailure.String())
|
||||
}
|
||||
if err := app.Register(late, commandkit.Alias("late-shortcut", "late")); err != nil {
|
||||
t.Fatalf("registration after failed batch: %v", err)
|
||||
}
|
||||
var recovered bytes.Buffer
|
||||
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &recovered, Err: io.Discard})
|
||||
if !strings.Contains(recovered.String(), "late-shortcut") {
|
||||
t.Fatalf("recovered root help omitted alias: %q", recovered.String())
|
||||
}
|
||||
if outcome := app.Execute(context.Background(), []string{"late-shortcut"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess || lateRuns != 1 {
|
||||
t.Fatalf("recovered alias execution = %#v, runs %d", outcome, lateRuns)
|
||||
}
|
||||
}
|
||||
7
doc.go
7
doc.go
@@ -1,5 +1,8 @@
|
||||
// Package commandkit provides the core instance-owned CLI dispatcher, typed
|
||||
// command declarations, input bindings, and invocation outcomes. Optional
|
||||
// integration modules provide ConfigKit and PromptKit-specific declarations;
|
||||
// command declarations, application-owned command and flag aliases, input
|
||||
// bindings, invocation-scoped reporting, and outcomes with separate operation
|
||||
// causes and presentation observations. New and App.Register accept canonical
|
||||
// Command declarations and RoutingRule declarations together. Optional
|
||||
// integration modules provide ConfigKit and PromptKit-specific commands;
|
||||
// importing those modules does not install their commands.
|
||||
package commandkit
|
||||
|
||||
68
docs/adr/0013-application-alias-routing.md
Normal file
68
docs/adr/0013-application-alias-routing.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# ADR 0013: Separate application alias routing from canonical command ownership
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Refines [ADR 0011](0011-captured-declarations-and-installation.md)
|
||||
by extending its shared installation boundary to application routing rules.
|
||||
Its canonical command ownership and declaration principles remain in force.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-19
|
||||
|
||||
## Context
|
||||
|
||||
Applications need alternate command paths and flag spellings that suit their
|
||||
operator vocabulary. These conveniences should reuse the behavior supplied by
|
||||
application commands and optional integrations. Canonical command families
|
||||
already establish ownership of handlers, namespaces, inputs, and invocation
|
||||
behavior.
|
||||
|
||||
A routing layer allows applications to choose alternate spellings while
|
||||
preserving those ownership boundaries. Commands and routing rules have distinct
|
||||
roles, and their relationships require validation during application assembly.
|
||||
|
||||
## Decision
|
||||
|
||||
CommandKit core owns alias declaration, resolution, and coordination with parsing
|
||||
and help. Applications choose their alias mappings. Alias infrastructure is
|
||||
available independently of optional integrations under the module boundaries
|
||||
established by [ADR 0010](0010-optional-integration-modules.md).
|
||||
|
||||
Aliases belong to an application routing layer that directs invocations to
|
||||
canonical commands and flags. Canonical definitions retain ownership of their
|
||||
handlers, inputs, and namespaces. An alternate path within an integration's
|
||||
namespace remains an application routing rule; the integration continues to own
|
||||
the canonical command family and its operations.
|
||||
|
||||
Canonical commands and routing rules are distinct declaration kinds installed
|
||||
through a common core assembly boundary. Installation validates their
|
||||
relationships together, preserves canonical definitions and namespace ownership,
|
||||
and applies the batch-atomicity and retained-error model of ADR 0011 to both
|
||||
layers. Declaration capture and reuse follow that existing model.
|
||||
|
||||
An invocation through an alias uses the canonical target's parsing, typed
|
||||
bindings, validation, and ordinary invocation lifecycle. Execution behavior
|
||||
remains with the canonical command and its owning application or integration.
|
||||
Core coordinates alternate routing and presentation through the same command
|
||||
infrastructure.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Alternative | Analysis |
|
||||
| --- | --- |
|
||||
| Install aliases as wrapper commands | Reuses the command tree for discovery, but introduces additional command nodes and handler delegation into namespace ownership and invocation lifetimes. |
|
||||
| Have applications rewrite arguments before dispatch | Gives applications direct control over shortcuts, but repeats routing infrastructure and separates alias handling from target validation, parsing, and help. |
|
||||
| Register aliases through a separate assembly API | Makes routing declarations explicit, but splits validation of commands and their alternate routes across installation boundaries. Distinct declaration kinds express the separation within one atomic batch. |
|
||||
|
||||
## Consequences
|
||||
|
||||
Applications can tailor operator vocabulary while reusing canonical behavior.
|
||||
Core maintains the relationship between routing rules and canonical definitions
|
||||
as declarations are installed. Optional integrations continue to supply their
|
||||
command families through the existing ownership model.
|
||||
|
||||
Routing, parsing, and help must coordinate across the two layers. Exact API
|
||||
shapes, resolution rules, and presentation choices belong in GoDoc and the
|
||||
[CLI reference](../cli.md) when implemented. Those details can evolve within
|
||||
this architectural separation.
|
||||
79
docs/adr/0014-invocation-reporting.md
Normal file
79
docs/adr/0014-invocation-reporting.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# ADR 0014: Share command-line presentation within each invocation
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Extends [ADR 0005](0005-invocation-outcomes.md) with shared reporting
|
||||
for each invocation. Its outcome classifications, standard status meanings,
|
||||
output channels, error-cause preservation, and application ownership of process
|
||||
exit remain in force.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-25
|
||||
|
||||
## Context
|
||||
|
||||
Applications need consistent control over command results, messages shown during
|
||||
a run, and messages shown at its end. Sharing that control reduces repeated
|
||||
output code across applications and integrations.
|
||||
|
||||
An application decides what an error means for its work. It may recover silently,
|
||||
show a warning, or end the invocation. CommandKit needs the chosen outcome and
|
||||
messages to present that decision to the operator.
|
||||
|
||||
## Decision
|
||||
|
||||
CommandKit core provides one reporting facility for each invocation. Application
|
||||
commands, integration commands, and core commands use it for results, immediate
|
||||
messages, and deferred messages. Core applies shared rules for visibility,
|
||||
formatting, and delivery, and completes reporting when the invocation ends.
|
||||
Callers choose the output destinations.
|
||||
|
||||
Applications decide how to respond to domain-specific errors and choose their
|
||||
outcomes and messages. CommandKit may provide shared helpers for carrying,
|
||||
inspecting, and presenting errors while leaving those decisions with the
|
||||
application.
|
||||
|
||||
Applications supply explicit outcomes and explanations for failures. They may
|
||||
also supply a Go error cause. CommandKit keeps that error unchanged for the caller
|
||||
to inspect. Supplying a cause alone does not select an outcome or generate an
|
||||
operator-facing message. CommandKit remains responsible for handling its own
|
||||
errors, including routing, parsing, and presentation failures.
|
||||
|
||||
Integrations adapt upstream results and diagnostics into command outcomes and
|
||||
messages. The upstream libraries retain ownership of their meanings and rules
|
||||
for safe disclosure.
|
||||
|
||||
An Error message explains a failed invocation with a non-zero status. Its
|
||||
explanation is presented at the end of the invocation, after normal reporting
|
||||
finishes. The returned outcome carries that explanation. The application entry
|
||||
point decides how to exit the process.
|
||||
|
||||
CommandKit reports rendering and delivery failures separately from the command's
|
||||
explanation. A presentation failure produces an operational failure for standard
|
||||
outcomes under ADR 0005. Application-defined domain classifications are preserved,
|
||||
along with an already non-zero domain status. A successful domain outcome receives
|
||||
a non-zero status if presentation fails. The original explanation and any supplied
|
||||
cause remain available to the caller. Intentionally suppressed output is not a
|
||||
delivery failure.
|
||||
|
||||
Reporting keeps the state needed to deliver messages and return the invocation
|
||||
outcome. Logging and message history remain application concerns.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Alternative | Analysis |
|
||||
| --- | --- |
|
||||
| Let each application manage all presentation | Gives applications full control, but repeats visibility and delivery code across commands and integrations. |
|
||||
| Have core decide whether domain-specific errors require recovery, a warning, or failure | Reduces some caller code, but gives core decisions that depend on application behavior. |
|
||||
| Use separate facilities for results, immediate messages, and deferred messages | Lets each facility serve one purpose, but spreads visibility and final delivery across several paths. |
|
||||
|
||||
## Consequences
|
||||
|
||||
Applications and integrations share presentation behavior while choosing their
|
||||
own results and messages. Core handles reporting failures consistently and keeps
|
||||
them visible in the returned outcome.
|
||||
|
||||
GoDoc and the [CLI reference](../cli.md) define the implemented API, flags,
|
||||
message formats, and delivery details. Those details can evolve within this
|
||||
ownership model.
|
||||
217
docs/cli.md
217
docs/cli.md
@@ -66,6 +66,130 @@ version: dev
|
||||
revision: ""
|
||||
```
|
||||
|
||||
## Shell completion
|
||||
|
||||
Applications may install shell completion explicitly. When installed, generate
|
||||
and load the script for the executable name chosen by the application:
|
||||
|
||||
```sh
|
||||
# Bash, after initializing bash-completion
|
||||
source <(app completion bash)
|
||||
# Zsh, after initializing its completion system with compinit
|
||||
source <(app completion zsh)
|
||||
# Fish
|
||||
app completion fish | source
|
||||
```
|
||||
|
||||
Bash requires the `bash-completion` package and its helpers in the current
|
||||
shell. If your startup files do not initialize it, source the package's
|
||||
initialization file before loading the generated script. For example, on
|
||||
systems that install it at the following path:
|
||||
|
||||
```sh
|
||||
source /usr/share/bash-completion/bash_completion
|
||||
source <(app completion bash)
|
||||
```
|
||||
|
||||
Use the path provided by your Bash completion installation.
|
||||
|
||||
The application selects the executable basename at installation; use that same
|
||||
name to invoke the generated script. Completion offers canonical command and
|
||||
flag names, static values, and file or directory paths where declarations
|
||||
provide hints. A fully entered alias is resolved to its canonical command;
|
||||
alias spellings and partial alias paths are not suggested. Completion queries
|
||||
do not invoke input factories, providers, or the selected command; application
|
||||
startup remains application-owned. See the
|
||||
[consumer guide](consumers/guide.md#shell-completion) for installation and
|
||||
hint declarations. Script generation follows the [reporting policy](#reporting),
|
||||
including quiet mode.
|
||||
|
||||
## Reporting
|
||||
|
||||
CommandKit applies one shared reporting policy to application handlers,
|
||||
integrations, help, version, and CommandKit's own invocation diagnostics. The
|
||||
caller passes result and diagnostic writers to `App.Execute`; substantive
|
||||
results go to `Streams.Out`, and categorized messages, terminal explanations,
|
||||
and usage guidance go to `Streams.Err`.
|
||||
|
||||
### Modes and visibility
|
||||
|
||||
Each invocation starts in ordinary mode. `--quiet`, `--verbose`, and `--debug`
|
||||
are mutually exclusive; the last enabled occurrence selects the mode. They
|
||||
accept presence or an explicit boolean value. A `false` occurrence clears the
|
||||
selected mode only when it names the current mode, returning to ordinary mode;
|
||||
it does not restore an earlier selection. Invalid boolean values are usage
|
||||
errors, even if a later flag would select another mode.
|
||||
|
||||
| Category | `--quiet` | No mode flag | `--verbose` | `--debug` |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Substantive result | Suppressed | Shown | Shown | Shown |
|
||||
| Terminal failure | Shown | Shown | Shown | Shown |
|
||||
| Warning | Suppressed | Suppressed | Shown | Shown |
|
||||
| Info | Suppressed | Suppressed | Shown | Shown |
|
||||
| Debug | Suppressed | Suppressed | Suppressed | Shown |
|
||||
| Progress, unless enabled explicitly | Suppressed | Suppressed | Suppressed | Shown |
|
||||
|
||||
`--progress` independently enables Progress; `--progress=false` disables it.
|
||||
Both accept the existing boolean syntax, and the last occurrence wins. An
|
||||
explicit setting overrides the mode default, except quiet always suppresses
|
||||
Progress. Without an explicit setting, debug enables Progress; ordinary and
|
||||
verbose modes leave it disabled. Progress does not enable Info, Warning, or
|
||||
Debug.
|
||||
|
||||
### Placement and aliases
|
||||
|
||||
Reporting flags are available on application commands, the root, canonical
|
||||
groups, help, version, and alias-only discovery help. Place them after the
|
||||
complete command path; they may be interspersed with operands after that path.
|
||||
For example:
|
||||
|
||||
```text
|
||||
example export --quiet
|
||||
example config show --verbose
|
||||
example promptkit test all --debug --progress=false
|
||||
example help config show --quiet
|
||||
example version --quiet
|
||||
```
|
||||
|
||||
Reporting flags do not go before or between command-path components. Tokens
|
||||
consumed as values and tokens after `--` retain their normal meaning.
|
||||
|
||||
The reserved long spellings are `--quiet`, `--verbose`, `--debug`, and
|
||||
`--progress`; application declarations cannot reuse them. There are no built-in
|
||||
short spellings. Applications may define aliases such as `-q → --quiet`,
|
||||
`-v → --verbose`, and `-d → --debug`; canonical and aliased occurrences share
|
||||
the same ordering. Thus `example export --verbose -q` selects quiet mode.
|
||||
|
||||
### Channels and messages
|
||||
|
||||
Substantive reports use the result destination. Warnings, Info, Progress,
|
||||
Debug, terminal explanations, and usage guidance use the diagnostic destination.
|
||||
Simple Warning, Debug, and terminal failure messages begin with `WARN:`,
|
||||
`DEBUG:`, and `ERROR:` respectively. Info, Progress, and substantive results
|
||||
have no prefix. For multiline messages, the prefix appears on the opening line;
|
||||
native structured details keep their own formatting without duplicate labels.
|
||||
Progress is streamed as unprefixed lines; terminal redraw and progress bars are
|
||||
not provided. A failed outcome with no message or detail uses the fallback
|
||||
`ERROR: Command failed.`.
|
||||
|
||||
Quiet mode suppresses successful help and version output, as it does other
|
||||
substantive results. Usage guidance and routing, parsing, and operational
|
||||
failures remain visible in every mode. A recovered problem is reported only if
|
||||
the application chooses to submit a Warning; an error cause by itself does not
|
||||
create a message.
|
||||
|
||||
### Presentation failures
|
||||
|
||||
CommandKit continues rendering substantive output under quiet mode through a
|
||||
discard destination. This suppresses writes to the caller's result writer while
|
||||
still detecting report preparation, encoding, or rendering failures. An
|
||||
intentional suppression is not a delivery failure. A failure while rendering or
|
||||
writing requested content is retained in the returned `Outcome` and makes an
|
||||
otherwise successful or usage invocation operational with status `1`. A domain
|
||||
classification and its nonzero status are preserved; a zero-status domain
|
||||
outcome becomes status `1` when presentation fails. Earlier bytes already
|
||||
written remain visible.
|
||||
|
||||
## Registered command syntax
|
||||
|
||||
Options are parsed after the complete command path and may be interspersed with
|
||||
@@ -83,6 +207,74 @@ Typed inputs keep supplied state separate from defaults. An explicit empty,
|
||||
false, or zero value is supplied. Help and version are available without
|
||||
constructing application command inputs.
|
||||
|
||||
## Application-owned aliases
|
||||
|
||||
Applications can install command and flag aliases alongside their canonical
|
||||
commands. Aliases route an invocation to existing behavior; they do not add
|
||||
handlers, commands, or flags to a canonical command family.
|
||||
|
||||
Command alias paths are sequences of command tokens relative to the application
|
||||
root. The executable name, flags, flag values, and operands are not part of an
|
||||
alias path. Matching uses complete tokens and chooses the longest matching
|
||||
path. A group alias preserves its remaining command tokens, and aliases can
|
||||
compose through chains. For example, `list collections` can route to
|
||||
`promptkit list collections`, while `pk` can route to the `promptkit` group.
|
||||
The target must resolve to a canonical command or group.
|
||||
|
||||
Aliases do not rewrite operands or supply arguments. In particular,
|
||||
`promptkit test KIND` remains one canonical command whose `KIND` is an operand;
|
||||
`all` is an operand value, not a child command. `smoke → promptkit test` is a
|
||||
valid shortcut because it preserves the caller's `KIND` value.
|
||||
|
||||
Flag aliases can be application-wide or scoped to a canonical command path.
|
||||
An application-wide alias is effective wherever its canonical target flag is
|
||||
available and is ignored elsewhere. A scoped alias requires its canonical
|
||||
command and target flag to be installed. The selected canonical command
|
||||
determines applicable scoped aliases, so the alias also works when that
|
||||
command is reached through another command path. A scoped mapping takes
|
||||
precedence over an application-wide mapping with the same spelling.
|
||||
|
||||
Only tokens parsed as flag names participate in flag aliasing. Values, operands,
|
||||
and tokens following `--` remain unchanged. This includes values that look like
|
||||
flags, such as `--message=--hlep`. Aliased flags use the target's canonical
|
||||
binding, conversion, default, required-value check, presence, and repetition
|
||||
behavior. Mixed spellings preserve occurrence order: singleton flags use the
|
||||
last successfully parsed occurrence, every supplied occurrence is converted,
|
||||
and repeatable flags accumulate values in order.
|
||||
|
||||
Flag aliases accept one long spelling or one individual short spelling. Short
|
||||
aliases use the canonical short-option cluster and attached-value grammar:
|
||||
|
||||
| Input | Interpretation |
|
||||
| --- | --- |
|
||||
| `-vq` | Two boolean flags, `--verbose` and `--quiet`. |
|
||||
| `-cblue` or `-c blue` | Value `blue` for a value-taking `-c` flag. |
|
||||
| `-vqcblue` | `-v`, `-q`, then `--color=blue`. |
|
||||
| `-cvq` | Value `vq` for a value-taking `-c` flag. |
|
||||
| `-v=false` | Explicit false for the boolean flag `-v`. |
|
||||
| `-c=` | An explicitly supplied empty string. |
|
||||
|
||||
Boolean flags use presence as true; an explicit boolean value requires `=`.
|
||||
The first value-taking short flag consumes the remaining cluster suffix or the
|
||||
next argument. A consumed value is never interpreted as an alias. The canonical
|
||||
short-option empty-value form `-c=` supplies an empty value, consistently with
|
||||
`--color=` and `-c ""`.
|
||||
|
||||
Help reached through an alias describes the canonical command's flags and
|
||||
operands while using the entered route in usage. It identifies the canonical
|
||||
target, lists applicable aliases separately, and supports global help aliases
|
||||
such as `--hlep → --help`. Help for an alias-only prefix lists its routes
|
||||
without creating a command group; the bare incomplete prefix points to that
|
||||
help. Help and alias registration do not construct command inputs or invoke
|
||||
acquisition callbacks.
|
||||
|
||||
Usage diagnostics preserve the command path and flag spelling the caller used.
|
||||
When a command alias resolved successfully, usage identifies its canonical
|
||||
target while retaining the entered path in the diagnostic. An unresolved route
|
||||
receives no invented canonical label. A flag conversion error identifies the
|
||||
entered flag spelling and the failing occurrence while preserving the original
|
||||
cause.
|
||||
|
||||
## Configuration scopes
|
||||
|
||||
An application installs the declaration returned by
|
||||
@@ -155,8 +347,8 @@ interrupt the native value. Existing native markers and leading comments are
|
||||
retained; documents without a marker receive one. Scope labels containing line
|
||||
control characters are quoted to keep them on one comment line. A provider
|
||||
failure leaves the output stream empty. A rendering or output failure may leave
|
||||
an aggregate prefix, and the outcome reports
|
||||
incomplete report delivery.
|
||||
an aggregate prefix; `Outcome.Presentation.Output` records incomplete delivery
|
||||
and the destination or rendering error.
|
||||
|
||||
### List configuration sources
|
||||
|
||||
@@ -322,8 +514,9 @@ again.
|
||||
The complete report is prepared before writing. Report preparation failures and
|
||||
cancellation observed before report delivery starts leave CommandKit's report
|
||||
output empty. Once writing starts, CommandKit does not interrupt the supplied
|
||||
writer or roll back delivered bytes. A write error or short write uses the
|
||||
existing incomplete-report delivery outcome and can leave partial output.
|
||||
writer or roll back delivered bytes. A write error or short write is recorded
|
||||
as incomplete delivery in `Outcome.Presentation.Output` and can leave partial
|
||||
output.
|
||||
|
||||
## PromptKit definition inspection
|
||||
|
||||
@@ -539,8 +732,9 @@ execution. See PromptKit's
|
||||
Builder, engine-loading, and native validation errors use the operational path
|
||||
(status `1`) with supported error identity and acquired diagnostics retained.
|
||||
Malformed syntax, missing operands, and unsupported formats use usage status
|
||||
`2`. Successful diagnostics and report-delivery failures follow the common
|
||||
delivery contract.
|
||||
`2`. Successful diagnostics use the common reporting policy. Report delivery
|
||||
failures are recorded in `Outcome.Presentation.Output`, separately from the
|
||||
operation cause.
|
||||
|
||||
### PromptKit catalogs
|
||||
|
||||
@@ -734,8 +928,8 @@ outcome. Operation errors use operational status `1`; aggregate runs continue
|
||||
after individual errors, retain their reports and ordered error text, and join
|
||||
causes for supported `errors.Is`/`errors.As` access. Cancellation stops later
|
||||
operations and preserves results already acquired. Report encoding or delivery
|
||||
failures use the common incomplete-report delivery outcome and retain operation
|
||||
causes where applicable. Syntax, operand, unsupported format, and conflicting
|
||||
failures are recorded in `Outcome.Presentation.Output`, separately from
|
||||
operation causes. Syntax, operand, unsupported format, and conflicting
|
||||
namespace selection are usage status `2`.
|
||||
|
||||
Native configuration, selection, and diagnostic meanings remain owned by
|
||||
@@ -748,4 +942,9 @@ and [engine API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f
|
||||
CommandKit usage failures use status `2`; operational failures use status `1`.
|
||||
Successful operations use status `0`. Application handlers may return a domain
|
||||
classification and status of their choice, which `Execute` preserves. The caller
|
||||
chooses how to map an outcome to process exit.
|
||||
chooses how to map an outcome to process exit. A rendering or delivery failure
|
||||
changes a successful or usage outcome to operational status `1`. A domain
|
||||
outcome keeps its classification and nonzero status; a zero-status domain
|
||||
outcome becomes `1` if presentation fails. The returned `Outcome.Presentation`
|
||||
records output and diagnostic delivery observations separately from its
|
||||
operation `Cause`.
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
# Consumer guide
|
||||
|
||||
CommandKit is distributed as three Go modules. Start with the core and add an
|
||||
integration module only when the application uses that library. Each module
|
||||
returns core `commandkit.Command` declarations, which the application installs
|
||||
through `commandkit.New` or `App.Register`.
|
||||
integration module only when the application uses that library. Core and
|
||||
compatible integration command factories return canonical `commandkit.Command` values.
|
||||
Applications may install those alongside application-owned `RoutingRule`
|
||||
declarations through `commandkit.New` or `App.Register`; routing rules are
|
||||
installed at the application root, not as group children.
|
||||
|
||||
## Module selection
|
||||
|
||||
| Import | Use when |
|
||||
| --- | --- |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit` | The application needs typed command declarations and common CLI behavior. |
|
||||
| `go.maximumdirect.net/commandkit` | The application needs typed command declarations and common CLI behavior. |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig` | The application wants ConfigKit-backed configuration scopes and reports. |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt` | The application wants PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, request validation, or native smoke testing. |
|
||||
|
||||
The integration paths in the table are their current source identities; they
|
||||
use the old core path and do not compose with `go.maximumdirect.net/commandkit`.
|
||||
For the new core v0.3.0 identity, the planned matched releases are
|
||||
`go.maximumdirect.net/ckconfig v0.2.0` and `go.maximumdirect.net/ckprompt v0.2.0`.
|
||||
Update application imports and dependency versions together when those releases
|
||||
are available. Old and new core paths have distinct Go type identities.
|
||||
Install the new core with `go get go.maximumdirect.net/commandkit@v0.3.0`.
|
||||
|
||||
The two integrations are independent optional imports. `ckprompt` uses the
|
||||
public scope and error contracts from `ckconfig`; importing it does not add the
|
||||
`config` family. An application that wants both families passes both returned
|
||||
@@ -22,9 +32,10 @@ core-only, ConfigKit-only, and composed applications.
|
||||
## Go compatibility
|
||||
|
||||
All three modules declare a consumer minimum of Go 1.26.0. The root module also
|
||||
selects ReleaseKit v0.4.2 as a Go tool, which makes that tool's module graph part
|
||||
of the root consumer module requirements even though production packages do not
|
||||
import ReleaseKit. The [release policy](../policy/release.md#consumer-minimum-and-release-toolchain)
|
||||
selects ReleaseKit v0.9.0 (`go.maximumdirect.net/releasekit`) as a Go tool, which
|
||||
makes that tool's module graph part of the root consumer module requirements
|
||||
even though production packages do not import ReleaseKit. The
|
||||
[release policy](../policy/release.md#consumer-minimum-and-release-toolchain)
|
||||
defines the support assumption. Maintainers use Go 1.26.8 for validation and
|
||||
publication; see the [release procedure](../release.md#release-toolchain).
|
||||
|
||||
@@ -41,7 +52,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
)
|
||||
|
||||
type GreetInput struct {
|
||||
@@ -62,7 +73,9 @@ func Greeting() commandkit.Command {
|
||||
),
|
||||
},
|
||||
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
|
||||
_, _ = fmt.Fprintln(invocation.Streams.Out, input.Count.Value)
|
||||
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil {
|
||||
return commandkit.Failure("Could not write the greeting.").WithCause(err)
|
||||
}
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
@@ -83,6 +96,116 @@ values convert before input construction. A command's `NewInput` creates input
|
||||
only when execution reaches it; help does not construct caller input or run
|
||||
provider callbacks.
|
||||
|
||||
## Shell completion
|
||||
|
||||
Install `commandkit.Completion("app")` alongside the application's other
|
||||
declarations, using the basename operators actually type. The basename accepts
|
||||
letters, digits, underscores, dots, hyphens, and colons, except for a leading
|
||||
hyphen. Add a hint to a flag or operand's metadata when a value has useful
|
||||
static choices or is a filesystem path:
|
||||
|
||||
```go
|
||||
commandkit.FlagMeta{
|
||||
Name: "format",
|
||||
Completion: commandkit.CompleteChoices(
|
||||
commandkit.CompletionChoice{Value: "text", Description: "Readable text"},
|
||||
commandkit.CompletionChoice{Value: "yaml", Description: "YAML output"},
|
||||
),
|
||||
}
|
||||
commandkit.FlagMeta{Name: "config", Completion: commandkit.CompleteFiles()}
|
||||
commandkit.ArgumentMeta{Name: "directory", Completion: commandkit.CompleteDirectories()}
|
||||
```
|
||||
|
||||
Hints are suggestions: converters and input validation still decide which
|
||||
values are accepted. An omitted hint or `NoCompletion()` gives no value
|
||||
suggestions. `CompleteChoices()` with no choices also gives no suggestions.
|
||||
Declarations retain copies of supplied choices. The [CLI reference](../cli.md#shell-completion)
|
||||
describes supported shells and loading scripts; the [core example](../../examples/core)
|
||||
and [composed example](../../integrations/ckprompt/examples/composed) show
|
||||
installation alongside aliases and integration commands.
|
||||
|
||||
## Invocation reporting
|
||||
|
||||
`Invocation.Reporter()` supplies the reporter for the current handler. Use
|
||||
`Output()` or `RenderOutput()` for substantive results; use `Warn`, `Info`,
|
||||
`Progress`, and `Debug` for categorized messages. Queue a summary with
|
||||
`AtEnd().Info` when it belongs after the handler returns. The
|
||||
[CLI reference](../cli.md#reporting) owns flag grammar, visibility, prefixes,
|
||||
and status behavior.
|
||||
|
||||
Return terminal text with `Failure(message, details...)`, `Usage(message,
|
||||
details...)`, or `WithExplanation` on a domain outcome. Attach an optional Go
|
||||
error separately with `WithCause`. A cause is preserved for inspection, but it
|
||||
does not choose the outcome or become user-facing text. Rendering callbacks
|
||||
write to their supplied writer only while the callback runs; they must not
|
||||
submit another message or start another rendering operation through the same
|
||||
reporter.
|
||||
|
||||
```go
|
||||
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
|
||||
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil {
|
||||
return commandkit.Failure("Could not write the greeting.").WithCause(err)
|
||||
}
|
||||
return commandkit.Success()
|
||||
}
|
||||
```
|
||||
|
||||
After `App.Execute`, inspect the returned `Outcome`. Its `Cause` retains the
|
||||
operation error; `Presentation.Output` and `Presentation.Diagnostics` report
|
||||
delivery state and the first rendering or write errors for their destinations.
|
||||
CommandKit does not retain a message transcript. Reporter operations for one
|
||||
invocation are safe concurrently, but concurrent submission order is not
|
||||
specified. The caller owns the writers passed to `Execute`; CommandKit does not
|
||||
close them.
|
||||
|
||||
### Migrating from the previous reporting API
|
||||
|
||||
| Previous API | Current API |
|
||||
| --- | --- |
|
||||
| `invocation.Streams.Out` / `.Err` | `invocation.Reporter().Output()` and categorized reporter methods. `Streams` remains the argument to `App.Execute`. |
|
||||
| `Failure(err)` / `Usage(err)` | `Failure("operator-facing explanation").WithCause(err)` / `Usage("usage explanation").WithCause(err)`. Choose the explanation deliberately; the cause alone is not printed. |
|
||||
| `Outcome.Diagnostics` or terminal `SeverityError` | Submit independent messages through reporter methods. Put failure context in `Explanation`; provider `Acquired.Diagnostics` remains passive data for the owning integration to submit. |
|
||||
| `ReportIncomplete`, `DiagnosticDelivery`, or `DeliveryError` | Inspect `Outcome.Presentation.Output` and `.Diagnostics`, including each `DeliveryResult.State`, `RenderError`, and `WriteError`. |
|
||||
|
||||
## Application-owned aliases
|
||||
|
||||
Pass commands and aliases together as declarations. Integration factories can
|
||||
be called directly in the installation list, and command-scoped flag aliases
|
||||
name the canonical command path:
|
||||
|
||||
```go
|
||||
app, err := commandkit.New(
|
||||
identity,
|
||||
ckprompt.Command(suite),
|
||||
commandkit.Alias("pk", "promptkit"),
|
||||
commandkit.FlagAliasFor("promptkit show prompt", "--fmt", "--format"),
|
||||
)
|
||||
```
|
||||
|
||||
For a reusable collection, use `[]commandkit.Declaration`. Go does not expand a
|
||||
`[]commandkit.Command` into `...commandkit.Declaration`, so convert command-only
|
||||
slices when composing them with routing rules:
|
||||
|
||||
```go
|
||||
commands := []commandkit.Command{ckprompt.Command(suite)}
|
||||
declarations := make([]commandkit.Declaration, 0, len(commands)+1)
|
||||
for _, command := range commands {
|
||||
declarations = append(declarations, command)
|
||||
}
|
||||
declarations = append(declarations, commandkit.Alias("pk", "promptkit"))
|
||||
app, err := commandkit.New(identity, declarations...)
|
||||
```
|
||||
|
||||
New and Register validate each supplied batch before publishing its commands
|
||||
and routing rules. Factories capture their inputs eagerly; malformed or
|
||||
conflicting rules are reported at installation. Identical rules are safe to
|
||||
contribute from reusable helpers. The [core example](../../examples/core) and
|
||||
[composed consumer](../../integrations/ckprompt/examples/composed) show complete
|
||||
maintained wiring with ordinary command behavior alongside aliases. The
|
||||
[Go declarations and GoDoc](../../command.go) own the public API contracts;
|
||||
the [CLI reference](../cli.md#application-owned-aliases) owns routing and
|
||||
operator-visible behavior.
|
||||
|
||||
## ConfigKit scopes
|
||||
|
||||
For an application with one ordinary app configuration loader,
|
||||
@@ -216,10 +339,11 @@ application-owned `session restore` command.
|
||||
## Outcomes and ownership
|
||||
|
||||
Core classifies usage and operational failures while preserving application
|
||||
domain outcomes and supported error identity. Report and diagnostic delivery
|
||||
errors remain inspectable separately from the operation cause. The caller owns
|
||||
process startup, signal handling, and exit mapping; `Execute` never exits the
|
||||
process.
|
||||
domain outcomes and supported error identity. Applications choose domain
|
||||
classification and status. A terminal explanation is presented separately from
|
||||
the optional operation cause, while report and diagnostic delivery observations
|
||||
are available in `Outcome.Presentation`. The caller owns process startup,
|
||||
signal handling, and exit mapping; `Execute` never exits the process.
|
||||
|
||||
Go declarations and GoDoc own exact exported contracts. ConfigKit owns
|
||||
configuration decoding, validation, and provenance. PromptKit owns prompt
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
CommandKit is distributed as a core Go module and two optional integration
|
||||
modules. Core owns captured declarations, typed input preparation, installation,
|
||||
dispatch, outcomes, help, and version. `integrations/ckconfig` owns ConfigKit
|
||||
scope adaptation and reports; `integrations/ckprompt` owns PromptKit engine,
|
||||
dispatch, invocation reporting and outcomes, help, and version.
|
||||
`integrations/ckconfig` owns ConfigKit scope adaptation and reports;
|
||||
`integrations/ckprompt` owns PromptKit engine,
|
||||
definition inspection, requirements discovery, and concrete request validation.
|
||||
The maintained [consumer guide](consumers/guide.md) links to the three maintained
|
||||
consumers.
|
||||
@@ -11,6 +12,12 @@ The [README](../README.md) provides project orientation; the
|
||||
[documentation policy](policy/documentation.md#canonical-ownership) assigns
|
||||
canonical documentation owners.
|
||||
|
||||
For application-owned aliases, use the
|
||||
[CLI reference](cli.md#application-owned-aliases),
|
||||
[Go declarations and GoDoc](../command.go), and
|
||||
[ADR 0013](adr/0013-application-alias-routing.md). Keep deferred capabilities in
|
||||
[deferred work](roadmap/deferred.md).
|
||||
|
||||
## Initial orientation
|
||||
|
||||
1. Inspect the worktree and preserve unrelated staged and unstaged changes.
|
||||
@@ -47,6 +54,8 @@ canonical documentation owners.
|
||||
| Task | Read |
|
||||
| --- | --- |
|
||||
| Implemented CLI behavior | [CLI reference](cli.md) |
|
||||
| Invocation reporting | [CLI reporting contract](cli.md#reporting), [consumer guide](consumers/guide.md#invocation-reporting), core GoDoc, [reporting roadmap](roadmap/reporting.md), and [ADR 0014](adr/0014-invocation-reporting.md) |
|
||||
| Application-owned aliases | [CLI reference](cli.md#application-owned-aliases), [Go declarations and GoDoc](../command.go), [consumer guide](consumers/guide.md#application-owned-aliases), and [ADR 0013](adr/0013-application-alias-routing.md) |
|
||||
| Public API usage and standard-suite wiring | [Consumer guide](consumers/guide.md), [ckconfig package](../integrations/ckconfig/doc.go), [ckprompt package](../integrations/ckprompt/doc.go), and applicable ADRs |
|
||||
| Dispatcher, registration, or typed inputs | [Command declarations](../command.go), [bindings](../binding.go), [invocation](../execute.go), and [ADR 0001](adr/0001-command-dispatch-and-inputs.md), refined by [ADR 0011](adr/0011-captured-declarations-and-installation.md) |
|
||||
| Backend integration | [Internal overview](internal/overview.md), [adapter implementation](../internal/cobra/parse.go), and [ADR 0002](adr/0002-cobra-backend.md) |
|
||||
@@ -63,7 +72,8 @@ Read all applicable rows for cross-cutting work. Go declarations and GoDoc own
|
||||
current exported contracts; the CLI reference owns operator behavior. The
|
||||
[maintainer release procedure](release.md) owns current release operations.
|
||||
Completed roadmaps and implementation evidence remain available in Git history,
|
||||
including [PromptKit requirements implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/40499a43608bf60d1104cd8ee24caf18ac0e9e66/docs/roadmap).
|
||||
including [application-owned alias planning, implementation, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/f42aefe02d84e154855ac922173e6f367180929f/docs/roadmap)
|
||||
and [PromptKit requirements implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/40499a43608bf60d1104cd8ee24caf18ac0e9e66/docs/roadmap).
|
||||
These records are not assignments to restart completed work. The
|
||||
[deferred-work record](roadmap/deferred.md) preserves future capability targets
|
||||
and unselected proposals separately from current contracts.
|
||||
@@ -89,7 +99,10 @@ Sibling-checkout references in retired planning records are historical review
|
||||
evidence, not build dependencies. Implemented contracts belong in CommandKit
|
||||
under their canonical owners.
|
||||
|
||||
The root module does not traverse nested modules. During development, use a
|
||||
The root module does not traverse nested modules. During the vanity-path
|
||||
migration, integrations still import the old core identity and cannot compose
|
||||
with the migrated root until their own module migrations are complete. After
|
||||
the matched migrations, during development use a
|
||||
local workspace for all three modules; it is ignored and must not be committed.
|
||||
If a local `go.work` already exists, preserve it and add these module directories:
|
||||
|
||||
@@ -114,14 +127,10 @@ publication commands.
|
||||
|
||||
## Release tooling development
|
||||
|
||||
The local publication exercise is opt-in and uses disposable Git repositories.
|
||||
Prepare the module cache before running it; ordinary tests do not bootstrap or
|
||||
publish a release:
|
||||
|
||||
```sh
|
||||
GOWORK=off GOTOOLCHAIN=local go mod download
|
||||
COMMANDKIT_RELEASE_INTEGRATION=1 GOWORK=off GOTOOLCHAIN=local GOPROXY=off GONOPROXY=none GOSUMDB=off go test -mod=readonly -count=1 ./scripts -run '^TestReleaseIntegration$'
|
||||
```
|
||||
Run the focused launcher wiring tests with `go test ./scripts`. The release
|
||||
profiles select upstream checks of committed inputs independently for each
|
||||
module; these checks do not replace domain review. See the
|
||||
[release procedure](release.md) for publication, delivery, and recovery.
|
||||
|
||||
Follow the [local commit policy](policy/agents.md#git-commit-policy). Completed
|
||||
units receive local commits by default, subject to explicit user instructions.
|
||||
@@ -158,3 +167,17 @@ go run ./examples/core workspace greet --count=0 --tag=first --tag second
|
||||
|
||||
The [consumer guide](consumers/guide.md) explains how their public API usage
|
||||
fits together.
|
||||
|
||||
|
||||
## Shell completion validation
|
||||
|
||||
The opt-in interactive completion checks require Bash, Zsh, Fish, Python with
|
||||
`pexpect`, and the Bash completion package. The harness starts a clean Bash
|
||||
and sources `/usr/share/bash-completion/bash_completion` by default. Set
|
||||
`COMPLETION_BASH_INIT` to your installation's initialization file if it is
|
||||
elsewhere. See the [operator setup](cli.md#shell-completion) for ordinary use.
|
||||
|
||||
```sh
|
||||
COMMANDKIT_COMPLETION_SHELLS=1 go test -count=1 -run '^TestCompletionShells$' ./internal/cobra
|
||||
COMMANDKIT_COMPLETION_SHELLS=1 go test -count=1 -run '^TestCompletionConsumerInShells$' .
|
||||
```
|
||||
|
||||
@@ -6,20 +6,61 @@ own the optional ConfigKit and PromptKit integrations. The
|
||||
[architecture policy](../policy/architecture.md) owns normative boundaries;
|
||||
this page inventories the implemented owners.
|
||||
|
||||
The core module now declares `go.maximumdirect.net/commandkit`. The integration
|
||||
modules retain their old module and core import identities until their planned
|
||||
matched releases; Go treats those identities as distinct.
|
||||
|
||||
## Root `commandkit`
|
||||
|
||||
The root facade owns captured command declarations, binding conversion,
|
||||
invocation coordination, the per-App registry, standard help/version commands,
|
||||
and outcome/diagnostic delivery. Its dependency graph has no ConfigKit or
|
||||
PromptKit integration dependency. Applications provide typed callbacks and
|
||||
choose when to install declarations.
|
||||
The root facade owns captured canonical command declarations, application-owned
|
||||
routing rules, binding conversion, invocation coordination, the per-App
|
||||
registries, standard help/version commands, invocation reporting, and outcome
|
||||
finalization.
|
||||
Its dependency graph has no ConfigKit or PromptKit integration dependency.
|
||||
Applications provide typed callbacks and install routing rules alongside the
|
||||
complete canonical command tree through the same batch API.
|
||||
|
||||
The shared command compiler prepares supplied values before constructing input.
|
||||
The batch installer validates retained issues, placement, and collisions before
|
||||
mutating the registry. Each invocation uses one routing snapshot and backend
|
||||
session; a successful registration invalidates the snapshot for a later
|
||||
invocation. The root package owns app input and callback execution, not provider
|
||||
configuration semantics.
|
||||
Canonical commands own handlers and parsing/help metadata; routing rules resolve
|
||||
entered command paths and flag spellings to those canonical records without
|
||||
adding command nodes or handlers. The shared command compiler prepares supplied
|
||||
values before constructing input. The batch installer validates retained
|
||||
issues, placement, and collisions across both registries before publishing
|
||||
either. Each invocation uses one routing snapshot and backend session; a
|
||||
successful registration invalidates the snapshot for a later invocation. The
|
||||
root package owns app input and callback execution, not provider configuration
|
||||
semantics.
|
||||
|
||||
Each execution creates one reporter backed by the caller's result and diagnostic
|
||||
writers. The Cobra adapter returns parsed values, flag spellings, and reporting
|
||||
flag occurrences. Core reduces those occurrences in entered order after route
|
||||
resolution, applies the final mode and progress selection, and presents any
|
||||
collected parser events through that selection. The selected mode therefore
|
||||
applies to command execution, help, and version; routing and parsing failures
|
||||
remain visible in every mode.
|
||||
|
||||
The reporter owns invocation-scoped visibility, message formatting, managed
|
||||
report rendering, delivery observations, and finalization. It presents queued
|
||||
messages after the handler returns, then a failed outcome's explanation, and
|
||||
closes its handles before `Execute` returns. It records rendering and write
|
||||
errors separately for result and diagnostic destinations; the returned
|
||||
`Outcome.Presentation` exposes those observations independently from the
|
||||
operation cause. The reporter does not retain a transcript and does not close
|
||||
or flush caller-owned writers.
|
||||
|
||||
Internally, one reporting-state mutex guards visibility, queued messages,
|
||||
destination state, and delivery errors while serializing reporter operations
|
||||
and writes. Rendering callbacks execute within that coordination boundary, so
|
||||
they must not recursively submit or render through the same reporter. This is
|
||||
an implementation detail supporting the callback contract; the mutex does not
|
||||
coordinate writes made outside the reporter.
|
||||
|
||||
## `internal/alias` and `internal/commandpath`
|
||||
|
||||
`internal/commandpath` provides shared path parsing and token operations used by
|
||||
declaration capture, routing, and backend selection. `internal/alias` compiles
|
||||
application routing rules, resolves command prefixes while retaining token
|
||||
provenance, and validates command-route termination. Neither package owns
|
||||
handlers, application factories, or backend types.
|
||||
|
||||
## `internal/commandmeta`
|
||||
|
||||
|
||||
@@ -139,16 +139,16 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
||||
|
||||
## ReleaseKit
|
||||
|
||||
Selected module: `gitea.maximumdirect.net/eric/ReleaseKit` at `v0.4.2` (selected Go tool; not a runtime import).
|
||||
Selected module: `go.maximumdirect.net/releasekit` at `v0.9.0` (selected Go tool; not a runtime import).
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
License and notices: [LGPL v3.0](../../LICENSE) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/LICENSE).
|
||||
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8).
|
||||
[Source](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4).
|
||||
|
||||
## Goldmark
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Base Policy Version:** 1.3
|
||||
|
||||
**Last Updated**: 2026-10-11
|
||||
**Last Updated**: 2026-10-26
|
||||
|
||||
**Upstream Source:** [architecture.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
||||
|
||||
@@ -75,26 +75,26 @@ Its documentation recommends the same grammar and vocabulary for application-own
|
||||
commands, including those outside its dispatcher, under
|
||||
[ADR 0006](../adr/0006-command-grammar.md).
|
||||
|
||||
CommandKit preserves application-supplied outcome classifications and supported
|
||||
error identity. It assigns responsibility for its diagnostics, avoids duplicate
|
||||
presentation through its own paths, and reports write failures. Caller callbacks
|
||||
retain control of their own output. [ADR 0005](../adr/0005-invocation-outcomes.md)
|
||||
describes the outcome and delivery model.
|
||||
|
||||
Use explicit instance construction and composable command registration, not
|
||||
package-global or import-time registries. Applications and integrations supply
|
||||
the same captured command declarations through the core installation API.
|
||||
captured canonical command declarations through the core installation API.
|
||||
Declaration factories capture metadata, bindings, and callback references eagerly;
|
||||
construction errors remain attached for installation to report. Capture the
|
||||
metadata collections needed to keep later edits to a source declaration from
|
||||
changing a returned command. Application callback objects retain their ownership.
|
||||
One definition supplies routing, parsing, and help metadata.
|
||||
Canonical command declarations own handlers, parsing metadata, and help
|
||||
metadata. Applications may provide separate routing rules in the same
|
||||
installation batch; these rules resolve invocations to canonical commands and
|
||||
do not add command nodes, handlers, or flags.
|
||||
|
||||
Validate each installation batch before mutating the instance registry. A failed
|
||||
construction yields no usable application; a failed later registration leaves the
|
||||
existing registry intact. Separate successful registration calls are not one
|
||||
transaction. Duplicate paths and attempts to replace reserved commands report
|
||||
errors rather than letting registration order select a winner.
|
||||
transaction. Duplicate canonical paths and attempts to replace reserved commands
|
||||
report errors rather than letting registration order select a winner. Identical
|
||||
routing rules are idempotent; competing targets for the same source and scope
|
||||
report an error. Command-scoped flag routing refers to an exact canonical
|
||||
command path.
|
||||
|
||||
Explicit absent-command declarations contribute no commands, help entries, or
|
||||
namespace claims; attached errors are reported before skipping absence. An invalid
|
||||
@@ -121,6 +121,30 @@ inspection/execution loading and focused acquisition callbacks are recommended
|
||||
integration practices. Independent consumer use of other CLI frameworks remains
|
||||
application-owned.
|
||||
|
||||
## Invocation Reporting And Outcomes
|
||||
|
||||
Use one reporting facility per invocation for command results, immediate messages,
|
||||
and deferred messages. Core owns shared visibility, formatting, delivery, and
|
||||
finalization. Applications and integrations choose their report content and
|
||||
messages; callers choose the destinations. [ADR 0014](../adr/0014-invocation-reporting.md)
|
||||
extends the outcome and delivery model in [ADR 0005](../adr/0005-invocation-outcomes.md).
|
||||
|
||||
Applications decide how to respond to domain-specific errors. CommandKit may
|
||||
provide shared helpers for carrying, inspecting, and presenting errors while
|
||||
leaving those decisions with the application. Supplying an error cause alone
|
||||
does not select an outcome or generate an operator-facing message. CommandKit
|
||||
handles its own errors, including routing, parsing, and presentation failures.
|
||||
|
||||
Preserve application-defined domain classifications and supplied error identity.
|
||||
Report rendering and delivery failures separately from the command's explanation
|
||||
and cause, applying the outcome rules in ADR 0005 and ADR 0014. Intentionally
|
||||
suppressed output is not a delivery failure. Present terminal failure explanations
|
||||
after normal reporting finishes, avoid duplicate presentation through core's own
|
||||
paths, and retain the explanation and any supplied cause in the returned outcome.
|
||||
|
||||
Keep reporting state scoped to the invocation's presentation and returned outcome.
|
||||
Logging and message history remain application concerns.
|
||||
|
||||
## Handler Inputs And Invocation State
|
||||
|
||||
Handlers receive typed input structs with explicit flag and operand bindings.
|
||||
@@ -209,8 +233,8 @@ transforming arbitrary application objects.
|
||||
Standard contracts such as `context.Context` and `io.Writer` remain appropriate.
|
||||
Upstream public types may be selected as deliberate dependencies in the
|
||||
integrations that own them; that permission does not justify optional library
|
||||
dependencies in the core. Core diagnostics use a CommandKit-owned severity type,
|
||||
and integrations convert upstream diagnostics while preserving selected outcome
|
||||
dependencies in the core. Core owns shared presentation categories,
|
||||
and integrations adapt upstream diagnostics while preserving selected outcome
|
||||
and error semantics. Application-owned input types remain application-owned.
|
||||
Expose narrow core composition contracts for demonstrated integration needs,
|
||||
without exporting private assembly or backend structures.
|
||||
|
||||
@@ -25,9 +25,11 @@ additional release requirements.
|
||||
CommandKit independently versions three source-only Go modules: the root core,
|
||||
`integrations/ckconfig`, and `integrations/ckprompt`. Standard CLI behavior is
|
||||
exposed by consuming applications; CommandKit does not declare binary
|
||||
deliverables or a cross-platform build matrix. ReleaseKit v0.4.2 is selected as
|
||||
the maintainer publication tool. The [release procedure](../release.md) owns
|
||||
the selected-module workflow, release order, and repository-specific commands;
|
||||
deliverables or a cross-platform build matrix. ReleaseKit v0.9.0 is selected as
|
||||
the maintainer publication tool. Maintainers may separately deliver page-only
|
||||
Gitea releases from tagged source; delivery has its own outcome and URL. The
|
||||
[release procedure](../release.md) owns the selected-module workflow, release
|
||||
order, and repository-specific commands;
|
||||
the integration adds no application command family or runtime ReleaseKit
|
||||
import.
|
||||
|
||||
|
||||
132
docs/release.md
132
docs/release.md
@@ -3,10 +3,11 @@
|
||||
## Scope and release order
|
||||
|
||||
CommandKit publishes source-only Go module releases. Each module has an
|
||||
independent version and immutable annotated tag. This procedure publishes no
|
||||
hosted release page, binaries, checksums, or other delivery assets. A successful
|
||||
source-tag push is release completion; CI, module proxy availability, and
|
||||
consumer installation are not completion gates.
|
||||
independent version and immutable annotated tag. A successful source-tag push
|
||||
completes the source release. A separate local delivery command can publish a
|
||||
Gitea release page; there are no binaries,
|
||||
checksums, or other delivery assets. CI, module proxy availability, and consumer
|
||||
installation are not completion gates.
|
||||
|
||||
Publish in dependency order: core, then `ckconfig`, then `ckprompt`. The
|
||||
integration modules require the preceding CommandKit releases, so prepare and
|
||||
@@ -15,26 +16,28 @@ release runs only the root module's default suite; it need not run suites for
|
||||
independently released integrations.
|
||||
|
||||
Use branch `main`, remote `origin`, and expected effective push endpoint
|
||||
`https://gitea.maximumdirect.net/eric/CommandKit.git`. The selected module profile
|
||||
enforces the endpoint. The maintainer needs the selected
|
||||
`ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git`. The selected
|
||||
module profile enforces the endpoint. The maintainer needs the selected
|
||||
[release toolchain](#release-toolchain), Git push access, and a POSIX shell.
|
||||
This procedure follows the [release policy](policy/release.md); release-note
|
||||
footers follow the [OSS discoverability policy](policy/oss.md#discoverability).
|
||||
|
||||
ReleaseKit v0.4.2 owns candidate validation, publication guards, tag creation,
|
||||
tag publication, and outcome reporting. Its pinned public contracts are the
|
||||
[CLI reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/cli.md),
|
||||
[profile reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/config.md),
|
||||
and [publication guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/consumers/publish.md).
|
||||
ReleaseKit v0.9.0 (`go.maximumdirect.net/releasekit`) owns candidate validation,
|
||||
publication guards, tag creation, tag publication, and outcome reporting.
|
||||
Its pinned public contracts are the
|
||||
[CLI reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/cli.md),
|
||||
[profile reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/config.md),
|
||||
the [publication guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/consumers/publish.md),
|
||||
and the [delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/consumers/delivery.md).
|
||||
The repository selects one default module suite per invocation. The launcher
|
||||
bootstraps the native tool into ignored `.releasekit/` storage and delegates to
|
||||
its `publish` command.
|
||||
uses the root module’s registered tool with native host settings and read-only
|
||||
module metadata.
|
||||
|
||||
## Release toolchain
|
||||
|
||||
The selected release toolchain is **Go 1.26.8**. Each module declares a consumer
|
||||
minimum of **Go 1.26.0**. Maintainers use Go 1.26.8 for dependency preparation,
|
||||
candidate validation, and the ReleaseKit bootstrap. The
|
||||
candidate validation, and ReleaseKit execution. The
|
||||
[consumer guide](consumers/guide.md#go-compatibility) and
|
||||
[release policy](policy/release.md#consumer-minimum-and-release-toolchain)
|
||||
describe these selections.
|
||||
@@ -66,14 +69,16 @@ its note explains the impact and consumer action. Never reuse a published
|
||||
version. Each release note starts with a product title and exact version, gives
|
||||
a proportionate summary and compatibility guidance, and ends with immutable
|
||||
links to that module's local grant, license, and attribution paths. The full tag
|
||||
in each URL includes the integration directory where applicable. Future tag
|
||||
URLs need not resolve before publication.
|
||||
in each URL includes the integration directory where applicable. For future
|
||||
notes, use `https://forge.maximumdirect.net/base-layer/commandkit/src/tag/FULL_TAG/`
|
||||
followed by the repository-relative path: `COPYRIGHT`, `LICENSE`, and
|
||||
`docs/oss/attribution.md` for core, or those paths prefixed with the selected
|
||||
integration directory. Future tag URLs need not resolve before publication.
|
||||
|
||||
All three release profiles explicitly select the repository-root `COPYRIGHT`,
|
||||
`LICENSE`, and `docs/oss/attribution.md` through `validation.shared_files`.
|
||||
ReleaseKit requires these committed files to exist in the candidate even when
|
||||
no optional file checks are enabled. The list does not enable content checks
|
||||
or expand Go validation scope; see the pinned [profile reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/b96262eb0ce9c8f2eb3e948a5841db2b799bc91b/docs/config.md).
|
||||
ReleaseKit requires these committed files to exist in the candidate. The list
|
||||
does not expand Go validation scope; see the pinned [profile reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/config.md).
|
||||
|
||||
Each integration also distributes its own `COPYRIGHT`, `LICENSE`, and
|
||||
`docs/oss/attribution.md`. These module-local materials are separate from the
|
||||
@@ -82,10 +87,12 @@ Automatic module file inputs include applicable module-local files and stop at
|
||||
independent nested-module boundaries. Their inclusion does not itself enable
|
||||
license or content validation.
|
||||
|
||||
The current profiles enable no optional file checks or additional product
|
||||
checks. Publication runs the selected module's standard offline suite and
|
||||
ReleaseKit's candidate/note and publication guards. The profiles select their
|
||||
physical module directory while using the same repository and endpoint.
|
||||
Each profile selects Go formatting, Markdown links, workspace-file and local
|
||||
replacement checks, and forbids the committed `.releasekit/gitea-token` path.
|
||||
These upstream checks inspect committed inputs; module file selection stops at
|
||||
independent module boundaries, while the forbidden path covers the repository.
|
||||
They do not replace domain review. Publication also runs the selected module’s
|
||||
standard offline suite and ReleaseKit’s candidate/note and publication guards.
|
||||
|
||||
## Prepare and publish one candidate
|
||||
|
||||
@@ -99,39 +106,92 @@ future sums.
|
||||
|
||||
Then commit the selected module's release note and all intended candidate
|
||||
changes on `main`, and publish the candidate commit to `origin`. Set one
|
||||
selector, module directory, and version from the table above, then run:
|
||||
module directory and version from the table above, then run the corresponding
|
||||
publication command:
|
||||
|
||||
```sh
|
||||
module_dir=.
|
||||
module_selector=core
|
||||
module_version=v0.2.0
|
||||
(cd "$module_dir" && GOWORK=off GOTOOLCHAIN=local go mod download)
|
||||
# Commit reviewed dependency changes and the selected module's note on main.
|
||||
git push origin HEAD:refs/heads/main
|
||||
./scripts/release "$module_selector" "$module_version"
|
||||
./scripts/release core vX.Y.Z
|
||||
# For integrations use: ./scripts/release ckconfig vX.Y.Z
|
||||
# or: ./scripts/release ckprompt vX.Y.Z
|
||||
```
|
||||
|
||||
Run only the command for the selected module. Its profile runs the complete
|
||||
offline deterministic default suite once for that module, checks candidate and
|
||||
remote guards, creates the annotated tag from the matching note, and pushes
|
||||
only that full tag. `check` is available for diagnosis and is not a required
|
||||
pre-publication command. The launcher does not push `main`; publish the
|
||||
only that full tag. `./scripts/releasekit core check vX.Y.Z` is an optional
|
||||
diagnostic, not a required pre-publication step: publication repeats validation.
|
||||
Use the relevant integration selector and its version when diagnosing that
|
||||
module. Inspect the registered tool with `./scripts/releasekit version`. The
|
||||
launcher does not push `main`; publish the
|
||||
candidate commit first. Successful source publication does not wait for CI,
|
||||
proxy availability, or downstream installation.
|
||||
|
||||
## Completion and ambiguous publication
|
||||
|
||||
A successful push to the expected endpoint completes the source release. On an
|
||||
ambiguous result, use the exact endpoint, full tag, and annotated tag object
|
||||
reported by that invocation. Confirm endpoint resolution has not changed, then
|
||||
query only that exact tag ref. Compare the remote annotated tag-object ID with
|
||||
the reported object: an exact match confirms publication, a different object is
|
||||
a conflict, successful output without the ref proves absence at that endpoint,
|
||||
A successful push to the expected endpoint completes the source release.
|
||||
ReleaseKit also attempts to save private publication evidence under the
|
||||
repository's Git common directory, shared by linked worktrees. It reports the
|
||||
saved record path or a recording warning separately from publication status;
|
||||
a storage failure does not change publication status. Records can contain
|
||||
credential-bearing endpoints. Retain them privately while recovery is needed
|
||||
and remove them when their recovery value expires; retention is maintainer-owned.
|
||||
|
||||
On an ambiguous result, use the recovery command printed for that invocation's
|
||||
saved record. To discover records and explicitly select the matching attempt,
|
||||
the shared helper can also be used:
|
||||
|
||||
```sh
|
||||
./scripts/releasekit publication-attempts
|
||||
./scripts/releasekit reconcile --record /absolute/path/to/attempt.json
|
||||
```
|
||||
|
||||
Replace the example record path with the saved path for the intended attempt.
|
||||
Relative record paths resolve from the repository root.
|
||||
Discovery lists local evidence without querying remotes or selecting an attempt.
|
||||
Reconciliation observes the recorded endpoint, full tag, and tag object without
|
||||
rerunning candidate validation or changing tags. It distinguishes confirmed
|
||||
publication, current absence, conflict, and an unresolved observation. Current
|
||||
absence does not prove that a version was never published. The pinned
|
||||
[CLI reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/cli.md#recover-a-saved-publication-attempt-or-discover-local-attempts)
|
||||
owns recovery statuses and structured output; the
|
||||
[publication guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/consumers/publish.md#retain-and-reconcile-publication-evidence)
|
||||
describes record storage and recovery entry points.
|
||||
|
||||
If no usable record was saved, use the exact endpoint, full tag, and annotated
|
||||
tag object reported by that invocation. Query only that exact tag ref at the
|
||||
captured endpoint. An exact tag-object match confirms publication, a different
|
||||
object is a conflict, successful output without the ref shows current absence,
|
||||
and a failed query leaves the result unresolved. Do not infer the captured
|
||||
endpoint's state from another remote or a rewritten URL.
|
||||
endpoint's state from another remote or a rewritten URL. Resolve unknown
|
||||
publication state before retrying.
|
||||
|
||||
ReleaseKit may retain a local tag after a failed push. Remove an unpublished
|
||||
local tag only after proving absence at the captured endpoint and confirming
|
||||
that the tag was never published or used by another release channel. Never
|
||||
move, overwrite, recreate, or delete a published tag. Resolve conflicts with a
|
||||
fresh version; corrections to published source require a new version.
|
||||
|
||||
## Hosted delivery
|
||||
|
||||
After source publication, check out the original tagged source with a clean
|
||||
worktree and index, then deliver the selected module's page:
|
||||
|
||||
```sh
|
||||
./scripts/releasekit core deliver vX.Y.Z
|
||||
# Or use ckconfig or ckprompt with that module's semantic version.
|
||||
```
|
||||
|
||||
The profile derives the full module-specific tag from the version. Delivery
|
||||
uses the checked-in release note as a page-only body and reports its own outcome
|
||||
and URL separately from source publication. Retry later from the same original
|
||||
tagged source. The pinned [delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/consumers/delivery.md)
|
||||
owns delivery and retry behavior.
|
||||
|
||||
A nonblank `RELEASEKIT_GITEA_TOKEN` takes precedence over the repository-relative
|
||||
`.releasekit/gitea-token` file. Keep the credential directory ignored and retain
|
||||
its local contents. Credentials and Gitea availability do not gate source
|
||||
publication.
|
||||
|
||||
88
docs/releases/v0.3.0.md
Normal file
88
docs/releases/v0.3.0.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# CommandKit v0.3.0
|
||||
|
||||
## Summary
|
||||
|
||||
CommandKit v0.3.0 moves core to `go.maximumdirect.net/commandkit`, adds
|
||||
application-owned command and flag aliases, shared invocation reporting, and
|
||||
shell completion. This pre-1.0 minor release also updates the installation
|
||||
declaration type and Invocation struct shape, replaces the error-first outcome
|
||||
and diagnostic-delivery APIs, and reserves reporting flag names; review the
|
||||
migration guidance before upgrading.
|
||||
|
||||
## Changes
|
||||
|
||||
- Declare command shortcuts and global or canonical-command-scoped flag
|
||||
spellings with `Alias`, `FlagAlias`, and `FlagAliasFor`. Install routing rules
|
||||
beside canonical commands through `New` or `App.Register`. Aliases route to
|
||||
existing handlers and preserve the target command's operand and binding
|
||||
behavior.
|
||||
- Read the entered and canonical command paths from `Invocation` with
|
||||
`EnteredPath` and `CanonicalPath`.
|
||||
- Interpret a canonical short value flag such as `-c=` as an explicitly
|
||||
supplied empty value, consistent with `--color=` and `-c ""`.
|
||||
- Obtain an invocation-scoped `Reporter` from each handler's `Invocation` for
|
||||
substantive output, Warning/Info/Progress/Debug messages, deferred messages,
|
||||
and managed report rendering. The shared `--quiet`, `--verbose`, `--debug`,
|
||||
and `--progress` flags apply across core and integration commands.
|
||||
- Return terminal explanation text with `Failure`, `Usage`, or
|
||||
`Outcome.WithExplanation`; attach an optional operation error separately with
|
||||
`Outcome.WithCause`. Returned `Outcome.Presentation` records output and
|
||||
diagnostic delivery observations without changing the operation cause.
|
||||
- Install the optional `Completion` command and declare static choices or
|
||||
path hints for interactive shell completion. Bash, Zsh, and Fish scripts
|
||||
use the application's captured commands and routing metadata.
|
||||
- Maintainer release tooling now uses the root-selected ReleaseKit v0.9.0 tool,
|
||||
with separate source publication and optional page-only hosted delivery.
|
||||
|
||||
## Compatibility and migration
|
||||
|
||||
Change core imports from `gitea.maximumdirect.net/eric/CommandKit` to
|
||||
`go.maximumdirect.net/commandkit` and select v0.3.0. These paths identify
|
||||
different Go packages, so their types cannot be mixed. Applications using the
|
||||
integrations should migrate together to the forthcoming matched
|
||||
`go.maximumdirect.net/ckconfig v0.2.0` and
|
||||
`go.maximumdirect.net/ckprompt v0.2.0` releases, which depend on the new core;
|
||||
the existing integration releases retain their old identities.
|
||||
|
||||
`New` and `App.Register` now accept `...Declaration` instead of `...Command`.
|
||||
Command, group, and integration command factories still return `Command`, so
|
||||
individual factory calls can be passed directly. A `[]Command` cannot be
|
||||
expanded into `...Declaration`; use a `[]Declaration` for mixed reusable
|
||||
collections or convert each command element before expanding it. Function
|
||||
values whose parameter type is `...Command`, including wrappers assigned from
|
||||
`New` or `Register`, must update their signature to `...Declaration`.
|
||||
|
||||
Adding private route fields to `Invocation` means positional struct literals no
|
||||
longer compile. `Invocation` no longer contains `Streams`; `App.Execute` supplies
|
||||
the reporter and route paths to handlers. `Streams` remains the argument to
|
||||
`App.Execute`, where the caller owns the result and diagnostic writers.
|
||||
|
||||
Handlers that used `invocation.Streams.Out` or `.Err` should use
|
||||
`invocation.Reporter().Output()` or the categorized reporting methods. Replace
|
||||
`Failure(err)` and `Usage(err)` with an operator-facing explanation and an
|
||||
optional `.WithCause(err)`; causes alone are not rendered. Replace
|
||||
`Outcome.Diagnostics` submissions and terminal `SeverityError` records with
|
||||
reporter messages and an `Outcome` explanation. Provider-returned
|
||||
`Acquired.Diagnostics` remain passive data for the owning integration to
|
||||
submit. Replace `ReportIncomplete`, `DiagnosticDelivery`, and `DeliveryError`
|
||||
with `Outcome.Presentation.Output` and `.Diagnostics`, whose `DeliveryResult`
|
||||
contains a state plus first render and write errors.
|
||||
|
||||
The long flags `--quiet`, `--verbose`, `--debug`, and `--progress` are reserved
|
||||
for CommandKit reporting and cannot be declared by applications. Application
|
||||
aliases may target them. See the current [CLI reporting contract](../cli.md#reporting)
|
||||
and [consumer migration guide](../consumers/guide.md#invocation-reporting) for
|
||||
their behavior and API examples.
|
||||
|
||||
The consumer minimum remains Go 1.26.0 and the maintainer release toolchain
|
||||
remains Go 1.26.8. Core v0.3.0 must be published before preparing dependent
|
||||
integration releases that adopt it and record downloaded dependency sums; this
|
||||
release does not change their v0.1.0 release records.
|
||||
|
||||
See the [consumer guide](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/consumers/guide.md),
|
||||
[CLI reference](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/cli.md),
|
||||
and [maintainer release procedure](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/release.md).
|
||||
|
||||
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/COPYRIGHT) |
|
||||
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/LICENSE) |
|
||||
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/oss/attribution.md)
|
||||
@@ -25,12 +25,28 @@ unselected extensions to the delivered ConfigKit comparison. Consider them only
|
||||
for a concrete consumer need and a deliberate API/behavior decision under the
|
||||
relevant ADRs.
|
||||
|
||||
### Alias completion and discovery
|
||||
|
||||
The delivered [shell completion feature](../cli.md#shell-completion) provides
|
||||
canonical suggestions and alias-aware interpretation. Potential future work
|
||||
includes suggesting command and flag alias spellings and valid continuations of
|
||||
alias-only prefixes. For example, completion could suggest `inspect` after
|
||||
`app legacy ` when `legacy inspect` is an installed alias.
|
||||
|
||||
Alias discovery is outside the initial scope. This deferral does not establish a
|
||||
permanent canonical-only completion contract or prohibit future alias discovery.
|
||||
A later design should determine how alias suggestions are presented alongside
|
||||
canonical suggestions while ensuring inserted continuations form valid next
|
||||
steps. No delivery date or final API is selected here.
|
||||
|
||||
## Other deferred proposals
|
||||
|
||||
These capabilities remain outside the delivered scope and require a concrete
|
||||
operator or consumer need and an appropriate ownership/behavior decision:
|
||||
|
||||
- JSON reports, shell completion, and output-file management or reloadable export.
|
||||
- JSON reports and output-file management or reloadable export.
|
||||
- Shell completion extensions, including alias discovery described above and
|
||||
dynamic discovery of configured or remote values.
|
||||
- Configuration mutation, generic value assignment, and exhaustive field-origin
|
||||
inventories.
|
||||
- Credential diagnostics beyond PromptKit's native smoke checks and additional
|
||||
|
||||
343
docs/roadmap/implementation.md
Normal file
343
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# CommandKit vanity migration and dependency alignment plan
|
||||
|
||||
Status: Stage 1 complete; Stages 2–7 are not started.
|
||||
|
||||
This plan is for the Luna coding agent implementing the
|
||||
[feature roadmap](integration-dependency-alignment.md). Follow the stages in
|
||||
integer order during one end-to-end sprint. Unpublished editing states may be
|
||||
temporarily inconsistent. Each published module must be a coherent, committed
|
||||
candidate validated by its ordinary release workflow.
|
||||
|
||||
This planning pass changes documentation only. The target includes three real
|
||||
source releases because later modules need downloadable predecessors. The
|
||||
publication stages below describe that future work; this planning request does
|
||||
not perform or authorize Git pushes. Honor the user's implementation/publication
|
||||
scope when executing the plan, and report any unperformed release as pending.
|
||||
|
||||
Read [development guidance](../development.md), the
|
||||
[agent policy](../policy/agents.md), [architecture policy](../policy/architecture.md),
|
||||
[testing policy](../policy/testing.md),
|
||||
[documentation policy](../policy/documentation.md),
|
||||
[release policy](../policy/release.md), and [OSS policy](../policy/oss.md) for
|
||||
the work they govern. Follow `AGENTS.md` codebase-memory discovery instructions.
|
||||
The [release procedure](../release.md) remains the owner of operational commands.
|
||||
|
||||
The previous plan's [completed ReleaseKit v0.9.0 stages and evidence](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/c8d659dfe5ae3e834b3aeabdfd0fe29b736dc05a/docs/roadmap/implementation.md)
|
||||
remain in Git history. R1/R2 in [remediation.md](remediation.md) and the ReleaseKit
|
||||
adoption are complete; do not restart them or rewrite their historical evidence.
|
||||
|
||||
## Baseline and fixed decisions
|
||||
|
||||
Planning baseline: `c8d659dfe5ae3e834b3aeabdfd0fe29b736dc05a`.
|
||||
|
||||
| Module directory | Current module path | Target module path | Target version and full tag |
|
||||
| --- | --- | --- | --- |
|
||||
| `.` | `gitea.maximumdirect.net/eric/CommandKit` | `go.maximumdirect.net/commandkit` | v0.3.0; `v0.3.0` |
|
||||
| `integrations/ckconfig` | `gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig` | `go.maximumdirect.net/ckconfig` | v0.2.0; `integrations/ckconfig/v0.2.0` |
|
||||
| `integrations/ckprompt` | `gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt` | `go.maximumdirect.net/ckprompt` | v0.2.0; `integrations/ckprompt/v0.2.0` |
|
||||
|
||||
- Both integrations currently require core v0.2.0; ckprompt also requires
|
||||
ckconfig v0.1.0. Their source uses newer core APIs. Independent baseline suites
|
||||
fail; temporary local-source selections pass only when ckprompt also uses
|
||||
current ckconfig. The feature roadmap records those diagnostics.
|
||||
- Target ckconfig requires `go.maximumdirect.net/commandkit v0.3.0`. Target
|
||||
ckprompt requires that core and `go.maximumdirect.net/ckconfig v0.2.0`.
|
||||
- Vanity discovery is verified. The integration metadata's fourth field maps
|
||||
the short paths to the existing physical directories. Existing tags still
|
||||
declare old identities. Discovery alone does not complete module migration.
|
||||
- Preserve package names, exported declaration shapes, runtime behavior, Go
|
||||
1.26.0 consumer minimums, and the Go 1.26.8 maintainer toolchain. Preserve root
|
||||
ReleaseKit v0.9.0 selection, scripts, profiles, endpoints, and validators.
|
||||
- Retain ConfigKit v0.8.0 and PromptKit v0.16.0; no upstream library upgrade is
|
||||
selected. Inspect tidy's actual graph changes and update affected attribution.
|
||||
- The three proposed tags were unused at planning time. Existing publication
|
||||
guards recheck freshness; never move/reuse a published tag. A conflict needs
|
||||
a deliberate new version selection and matching dependent metadata/notes.
|
||||
- Retain ignored `go.work` and `go.work.sum`, and the supplied ignored
|
||||
`.releasekit/gitea-token` without reading its contents. No credential or live
|
||||
provider is needed for local validation. Source publication uses the existing
|
||||
Git endpoint; hosted delivery remains a separate operation/outcome.
|
||||
|
||||
Do not rename physical module directories or flatten integration tag prefixes.
|
||||
Do not add an old-path forwarding module, type-interoperability layer, API
|
||||
rollback, permanent validation shim, or new recurring release gate. These are
|
||||
outside the selected migration, not new restrictions on application behavior.
|
||||
|
||||
## Execution and validation conventions
|
||||
|
||||
Use Go 1.26.8 on `PATH` following the existing
|
||||
[toolchain setup](../release.md#release-toolchain). The ignored workspace declares
|
||||
Go 1.26.10 at baseline; preserve it. Use `GOWORK=off GOTOOLCHAIN=local` for module
|
||||
preparation and independent validation. Use normal dependency retrieval during
|
||||
preparation; ReleaseKit runs the final selected-module suite offline/read-only.
|
||||
|
||||
Migrate module-owned source, tests, and examples together. Apply integration
|
||||
path mappings before the broader core prefix when a replacement tool is used;
|
||||
`go.maximumdirect.net/commandkit/integrations/ckconfig` is not the selected
|
||||
ckconfig identity. Preserve import aliases such as `commandkit`. Core `internal`
|
||||
imports must use the new core prefix. Review identity-sensitive strings as well
|
||||
as Go import declarations.
|
||||
|
||||
Update current prose and GoDoc for the module being migrated. Treat repository,
|
||||
license, and immutable historical source links separately from Go import paths.
|
||||
Keep published notes, accepted ADR history, and completed review evidence intact.
|
||||
Do not globally replace every old URL or migrate unrelated upstream identities.
|
||||
|
||||
Publication steps use the existing protocol: prepare dependencies, review and
|
||||
commit the final selected candidate on `main`, publish that candidate commit to
|
||||
the authoritative branch, and invoke the module's release wrapper. The wrapper
|
||||
runs the owning complete suite once, selected file checks, and publication
|
||||
guards, then creates/pushes the intended tag. It does not push `main` for you.
|
||||
Run validation-only `check` for diagnosis when useful; do not make it a required
|
||||
duplicate suite before `publish`. Do not add CI/proxy polling or post-push
|
||||
read-back as a successful-publication gate. Reuse evidence for unchanged content.
|
||||
|
||||
Each release has its own candidate commit, suite result, and publication outcome.
|
||||
The next module's dependency download is preparation for that next release;
|
||||
failure to download does not undo completion of the predecessor's source push.
|
||||
Use a command-scoped direct download to diagnose proxy lag if necessary rather
|
||||
than permanently changing proxy settings. Retain authentic downloaded sums.
|
||||
|
||||
## Stage 1: Migrate core identity and prepare its release candidate
|
||||
|
||||
**Completed.** The root module, owned imports, component build identity,
|
||||
consumer guidance, and v0.3.0 release note use the new core identity. The
|
||||
focused build-identity test passed offline with Go 1.26.8; affected links and
|
||||
whitespace were checked. The complete candidate suite belongs to publication.
|
||||
|
||||
1. Change only the root module declaration to `go.maximumdirect.net/commandkit`.
|
||||
Migrate root-owned imports in production files, root/external tests, internal
|
||||
packages, and `examples/core`. Leave nested module migration to Stages 3/5.
|
||||
Preserve the root tool declaration and other dependency versions; prepare
|
||||
caches and tidy the root with workspace discovery off.
|
||||
2. Update `commandKitModulePath` in `builtins.go` to the new core identity.
|
||||
`version --scope commandkit` uses it to find build information. Retain its
|
||||
existing application/component scopes, tag/replacement handling, and `dev`
|
||||
fallback; do not add a legacy-identity fallback.
|
||||
3. Adapt existing identity/import-path test expectations. In
|
||||
`TestCommandKitBuildIdentityProjectsEffectiveModuleVersion`, make at least
|
||||
one existing successful tagged case supply the literal new module path
|
||||
independently of `commandKitModulePath`. Otherwise a stale constant and its
|
||||
self-referencing fixtures could agree while real version lookup fails.
|
||||
Preserve all existing replacement/development cases and boundary assertions.
|
||||
4. Update the root row in README's module table, current core imports and
|
||||
installation guidance in `docs/consumers/guide.md`, affected developer and
|
||||
internal guidance, example documentation, and GoDoc. Describe integrations
|
||||
according to their implemented identity until their migration stages land;
|
||||
keep the matched future set in migration guidance explicit.
|
||||
5. Refresh `docs/releases/v0.3.0.md` against the full v0.2.0-to-candidate change:
|
||||
aliases, reporting migration, completion, ReleaseKit maintenance, and the new
|
||||
core import path. Explain consumer updates and the forthcoming matched
|
||||
integration versions. Use current procedure URLs with immutable `v0.3.0`
|
||||
source/licensing links. Keep the declared toolchains unchanged.
|
||||
6. Format changed Go files. Review root-owned imports/identity literals and
|
||||
module metadata; check affected prose links and whitespace. Run the existing
|
||||
focused identity test with prepared caches:
|
||||
|
||||
```sh
|
||||
GOWORK=off GOTOOLCHAIN=local GOPROXY=off GONOPROXY=none GOSUMDB=off \
|
||||
go test -mod=readonly -count=1 -run '^TestCommandKitBuildIdentityProjectsEffectiveModuleVersion$' .
|
||||
```
|
||||
|
||||
**Stage result:** a reviewed core v0.3.0 candidate with the new identity, correct
|
||||
component version lookup, migrated root consumers, and current release notes.
|
||||
The full root suite belongs to Stage 2's normal publication workflow.
|
||||
|
||||
## Stage 2: Validate and publish core v0.3.0
|
||||
|
||||
**Status:** not started.
|
||||
|
||||
1. Review and commit the complete core candidate under the local commit policy.
|
||||
Confirm that unrelated/private files and ignored workspaces are excluded.
|
||||
The independently released integration sources may still declare old paths;
|
||||
that does not make their suites a prerequisite for this root release.
|
||||
2. From the repository root, execute the existing publication sequence within
|
||||
the authorized release scope:
|
||||
|
||||
```sh
|
||||
git push origin HEAD:refs/heads/main
|
||||
./scripts/release core v0.3.0
|
||||
```
|
||||
|
||||
3. Require the real selected-module suite and file checks to pass. Fix actual
|
||||
failures in scope and commit the corrected candidate before retrying. On a
|
||||
failed/ambiguous push, use the existing publication evidence and recovery
|
||||
procedure. Do not force refs, bypass guards, or treat a local tag as publication.
|
||||
4. Record the candidate commit, module path, full tag, validation outcome, and
|
||||
confirmed source outcome here. A successful tag push completes this source
|
||||
release; move to dependent preparation without waiting for hosted pages.
|
||||
|
||||
**Stage result:** core v0.3.0 is source-published with
|
||||
`module go.maximumdirect.net/commandkit`. Actual download of this version and its
|
||||
sums is part of Stage 3, not a new completion gate for core publication.
|
||||
|
||||
## Stage 3: Migrate ckconfig and prepare its independent candidate
|
||||
|
||||
**Status:** not started.
|
||||
|
||||
1. In `integrations/ckconfig`, migrate the module declaration to
|
||||
`go.maximumdirect.net/ckconfig`. Update all owned imports, tests, and the
|
||||
maintained simple example to that self-path and the new core path. Keep
|
||||
package names and existing adapter behavior.
|
||||
2. Replace its old core dependency key with the published new-path version.
|
||||
From the ckconfig module directory, use:
|
||||
|
||||
```sh
|
||||
GOWORK=off GOTOOLCHAIN=local go mod edit -module=go.maximumdirect.net/ckconfig -droprequire=gitea.maximumdirect.net/eric/CommandKit -require=go.maximumdirect.net/commandkit@v0.3.0
|
||||
GOWORK=off GOTOOLCHAIN=local go mod download go.maximumdirect.net/commandkit@v0.3.0
|
||||
GOWORK=off GOTOOLCHAIN=local go mod tidy
|
||||
```
|
||||
|
||||
Perform source import migration before tidy to avoid resolving old imports.
|
||||
Keep real downloaded sums. If retrieval is unavailable, resolve dependency
|
||||
preparation before proceeding; do not fabricate metadata or substitute a
|
||||
local replacement as final release evidence.
|
||||
3. Inspect the actual direct and indirect graph, including tool requirements.
|
||||
Preserve ConfigKit v0.8.0 and unrelated versions. Update affected notices in
|
||||
`integrations/ckconfig/docs/oss/attribution.md` under the OSS policy using the
|
||||
actual selected source revisions; add/retain applicable license texts. Do
|
||||
not assume similarly named old/new upstream modules are interchangeable.
|
||||
4. Prepare `integrations/ckconfig/docs/releases/v0.2.0.md` with product
|
||||
`CommandKit ckconfig`, the new module/core paths, compatible dependency set,
|
||||
and relevant reporting/completion migration. Immutable footer/source URLs
|
||||
use the full tag `integrations/ckconfig/v0.2.0` and correct physical files.
|
||||
5. Update README's ckconfig row, consumer module/import examples, the simple
|
||||
example guidance, affected GoDoc, and relevant current release/development
|
||||
references. Explain that applications moving to the new core identity also
|
||||
use the matched new integration identity.
|
||||
6. Format changed Go files. Review for stale owned imports/direct requirement
|
||||
keys, accidental replacements/workspace files, and unexplained graph churn.
|
||||
Check current links and whitespace. The owning complete suite runs in
|
||||
Stage 4; a local workspace pass is not a substitute.
|
||||
|
||||
**Stage result:** a ckconfig v0.2.0 candidate whose actual dependency is
|
||||
downloaded `go.maximumdirect.net/commandkit v0.3.0`, with authentic metadata and
|
||||
aligned current documentation/attribution.
|
||||
|
||||
## Stage 4: Validate and publish ckconfig v0.2.0
|
||||
|
||||
**Status:** not started.
|
||||
|
||||
Review and commit the complete ckconfig candidate. From the repository root,
|
||||
use the same authoritative-branch protocol as Stage 2:
|
||||
|
||||
```sh
|
||||
git push origin HEAD:refs/heads/main
|
||||
./scripts/release ckconfig v0.2.0
|
||||
```
|
||||
|
||||
The normal release command must pass the independent ckconfig suite, including
|
||||
its maintained example, with workspace discovery off and without dependency
|
||||
redirection. It must also pass selected committed-file checks and publication
|
||||
guards. Do not insert the earlier temporary Go shim or weaken a selected check.
|
||||
Apply the existing failed/ambiguous-publication procedure if needed.
|
||||
|
||||
Record the candidate, path, version, full tag, validation, and source outcome.
|
||||
The intended tag is `integrations/ckconfig/v0.2.0`; the physical directory remains
|
||||
`integrations/ckconfig` despite the short vanity module path.
|
||||
|
||||
**Stage result:** compatible ckconfig v0.2.0 is source-published under its vanity
|
||||
identity. Downloading it for ckprompt is the next module's preparation.
|
||||
|
||||
## Stage 5: Migrate ckprompt and prepare its independent candidate
|
||||
|
||||
**Status:** not started.
|
||||
|
||||
1. Migrate `integrations/ckprompt` to `go.maximumdirect.net/ckprompt`. Update its
|
||||
owned source/tests and composed example to import the new core, ckconfig,
|
||||
and ckprompt paths. Preserve PromptKit adaptation and reporting behavior.
|
||||
2. From that module directory, replace both old CommandKit dependency keys:
|
||||
|
||||
```sh
|
||||
GOWORK=off GOTOOLCHAIN=local go mod edit -module=go.maximumdirect.net/ckprompt -droprequire=gitea.maximumdirect.net/eric/CommandKit -droprequire=gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig -require=go.maximumdirect.net/commandkit@v0.3.0 -require=go.maximumdirect.net/ckconfig@v0.2.0
|
||||
GOWORK=off GOTOOLCHAIN=local go mod download go.maximumdirect.net/commandkit@v0.3.0 go.maximumdirect.net/ckconfig@v0.2.0
|
||||
GOWORK=off GOTOOLCHAIN=local go mod tidy
|
||||
```
|
||||
|
||||
Migrate imports before tidy. Retain actual downloaded sums; resolve fetch
|
||||
failures as preparation rather than masking them with local source. A new
|
||||
core with old ckconfig v0.1.0 is the known incompatible combination.
|
||||
3. Review graph changes while retaining ConfigKit v0.8.0 and PromptKit v0.16.0.
|
||||
Update affected module-local attribution and immutable notice links based on
|
||||
the resolved graph, including transitive tools. Keep actual upstream module
|
||||
identities distinct from the three CommandKit identities being migrated.
|
||||
4. Prepare `integrations/ckprompt/docs/releases/v0.2.0.md` with product
|
||||
`CommandKit ckprompt`, new imports/dependencies, and consumer migration.
|
||||
Footer/source links use full tag `integrations/ckprompt/v0.2.0` and physical
|
||||
module-local files. Preserve the published v0.1.0 record.
|
||||
5. Update README's ckprompt row, consumer composition/import examples, composed
|
||||
example guidance, affected GoDoc, and remaining current module descriptions.
|
||||
Keep URLs to hosted repository files separate from Go package links.
|
||||
6. Format changed Go files and review remaining owned old-path imports or direct
|
||||
requirement keys, metadata, links, and whitespace. Keep the ordinary source
|
||||
checks intact; the independent suite runs in Stage 6.
|
||||
|
||||
**Stage result:** ckprompt v0.2.0 composes the real new-path core and ckconfig
|
||||
versions in its declared graph, with current guidance and authentic metadata.
|
||||
|
||||
## Stage 6: Validate and publish ckprompt v0.2.0
|
||||
|
||||
**Status:** not started.
|
||||
|
||||
Review and commit the complete ckprompt candidate. From the repository root:
|
||||
|
||||
```sh
|
||||
git push origin HEAD:refs/heads/main
|
||||
./scripts/release ckprompt v0.2.0
|
||||
```
|
||||
|
||||
The normal publication workflow must pass the independent ckprompt suite and
|
||||
composed consumer with actual downloaded predecessors, plus selected file and
|
||||
publication checks. Preserve the offline/read-only suite environment and
|
||||
ordinary recovery handling. No workspace or replacement shim supplies final
|
||||
release evidence.
|
||||
|
||||
Record its candidate, module identity, full tag
|
||||
`integrations/ckprompt/v0.2.0`, suite outcome, and source-publication outcome.
|
||||
Report any separate hosted delivery under its own outcome; page publication
|
||||
is not required to establish dependency alignment.
|
||||
|
||||
**Stage result:** all three matched source releases are published with vanity
|
||||
module declarations and verified independent dependency graphs.
|
||||
|
||||
## Stage 7: Audit the completed migration and record evidence
|
||||
|
||||
**Status:** not started.
|
||||
|
||||
1. Review the cumulative diff against the feature roadmap. Confirm all three
|
||||
module declarations, owned imports, examples, direct dependency keys, and
|
||||
component build identity use the new paths. Preserve physical directories,
|
||||
tag prefixes, root ReleaseKit selection, scripts/profiles, exported shapes,
|
||||
Go minimums, and upstream versions apart from explained graph consequences.
|
||||
2. Check that tests and current guidance use the new identities while historical
|
||||
notes, accepted ADRs, and immutable source URLs retain their historical
|
||||
meaning. Resolve current dependency-readiness claims that are now stale.
|
||||
Do not mechanically rewrite every old-path reference in completed records.
|
||||
3. Record each final candidate's exact commit, full tag, suite result, and
|
||||
publication outcome. Identify diagnostic-only earlier evidence separately.
|
||||
Reuse each successful publication suite for its unchanged candidate; do not
|
||||
require another complete run just to finish this stage. Any later code or
|
||||
metadata correction belongs in a new candidate/version under release policy.
|
||||
4. Mark stages complete only with their stated evidence and update the feature
|
||||
roadmap's status/link here. Preserve this record and prior immutable plans;
|
||||
apply normal roadmap retirement only during later preparation. If publication
|
||||
was outside the authorized implementation scope or could not finish, record
|
||||
the actual prepared state and pending release stages rather than claiming
|
||||
alignment is complete.
|
||||
5. Review new evidence prose, links/fragments, intended staged files, and
|
||||
`git diff --check` / `git diff --cached --check`. Commit any remaining
|
||||
nonempty documentation unit with a short plain-English message, following
|
||||
the local commit policy. Finish with a clean tracked worktree when only
|
||||
planned work is present; preserve unrelated and ignored local files.
|
||||
|
||||
**Stage result:** the implemented and published matched version set has accurate
|
||||
source/validation evidence, current migration guidance, and committed tracking.
|
||||
Source release and hosted-delivery outcomes remain separate.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None required to choose the implementation. Vanity paths, target versions,
|
||||
physical layout, dependency order, validation ownership, and compatibility scope
|
||||
are selected in the roadmap. Publication authorization is an execution-scope
|
||||
constraint, not a reason to invent different versions, fabricated sums, or a
|
||||
local-only substitute for the target end state.
|
||||
208
docs/roadmap/integration-dependency-alignment.md
Normal file
208
docs/roadmap/integration-dependency-alignment.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Integration dependency alignment and vanity-path migration
|
||||
|
||||
Status: proposed release-preparation roadmap. This pass adds documentation only;
|
||||
dependency changes and publication remain future work. The
|
||||
[implementation plan](implementation.md) owns execution stages, concrete tasks,
|
||||
and validation tracking; this roadmap owns the target and selected scope.
|
||||
|
||||
## Purpose and authority
|
||||
|
||||
Align the independently released integrations with the core APIs their current
|
||||
source already uses, and migrate all three CommandKit modules to the supplied
|
||||
vanity paths. Publish compatible module versions in dependency order so each
|
||||
integration builds and validates against its declared, downloaded dependencies
|
||||
with workspace discovery off.
|
||||
|
||||
The [release procedure](../release.md) owns publication commands and release
|
||||
order. The [release policy](../policy/release.md),
|
||||
[agent policy](../policy/agents.md), [testing policy](../policy/testing.md),
|
||||
[documentation policy](../policy/documentation.md), and
|
||||
[OSS policy](../policy/oss.md) govern the work. This roadmap selects the target
|
||||
and release-preparation scope; it does not change those policies.
|
||||
|
||||
## Baseline and investigation evidence
|
||||
|
||||
Reviewed baseline: `289a171e97699ad9aa57ee2cc40bdad1c0b680a9`.
|
||||
Vanity-path review baseline: `d7cbaed3ed18bc2909c8144f308e2dfd58ade7ec`.
|
||||
Module declarations and library source were unchanged between these revisions.
|
||||
|
||||
- Both integration module declarations require core v0.2.0. The
|
||||
[ckprompt declaration](../../integrations/ckprompt/go.mod) also requires
|
||||
ckconfig v0.1.0; the [ckconfig declaration](../../integrations/ckconfig/go.mod)
|
||||
has no dependency on ckprompt.
|
||||
- Current integration source uses newer core APIs including `CompletionHint`,
|
||||
`Explanation`, and `Reporter`. Both independent Go 1.26.8 suites fail to
|
||||
compile with `GOWORK=off` and their existing read-only module graphs.
|
||||
- Updating core alone is insufficient for ckprompt: published ckconfig v0.1.0
|
||||
still uses removed core APIs, including `Invocation.Streams`,
|
||||
`Outcome.Diagnostics`, and `SeverityError`, plus the old `Failure(error)`
|
||||
call shape.
|
||||
- The authoritative `origin` tag listing contains core v0.1.0 and v0.2.0, and
|
||||
the two integration v0.1.0 tags. The proposed next versions below were unused
|
||||
at review. Remote `main` was `230187296fae5253cb1773f16c2f1d1a39499ed0`;
|
||||
the reviewed local changes still need normal publication to the authoritative
|
||||
branch as part of candidate preparation.
|
||||
- A checked-in [core v0.3.0 draft](../releases/v0.3.0.md) already describes the
|
||||
reporting and alias migration. It is an unpublished draft, not proof that
|
||||
the module version can be downloaded.
|
||||
|
||||
The investigation used disposable alternate module files, Go 1.26.8,
|
||||
`GOWORK=off`, and read-only offline tests. Only temporary dependency selections
|
||||
changed; the real module declarations, sums, and source remained unchanged.
|
||||
|
||||
| Current source under test | Temporary dependency selection | Result |
|
||||
| --- | --- | --- |
|
||||
| ckconfig | Current core source | Module suite and maintained example passed. |
|
||||
| ckprompt | Current core source, published ckconfig v0.1.0 | Compilation failed in the old ckconfig implementation. |
|
||||
| ckprompt | Current core and current ckconfig source | Module suite and maintained example passed. |
|
||||
|
||||
The successful cases retained ConfigKit v0.8.0 and PromptKit v0.16.0. These
|
||||
results isolate the CommandKit dependency alignment needed here; they do not
|
||||
prove that future published versions or their downloaded sums already exist.
|
||||
|
||||
### Verified vanity discovery
|
||||
|
||||
The core endpoint at `https://go.maximumdirect.net/commandkit?go-get=1` serves
|
||||
the mapping to `https://forge.maximumdirect.net/base-layer/commandkit`. Go direct
|
||||
discovery finds the existing core v0.1.0 and v0.2.0 tags. Fetching v0.2.0 through
|
||||
the vanity path reaches its source but reports a module-path mismatch because
|
||||
that release declares `gitea.maximumdirect.net/eric/CommandKit`.
|
||||
|
||||
Both integration endpoints now return HTTP 200 with these `go-import` contents:
|
||||
|
||||
| Vanity path | VCS | Repository URL | Subdirectory field |
|
||||
| --- | --- | --- | --- |
|
||||
| `go.maximumdirect.net/ckconfig` | `git` | `https://forge.maximumdirect.net/base-layer/commandkit` | `integrations/ckconfig` |
|
||||
| `go.maximumdirect.net/ckprompt` | `git` | `https://forge.maximumdirect.net/base-layer/commandkit` | `integrations/ckprompt` |
|
||||
|
||||
Go 1.26.8 direct discovery finds each integration's v0.1.0 tag. Disposable
|
||||
consumer fetches reach the correct nested `go.mod` and report its old declared
|
||||
module path, confirming subdirectory routing. Discovery is working; the
|
||||
existing tags have not migrated. The optional fourth metadata field is
|
||||
supported from Go 1.25, within the existing Go 1.26.0 consumer minimum; see the
|
||||
[Go discovery reference](https://go.dev/ref/mod#vcs-find).
|
||||
|
||||
## Targeted end state
|
||||
|
||||
### Compatible independently released modules
|
||||
|
||||
Use the following proposed next versions, subject to the existing fresh-version
|
||||
publication guards:
|
||||
|
||||
| Module path | Physical module directory | Next release | Required CommandKit dependencies | Full tag |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `go.maximumdirect.net/commandkit` | `.` | v0.3.0 | None | `v0.3.0` |
|
||||
| `go.maximumdirect.net/ckconfig` | `integrations/ckconfig` | v0.2.0 | `go.maximumdirect.net/commandkit v0.3.0` | `integrations/ckconfig/v0.2.0` |
|
||||
| `go.maximumdirect.net/ckprompt` | `integrations/ckprompt` | v0.2.0 | `go.maximumdirect.net/commandkit v0.3.0` and `go.maximumdirect.net/ckconfig v0.2.0` | `integrations/ckprompt/v0.2.0` |
|
||||
|
||||
Minor integration releases fit the existing pre-1.0 compatibility policy: the
|
||||
reporting migration changes consumer-facing behavior and adopts a core version
|
||||
with incompatible API changes. The vanity migration also deliberately changes
|
||||
Go module and import identities. Migration notes explain the new imports and
|
||||
dependency set, provider results, invocation reporting, and application changes.
|
||||
|
||||
The three modules retain independent versions and source tags. Their candidates
|
||||
may identify successive commits: core publication need not wait for integration
|
||||
metadata that depends on that newly published core. Each selected module's
|
||||
final release suite runs against its actual declared dependencies.
|
||||
|
||||
### Module and import identity
|
||||
|
||||
Change each module declaration and all CommandKit-owned imports to the matching
|
||||
new path. Core self-imports, including its `internal` packages, use
|
||||
`go.maximumdirect.net/commandkit/...`; integration self-imports use
|
||||
`go.maximumdirect.net/ckconfig/...` or `go.maximumdirect.net/ckprompt/...`.
|
||||
Cross-module imports and requirements use the complete new paths in the table,
|
||||
including ckprompt's ckconfig dependency. Core component-version discovery uses
|
||||
`go.maximumdirect.net/commandkit` as its build-info identity, retaining the
|
||||
existing `version --scope commandkit` behavior with the new module path.
|
||||
|
||||
Update production files, tests, maintained examples, and any import-path-based
|
||||
test expectations together. A declared vanity module that still imports the
|
||||
old core path would select a different Go package identity and can violate
|
||||
`internal` import rules. The matched new releases compose through one core
|
||||
identity rather than mixing old-path and new-path CommandKit types.
|
||||
|
||||
Keep physical directories, repository URLs, and Git tag prefixes as shown. The
|
||||
Go subdirectory metadata makes the short integration vanity paths resolve to
|
||||
the existing layout. The existing release profiles select physical directories;
|
||||
the migration does not rename those directories or flatten integration tags.
|
||||
|
||||
Published old-path versions remain immutable. This scope selects the new paths
|
||||
for new releases and updates consumer guidance; it makes no new promise of
|
||||
type interoperability between the two identities and adds no old-path forwarding
|
||||
module or compatibility layer.
|
||||
|
||||
### Retained architecture and scope
|
||||
|
||||
Preserve exported declaration shapes and runtime behavior while deliberately
|
||||
changing module/import identities. Retain the integration adapters, Go 1.26.0
|
||||
consumer minimum, Go 1.26.8 maintainer toolchain, and root-selected ReleaseKit
|
||||
v0.9.0. The earlier successful diagnostic suites establish the dependency fix
|
||||
before renaming; the migrated source still needs its own validation. This work
|
||||
selects no API rollback or shim for old reporting APIs.
|
||||
|
||||
Retain ConfigKit v0.8.0, PromptKit v0.16.0, and other dependency selections
|
||||
unless ordinary tidy changes the resolved graph as a consequence of the
|
||||
CommandKit updates. Inspect and explain the resulting graph rather than
|
||||
bundling unrelated upgrades. In particular, transitive tools with different
|
||||
module paths may coexist; attribution must reflect the actual selection.
|
||||
|
||||
Preserve the existing launchers, three release profiles, generic validators,
|
||||
credential handling, and separate source-publication and hosted-delivery
|
||||
outcomes. Development workspaces remain useful and ignored. Diagnostic local
|
||||
replacements and the earlier temporary Go shim remain development evidence;
|
||||
release validation uses normal downloaded dependencies without that redirection.
|
||||
|
||||
## Release-preparation deliverables
|
||||
|
||||
The core v0.3.0 draft describes the complete candidate, including completion,
|
||||
reporting, aliases, tool maintenance, and the vanity-path migration. New
|
||||
integration notes at `integrations/ckconfig/docs/releases/v0.2.0.md` and
|
||||
`integrations/ckprompt/docs/releases/v0.2.0.md` explain the matched dependency set
|
||||
and consumer migration. Their source and licensing links follow the current
|
||||
release procedure; published notes and tags remain immutable.
|
||||
|
||||
Current installation examples, README module selections, consumer/developer
|
||||
guidance, affected GoDoc, and maintained examples use the implemented new
|
||||
identities. Import-path-aware tests retain their existing boundary assertions.
|
||||
Source-hosting and license URLs remain repository links, distinct from Go
|
||||
import paths. Module-local attribution describes each actual resolved graph.
|
||||
|
||||
The final record identifies module paths, versions, candidate revisions,
|
||||
validation evidence, and independent source-publication outcomes. Current
|
||||
readiness guidance reflects the aligned state; historical failures remain in
|
||||
completed investigation records. The implementation plan owns the ordered
|
||||
migration, dependency preparation, release, and evidence-recording steps.
|
||||
|
||||
## Validation and completion boundaries
|
||||
|
||||
Review current CommandKit-owned source and dependency selections for stale
|
||||
self-imports or old direct dependency keys. Historical notes, source URLs, and
|
||||
unrelated upstream module identities are outside that mechanical rename.
|
||||
Confirm release identity still pairs each new module path with its existing
|
||||
physical directory and tag prefix. The verified redirects are preparation
|
||||
evidence; publishing the renamed modules is needed for real consumers to use
|
||||
them through those paths.
|
||||
|
||||
Use the selected module's ordinary deterministic offline suite with
|
||||
`GOWORK=off` and read-only metadata after dependency preparation. Its maintained
|
||||
consumer compiles and runs with that suite. ReleaseKit's publication command
|
||||
owns the final candidate suite and selected file checks; validation-only checks
|
||||
remain optional diagnostics. Reuse successful evidence for unchanged candidates
|
||||
instead of adding a required check-then-publish repetition.
|
||||
|
||||
Retrieving a newly published dependency is preparation for the next module's
|
||||
release. It does not turn proxy availability or consumer installation into a
|
||||
completion gate for the preceding source release. Continue to resolve failed
|
||||
or ambiguous publication using the existing policy and upstream recovery flow.
|
||||
Hosted Gitea delivery keeps its separate outcome; this roadmap adds no CI
|
||||
polling, live provider calls, or additional publication gate.
|
||||
|
||||
Alignment is complete when the matched source versions are published under all
|
||||
three vanity module paths, owned imports and examples use the new identities,
|
||||
each integration declares the compatible vanity-path versions with authentic
|
||||
downloaded sums, each final selected-module suite passes without workspace or
|
||||
replacement redirection, and current documentation and attribution agree with
|
||||
that graph. Temporary local-source success alone does not establish that end
|
||||
state.
|
||||
198
docs/roadmap/release-simplification.md
Normal file
198
docs/roadmap/release-simplification.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# Release tooling simplification
|
||||
|
||||
Status: the selected release-tooling design has been implemented. All four
|
||||
generic file checks, registered-tool execution, page-only hosted delivery, and
|
||||
protection of the supplied token path are selected.
|
||||
The [completed implementation record](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/87eb84ccb462bf54e6250c8b287ac217efe90883/docs/roadmap/implementation.md)
|
||||
contains stage order, tasks, validation, and completion evidence. Current release
|
||||
behavior stays with its canonical owners.
|
||||
|
||||
## Purpose and authority
|
||||
|
||||
Reduce CommandKit's release infrastructure to declarative module profiles,
|
||||
a small launcher, and focused tests of CommandKit-owned wiring. Delegate
|
||||
reusable candidate validation and publication mechanics to ReleaseKit while
|
||||
retaining CommandKit's domain-specific checks in their existing owners. Add
|
||||
hosted Gitea release pages as a separate local delivery operation after source
|
||||
publication.
|
||||
|
||||
The [agent policy](../policy/agents.md),
|
||||
[release policy](../policy/release.md#candidate-validation), and
|
||||
[testing policy](../policy/testing.md#upstream-integration-testing-ownership)
|
||||
govern implementation. The [release procedure](../release.md) continues to own
|
||||
the current workflow.
|
||||
|
||||
## Targeted end state
|
||||
|
||||
### Ownership
|
||||
|
||||
CommandKit retains module selectors, product identities, profile locations,
|
||||
the authoritative endpoint selection, release order, release-note meaning,
|
||||
compatibility decisions, and attribution accuracy. Its existing public API,
|
||||
CLI, integration-adaptation, and consumer-example tests remain local and run
|
||||
through the owning module's suite. No parallel release-only domain suite is
|
||||
needed unless a concrete CommandKit requirement is otherwise unprotected.
|
||||
|
||||
ReleaseKit supplies the standard offline candidate suite, selected generic file
|
||||
checks, candidate/note and publication guards, tag creation and publication,
|
||||
hosted delivery, outcome reporting, and recovery. CommandKit's launcher delegates
|
||||
those operations. Existing source-release completion and immutability rules
|
||||
continue to apply. Hosted pages are the added delivery channel; binary
|
||||
deliverables and platform support commitments remain unchanged.
|
||||
|
||||
### Profiles
|
||||
|
||||
Select these existing ReleaseKit options in each profile:
|
||||
|
||||
| Selection | Intended protection |
|
||||
| --- | --- |
|
||||
| `validation.go_format: true` | Detect unformatted committed Go source. |
|
||||
| `validation.markdown: {}` | Detect broken local Markdown destinations and supported heading fragments. |
|
||||
| `validation.module_distribution.workspace_files: true` | Detect committed development workspace files in selected inputs. |
|
||||
| `validation.module_distribution.local_replacements: true` | Detect filesystem replacements in selected committed module declarations. |
|
||||
|
||||
Also select
|
||||
`validation.forbidden_files: [".releasekit/gitea-token"]` in all three profiles.
|
||||
This delegates protection of the supplied credential path to ReleaseKit's
|
||||
existing check. The path is repository-relative and the check applies across
|
||||
the captured repository even for an integration release. It does not read token
|
||||
contents, scan other paths, or inspect Git history.
|
||||
|
||||
Retain the explicit shared-file lists and use ReleaseKit's existing input scope.
|
||||
The root profile does not run every nested module's suite. File checks inspect
|
||||
committed inputs and do not replace formatting worktree changes or reviewing
|
||||
documentation meaning, attribution, and external links. Markdown keeps its
|
||||
default treatment of outside-checkout destinations; no stricter boundary mode
|
||||
is proposed.
|
||||
|
||||
The pinned [ReleaseKit configuration reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/23203f235cc414c4ffe0fd3941030eb0c9f9943f/docs/config.md)
|
||||
owns exact scope and behavior, including nested-module boundaries and vendored
|
||||
inputs. Do not expand an option's scope into a general repository prohibition.
|
||||
Race testing, full vet, compilation matrices, size limits, and additional
|
||||
forbidden-file paths are not selected by this roadmap. They can be considered
|
||||
separately for demonstrated needs.
|
||||
|
||||
### Launcher and recovery
|
||||
|
||||
Use the root module's registered `go tool releasekit` through `exec`, keeping
|
||||
the current selector interface and publication argument mapping.
|
||||
Retain host normalization, workspace-off execution, the local toolchain, and
|
||||
read-only module metadata. Preserve other effective Go settings while arranging
|
||||
read-only selection.
|
||||
The implementation plan specifies the wrapper interface and invocation details.
|
||||
|
||||
Go's cache supplies executable storage; the launcher creates no persistent
|
||||
binary directory. `/.releasekit/` remains ignored for the credential file, with
|
||||
an explanatory comment. Recovery instructions use the registered tool or the
|
||||
entry point printed for the saved attempt, preserving ReleaseKit's distinction
|
||||
between publication outcome and record-storage errors.
|
||||
|
||||
Share module selection and host-safe tool invocation between publication,
|
||||
delivery, and recovery where applicable. Delivery selects the same module
|
||||
profile as publication; recovery uses its recorded attempt rather than inferring
|
||||
identity from a current profile. Keep the existing publication interface and
|
||||
provide an explicit delivery invocation without automatically chaining the two
|
||||
operations. Keep one selector mapping and shared invocation plumbing.
|
||||
|
||||
ReleaseKit v0.9.0 shipped host-safe consumer launcher examples and regression
|
||||
coverage. The earlier simplification used v0.8.0, which already supported
|
||||
registered tools. Keep host normalization locally because the reviewed
|
||||
`go tool` invocation inherited foreign `GOOS`/`GOARCH` values and failed to run.
|
||||
Do not switch tool-version selection from the root module to each integration
|
||||
merely to match the upstream multi-module example.
|
||||
|
||||
### Hosted Gitea release pages
|
||||
|
||||
Configure this delivery selection in each module profile:
|
||||
|
||||
| Field | Selected value |
|
||||
| --- | --- |
|
||||
| `delivery.forge` | `gitea` |
|
||||
| `delivery.base_url` | `https://forge.maximumdirect.net` |
|
||||
| `delivery.owner` | `base-layer` |
|
||||
| `delivery.repository` | `commandkit` |
|
||||
| `delivery.token_file` | `.releasekit/gitea-token` |
|
||||
|
||||
Preserve the profiles' distinct product identities and physical module
|
||||
directories. All pages belong to the same Gitea repository and are keyed by
|
||||
the complete tag: `vX.Y.Z` for core, `integrations/ckconfig/vX.Y.Z` for ckconfig,
|
||||
and `integrations/ckprompt/vX.Y.Z` for ckprompt. Pass the selected profile and
|
||||
semantic version to `deliver`; ReleaseKit derives the complete module tag.
|
||||
|
||||
With no build targets, delivery is page-only. ReleaseKit uses the complete
|
||||
checked-in module release note, including its licensing footer, as the hosted
|
||||
body. No binary assets, receipts, or checksum manifests are added. Use current
|
||||
forge URLs and the full module tag for future notes' immutable source and
|
||||
licensing links. Existing published tags and notes remain unchanged.
|
||||
|
||||
After source publication succeeds, explicitly invoke delivery from a clean
|
||||
checkout whose `HEAD` identifies that published tag's commit. For later retries,
|
||||
use the original tagged source and its recorded module files. Report the source
|
||||
publication outcome and hosted-delivery outcome separately, including the hosted
|
||||
URL on success. A delivery failure leaves the source release complete; repair
|
||||
delivery without republishing or moving its tag.
|
||||
|
||||
Delivery performs its own read-only credential preflight, without rerunning
|
||||
candidate validation or publishing a tag. Hosted-service availability and the
|
||||
Gitea token do not become source-publication gates. A nonblank
|
||||
`RELEASEKIT_GITEA_TOKEN` environment value takes precedence over the configured
|
||||
file. The file path resolves from the repository root for all three modules.
|
||||
|
||||
ReleaseKit accepts matching hosted content and reports conflicting content
|
||||
without overwriting it. Resolve uncertain hosted writes before retrying the
|
||||
same immutable version. The pinned
|
||||
[delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/23203f235cc414c4ffe0fd3941030eb0c9f9943f/docs/consumers/delivery.md)
|
||||
owns exact delivery behavior and recovery. Start with tags containing the new
|
||||
delivery configuration; historical page backfilling remains a separate decision.
|
||||
This local workflow needs no CI delivery job or additional secret distribution.
|
||||
|
||||
### Focused local tests
|
||||
|
||||
Replace the release-test scaffolding with a small controlled Go-command boundary
|
||||
that invokes the actual launcher. Cover:
|
||||
|
||||
- Selector-to-profile/module routing for core, ckconfig, and ckprompt, including
|
||||
consistency with the checked-in profiles' intended module and product.
|
||||
- Invalid argument handling before tool invocation.
|
||||
- Invocation from another directory and paths containing spaces.
|
||||
- Host normalization and workspace/toolchain/read-only settings.
|
||||
- Publication and delivery argument, output-stream, and child exit-status
|
||||
forwarding, with the selected module profile reaching the delivery operation.
|
||||
- Profile wiring for the shared Gitea destination, token path, and its selected
|
||||
committed-path protection; assertions do not read the credential file.
|
||||
|
||||
Exercise module routing separately from exit statuses rather than multiplying
|
||||
every combination. Assert behavior and meaningful selections, not helper
|
||||
implementation details or exact usage prose.
|
||||
|
||||
The resulting local suite omits the disposable publication exercise, generic
|
||||
tag inspection, assertion-helper self-tests, and custom build-call log/parser.
|
||||
Upstream publication and delivery coverage owns those generic behaviors. No
|
||||
replacement publication engine or exported test-helper API is needed. There is
|
||||
no line-count target for the replacement tests.
|
||||
|
||||
## Selected choices and rationale
|
||||
|
||||
All four generic file checks are selected as release-time checks, in addition
|
||||
to the agreed forbidden token path. Formatting and local-link validation automate
|
||||
existing contributor review work. Workspace-file and filesystem-replacement
|
||||
checks address accidental inclusion of development metadata that a successful
|
||||
local test run can overlook. This adds specific release checks; it does not
|
||||
make every optional ReleaseKit validator mandatory or prohibit development
|
||||
workspaces and replacements outside the selected committed candidate inputs.
|
||||
Selecting only formatting and Markdown would change less release behavior, but
|
||||
would leave these distribution mistakes to manual review. The selected upstream
|
||||
checks avoid a second local implementation.
|
||||
|
||||
Host-normalized registered-tool execution is selected because the root module
|
||||
already registers ReleaseKit and Go caches the executable. Retaining a persistent
|
||||
native executable would provide a recovery entry point independent of a later
|
||||
Go-driver invocation, but would retain executable-storage and bootstrap test
|
||||
maintenance. Recovery instead uses the registered tool or ReleaseKit's reported
|
||||
entry point. The credential directory remains ignored under either approach.
|
||||
|
||||
The implementation plan records the existing integration dependency-version
|
||||
mismatch separately from validation of the current source. This infrastructure
|
||||
sprint does not publish releases or select new library dependency versions.
|
||||
Historical hosted-page backfilling and CI delivery remain separate future
|
||||
choices, not prerequisites for the selected local page-only workflow.
|
||||
184
docs/roadmap/releasekit-v0.9.0.md
Normal file
184
docs/roadmap/releasekit-v0.9.0.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# ReleaseKit v0.9.0 adoption
|
||||
|
||||
Status: R1 and R2 remediation, ReleaseKit v0.9.0 adoption, and the completion
|
||||
assertion repair are complete. The [completed implementation record](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/c8d659dfe5ae3e834b3aeabdfd0fe29b736dc05a/docs/roadmap/implementation.md)
|
||||
records stage order, tasks, and validation evidence. This document owns the
|
||||
target and selected choices; it does not authorize source or hosted publication.
|
||||
|
||||
## Purpose and authority
|
||||
|
||||
Adopt ReleaseKit v0.9.0's improved recovery behavior while preserving
|
||||
CommandKit's existing release-tooling architecture. Update current dependency
|
||||
and maintainer documentation together, and make the validation evidence clear
|
||||
about existing local defects.
|
||||
|
||||
The [agent policy](../policy/agents.md),
|
||||
[release policy](../policy/release.md#candidate-validation),
|
||||
[testing policy](../policy/testing.md#upstream-integration-testing-ownership),
|
||||
and [documentation policy](../policy/documentation.md) govern the work. Current
|
||||
behavior remains owned by the [release procedure](../release.md), Go declarations,
|
||||
and owning package tests until implementation updates them.
|
||||
|
||||
## Current baseline and review evidence
|
||||
|
||||
Reviewed CommandKit code baseline: `4d92ee4ee6a8403b22a4172e0f04face802b9499`.
|
||||
The original upgrade review used `98e7e9933353cf43f2be4a96cb0436a0bce068d2`;
|
||||
its findings and validation history are retained below.
|
||||
|
||||
- The root [module](../../go.mod) selects
|
||||
`go.maximumdirect.net/releasekit v0.8.0` as a Go tool. Its selected version
|
||||
serves core, ckconfig, and ckprompt operations.
|
||||
- The [shared helper](../../scripts/releasekit) already normalizes the Go host,
|
||||
disables workspace discovery, selects the local toolchain, retains read-only
|
||||
metadata, and invokes the full executable package path. The
|
||||
[legacy entry point](../../scripts/release) delegates publication to it.
|
||||
- All three profiles already select the agreed generic file checks, forbidden
|
||||
token path, and page-only Gitea destination. Source publication and hosted
|
||||
delivery are separate operations. The local token directory remains ignored.
|
||||
- [R1 and R2](remediation.md) are complete. Commit `7eac3fe` rejects invalid
|
||||
legacy selectors before invoking Go; commit `4d92ee4` restores explicit,
|
||||
optional POSIX-shell invocation in launcher tests. The remediation record owns
|
||||
their acceptance evidence, including passing focused tests, shell syntax
|
||||
checks, and profile assertions running when no shell is available.
|
||||
- Both integrations still require older published CommandKit dependencies than
|
||||
their current source needs. The completed [release-tooling implementation record](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/87eb84ccb462bf54e6250c8b287ac217efe90883/docs/roadmap/implementation.md#stage-5-verify-the-final-source-and-record-validation-boundaries)
|
||||
distinguishes workspace-backed tests from independent release readiness.
|
||||
|
||||
The upstream v0.9.0 tag identifies commit
|
||||
`2180aa868ec2caa95fc8279fd59d5f0ebac41bd4`. The tag and direct Go-module
|
||||
resolution were verified. The default proxy initially returned v0.8.0 for
|
||||
`@latest`; select v0.9.0 explicitly rather than changing repository-wide proxy
|
||||
settings to work around that observation.
|
||||
|
||||
Upstream [release notes](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/releases/v0.9.0.md)
|
||||
describe host-safe launchers and generated recovery commands, full package-path
|
||||
tool selection, preservation of wildcard and version-specific replacements in
|
||||
API-only recovery, and safer manual hosted-page retries. Public Go APIs,
|
||||
profiles, record formats, and the Go 1.26.0 consumer minimum remain unchanged.
|
||||
The v0.8.0-to-v0.9.0 comparison also left upstream module requirements, checksums,
|
||||
copyright, and license texts unchanged.
|
||||
|
||||
A disposable CommandKit upgrade changed only the selected ReleaseKit version in
|
||||
`go.mod`. With Go 1.26.8, the root suite passed on a subsequent run, both
|
||||
workspace-backed integration suites passed, and the real helper reported v0.9.0
|
||||
under foreign target settings. No production publication or delivery was run.
|
||||
|
||||
A later disposable review at `7eac3fe` confirmed that downloading and tidying
|
||||
v0.9.0 changed only the ReleaseKit requirement and its checksum entries. The
|
||||
offline root suite and both workspace-backed integration suites passed with
|
||||
Go 1.26.8, and the helper reported v0.9.0 under Windows/ARM64 target settings.
|
||||
That snapshot excluded the then-uncommitted R2 change; it does not replace
|
||||
validation of the eventual adoption commit.
|
||||
|
||||
The initial upgrade run failed in
|
||||
[`TestCompletionCommandContext`](../../completion_context_test.go). The same
|
||||
alias-order failure reproduced four times in 30 focused repetitions on the
|
||||
unchanged v0.8.0 baseline. A single passing rerun therefore does not establish
|
||||
stable validation. The test compares an internal alias slice in a fixed order;
|
||||
commit `0acbbf4` deliberately changed completion to use unsorted lookup records
|
||||
before this upgrade review.
|
||||
|
||||
## Targeted end state
|
||||
|
||||
### Tool adoption and retained architecture
|
||||
|
||||
The root module selects ReleaseKit v0.9.0 with authentic downloaded checksums.
|
||||
Its existing tool declaration and root-owned selection remain. No integration
|
||||
dependency version, library import path, Go minimum, or selected Go 1.26.8
|
||||
maintainer toolchain changes as part of adoption.
|
||||
|
||||
Keep host normalization and full-package invocation in the existing helper:
|
||||
upgrading a dependency does not update or replace local shell scripts. Keep one
|
||||
module/profile mapping and the established wrapper forms. Retain the selected
|
||||
profiles, token-file handling, credential-directory ignore rule, and separation
|
||||
of source and hosted outcomes. No new validator, binary target, CI workflow,
|
||||
publication channel, credential preflight gate, or runtime API is needed.
|
||||
|
||||
The new generated Go recovery commands complement the helper's existing host
|
||||
handling. Recovery continues to use saved publication evidence and the upstream
|
||||
record formats; CommandKit does not acquire a parallel recovery implementation.
|
||||
|
||||
### Release-code simplification review
|
||||
|
||||
The v0.9.0 review identified no additional local release implementation to remove.
|
||||
The earlier simplification already delegated generic validation, publication,
|
||||
delivery, and recovery to ReleaseKit. This release improves launcher examples
|
||||
and generated recovery commands without adding validation options that replace
|
||||
the remaining CommandKit code.
|
||||
|
||||
Retain the shared helper's module/profile selection and execution environment.
|
||||
Host normalization must happen before Go compiles and runs ReleaseKit. Retain
|
||||
the legacy publication wrapper to preserve the established maintainer interface.
|
||||
The upstream multi-module launcher example adds a module map and temporary
|
||||
metadata preparation for a different tool-selection model; replacing our
|
||||
root-owned registered-tool helper with it would add machinery.
|
||||
|
||||
Retain the focused [wiring tests](../../scripts/release_test.go) for profile
|
||||
selections, argument forwarding, environment, streams, status, and optional-shell
|
||||
handling. ReleaseKit owns validation and recovery semantics; CommandKit's tests
|
||||
continue to cover its own adapter. The adoption work therefore preserves the
|
||||
release-code structure while updating the dependency and documentation, alongside
|
||||
the separately planned completion assertion repair.
|
||||
|
||||
### Current documentation and history
|
||||
|
||||
Current release procedure, project-specific release policy, consumer Go-tool
|
||||
selection summary, and root attribution describe v0.9.0. Upstream contract and
|
||||
notice links use the corresponding immutable source revision. Record that the
|
||||
host-safe launcher request has shipped upstream instead of describing it as
|
||||
pending in the earlier feature roadmap.
|
||||
|
||||
Continue linking to upstream recovery and delivery contracts from the maintainer
|
||||
procedure. Use ReleaseKit's generated recovery commands and keep local guidance
|
||||
focused on CommandKit's entry points and selections.
|
||||
|
||||
Preserve the baseline and validation history in completed implementation and
|
||||
review records. Do not mechanically replace every historical v0.8.0 reference.
|
||||
Integration attribution describes its own selected published dependency graph;
|
||||
update it only if that actual selection changes. No license-text replacement
|
||||
is needed for the unchanged upstream texts.
|
||||
|
||||
### Completed remediation and remaining correctness work
|
||||
|
||||
R1 and R2 were delivered before the dependency upgrade, with their completion
|
||||
markers and acceptance evidence in [remediation.md](remediation.md). Preserve
|
||||
their selector rejection, legitimate raw helper forms, and optional-shell test
|
||||
boundary during adoption; no further implementation of those findings remains.
|
||||
|
||||
The completion assertion compares the expected alias source/target records
|
||||
without depending on their iteration order, while detecting missing, unexpected,
|
||||
or duplicate records. Its routing and visible-completion assertions remain.
|
||||
Production alias ordering and public contracts are unchanged. This correction
|
||||
was completed as part of the maintenance scope, separate from R1/R2 remediation;
|
||||
the implementation plan records the test changes and focused verification.
|
||||
|
||||
Ordinary releases retain their single selected-module publication suite and
|
||||
separate hosted-delivery outcome. One-time upgrade diagnostics are maintenance
|
||||
evidence, with no new recurring release gate. Credentials and live publication
|
||||
are unnecessary for this adoption.
|
||||
|
||||
## Completion evidence
|
||||
|
||||
Adoption is demonstrated by authentic module metadata, the real helper reporting
|
||||
v0.9.0 for the host, aligned current documentation and attribution, and recorded
|
||||
validation of the final source. The reviewed upgrade introduced no other module
|
||||
version change; verify rather than assume that result during implementation.
|
||||
|
||||
R1/R2 acceptance evidence is complete in the remediation record. Completion of
|
||||
the remaining maintenance work also includes a stable, order-independent
|
||||
completion assertion and validation that the adopted helper retains the
|
||||
corrected launcher behavior. Independent integration dependency alignment
|
||||
remains separate release-preparation work; this roadmap selects no future
|
||||
versions or sums.
|
||||
|
||||
## Resolved choices
|
||||
|
||||
R1 and R2 were corrected in separate commits before adoption, resolving the
|
||||
former OD-1 scheduling question. Their acceptance evidence remains in
|
||||
[remediation.md](remediation.md).
|
||||
|
||||
The selected maintenance scope is the completion assertion repair, root tool
|
||||
upgrade, aligned current documentation and attribution, and validation of the
|
||||
resulting source. The older integration dependency-version mismatch remains
|
||||
separate release-preparation work. The implementation plan defines execution
|
||||
and commit sequencing.
|
||||
116
docs/roadmap/remediation.md
Normal file
116
docs/roadmap/remediation.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Release tooling review findings
|
||||
|
||||
Status: R1 and R2 complete; no open findings. Review baseline:
|
||||
`124a9fa57fb3f815bedbafe2d4819f6aa265ee7d`, covering the implementation commits
|
||||
after `cb40f1d`. The findings below preserve the review evidence; completion
|
||||
markers record the subsequent corrections. No implementation was changed during
|
||||
the original review.
|
||||
|
||||
The selected architecture is substantially present: three declarative profiles,
|
||||
one host-normalized registered-tool helper, separate publication and page-only
|
||||
delivery, retained credential-directory ignoring, and focused wiring tests.
|
||||
Both issues identified by the review have been addressed.
|
||||
|
||||
## R1 — P2: Reject raw helper command names as legacy module selectors
|
||||
|
||||
**Completed.** The legacy entry point now checks its three module selectors
|
||||
before delegating to the helper, retaining the single profile/module mapping
|
||||
there. Regression cases cover all three raw-command collisions and require
|
||||
usage status 2 with no Go invocation. The new regression failed before the fix;
|
||||
the complete focused scripts suite and shell syntax checks passed afterward
|
||||
with Go 1.26.8, including valid legacy and direct raw-command forwarding.
|
||||
|
||||
**Location:** [scripts/release](../../scripts/release), lines 9–10, delegating to
|
||||
the first-argument dispatch in [scripts/releasekit](../../scripts/releasekit),
|
||||
lines 10–15. The invalid-form cases in
|
||||
[scripts/release_test.go](../../scripts/release_test.go), lines 186–200, miss
|
||||
these collisions.
|
||||
|
||||
The legacy interface is `scripts/release MODULE VERSION`, with three module
|
||||
selectors. It now delegates its first argument directly into a helper that also
|
||||
accepts `version`, `publication-attempts`, and `reconcile` as raw commands. These
|
||||
invalid module names consequently bypass selector rejection and invoke Go.
|
||||
|
||||
For example, `./scripts/release version v1.2.3` reaches the tool as
|
||||
`version publish v1.2.3`. With the real pinned tool it returns upstream's
|
||||
"version takes no arguments" diagnostic after tool resolution. In a disposable
|
||||
copy with a controlled Go boundary returning status 73, each of the three raw
|
||||
command names returned 73 and invoked Go; an ordinary unknown selector returned
|
||||
2 without invoking Go. Thus invalid legacy input depends on tool availability
|
||||
and behavior instead of being rejected at the wrapper boundary.
|
||||
|
||||
**Required correction:** Make legacy module selection unambiguous before raw
|
||||
command dispatch. Keep one profile/module mapping and the supported raw helper
|
||||
forms; a small selector check is preferable to another routing framework.
|
||||
Preserve upstream ownership of version and flag validation.
|
||||
|
||||
**Verification:** Add all three raw command names as invalid legacy selectors.
|
||||
Each must return usage status 2 before any Go command runs. Valid legacy
|
||||
publication and the helper's direct raw forms must retain their forwarding and
|
||||
exit-status behavior. This restores the requested malformed-wrapper handling;
|
||||
it adds no new accepted-version restriction.
|
||||
|
||||
## R2 — P2: Restore the tests' optional POSIX-shell execution boundary
|
||||
|
||||
**Completed.** Launcher execution tests now discover `sh`, invoke it explicitly
|
||||
with the script path, and skip when it is unavailable. Profile assertions remain
|
||||
independent of shell availability. A regression case removes the fixture
|
||||
script's executable permission to verify interpreter invocation; it failed
|
||||
before the correction and passes afterward. The focused scripts suite and shell
|
||||
syntax checks passed with Go 1.26.8 on the available POSIX host. Running the
|
||||
compiled test binary with an empty `PATH` confirmed that every launcher
|
||||
execution case skips while all three profile assertions pass. Native Windows
|
||||
execution was not tested and no new platform guarantee is introduced.
|
||||
|
||||
**Location:** [scripts/release_test.go](../../scripts/release_test.go), especially
|
||||
`fixture.run` at line 63 and the launcher test setup.
|
||||
|
||||
The rewritten tests execute extensionless POSIX shell scripts directly through
|
||||
`exec.Command`. They neither discover a shell nor skip launcher execution when
|
||||
it is unavailable. The previous suite resolved `sh`, skipped shell-dependent
|
||||
tests when it was missing, and passed the script as an argument to that shell.
|
||||
|
||||
Direct execution works on this review's Linux host, but relies on Unix script
|
||||
execution semantics. Native Windows process creation does not supply a POSIX
|
||||
interpreter for these files. Go's
|
||||
[os/exec documentation](https://pkg.go.dev/os/exec#hdr-Overview) explicitly notes
|
||||
that it does not invoke a system shell. Consequently the default repository
|
||||
suite now fails on hosts without compatible script execution rather than
|
||||
preserving the previous optional-shell behavior. This portability finding is
|
||||
based on the changed execution path and platform implementation; a native
|
||||
Windows test run was not available during review.
|
||||
|
||||
**Required correction:** Discover a POSIX shell for launcher execution tests,
|
||||
invoke it explicitly with the script path, and skip those tests when the shell
|
||||
is unavailable. Keep profile-only JSON assertions runnable without requiring a
|
||||
shell. Preserve the reduced test architecture and existing argv/status checks.
|
||||
No native Windows release launcher or additional supported-platform guarantee
|
||||
is requested.
|
||||
|
||||
**Verification:** Confirm that launcher tests use the discovered interpreter
|
||||
and handle its absence explicitly, while profile assertions still run. Rerun
|
||||
the focused suite on the available POSIX host. Broader platform matrices are
|
||||
not a new release gate; this correction removes an incidental environment
|
||||
restriction introduced by the test rewrite.
|
||||
|
||||
## Review evidence and limits
|
||||
|
||||
- Go 1.26.8 offline root tests and the temporary-workspace suites for both
|
||||
integrations passed. Both production scripts passed shell syntax checks.
|
||||
- The real helper reported ReleaseKit v0.8.0 under foreign target settings.
|
||||
- ReleaseKit checks passed for a disposable committed source snapshot using
|
||||
the actual profiles: ordinary root validation, then both integration checks
|
||||
with the implementation plan's temporary workspace-backed test process.
|
||||
Selected file checks continued to inspect the committed declarations.
|
||||
- Local documentation destinations and applicable Markdown fragments passed
|
||||
inspection. The credential file remained ignored and its contents were not
|
||||
read. No real publication or hosted-delivery operation was invoked.
|
||||
- Library code and dependency declarations were unchanged by the sprint. The
|
||||
documented integration dependency-version mismatch remains a pre-existing
|
||||
independent-release-readiness limitation; the workspace-backed results do
|
||||
not establish that published dependencies are aligned.
|
||||
|
||||
No additional architecture gaps or unrequested runtime/public-API guarantees
|
||||
were identified. The selected file checks and forbidden token path match the
|
||||
finalized scope. R2 concerns the default tests' execution environment rather
|
||||
than a new production validator or library contract.
|
||||
890
docs/roadmap/reporting.md
Normal file
890
docs/roadmap/reporting.md
Normal file
@@ -0,0 +1,890 @@
|
||||
# Output policy and invocation reporting
|
||||
|
||||
Status: behavioral decisions selected through OD-4. Message categories, all four
|
||||
output modes, uniform treatment of help and version, message prefixes, and
|
||||
OD-1's flag grammar and scope (including independent Progress control) are selected.
|
||||
OD-2's unified reporting direction, the terminal failure model, and application
|
||||
ownership of domain-specific error decisions are selected.
|
||||
OD-3's failure presentation and retention boundaries are selected.
|
||||
OD-4's delivery lifecycle, write-failure handling, and quiet-mode rendering are
|
||||
selected. The consumer API signatures, presentation-result model, provider
|
||||
diagnostic boundary, fallback explanations, and rendering callback boundary
|
||||
below are also selected. Architectural preparation is complete:
|
||||
[ADR 0014](../adr/0014-invocation-reporting.md) is accepted, and the
|
||||
[architecture policy](../policy/architecture.md) is aligned. Implementation and
|
||||
final validation are complete; Stages 1–20 are complete. The [implementation
|
||||
plan](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/230187296fae5253cb1773f16c2f1d1a39499ed0/docs/roadmap/implementation.md) records the stage order and validation evidence.
|
||||
|
||||
## Purpose
|
||||
|
||||
Provide consistent handling of `--quiet`, `--verbose`, `--debug`, and
|
||||
`--progress` across application commands and optional integrations. Reduce
|
||||
repeated output classification, diagnostic presentation, and delivery bookkeeping
|
||||
where CommandKit can provide a meaningful benefit.
|
||||
|
||||
Ordinary Go writers and formatting functions already make application output
|
||||
straightforward. Design reporting conveniences around the shared output policy
|
||||
and invocation lifecycle. Applications continue to own their report content,
|
||||
domain behavior, and operation outcomes.
|
||||
|
||||
## CLI presentation and error-handling ownership
|
||||
|
||||
Applications decide how to respond to domain-specific errors: recover, retry,
|
||||
continue silently, report a Warning, or end the invocation with a failure.
|
||||
CommandKit may provide shared helpers for carrying, inspecting, and presenting
|
||||
errors while leaving those decisions with the application. A Go `error` alone
|
||||
does not determine a CLI message category. Warnings may describe issues with or
|
||||
without an underlying Go error, and handling an error does not require emitting
|
||||
a message.
|
||||
|
||||
CommandKit handles the presentation the application requests: message category,
|
||||
visibility, formatting, timing, and delivery. For terminal application failures,
|
||||
the application supplies a non-zero outcome and an operator-facing explanation.
|
||||
It may also supply an optional Go error cause for the execution caller to inspect.
|
||||
CommandKit preserves that cause and its existing `errors.Is`/`errors.As` behavior.
|
||||
Supplying a cause alone does not select an outcome, generate an operator-facing
|
||||
message, or change visibility.
|
||||
|
||||
Applications can explicitly present `err.Error()` as a Warning through the
|
||||
ordinary message API, illustrated by `report.Warn(err.Error())`. Defer a separate
|
||||
`WarnError` convenience helper pending demonstrated value; this conversion
|
||||
already requires little code.
|
||||
|
||||
CommandKit retains responsibility for handling its own errors, including
|
||||
routing, parsing, and reporting failures. Integration-owned commands similarly
|
||||
adapt their upstream APIs into deliberate command outcomes and presentations.
|
||||
Ordinary Go error returns from CommandKit APIs and writers remain available where
|
||||
appropriate.
|
||||
|
||||
## Current baseline
|
||||
|
||||
[`Streams` and `Invocation`](../../identity.go) expose caller-selected output
|
||||
and diagnostic writers. [`Outcome` and `Diagnostic`](../../outcome.go) carry
|
||||
operation causes, diagnostic writers, and delivery state. Handlers can return
|
||||
`Failure(err)` or attach diagnostics to an outcome for presentation after the
|
||||
handler returns. `MessageDiagnostic` currently writes the supplied message with
|
||||
a newline; severity does not add a label automatically.
|
||||
|
||||
CommandKit coordinates final diagnostic delivery and retains delivery failures
|
||||
separately from the operation cause. Custom handlers can also write directly to
|
||||
the supplied streams and retain responsibility for their output. The existing
|
||||
outcome and channel ownership decisions are recorded in
|
||||
[ADR 0005](../adr/0005-invocation-outcomes.md).
|
||||
|
||||
Integration adapters already translate upstream diagnostic records into
|
||||
CommandKit diagnostics while preserving native rendering and source context.
|
||||
Some adapters wrap multiple records in one diagnostic writer and classify the
|
||||
batch by its highest severity. This matters when designing selective visibility.
|
||||
|
||||
There is currently no shared verbosity policy or invocation reporter API.
|
||||
|
||||
## Selected output policy
|
||||
|
||||
Distinguish substantive application output from diagnostic message categories:
|
||||
|
||||
- **Substantive output (Normal):** The requested report or command result.
|
||||
- **Error:** Terminal presentation of an unrecoverable invocation failure,
|
||||
resulting in a non-zero status code.
|
||||
- **Warning:** Something went wrong or deserves attention, but the invocation
|
||||
can still succeed. The application chooses whether to report such an issue.
|
||||
- **Info:** Informational context and useful explanation, such as which profile
|
||||
was selected or a final operation summary.
|
||||
- **Progress:** Streaming updates about work underway, such as items processed.
|
||||
- **Debug:** Troubleshooting detail, including intermediate decisions and
|
||||
execution-path information.
|
||||
|
||||
Debug mode adds troubleshooting detail to verbose output:
|
||||
|
||||
| Output category | `--quiet` | No flag | `--verbose` | `--debug` |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Substantive application output | Suppressed | Shown | Shown | Shown |
|
||||
| Error | Shown | Shown | Shown | Shown |
|
||||
| Warning | Suppressed | Suppressed | Shown | Shown |
|
||||
| Info | Suppressed | Suppressed | Shown | Shown |
|
||||
| Debug | Suppressed | Suppressed | Suppressed | Shown |
|
||||
| Progress, without an explicit setting | Suppressed | Suppressed | Suppressed | Shown |
|
||||
|
||||
Info belongs in verbose mode; debug includes verbose messages. Normal output
|
||||
remains a substantive result category, separate from diagnostic severity.
|
||||
The category of a message determines its visibility; stream destination alone
|
||||
cannot distinguish a warning from an error.
|
||||
|
||||
Progress is distinct from Info and is controlled separately as specified below.
|
||||
Applications and integrations are not required to generate messages in any
|
||||
category. Enabling a category adds no output when there are no messages in it;
|
||||
other output remains subject to its own visibility rules.
|
||||
|
||||
Error presentation follows the terminal failure model below. An application
|
||||
may report a recovered problem as a Warning or continue without a message.
|
||||
|
||||
Apply the same policy to help and version. There are no command-specific
|
||||
exceptions for successful output:
|
||||
|
||||
```text
|
||||
app help --quiet # succeeds silently
|
||||
app version --quiet # succeeds silently
|
||||
```
|
||||
|
||||
Usage and operational failures still report their errors in quiet mode.
|
||||
The feature must distinguish successful output suppression from failure
|
||||
diagnostic delivery.
|
||||
|
||||
### Message prefixes and channels
|
||||
|
||||
Substantive output uses the result destination, conventionally stdout. Warning,
|
||||
Info, Progress, Debug, and terminal explanations use the diagnostic destination,
|
||||
conventionally stderr. Callers supply both destinations.
|
||||
|
||||
Use `WARN:`, `DEBUG:`, and `ERROR:` prefixes for simple warning, debug, and error
|
||||
messages respectively. Substantive output and Info messages have no prefix.
|
||||
For multiline diagnostics, prefix the opening line and preserve the structured
|
||||
detail. Integration adapters coordinate native diagnostic rendering to avoid
|
||||
duplicate severity labels or loss of upstream formatting.
|
||||
|
||||
Usage guidance belongs to the terminal usage explanation and remains visible in
|
||||
quiet mode. Successful help output follows the substantive-output policy.
|
||||
|
||||
Initially, Progress messages are unprefixed streamed lines. The diagnostic
|
||||
destination keeps them separate from substantive output such as JSON or YAML.
|
||||
Terminal redraw and progress-bar behavior are outside this implementation.
|
||||
|
||||
### Terminal failure model
|
||||
|
||||
During execution, applications can produce substantive output, Progress, Info,
|
||||
Debug, and Warnings. An unrecoverable failure ends the handler by returning a
|
||||
failed outcome. There is no standalone `report.Error(...)` method. Detailed
|
||||
error explanations accompany the returned failure and are presented as part of
|
||||
that terminal step.
|
||||
|
||||
CommandKit finalizes reporting, presents the terminal failure explanation, and
|
||||
returns the non-zero outcome to the application entry point. It accepts no
|
||||
further normal output for that invocation after terminal error presentation.
|
||||
The same boundary applies to core routing and parsing failures that prevent
|
||||
handler execution. The application entry point retains ownership of process
|
||||
exit; CommandKit ends the invocation and returns its outcome.
|
||||
|
||||
For example, if an export operation fails and the application decides it cannot
|
||||
continue, the handler returns a non-zero outcome with an explicit explanation
|
||||
such as "Unable to export the requested report." The application can choose to
|
||||
include detail from a Go error in that explanation. CommandKit presents the
|
||||
supplied explanation without automatically deriving text from an application
|
||||
error. The selected API below provides failure constructors for this purpose.
|
||||
When a failed outcome supplies no explanation, CommandKit supplies the generic
|
||||
fallback defined under terminal outcomes below.
|
||||
|
||||
Output streamed before the failure remains visible according to its selected
|
||||
mode; terminal failure does not withdraw earlier bytes. Deferred messages,
|
||||
cleanup writes, and delivery failures must respect the end of normal reporting.
|
||||
Their finalization and late-write behavior follow OD-4 below. A terminal
|
||||
failure may have multiple explanatory records, all belonging to that failure.
|
||||
|
||||
### Failure explanations and presentation ownership (OD-3)
|
||||
|
||||
Make a non-zero outcome with an explicit message the convenient failure path.
|
||||
Support structured explanations, including multiple records, through the same
|
||||
presentation facility for richer application and integration reports. Neither
|
||||
form requires an underlying Go error.
|
||||
|
||||
The application or integration explicitly identifies the context belonging to
|
||||
a terminal explanation. Present that context with the failure in every mode,
|
||||
including quiet. Independent warnings and other messages retain their own
|
||||
visibility rules. For mixed upstream batches, adapters preserve useful native
|
||||
rendering while distinguishing terminal explanatory context from independent
|
||||
messages. Core does not infer those relationships from error chains or text.
|
||||
|
||||
Assign each submitted message one presentation path. Returning a terminal
|
||||
explanation schedules its presentation once; core does not append another
|
||||
message derived from an application error cause. Native renderers can supply
|
||||
already-labelled content without receiving duplicate prefixes. Avoid text-based
|
||||
deduplication: separate events may legitimately produce identical messages.
|
||||
|
||||
Constructing a failure result attaches explanatory content without emitting or
|
||||
independently scheduling an Error. Returning that result hands the explanation
|
||||
to CommandKit for terminal delivery through the invocation's reporting facility.
|
||||
The selected API expresses this distinction for both simple and structured
|
||||
explanations. For example:
|
||||
|
||||
| Handler action | Presentation |
|
||||
| --- | --- |
|
||||
| Construct a non-zero result with "Unable to export the requested report," then return it. | Construction emits nothing. CommandKit presents the message once during terminal delivery. |
|
||||
| Return a failure with native diagnostic records and their source context, submitting independent warnings separately. | CommandKit presents the structured terminal explanation in every mode. Independent warnings follow their own visibility policy. |
|
||||
|
||||
### Retention boundary (OD-3)
|
||||
|
||||
CommandKit provides invocation-scoped CLI presentation. Logging and message
|
||||
history collection remain application concerns. Keep terminal explanations and
|
||||
any supplied cause with the returned outcome, and keep the temporary state needed
|
||||
for deferred delivery, parser events awaiting mode selection, and delivery
|
||||
bookkeeping. The reporting facility does not collect a transcript or retain other
|
||||
emitted or suppressed messages for later inspection. No optional message-history
|
||||
facility is planned for this implementation.
|
||||
|
||||
## Selected flag grammar and scope
|
||||
|
||||
### Mode selection and resets
|
||||
|
||||
Model quiet, ordinary, verbose, and debug as mutually exclusive presentation
|
||||
modes. Each invocation starts in ordinary mode. Process flag occurrences in
|
||||
invocation order; the last enabled mode wins. Repetition does not increment
|
||||
verbosity, and debug is selected explicitly.
|
||||
|
||||
Accept presence and the existing explicit boolean syntax, including
|
||||
`--quiet=true` and `--quiet=false`. A false occurrence clears its mode only if
|
||||
that mode is currently selected, returning to ordinary mode. It does not restore
|
||||
an earlier selection or disable a different mode. Every occurrence is converted;
|
||||
invalid boolean values remain usage errors even when a later flag would replace
|
||||
the selection.
|
||||
|
||||
| Flag sequence | Final mode |
|
||||
| --- | --- |
|
||||
| `--quiet --verbose` | Verbose |
|
||||
| `--debug --quiet` | Quiet |
|
||||
| `--verbose --verbose` | Verbose |
|
||||
| `--verbose --verbose=false` | Ordinary |
|
||||
| `--quiet --verbose=false` | Quiet |
|
||||
| `--verbose --quiet --quiet=false` | Ordinary |
|
||||
| `--quiet=false --debug` | Debug |
|
||||
|
||||
Keep verbosity a presentation choice: quiet preserves the requested operation's
|
||||
semantics and rendering failures. Delivery failures depend on the output actually
|
||||
requested; suppressing a destination write can avoid a delivery failure. Apply
|
||||
the policy through CommandKit-managed reporting and streams. Output written
|
||||
directly to process streams or external destinations remains application-owned.
|
||||
|
||||
### Progress selection
|
||||
|
||||
`--progress` enables Progress without enabling Info, Warning, or Debug messages.
|
||||
`--progress=false` explicitly disables it. Accept the existing boolean syntax;
|
||||
repeated canonical or aliased progress settings use the last value. Retain
|
||||
whether a setting was explicitly supplied, separately from its boolean value.
|
||||
|
||||
Determine effective Progress visibility from the final presentation mode and
|
||||
explicit progress setting:
|
||||
|
||||
1. Quiet mode always suppresses Progress.
|
||||
2. Otherwise, an explicit progress setting controls visibility.
|
||||
3. Without an explicit setting, debug enables Progress; ordinary and verbose
|
||||
modes leave it disabled.
|
||||
|
||||
An explicit progress setting overrides the mode's default regardless of its
|
||||
position relative to mode flags. It does not select or reset the presentation
|
||||
mode. Debug includes verbose detail and defaults Progress on; verbose alone
|
||||
does not enable Progress.
|
||||
|
||||
| Flags | Result |
|
||||
| --- | --- |
|
||||
| `--verbose` | Warning and Info, without Progress. |
|
||||
| `--verbose --progress` | Warning, Info, and Progress. |
|
||||
| `--debug` | Warning, Info, Debug, and Progress. |
|
||||
| `--debug --progress=false` or `--progress=false --debug` | Debug detail without Progress. |
|
||||
| `--quiet --progress` or `--progress --quiet` | Only failure diagnostics. |
|
||||
| `--debug --verbose` | Verbose detail without Progress, absent an explicit progress setting. |
|
||||
|
||||
Passing `--progress` to an application that emits no Progress messages adds no
|
||||
visible output. It does not suppress that application's substantive results or
|
||||
require a progress implementation.
|
||||
|
||||
### Placement, availability, and aliases
|
||||
|
||||
Core supplies `--quiet`, `--verbose`, `--debug`, and `--progress` for application
|
||||
commands, integrations, the root, canonical groups, and alias-only discovery help.
|
||||
Consumers do not register them individually. Preserve the existing placement
|
||||
grammar: flags follow the complete command path and may be interspersed with
|
||||
operands after that path. For example:
|
||||
|
||||
```text
|
||||
app config show --quiet
|
||||
app export destination --verbose
|
||||
app help config show --quiet
|
||||
app version --quiet
|
||||
```
|
||||
|
||||
Options before or between command-path components are outside this grammar.
|
||||
Tokens consumed as values and tokens following `--` retain their ordinary
|
||||
meaning.
|
||||
|
||||
Reserve the four long spellings. Applications may add `-q`, `-v`, or `-d`
|
||||
through the alias API; no built-in short spellings are selected. An application
|
||||
flag declaration that defines a reserved long name fails installation with a
|
||||
collision error. Aliases may target these flags but may not overload canonical
|
||||
spellings. Apply the existing collision and atomic installation rules, including
|
||||
later registration.
|
||||
|
||||
Aliased and canonical occurrences participate in the same ordering. With
|
||||
`-q → --quiet`, `app export --verbose -q` selects quiet mode. Document the newly
|
||||
reserved canonical names as a compatibility change.
|
||||
|
||||
### Routing and parsing failures
|
||||
|
||||
Determine the mode through the normal parser before execution or successful
|
||||
help/report presentation. Do not add a separate verbosity scanner. This keeps
|
||||
token interpretation consistent and avoids reinterpreting values or operands.
|
||||
|
||||
Routing and parsing failures report their errors regardless of verbosity. For
|
||||
example, `app export --quiet --unknown` still reports the unknown flag. Error
|
||||
visibility follows the selected output policy independently of how the mode is
|
||||
determined; avoiding a separate scanner is an implementation simplification.
|
||||
|
||||
## CommandKit's own diagnostics
|
||||
|
||||
Use the shared reporting system for CommandKit's invocation diagnostics so
|
||||
parser, integration, and application messages follow the same visibility and
|
||||
delivery policy. Installation errors occur before an invocation exists and
|
||||
continue to be returned to the application without automatic printing.
|
||||
|
||||
Supported repetition and last-occurrence overrides are routine decisions.
|
||||
Repeated singleton flags, including `--verbose --verbose`, may produce debug
|
||||
detail; repetition alone does not warrant a warning. Repeatable flags retain
|
||||
their intended accumulation behavior. Add warnings for concrete concerns an
|
||||
operator can act on, such as a deprecated spelling if deprecation is introduced.
|
||||
Unknown flags, invalid values, and invalid command paths remain invocation
|
||||
failures with error diagnostics.
|
||||
|
||||
Start debug instrumentation with routing, alias resolution, verbosity selection,
|
||||
and handler completion. Other useful candidates can be added where they
|
||||
explain a consequential decision:
|
||||
|
||||
| Event | Useful detail |
|
||||
| --- | --- |
|
||||
| Command-alias resolution | Entered path, intermediate redirects, and final canonical path. |
|
||||
| Flag-alias resolution | Entered spelling and canonical flag name. |
|
||||
| Scoped alias selection | A command-scoped mapping took precedence over an application-wide mapping. |
|
||||
| Verbosity selection | A later occurrence selected a different mode; for `--verbose --debug`, debug wins because it appears last. |
|
||||
| Help-route selection | A canonical command or alias-only discovery context was selected. |
|
||||
| Repeated singleton flag | A later occurrence replaced an earlier occurrence, without printing their values. |
|
||||
| Input preparation | Canonical input validation completed and the handler is starting. |
|
||||
| Handler completion | Canonical command and the handler's returned classification and status, before final delivery. |
|
||||
|
||||
The handler-completion event describes the handler's result. The final returned
|
||||
invocation outcome also includes delivery failures and remains authoritative.
|
||||
Present this event before terminal failure presentation; do not emit a completion
|
||||
message afterward.
|
||||
|
||||
Use the final selected mode to present earlier parser decisions. Collect the
|
||||
relevant events during parsing and apply visibility after mode selection:
|
||||
`--verbose --debug` shows the selection explanation, while `--debug --quiet`
|
||||
suppresses it. This collection of parser events does not require whole-run
|
||||
output buffering.
|
||||
|
||||
Debug messages should explain operator-relevant decisions independently of
|
||||
incidental implementation steps. Use command paths, flag names, and decision
|
||||
descriptions rather than raw argument or configuration-value dumps. Upstream
|
||||
libraries retain ownership of their diagnostic detail and disclosure rules.
|
||||
|
||||
## Selected delivery lifecycle and quiet execution (OD-4)
|
||||
|
||||
### Ordering and finalization
|
||||
|
||||
Use synchronous delivery. Present immediate messages and substantive writes in
|
||||
submission order. After the handler returns:
|
||||
|
||||
1. Deliver eligible deferred messages in queue order, without sorting by severity.
|
||||
2. Finish ordinary reporting, including any handler-completion debug message and
|
||||
flushing any CommandKit-owned buffers for ordinary output. Account for failures
|
||||
discovered during this delivery before preparing the terminal explanation.
|
||||
3. Present any terminal failure explanation and complete its delivery, including
|
||||
any CommandKit-owned buffering of that explanation.
|
||||
4. Close the invocation's reporting facility and return the final outcome.
|
||||
|
||||
Deferred messages remain eligible when the operation fails, subject to verbosity.
|
||||
Deferral controls timing; applications queue success summaries only after the
|
||||
relevant operation succeeds. Ordering governs CommandKit's writes; separate
|
||||
stdout and stderr destinations may be buffered or displayed differently by the
|
||||
caller.
|
||||
|
||||
CommandKit finalizes automatically. Consumers do not need a separate flush or
|
||||
finish call. Handler cleanup, including Go `defer` functions, runs before the
|
||||
handler returns and participates in normal reporting. Applications complete any
|
||||
reporting goroutines before returning. After finalization, writes and message
|
||||
submissions return a closed-reporter error and emit nothing.
|
||||
|
||||
Reporter operations within an invocation are safe to call concurrently.
|
||||
Serialize access to reporting state and delivery so individual messages do not
|
||||
interleave. Concurrent submissions have no predetermined order; deferred messages
|
||||
follow the order in which they enter the queue. Multiple writes composing one
|
||||
application report still require application coordination if they must remain
|
||||
together. This contract covers operations through one invocation's reporter;
|
||||
coordination with writes outside that reporter remains caller-owned.
|
||||
|
||||
Finalize any buffers owned by CommandKit. Caller-supplied writers remain
|
||||
caller-owned; CommandKit does not automatically close them or invoke arbitrary
|
||||
flushing interfaces.
|
||||
|
||||
Visibility applies when output is emitted. A verbose or debug run may show
|
||||
informational detail before subsequently failing. Progress and substantive
|
||||
output may likewise be partially delivered before a failure. Previously written
|
||||
bytes cannot be withdrawn. Whole-run buffering solely to make failed runs
|
||||
contain only error output is outside this implementation.
|
||||
|
||||
Keep errors understandable in every mode, including source locations and
|
||||
related explanatory context. Unrelated warnings and informational messages
|
||||
remain subject to the selected mode. Apply the selected retention boundary
|
||||
without accumulating a message history.
|
||||
|
||||
### Rendering failures, write failures, and execution flow
|
||||
|
||||
Distinguish who invokes a renderer and whether its destination failed:
|
||||
|
||||
| Failure source | Responsibility and handling |
|
||||
| --- | --- |
|
||||
| An encoder or renderer called directly by the application | The application handles its returned error and chooses the outcome and explanation. |
|
||||
| A rendering callback invoked by CommandKit | CommandKit handles its returned error as a presentation failure and reflects it in the invocation outcome. |
|
||||
| A destination rejects a write | CommandKit records the delivery failure and stops further writes to that destination. |
|
||||
|
||||
A rendering failure may occur before any write or while the destination remains
|
||||
healthy. It does not by itself establish a failed destination. CommandKit can
|
||||
explain its rendering failure through a working diagnostic destination. These
|
||||
responsibilities also apply when rendering into a discard destination under
|
||||
quiet mode.
|
||||
|
||||
Failure to deliver requested output makes an otherwise successful invocation
|
||||
unsuccessful. Apply this rule to substantive output, Warning, Info, Progress,
|
||||
Debug, and terminal explanations. Intentional suppression requests no delivery
|
||||
and is not a delivery failure.
|
||||
|
||||
Standard writer operations return write errors normally. Immediate reporting
|
||||
methods also expose delivery errors. CommandKit records bounded delivery-failure
|
||||
information so an ignored write error cannot yield a successful invocation.
|
||||
Deferred delivery failures appear in the final outcome.
|
||||
|
||||
Apply ADR 0005's outcome rules when presentation fails. For standard outcomes,
|
||||
return operational failure with status `1`, including when the original outcome
|
||||
was a usage failure. Preserve the original explanation and any supplied cause;
|
||||
record the presentation failure separately in `Presentation`.
|
||||
|
||||
Application-defined domain outcomes retain their domain classification. Preserve
|
||||
an already non-zero domain status. If the domain status was `0`, change the
|
||||
suggested status to `1` so presentation failure cannot yield a successful
|
||||
invocation. The application entry point retains control of process exit.
|
||||
|
||||
| Outcome before presentation fails | Final outcome |
|
||||
| --- | --- |
|
||||
| Success, status `0` | Operational failure, status `1`. |
|
||||
| Usage failure, status `2` | Operational failure, status `1`. |
|
||||
| Operational failure, status `1` | Operational failure, status `1`. |
|
||||
| Domain outcome, non-zero status | Same domain classification and status. |
|
||||
| Domain outcome, status `0` | Same domain classification, status `1`. |
|
||||
|
||||
These rules apply to both destination-write failures and rendering failures from
|
||||
callbacks invoked by CommandKit. Reporting failures remain CommandKit's own
|
||||
error-handling responsibility.
|
||||
|
||||
Stop writing to a destination after it fails. A substantive-output failure can
|
||||
receive a terminal explanation through the diagnostic destination if it still
|
||||
works. If diagnostic delivery fails, return that information to the caller.
|
||||
Do not redirect diagnostics into substantive output or recursively attempt to
|
||||
report the reporting failure.
|
||||
|
||||
A failed write does not stop application code. The handler can inspect the error
|
||||
and return immediately; otherwise, CommandKit records the failure and finalizes
|
||||
when the handler returns. CommandKit does not use process exit or panic to
|
||||
interrupt execution. Terminal failure presentation ends normal output.
|
||||
|
||||
### Quiet-mode rendering and suppression
|
||||
|
||||
Initially, run substantive report generation normally with its managed writer
|
||||
discarding output in quiet mode. This preserves report generation, validation,
|
||||
and serialization failures while changing presentation. For example, a report
|
||||
encoder failure still fails the invocation under `--quiet`; the handler remains
|
||||
responsible for handling errors returned by an encoder it calls.
|
||||
|
||||
Simple suppressed messages need no formatting or destination writes. For richer
|
||||
report renderers, initially render into a discard destination. Rendering may be
|
||||
skipped later where doing so preserves the command's operation and failure
|
||||
semantics.
|
||||
|
||||
Treat suppression as intentional suppression, never as failed or incomplete
|
||||
delivery. Do not retain suppressed content. Delivery needs only the deferred
|
||||
queue, reporting lifecycle state, and bounded delivery-failure information,
|
||||
alongside the temporary parser state and returned explanations described above.
|
||||
|
||||
## Selected consumer reporting API
|
||||
|
||||
Provide one reporting facility per invocation for substantive output, immediate
|
||||
messages, and end-of-run diagnostics. The consumer API is chosen for its intended
|
||||
long-term design; with no current consumers, preserving existing API shapes
|
||||
carries no weight in this decision.
|
||||
|
||||
The following declarations specify the target API and are not yet implemented.
|
||||
|
||||
The reporter owns presentation: output visibility, progress settings, prefixes,
|
||||
ordering, and delivery bookkeeping. Its views share invocation state, allowing
|
||||
already-emitted messages, deferred messages, suppression, and write failures to
|
||||
be coordinated in one place. Helpers may receive the reporter or a narrow
|
||||
application-defined interface. Domain libraries can continue returning
|
||||
diagnostic data for the handler to submit, independently of CommandKit.
|
||||
|
||||
Use the reporter as the common diagnostic submission path, replacing the earlier
|
||||
proposal to split submission between a reporter and `Outcome.Diagnostics`.
|
||||
Terminal explanations accompany returned outcomes and are delivered through the
|
||||
same facility. Caller-selected destinations remain at `App.Execute`; handlers
|
||||
use `Invocation.Reporter()` in place of raw invocation streams.
|
||||
|
||||
### Invocation reporter and structured detail
|
||||
|
||||
```go
|
||||
func (inv Invocation) Reporter() *Reporter
|
||||
|
||||
type Reporter struct {
|
||||
// Private invocation state.
|
||||
}
|
||||
|
||||
func (r *Reporter) Output() io.Writer
|
||||
func (r *Reporter) RenderOutput(write func(io.Writer) error) error
|
||||
|
||||
func (r *Reporter) Info(message string, details ...Detail) error
|
||||
func (r *Reporter) Warn(message string, details ...Detail) error
|
||||
func (r *Reporter) Debug(message string, details ...Detail) error
|
||||
func (r *Reporter) Progress(message string, details ...Detail) error
|
||||
|
||||
func (r *Reporter) AtEnd() DeferredReporter
|
||||
|
||||
type Detail func(io.Writer) error
|
||||
```
|
||||
|
||||
`Output()` supports ordinary writers and encoders. `RenderOutput()` coordinates
|
||||
an entire rendering operation, captures its rendering error, and prevents other
|
||||
reporter operations from interleaving with it. Rendering callbacks write through
|
||||
their supplied writer. Each complete categorized message, including its details,
|
||||
is likewise coordinated as one reporter operation.
|
||||
|
||||
Rendering callbacks use their supplied writer only during the callback. They may
|
||||
call rendering helpers that write through it. While a callback is running, it
|
||||
must not recursively submit messages, queue deferred messages, start another
|
||||
rendering operation, or write through a separate `Output()` handle belonging to
|
||||
the same invocation's reporter. Make additional reporter submissions before or
|
||||
after the callback. A callback must also avoid waiting for another goroutine to
|
||||
submit through that reporter, since the submission may be waiting for the
|
||||
callback to finish.
|
||||
|
||||
This boundary applies to `RenderOutput`, immediate and deferred message details,
|
||||
and terminal explanation details. Ordinary concurrent reporter calls remain
|
||||
supported and serialized. Keeping each rendering operation together motivates
|
||||
the boundary; it does not prescribe a lock implementation or promise runtime
|
||||
detection of recursive calls.
|
||||
|
||||
Message strings are literal; consumers use `fmt.Sprintf` when formatting is
|
||||
needed. Initially omit parallel `Infof`, `Warnf`, and similar methods. CommandKit
|
||||
formats the opening message with its category's prefix and a terminating newline.
|
||||
Optional details follow in order with their formatting preserved. An empty
|
||||
opening message produces no heading, prefix, or blank opening line, supporting
|
||||
native renderers that supply a complete, labelled diagnostic:
|
||||
|
||||
```go
|
||||
_ = report.Warn("Using the default profile")
|
||||
_ = report.Warn("Some configuration settings need attention.", writeNativeWarnings)
|
||||
_ = report.Warn("", writeNativeWarnings)
|
||||
```
|
||||
|
||||
These are alternative calls; `writeNativeWarnings` represents an
|
||||
application- or integration-supplied function with signature
|
||||
`func(io.Writer) error`. No generic logging method or severity enumeration is
|
||||
needed to submit messages. Applications need not check verbosity before reporting
|
||||
or implement categories they do not use.
|
||||
|
||||
Immediate methods return rendering or delivery errors, and CommandKit records
|
||||
the corresponding presentation failure even if the caller ignores the error.
|
||||
A caller can inspect the returned error and end its handler immediately.
|
||||
|
||||
### Deferred messages
|
||||
|
||||
```go
|
||||
type DeferredReporter struct {
|
||||
// Private reference to the same invocation state.
|
||||
}
|
||||
|
||||
func (r DeferredReporter) Info(message string, details ...Detail) error
|
||||
func (r DeferredReporter) Warn(message string, details ...Detail) error
|
||||
func (r DeferredReporter) Debug(message string, details ...Detail) error
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
```go
|
||||
_ = report.AtEnd().Warn("Three optional files were unavailable.")
|
||||
```
|
||||
|
||||
Copies of the deferred view share invocation state. Its methods return submission
|
||||
errors, such as attempting to use a closed reporter. Errors encountered during
|
||||
deferred rendering appear in the final invocation outcome. Progress remains an
|
||||
immediate streaming operation; substantive output uses the main reporter.
|
||||
|
||||
Copy callback lists when accepting a submission. Callers retain ownership of
|
||||
data captured by rendering callbacks and keep that data valid until presentation.
|
||||
|
||||
### Terminal outcomes
|
||||
|
||||
Outcome construction requires no invocation state and emits nothing:
|
||||
|
||||
```go
|
||||
type Explanation struct {
|
||||
Message string
|
||||
Details []Detail
|
||||
}
|
||||
|
||||
type Outcome struct {
|
||||
Kind OutcomeKind
|
||||
Status int
|
||||
Domain string
|
||||
Cause error
|
||||
Explanation Explanation
|
||||
Presentation PresentationResult
|
||||
}
|
||||
|
||||
func Success() Outcome
|
||||
func Failure(message string, details ...Detail) Outcome
|
||||
func Usage(message string, details ...Detail) Outcome
|
||||
func Domain(name string, status int) Outcome
|
||||
|
||||
func (o Outcome) WithExplanation(message string, details ...Detail) Outcome
|
||||
func (o Outcome) WithCause(cause error) Outcome
|
||||
```
|
||||
|
||||
`Success` returns a successful outcome with status `0`. `Failure` produces an
|
||||
operational failure with status `1`; `Usage` produces a usage failure with status
|
||||
`2`. `Domain` constructs an outcome with the supplied application-defined
|
||||
classification and status. Subsequent presentation failures follow OD-4's rules:
|
||||
domain classification is preserved, and a zero status becomes `1` if presentation
|
||||
fails.
|
||||
`OutcomeKind` retains the success, usage, operational, and domain classifications.
|
||||
|
||||
`WithExplanation` returns a modified outcome value and replaces its terminal
|
||||
explanation. Explanations belong to failed outcomes; successful summaries use
|
||||
Info or substantive output. Ordinary application failure, native failure
|
||||
rendering, and a custom domain failure respectively can be expressed as:
|
||||
|
||||
```go
|
||||
return commandkit.Failure("Unable to export the requested report.")
|
||||
```
|
||||
|
||||
```go
|
||||
return commandkit.Failure("", writeNativeFailure)
|
||||
```
|
||||
|
||||
```go
|
||||
return commandkit.Domain("validation", 3).
|
||||
WithExplanation("The document failed validation.", writeFindings)
|
||||
```
|
||||
|
||||
The named renderers above have signature `func(io.Writer) error` and preserve the
|
||||
owning library's safe diagnostic formatting. They run during terminal delivery,
|
||||
not when constructing the outcome.
|
||||
|
||||
Before terminal delivery, supply the explanation `Command failed.` when a
|
||||
non-zero outcome has an empty `Explanation.Message` and no non-nil callback in
|
||||
`Explanation.Details`. This produces `ERROR: Command failed.` in every mode,
|
||||
including quiet, and becomes the explanation retained in the returned outcome.
|
||||
Adding the fallback leaves classification, status, and cause unchanged;
|
||||
presentation failures still follow OD-4's rules. The fallback is based on the
|
||||
failed outcome and absent explanation, without deriving text from `Cause.Error()`.
|
||||
|
||||
An empty heading with a non-nil detail renderer is an explicitly supplied
|
||||
explanation. Trust that renderer to provide its content. Do not buffer or replay
|
||||
it to decide whether the explanation is sufficiently informative, and do not
|
||||
add the fallback merely because it writes no bytes.
|
||||
|
||||
| Returned outcome | Terminal presentation |
|
||||
| --- | --- |
|
||||
| `Failure("")` | Generic fallback, operational classification and status `1`. |
|
||||
| `Domain("validation", 3)` | Generic fallback, domain classification and status `3`. |
|
||||
| `Failure("").WithCause(err)` | Generic fallback; the supplied cause remains available without automatic rendering. |
|
||||
| `Failure("", writeNativeFailure)` | Native explanation without an added generic heading. |
|
||||
|
||||
`Cause` is optional. `WithCause` returns a modified outcome value, replacing the
|
||||
cause without changing the classification, status, or explanation. For example:
|
||||
|
||||
```go
|
||||
return commandkit.Failure("Unable to open the configuration file.").
|
||||
WithCause(err)
|
||||
```
|
||||
|
||||
CommandKit presents the supplied explanation and carries the original error
|
||||
through to the execution caller, which can inspect it with expressions such as
|
||||
`errors.Is(outcome.Cause, fs.ErrNotExist)`. It does not automatically present
|
||||
`Cause.Error()` or use the cause to decide success, failure, or message category.
|
||||
Providing a cause is independent of choosing the terminal explanation; neither
|
||||
is inferred from the other. Warnings do not require supplying or retaining causes.
|
||||
|
||||
Outcome processing preserves supplied causes, including their existing Go error
|
||||
identity, when a subsequent rendering or delivery failure occurs. Presentation
|
||||
failures are recorded separately in `Presentation`. The returned outcome retains
|
||||
its terminal explanation and optional cause, with other retention limited to the
|
||||
presentation needs described above.
|
||||
|
||||
### Provider-returned diagnostic data
|
||||
|
||||
Providers return values and diagnostic data without needing a reporter parameter.
|
||||
Retain `Acquired[T]` with separate fields for independent messages and failure
|
||||
explanations:
|
||||
|
||||
```go
|
||||
type Acquired[T any] struct {
|
||||
Value T
|
||||
Diagnostics []Diagnostic
|
||||
Explanation Explanation
|
||||
}
|
||||
```
|
||||
|
||||
`Diagnostics` carries independent, nonterminal messages. `Diagnostic` and
|
||||
`Severity` remain passive data types for those messages, with each diagnostic
|
||||
carrying its `Severity` and a `Write func(io.Writer) error` renderer. Adapt their
|
||||
contracts to the nonterminal reporting categories. Integrations submit the
|
||||
records through the corresponding reporter methods, including the deferred view
|
||||
where appropriate. Removing `Outcome.Diagnostics` removes a submission path;
|
||||
provider-returned records remain available for the integration to submit.
|
||||
|
||||
`Explanation` carries context to attach when the integration returns a failed
|
||||
outcome. Terminal Error content uses this field rather than an independently
|
||||
submitted diagnostic. An upstream warning that helps explain the failure may
|
||||
belong in the explanation; unrelated warnings remain in `Diagnostics` and follow
|
||||
their ordinary visibility rules. Adapters preserve this distinction and useful
|
||||
native formatting when translating mixed upstream batches, instead of assigning
|
||||
the whole batch its highest severity.
|
||||
|
||||
Providers may return acquired data alongside a Go error. The owning integration
|
||||
chooses the command outcome, attaches any terminal explanation, and decides which
|
||||
cause to preserve. Returning diagnostic data does not itself emit messages or
|
||||
select the invocation outcome. Diagnostic renderers follow the same callback
|
||||
boundary as other details when invoked through the reporter.
|
||||
|
||||
### Presentation observations and closed reporters
|
||||
|
||||
```go
|
||||
type PresentationResult struct {
|
||||
Output DeliveryResult
|
||||
Diagnostics DeliveryResult
|
||||
}
|
||||
|
||||
type DeliveryResult struct {
|
||||
State DeliveryState
|
||||
RenderError error
|
||||
WriteError error
|
||||
}
|
||||
|
||||
type DeliveryState uint8
|
||||
|
||||
const (
|
||||
DeliveryNone DeliveryState = iota
|
||||
DeliverySuppressed
|
||||
DeliveryComplete
|
||||
DeliveryIncomplete
|
||||
)
|
||||
|
||||
var ErrReporterClosed = errors.New("reporter is closed")
|
||||
```
|
||||
|
||||
| State | Meaning |
|
||||
| --- | --- |
|
||||
| `DeliveryNone` | No content was submitted for this destination. |
|
||||
| `DeliverySuppressed` | All submitted content was intentionally suppressed, and rendering succeeded. |
|
||||
| `DeliveryComplete` | All requested delivery completed; other content may have been suppressed. |
|
||||
| `DeliveryIncomplete` | Rendering or destination writing failed. |
|
||||
|
||||
Retain the first rendering failure and first destination-write failure for each
|
||||
destination. This provides bounded evidence without collecting an event history.
|
||||
`Outcome.Presentation` is populated by CommandKit during execution and
|
||||
finalization; it is observation data, not another submission API.
|
||||
|
||||
The invocation supplies the reporter. Consumers neither construct nor finalize
|
||||
it. After closure, message submissions and writer operations return
|
||||
`ErrReporterClosed`, including in quiet mode. The initial API needs no explicit
|
||||
flush method, transcript accessor, or verbosity checks. Error-conversion helpers
|
||||
are deferred pending demonstrated value.
|
||||
|
||||
### Application example
|
||||
|
||||
This target-API handler uses an application-owned `exportData` operation that
|
||||
returns a JSON-encodable result with a `UsedDefaults` field:
|
||||
|
||||
```go
|
||||
func runExport(inv commandkit.Invocation) commandkit.Outcome {
|
||||
report := inv.Reporter()
|
||||
|
||||
result, err := exportData(inv.Context)
|
||||
if err != nil {
|
||||
return commandkit.Failure("Unable to export the requested data.")
|
||||
}
|
||||
|
||||
if result.UsedDefaults {
|
||||
_ = report.Warn("Some optional settings used defaults.")
|
||||
}
|
||||
|
||||
if err := report.RenderOutput(func(w io.Writer) error {
|
||||
return json.NewEncoder(w).Encode(result)
|
||||
}); err != nil {
|
||||
return commandkit.Failure("Unable to present the export result.")
|
||||
}
|
||||
|
||||
_ = report.AtEnd().Info("Export completed.")
|
||||
return commandkit.Success()
|
||||
}
|
||||
```
|
||||
|
||||
Ignored immediate reporting errors are still recorded and can make the final
|
||||
invocation unsuccessful. The renderer runs in quiet mode against the discard
|
||||
destination, preserving serialization failures. The deferred summary follows
|
||||
the selected verbosity policy and describes the completed export operation;
|
||||
the final invocation outcome also accounts for presentation failures.
|
||||
|
||||
## Upstream diagnostic ownership
|
||||
|
||||
ConfigKit and PromptKit remain authoritative for their diagnostic semantics,
|
||||
severity, source context, and disclosure rules. Integration adapters expose
|
||||
those capabilities through the shared CommandKit presentation policy.
|
||||
|
||||
Support consistent reporting of ConfigKit errors and warnings as its adopted
|
||||
public API evolves. This roadmap does not assume that a future warning API
|
||||
already exists or prescribe its upstream shape. Integration-owned commands
|
||||
select the outcome and submit useful native diagnostic detail for presentation.
|
||||
Core presents those records under the shared policy. Applications using upstream
|
||||
libraries directly choose how to respond to domain-specific errors and which
|
||||
outcomes and messages to supply, as for their other operations.
|
||||
|
||||
An integration may receive both a useful substantive report and an operation
|
||||
failure, as PromptKit smoke testing supports. Preserve the report under the
|
||||
ordinary substantive-output policy, then present the terminal explanation.
|
||||
Quiet mode suppresses the report while retaining the explanation. Having a
|
||||
report does not imply a successful outcome, and returning a failure does not
|
||||
discard substantive output already prepared for delivery before the terminal step.
|
||||
|
||||
Debug output follows the same upstream disclosure and safe-rendering contracts.
|
||||
Enabling debug does not authorize secret disclosure or bypass owning-library
|
||||
rules.
|
||||
|
||||
## Delivery scope
|
||||
|
||||
The completed feature includes the selected reporter and outcome API, ordered
|
||||
mode/progress selection through normal parsing, shared core presentation, and
|
||||
both optional integration migrations. Passive provider diagnostics distinguish
|
||||
independent messages from terminal explanations while preserving native evidence
|
||||
and rendering. Existing command behavior remains unchanged outside the selected
|
||||
reporting policy.
|
||||
|
||||
Maintained consumers demonstrate ordinary output, immediate/deferred messages,
|
||||
silent recovery, explicit warnings, failure after partial output, optional cause
|
||||
transport, and structured terminal explanations. Integration consumers include
|
||||
native diagnostic context and useful reports returned alongside failures.
|
||||
|
||||
Behavioral coverage establishes CommandKit-owned parsing, visibility, ordering,
|
||||
concurrent reporter coordination, closure, presentation observations, and failure
|
||||
handling. It preserves existing alias, input/state, discovery, native-report,
|
||||
and upstream-adapter protections without testing upstream domain correctness.
|
||||
GoDoc, CLI documentation, consumer guidance, internal documentation, and release
|
||||
notes describe the implemented boundary under their canonical ownership.
|
||||
|
||||
The [implementation plan](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/230187296fae5253cb1773f16c2f1d1a39499ed0/docs/roadmap/implementation.md) defines the concrete migration
|
||||
steps, focused validation, and completion criteria. This roadmap defines the
|
||||
target behavior and selected policy choices.
|
||||
|
||||
## Architectural preparation completed
|
||||
|
||||
- [x] Review and accept [ADR 0014](../adr/0014-invocation-reporting.md) as an
|
||||
extension for shared reporting, retaining ADR 0005's outcome and supplied-error
|
||||
preservation decisions.
|
||||
- [x] Align the [architecture policy](../policy/architecture.md) with the reporting
|
||||
ownership model, including application ownership of domain-specific error
|
||||
decisions and scope for useful shared error helpers.
|
||||
|
||||
OD-1 through OD-4 and the consumer API signatures are selected. The target end
|
||||
state includes maintained, executable consumers using those APIs; the
|
||||
[implementation plan](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/230187296fae5253cb1773f16c2f1d1a39499ed0/docs/roadmap/implementation.md) owns their delivery sequence.
|
||||
39
examples/core/README.md
Normal file
39
examples/core/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Core example
|
||||
|
||||
Run the example from the repository root:
|
||||
|
||||
```sh
|
||||
go run ./examples/core hello --times=0 --tag=first
|
||||
go run ./examples/core ws greet --count=2
|
||||
go run ./examples/core ws --help
|
||||
go run ./examples/core hello --hlep
|
||||
go build -o core-example ./examples/core
|
||||
./core-example completion bash
|
||||
```
|
||||
|
||||
The example installs a canonical `workspace greet` command together with a
|
||||
command shortcut, a group shortcut, a scoped flag alias, and a global help
|
||||
alias. Its declaration slice mixes the canonical group and routing rules as
|
||||
`[]commandkit.Declaration`. The handler prints `Invocation.EnteredPath()` and
|
||||
`Invocation.CanonicalPath()` so the alternate route and its canonical target
|
||||
are visible in the output.
|
||||
|
||||
The example also installs `Completion("core-example")` and gives the `report`
|
||||
scenario argument static choices. Initialize `bash-completion` first if using
|
||||
Bash. Build it with the basename shown above, load
|
||||
the script for your shell using the [CLI reference](../../docs/cli.md#shell-completion),
|
||||
and try completing `core-example report d` or `core-example ws gr`.
|
||||
|
||||
Compatible integration factories also return `commandkit.Command` and can be
|
||||
passed directly to `New` beside `commandkit.Alias` or flag-alias declarations.
|
||||
The current integrations still use the old core identity; the planned matched
|
||||
vanity-path releases will use the new core identity. For a
|
||||
command-only `[]commandkit.Command`, convert its elements into a
|
||||
`[]commandkit.Declaration` before appending routing rules; Go cannot expand the
|
||||
command slice as `...commandkit.Declaration`. The
|
||||
[consumer guide](../../docs/consumers/guide.md#application-owned-aliases)
|
||||
shows both forms, and the [composed consumer](../../integrations/ckprompt/examples/composed)
|
||||
is the maintained integration example.
|
||||
|
||||
These aliases require core v0.3.0. Repository contributors can run its test with
|
||||
`go test ./examples/core` from the root.
|
||||
@@ -2,11 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
)
|
||||
|
||||
type greetInput struct {
|
||||
@@ -14,6 +16,16 @@ type greetInput struct {
|
||||
Tags commandkit.Supplied[[]string]
|
||||
}
|
||||
|
||||
type reportInput struct {
|
||||
Scenario string
|
||||
}
|
||||
|
||||
var (
|
||||
errRecoveredFixture = errors.New("a local read failed and was recovered")
|
||||
errWarningFixture = errors.New("a local warning has a cause")
|
||||
errPartialFixture = errors.New("the local report could not be completed")
|
||||
)
|
||||
|
||||
func newApp() (*commandkit.App, error) {
|
||||
countDefault := 1
|
||||
tagDefaults := []string{"demo"}
|
||||
@@ -29,6 +41,7 @@ func newApp() (*commandkit.App, error) {
|
||||
}),
|
||||
},
|
||||
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
|
||||
reporter := invocation.Reporter()
|
||||
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
|
||||
if input.Count.Set {
|
||||
count = fmt.Sprintf("explicit %d", input.Count.Value)
|
||||
@@ -37,14 +50,96 @@ func newApp() (*commandkit.App, error) {
|
||||
if input.Tags.Set {
|
||||
tags = fmt.Sprintf("explicit %v", input.Tags.Value)
|
||||
}
|
||||
if _, err := fmt.Fprintf(invocation.Streams.Out, "Hello; count=%s; tags=%s\n", count, tags); err != nil {
|
||||
return commandkit.Failure(err)
|
||||
}
|
||||
_ = reporter.RenderOutput(func(writer io.Writer) error {
|
||||
_, err := fmt.Fprintf(writer, "Hello; count=%s; tags=%s; entered=%q; canonical=%q\n", count, tags, invocation.EnteredPath(), invocation.CanonicalPath())
|
||||
return err
|
||||
})
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
report := commandkit.NewCommand(commandkit.CommandSpec[reportInput]{
|
||||
Name: "report",
|
||||
Summary: "Demonstrate invocation reporting with local scenarios.",
|
||||
NewInput: func() reportInput {
|
||||
return reportInput{Scenario: "demo"}
|
||||
},
|
||||
Arguments: []commandkit.Argument[reportInput]{
|
||||
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "scenario", Usage: "demo, recover, warn, warn-error, fail, or partial.", Completion: commandkit.CompleteChoices(
|
||||
commandkit.CompletionChoice{Value: "demo"}, commandkit.CompletionChoice{Value: "recover"}, commandkit.CompletionChoice{Value: "warn"},
|
||||
commandkit.CompletionChoice{Value: "warn-error"}, commandkit.CompletionChoice{Value: "fail"}, commandkit.CompletionChoice{Value: "partial"})}, false, func(value string) (string, error) { return value, nil },
|
||||
func(input *reportInput, value commandkit.Supplied[string]) { input.Scenario = value.Value }),
|
||||
},
|
||||
CheckInput: func(input reportInput) error {
|
||||
switch input.Scenario {
|
||||
case "demo", "recover", "warn", "warn-error", "fail", "partial":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown reporting scenario %q", input.Scenario)
|
||||
}
|
||||
},
|
||||
Run: func(invocation commandkit.Invocation, input reportInput) commandkit.Outcome {
|
||||
return runReportingExample(invocation, input.Scenario)
|
||||
},
|
||||
})
|
||||
workspace := commandkit.Group("workspace", "Workspace commands.", greet)
|
||||
return commandkit.New(commandkit.Identity{Name: "core-example", Version: "1.0"}, workspace)
|
||||
declarations := []commandkit.Declaration{
|
||||
workspace,
|
||||
report,
|
||||
commandkit.Completion("core-example"),
|
||||
commandkit.Alias("hello", "workspace greet"),
|
||||
commandkit.Alias("ws", "workspace"),
|
||||
commandkit.FlagAliasFor("workspace greet", "--times", "--count"),
|
||||
commandkit.FlagAlias("--hlep", "--help"),
|
||||
}
|
||||
return commandkit.New(commandkit.Identity{Name: "core-example", Version: "1.0"}, declarations...)
|
||||
}
|
||||
|
||||
func runReportingExample(invocation commandkit.Invocation, scenario string) commandkit.Outcome {
|
||||
reporter := invocation.Reporter()
|
||||
deferred := reporter.AtEnd()
|
||||
switch scenario {
|
||||
case "demo":
|
||||
_ = reporter.Progress("Loaded one local fixture.")
|
||||
_ = deferred.Warn("Optional fixture warning", func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "The sample contains an optional note.\n")
|
||||
return err
|
||||
})
|
||||
_ = deferred.Info("Using deterministic local data.")
|
||||
_ = deferred.Debug("Selected the built-in reporting example.")
|
||||
_ = deferred.Info("Summary", writeExampleSummary)
|
||||
_ = reporter.RenderOutput(writeExampleReport)
|
||||
return commandkit.Success()
|
||||
case "recover":
|
||||
return commandkit.Success().WithCause(errRecoveredFixture)
|
||||
case "warn":
|
||||
_ = deferred.Warn("The local operation completed with a warning.")
|
||||
return commandkit.Success()
|
||||
case "warn-error":
|
||||
_ = deferred.Warn("The local operation completed with a warning.")
|
||||
return commandkit.Success().WithCause(errWarningFixture)
|
||||
case "fail":
|
||||
return commandkit.Failure("The selected local operation could not be completed.")
|
||||
case "partial":
|
||||
_ = reporter.RenderOutput(writePartialExampleReport)
|
||||
return commandkit.Failure("The local report stopped after partial output.").WithCause(errPartialFixture)
|
||||
default:
|
||||
return commandkit.Usage(fmt.Sprintf("unknown reporting scenario %q", scenario))
|
||||
}
|
||||
}
|
||||
|
||||
func writeExampleReport(writer io.Writer) error {
|
||||
_, err := fmt.Fprintln(writer, "Prepared a local report from one fixture.")
|
||||
return err
|
||||
}
|
||||
|
||||
func writePartialExampleReport(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "Partial local report: first section complete.\n")
|
||||
return err
|
||||
}
|
||||
|
||||
func writeExampleSummary(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "Processed one local fixture.\n")
|
||||
return err
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
199
examples/core/main_test.go
Normal file
199
examples/core/main_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
)
|
||||
|
||||
func TestAliasesReachCoreExampleCommand(t *testing.T) {
|
||||
app, err := newApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
entered string
|
||||
canonical string
|
||||
greeting string
|
||||
}{
|
||||
{name: "command shortcut and scoped flag", args: []string{"hello", "--times=0", "--tag", "one"}, entered: "hello", canonical: "workspace greet", greeting: "count=explicit 0; tags=explicit [one]"},
|
||||
{name: "group shortcut", args: []string{"ws", "greet", "--count=2"}, entered: "ws greet", canonical: "workspace greet", greeting: "count=explicit 2"},
|
||||
{name: "canonical command", args: []string{"workspace", "greet"}, entered: "workspace greet", canonical: "workspace greet", greeting: "count=default 1 (not supplied)"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), test.args, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("Execute(%q) = %#v; output %q", test.args, outcome, output.String())
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
test.greeting,
|
||||
"entered=\"" + test.entered + "\"",
|
||||
"canonical=\"" + test.canonical + "\"",
|
||||
} {
|
||||
if !strings.Contains(output.String(), fragment) {
|
||||
t.Errorf("output %q does not contain %q", output.String(), fragment)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalHelpAliasDoesNotRunCoreExampleCommand(t *testing.T) {
|
||||
app, err := newApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"hello", "--hlep"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "--hlep → --help") || strings.Contains(output.String(), "Hello;") {
|
||||
t.Fatalf("help alias outcome/output = (%#v, %q)", outcome, output.String())
|
||||
}
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"ws", "--help"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "greet") || strings.Contains(output.String(), "Hello;") {
|
||||
t.Fatalf("group shortcut help outcome/output = (%#v, %q)", outcome, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportingExampleModes(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantOutput []string
|
||||
wantDiag []string
|
||||
omitDiag []string
|
||||
}{
|
||||
{
|
||||
name: "ordinary result",
|
||||
args: []string{"report", "demo"},
|
||||
wantOutput: []string{"Prepared a local report from one fixture."},
|
||||
omitDiag: []string{"Optional fixture warning", "Using deterministic local data.", "Loaded one local fixture.", "DEBUG:"},
|
||||
},
|
||||
{
|
||||
name: "progress selected",
|
||||
args: []string{"report", "demo", "--progress"},
|
||||
wantOutput: []string{"Prepared a local report from one fixture."},
|
||||
wantDiag: []string{"Loaded one local fixture."},
|
||||
omitDiag: []string{"WARN:", "Using deterministic local data.", "DEBUG:"},
|
||||
},
|
||||
{
|
||||
name: "verbose messages",
|
||||
args: []string{"report", "demo", "--verbose"},
|
||||
wantOutput: []string{"Prepared a local report from one fixture."},
|
||||
wantDiag: []string{"WARN: Optional fixture warning", "The sample contains an optional note.", "Using deterministic local data.", "Summary", "Processed one local fixture."},
|
||||
omitDiag: []string{"Loaded one local fixture.", "DEBUG:"},
|
||||
},
|
||||
{
|
||||
name: "debug messages",
|
||||
args: []string{"report", "demo", "--debug"},
|
||||
wantOutput: []string{"Prepared a local report from one fixture."},
|
||||
wantDiag: []string{"WARN: Optional fixture warning", "Using deterministic local data.", "Loaded one local fixture.", "DEBUG: Selected the built-in reporting example.", "Processed one local fixture."},
|
||||
},
|
||||
{
|
||||
name: "quiet suppresses result and messages",
|
||||
args: []string{"report", "demo", "--quiet"},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
outcome, output, diagnostics := executeExample(t, test.args...)
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("Execute(%q) = %#v", test.args, outcome)
|
||||
}
|
||||
for _, fragment := range test.wantOutput {
|
||||
if !strings.Contains(output, fragment) {
|
||||
t.Errorf("output %q does not contain %q", output, fragment)
|
||||
}
|
||||
}
|
||||
for _, fragment := range test.wantDiag {
|
||||
if !strings.Contains(diagnostics, fragment) {
|
||||
t.Errorf("diagnostics %q does not contain %q", diagnostics, fragment)
|
||||
}
|
||||
}
|
||||
for _, fragment := range test.omitDiag {
|
||||
if strings.Contains(diagnostics, fragment) {
|
||||
t.Errorf("diagnostics %q unexpectedly contain %q", diagnostics, fragment)
|
||||
}
|
||||
}
|
||||
if test.name == "quiet suppresses result and messages" && (output != "" || diagnostics != "") {
|
||||
t.Errorf("quiet output/diagnostics = (%q, %q), want both empty", output, diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportingExampleScenarios(t *testing.T) {
|
||||
t.Run("silent recovery retains cause", func(t *testing.T) {
|
||||
outcome, output, diagnostics := executeExample(t, "report", "recover")
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !errors.Is(outcome.Cause, errRecoveredFixture) {
|
||||
t.Fatalf("outcome = %#v, want successful recovery with original cause", outcome)
|
||||
}
|
||||
if output != "" || diagnostics != "" {
|
||||
t.Fatalf("recovery output/diagnostics = (%q, %q), want silent recovery", output, diagnostics)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("warning without cause", func(t *testing.T) {
|
||||
outcome, _, diagnostics := executeExample(t, "report", "warn", "--verbose")
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Cause != nil {
|
||||
t.Fatalf("outcome = %#v, want success without cause", outcome)
|
||||
}
|
||||
if !strings.Contains(diagnostics, "WARN: The local operation completed with a warning.") {
|
||||
t.Fatalf("diagnostics = %q, want explicit warning", diagnostics)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("warning with independent cause", func(t *testing.T) {
|
||||
outcome, _, diagnostics := executeExample(t, "report", "warn-error", "--verbose")
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !errors.Is(outcome.Cause, errWarningFixture) {
|
||||
t.Fatalf("outcome = %#v, want successful warning with original cause", outcome)
|
||||
}
|
||||
if !strings.Contains(diagnostics, "WARN: The local operation completed with a warning.") || strings.Contains(diagnostics, errWarningFixture.Error()) {
|
||||
t.Fatalf("diagnostics = %q, want warning text without cause text", diagnostics)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("terminal explanation without cause", func(t *testing.T) {
|
||||
outcome, _, diagnostics := executeExample(t, "report", "fail", "--quiet")
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status == 0 || outcome.Cause != nil {
|
||||
t.Fatalf("outcome = %#v, want terminal failure without cause", outcome)
|
||||
}
|
||||
if outcome.Explanation.Message != "The selected local operation could not be completed." || !strings.Contains(diagnostics, outcome.Explanation.Message) {
|
||||
t.Fatalf("explanation/diagnostics = (%q, %q)", outcome.Explanation.Message, diagnostics)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial output keeps cause independent", func(t *testing.T) {
|
||||
outcome, output, diagnostics := executeExample(t, "report", "partial")
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, errPartialFixture) {
|
||||
t.Fatalf("outcome = %#v, want terminal failure with original cause", outcome)
|
||||
}
|
||||
if !strings.Contains(output, "Partial local report: first section complete.") {
|
||||
t.Fatalf("output = %q, want completed first section", output)
|
||||
}
|
||||
if outcome.Explanation.Message != "The local report stopped after partial output." || !strings.Contains(diagnostics, outcome.Explanation.Message) {
|
||||
t.Fatalf("explanation/diagnostics = (%q, %q)", outcome.Explanation.Message, diagnostics)
|
||||
}
|
||||
if strings.Contains(diagnostics, errPartialFixture.Error()) {
|
||||
t.Errorf("diagnostics %q unexpectedly contain cause text", diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func executeExample(t *testing.T, args ...string) (commandkit.Outcome, string, string) {
|
||||
t.Helper()
|
||||
app, err := newApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnostics})
|
||||
return outcome, output.String(), diagnostics.String()
|
||||
}
|
||||
403
execute.go
403
execute.go
@@ -5,52 +5,88 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
||||
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||
)
|
||||
|
||||
// Execute parses and dispatches one invocation. args excludes the executable
|
||||
// name. Supplied values are converted before command input construction; help
|
||||
// does not construct caller inputs or run acquisition callbacks. 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.
|
||||
// does not construct caller inputs or run acquisition callbacks. It applies the
|
||||
// parsed reporting selection, finalizes invocation presentation, and returns
|
||||
// the resulting Outcome without exiting the process. Supplied streams remain
|
||||
// caller-owned; nil destinations are replaced with io.Discard. A nil context
|
||||
// returns an operational failure.
|
||||
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)
|
||||
current := newExecution(a, ctx, streams)
|
||||
if a == nil {
|
||||
err := errors.New("cannot execute commands on a nil app")
|
||||
return current.finalize(Failure(err.Error()).WithCause(err), nil, false)
|
||||
}
|
||||
if ctx == nil {
|
||||
err := errors.New("invocation context must not be nil")
|
||||
return current.finalize(Failure(err.Error()).WithCause(err), nil, false)
|
||||
}
|
||||
if current.snapshot != nil && current.snapshot.completion && len(args) > 0 && args[0] == internalcobra.CompletionQueryToken {
|
||||
return current.complete(args[1:])
|
||||
}
|
||||
routedSnapshot := current.snapshot
|
||||
parsed := current.parse(args)
|
||||
current.route = resolveEnteredRoute(args, routedSnapshot)
|
||||
current.recordRouteEvents()
|
||||
parsed := current.parse(current.route.args)
|
||||
current.reporter.applySelection(parsed.Reporting)
|
||||
current.recordSelectionEvents(parsed)
|
||||
current.submitDebugEvents()
|
||||
var outcome Outcome
|
||||
path := parsed.Path
|
||||
handlerRan := false
|
||||
if parsed.Failure != nil {
|
||||
return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path)
|
||||
}
|
||||
payload, ok := routedSnapshot.runners[parsed.Key]
|
||||
if !ok || payload == nil {
|
||||
return current.present(current.renderHelp(parsed.Path), parsed.Path)
|
||||
}
|
||||
if parsed.HelpRequested || !payload.hasRun() {
|
||||
if _, err := payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil {
|
||||
return current.present(Usage(err), parsed.Path)
|
||||
outcome = Usage(parsed.Failure.Message)
|
||||
} else if current.route.kind == routeDiscovery {
|
||||
if parsed.HelpRequested {
|
||||
outcome = current.renderHelp(parsed.Path)
|
||||
path = clonePath(current.route.enteredPath)
|
||||
} else {
|
||||
entered := current.route.enteredPath.String()
|
||||
outcome = Usage(fmt.Sprintf("incomplete command path %q; use %q --help", entered, entered))
|
||||
path = clonePath(current.route.enteredPath)
|
||||
}
|
||||
} else {
|
||||
payload, ok := routedSnapshot.runners[parsed.Key]
|
||||
if !ok || payload == nil {
|
||||
outcome = current.renderHelp(parsed.Path)
|
||||
} else if parsed.HelpRequested || !payload.hasRun() {
|
||||
if _, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.Operands, false); err != nil {
|
||||
outcome = classifyInputError(err)
|
||||
} else {
|
||||
outcome = current.renderHelp(parsed.Path)
|
||||
}
|
||||
} else {
|
||||
run, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.Operands, true)
|
||||
if err != nil {
|
||||
outcome = classifyInputError(err)
|
||||
} else {
|
||||
handlerRan = true
|
||||
outcome = run(current)
|
||||
}
|
||||
}
|
||||
return current.present(current.renderHelp(parsed.Path), parsed.Path)
|
||||
}
|
||||
run, err := payload.prepare(parsed.FlagValues, parsed.Operands, true)
|
||||
if err != nil {
|
||||
return current.present(Usage(err), parsed.Path)
|
||||
}
|
||||
return current.present(run(current), parsed.Path)
|
||||
return current.finalize(outcome, path, handlerRan)
|
||||
}
|
||||
|
||||
type execution struct {
|
||||
app *App
|
||||
ctx context.Context
|
||||
streams Streams
|
||||
options commandmeta.ParseOptions
|
||||
snapshot *routingSnapshot
|
||||
session *internalcobra.Session
|
||||
app *App
|
||||
ctx context.Context
|
||||
streams Streams
|
||||
reporter *Reporter
|
||||
debugEvents []string
|
||||
options commandmeta.ParseOptions
|
||||
snapshot *routingSnapshot
|
||||
session *internalcobra.Session
|
||||
route routeResult
|
||||
}
|
||||
|
||||
type commandExecution struct {
|
||||
@@ -60,25 +96,35 @@ 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,
|
||||
reporter: current.reporter,
|
||||
enteredPath: current.route.enteredPath.String(),
|
||||
canonicalPath: current.route.canonicalPath.String(),
|
||||
}
|
||||
}
|
||||
|
||||
func (current commandExecution) renderHelpTargets(targets []string) Outcome {
|
||||
return current.renderHelpTargetsFunc(targets)
|
||||
}
|
||||
|
||||
func newExecution(app *App, ctx context.Context, streams Streams) *execution {
|
||||
streams = normalizeStreams(streams)
|
||||
current := &execution{
|
||||
app: app,
|
||||
ctx: ctx,
|
||||
streams: streams,
|
||||
options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
|
||||
app: app,
|
||||
ctx: ctx,
|
||||
streams: streams,
|
||||
reporter: newReporter(streams),
|
||||
options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
|
||||
}
|
||||
if app != nil {
|
||||
current.snapshot = app.routeSnapshot()
|
||||
current.session = internalcobra.Prepare(current.snapshot.metadata, current.options)
|
||||
}
|
||||
return current
|
||||
}
|
||||
@@ -87,7 +133,34 @@ func (current *execution) parse(args []string) commandmeta.Result {
|
||||
if current.app == nil {
|
||||
return commandmeta.Result{Failure: &commandmeta.Failure{Message: "cannot parse commands on a nil app"}}
|
||||
}
|
||||
return current.session.Parse(args)
|
||||
if current.session == nil {
|
||||
current.prepareSession()
|
||||
}
|
||||
return reduceReportingOccurrences(current.session.Parse(args))
|
||||
}
|
||||
|
||||
func (current *execution) prepareSession() {
|
||||
if current.app == nil {
|
||||
return
|
||||
}
|
||||
if current.snapshot == nil {
|
||||
current.snapshot = current.app.routeSnapshot()
|
||||
}
|
||||
options := current.options
|
||||
if current.snapshot != nil {
|
||||
switch current.route.kind {
|
||||
case routeCanonical:
|
||||
options.FlagAliases = current.snapshot.flagAliases[current.route.canonicalPath.Key()].neutralRecords()
|
||||
case routeDiscovery:
|
||||
options.FlagAliases = current.snapshot.discoveryFlags[current.route.discoveryPath.Key()].neutralRecords()
|
||||
default:
|
||||
snapshot := current.snapshot
|
||||
options.FlagAliasesForPath = func(path []string) []commandmeta.FlagAlias {
|
||||
return snapshot.flagAliases[commandpath.FromTokens(path).Key()].neutralRecords()
|
||||
}
|
||||
}
|
||||
current.session = internalcobra.Prepare(current.snapshot.metadata, options)
|
||||
}
|
||||
}
|
||||
|
||||
func (current *execution) presentationSession() *internalcobra.Session {
|
||||
@@ -97,23 +170,50 @@ func (current *execution) presentationSession() *internalcobra.Session {
|
||||
snapshot := current.app.routeSnapshot()
|
||||
if current.session == nil || current.snapshot != snapshot {
|
||||
current.snapshot = snapshot
|
||||
current.session = internalcobra.Prepare(snapshot.metadata, current.options)
|
||||
current.session = nil
|
||||
current.prepareSession()
|
||||
}
|
||||
return current.session
|
||||
}
|
||||
|
||||
func (current *execution) renderHelp(path []string) Outcome {
|
||||
if current.app == nil {
|
||||
return Failure(errors.New("cannot render help on a nil app"))
|
||||
err := errors.New("cannot render help on a nil app")
|
||||
return Failure(err.Error()).WithCause(err)
|
||||
}
|
||||
return current.app.renderHelpOutcomeWithSession(path, current.streams, current.presentationSession())
|
||||
session := current.presentationSession()
|
||||
view := current.app.helpView(current.route, current.snapshot)
|
||||
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
|
||||
}
|
||||
|
||||
func (current *execution) renderHelpTargets(targets []string) Outcome {
|
||||
if current.app == nil {
|
||||
return Failure(errors.New("cannot render help on a nil app"))
|
||||
err := errors.New("cannot render help on a nil app")
|
||||
return Failure(err.Error()).WithCause(err)
|
||||
}
|
||||
return current.app.renderHelpTargetsWithSession(targets, current.streams, current.presentationSession())
|
||||
snapshot := current.app.routeSnapshot()
|
||||
if snapshot == nil {
|
||||
err := errors.New("cannot render help without command metadata")
|
||||
return Failure(err.Error()).WithCause(err)
|
||||
}
|
||||
route := resolveEnteredRoute(targets, snapshot)
|
||||
// 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)
|
||||
view := commandmeta.HelpView{EnteredPath: targets, CanonicalPath: targets}
|
||||
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
|
||||
}
|
||||
options := current.options
|
||||
switch route.kind {
|
||||
case routeCanonical:
|
||||
options.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()
|
||||
case routeDiscovery:
|
||||
options.FlagAliases = snapshot.discoveryFlags[route.discoveryPath.Key()].neutralRecords()
|
||||
}
|
||||
session := internalcobra.Prepare(snapshot.metadata, options)
|
||||
view := current.app.helpView(route, snapshot)
|
||||
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
|
||||
}
|
||||
|
||||
func (current *execution) usageLine(path []string) string {
|
||||
@@ -129,71 +229,133 @@ func (current *execution) usageLine(path []string) string {
|
||||
return current.app.usageFallback()
|
||||
}
|
||||
|
||||
func (current *execution) present(outcome Outcome, path []string) Outcome {
|
||||
return current.app.presentWithUsageLine(outcome, path, current.streams, current.usageLine)
|
||||
func (current *execution) routeUsageLine() string {
|
||||
line := current.usageLine(clonePath(current.route.canonicalPath))
|
||||
canonical := strings.TrimSpace(current.app.identity.Name + " " + current.route.canonicalPath.String())
|
||||
entered := strings.TrimSpace(current.app.identity.Name + " " + current.route.enteredPath.String())
|
||||
line = strings.Replace(line, "Usage: "+canonical, "Usage: "+entered, 1)
|
||||
return line + "\nAlias for: " + canonical
|
||||
}
|
||||
|
||||
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) present(outcome Outcome, path []string, streams Streams) Outcome {
|
||||
return a.presentWithUsageLine(outcome, path, streams, a.usageLine)
|
||||
}
|
||||
|
||||
func (a *App) presentWithUsageLine(outcome Outcome, path []string, streams Streams, usageLine func([]string) string) Outcome {
|
||||
if outcome.Kind == "" && outcome.Status == 0 && outcome.Domain == "" &&
|
||||
outcome.Cause == nil && outcome.DeliveryError == nil &&
|
||||
!outcome.ReportIncomplete && len(outcome.Diagnostics) == 0 {
|
||||
outcome.Kind = OutcomeSuccess
|
||||
}
|
||||
if outcome.DiagnosticDelivery == "" {
|
||||
outcome.DiagnosticDelivery = DeliveryNone
|
||||
}
|
||||
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(SeverityError, outcome.Cause.Error()))
|
||||
}
|
||||
if outcome.Kind == OutcomeUsage {
|
||||
diagnostics = append(diagnostics, MessageDiagnostic(SeverityError, usageLine(path)))
|
||||
}
|
||||
outcome.Diagnostics = diagnostics
|
||||
if len(diagnostics) == 0 {
|
||||
return outcome
|
||||
}
|
||||
delivered := false
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.Write == nil {
|
||||
continue
|
||||
func (current *execution) recordRouteEvents() {
|
||||
entered := current.route.enteredPath.String()
|
||||
canonical := current.route.canonicalPath.String()
|
||||
switch current.route.kind {
|
||||
case routeCanonical:
|
||||
if entered == canonical {
|
||||
if canonical == "" {
|
||||
current.debugEvents = append(current.debugEvents, "selected the application root command")
|
||||
} else {
|
||||
current.debugEvents = append(current.debugEvents, fmt.Sprintf("selected command path %q", canonical))
|
||||
}
|
||||
} else {
|
||||
current.debugEvents = append(current.debugEvents, fmt.Sprintf("resolved command alias %q to %q", entered, canonical))
|
||||
}
|
||||
delivered = true
|
||||
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)
|
||||
case routeDiscovery:
|
||||
current.debugEvents = append(current.debugEvents, fmt.Sprintf("selected discovery route for prefix %q", entered))
|
||||
default:
|
||||
if entered != "" {
|
||||
current.debugEvents = append(current.debugEvents, fmt.Sprintf("no canonical route matched command path %q", entered))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (current *execution) recordSelectionEvents(result commandmeta.Result) {
|
||||
mode := "ordinary"
|
||||
flagName := ""
|
||||
switch result.Reporting.Mode {
|
||||
case commandmeta.ReportingModeQuiet:
|
||||
mode, flagName = "quiet", "quiet"
|
||||
case commandmeta.ReportingModeVerbose:
|
||||
mode, flagName = "verbose", "verbose"
|
||||
case commandmeta.ReportingModeDebug:
|
||||
mode, flagName = "debug", "debug"
|
||||
}
|
||||
event := "selected ordinary reporting mode"
|
||||
if flagName != "" {
|
||||
event = fmt.Sprintf("selected %s reporting mode", mode)
|
||||
for index := len(result.FlagOccurrences) - 1; index >= 0; index-- {
|
||||
occurrence := result.FlagOccurrences[index]
|
||||
if occurrence.Name != flagName {
|
||||
continue
|
||||
}
|
||||
enabled, err := strconv.ParseBool(occurrence.RawValue)
|
||||
if err != nil || !enabled {
|
||||
continue
|
||||
}
|
||||
canonical := "--" + flagName
|
||||
if occurrence.Spelling != canonical {
|
||||
event += fmt.Sprintf(" using %s (alias for %s)", occurrence.Spelling, canonical)
|
||||
} else {
|
||||
event += fmt.Sprintf(" using %s", canonical)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
current.debugEvents = append(current.debugEvents, event)
|
||||
if result.Reporting.ProgressSet {
|
||||
state := "disabled"
|
||||
if result.Reporting.Progress {
|
||||
state = "enabled"
|
||||
}
|
||||
event := "explicitly " + state + " progress output"
|
||||
for index := len(result.FlagOccurrences) - 1; index >= 0; index-- {
|
||||
occurrence := result.FlagOccurrences[index]
|
||||
if occurrence.Name == "progress" {
|
||||
if occurrence.Spelling != "--progress" {
|
||||
event += fmt.Sprintf(" using %s (alias for --progress)", occurrence.Spelling)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if writeErr != nil {
|
||||
return deliveryFailure(outcome, writeErr)
|
||||
current.debugEvents = append(current.debugEvents, event)
|
||||
}
|
||||
}
|
||||
|
||||
func (current *execution) submitDebugEvents() {
|
||||
events := current.debugEvents
|
||||
current.debugEvents = nil
|
||||
for _, event := range events {
|
||||
_ = current.reporter.Debug(event)
|
||||
}
|
||||
}
|
||||
|
||||
func (current *execution) finalize(outcome Outcome, path []string, handlerRan bool) Outcome {
|
||||
if outcome.Kind == OutcomeUsage {
|
||||
details := append([]Detail(nil), outcome.Explanation.Details...)
|
||||
details = append(details, func(w io.Writer) error {
|
||||
line := current.usageText(path)
|
||||
if !strings.HasSuffix(line, "\n") {
|
||||
line += "\n"
|
||||
}
|
||||
_, err := io.WriteString(w, line)
|
||||
return err
|
||||
})
|
||||
outcome.Explanation.Details = details
|
||||
}
|
||||
var completion func(*Reporter)
|
||||
if handlerRan {
|
||||
completion = func(reporter *Reporter) {
|
||||
kind := outcome.Kind
|
||||
if kind == "" && outcome.Status == 0 && outcome.Domain == "" {
|
||||
kind = OutcomeSuccess
|
||||
}
|
||||
path := current.route.canonicalPath.String()
|
||||
_ = reporter.Debug(fmt.Sprintf("handler completed for %q with %s outcome and status %d", path, kind, outcome.Status))
|
||||
}
|
||||
}
|
||||
if delivered {
|
||||
outcome.DiagnosticDelivery = DeliveryComplete
|
||||
return current.reporter.finalize(outcome, completion)
|
||||
}
|
||||
|
||||
func (current *execution) usageText(path []string) string {
|
||||
switch {
|
||||
case current.route.kind == routeDiscovery && current.app != nil:
|
||||
return fmt.Sprintf("Usage: %s %s [command]", current.app.identity.Name, current.route.enteredPath.String())
|
||||
case current.route.kind == routeCanonical && !current.route.enteredPath.Equal(current.route.canonicalPath):
|
||||
return current.routeUsageLine()
|
||||
default:
|
||||
return current.usageLine(path)
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
func (a *App) usageLine(path []string) string {
|
||||
@@ -212,42 +374,3 @@ func (a *App) usageFallback() string {
|
||||
}
|
||||
return fmt.Sprintf("Usage: %s [command]", name)
|
||||
}
|
||||
|
||||
func hasErrorDiagnostic(diagnostics []Diagnostic) bool {
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.Write != nil && diagnostic.Severity == 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
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
)
|
||||
|
||||
func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||
@@ -19,7 +19,7 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||
}
|
||||
var inputs []input
|
||||
var contexts []context.Context
|
||||
var streams []commandkit.Streams
|
||||
var reporters []*commandkit.Reporter
|
||||
firstContext := context.WithValue(context.Background(), struct{}{}, "first")
|
||||
secondContext := context.WithValue(context.Background(), struct{}{}, "second")
|
||||
var firstOut, secondOut, firstDiagnostic, secondDiagnostic bytes.Buffer
|
||||
@@ -32,7 +32,12 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||
Run: func(invocation commandkit.Invocation, value input) commandkit.Outcome {
|
||||
inputs = append(inputs, value)
|
||||
contexts = append(contexts, invocation.Context)
|
||||
streams = append(streams, invocation.Streams)
|
||||
reporter := invocation.Reporter()
|
||||
reporters = append(reporters, reporter)
|
||||
_ = reporter.RenderOutput(func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, value.name.Value+"\n")
|
||||
return err
|
||||
})
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
@@ -52,11 +57,11 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||
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 reporters[0] == nil || reporters[1] == nil || reporters[2] == nil || reporters[0] == reporters[1] || reporters[0] == reporters[2] || reporters[1] == reporters[2] {
|
||||
t.Fatalf("invocations did not receive fresh reporters: %#v", reporters)
|
||||
}
|
||||
if contexts[0] != firstContext || contexts[1] != secondContext || streams[0].Out != &firstOut || streams[0].Err != &firstDiagnostic || streams[1].Out != &secondOut || streams[1].Err != &secondDiagnostic {
|
||||
t.Fatalf("handler invocation contexts or streams were not forwarded independently")
|
||||
if firstOut.String() != "first\n" || secondOut.String() != "\n" || contexts[0] != firstContext || contexts[1] != secondContext {
|
||||
t.Fatalf("handler invocation contexts or reporter destinations were not independent: out=%q/%q contexts=%v/%v", firstOut.String(), secondOut.String(), contexts[0], contexts[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +70,7 @@ func TestOmittedInputFactoryUsesExactPointerZeroValue(t *testing.T) {
|
||||
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.Failure("omitted input factory allocated a pointer")
|
||||
}
|
||||
return commandkit.Success()
|
||||
},
|
||||
@@ -136,9 +141,9 @@ func TestExecutePresentsHandlerUsageAfterRegisteringCommand(t *testing.T) {
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
calls++
|
||||
if err := app.Register(late); err != nil {
|
||||
return commandkit.Failure(err)
|
||||
return commandkit.Failure(err.Error()).WithCause(err)
|
||||
}
|
||||
return commandkit.Usage(usageCause)
|
||||
return commandkit.Usage("The handler requests usage.").WithCause(usageCause)
|
||||
},
|
||||
})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
@@ -195,12 +200,11 @@ func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
|
||||
|
||||
func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||
domainCause := errors.New("domain rejected the request")
|
||||
warning := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "review needed")
|
||||
command := 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}
|
||||
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
|
||||
_ = invocation.Reporter().Warn("review needed")
|
||||
outcome := commandkit.Domain("sample", 2).WithExplanation("The request was rejected.").WithCause(domainCause)
|
||||
return outcome
|
||||
},
|
||||
})
|
||||
@@ -209,19 +213,18 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
||||
outcome := app.Execute(context.Background(), []string{"run", "--verbose"}, 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 {
|
||||
if diagnostic.String() != "WARN: review needed\nERROR: The request was rejected.\n" || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete {
|
||||
t.Fatalf("warning delivery = %q, %#v", diagnostic.String(), outcome)
|
||||
}
|
||||
warningOnly := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "warn",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
result := commandkit.Success()
|
||||
result.Diagnostics = []commandkit.Diagnostic{warning}
|
||||
return result
|
||||
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
|
||||
_ = invocation.Reporter().Warn("review needed")
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
app, err = commandkit.New(commandkit.Identity{Name: "example"}, warningOnly)
|
||||
@@ -229,23 +232,18 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||
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" {
|
||||
outcome = app.Execute(context.Background(), []string{"warn", "--verbose"}, commandkit.Streams{Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || diagnostic.String() != "WARN: review needed\n" {
|
||||
t.Fatalf("warning-only outcome = %#v, diagnostics %q", outcome, diagnostic.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T) {
|
||||
cause := errors.New("already shown")
|
||||
func TestExecuteDoesNotPresentOperationCauseWithoutExplanation(t *testing.T) {
|
||||
cause := errors.New("private operation detail")
|
||||
command := 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: commandkit.SeverityError, Write: func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "presented\n")
|
||||
return err
|
||||
}}}
|
||||
return outcome
|
||||
return commandkit.Domain("sample", 2).WithCause(cause)
|
||||
},
|
||||
})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
@@ -254,21 +252,11 @@ func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T)
|
||||
}
|
||||
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())
|
||||
if outcome.Kind != commandkit.OutcomeDomain || !errors.Is(outcome.Cause, cause) {
|
||||
t.Fatalf("domain outcome changed: %#v", outcome)
|
||||
}
|
||||
|
||||
diagnostic.Reset()
|
||||
completed := commandkit.Domain("sample", 2, cause)
|
||||
completed.DiagnosticDelivery = commandkit.DeliveryComplete
|
||||
command = commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "done", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return completed }})
|
||||
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())
|
||||
if strings.Contains(diagnostic.String(), cause.Error()) || !strings.Contains(diagnostic.String(), "ERROR: Command failed.") {
|
||||
t.Fatalf("cause was presented instead of the fallback explanation: %#v, %q", outcome, diagnostic.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,9 +266,7 @@ func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
outcome := commandkit.Failure(operationFailure)
|
||||
outcome.ReportIncomplete = true
|
||||
return outcome
|
||||
return commandkit.Failure("The operation was canceled.").WithCause(operationFailure)
|
||||
},
|
||||
})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
@@ -288,12 +274,12 @@ func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
|
||||
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) {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete || !errors.Is(outcome.Cause, operationFailure) || !errors.Is(outcome.Presentation.Diagnostics.WriteError, 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 {
|
||||
if !errors.Is(short.Presentation.Diagnostics.WriteError, io.ErrShortWrite) || short.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete {
|
||||
t.Fatalf("short write was not detected: %#v", short)
|
||||
}
|
||||
}
|
||||
@@ -339,7 +325,7 @@ func TestExecuteRunsApplicationOwnedNestedHelp(t *testing.T) {
|
||||
)},
|
||||
Run: func(_ commandkit.Invocation, value input) commandkit.Outcome {
|
||||
received = append(received, value)
|
||||
return commandkit.Domain("session-help", 7, domainCause)
|
||||
return commandkit.Domain("session-help", 7).WithExplanation("Session help is unavailable.").WithCause(domainCause)
|
||||
},
|
||||
})
|
||||
session := commandkit.Group("session", "Session commands.", help)
|
||||
@@ -463,7 +449,7 @@ func TestHelpRendersRegisteredMetadataAndResolvesExactPaths(t *testing.T) {
|
||||
t.Fatalf("version invoked an application command: %#v calls=%d/%d", versionOutcome, factoryCalls, runCalls)
|
||||
}
|
||||
failed := app.Execute(context.Background(), []string{"config", "show", "--help"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic})
|
||||
if failed.Kind != commandkit.OutcomeOperational || !failed.ReportIncomplete || !errors.Is(failed.DeliveryError, io.ErrShortWrite) {
|
||||
if failed.Kind != commandkit.OutcomeOperational || failed.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(failed.Presentation.Output.WriteError, io.ErrShortWrite) {
|
||||
t.Fatalf("failed help delivery = %#v", failed)
|
||||
}
|
||||
out.Reset()
|
||||
@@ -503,7 +489,7 @@ func TestVersionCommandFormatsApplicationAndCommandKitIdentity(t *testing.T) {
|
||||
t.Fatalf("invalid version scope = %#v, output %q", invalid, out.String())
|
||||
}
|
||||
failed := app.Execute(context.Background(), []string{"version"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic})
|
||||
if failed.Kind != commandkit.OutcomeOperational || !failed.ReportIncomplete || !errors.Is(failed.DeliveryError, io.ErrShortWrite) {
|
||||
if failed.Kind != commandkit.OutcomeOperational || failed.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(failed.Presentation.Output.WriteError, io.ErrShortWrite) {
|
||||
t.Fatalf("failed version delivery = %#v", failed)
|
||||
}
|
||||
}
|
||||
@@ -532,11 +518,11 @@ func TestHelpEntryFormsPresentReportAndDiagnosticFailures(t *testing.T) {
|
||||
for _, args := range [][]string{nil, {"notes"}, {"--help"}, {"-h"}, {"notes", "--help"}, {"help"}, {"help", "notes"}} {
|
||||
var diagnostics bytes.Buffer
|
||||
result := app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: &diagnostics})
|
||||
if result.Kind != commandkit.OutcomeOperational || !result.ReportIncomplete || !errors.Is(result.Cause, reportErr) || !errors.Is(result.DeliveryError, reportErr) || result.DiagnosticDelivery != commandkit.DeliveryComplete || strings.Count(diagnostics.String(), reportErr.Error()) != 1 {
|
||||
if result.Kind != commandkit.OutcomeOperational || result.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Output.WriteError, reportErr) || result.Presentation.Diagnostics.State != commandkit.DeliveryComplete || (!strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output")) {
|
||||
t.Fatalf("%v: result=%#v diagnostics=%q", args, result, diagnostics.String())
|
||||
}
|
||||
result = app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: failingWriter{diagnosticErr}})
|
||||
if result.DiagnosticDelivery != commandkit.DeliveryIncomplete || !errors.Is(result.Cause, reportErr) || !errors.Is(result.DeliveryError, reportErr) || !errors.Is(result.DeliveryError, diagnosticErr) {
|
||||
if result.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Output.WriteError, reportErr) || result.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Diagnostics.WriteError, diagnosticErr) {
|
||||
t.Fatalf("%v: result=%#v", args, result)
|
||||
}
|
||||
}
|
||||
@@ -560,46 +546,34 @@ func TestExecuteNormalizesOutcomeDefaultsAndPreservesDomainValues(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{})
|
||||
wantDelivery := commandkit.DeliveryNone
|
||||
if tc.cause != nil {
|
||||
wantDelivery = commandkit.DeliveryComplete
|
||||
}
|
||||
if result.Kind != tc.kind || result.Status != tc.status || result.Domain != tc.domain || result.Cause != tc.cause || result.DiagnosticDelivery != wantDelivery {
|
||||
var diagnostic bytes.Buffer
|
||||
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
||||
if result.Kind != tc.kind || result.Status != tc.status || result.Domain != tc.domain || result.Cause != tc.cause {
|
||||
t.Fatalf("result=%#v expected=%#v", result, tc)
|
||||
}
|
||||
if tc.status != 0 && !strings.Contains(diagnostic.String(), "ERROR: Command failed.") {
|
||||
t.Fatalf("failed outcome omitted its fallback explanation: %q", diagnostic.String())
|
||||
}
|
||||
if tc.cause != nil && strings.Contains(diagnostic.String(), tc.cause.Error()) {
|
||||
t.Fatalf("operation cause was presented automatically: %q", diagnostic.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilDiagnosticWritersHaveNoOutputAndDoNotHideCauses(t *testing.T) {
|
||||
func TestNilDiagnosticWriterDoesNotHideOperationCause(t *testing.T) {
|
||||
cause := errors.New("operation failed")
|
||||
for _, base := range []commandkit.Outcome{commandkit.Success(), commandkit.Domain("needs action", 2, nil), commandkit.Failure(cause)} {
|
||||
base.Diagnostics = []commandkit.Diagnostic{{}, {Severity: commandkit.SeverityWarning}, {Severity: commandkit.SeverityError}}
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return base }})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var diagnostics bytes.Buffer
|
||||
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostics})
|
||||
want := commandkit.DeliveryNone
|
||||
if base.Cause != nil {
|
||||
want = commandkit.DeliveryComplete
|
||||
}
|
||||
if result.Kind != base.Kind || result.Status != base.Status || result.Domain != base.Domain || result.Cause != base.Cause || result.DeliveryError != nil || result.DiagnosticDelivery != want {
|
||||
t.Fatalf("result=%#v base=%#v", result, base)
|
||||
}
|
||||
if base.Cause == nil && diagnostics.Len() != 0 {
|
||||
t.Fatalf("unexpected output=%q", diagnostics.String())
|
||||
}
|
||||
if base.Cause != nil && strings.Count(diagnostics.String(), cause.Error()) != 1 {
|
||||
t.Fatalf("cause output=%q", diagnostics.String())
|
||||
}
|
||||
if base.Cause != nil {
|
||||
result = app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: failingWriter{io.ErrClosedPipe}})
|
||||
if !errors.Is(result.Cause, cause) || !errors.Is(result.DeliveryError, io.ErrClosedPipe) || result.DiagnosticDelivery != commandkit.DeliveryIncomplete {
|
||||
t.Fatalf("failed delivery=%#v", result)
|
||||
}
|
||||
}
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
return commandkit.Failure("The operation failed.").WithCause(cause)
|
||||
},
|
||||
})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{})
|
||||
if result.Kind != commandkit.OutcomeOperational || !errors.Is(result.Cause, cause) || result.Presentation.Diagnostics.State != commandkit.DeliveryComplete {
|
||||
t.Fatalf("nil diagnostic destination changed the outcome: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
189
execute_reporting_test.go
Normal file
189
execute_reporting_test.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExecuteGivesEachInvocationItsReporterAndAppliesSelection(t *testing.T) {
|
||||
var reporters []*Reporter
|
||||
command := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(inv Invocation, _ struct{}) Outcome {
|
||||
reporter := inv.Reporter()
|
||||
reporters = append(reporters, reporter)
|
||||
if _, err := fmt.Fprint(reporter.Output(), "result\n"); err != nil {
|
||||
t.Errorf("write result: %v", err)
|
||||
}
|
||||
if err := reporter.Warn("attention"); err != nil {
|
||||
t.Errorf("submit warning: %v", err)
|
||||
}
|
||||
return Outcome{
|
||||
Kind: OutcomeDomain, Domain: "accepted", Status: 0,
|
||||
Presentation: PresentationResult{
|
||||
Output: DeliveryResult{State: DeliveryIncomplete, WriteError: io.ErrClosedPipe},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
app, err := New(Identity{Name: "Example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var quietOut, quietErr bytes.Buffer
|
||||
quiet := app.Execute(context.Background(), []string{"run", "--quiet"}, Streams{Out: &quietOut, Err: &quietErr})
|
||||
if quiet.Kind != OutcomeDomain || quiet.Status != 0 || quiet.Domain != "accepted" {
|
||||
t.Fatalf("quiet outcome = %#v", quiet)
|
||||
}
|
||||
if quietOut.Len() != 0 || quietErr.Len() != 0 {
|
||||
t.Fatalf("quiet output = %q, diagnostics = %q", quietOut.String(), quietErr.String())
|
||||
}
|
||||
if quiet.Presentation.Output.State != DeliverySuppressed || quiet.Presentation.Diagnostics.State != DeliverySuppressed {
|
||||
t.Fatalf("quiet presentation = %#v", quiet.Presentation)
|
||||
}
|
||||
|
||||
var verboseOut, verboseErr bytes.Buffer
|
||||
verbose := app.Execute(context.Background(), []string{"run", "--verbose"}, Streams{Out: &verboseOut, Err: &verboseErr})
|
||||
if verbose.Kind != OutcomeDomain || verbose.Status != 0 {
|
||||
t.Fatalf("verbose outcome = %#v", verbose)
|
||||
}
|
||||
if verboseOut.String() != "result\n" || verboseErr.String() != "WARN: attention\n" {
|
||||
t.Fatalf("verbose output = %q, diagnostics = %q", verboseOut.String(), verboseErr.String())
|
||||
}
|
||||
if verbose.Presentation.Output.State != DeliveryComplete || verbose.Presentation.Diagnostics.State != DeliveryComplete {
|
||||
t.Fatalf("verbose presentation = %#v", verbose.Presentation)
|
||||
}
|
||||
if len(reporters) != 2 || reporters[0] == nil || reporters[1] == nil || reporters[0] == reporters[1] {
|
||||
t.Fatalf("invocation reporters = %#v", reporters)
|
||||
}
|
||||
if _, err := reporters[0].Output().Write([]byte("late")); !errors.Is(err, ErrReporterClosed) {
|
||||
t.Fatalf("write through finalized reporter error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteQuietUsageKeepsAliasGuidanceVisible(t *testing.T) {
|
||||
command := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||
})
|
||||
app, err := New(Identity{Name: "Example"}, command, Alias("r", "run"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"r", "--quiet", "--unknown"}, Streams{Out: &output, Err: &diagnostics})
|
||||
if outcome.Kind != OutcomeUsage || outcome.Status != 2 {
|
||||
t.Fatalf("outcome = %#v", outcome)
|
||||
}
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("usage output = %q", output.String())
|
||||
}
|
||||
if got := diagnostics.String(); !strings.Contains(got, "ERROR: unknown flag") || !strings.Contains(got, "Usage: Example r") || !strings.Contains(got, "Alias for: Example run") {
|
||||
t.Fatalf("usage diagnostics = %q", got)
|
||||
}
|
||||
if outcome.Presentation.Diagnostics.State != DeliveryComplete {
|
||||
t.Fatalf("diagnostic presentation = %#v", outcome.Presentation.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnresolvedRoutesUseReportingAliasesForTheirParseContext(t *testing.T) {
|
||||
calls := 0
|
||||
command := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
NewInput: func() struct{} { calls++; return struct{}{} },
|
||||
Run: func(Invocation, struct{}) Outcome { calls++; return Success() },
|
||||
})
|
||||
app, err := New(Identity{Name: "Example"}, Group("group", "", Group("nested", "", command)),
|
||||
FlagAlias("-d", "--debug"), FlagAlias("-q", "--quiet"),
|
||||
FlagAlias("-x", "--quiet"), FlagAliasFor("group nested", "-x", "--debug"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range [][]string{{"missing"}, {"group", "missing"}, {"group", "nested", "missing"}} {
|
||||
t.Run(strings.Join(path, "/"), func(t *testing.T) {
|
||||
var canonicalDiagnostics, aliasDiagnostics bytes.Buffer
|
||||
canonical := app.Execute(context.Background(), append(append([]string(nil), path...), "--debug"), Streams{Err: &canonicalDiagnostics})
|
||||
alias := app.Execute(context.Background(), append(append([]string(nil), path...), "-d"), Streams{Err: &aliasDiagnostics})
|
||||
if canonical.Kind != OutcomeUsage || alias.Kind != OutcomeUsage || canonical.Status != alias.Status || canonical.Explanation.Message != alias.Explanation.Message {
|
||||
t.Fatalf("canonical=%#v alias=%#v", canonical, alias)
|
||||
}
|
||||
for _, text := range []string{canonicalDiagnostics.String(), aliasDiagnostics.String()} {
|
||||
if !strings.Contains(text, "DEBUG:") || !strings.Contains(text, "ERROR:") || strings.Contains(text, "unknown shorthand") || strings.Contains(text, "Alias for:") {
|
||||
t.Fatalf("diagnostics=%q", text)
|
||||
}
|
||||
}
|
||||
route := resolveEnteredRoute(path, app.routeSnapshot())
|
||||
if route.kind != routeUnknown || len(route.canonicalPath) != 0 {
|
||||
t.Fatalf("invalid route acquired canonical identity: %#v", route)
|
||||
}
|
||||
var quiet bytes.Buffer
|
||||
got := app.Execute(context.Background(), append(append([]string(nil), path...), "-q", "--unknown"), Streams{Err: &quiet})
|
||||
if got.Kind != OutcomeUsage || !strings.Contains(quiet.String(), "ERROR: unknown flag") || strings.Contains(quiet.String(), "DEBUG:") {
|
||||
t.Fatalf("quiet alias outcome=%#v diagnostics=%q", got, quiet.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
var scoped bytes.Buffer
|
||||
got := app.Execute(context.Background(), []string{"group", "nested", "missing", "-x"}, Streams{Err: &scoped})
|
||||
if got.Kind != OutcomeUsage || !strings.Contains(scoped.String(), "DEBUG:") {
|
||||
t.Fatalf("scoped precedence outcome=%#v diagnostics=%q", got, scoped.String())
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("invalid routes ran factories or handlers %d times", calls)
|
||||
}
|
||||
}
|
||||
|
||||
type executeReportingFailureWriter struct{ err error }
|
||||
|
||||
func (w executeReportingFailureWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||
|
||||
func TestExecuteFinalizesEarlyAndIgnoredWriterFailures(t *testing.T) {
|
||||
command := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(inv Invocation, _ struct{}) Outcome {
|
||||
_, _ = inv.Reporter().Output().Write([]byte("report"))
|
||||
return Success()
|
||||
},
|
||||
})
|
||||
app, err := New(Identity{Name: "Example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writeErr := errors.New("result destination failed")
|
||||
var diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"run"}, Streams{
|
||||
Out: executeReportingFailureWriter{err: writeErr}, Err: &diagnostics,
|
||||
})
|
||||
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) {
|
||||
t.Fatalf("ignored output failure outcome = %#v", outcome)
|
||||
}
|
||||
if !strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output") {
|
||||
t.Fatalf("fallback diagnostic = %q", diagnostics.String())
|
||||
}
|
||||
|
||||
diagnostics.Reset()
|
||||
outcome = app.Execute(nil, []string{"run"}, Streams{Err: &diagnostics})
|
||||
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || outcome.Cause == nil {
|
||||
t.Fatalf("nil context outcome = %#v", outcome)
|
||||
}
|
||||
if !strings.Contains(diagnostics.String(), "ERROR: invocation context must not be nil\n") {
|
||||
t.Fatalf("nil context diagnostic = %q", diagnostics.String())
|
||||
}
|
||||
|
||||
diagnostics.Reset()
|
||||
var nilApp *App
|
||||
outcome = nilApp.Execute(context.Background(), []string{"run"}, Streams{Err: &diagnostics})
|
||||
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || outcome.Cause == nil {
|
||||
t.Fatalf("nil app outcome = %#v", outcome)
|
||||
}
|
||||
if !strings.Contains(diagnostics.String(), "ERROR: cannot execute commands on a nil app\n") {
|
||||
t.Fatalf("nil app diagnostic = %q", diagnostics.String())
|
||||
}
|
||||
}
|
||||
296
flag_aliases.go
Normal file
296
flag_aliases.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||
)
|
||||
|
||||
type resolvedFlagAlias struct {
|
||||
source capturedFlagSpelling
|
||||
target capturedFlagSpelling
|
||||
}
|
||||
|
||||
type resolvedFlagAliases struct {
|
||||
bySpelling map[string]resolvedFlagAlias
|
||||
}
|
||||
|
||||
type flagAliasMapping struct {
|
||||
target capturedFlagSpelling
|
||||
scoped bool
|
||||
}
|
||||
|
||||
type flagAliasContext struct {
|
||||
path commandpath.Path
|
||||
canonical map[string]capturedFlagSpelling
|
||||
discovery bool
|
||||
}
|
||||
|
||||
func (aliases resolvedFlagAliases) neutralRecords() []commandmeta.FlagAlias {
|
||||
keys := make([]string, 0, len(aliases.bySpelling))
|
||||
for key := range aliases.bySpelling {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
records := make([]commandmeta.FlagAlias, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
alias := aliases.bySpelling[key]
|
||||
records = append(records, alias.neutralRecord())
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
// lookupRecords skips sorting: resolved source spellings are unique and the
|
||||
// parser's lookup does not depend on record order.
|
||||
func (aliases resolvedFlagAliases) lookupRecords() []commandmeta.FlagAlias {
|
||||
records := make([]commandmeta.FlagAlias, 0, len(aliases.bySpelling))
|
||||
for _, alias := range aliases.bySpelling {
|
||||
records = append(records, alias.neutralRecord())
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func (alias resolvedFlagAlias) neutralRecord() commandmeta.FlagAlias {
|
||||
return commandmeta.FlagAlias{Source: alias.source.token(), Target: alias.target.token()}
|
||||
}
|
||||
|
||||
func (spelling capturedFlagSpelling) token() string {
|
||||
if spelling.short {
|
||||
return "-" + spelling.name
|
||||
}
|
||||
return "--" + spelling.name
|
||||
}
|
||||
|
||||
func compileFlagAliases(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
|
||||
Resolve(commandpath.Path) commandpath.Path
|
||||
}) (map[string]resolvedFlagAliases, map[string]resolvedFlagAliases, error) {
|
||||
canonicalNodes := indexCanonicalNodes(roots)
|
||||
global := make(map[string]flagAliasMapping)
|
||||
scoped := make(map[string]map[string]flagAliasMapping)
|
||||
for _, rule := range rules {
|
||||
record := rule.record
|
||||
switch record.kind {
|
||||
case globalFlagAliasRule:
|
||||
source := flagSpellingKey(record.sourceFlag)
|
||||
global[source] = flagAliasMapping{target: record.targetFlag}
|
||||
case scopedFlagAliasRule:
|
||||
scope := record.scopePath.Key()
|
||||
if canonicalNodes[scope] == nil {
|
||||
return nil, nil, fmt.Errorf("flag alias scope %q is not a canonical command path", record.scopePath.String())
|
||||
}
|
||||
if scoped[scope] == nil {
|
||||
scoped[scope] = make(map[string]flagAliasMapping)
|
||||
}
|
||||
source := flagSpellingKey(record.sourceFlag)
|
||||
scoped[scope][source] = flagAliasMapping{target: record.targetFlag, scoped: true}
|
||||
}
|
||||
}
|
||||
|
||||
canonicalContexts := make(map[string]flagAliasContext, len(canonicalNodes)+1)
|
||||
rootPath := commandpath.Path(nil)
|
||||
canonicalContexts[rootPath.Key()] = flagAliasContext{path: rootPath, canonical: helpFlagSpellings()}
|
||||
for key, node := range canonicalNodes {
|
||||
canonicalContexts[key] = flagAliasContext{path: node.path.Clone(), canonical: canonicalFlagSpellings(node.command)}
|
||||
}
|
||||
discoveryContexts := discoverFlagContexts(rules, roots, aliases, canonicalNodes)
|
||||
|
||||
canonicalKeys := sortedContextKeys(canonicalContexts)
|
||||
resolvedCanonical := make(map[string]resolvedFlagAliases, len(canonicalContexts))
|
||||
for _, key := range canonicalKeys {
|
||||
context := canonicalContexts[key]
|
||||
mappings := cloneFlagMappings(global)
|
||||
for source, mapping := range scoped[key] {
|
||||
mappings[source] = mapping
|
||||
}
|
||||
resolved, err := resolveFlagAliases(context, mappings)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
resolvedCanonical[key] = resolved
|
||||
}
|
||||
|
||||
discoveryKeys := sortedContextKeys(discoveryContexts)
|
||||
resolvedDiscovery := make(map[string]resolvedFlagAliases, len(discoveryContexts))
|
||||
for _, key := range discoveryKeys {
|
||||
context := discoveryContexts[key]
|
||||
resolved, err := resolveFlagAliases(context, cloneFlagMappings(global))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
resolvedDiscovery[key] = resolved
|
||||
}
|
||||
return resolvedCanonical, resolvedDiscovery, nil
|
||||
}
|
||||
|
||||
func indexCanonicalNodes(roots map[string]*canonicalNode) map[string]*canonicalNode {
|
||||
indexed := make(map[string]*canonicalNode)
|
||||
var add func(*canonicalNode)
|
||||
add = func(node *canonicalNode) {
|
||||
indexed[node.path.Key()] = node
|
||||
for _, child := range node.children {
|
||||
add(child)
|
||||
}
|
||||
}
|
||||
for _, root := range roots {
|
||||
add(root)
|
||||
}
|
||||
return indexed
|
||||
}
|
||||
|
||||
func helpFlagSpellings() map[string]capturedFlagSpelling {
|
||||
flags := make(map[string]capturedFlagSpelling, 2+len(commandmeta.ReportingFlags()))
|
||||
for _, spelling := range []capturedFlagSpelling{{name: "help"}, {name: "h", short: true}} {
|
||||
flags[flagSpellingKey(spelling)] = spelling
|
||||
}
|
||||
for _, metadata := range commandmeta.ReportingFlags() {
|
||||
spelling := capturedFlagSpelling{name: metadata.Name}
|
||||
flags[flagSpellingKey(spelling)] = spelling
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func canonicalFlagSpellings(command Command) map[string]capturedFlagSpelling {
|
||||
flags := helpFlagSpellings()
|
||||
if command.record.payload == nil {
|
||||
return flags
|
||||
}
|
||||
for _, metadata := range command.record.payload.bridgeFlags() {
|
||||
long := capturedFlagSpelling{name: metadata.Name}
|
||||
flags[flagSpellingKey(long)] = long
|
||||
if metadata.Short != "" {
|
||||
short := capturedFlagSpelling{name: metadata.Short, short: true}
|
||||
flags[flagSpellingKey(short)] = short
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
|
||||
Resolve(commandpath.Path) commandpath.Path
|
||||
}, canonicalNodes map[string]*canonicalNode) map[string]flagAliasContext {
|
||||
commandSources := make(map[string]struct{})
|
||||
prefixes := make(map[string]commandpath.Path)
|
||||
for _, rule := range rules {
|
||||
if rule.record.kind == commandAliasRule {
|
||||
commandSources[rule.record.sourcePath.Key()] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if rule.record.kind != commandAliasRule {
|
||||
continue
|
||||
}
|
||||
for length := 1; length < len(rule.record.sourcePath); length++ {
|
||||
prefix := rule.record.sourcePath[:length].Clone()
|
||||
key := prefix.Key()
|
||||
if canonicalNodes[key] != nil {
|
||||
continue
|
||||
}
|
||||
if _, exactAlias := commandSources[key]; exactAlias {
|
||||
continue
|
||||
}
|
||||
resolved := aliases.Resolve(prefix)
|
||||
trace := traceCanonicalPath(roots, resolved)
|
||||
if trace.exact != nil || (trace.operandOwner != nil && acceptsOperandPrefix(trace.operandOwner.command, len(resolved)-len(trace.operandOwner.path))) {
|
||||
continue
|
||||
}
|
||||
prefixes[key] = prefix
|
||||
}
|
||||
}
|
||||
contexts := make(map[string]flagAliasContext, len(prefixes))
|
||||
for key, path := range prefixes {
|
||||
contexts[key] = flagAliasContext{path: path, canonical: helpFlagSpellings(), discovery: true}
|
||||
}
|
||||
return contexts
|
||||
}
|
||||
|
||||
func cloneFlagMappings(mappings map[string]flagAliasMapping) map[string]flagAliasMapping {
|
||||
cloned := make(map[string]flagAliasMapping, len(mappings))
|
||||
for source, mapping := range mappings {
|
||||
cloned[source] = mapping
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func sortedContextKeys(contexts map[string]flagAliasContext) []string {
|
||||
keys := make([]string, 0, len(contexts))
|
||||
for key := range contexts {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func resolveFlagAliases(context flagAliasContext, mappings map[string]flagAliasMapping) (resolvedFlagAliases, error) {
|
||||
result := resolvedFlagAliases{bySpelling: make(map[string]resolvedFlagAlias)}
|
||||
sources := make([]string, 0, len(mappings))
|
||||
for source := range mappings {
|
||||
sources = append(sources, source)
|
||||
}
|
||||
sort.Strings(sources)
|
||||
for _, sourceKey := range sources {
|
||||
mapping := mappings[sourceKey]
|
||||
source := spellingFromKey(sourceKey)
|
||||
target, active, err := resolveFlagTarget(source, mapping.target, mappings, context.canonical)
|
||||
if err != nil {
|
||||
return resolvedFlagAliases{}, fmt.Errorf("%s: %w", flagContextDescription(context), err)
|
||||
}
|
||||
if !active {
|
||||
if mapping.scoped {
|
||||
return resolvedFlagAliases{}, fmt.Errorf("scoped flag alias %s has no canonical destination in %s", source.token(), flagContextDescription(context))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, collision := context.canonical[sourceKey]; collision {
|
||||
return resolvedFlagAliases{}, fmt.Errorf("flag alias %s conflicts with a canonical spelling in %s", source.token(), flagContextDescription(context))
|
||||
}
|
||||
result.bySpelling[sourceKey] = resolvedFlagAlias{source: source, target: target}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func resolveFlagTarget(source, target capturedFlagSpelling, mappings map[string]flagAliasMapping, canonical map[string]capturedFlagSpelling) (capturedFlagSpelling, bool, error) {
|
||||
visited := map[string]int{flagSpellingKey(source): 0}
|
||||
chain := []capturedFlagSpelling{source}
|
||||
current := target
|
||||
for {
|
||||
key := flagSpellingKey(current)
|
||||
if endpoint, exists := canonical[key]; exists {
|
||||
return endpoint, true, nil
|
||||
}
|
||||
if start, exists := visited[key]; exists {
|
||||
cycle := append(append([]capturedFlagSpelling(nil), chain[start:]...), current)
|
||||
spellings := make([]string, 0, len(cycle))
|
||||
for _, spelling := range cycle {
|
||||
spellings = append(spellings, spelling.token())
|
||||
}
|
||||
return capturedFlagSpelling{}, false, fmt.Errorf("flag alias cycle: %s", strings.Join(spellings, " → "))
|
||||
}
|
||||
visited[key] = len(chain)
|
||||
chain = append(chain, current)
|
||||
next, exists := mappings[key]
|
||||
if !exists {
|
||||
return capturedFlagSpelling{}, false, nil
|
||||
}
|
||||
current = next.target
|
||||
}
|
||||
}
|
||||
|
||||
func spellingFromKey(key string) capturedFlagSpelling {
|
||||
if strings.HasPrefix(key, "short:") {
|
||||
return capturedFlagSpelling{name: strings.TrimPrefix(key, "short:"), short: true}
|
||||
}
|
||||
return capturedFlagSpelling{name: strings.TrimPrefix(key, "long:")}
|
||||
}
|
||||
|
||||
func flagContextDescription(context flagAliasContext) string {
|
||||
if context.discovery {
|
||||
return fmt.Sprintf("alias prefix %q", context.path.String())
|
||||
}
|
||||
if len(context.path) == 0 {
|
||||
return "application root"
|
||||
}
|
||||
return fmt.Sprintf("command %q", context.path.String())
|
||||
}
|
||||
297
flag_aliases_test.go
Normal file
297
flag_aliases_test.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||
)
|
||||
|
||||
func flagAliasCommand(name string, flags ...Flag[struct{}]) Command {
|
||||
return NewCommand(CommandSpec[struct{}]{
|
||||
Name: name, Summary: name, Flags: flags,
|
||||
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||
})
|
||||
}
|
||||
|
||||
func stringFlag(name, short string) Flag[struct{}] {
|
||||
return ValueFlag(FlagMeta{Name: name, Short: short}, (*string)(nil),
|
||||
func(value string) (string, error) { return value, nil },
|
||||
func(*struct{}, Supplied[string]) {},
|
||||
)
|
||||
}
|
||||
|
||||
func TestFlagAliasChainsUseScopedOverridesAndCanonicalEndpoints(t *testing.T) {
|
||||
inspect := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("format", "f"))
|
||||
show := flagAliasCommand("show", stringFlag("color", "c"), stringFlag("format", "f"))
|
||||
group := Group("Tools", "tools", show)
|
||||
app, err := New(Identity{Name: "Example"},
|
||||
inspect,
|
||||
group,
|
||||
Alias("look", "inspect"),
|
||||
FlagAlias("--colour", "--hue"),
|
||||
FlagAlias("--hue", "--color"),
|
||||
FlagAlias("-x", "--color"),
|
||||
FlagAlias("--tint", "-c"),
|
||||
FlagAliasFor("Tools", "--group-help", "--help"),
|
||||
FlagAliasFor("inspect", "--hue", "--format"),
|
||||
FlagAliasFor("inspect", "--local", "--format"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
inspectAliases := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling
|
||||
for source, target := range map[string]string{
|
||||
"--colour": "--format",
|
||||
"--hue": "--format",
|
||||
"-x": "--color",
|
||||
"--tint": "-c",
|
||||
"--local": "--format",
|
||||
} {
|
||||
alias, ok := inspectAliases[flagSpellingKey(capturedSpelling(source))]
|
||||
if !ok || alias.target.token() != target {
|
||||
t.Errorf("inspect alias %s = (%s, %v), want %s", source, alias.target.token(), ok, target)
|
||||
}
|
||||
}
|
||||
|
||||
groupAliases := app.flagAliases[commandpath.Path{"Tools"}.Key()].bySpelling
|
||||
if _, active := groupAliases[flagSpellingKey(capturedFlagSpelling{name: "colour"})]; active {
|
||||
t.Error("global alias was active on a group without its destination flag")
|
||||
}
|
||||
if _, inherited := groupAliases[flagSpellingKey(capturedFlagSpelling{name: "local"})]; inherited {
|
||||
t.Fatal("command-scoped alias was inherited by its parent group")
|
||||
}
|
||||
if _, inherited := app.flagAliases[commandpath.Path{"Tools", "show"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "local"})]; inherited {
|
||||
t.Fatal("command-scoped alias was inherited by a different command")
|
||||
}
|
||||
groupHelp := flagSpellingKey(capturedFlagSpelling{name: "group-help"})
|
||||
if got := groupAliases[groupHelp].target.token(); got != "--help" {
|
||||
t.Fatalf("group-scoped help alias target = %s, want --help", got)
|
||||
}
|
||||
if _, inherited := app.flagAliases[commandpath.Path{"Tools", "show"}.Key()].bySpelling[groupHelp]; inherited {
|
||||
t.Fatal("group-scoped alias was inherited by its child command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagAliasDiscoveryContextsUseGlobalReportingAliases(t *testing.T) {
|
||||
show := flagAliasCommand("show")
|
||||
app, err := New(Identity{Name: "Example"},
|
||||
Group("Tools", "tools", show),
|
||||
Alias("shortcut show", "Tools show"),
|
||||
FlagAlias("--hlep", "--help"),
|
||||
FlagAlias("--silent", "--quiet"),
|
||||
FlagAlias("--chatty", "--verbose"),
|
||||
FlagAlias("--trace", "--debug"),
|
||||
FlagAlias("--show-progress", "--progress"),
|
||||
FlagAliasFor("Tools show", "--local-help", "--help"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
globalHelp := flagSpellingKey(capturedFlagSpelling{name: "hlep"})
|
||||
if got := app.flagAliases[commandpath.Path(nil).Key()].bySpelling[globalHelp].target.token(); got != "--help" {
|
||||
t.Fatalf("root --hlep target = %s, want --help", got)
|
||||
}
|
||||
if got := app.flagAliases[commandpath.Path{"Tools"}.Key()].bySpelling[globalHelp].target.token(); got != "--help" {
|
||||
t.Fatalf("group --hlep target = %s, want --help", got)
|
||||
}
|
||||
discovery := app.discoveryFlags[commandpath.Path{"shortcut"}.Key()].bySpelling
|
||||
if got := discovery[globalHelp].target.token(); got != "--help" {
|
||||
t.Fatalf("discovery --hlep target = %s, want --help", got)
|
||||
}
|
||||
for label, aliases := range map[string]resolvedFlagAliases{
|
||||
"root": app.flagAliases[commandpath.Path(nil).Key()],
|
||||
"group": app.flagAliases[commandpath.Path{"Tools"}.Key()],
|
||||
"command": app.flagAliases[commandpath.Path{"Tools", "show"}.Key()],
|
||||
"discovery": app.discoveryFlags[commandpath.Path{"shortcut"}.Key()],
|
||||
} {
|
||||
for source, target := range map[string]string{
|
||||
"silent": "quiet",
|
||||
"chatty": "verbose",
|
||||
"trace": "debug",
|
||||
"show-progress": "progress",
|
||||
} {
|
||||
key := flagSpellingKey(capturedFlagSpelling{name: source})
|
||||
if got := aliases.bySpelling[key].target.token(); got != "--"+target {
|
||||
t.Errorf("%s --%s target = %s, want --%s", label, source, got, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, inherited := discovery[flagSpellingKey(capturedFlagSpelling{name: "local-help"})]; inherited {
|
||||
t.Fatal("discovery context inherited a command-scoped alias")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportingFlagsAreReservedAndAcceptApplicationShortAliases(t *testing.T) {
|
||||
for _, name := range []string{"quiet", "verbose", "debug", "progress"} {
|
||||
command := flagAliasCommand("reserved", stringFlag(name, ""))
|
||||
if len(command.record.issues) == 0 {
|
||||
t.Errorf("capturing --%s did not retain a declaration error", name)
|
||||
}
|
||||
app, err := New(Identity{Name: "Example"}, flagAliasCommand("safe"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := app.routeSnapshot()
|
||||
if err := app.Register(command, Alias("reserved-shortcut", "reserved")); err == nil || !strings.Contains(err.Error(), "reserved for reporting") {
|
||||
t.Errorf("registering --%s returned %v, want a retained reporting reservation error", name, err)
|
||||
}
|
||||
if app.routeSnapshot() != snapshot || app.commands["reserved"] != nil || app.commands["safe"] == nil || len(app.routingRules) != 0 {
|
||||
t.Fatalf("failed registration of --%s changed the installed commands", name)
|
||||
}
|
||||
}
|
||||
|
||||
declarations := []Declaration{flagAliasCommand("run")}
|
||||
for short, target := range map[string]string{"q": "quiet", "v": "verbose", "d": "debug"} {
|
||||
declarations = append(declarations, FlagAlias("-"+short, "--"+target))
|
||||
}
|
||||
if _, err := New(Identity{Name: "Example"}, declarations...); err != nil {
|
||||
t.Fatalf("application short aliases for reporting flags were rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportingFlagSpellingsConflictWithAliasSources(t *testing.T) {
|
||||
for _, name := range []string{"quiet", "verbose", "debug", "progress"} {
|
||||
if _, err := New(Identity{Name: "Example"}, FlagAlias("--"+name, "--help")); err == nil || !strings.Contains(err.Error(), "conflicts with a canonical spelling") {
|
||||
t.Errorf("alias source --%s returned %v, want a canonical spelling collision", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := New(Identity{Name: "Example"}, Group("tools", "", flagAliasCommand("show")), FlagAliasFor("tools", "--quiet", "--help")); err == nil || !strings.Contains(err.Error(), "conflicts with a canonical spelling") {
|
||||
t.Errorf("group-scoped canonical reporting spelling returned %v, want a collision", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutingSnapshotCarriesReportingMetadataOutsideConsumerBindings(t *testing.T) {
|
||||
app, err := New(Identity{Name: "Example"},
|
||||
flagAliasCommand("run", stringFlag("format", "f")),
|
||||
Group("tools", "", flagAliasCommand("show")),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var check func(*commandmeta.Command)
|
||||
check = func(node *commandmeta.Command) {
|
||||
if len(node.CoreFlags) != 4 {
|
||||
t.Errorf("%s core reporting flags = %#v", node.Name, node.CoreFlags)
|
||||
}
|
||||
for _, flag := range node.Flags {
|
||||
if commandmeta.IsReportingFlag(flag.Name) {
|
||||
t.Errorf("%s reporting flag %q was added to consumer bindings", node.Name, flag.Name)
|
||||
}
|
||||
}
|
||||
for _, child := range node.Children {
|
||||
check(child)
|
||||
}
|
||||
}
|
||||
check(app.routeSnapshot().metadata)
|
||||
}
|
||||
|
||||
func TestFlagAliasDuplicatesAndScopesAreDeterministic(t *testing.T) {
|
||||
command := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("format", "f"))
|
||||
duplicate := FlagAlias("--colour", "--color")
|
||||
app, err := New(Identity{Name: "Example"}, command, flagAliasCommand("plain", stringFlag("color", "c")), duplicate, FlagAlias("--colour", "--color"),
|
||||
FlagAliasFor("inspect", "--colour", "--format"))
|
||||
if err != nil {
|
||||
t.Fatalf("identical duplicates and scoped override were rejected: %v", err)
|
||||
}
|
||||
if got := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--format" {
|
||||
t.Fatalf("scoped alias target = %s, want --format", got)
|
||||
}
|
||||
if got := app.flagAliases[commandpath.Path{"plain"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--color" {
|
||||
t.Fatalf("effective global alias target = %s, want --color", got)
|
||||
}
|
||||
|
||||
for name, rules := range map[string][]RoutingRule{
|
||||
"competing global targets": {
|
||||
FlagAlias("--colour", "--color"),
|
||||
FlagAlias("--colour", "--format"),
|
||||
},
|
||||
"competing scoped targets": {
|
||||
FlagAliasFor("inspect", "--colour", "--color"),
|
||||
FlagAliasFor("inspect", "--colour", "--format"),
|
||||
},
|
||||
"unknown scope": {FlagAliasFor("missing", "--colour", "--color")},
|
||||
"missing scoped destination": {FlagAliasFor("inspect", "--colour", "--missing")},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
declarations := []Declaration{command}
|
||||
for _, rule := range rules {
|
||||
declarations = append(declarations, rule)
|
||||
}
|
||||
if _, err := New(Identity{Name: "Example"}, declarations...); err == nil {
|
||||
t.Fatal("New() accepted invalid flag alias rules")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagAliasCyclesIdentifyParticipatingSpellings(t *testing.T) {
|
||||
for name, declarations := range map[string][]Declaration{
|
||||
"global": {
|
||||
FlagAlias("--first", "--second"),
|
||||
FlagAlias("--second", "--first"),
|
||||
},
|
||||
"scoped": {
|
||||
flagAliasCommand("inspect"),
|
||||
FlagAliasFor("inspect", "-a", "--second"),
|
||||
FlagAliasFor("inspect", "--second", "-a"),
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := New(Identity{Name: "Example"}, declarations...)
|
||||
if err == nil {
|
||||
t.Fatal("New() accepted a flag alias cycle")
|
||||
}
|
||||
message := err.Error()
|
||||
for _, spelling := range []string{"--first", "--second"} {
|
||||
if name == "scoped" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(message, spelling) {
|
||||
t.Errorf("cycle error %q does not identify %s", message, spelling)
|
||||
}
|
||||
}
|
||||
if name == "scoped" && (!strings.Contains(message, "-a") || !strings.Contains(message, "--second")) {
|
||||
t.Errorf("scoped cycle error %q does not identify both spellings", message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInactiveGlobalAliasesActivateAtomicallyWhenDestinationAppears(t *testing.T) {
|
||||
command := flagAliasCommand("inspect", stringFlag("color", "c"))
|
||||
app, err := New(Identity{Name: "Example"}, command,
|
||||
FlagAlias("--color", "--missing"),
|
||||
FlagAlias("--colour", "--color"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aliases := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling
|
||||
if _, active := aliases[flagSpellingKey(capturedFlagSpelling{name: "color"})]; active {
|
||||
t.Fatal("global alias with an unavailable destination became active")
|
||||
}
|
||||
if got := aliases[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--color" {
|
||||
t.Fatalf("--colour endpoint = %s, want canonical --color", got)
|
||||
}
|
||||
|
||||
snapshot := app.routeSnapshot()
|
||||
commands := len(app.commands)
|
||||
rules := len(app.routingRules)
|
||||
originalAlias := aliases[flagSpellingKey(capturedFlagSpelling{name: "colour"})]
|
||||
if err := app.Register(flagAliasCommand("later", stringFlag("color", "c"), stringFlag("missing", ""))); err == nil {
|
||||
t.Fatal("Register() accepted a newly active global source collision")
|
||||
}
|
||||
currentAlias := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})]
|
||||
if app.routeSnapshot() != snapshot || len(app.commands) != commands || len(app.routingRules) != rules || currentAlias != originalAlias {
|
||||
t.Fatal("failed registration changed the existing command or routing state")
|
||||
}
|
||||
}
|
||||
|
||||
func capturedSpelling(token string) capturedFlagSpelling {
|
||||
issues := []error(nil)
|
||||
return captureFlagSpelling("test", token, &issues)
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func (values FlagValues[I]) ApplyTo(target *I, selected ...Flag[I]) error {
|
||||
|
||||
func classifyInputError(err error) Outcome {
|
||||
if errors.Is(err, ErrFlagWiring) {
|
||||
return Failure(err)
|
||||
return Failure(err.Error()).WithCause(err)
|
||||
}
|
||||
return Usage(err)
|
||||
return Usage(err.Error()).WithCause(err)
|
||||
}
|
||||
|
||||
6
go.mod
6
go.mod
@@ -1,4 +1,4 @@
|
||||
module gitea.maximumdirect.net/eric/CommandKit
|
||||
module go.maximumdirect.net/commandkit
|
||||
|
||||
go 1.26.0
|
||||
|
||||
@@ -9,10 +9,10 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||
go.maximumdirect.net/releasekit v0.9.0 // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
)
|
||||
|
||||
tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
||||
tool go.maximumdirect.net/releasekit/cmd/releasekit
|
||||
|
||||
4
go.sum
4
go.sum
@@ -1,5 +1,3 @@
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 h1:P2eDQ29BgyYqHnaz5MV+vzOk7r3s8py2y9k8JISFtJ0=
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
@@ -10,6 +8,8 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||
go.maximumdirect.net/releasekit v0.9.0 h1:sK+Aoj6gS6ATU/1reocjwBekp4YETYENwkcX5ti6mrE=
|
||||
go.maximumdirect.net/releasekit v0.9.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
|
||||
63
help_view.go
Normal file
63
help_view.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||
)
|
||||
|
||||
func (a *App) helpView(route routeResult, snapshot *routingSnapshot) commandmeta.HelpView {
|
||||
view := commandmeta.HelpView{
|
||||
EnteredPath: clonePath(route.enteredPath),
|
||||
CanonicalPath: clonePath(route.canonicalPath),
|
||||
Discovery: route.kind == routeDiscovery,
|
||||
CoreFlags: commandmeta.ReportingFlags(),
|
||||
}
|
||||
if snapshot == nil {
|
||||
return view
|
||||
}
|
||||
view.CommandAliases = routeHelpAliases(snapshot, route)
|
||||
if view.Discovery {
|
||||
view.FlagAliases = snapshot.discoveryFlags[route.discoveryPath.Key()].neutralRecords()
|
||||
return view
|
||||
}
|
||||
view.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()
|
||||
return view
|
||||
}
|
||||
|
||||
func clonePath(path commandpath.Path) []string {
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
|
||||
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 snapshot.aliases.ResolutionPaths(route.enteredPath) {
|
||||
for _, alias := range snapshot.commandAliases {
|
||||
source := commandpath.FromTokens(alias.Source)
|
||||
if len(source) <= len(prefix) || !source.HasPrefix(prefix) {
|
||||
continue
|
||||
}
|
||||
suffix := source[len(prefix):]
|
||||
entered := append(route.enteredPath.Clone(), suffix...)
|
||||
target := snapshot.aliases.Resolve(entered)
|
||||
if traceCanonicalPath(snapshot.canonical, target).exact == nil {
|
||||
continue
|
||||
}
|
||||
bySource[suffix.Key()] = commandmeta.CommandAlias{Source: clonePath(suffix), Target: clonePath(target)}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
45
identity.go
45
identity.go
@@ -18,8 +18,11 @@ type Identity struct {
|
||||
Revision string
|
||||
}
|
||||
|
||||
// Streams contains the output destinations associated with a command
|
||||
// invocation.
|
||||
// Streams contains the caller-owned output destinations associated with an
|
||||
// invocation. Out receives substantive results; Err receives messages,
|
||||
// terminal explanations, usage guidance, and CommandKit diagnostics. Nil
|
||||
// destinations are replaced with io.Discard. CommandKit does not close or flush
|
||||
// supplied writers.
|
||||
type Streams struct {
|
||||
// Out receives help and command reports.
|
||||
Out io.Writer
|
||||
@@ -27,11 +30,41 @@ type Streams struct {
|
||||
Err io.Writer
|
||||
}
|
||||
|
||||
// Invocation provides execution context and output streams to a command
|
||||
// handler.
|
||||
func normalizeStreams(streams Streams) Streams {
|
||||
if streams.Out == nil {
|
||||
streams.Out = io.Discard
|
||||
}
|
||||
if streams.Err == nil {
|
||||
streams.Err = io.Discard
|
||||
}
|
||||
return streams
|
||||
}
|
||||
|
||||
// Invocation provides execution context and invocation-scoped reporting to a
|
||||
// command handler. Execute finalizes its reporter before returning. For command
|
||||
// aliases, EnteredPath and CanonicalPath expose the route the caller used and
|
||||
// the selected canonical command path. Both omit the executable name, flags,
|
||||
// and operands.
|
||||
type Invocation struct {
|
||||
// Context carries caller cancellation and request-scoped values.
|
||||
Context context.Context
|
||||
// Streams are the caller-selected output destinations.
|
||||
Streams Streams
|
||||
|
||||
reporter *Reporter
|
||||
enteredPath string
|
||||
canonicalPath string
|
||||
}
|
||||
|
||||
// Reporter returns the reporting facility supplied for this invocation. A
|
||||
// manually constructed Invocation has no reporter.
|
||||
func (inv Invocation) Reporter() *Reporter { return inv.reporter }
|
||||
|
||||
// EnteredPath returns the command path used to invoke the selected command,
|
||||
// excluding the executable name, flags, and operands. 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 after resolving
|
||||
// aliases, excluding the executable name, flags, and operands. It equals
|
||||
// EnteredPath for a direct canonical invocation and is empty for an Invocation
|
||||
// that was not created by command execution.
|
||||
func (inv Invocation) CanonicalPath() string { return inv.canonicalPath }
|
||||
|
||||
@@ -192,14 +192,14 @@ func TestFromConfigKitCompareRetainsSideDiagnosticsAndLoadError(t *testing.T) {
|
||||
if !errors.As(gotErr, &typed) || typed != loadErr {
|
||||
t.Fatalf("load error type not preserved: %#v", typed)
|
||||
}
|
||||
if len(acquired.Diagnostics) != 2 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning || acquired.Diagnostics[1].Severity != commandkit.SeverityError {
|
||||
t.Fatalf("retained diagnostics = %#v", acquired.Diagnostics)
|
||||
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning || len(acquired.Explanation.Details) != 1 {
|
||||
t.Fatalf("retained diagnostics/explanation = %#v / %#v", acquired.Diagnostics, acquired.Explanation)
|
||||
}
|
||||
var leftText, rightText bytes.Buffer
|
||||
if err := acquired.Diagnostics[0].Write(&leftText); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := acquired.Diagnostics[1].Write(&rightText); err != nil {
|
||||
if err := acquired.Explanation.Details[0](&rightText); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(leftText.String(), `Configuration scope "app" left:`) || !strings.Contains(leftText.String(), `"left-source"`) {
|
||||
@@ -231,8 +231,15 @@ func TestFromConfigKitCompareStopsAfterLeftLoadFailure(t *testing.T) {
|
||||
t.Fatalf("left failure/right load = %v / %v", err, rightCalled)
|
||||
}
|
||||
var typed *configkit.LoadError
|
||||
if !errors.As(err, &typed) || typed != loadErr || len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
||||
t.Fatalf("left failure identity/diagnostics = %v / %#v", err, acquired.Diagnostics)
|
||||
if !errors.As(err, &typed) || typed != loadErr || len(acquired.Explanation.Details) != 1 {
|
||||
t.Fatalf("left failure identity/explanation = %v / %#v", err, acquired.Explanation)
|
||||
}
|
||||
var terminal bytes.Buffer
|
||||
if err := acquired.Explanation.Details[0](&terminal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(terminal.String(), `Configuration scope "app" left:`) || !strings.Contains(terminal.String(), `"left-source"`) {
|
||||
t.Fatalf("left terminal context lost side/source identity: %q", terminal.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
integrations/ckconfig/completion.go
Normal file
11
integrations/ckconfig/completion.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package ckconfig
|
||||
|
||||
import commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
|
||||
func completionChoices(values ...string) commandkit.CompletionHint {
|
||||
choices := make([]commandkit.CompletionChoice, len(values))
|
||||
for i, value := range values {
|
||||
choices[i].Value = value
|
||||
}
|
||||
return commandkit.CompleteChoices(choices...)
|
||||
}
|
||||
66
integrations/ckconfig/completion_test.go
Normal file
66
integrations/ckconfig/completion_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestCompletionUsesInstalledScopeCapabilitiesWithoutAcquisition(t *testing.T) {
|
||||
var inputs, states, acquisitions, comparisons int
|
||||
suite := Suite[struct{}, struct{}]{
|
||||
NewInput: func() struct{} { inputs++; return struct{}{} },
|
||||
NewState: func() struct{} { states++; return struct{}{} },
|
||||
Scopes: []Scope[struct{}, struct{}]{
|
||||
{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
comparisons++
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
}},
|
||||
{Name: "other", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
acquisitions++
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
}},
|
||||
},
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, Command(suite), commandkit.Completion("example"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
contains, omit string
|
||||
}{
|
||||
{[]string{"__complete", "config", "diff", "--scope="}, "app\n", "other\n"},
|
||||
{[]string{"__complete", "config", "validate", "--scope="}, "other\n", "all\n"},
|
||||
{[]string{"__complete", "config", "diff", "--format="}, "yaml\n", "text\n"},
|
||||
{[]string{"__complete", "config", "validate", "--format="}, "text\n", "invalid\n"},
|
||||
{[]string{"__complete", "config", "diff", "--against-config=fixtures/"}, ":0\n", ":4\n"},
|
||||
} {
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), tc.args, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Status != 0 || !strings.Contains(output.String(), tc.contains) || strings.Contains(output.String(), tc.omit) {
|
||||
t.Fatalf("query %q outcome=%#v output=%q", tc.args, outcome, output.String())
|
||||
}
|
||||
}
|
||||
allSuite := Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{
|
||||
{Name: "app", Operations: []Operation{Validate}, Acquire: suite.Scopes[1].Acquire},
|
||||
{Name: "other", Operations: []Operation{Validate}, Acquire: suite.Scopes[1].Acquire},
|
||||
}}
|
||||
allApp, err := commandkit.New(commandkit.Identity{Name: "example"}, Command(allSuite), commandkit.Completion("example"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var allOutput bytes.Buffer
|
||||
allOutcome := allApp.Execute(context.Background(), []string{"__complete", "config", "validate", "--scope="}, commandkit.Streams{Out: &allOutput})
|
||||
if allOutcome.Status != 0 || !strings.Contains(allOutput.String(), "all\n") {
|
||||
t.Fatalf("all-scope query outcome=%#v output=%q", allOutcome, allOutput.String())
|
||||
}
|
||||
if inputs != 0 || states != 0 || acquisitions != 0 || comparisons != 0 {
|
||||
t.Fatalf("completion ran suite work: %d %d %d %d", inputs, states, acquisitions, comparisons)
|
||||
}
|
||||
}
|
||||
@@ -33,53 +33,58 @@ func FromConfigKit[I, S, T any](
|
||||
|
||||
scope.Acquire = func(ctx context.Context, request Request, input I, state S) (commandkit.Acquired[View], error) {
|
||||
result, err := load(ctx, request.Selection, input, state)
|
||||
acquired := commandkit.Acquired[View]{}
|
||||
if result != nil {
|
||||
acquired.Value = View{Effective: result, Sources: result, Explanation: result}
|
||||
acquired.Diagnostics, acquired.Explanation = configKitDiagnostics(name, result.Sources(), result.Diagnostics())
|
||||
}
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
if errors.As(err, &loadError) && loadError != nil {
|
||||
return commandkit.Acquired[View]{Diagnostics: configKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())}, err
|
||||
warnings, explanation := configKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())
|
||||
acquired.Diagnostics = append(acquired.Diagnostics, warnings...)
|
||||
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, explanation)
|
||||
}
|
||||
return commandkit.Acquired[View]{}, err
|
||||
return acquired, err
|
||||
}
|
||||
if result == nil {
|
||||
return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
|
||||
}
|
||||
|
||||
sources := result.Sources()
|
||||
return commandkit.Acquired[View]{
|
||||
Value: View{Effective: result, Sources: result, Explanation: result},
|
||||
Diagnostics: configKitDiagnostics(name, sources, result.Diagnostics()),
|
||||
}, nil
|
||||
return acquired, nil
|
||||
}
|
||||
scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("ConfigKit scope %q comparison before left load: %w", name, err)
|
||||
}
|
||||
|
||||
left, leftDiagnostics, err := loadComparisonSide(ctx, name, "left", load, request.Left, state)
|
||||
diagnostics := append([]commandkit.Diagnostic(nil), leftDiagnostics...)
|
||||
acquired := commandkit.Acquired[[]configkit.Difference]{}
|
||||
left, leftDiagnostics, leftExplanation, err := loadComparisonSide(ctx, name, "left", load, request.Left, state)
|
||||
acquired.Diagnostics = append(acquired.Diagnostics, leftDiagnostics...)
|
||||
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, leftExplanation)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err
|
||||
return acquired, err
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q comparison after left load: %w", name, err)
|
||||
return acquired, fmt.Errorf("ConfigKit scope %q comparison after left load: %w", name, err)
|
||||
}
|
||||
|
||||
right, rightDiagnostics, err := loadComparisonSide(ctx, name, "right", load, request.Right, state)
|
||||
diagnostics = append(diagnostics, rightDiagnostics...)
|
||||
right, rightDiagnostics, rightExplanation, err := loadComparisonSide(ctx, name, "right", load, request.Right, state)
|
||||
acquired.Diagnostics = append(acquired.Diagnostics, rightDiagnostics...)
|
||||
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, rightExplanation)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err
|
||||
return acquired, err
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q comparison before native diff: %w", name, err)
|
||||
return acquired, fmt.Errorf("ConfigKit scope %q comparison before native diff: %w", name, err)
|
||||
}
|
||||
|
||||
differences, err := configkit.Diff(left, right)
|
||||
acquired.Value, err = configkit.Diff(left, right)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q native diff: %w", name, err)
|
||||
return acquired, fmt.Errorf("ConfigKit scope %q native diff: %w", name, err)
|
||||
}
|
||||
return commandkit.Acquired[[]configkit.Difference]{Value: differences, Diagnostics: diagnostics}, nil
|
||||
return acquired, nil
|
||||
}
|
||||
return scope
|
||||
}
|
||||
@@ -90,67 +95,110 @@ func loadComparisonSide[I, S, T any](
|
||||
load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
|
||||
request ComparisonSide[I],
|
||||
state S,
|
||||
) (*configkit.Result[T], []commandkit.Diagnostic, error) {
|
||||
) (*configkit.Result[T], []commandkit.Diagnostic, commandkit.Explanation, error) {
|
||||
result, err := load(ctx, request.Selection, request.Input, state)
|
||||
var diagnostics []commandkit.Diagnostic
|
||||
var explanation commandkit.Explanation
|
||||
if result != nil {
|
||||
warnings, terminal := configKitComparisonDiagnostics(scope, side, result.Sources(), result.Diagnostics())
|
||||
diagnostics = append(diagnostics, warnings...)
|
||||
explanation = appendConfigKitExplanation(explanation, terminal)
|
||||
}
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
var diagnostics []commandkit.Diagnostic
|
||||
if errors.As(err, &loadError) && loadError != nil {
|
||||
diagnostics = configKitComparisonDiagnostics(scope, side, loadError.Sources(), loadError.Diagnostics())
|
||||
warnings, terminal := configKitComparisonDiagnostics(scope, side, loadError.Sources(), loadError.Diagnostics())
|
||||
diagnostics = append(diagnostics, warnings...)
|
||||
explanation = appendConfigKitExplanation(explanation, terminal)
|
||||
}
|
||||
return nil, diagnostics, fmt.Errorf("ConfigKit scope %q %s load: %w", scope, side, err)
|
||||
return result, diagnostics, explanation, fmt.Errorf("ConfigKit scope %q %s load: %w", scope, side, err)
|
||||
}
|
||||
if result == nil {
|
||||
return nil, nil, fmt.Errorf("ConfigKit scope %q %s loader returned a nil result", scope, side)
|
||||
return nil, diagnostics, explanation, fmt.Errorf("ConfigKit scope %q %s loader returned a nil result", scope, side)
|
||||
}
|
||||
return result, configKitComparisonDiagnostics(scope, side, result.Sources(), result.Diagnostics()), nil
|
||||
return result, diagnostics, explanation, nil
|
||||
}
|
||||
|
||||
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
||||
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
||||
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+":\n", sources, records)
|
||||
}
|
||||
|
||||
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
||||
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
||||
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+" "+side+":\n", sources, records)
|
||||
}
|
||||
|
||||
func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
||||
func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
return nil, commandkit.Explanation{}
|
||||
}
|
||||
|
||||
severity := commandkit.SeverityWarning
|
||||
sources = append([]configkit.SourceInfo(nil), sources...)
|
||||
records = cloneConfigKitDiagnostics(records)
|
||||
warnings := make([]configkit.Diagnostic, 0, len(records))
|
||||
terminal := make([]configkit.Diagnostic, 0, len(records))
|
||||
for _, record := range records {
|
||||
if record.Severity == configkit.SeverityError {
|
||||
severity = commandkit.SeverityError
|
||||
break
|
||||
terminal = append(terminal, record)
|
||||
} else {
|
||||
warnings = append(warnings, record)
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the upstream snapshots so later calls to Write do not depend on
|
||||
// slices returned by the provider.
|
||||
sources = append([]configkit.SourceInfo(nil), sources...)
|
||||
records = append([]configkit.Diagnostic(nil), records...)
|
||||
return []commandkit.Diagnostic{{
|
||||
Severity: severity,
|
||||
Write: func(writer io.Writer) error {
|
||||
if err := writeDiagnosticText(writer, heading); err != nil {
|
||||
if len(warnings) == 0 {
|
||||
return nil, explanationWithConfigKitRecords(heading, sources, terminal)
|
||||
}
|
||||
warning := commandkit.Diagnostic{
|
||||
Severity: commandkit.SeverityWarning,
|
||||
Write: configKitDiagnosticWriter(heading, sources, warnings),
|
||||
}
|
||||
return []commandkit.Diagnostic{warning}, explanationWithConfigKitRecords(heading, sources, terminal)
|
||||
}
|
||||
|
||||
func explanationWithConfigKitRecords(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) commandkit.Explanation {
|
||||
if len(records) == 0 {
|
||||
return commandkit.Explanation{}
|
||||
}
|
||||
return commandkit.Explanation{Details: []commandkit.Detail{configKitDiagnosticWriter(heading, sources, records)}}
|
||||
}
|
||||
|
||||
func configKitDiagnosticWriter(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) commandkit.Detail {
|
||||
return func(writer io.Writer) error {
|
||||
if err := writeDiagnosticText(writer, heading); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil {
|
||||
for _, source := range sources {
|
||||
line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
|
||||
if err := writeDiagnosticText(writer, line); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, source := range sources {
|
||||
line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
|
||||
if err := writeDiagnosticText(writer, line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return configkit.WriteDiagnostics(writer, records)
|
||||
},
|
||||
}}
|
||||
}
|
||||
return configkit.WriteDiagnostics(writer, records)
|
||||
}
|
||||
}
|
||||
|
||||
func cloneConfigKitDiagnostics(records []configkit.Diagnostic) []configkit.Diagnostic {
|
||||
cloned := append([]configkit.Diagnostic(nil), records...)
|
||||
for index := range cloned {
|
||||
if records[index].Primary != nil {
|
||||
primary := *records[index].Primary
|
||||
cloned[index].Primary = &primary
|
||||
}
|
||||
cloned[index].Related = append([]configkit.Location(nil), records[index].Related...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func appendConfigKitExplanation(left, right commandkit.Explanation) commandkit.Explanation {
|
||||
if left.Message == "" {
|
||||
left.Message = right.Message
|
||||
}
|
||||
left.Details = append(left.Details, right.Details...)
|
||||
return left
|
||||
}
|
||||
|
||||
func writeDiagnosticText(writer io.Writer, value string) error {
|
||||
|
||||
@@ -98,12 +98,13 @@ type Scope[I, S any] struct {
|
||||
// Operations selects the standard handlers exposed for this scope.
|
||||
Operations []Operation
|
||||
// Acquire loads the selected configuration for one ordinary inspection
|
||||
// invocation.
|
||||
// invocation. It returns independent diagnostics and terminal context
|
||||
// separately from the native value and optional operation error.
|
||||
Acquire func(context.Context, Request, I, S) (commandkit.Acquired[View], error)
|
||||
// Compare compares two selections for this scope using one shared state.
|
||||
// Its Acquired result carries native difference records and diagnostics;
|
||||
// returned errors retain their supported cause identity. It is required when
|
||||
// Diff is declared in Operations.
|
||||
// Its Acquired result carries native difference records, independent
|
||||
// diagnostics, and terminal context; returned errors retain their supported
|
||||
// cause identity. It is required when Diff is declared in Operations.
|
||||
Compare func(context.Context, ComparisonRequest[I], S) (commandkit.Acquired[[]configkit.Difference], error)
|
||||
|
||||
issues []error
|
||||
|
||||
@@ -126,14 +126,20 @@ func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
||||
type settings struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
_, loadErr := configkit.Load(configkit.Mapping("bad source", map[string]any{"unknown": true}), configkit.Options[settings]{Definition: configkit.Definition[settings]{}})
|
||||
path := configkit.Keys("schema_version")
|
||||
_, loadErr := configkit.Load(configkit.Bytes("bad source", []byte("{}\n")), configkit.Options[struct{}]{
|
||||
Definition: configkit.Definition[struct{}]{Version: &configkit.VersionRule{
|
||||
Path: path,
|
||||
Check: func(_ configkit.Version, validation *configkit.Validation) error {
|
||||
validation.Warning(path, "review schema compatibility")
|
||||
return errors.New("private checker error")
|
||||
},
|
||||
}},
|
||||
})
|
||||
if loadErr == nil {
|
||||
t.Fatal("fixture did not produce a ConfigKit load error")
|
||||
}
|
||||
scope := FromConfigKit[struct{}, struct{}, settings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[settings], error) {
|
||||
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[struct{}], error) {
|
||||
return nil, loadErr
|
||||
})
|
||||
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||
@@ -144,38 +150,121 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
||||
if !errors.As(gotErr, &typed) {
|
||||
t.Fatalf("load error type was not retained: %T", gotErr)
|
||||
}
|
||||
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
||||
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
|
||||
t.Fatalf("failure diagnostics = %#v", acquired.Diagnostics)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := acquired.Diagnostics[0].Write(&rendered); err != nil {
|
||||
if acquired.Explanation.Message != "" || len(acquired.Explanation.Details) != 1 {
|
||||
t.Fatalf("failure explanation = %#v", acquired.Explanation)
|
||||
}
|
||||
var warning, terminal bytes.Buffer
|
||||
if err := acquired.Diagnostics[0].Write(&warning); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(rendered.String(), `Configuration scope "app":`) || !strings.Contains(rendered.String(), `"bad source"`) {
|
||||
t.Fatalf("diagnostic output lacks scope/source legend: %q", rendered.String())
|
||||
if err := acquired.Explanation.Details[0](&terminal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(warning.String(), `Configuration scope "app":`) || !strings.Contains(warning.String(), `review schema compatibility`) {
|
||||
t.Fatalf("warning output lost its scope or record: %q", warning.String())
|
||||
}
|
||||
if !strings.Contains(terminal.String(), `Configuration scope "app":`) || !strings.Contains(terminal.String(), `"bad source"`) || strings.Contains(terminal.String(), "review schema compatibility") {
|
||||
t.Fatalf("terminal explanation lost native context or included an independent warning: %q", terminal.String())
|
||||
}
|
||||
if strings.Contains(terminal.String(), "private checker error") {
|
||||
t.Fatalf("terminal explanation exposed the upstream cause: %q", terminal.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromConfigKitPreservesValueAndDiagnosticsAlongsideError(t *testing.T) {
|
||||
result, err := adapterSettingsLoad(configkit.Mapping("partial source", map[string]any{"greeting": "Hello"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loadErr := errors.New("loader returned a value with an error")
|
||||
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
|
||||
return result, loadErr
|
||||
})
|
||||
|
||||
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||
if gotErr != loadErr {
|
||||
t.Fatalf("load error identity = %v, want %v", gotErr, loadErr)
|
||||
}
|
||||
if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result {
|
||||
t.Fatalf("acquired value facets were lost: %#v", acquired.Value)
|
||||
}
|
||||
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
|
||||
t.Fatalf("independent diagnostics = %#v", acquired.Diagnostics)
|
||||
}
|
||||
if len(acquired.Explanation.Details) != 0 {
|
||||
t.Fatalf("warning-only result acquired terminal context: %#v", acquired.Explanation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigKitDiagnosticsMapSeverityAndPropagateWriterFailures(t *testing.T) {
|
||||
warning := configKitDiagnostics("app", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}})
|
||||
warning, noExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}})
|
||||
if len(warning) != 1 || warning[0].Severity != commandkit.SeverityWarning {
|
||||
t.Fatalf("warning diagnostics = %#v", warning)
|
||||
}
|
||||
withError := configKitDiagnostics("app", nil, []configkit.Diagnostic{
|
||||
if noExplanation.Message != "" || len(noExplanation.Details) != 0 {
|
||||
t.Fatalf("warning-only explanation = %#v", noExplanation)
|
||||
}
|
||||
withWarning, withExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{
|
||||
{Severity: configkit.SeverityWarning, Message: "safe warning"},
|
||||
{Severity: configkit.SeverityError, Message: "safe error"},
|
||||
})
|
||||
if withError[0].Severity != commandkit.SeverityError {
|
||||
t.Fatalf("mixed diagnostics severity = %q", withError[0].Severity)
|
||||
if len(withWarning) != 1 || withWarning[0].Severity != commandkit.SeverityWarning || len(withExplanation.Details) != 1 {
|
||||
t.Fatalf("mixed diagnostics = %#v, explanation = %#v", withWarning, withExplanation)
|
||||
}
|
||||
|
||||
if err := withError[0].Write(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
|
||||
if err := withWarning[0].Write(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("short writer error = %v, want %v", err, io.ErrShortWrite)
|
||||
}
|
||||
if err := withExplanation.Details[0](shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("terminal short writer error = %v, want %v", err, io.ErrShortWrite)
|
||||
}
|
||||
wantErr := errors.New("writer failed")
|
||||
if err := withError[0].Write(failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
|
||||
if err := withWarning[0].Write(failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("writer error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if err := withExplanation.Details[0](failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("terminal writer error = %v, want %v", err, wantErr)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestConfigKitTerminalContextRetainsRelatedLocationsAndSnapshots(t *testing.T) {
|
||||
primary := &configkit.Location{SourceID: "primary-source", Line: 4, Column: 2}
|
||||
related := []configkit.Location{{SourceID: "related-source", Line: 2, Column: 1}}
|
||||
sources := []configkit.SourceInfo{
|
||||
{ID: "primary-source", Label: "primary", Path: "primary.yml"},
|
||||
{ID: "related-source", Label: "related", Path: "related.yml"},
|
||||
}
|
||||
records := []configkit.Diagnostic{{
|
||||
Severity: configkit.SeverityError,
|
||||
Path: configkit.Keys("name"),
|
||||
Message: "native terminal context",
|
||||
Primary: primary,
|
||||
Related: related,
|
||||
}}
|
||||
_, explanation := configKitDiagnostics("app", sources, records)
|
||||
primary.SourceID = "mutated-primary"
|
||||
related[0].SourceID = "mutated-related"
|
||||
sources[0].Path = "mutated.yml"
|
||||
|
||||
if len(explanation.Details) != 1 {
|
||||
t.Fatalf("explanation = %#v", explanation)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := explanation.Details[0](&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, fragment := range []string{"primary.yml", "primary-source", "related-source", "native terminal context"} {
|
||||
if !strings.Contains(output.String(), fragment) {
|
||||
t.Errorf("terminal context %q omits %q", output.String(), fragment)
|
||||
}
|
||||
}
|
||||
if strings.Contains(output.String(), "mutated-") || strings.Contains(output.String(), "mutated.yml") {
|
||||
t.Fatalf("terminal renderer retained caller mutations: %q", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
type shortWriter struct{}
|
||||
|
||||
@@ -9,35 +9,28 @@ import (
|
||||
|
||||
func runConfigDiff[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
||||
if plan.comparison == nil || plan.comparison.scope == nil {
|
||||
return configOperationFailure(errors.New("configuration comparison was not prepared"), nil, false)
|
||||
cause := errors.New("configuration comparison was not prepared")
|
||||
return configFailure("Configuration comparison was not prepared.", commandkit.Explanation{}, cause)
|
||||
}
|
||||
selected := plan.comparison
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, nil, false)
|
||||
return configFailure("Configuration comparison was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
compare := selected.scope.scope.Compare
|
||||
if compare == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q has no comparison callback", selected.scope.scope.Name), nil, false)
|
||||
cause := fmt.Errorf("configuration scope %q has no comparison callback", selected.scope.scope.Name)
|
||||
return configFailure("Configuration scope does not support comparison.", commandkit.Explanation{}, cause)
|
||||
}
|
||||
acquired, err := compare(invocation.Context, selected.request, state)
|
||||
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
|
||||
reporter := invocation.Reporter()
|
||||
queueConfigDiagnostics(reporter, acquired.Diagnostics)
|
||||
if err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return configFailure(fmt.Sprintf("Unable to compare configuration scope %q.", selected.scope.scope.Name), acquired.Explanation, err)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return configFailure("Configuration comparison was canceled.", acquired.Explanation, err)
|
||||
}
|
||||
report, err := renderDiffReport(acquired.Value)
|
||||
if err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
if err := writeConfigReport(invocation.Streams.Out, report); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||
return renderDiffReport(acquired.Value)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -101,21 +101,30 @@ func TestConfigDiffRunsComparisonOnlyScopeAndRejectsUnsupportedScopeBeforeWork(t
|
||||
|
||||
func TestConfigDiffProviderFailureRetainsDiagnosticsAndCause(t *testing.T) {
|
||||
wantErr := errors.New("provider unavailable")
|
||||
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityError, Write: func(writer io.Writer) error {
|
||||
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "provider warning\n")
|
||||
return err
|
||||
}}
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Diff},
|
||||
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, fmt.Errorf("comparison setup: %w", wantErr)
|
||||
return commandkit.Acquired[[]configkit.Difference]{
|
||||
Diagnostics: []commandkit.Diagnostic{diagnostic},
|
||||
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "native comparison context\n")
|
||||
return err
|
||||
}}},
|
||||
}, fmt.Errorf("comparison setup: %w", wantErr)
|
||||
},
|
||||
}}}))
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, wantErr) || outcome.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityError {
|
||||
var output, diagnosticOutput bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, wantErr) || output.Len() != 0 {
|
||||
t.Fatalf("provider failure outcome/output = %#v / %q", outcome, output.String())
|
||||
}
|
||||
if got := diagnosticOutput.String(); !strings.Contains(got, "provider warning") || !strings.Contains(got, "native comparison context") || strings.Index(got, "provider warning") > strings.Index(got, "native comparison context") || strings.Contains(got, wantErr.Error()) {
|
||||
t.Fatalf("provider messages and terminal context = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) {
|
||||
@@ -128,8 +137,8 @@ func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) {
|
||||
},
|
||||
}}}))
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 {
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff", "--quiet"}, commandkit.Streams{Out: failingOutput{err: errors.New("quiet destination should not be called")}, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete || outcome.Presentation.Output.RenderError == nil || output.Len() != 0 {
|
||||
t.Fatalf("report preparation failure outcome/output = %#v / %q", outcome, output.String())
|
||||
}
|
||||
}
|
||||
@@ -146,8 +155,8 @@ func TestConfigDiffCancellationAfterAcquisitionRetainsDiagnosticsWithoutWriting(
|
||||
}}}))
|
||||
defer cancel()
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(ctx, []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || outcome.ReportIncomplete || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityWarning {
|
||||
outcome := app.Execute(ctx, []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || output.Len() != 0 {
|
||||
t.Fatalf("cancelled comparison outcome/output = %#v / %q", outcome, output.String())
|
||||
}
|
||||
}
|
||||
@@ -170,13 +179,36 @@ func TestConfigDiffOutputErrorsCarryIncompleteDeliveryOutcome(t *testing.T) {
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: test.writer, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.DeliveryError, test.want) || !errors.Is(outcome.Cause, test.want) || !outcome.ReportIncomplete {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.WriteError, test.want) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete {
|
||||
t.Fatalf("delivery failure outcome = %#v", outcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffDiagnosticDestinationFailureIsPresentationEvidence(t *testing.T) {
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Diff},
|
||||
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{
|
||||
Diagnostics: []commandkit.Diagnostic{{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "provider warning\n")
|
||||
return err
|
||||
}}},
|
||||
}, nil
|
||||
},
|
||||
}}}))
|
||||
writeErr := errors.New("diagnostic destination failed")
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: failingOutput{err: writeErr}})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Diagnostics.WriteError, writeErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete {
|
||||
t.Fatalf("diagnostic delivery failure outcome = %#v", outcome)
|
||||
}
|
||||
if output.Len() == 0 {
|
||||
t.Fatal("healthy result destination did not receive the diff report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffRepeatedInvocationsPrepareFreshInputsAndState(t *testing.T) {
|
||||
type input struct {
|
||||
ID int
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Package ckconfig provides CommandKit's optional ConfigKit integration. Its
|
||||
// declarations install the standard config command family through the core
|
||||
// commandkit package. Source reports preserve ConfigKit's destination,
|
||||
// placement, and version-check evidence; importing ckconfig alone installs no
|
||||
// commands.
|
||||
// canonical Command declaration installs the standard config command family
|
||||
// through the core commandkit package. Applications can install CommandKit
|
||||
// RoutingRule declarations alongside it through commandkit.New or
|
||||
// App.Register. Source reports preserve ConfigKit's destination, placement,
|
||||
// and version-check evidence. Invocation reporting applies CommandKit's shared
|
||||
// visibility and delivery policy; provider warnings remain independent of
|
||||
// failure explanations. Importing ckconfig alone installs no commands.
|
||||
package ckconfig
|
||||
|
||||
@@ -4,6 +4,8 @@ Run the maintained example with:
|
||||
|
||||
```sh
|
||||
go run . config show
|
||||
go run . config show --config fixtures/before.yml --verbose
|
||||
go run . config show --config fixtures/before.yml --quiet
|
||||
go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml
|
||||
go run . greet
|
||||
go run . greet --count=0 --tag=first --tag second
|
||||
@@ -15,3 +17,6 @@ omitted `--count` and an explicitly supplied zero, plus ordered repeated tags.
|
||||
With no `--config`, application settings use the in-memory default. A supplied
|
||||
configuration and overlay are passed to ConfigKit unchanged. The comparison
|
||||
command loads the two local fixtures and reports `changes.greeting: Goodbye`.
|
||||
The before fixture uses schema version 1, which ConfigKit reports as a provider
|
||||
warning in verbose mode. Ordinary mode hides the warning while preserving the
|
||||
effective YAML report; quiet mode suppresses that report.
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
schema:
|
||||
version: 2
|
||||
greeting: Goodbye
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
schema:
|
||||
version: 1
|
||||
greeting: Hello
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
@@ -12,6 +13,9 @@ import (
|
||||
)
|
||||
|
||||
type appConfiguration struct {
|
||||
Schema struct {
|
||||
Version int `yaml:"version"`
|
||||
} `yaml:"schema"`
|
||||
Greeting string `yaml:"greeting"`
|
||||
}
|
||||
|
||||
@@ -22,12 +26,25 @@ type greetInput struct {
|
||||
|
||||
func loadAppConfiguration(_ context.Context, selection ckconfig.Selection) (*configkit.Result[appConfiguration], error) {
|
||||
source := configkit.Mapping("simple application settings", map[string]any{
|
||||
"schema": map[string]any{"version": 2},
|
||||
"greeting": "Hello",
|
||||
})
|
||||
if selection.Config.Set {
|
||||
source = configkit.File(selection.Config.Value)
|
||||
}
|
||||
options := configkit.Options[appConfiguration]{}
|
||||
options := configkit.Options[appConfiguration]{
|
||||
Definition: configkit.Definition[appConfiguration]{
|
||||
Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("schema", "version"),
|
||||
Check: func(version configkit.Version, validation *configkit.Validation) error {
|
||||
if version.Text == "1" {
|
||||
validation.Warning(configkit.Keys("schema", "version"), "schema version 1 is supported; upgrade to version 2 before the next example release")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if selection.Overlay.Set {
|
||||
options.Overlay = selection.Overlay.Value
|
||||
options.Filesystem = &configkit.FilesystemContext{Root: ".", BaseDir: "."}
|
||||
@@ -50,9 +67,10 @@ func newApp() (*commandkit.App, error) {
|
||||
}),
|
||||
},
|
||||
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
|
||||
reporter := invocation.Reporter()
|
||||
configuration, err := loadAppConfiguration(invocation.Context, ckconfig.Selection{})
|
||||
if err != nil {
|
||||
return commandkit.Failure(err)
|
||||
return commandkit.Failure("Could not load application configuration.").WithCause(err)
|
||||
}
|
||||
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
|
||||
if input.Count.Set {
|
||||
@@ -62,9 +80,10 @@ func newApp() (*commandkit.App, error) {
|
||||
if input.Tags.Set {
|
||||
tags = fmt.Sprintf("explicit %v", input.Tags.Value)
|
||||
}
|
||||
if _, err := fmt.Fprintf(invocation.Streams.Out, "%s; count=%s; tags=%s\n", configuration.Config.Greeting, count, tags); err != nil {
|
||||
return commandkit.Failure(err)
|
||||
}
|
||||
_ = reporter.RenderOutput(func(writer io.Writer) error {
|
||||
_, err := fmt.Fprintf(writer, "%s; count=%s; tags=%s\n", configuration.Config.Greeting, count, tags)
|
||||
return err
|
||||
})
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
@@ -42,7 +43,7 @@ func TestSimpleAppComparesFixturesAndKeepsDefaultCommands(t *testing.T) {
|
||||
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"config", "show"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || output.String() != "greeting: Hello\n" {
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || output.String() != "greeting: Hello\nschema:\n version: 2\n" {
|
||||
t.Fatalf("default show outcome/output = %#v / %q", outcome, output.String())
|
||||
}
|
||||
|
||||
@@ -60,6 +61,73 @@ func TestSimpleLoaderDoesNotReplaceExplicitEmptyConfiguration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleConfigurationReportingModes(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantOutput string
|
||||
wantDiagnostic string
|
||||
quiet bool
|
||||
}{
|
||||
{
|
||||
name: "ordinary output hides provider warning",
|
||||
args: []string{"config", "show", "--config", "fixtures/before.yml"},
|
||||
wantOutput: "greeting: Hello\nschema:\n version: 1\n",
|
||||
},
|
||||
{
|
||||
name: "quiet suppresses configuration report",
|
||||
args: []string{"config", "show", "--config", "fixtures/before.yml", "--quiet"},
|
||||
quiet: true,
|
||||
},
|
||||
{
|
||||
name: "verbose shows ConfigKit warning",
|
||||
args: []string{"config", "show", "--config", "fixtures/before.yml", "--verbose"},
|
||||
wantOutput: "greeting: Hello\nschema:\n version: 1\n",
|
||||
wantDiagnostic: "schema version 1 is supported",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
outcome, output, diagnostics := executeSimple(t, test.args...)
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Cause != nil {
|
||||
t.Fatalf("outcome = %#v", outcome)
|
||||
}
|
||||
if output != test.wantOutput {
|
||||
t.Errorf("output = %q, want %q", output, test.wantOutput)
|
||||
}
|
||||
if test.wantDiagnostic != "" && !strings.Contains(diagnostics, test.wantDiagnostic) {
|
||||
t.Errorf("diagnostics = %q, want provider warning %q", diagnostics, test.wantDiagnostic)
|
||||
}
|
||||
if test.wantDiagnostic == "" && diagnostics != "" {
|
||||
t.Errorf("diagnostics = %q, want no warning outside verbose mode", diagnostics)
|
||||
}
|
||||
if strings.Contains(diagnostics, "ERROR:") {
|
||||
t.Errorf("diagnostics %q unexpectedly contain a terminal failure", diagnostics)
|
||||
}
|
||||
if test.quiet && (outcome.Presentation.Output.State != commandkit.DeliverySuppressed || outcome.Presentation.Diagnostics.State != commandkit.DeliverySuppressed) {
|
||||
t.Errorf("quiet presentation = %#v, want report and provider warning suppressed", outcome.Presentation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCustomCommandUsesSharedReportingFlags(t *testing.T) {
|
||||
outcome, output, diagnostics := executeSimple(t, "greet", "--quiet")
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || output != "" || diagnostics != "" {
|
||||
t.Fatalf("quiet greet outcome/output/diagnostics = %#v / %q / %q", outcome, output, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func executeSimple(t *testing.T, args ...string) (commandkit.Outcome, string, string) {
|
||||
t.Helper()
|
||||
app, err := newApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnostics})
|
||||
return outcome, output.String(), diagnostics.String()
|
||||
}
|
||||
|
||||
func field(mapping *yaml.Node, name string) *yaml.Node {
|
||||
if mapping == nil || mapping.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
|
||||
@@ -2,7 +2,6 @@ package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
@@ -54,70 +53,54 @@ type secretReferenceDTO struct {
|
||||
func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
||||
selected := plan.scopes[0]
|
||||
name := selected.scope.scope.Name
|
||||
reporter := invocation.Reporter()
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, nil, false)
|
||||
return configFailure("Configuration explanation was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
if selected.scope.scope.Acquire == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), nil, false)
|
||||
cause := fmt.Errorf("configuration scope %q has no acquisition callback", name)
|
||||
return configFailure(fmt.Sprintf("Unable to explain configuration scope %q.", name), commandkit.Explanation{}, cause)
|
||||
}
|
||||
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
|
||||
Operation: Explain,
|
||||
Selection: plan.selection,
|
||||
}, selected.input, state)
|
||||
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
|
||||
queueConfigDiagnostics(reporter, acquired.Diagnostics)
|
||||
if acquireErr != nil {
|
||||
return configOperationFailure(acquireErr, diagnostics, false)
|
||||
return configFailure(fmt.Sprintf("Unable to explain configuration scope %q.", name), acquired.Explanation, acquireErr)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return configFailure("Configuration explanation was canceled.", acquired.Explanation, err)
|
||||
}
|
||||
if acquired.Value.Explanation == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q did not provide an explanation reader", name), diagnostics, false)
|
||||
cause := fmt.Errorf("configuration scope %q did not provide an explanation reader", name)
|
||||
return configFailure("Configuration scope did not provide explanation data.", acquired.Explanation, cause)
|
||||
}
|
||||
|
||||
explanation, found := acquired.Value.Explanation.Explain(plan.path)
|
||||
if !found {
|
||||
return configOperationFailure(fmt.Errorf("configuration path %q is not known in scope %q", plan.path.String(), name), diagnostics, false)
|
||||
cause := fmt.Errorf("configuration path %q is not known in scope %q", plan.path.String(), name)
|
||||
return configFailure("The requested configuration path is not available.", acquired.Explanation, cause)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return configFailure("Configuration explanation was canceled.", acquired.Explanation, err)
|
||||
}
|
||||
if plan.format != "yaml" {
|
||||
return writeNativeExplanation(invocation.Streams.Out, acquired.Value.Explanation, plan.path, diagnostics)
|
||||
}
|
||||
var report bytes.Buffer
|
||||
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
if err := writeConfigReport(invocation.Streams.Out, report.Bytes()); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
}
|
||||
|
||||
func writeNativeExplanation(writer io.Writer, reader ExplanationReader, path configkit.Path, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
||||
checked := &checkedWriter{writer: writer}
|
||||
renderErr := reader.WriteExplanation(checked, path)
|
||||
if checked.failure != nil {
|
||||
if renderErr == nil {
|
||||
renderErr = checked.failure
|
||||
} else {
|
||||
renderErr = errors.Join(renderErr, checked.failure)
|
||||
err := reporter.RenderOutput(func(writer io.Writer) error {
|
||||
return acquired.Value.Explanation.WriteExplanation(writer, plan.path)
|
||||
})
|
||||
if err != nil && invocation.Context.Err() != nil {
|
||||
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err())
|
||||
}
|
||||
return commandkit.Success()
|
||||
}
|
||||
if renderErr != nil {
|
||||
outcome := configOperationFailure(renderErr, diagnostics, true)
|
||||
outcome.DeliveryError = checked.failure
|
||||
return outcome
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||
var report bytes.Buffer
|
||||
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return report.Bytes(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -10,7 +11,6 @@ import (
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
type scopeMetadata struct {
|
||||
@@ -260,24 +260,23 @@ func newConfigScopesCommand[I, S any](prepared *preparedSuite[I, S]) commandkit.
|
||||
Name: "scopes",
|
||||
Summary: "List configuration scopes and supported operations.",
|
||||
Flags: []commandkit.Flag[scopeInventoryInput]{commandkit.ValueFlag(
|
||||
commandkit.FlagMeta{Name: "format", Usage: "Output format: text or yaml."}, &formatDefault, parseConfigString,
|
||||
commandkit.FlagMeta{Name: "format", Usage: "Output format: text or yaml.", Completion: completionChoices("text", "yaml")}, &formatDefault, parseConfigString,
|
||||
func(input *scopeInventoryInput, value commandkit.Supplied[string]) { input.Format = value.Value },
|
||||
)},
|
||||
CheckInput: func(input scopeInventoryInput) error { return checkConfigFormat(input.Format) },
|
||||
Run: func(invocation commandkit.Invocation, input scopeInventoryInput) commandkit.Outcome {
|
||||
var output []byte
|
||||
var err error
|
||||
if input.Format == "yaml" {
|
||||
output, err = yaml.Marshal(prepared.inventory)
|
||||
} else {
|
||||
output = []byte(formatScopeInventory(prepared.inventory))
|
||||
}
|
||||
if err != nil {
|
||||
return commandkit.Failure(err)
|
||||
}
|
||||
if err := writeConfigReport(invocation.Streams.Out, output); err != nil {
|
||||
return incompleteConfigReport(err)
|
||||
}
|
||||
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
|
||||
var report bytes.Buffer
|
||||
if input.Format == "yaml" {
|
||||
if err := encodeYAML(&report, prepared.inventory); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
_, _ = report.WriteString(formatScopeInventory(prepared.inventory))
|
||||
}
|
||||
_, err := writer.Write(report.Bytes())
|
||||
return err
|
||||
})
|
||||
return commandkit.Success()
|
||||
},
|
||||
}
|
||||
@@ -314,21 +313,3 @@ func checkConfigFormat(format string) error {
|
||||
}
|
||||
|
||||
func parseConfigString(value string) (string, error) { return value, nil }
|
||||
|
||||
func writeConfigReport(writer io.Writer, content []byte) error {
|
||||
written, err := writer.Write(content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written != len(content) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func incompleteConfigReport(err error) commandkit.Outcome {
|
||||
outcome := commandkit.Failure(err)
|
||||
outcome.ReportIncomplete = true
|
||||
outcome.DeliveryError = err
|
||||
return outcome
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ func TestValidationPreparesIndependentScopeInputsAndOneSharedState(t *testing.T)
|
||||
}
|
||||
app := newConfigTestApp(t, Command(suite))
|
||||
var output, diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all", "--config", "chosen.yaml", "--campaign", "spring"}, commandkit.Streams{Out: &output, Err: &diagnostic})
|
||||
outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all", "--config", "chosen.yaml", "--campaign", "spring", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(diagnostic.String(), "app warning") || !strings.Contains(diagnostic.String(), "promptkit warning") {
|
||||
t.Fatalf("validation outcome=%#v diagnostics=%q", outcome, diagnostic.String())
|
||||
}
|
||||
@@ -225,6 +225,51 @@ func TestRequiredSelectorAppliesOnlyToItsSelectedScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigAliasesForwardSelectedTenantAndGlobalHelp(t *testing.T) {
|
||||
type input struct{ Tenant commandkit.Supplied[string] }
|
||||
flag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "tenant"}, (*string)(nil),
|
||||
func(value string) (string, error) { return value, nil },
|
||||
func(target *input, value commandkit.Supplied[string]) { target.Tenant = value })
|
||||
var acquired []commandkit.Supplied[string]
|
||||
command := Command(Suite[input, struct{}]{
|
||||
Selectors: []Selector[input]{{Binding: flag, Scopes: []string{"promptkit"}}},
|
||||
Scopes: []Scope[input, struct{}]{
|
||||
{
|
||||
Name: "promptkit",
|
||||
Operations: []Operation{Validate},
|
||||
Acquire: func(_ context.Context, _ Request, value input, _ struct{}) (commandkit.Acquired[View], error) {
|
||||
acquired = append(acquired, value.Tenant)
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"},
|
||||
command,
|
||||
commandkit.Alias("check-config", "config validate"),
|
||||
commandkit.FlagAliasFor("config validate", "--tenant-id", "--tenant"),
|
||||
commandkit.FlagAlias("--hlep", "--help"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"check-config", "--hlep"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "validate") || len(acquired) != 0 {
|
||||
t.Fatalf("global help alias outcome/output/acquisitions = (%#v, %q, %#v)", outcome, output.String(), acquired)
|
||||
}
|
||||
|
||||
outcome = app.Execute(context.Background(), []string{"check-config", "--scope", "promptkit", "--tenant-id=acme"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || len(acquired) != 1 || acquired[0] != (commandkit.Supplied[string]{Value: "acme", Set: true}) {
|
||||
t.Fatalf("aliased selector outcome/acquisitions = (%#v, %#v)", outcome, acquired)
|
||||
}
|
||||
outcome = app.Execute(context.Background(), []string{"config", "validate", "--scope", "promptkit", "--tenant=canonical"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || len(acquired) != 2 || acquired[1] != (commandkit.Supplied[string]{Value: "canonical", Set: true}) {
|
||||
t.Fatalf("canonical selector outcome/acquisitions = (%#v, %#v)", outcome, acquired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
var acquisitionOutputLengths []int
|
||||
@@ -244,7 +289,7 @@ func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) {
|
||||
makeScope("promptkit", "---\nname: promptkit\n", providerErr),
|
||||
}}))
|
||||
outcome := app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != providerErr || output.Len() != 0 {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != providerErr || output.Len() != 0 || outcome.Presentation.Output.State != commandkit.DeliveryNone {
|
||||
t.Fatalf("aggregate provider failure outcome=%#v output=%q", outcome, output.String())
|
||||
}
|
||||
if len(acquisitionOutputLengths) != 2 || acquisitionOutputLengths[0] != 0 || acquisitionOutputLengths[1] != 0 {
|
||||
@@ -258,7 +303,7 @@ func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) {
|
||||
makeScope("promptkit", "name: promptkit\n", nil),
|
||||
}}))
|
||||
outcome = app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Presentation.Output.State != commandkit.DeliveryComplete {
|
||||
t.Fatalf("aggregate show outcome = %#v", outcome)
|
||||
}
|
||||
want := "---\n# scope: app\nname: app\n---\n# scope: promptkit\nname: promptkit\n"
|
||||
@@ -267,6 +312,109 @@ func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowSeparatesWarningsFromTerminalContextAcrossModes(t *testing.T) {
|
||||
providerErr := errors.New("private provider cause")
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{
|
||||
Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(commandkit.SeverityWarning, "independent warning")},
|
||||
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "native terminal context\n")
|
||||
return err
|
||||
}}},
|
||||
}, providerErr
|
||||
},
|
||||
}}}))
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
flags []string
|
||||
wantWarning bool
|
||||
wantTerminal bool
|
||||
}{
|
||||
{name: "ordinary", wantTerminal: true},
|
||||
{name: "verbose", flags: []string{"--verbose"}, wantWarning: true, wantTerminal: true},
|
||||
{name: "quiet", flags: []string{"--quiet"}, wantTerminal: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := append([]string{"config", "show"}, test.flags...)
|
||||
var output, diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != providerErr {
|
||||
t.Fatalf("provider failure outcome = %#v", outcome)
|
||||
}
|
||||
if got := strings.Contains(diagnostic.String(), "independent warning"); got != test.wantWarning {
|
||||
t.Fatalf("warning visibility = %v, output %q", got, diagnostic.String())
|
||||
}
|
||||
if got := strings.Contains(diagnostic.String(), "native terminal context"); got != test.wantTerminal {
|
||||
t.Fatalf("terminal context visibility = %v, output %q", got, diagnostic.String())
|
||||
}
|
||||
if strings.Contains(diagnostic.String(), providerErr.Error()) {
|
||||
t.Fatalf("cause text was duplicated into presentation: %q", diagnostic.String())
|
||||
}
|
||||
if test.wantWarning && strings.Index(diagnostic.String(), "independent warning") > strings.Index(diagnostic.String(), "native terminal context") {
|
||||
t.Fatalf("deferred warning did not precede terminal context: %q", diagnostic.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowAndValidationUseManagedRendering(t *testing.T) {
|
||||
t.Run("quiet show still records native render errors", func(t *testing.T) {
|
||||
renderErr := errors.New("native renderer failed")
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{Value: View{Effective: failedEffective{err: renderErr}}}, nil
|
||||
},
|
||||
}}}))
|
||||
var output, diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "show", "--quiet"}, commandkit.Streams{Out: &output, Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.RenderError, renderErr) || outcome.Cause != nil || output.Len() != 0 {
|
||||
t.Fatalf("quiet render failure outcome=%#v output=%q", outcome, output.String())
|
||||
}
|
||||
})
|
||||
|
||||
for _, operation := range []Operation{Show, Validate} {
|
||||
t.Run(string(operation)+" write failure", func(t *testing.T) {
|
||||
writeErr := errors.New("report destination failed")
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app",
|
||||
Operations: []Operation{operation},
|
||||
Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{Value: View{Effective: effectiveValue("value: ready\n")}}, nil
|
||||
},
|
||||
}}}))
|
||||
commandName := "show"
|
||||
if operation == Validate {
|
||||
commandName = "validate"
|
||||
}
|
||||
outcome := app.Execute(context.Background(), []string{"config", commandName}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Cause != nil {
|
||||
t.Fatalf("report write failure outcome = %#v", outcome)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(string(operation)+" quiet suppression", func(t *testing.T) {
|
||||
writeErr := errors.New("quiet destination should not be used")
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app",
|
||||
Operations: []Operation{operation},
|
||||
Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{Value: View{Effective: effectiveValue("value: ready\n")}}, nil
|
||||
},
|
||||
}}}))
|
||||
commandName := "show"
|
||||
if operation == Validate {
|
||||
commandName = "validate"
|
||||
}
|
||||
outcome := app.Execute(context.Background(), []string{"config", commandName, "--quiet"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Presentation.Output.State != commandkit.DeliverySuppressed || outcome.Presentation.Output.WriteError != nil || outcome.Cause != nil {
|
||||
t.Fatalf("quiet report outcome = %#v", outcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesReadsNativeInventoryOnceAndPreservesZeroValues(t *testing.T) {
|
||||
reader := &sourceInventory{values: []configkit.SourceInfo{{
|
||||
ID: "synthetic", Label: "", Path: "", Layer: configkit.Layer("base"), Kind: configkit.SourceKind("mapping"), UnderlayIndex: 0,
|
||||
@@ -324,13 +472,28 @@ func TestExplainParsesBeforeAcquisitionAndPreservesNativeSafeHistory(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportWriteFailureTracksIncompleteDeliveryAndCancellation(t *testing.T) {
|
||||
func TestExplainTracksNativeRenderFailureWithoutReplacingOperationCause(t *testing.T) {
|
||||
renderErr := errors.New("native explanation renderer failed")
|
||||
reader := &failingNativeExplanation{err: renderErr}
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{Value: View{Explanation: reader}}, nil
|
||||
},
|
||||
}}}))
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "explain", "--path", "/token"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.RenderError, renderErr) || output.String() != "partial native explanation\n" {
|
||||
t.Fatalf("native explanation failure outcome/output = %#v / %q", outcome, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportWriteFailureIsRecordedByReporterAndPreservesCancellation(t *testing.T) {
|
||||
writeErr := errors.New("output failed")
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Sources}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{Value: View{Sources: &sourceInventory{values: []configkit.SourceInfo{}}}}, nil
|
||||
}}}}))
|
||||
outcome := app.Execute(context.Background(), []string{"config", "sources"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
|
||||
if !errors.Is(outcome.Cause, writeErr) || outcome.DeliveryError != writeErr || !outcome.ReportIncomplete {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete {
|
||||
t.Fatalf("source delivery failure outcome = %#v", outcome)
|
||||
}
|
||||
|
||||
@@ -349,7 +512,7 @@ func TestReportWriteFailureTracksIncompleteDeliveryAndCancellation(t *testing.T)
|
||||
}}))
|
||||
var output bytes.Buffer
|
||||
outcome = app.Execute(ctx, []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if !errors.Is(outcome.Cause, context.Canceled) || !outcome.ReportIncomplete || calls != 2 || output.String() != "app: valid\n" {
|
||||
if !errors.Is(outcome.Cause, context.Canceled) || outcome.Presentation.Output.State != commandkit.DeliveryComplete || calls != 2 || output.String() != "app: valid\n" {
|
||||
t.Fatalf("cancelled aggregate validation outcome=%#v calls=%d output=%q", outcome, calls, output.String())
|
||||
}
|
||||
}
|
||||
@@ -361,6 +524,12 @@ func (value effectiveValue) WriteEffective(writer io.Writer) error {
|
||||
return err
|
||||
}
|
||||
|
||||
type failedEffective struct{ err error }
|
||||
|
||||
func (value failedEffective) WriteEffective(io.Writer) error {
|
||||
return value.err
|
||||
}
|
||||
|
||||
type sourceInventory struct {
|
||||
values []configkit.SourceInfo
|
||||
calls int
|
||||
@@ -377,6 +546,19 @@ type nativeExplanation struct {
|
||||
found bool
|
||||
}
|
||||
|
||||
type failingNativeExplanation struct{ err error }
|
||||
|
||||
func (reader *failingNativeExplanation) Explain(configkit.Path) (configkit.Explanation, bool) {
|
||||
return configkit.Explanation{}, true
|
||||
}
|
||||
|
||||
func (reader *failingNativeExplanation) WriteExplanation(writer io.Writer, _ configkit.Path) error {
|
||||
if _, err := io.WriteString(writer, "partial native explanation\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
return reader.err
|
||||
}
|
||||
|
||||
func (reader *nativeExplanation) Explain(path configkit.Path) (configkit.Explanation, bool) {
|
||||
reader.path = path
|
||||
return reader.value, reader.found
|
||||
|
||||
@@ -45,27 +45,43 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
|
||||
if operation == Show || operation == Diff {
|
||||
defaultFormat = "yaml"
|
||||
}
|
||||
formats := completionChoices("text", "yaml")
|
||||
if operation == Show || operation == Diff {
|
||||
formats = completionChoices("yaml")
|
||||
}
|
||||
var scopeChoices []commandkit.CompletionChoice
|
||||
allSupported := len(prepared.scopes) > 0
|
||||
for _, scope := range prepared.scopes {
|
||||
if containsOperation(scope.scope.Operations, operation) {
|
||||
scopeChoices = append(scopeChoices, commandkit.CompletionChoice{Value: scope.scope.Name})
|
||||
} else {
|
||||
allSupported = false
|
||||
}
|
||||
}
|
||||
if allSupported && (operation == Validate || operation == Show) {
|
||||
scopeChoices = append(scopeChoices, commandkit.CompletionChoice{Value: "all"})
|
||||
}
|
||||
flags := []commandkit.Flag[*configCommandInput[I, S]]{
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported."}, &defaultScope, parseConfigString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported.", Completion: commandkit.CompleteChoices(scopeChoices...)}, &defaultScope, parseConfigString,
|
||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).scope = value }),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Output format."}, &defaultFormat, parseConfigString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Output format.", Completion: formats}, &defaultFormat, parseConfigString,
|
||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).format = value }),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration source."}, (*string)(nil), parseConfigString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||
(*input).selection.Config = value
|
||||
}),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Overlay source."}, (*string)(nil), parseConfigString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Overlay source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||
(*input).selection.Overlay = value
|
||||
}),
|
||||
}
|
||||
if operation == Diff {
|
||||
flags = append(flags,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-config", Usage: "Configuration source for the right side."}, (*string)(nil), parseConfigString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-config", Usage: "Configuration source for the right side.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||
(*input).against.Config = value
|
||||
}),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-overlay", Usage: "Overlay source for the right side."}, (*string)(nil), parseConfigString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-overlay", Usage: "Overlay source for the right side.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||
(*input).against.Overlay = value
|
||||
}),
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
)
|
||||
|
||||
func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Operation, input *configCommandInput[I, S]) commandkit.Outcome {
|
||||
if input == nil || input.plan == nil {
|
||||
return commandkit.Failure(errors.New("configuration operation was not prepared"))
|
||||
return commandkit.Failure("Configuration operation was not prepared.")
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return commandkit.Failure(err)
|
||||
return configFailure("Configuration operation was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
plan := input.plan
|
||||
var state S
|
||||
@@ -31,19 +31,70 @@ func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Op
|
||||
case Diff:
|
||||
return runConfigDiff(invocation, plan, state)
|
||||
default:
|
||||
return commandkit.Failure(fmt.Errorf("unsupported configuration operation %q", operation))
|
||||
return commandkit.Failure(fmt.Sprintf("Unsupported configuration operation %q.", operation))
|
||||
}
|
||||
}
|
||||
|
||||
func configOperationFailure(cause error, diagnostics []commandkit.Diagnostic, reportStarted bool) commandkit.Outcome {
|
||||
outcome := commandkit.Failure(cause)
|
||||
outcome.Diagnostics = diagnostics
|
||||
outcome.ReportIncomplete = reportStarted
|
||||
func queueConfigDiagnostics(reporter *commandkit.Reporter, diagnostics []commandkit.Diagnostic) {
|
||||
deferred := reporter.AtEnd()
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.Write == nil {
|
||||
continue
|
||||
}
|
||||
switch diagnostic.Severity {
|
||||
case commandkit.SeverityInfo:
|
||||
_ = deferred.Info("", diagnostic.Write)
|
||||
case commandkit.SeverityDebug:
|
||||
_ = deferred.Debug("", diagnostic.Write)
|
||||
case commandkit.SeverityProgress:
|
||||
_ = reporter.Progress("", diagnostic.Write)
|
||||
default:
|
||||
_ = deferred.Warn("", diagnostic.Write)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func configFailure(message string, explanation commandkit.Explanation, cause error) commandkit.Outcome {
|
||||
if explanation.Message != "" || len(explanation.Details) > 0 {
|
||||
message = explanation.Message
|
||||
}
|
||||
outcome := commandkit.Failure(message, explanation.Details...)
|
||||
if cause != nil {
|
||||
outcome = outcome.WithCause(cause)
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
func configDeliveryFailure(cause error, diagnostics []commandkit.Diagnostic, reportStarted bool) commandkit.Outcome {
|
||||
outcome := configOperationFailure(cause, diagnostics, reportStarted)
|
||||
outcome.DeliveryError = cause
|
||||
return outcome
|
||||
func configAcquisitionFailure(
|
||||
reporter *commandkit.Reporter,
|
||||
operation, scope string,
|
||||
acquired commandkit.Acquired[View],
|
||||
cause error,
|
||||
) commandkit.Outcome {
|
||||
queueConfigDiagnostics(reporter, acquired.Diagnostics)
|
||||
message := fmt.Sprintf("Unable to %s configuration scope %q.", operation, scope)
|
||||
return configFailure(message, acquired.Explanation, cause)
|
||||
}
|
||||
|
||||
// Preserve the preparation/delivery cancellation checkpoint for buffered reports.
|
||||
func renderPreparedConfigReport(invocation commandkit.Invocation, explanation commandkit.Explanation, prepare func() ([]byte, error)) commandkit.Outcome {
|
||||
var canceled error
|
||||
renderErr := invocation.Reporter().RenderOutput(func(writer io.Writer) error {
|
||||
report, err := prepare()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if canceled = invocation.Context.Err(); canceled != nil {
|
||||
return nil
|
||||
}
|
||||
_, err = writer.Write(report)
|
||||
return err
|
||||
})
|
||||
if canceled == nil && renderErr != nil {
|
||||
canceled = invocation.Context.Err()
|
||||
}
|
||||
if canceled != nil {
|
||||
return configFailure("Configuration report preparation was canceled.", explanation, canceled)
|
||||
}
|
||||
return commandkit.Success()
|
||||
}
|
||||
|
||||
@@ -2,12 +2,26 @@
|
||||
"product": "CommandKit ckconfig",
|
||||
"module_dir": "integrations/ckconfig",
|
||||
"remote": "origin",
|
||||
"expected_push_url": "https://gitea.maximumdirect.net/eric/CommandKit.git",
|
||||
"expected_push_url": "ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git",
|
||||
"validation": {
|
||||
"shared_files": [
|
||||
"COPYRIGHT",
|
||||
"LICENSE",
|
||||
"docs/oss/attribution.md"
|
||||
]
|
||||
],
|
||||
"go_format": true,
|
||||
"markdown": {},
|
||||
"module_distribution": {
|
||||
"workspace_files": true,
|
||||
"local_replacements": true
|
||||
},
|
||||
"forbidden_files": [".releasekit/gitea-token"]
|
||||
},
|
||||
"delivery": {
|
||||
"forge": "gitea",
|
||||
"base_url": "https://forge.maximumdirect.net",
|
||||
"owner": "base-layer",
|
||||
"repository": "commandkit",
|
||||
"token_file": ".releasekit/gitea-token"
|
||||
}
|
||||
}
|
||||
|
||||
69
integrations/ckconfig/report_cancellation_test.go
Normal file
69
integrations/ckconfig/report_cancellation_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
)
|
||||
|
||||
func TestBufferedReportCancellationBeforeDelivery(t *testing.T) {
|
||||
for _, quiet := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "ordinary", true: "quiet"}[quiet], func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
prepared := false
|
||||
app := newConfigTestApp(t, commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "report",
|
||||
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
|
||||
return renderPreparedConfigReport(invocation, commandkit.Explanation{Message: "selected context"}, func() ([]byte, error) {
|
||||
prepared = true
|
||||
cancel()
|
||||
return []byte("prepared report"), nil
|
||||
})
|
||||
},
|
||||
}))
|
||||
args := []string{"report"}
|
||||
if quiet {
|
||||
args = append(args, "--quiet")
|
||||
}
|
||||
writes := 0
|
||||
got := app.Execute(ctx, args, commandkit.Streams{Out: reportWriteFunc(func(p []byte) (int, error) {
|
||||
writes++
|
||||
return len(p), nil
|
||||
})})
|
||||
if !prepared || writes != 0 || got.Status != 1 || !errors.Is(got.Cause, context.Canceled) || got.Explanation.Message != "selected context" {
|
||||
t.Fatalf("prepared=%t writes=%d outcome=%#v", prepared, writes, got)
|
||||
}
|
||||
if got.Presentation.Output.RenderError != nil || got.Presentation.Output.WriteError != nil {
|
||||
t.Fatalf("cancellation became a presentation failure: %#v", got.Presentation.Output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type reportWriteFunc func([]byte) (int, error)
|
||||
|
||||
func (write reportWriteFunc) Write(p []byte) (int, error) { return write(p) }
|
||||
|
||||
func TestBufferedReportDoesNotRejectCancellationAfterDeliveryStarts(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
app := newConfigTestApp(t, commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "report",
|
||||
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
|
||||
return renderPreparedConfigReport(invocation, commandkit.Explanation{}, func() ([]byte, error) {
|
||||
return []byte("report"), nil
|
||||
})
|
||||
},
|
||||
}))
|
||||
got := app.Execute(ctx, []string{"report"}, commandkit.Streams{Out: reportWriteFunc(func(p []byte) (int, error) {
|
||||
cancel()
|
||||
return io.Discard.Write(p)
|
||||
})})
|
||||
if got.Status != 0 || got.Cause != nil {
|
||||
t.Fatalf("post-delivery cancellation changed the operation outcome: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
@@ -18,101 +17,76 @@ type effectiveDocument struct {
|
||||
|
||||
func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
||||
documents := make([]effectiveDocument, 0, len(plan.scopes))
|
||||
diagnostics := make([]commandkit.Diagnostic, 0)
|
||||
reporter := invocation.Reporter()
|
||||
for _, selected := range plan.scopes {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
if selected.scope.scope.Acquire == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", selected.scope.scope.Name), diagnostics, false)
|
||||
message := fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name)
|
||||
return commandkit.Failure(message)
|
||||
}
|
||||
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
|
||||
Operation: Show,
|
||||
Selection: plan.selection,
|
||||
}, selected.input, state)
|
||||
diagnostics = append(diagnostics, acquired.Diagnostics...)
|
||||
if acquireErr != nil {
|
||||
return configOperationFailure(acquireErr, diagnostics, false)
|
||||
return configAcquisitionFailure(reporter, "show", selected.scope.scope.Name, acquired, acquireErr)
|
||||
}
|
||||
queueConfigDiagnostics(reporter, acquired.Diagnostics)
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return configFailure("Configuration show was canceled.", acquired.Explanation, err)
|
||||
}
|
||||
if acquired.Value.Effective == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q did not provide an effective configuration writer", selected.scope.scope.Name), diagnostics, false)
|
||||
message := fmt.Sprintf("Configuration scope %q did not provide an effective configuration writer.", selected.scope.scope.Name)
|
||||
return commandkit.Failure(message)
|
||||
}
|
||||
documents = append(documents, effectiveDocument{name: selected.scope.scope.Name, view: acquired.Value})
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
if len(documents) == 1 && plan.scopeName != "all" {
|
||||
checked := &checkedWriter{writer: invocation.Streams.Out}
|
||||
renderErr := documents[0].view.Effective.WriteEffective(checked)
|
||||
if checked.failure != nil {
|
||||
if renderErr == nil {
|
||||
renderErr = checked.failure
|
||||
} else {
|
||||
renderErr = errors.Join(renderErr, checked.failure)
|
||||
err := reporter.RenderOutput(func(writer io.Writer) error {
|
||||
return documents[0].view.Effective.WriteEffective(writer)
|
||||
})
|
||||
if err != nil && invocation.Context.Err() != nil {
|
||||
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err())
|
||||
}
|
||||
return commandkit.Success()
|
||||
}
|
||||
|
||||
err := reporter.RenderOutput(func(writer io.Writer) error {
|
||||
for index, document := range documents {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
var native bytes.Buffer
|
||||
if err := document.view.Effective.WriteEffective(&native); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
framed, hasDirective := frameEffectiveYAML(document.name, native.Bytes())
|
||||
if index > 0 && hasDirective {
|
||||
if _, err := io.WriteString(writer, "...\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if index < len(documents)-1 && len(framed) > 0 && framed[len(framed)-1] != '\n' {
|
||||
framed = append(framed, '\n')
|
||||
}
|
||||
if _, err := writer.Write(framed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if renderErr != nil {
|
||||
outcome := configOperationFailure(renderErr, diagnostics, true)
|
||||
outcome.DeliveryError = checked.failure
|
||||
return outcome
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
return nil
|
||||
})
|
||||
if err != nil && invocation.Context.Err() != nil {
|
||||
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err())
|
||||
}
|
||||
|
||||
written := false
|
||||
for index, document := range documents {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, written)
|
||||
}
|
||||
var native bytes.Buffer
|
||||
if err := document.view.Effective.WriteEffective(&native); err != nil {
|
||||
return configOperationFailure(err, diagnostics, written)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, written)
|
||||
}
|
||||
framed, hasDirective := frameEffectiveYAML(document.name, native.Bytes())
|
||||
if index > 0 && hasDirective {
|
||||
if err := writeConfigReport(invocation.Streams.Out, []byte("...\n")); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
}
|
||||
}
|
||||
if index < len(documents)-1 && len(framed) > 0 && framed[len(framed)-1] != '\n' {
|
||||
framed = append(framed, '\n')
|
||||
}
|
||||
if err := writeConfigReport(invocation.Streams.Out, framed); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
}
|
||||
written = true
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
}
|
||||
|
||||
type checkedWriter struct {
|
||||
writer io.Writer
|
||||
failure error
|
||||
}
|
||||
|
||||
func (writer *checkedWriter) Write(content []byte) (int, error) {
|
||||
if writer.failure != nil {
|
||||
return 0, writer.failure
|
||||
}
|
||||
written, err := writer.writer.Write(content)
|
||||
if err == nil && written != len(content) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
if err != nil {
|
||||
writer.failure = err
|
||||
}
|
||||
return written, err
|
||||
return commandkit.Success()
|
||||
}
|
||||
|
||||
type yamlPreamble struct {
|
||||
|
||||
@@ -52,25 +52,28 @@ type versionDTO struct {
|
||||
func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
||||
selected := plan.scopes[0]
|
||||
name := selected.scope.scope.Name
|
||||
reporter := invocation.Reporter()
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, nil, false)
|
||||
return configFailure("Configuration source listing was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
if selected.scope.scope.Acquire == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), nil, false)
|
||||
cause := fmt.Errorf("configuration scope %q has no acquisition callback", name)
|
||||
return configFailure(fmt.Sprintf("Unable to list sources for configuration scope %q.", name), commandkit.Explanation{}, cause)
|
||||
}
|
||||
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
|
||||
Operation: Sources,
|
||||
Selection: plan.selection,
|
||||
}, selected.input, state)
|
||||
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
|
||||
queueConfigDiagnostics(reporter, acquired.Diagnostics)
|
||||
if acquireErr != nil {
|
||||
return configOperationFailure(acquireErr, diagnostics, false)
|
||||
return configFailure(fmt.Sprintf("Unable to list sources for configuration scope %q.", name), acquired.Explanation, acquireErr)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return configFailure("Configuration source listing was canceled.", acquired.Explanation, err)
|
||||
}
|
||||
if acquired.Value.Sources == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q did not provide a source reader", name), diagnostics, false)
|
||||
cause := fmt.Errorf("configuration scope %q did not provide a source reader", name)
|
||||
return configFailure("Configuration scope did not provide source information.", acquired.Explanation, cause)
|
||||
}
|
||||
|
||||
sources := acquired.Value.Sources.Sources()
|
||||
@@ -111,23 +114,20 @@ func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOp
|
||||
}
|
||||
document.Sources = append(document.Sources, item)
|
||||
}
|
||||
var report bytes.Buffer
|
||||
if plan.format == "yaml" {
|
||||
if err := encodeYAML(&report, document); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
} else {
|
||||
writeSourceReportText(&report, document)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return configFailure("Configuration source listing was canceled.", acquired.Explanation, err)
|
||||
}
|
||||
if err := writeConfigReport(invocation.Streams.Out, report.Bytes()); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||
var report bytes.Buffer
|
||||
if plan.format == "yaml" {
|
||||
if err := encodeYAML(&report, document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
writeSourceReportText(&report, document)
|
||||
}
|
||||
return report.Bytes(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func writeSourceReportText(writer *bytes.Buffer, document sourceReport) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package ckconfig
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
@@ -10,44 +11,50 @@ import (
|
||||
)
|
||||
|
||||
func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
||||
diagnostics := make([]commandkit.Diagnostic, 0)
|
||||
reporter := invocation.Reporter()
|
||||
writtenRecord := false
|
||||
for _, selected := range plan.scopes {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, writtenRecord)
|
||||
return configFailure("Configuration validation was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
if selected.scope.scope.Acquire == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", selected.scope.scope.Name), diagnostics, writtenRecord)
|
||||
message := fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name)
|
||||
return commandkit.Failure(message)
|
||||
}
|
||||
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
|
||||
Operation: Validate,
|
||||
Selection: plan.selection,
|
||||
}, selected.input, state)
|
||||
diagnostics = append(diagnostics, acquired.Diagnostics...)
|
||||
if acquireErr != nil {
|
||||
return configOperationFailure(acquireErr, diagnostics, writtenRecord)
|
||||
return configAcquisitionFailure(reporter, "validate", selected.scope.scope.Name, acquired, acquireErr)
|
||||
}
|
||||
queueConfigDiagnostics(reporter, acquired.Diagnostics)
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, writtenRecord)
|
||||
return configFailure("Configuration validation was canceled.", acquired.Explanation, err)
|
||||
}
|
||||
|
||||
record, err := validationSuccessRecord(selected.scope.scope.Name, plan.format)
|
||||
if err != nil {
|
||||
return configOperationFailure(err, diagnostics, writtenRecord)
|
||||
}
|
||||
if plan.format == "yaml" && !writtenRecord {
|
||||
if err := writeConfigReport(invocation.Streams.Out, []byte("scopes:\n")); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
err := reporter.RenderOutput(func(writer io.Writer) error {
|
||||
record, err := validationSuccessRecord(selected.scope.scope.Name, plan.format)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writeConfigReport(invocation.Streams.Out, record); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
if plan.format == "yaml" && !writtenRecord {
|
||||
if _, err := io.WriteString(writer, "scopes:\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = writer.Write(record)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
if ctxErr := invocation.Context.Err(); ctxErr != nil {
|
||||
return configFailure("Configuration validation was canceled.", acquired.Explanation, ctxErr)
|
||||
}
|
||||
return commandkit.Success()
|
||||
}
|
||||
writtenRecord = true
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
return commandkit.Success()
|
||||
}
|
||||
|
||||
func validationSuccessRecord(scope, format string) ([]byte, error) {
|
||||
|
||||
@@ -88,26 +88,33 @@ type Suite[I, S any] struct {
|
||||
// CheckInput validates selected inspection, requirements, validation, smoke,
|
||||
// or catalog input before state creation.
|
||||
CheckInput func(ckconfig.Selection, I) error
|
||||
// InspectPrompt inspects the selected prompt after input and state preparation.
|
||||
// InspectPrompt inspects the selected prompt after input and state
|
||||
// preparation. Its acquisition carries independent loader diagnostics and
|
||||
// terminal context alongside any native inspection result.
|
||||
InspectPrompt func(context.Context, PromptRequest, I, S) (commandkit.Acquired[*promptkit.PromptInspection], error)
|
||||
// InspectProfile inspects the selected profile after input and state preparation.
|
||||
// InspectProfile inspects the selected profile after input and state
|
||||
// preparation. Its acquisition carries independent loader diagnostics and
|
||||
// terminal context alongside any native inspection result.
|
||||
InspectProfile func(context.Context, ProfileRequest, I, S) (commandkit.Acquired[*promptkit.ProfileInspection], error)
|
||||
// InspectRequirements inspects native PromptKit requirements after input and
|
||||
// state preparation. It is optional; a nil callback omits
|
||||
// `promptkit requirements show` without disabling validation or other
|
||||
// capabilities.
|
||||
// state preparation. Its acquisition carries independent loader diagnostics
|
||||
// and terminal context alongside any native inspection result. It is
|
||||
// optional; a nil callback omits `promptkit requirements show` without
|
||||
// disabling validation or other capabilities.
|
||||
InspectRequirements func(context.Context, RequirementsRequest, I, S) (commandkit.Acquired[*promptkit.RequirementInspection], error)
|
||||
// ValidateRequest validates a concrete PromptKit request after input and
|
||||
// state preparation. It is optional; a nil callback omits
|
||||
// `promptkit requirements validate` without disabling discovery or other
|
||||
// capabilities. The acquisition carries diagnostics and an empty value on
|
||||
// success; PromptKit errors are returned unchanged where supported.
|
||||
// capabilities. The acquisition carries independent diagnostics, terminal
|
||||
// context, and an empty value on success; PromptKit errors are returned
|
||||
// unchanged where supported.
|
||||
ValidateRequest func(context.Context, ValidationRequest, I, S) (commandkit.Acquired[struct{}], error)
|
||||
// SmokeTest runs one concrete native smoke-test operation after input and
|
||||
// state preparation. It is optional; nil omits only the smoke-test command.
|
||||
// The acquisition retains a native report even when the callback also
|
||||
// returns an error. For `test all`, CommandKit calls the captured callback
|
||||
// sequentially for each selected kind after preparing input and state once.
|
||||
// The acquisition retains a native report, independent diagnostics, and
|
||||
// terminal context even when the callback also returns an error. For
|
||||
// `test all`, CommandKit calls the captured callback sequentially for each
|
||||
// selected kind after preparing input and state once.
|
||||
SmokeTest func(context.Context, SmokeRequest, I, S) (commandkit.Acquired[*promptkit.SmokeTestReport], error)
|
||||
// ListPrompts lists prompt identities after input and state preparation.
|
||||
// It is optional independently of the other catalogs; nil omits only this
|
||||
@@ -142,10 +149,12 @@ type Suite[I, S any] struct {
|
||||
// PromptKit unchanged; the loader receives the seed's original configuration
|
||||
// selection. A nil builder forwards the seed request unchanged. A builder
|
||||
// failure prevents engine loading and preserves its supported error identity.
|
||||
// Native validation errors are returned with supported identity, and
|
||||
// configuration diagnostics from engine loading remain in the acquisition. A
|
||||
// nil loader is retained as a suite wiring issue and does not install a
|
||||
// validation callback. Callback references and objects remain caller-owned.
|
||||
// Native validation errors supply terminal explanations and are returned with
|
||||
// supported identity, and
|
||||
// configuration diagnostics and terminal context from engine loading remain
|
||||
// separate in the acquisition. A nil loader is retained as a suite wiring
|
||||
// issue and does not install a validation callback. Callback references and
|
||||
// objects remain caller-owned.
|
||||
func WithRequestValidation[I, S any](
|
||||
suite Suite[I, S],
|
||||
load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error),
|
||||
@@ -175,34 +184,56 @@ func WithRequestValidation[I, S any](
|
||||
return commandkit.Acquired[struct{}]{}, fmt.Errorf("PromptKit request validation after building: %w", err)
|
||||
}
|
||||
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
if err := promptKitContextError(ctx); err != nil {
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit request validation before native validation: %w", err)
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit request validation before native validation: %w", err)
|
||||
}
|
||||
|
||||
if err := engine.ValidateRequest(ctx, concreteRequest); err != nil {
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
if err := promptKitContextError(ctx); err != nil {
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit request validation after native validation: %w", err)
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit request validation after native validation: %w", err)
|
||||
}
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, nil
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, nil
|
||||
}
|
||||
return suite
|
||||
}
|
||||
|
||||
type engineLoader[I, S any] func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)
|
||||
|
||||
type promptKitAcquisition struct {
|
||||
diagnostics []commandkit.Diagnostic
|
||||
explanation commandkit.Explanation
|
||||
}
|
||||
|
||||
// withOperationError deliberately presents a native PromptKit operation error.
|
||||
// Loader and request-builder errors remain application-owned. Native structured
|
||||
// context takes precedence when an acquisition already supplies it.
|
||||
func (messages promptKitAcquisition) withOperationError(err error) promptKitAcquisition {
|
||||
if err == nil || messages.explanation.Message != "" {
|
||||
return messages
|
||||
}
|
||||
for _, detail := range messages.explanation.Details {
|
||||
if detail != nil {
|
||||
return messages
|
||||
}
|
||||
}
|
||||
messages.explanation.Message = err.Error()
|
||||
return messages
|
||||
}
|
||||
|
||||
// ConfigScope adapts PromptKit's captured configuration inspection to the
|
||||
// standard configuration commands, including native comparison when the scope
|
||||
// declares Diff. The loader is not called during declaration construction or
|
||||
// installation; it is called only when a config operation selects the
|
||||
// promptkit scope, with that operation's input and shared state. PromptKit
|
||||
// diagnostics and supported error identity are returned through ckconfig's
|
||||
// acquisition and comparison contracts.
|
||||
// independent diagnostics, terminal context, and supported error identity are
|
||||
// returned through ckconfig's acquisition and comparison contracts.
|
||||
func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)) ckconfig.Scope[I, S] {
|
||||
scope := ckconfig.Scope[I, S]{
|
||||
Name: "promptkit",
|
||||
@@ -214,9 +245,9 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S)
|
||||
}
|
||||
|
||||
scope.Acquire = func(ctx context.Context, request ckconfig.Request, input I, state S) (commandkit.Acquired[ckconfig.View], error) {
|
||||
_, inspection, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
_, inspection, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[ckconfig.View]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[ckconfig.View]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
return commandkit.Acquired[ckconfig.View]{
|
||||
Value: ckconfig.View{
|
||||
@@ -224,7 +255,8 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S)
|
||||
Sources: inspection,
|
||||
Explanation: inspection,
|
||||
},
|
||||
Diagnostics: diagnostics,
|
||||
Diagnostics: messages.diagnostics,
|
||||
Explanation: messages.explanation,
|
||||
}, nil
|
||||
}
|
||||
scope.Compare = func(ctx context.Context, request ckconfig.ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
@@ -232,31 +264,31 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S)
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("PromptKit scope %q comparison before left load: %w", scope.Name, err)
|
||||
}
|
||||
|
||||
_, leftInspection, leftDiagnostics, err := loadPromptKitEngine(ctx, request.Left.Selection, request.Left.Input, state, "left", load)
|
||||
diagnostics := append([]commandkit.Diagnostic(nil), leftDiagnostics...)
|
||||
_, leftInspection, messages, err := loadPromptKitEngine(ctx, request.Left.Selection, request.Left.Input, state, "left", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q left comparison load: %w", scope.Name, err)
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q left comparison load: %w", scope.Name, err)
|
||||
}
|
||||
|
||||
if err := promptKitContextError(ctx); err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q comparison after left load: %w", scope.Name, err)
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q comparison after left load: %w", scope.Name, err)
|
||||
}
|
||||
|
||||
_, rightInspection, rightDiagnostics, err := loadPromptKitEngine(ctx, request.Right.Selection, request.Right.Input, state, "right", load)
|
||||
diagnostics = append(diagnostics, rightDiagnostics...)
|
||||
_, rightInspection, rightMessages, err := loadPromptKitEngine(ctx, request.Right.Selection, request.Right.Input, state, "right", load)
|
||||
messages = appendPromptKitAcquisition(messages, rightMessages)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q right comparison load: %w", scope.Name, err)
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q right comparison load: %w", scope.Name, err)
|
||||
}
|
||||
|
||||
if err := promptKitContextError(ctx); err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q comparison before native diff: %w", scope.Name, err)
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q comparison before native diff: %w", scope.Name, err)
|
||||
}
|
||||
|
||||
differences, err := leftInspection.Diff(rightInspection)
|
||||
messages = messages.withOperationError(err)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q native configuration diff: %w", scope.Name, err)
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q native configuration diff: %w", scope.Name, err)
|
||||
}
|
||||
return commandkit.Acquired[[]configkit.Difference]{Value: differences, Diagnostics: diagnostics}, nil
|
||||
return commandkit.Acquired[[]configkit.Difference]{Value: differences, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, nil
|
||||
}
|
||||
return scope
|
||||
}
|
||||
@@ -280,8 +312,9 @@ func promptKitContextError(ctx context.Context) error {
|
||||
// shared invocation state to the loader before calling PromptKit's public
|
||||
// listing or inspection method. Applications can narrow, wrap, or replace these
|
||||
// callbacks before Command captures them. A nil loader is retained as an
|
||||
// adapter issue. Each callback returns loader diagnostics with the native
|
||||
// operation's value and error.
|
||||
// adapter issue. Each callback returns independent loader diagnostics and
|
||||
// terminal context alongside the native operation's value and error. Native
|
||||
// operation errors supply terminal explanations when structured context is absent.
|
||||
func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)) Suite[I, S] {
|
||||
if load == nil {
|
||||
return Suite[I, S]{issues: []error{errors.New("PromptKit engine loader is nil")}}
|
||||
@@ -289,68 +322,76 @@ func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) (
|
||||
|
||||
return Suite[I, S]{
|
||||
ListPrompts: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]promptkit.PromptIdentity], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
prompts, err := engine.ListPrompts(ctx, request.Filter)
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: prompts, Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: prompts, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
},
|
||||
ListProfiles: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]string], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]string]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[[]string]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
profiles, err := engine.ListProfiles(ctx, request.Filter)
|
||||
return commandkit.Acquired[[]string]{Value: profiles, Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[[]string]{Value: profiles, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
},
|
||||
ListBackends: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]string], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]string]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[[]string]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
backends, err := engine.ListBackends(ctx, request.Filter)
|
||||
return commandkit.Acquired[[]string]{Value: backends, Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[[]string]{Value: backends, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
},
|
||||
ListNamespaces: func(ctx context.Context, request NamespaceRequest, input I, state S) (commandkit.Acquired[[]string], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]string]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[[]string]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
namespaces, err := engine.ListNamespaces(ctx, request.Kinds...)
|
||||
return commandkit.Acquired[[]string]{Value: namespaces, Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[[]string]{Value: namespaces, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
},
|
||||
ListCollections: func(ctx context.Context, selection ckconfig.Selection, input I, state S) (commandkit.Acquired[[]promptkit.CollectionInfo], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]promptkit.CollectionInfo]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[[]promptkit.CollectionInfo]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
collections, err := engine.ListCollections(ctx)
|
||||
return commandkit.Acquired[[]promptkit.CollectionInfo]{Value: collections, Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[[]promptkit.CollectionInfo]{Value: collections, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
},
|
||||
InspectPrompt: func(ctx context.Context, request PromptRequest, input I, state S) (commandkit.Acquired[*promptkit.PromptInspection], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
inspection, err := engine.InspectPrompt(ctx, request.ID)
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{Value: inspection, Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{Value: inspection, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
},
|
||||
InspectProfile: func(ctx context.Context, request ProfileRequest, input I, state S) (commandkit.Acquired[*promptkit.ProfileInspection], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[*promptkit.ProfileInspection]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[*promptkit.ProfileInspection]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
inspection, err := engine.InspectProfile(ctx, request.ID)
|
||||
return commandkit.Acquired[*promptkit.ProfileInspection]{Value: inspection, Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[*promptkit.ProfileInspection]{Value: inspection, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
},
|
||||
InspectRequirements: func(ctx context.Context, request RequirementsRequest, input I, state S) (commandkit.Acquired[*promptkit.RequirementInspection], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[*promptkit.RequirementInspection]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[*promptkit.RequirementInspection]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
inspection, err := engine.InspectRequirements(ctx, request.Inspection)
|
||||
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: inspection, Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: inspection, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -362,41 +403,45 @@ func loadPromptKitEngine[I, S any](
|
||||
state S,
|
||||
side string,
|
||||
load engineLoader[I, S],
|
||||
) (*promptkit.Engine, promptkit.ConfigurationInspection, []commandkit.Diagnostic, error) {
|
||||
) (*promptkit.Engine, promptkit.ConfigurationInspection, promptKitAcquisition, error) {
|
||||
engine, err := load(ctx, selection, input, state)
|
||||
if err != nil {
|
||||
var loadErr *configkit.LoadError
|
||||
if errors.As(err, &loadErr) {
|
||||
return nil, promptkit.ConfigurationInspection{}, promptKitDiagnostics("promptkit", side, loadErr.Sources(), loadErr.Diagnostics()), err
|
||||
messages := promptKitDiagnostics("promptkit", side, loadErr.Sources(), loadErr.Diagnostics())
|
||||
return nil, promptkit.ConfigurationInspection{}, messages, err
|
||||
}
|
||||
return nil, promptkit.ConfigurationInspection{}, nil, err
|
||||
return nil, promptkit.ConfigurationInspection{}, promptKitAcquisition{}, err
|
||||
}
|
||||
if engine == nil {
|
||||
return nil, promptkit.ConfigurationInspection{}, nil, errors.New("PromptKit configuration loader returned a nil engine")
|
||||
return nil, promptkit.ConfigurationInspection{}, promptKitAcquisition{}, errors.New("PromptKit configuration loader returned a nil engine")
|
||||
}
|
||||
inspection, err := engine.InspectConfiguration()
|
||||
messages := promptKitDiagnostics("promptkit", side, inspection.Sources(), inspection.Diagnostics())
|
||||
messages = messages.withOperationError(err)
|
||||
if err != nil {
|
||||
return nil, promptkit.ConfigurationInspection{}, nil, err
|
||||
return nil, inspection, messages, err
|
||||
}
|
||||
diagnostics := promptKitDiagnostics("promptkit", side, inspection.Sources(), inspection.Diagnostics())
|
||||
if ctx != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, promptkit.ConfigurationInspection{}, diagnostics, err
|
||||
return nil, promptkit.ConfigurationInspection{}, messages, err
|
||||
}
|
||||
}
|
||||
return engine, inspection, diagnostics, nil
|
||||
return engine, inspection, messages, nil
|
||||
}
|
||||
|
||||
func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
func appendPromptKitAcquisition(left, right promptKitAcquisition) promptKitAcquisition {
|
||||
left.diagnostics = append(left.diagnostics, right.diagnostics...)
|
||||
left.explanation.Details = append(left.explanation.Details, right.explanation.Details...)
|
||||
if left.explanation.Message == "" {
|
||||
left.explanation.Message = right.explanation.Message
|
||||
}
|
||||
severity := commandkit.SeverityWarning
|
||||
for _, record := range records {
|
||||
if record.Severity == configkit.SeverityError {
|
||||
severity = commandkit.SeverityError
|
||||
break
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) promptKitAcquisition {
|
||||
if len(records) == 0 {
|
||||
return promptKitAcquisition{}
|
||||
}
|
||||
// Snapshot provider-owned slices before deferring their report to invocation.
|
||||
sources = append([]configkit.SourceInfo(nil), sources...)
|
||||
@@ -408,27 +453,48 @@ func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, re
|
||||
}
|
||||
records[index].Related = append([]configkit.Location(nil), records[index].Related...)
|
||||
}
|
||||
return []commandkit.Diagnostic{{
|
||||
Severity: severity,
|
||||
Write: func(writer io.Writer) error {
|
||||
heading := "Configuration scope " + strconv.Quote(scope)
|
||||
if side != "" {
|
||||
heading += " (" + side + ")"
|
||||
}
|
||||
if _, err := fmt.Fprintf(writer, "%s:\n", heading); err != nil {
|
||||
var warnings, failures []configkit.Diagnostic
|
||||
for _, record := range records {
|
||||
if record.Severity == configkit.SeverityError {
|
||||
failures = append(failures, record)
|
||||
} else {
|
||||
warnings = append(warnings, record)
|
||||
}
|
||||
}
|
||||
messages := promptKitAcquisition{}
|
||||
if len(warnings) > 0 {
|
||||
messages.diagnostics = []commandkit.Diagnostic{{
|
||||
Severity: commandkit.SeverityWarning,
|
||||
Write: func(writer io.Writer) error {
|
||||
return writePromptKitDiagnostics(writer, scope, side, sources, warnings)
|
||||
},
|
||||
}}
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
messages.explanation = commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
|
||||
return writePromptKitDiagnostics(writer, scope, side, sources, failures)
|
||||
}}}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func writePromptKitDiagnostics(writer io.Writer, scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) error {
|
||||
heading := "Configuration scope " + strconv.Quote(scope)
|
||||
if side != "" {
|
||||
heading += " (" + side + ")"
|
||||
}
|
||||
if _, err := fmt.Fprintf(writer, "%s:\n", heading); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
if _, err := io.WriteString(writer, "Source IDs:\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, source := range sources {
|
||||
if _, err := fmt.Fprintf(writer, " %s: label=%s, path=%s\n", strconv.Quote(source.ID), strconv.Quote(source.Label), strconv.Quote(source.Path)); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
if _, err := io.WriteString(writer, "Source IDs:\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, source := range sources {
|
||||
if _, err := fmt.Fprintf(writer, " %s: label=%s, path=%s\n", strconv.Quote(source.ID), strconv.Quote(source.Label), strconv.Quote(source.Path)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return configkit.WriteDiagnostics(writer, records)
|
||||
},
|
||||
}}
|
||||
}
|
||||
}
|
||||
return configkit.WriteDiagnostics(writer, records)
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestWithSmokeTestingRetainsNilLoaderIssueAndChecksCancellationBeforeBuild(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithSmokeTestingRetainsLoaderDiagnosticsAndErrorIdentity(t *testing.T) {
|
||||
func TestWithSmokeTestingRetainsLoaderExplanationAndErrorIdentity(t *testing.T) {
|
||||
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid smoke configuration", []byte("broken: [")))
|
||||
if loadErr == nil {
|
||||
t.Fatal("invalid configuration unexpectedly loaded")
|
||||
@@ -249,7 +249,7 @@ func TestWithSmokeTestingRetainsLoaderDiagnosticsAndErrorIdentity(t *testing.T)
|
||||
}, nil)
|
||||
acquired, err := suite.SmokeTest(context.Background(), SmokeRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || acquired.Value != nil || len(acquired.Diagnostics) == 0 {
|
||||
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || acquired.Value != nil || len(acquired.Explanation.Details) == 0 {
|
||||
t.Fatalf("smoke load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
}
|
||||
@@ -394,7 +394,7 @@ func TestConfigScopeCompareStopsAfterLeftLoadFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigScopeCompareRetainsRightLoadDiagnosticsAndErrorIdentity(t *testing.T) {
|
||||
func TestConfigScopeCompareRetainsRightLoadExplanationAndErrorIdentity(t *testing.T) {
|
||||
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid right prompt config", []byte("broken: [")))
|
||||
if loadErr == nil {
|
||||
t.Fatal("invalid right configuration unexpectedly loaded")
|
||||
@@ -415,12 +415,12 @@ func TestConfigScopeCompareRetainsRightLoadDiagnosticsAndErrorIdentity(t *testin
|
||||
|
||||
acquired, err := scope.Compare(context.Background(), request, struct{}{})
|
||||
var retained *configkit.LoadError
|
||||
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(calls) != 2 || len(acquired.Diagnostics) == 0 {
|
||||
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(calls) != 2 || len(acquired.Explanation.Details) == 0 {
|
||||
t.Fatalf("right failure = (%#v, %v), calls=%#v", acquired, err, calls)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if writeErr := acquired.Diagnostics[0].Write(&output); writeErr != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (right):`) {
|
||||
t.Fatalf("right diagnostic = %q, %v", output.String(), writeErr)
|
||||
if writeErr := acquired.Explanation.Details[0](&output); writeErr != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (right):`) {
|
||||
t.Fatalf("right explanation = %q, %v", output.String(), writeErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,7 +534,7 @@ func TestFromEngineInspectsRequirementsWithNativeRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEngineRequirementsRetainLoadDiagnosticsAndSelectionErrors(t *testing.T) {
|
||||
func TestFromEngineRequirementsRetainLoadExplanationAndSelectionErrors(t *testing.T) {
|
||||
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid requirements configuration", []byte("broken: [")))
|
||||
if loadErr == nil {
|
||||
t.Fatal("invalid configuration unexpectedly loaded")
|
||||
@@ -544,11 +544,8 @@ func TestFromEngineRequirementsRetainLoadDiagnosticsAndSelectionErrors(t *testin
|
||||
})
|
||||
acquired, err := loadFailure.InspectRequirements(context.Background(), RequirementsRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 || acquired.Value != nil {
|
||||
t.Fatalf("requirements load failure = (%#v, %v), want original ConfigKit error and diagnostics", acquired, err)
|
||||
}
|
||||
if acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
||||
t.Fatalf("load diagnostics = %#v, want error severity", acquired.Diagnostics)
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 || acquired.Value != nil {
|
||||
t.Fatalf("requirements load failure = (%#v, %v), want original ConfigKit error and terminal context", acquired, err)
|
||||
}
|
||||
|
||||
nilEngine := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
|
||||
@@ -651,7 +648,7 @@ func TestFromEngineListsNativeCatalogsWithInvocationValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEngineCatalogsRetainLoadDiagnosticsAndErrorIdentity(t *testing.T) {
|
||||
func TestFromEngineCatalogsRetainLoadExplanationAndErrorIdentity(t *testing.T) {
|
||||
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid catalog configuration", []byte("broken: [")))
|
||||
if loadErr == nil {
|
||||
t.Fatal("invalid configuration unexpectedly loaded")
|
||||
@@ -666,42 +663,42 @@ func TestFromEngineCatalogsRetainLoadDiagnosticsAndErrorIdentity(t *testing.T) {
|
||||
{name: "prompts", call: func() (int, error) {
|
||||
acquired, err := suite.ListPrompts(context.Background(), CatalogRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
|
||||
t.Errorf("prompt load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
return len(acquired.Diagnostics), err
|
||||
return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err
|
||||
}},
|
||||
{name: "profiles", call: func() (int, error) {
|
||||
acquired, err := suite.ListProfiles(context.Background(), CatalogRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
|
||||
t.Errorf("profile load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
return len(acquired.Diagnostics), err
|
||||
return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err
|
||||
}},
|
||||
{name: "backends", call: func() (int, error) {
|
||||
acquired, err := suite.ListBackends(context.Background(), CatalogRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
|
||||
t.Errorf("backend load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
return len(acquired.Diagnostics), err
|
||||
return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err
|
||||
}},
|
||||
{name: "namespaces", call: func() (int, error) {
|
||||
acquired, err := suite.ListNamespaces(context.Background(), NamespaceRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
|
||||
t.Errorf("namespace load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
return len(acquired.Diagnostics), err
|
||||
return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err
|
||||
}},
|
||||
{name: "collections", call: func() (int, error) {
|
||||
acquired, err := suite.ListCollections(context.Background(), ckconfig.Selection{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
|
||||
t.Errorf("collection load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
return len(acquired.Diagnostics), err
|
||||
return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -782,7 +779,7 @@ func TestAdapterAcquisitionPreservesLoadAndContextErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEngineRetainsConfigKitLoadDiagnostics(t *testing.T) {
|
||||
func TestFromEngineRetainsConfigKitLoadExplanation(t *testing.T) {
|
||||
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid PromptKit config", []byte("broken: [")))
|
||||
if loadErr == nil {
|
||||
t.Fatal("invalid configuration unexpectedly loaded")
|
||||
@@ -793,11 +790,8 @@ func TestFromEngineRetainsConfigKitLoadDiagnostics(t *testing.T) {
|
||||
|
||||
acquired, err := suite.InspectProfile(context.Background(), ProfileRequest{ID: "default"}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
t.Fatalf("load failure = (%#v, %v), want original ConfigKit error and diagnostics", acquired, err)
|
||||
}
|
||||
if acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
||||
t.Fatalf("load diagnostics = %#v, want error severity", acquired.Diagnostics)
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
|
||||
t.Fatalf("load failure = (%#v, %v), want original ConfigKit error and terminal context", acquired, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,25 +817,55 @@ func TestPromptKitDiagnosticsRemainDeferredAndPropagateWriterErrors(t *testing.T
|
||||
Severity: configkit.SeverityWarning,
|
||||
Message: "safe warning",
|
||||
}}
|
||||
diagnostics := promptKitDiagnostics("promptkit", "", sources, records)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Severity != commandkit.SeverityWarning {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
messages := promptKitDiagnostics("promptkit", "", sources, records)
|
||||
if len(messages.diagnostics) != 1 || messages.diagnostics[0].Severity != commandkit.SeverityWarning || len(messages.explanation.Details) != 0 {
|
||||
t.Fatalf("diagnostic messages = %#v", messages)
|
||||
}
|
||||
if err := diagnostics[0].Write(failingDiagnosticWriter{err: writeErr}); !errors.Is(err, writeErr) {
|
||||
if err := messages.diagnostics[0].Write(failingDiagnosticWriter{err: writeErr}); !errors.Is(err, writeErr) {
|
||||
t.Fatalf("diagnostic write error = %v", err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := diagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit":`) || !strings.Contains(output.String(), "safe warning") || !strings.Contains(output.String(), `"source"`) {
|
||||
if err := messages.diagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit":`) || !strings.Contains(output.String(), "safe warning") || !strings.Contains(output.String(), `"source"`) {
|
||||
t.Fatalf("diagnostic text = %q, %v", output.String(), err)
|
||||
}
|
||||
|
||||
sideDiagnostics := promptKitDiagnostics("promptkit", "left", sources, records)
|
||||
output.Reset()
|
||||
if err := sideDiagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (left):`) {
|
||||
if err := sideDiagnostics.diagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (left):`) {
|
||||
t.Fatalf("comparison-side diagnostic text = %q, %v", output.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptKitDiagnosticsSeparateIndependentWarningsAndTerminalRecords(t *testing.T) {
|
||||
sources := []configkit.SourceInfo{{ID: "source", Label: "safe label", Path: "config.yml"}}
|
||||
primary := configkit.Location{SourceID: "source", Line: 3, Column: 2}
|
||||
related := configkit.Location{SourceID: "base.yml", Line: 7, Column: 4}
|
||||
messages := promptKitDiagnostics("promptkit", "right", sources, []configkit.Diagnostic{
|
||||
{Severity: configkit.SeverityWarning, Message: "independent warning", Primary: &primary, Related: []configkit.Location{related}},
|
||||
{Severity: configkit.SeverityError, Message: "terminal configuration error", Primary: &related, Related: []configkit.Location{primary}},
|
||||
})
|
||||
if len(messages.diagnostics) != 1 || len(messages.explanation.Details) != 1 {
|
||||
t.Fatalf("mixed acquisition messages = %#v", messages)
|
||||
}
|
||||
writeErr := errors.New("terminal explanation writer failed")
|
||||
if err := messages.explanation.Details[0](failingDiagnosticWriter{err: writeErr}); !errors.Is(err, writeErr) {
|
||||
t.Fatalf("terminal explanation write error = %v", err)
|
||||
}
|
||||
var warning, terminal bytes.Buffer
|
||||
if err := messages.diagnostics[0].Write(&warning); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := messages.explanation.Details[0](&terminal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := warning.String(); !strings.Contains(got, "independent warning") || !strings.Contains(got, `Configuration scope "promptkit" (right):`) || !strings.Contains(got, `"source": label="safe label", path="config.yml"`) || !strings.Contains(got, "source:3:2") || !strings.Contains(got, "related: base.yml:7:4") || strings.Contains(got, "terminal configuration error") {
|
||||
t.Fatalf("independent warning report = %q", got)
|
||||
}
|
||||
if got := terminal.String(); !strings.Contains(got, "terminal configuration error") || !strings.Contains(got, `Configuration scope "promptkit" (right):`) || !strings.Contains(got, `"source": label="safe label", path="config.yml"`) || !strings.Contains(got, "base.yml:7:4") || !strings.Contains(got, "related: source:3:2") || strings.Contains(got, "independent warning") {
|
||||
t.Fatalf("terminal explanation report = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
type failingDiagnosticWriter struct{ err error }
|
||||
|
||||
func (writer failingDiagnosticWriter) Write([]byte) (int, error) { return 0, writer.err }
|
||||
|
||||
@@ -134,11 +134,11 @@ func newCatalogListCommand[I, S any](suite Suite[I, S], name string) commandkit.
|
||||
return &(*input).captured
|
||||
})
|
||||
flags = append(flags,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Overlay = value }),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Catalog format: yaml."}, stringDefault("yaml"), parseDefinitionString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Catalog format: yaml.", Completion: completionChoices("yaml")}, stringDefault("yaml"), parseDefinitionString,
|
||||
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }),
|
||||
)
|
||||
if name == "prompts" || name == "profiles" || name == "backends" {
|
||||
@@ -185,23 +185,23 @@ func newCatalogListCommand[I, S any](suite Suite[I, S], name string) commandkit.
|
||||
Run: func(invocation commandkit.Invocation, input *catalogInput[I]) commandkit.Outcome {
|
||||
switch name {
|
||||
case "prompts":
|
||||
return runCatalogList(invocation, suite, input, suite.ListPrompts, encodePromptCatalog)
|
||||
return runCatalogList(invocation, suite, input, name, suite.ListPrompts, encodePromptCatalog)
|
||||
case "profiles":
|
||||
return runCatalogList(invocation, suite, input, suite.ListProfiles, encodeProfileCatalog)
|
||||
return runCatalogList(invocation, suite, input, name, suite.ListProfiles, encodeProfileCatalog)
|
||||
case "backends":
|
||||
return runCatalogList(invocation, suite, input, suite.ListBackends, encodeProfileCatalog)
|
||||
return runCatalogList(invocation, suite, input, name, suite.ListBackends, encodeProfileCatalog)
|
||||
case "namespaces":
|
||||
list := func(ctx context.Context, request CatalogRequest, value I, state S) (commandkit.Acquired[[]string], error) {
|
||||
return suite.ListNamespaces(ctx, NamespaceRequest{Selection: request.Selection, Kinds: input.kinds}, value, state)
|
||||
}
|
||||
return runCatalogList(invocation, suite, input, list, encodeProfileCatalog)
|
||||
return runCatalogList(invocation, suite, input, name, list, encodeProfileCatalog)
|
||||
case "collections":
|
||||
list := func(ctx context.Context, request CatalogRequest, value I, state S) (commandkit.Acquired[[]promptkit.CollectionInfo], error) {
|
||||
return suite.ListCollections(ctx, request.Selection, value, state)
|
||||
}
|
||||
return runCatalogList(invocation, suite, input, list, encodeCollectionCatalog)
|
||||
return runCatalogList(invocation, suite, input, name, list, encodeCollectionCatalog)
|
||||
default:
|
||||
return definitionFailure(fmt.Errorf("unknown PromptKit catalog %q", name), nil)
|
||||
return definitionFailure("Unable to list the selected PromptKit catalog.", commandkit.Explanation{}, fmt.Errorf("unknown PromptKit catalog %q", name))
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -256,13 +256,13 @@ func newDefinitionCommand[I, S any](suite Suite[I, S], name string, prompt bool)
|
||||
return &(*input).captured
|
||||
})
|
||||
flags = append(flags,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||
func(input **definitionInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||
func(input **definitionInput[I], value commandkit.Supplied[string]) {
|
||||
(*input).selection.Overlay = value
|
||||
}),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Report format: text or yaml."}, stringDefault("text"), parseDefinitionString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Report format: text or yaml.", Completion: completionChoices("text", "yaml")}, stringDefault("text"), parseDefinitionString,
|
||||
func(input **definitionInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }),
|
||||
)
|
||||
spec := commandkit.CommandSpec[*definitionInput[I]]{
|
||||
@@ -302,11 +302,11 @@ func newRequirementsCommand[I, S any](suite Suite[I, S]) commandkit.Command {
|
||||
return &(*input).captured
|
||||
})
|
||||
flags = append(flags,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
||||
(*input).selection.Config = value
|
||||
}),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
||||
(*input).selection.Overlay = value
|
||||
}),
|
||||
@@ -318,7 +318,7 @@ func newRequirementsCommand[I, S any](suite Suite[I, S]) commandkit.Command {
|
||||
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
||||
(*input).inspection.SessionID = value.Value
|
||||
}),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Report format: yaml."}, stringDefault("yaml"), parseDefinitionString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Report format: yaml.", Completion: completionChoices("yaml")}, stringDefault("yaml"), parseDefinitionString,
|
||||
func(input **requirementsInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }),
|
||||
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "var", Usage: "Caller variable assignment KEY=VALUE."}, []variableAssignment(nil), parseVariableAssignment,
|
||||
func(input **requirementsInput[I], value commandkit.Supplied[[]variableAssignment]) {
|
||||
@@ -385,12 +385,13 @@ func runCatalogList[T any, I, S any](
|
||||
invocation commandkit.Invocation,
|
||||
suite Suite[I, S],
|
||||
input *catalogInput[I],
|
||||
catalog string,
|
||||
list func(context.Context, CatalogRequest, I, S) (commandkit.Acquired[T], error),
|
||||
encode func(T) ([]byte, error),
|
||||
) commandkit.Outcome {
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
return definitionFailure("PromptKit catalog listing was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
}
|
||||
var state S
|
||||
@@ -399,30 +400,29 @@ func runCatalogList[T any, I, S any](
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
return definitionFailure("PromptKit catalog listing was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
}
|
||||
|
||||
acquired, err := list(invocation.Context, CatalogRequest{Selection: input.selection, Filter: input.filter}, input.selected, state)
|
||||
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure(fmt.Sprintf("Unable to list PromptKit %s.", catalog), acquired.Explanation, err)
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure("PromptKit catalog listing was canceled.", acquired.Explanation, err)
|
||||
}
|
||||
}
|
||||
output, err := encode(acquired.Value)
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
}
|
||||
return writePromptReport(invocation, output, acquired.Diagnostics)
|
||||
return renderPreparedPromptReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||
return encode(acquired.Value)
|
||||
})
|
||||
}
|
||||
|
||||
func runDefinitionInspection[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *definitionInput[I], prompt bool) commandkit.Outcome {
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
return definitionFailure("PromptKit inspection was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
}
|
||||
var state S
|
||||
@@ -431,41 +431,43 @@ func runDefinitionInspection[I, S any](invocation commandkit.Invocation, suite S
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
return definitionFailure("PromptKit inspection was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
}
|
||||
|
||||
if prompt {
|
||||
request := PromptRequest{Selection: input.selection, ID: input.id}
|
||||
acquired, err := suite.InspectPrompt(invocation.Context, request, input.selected, state)
|
||||
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure(fmt.Sprintf("Unable to inspect PromptKit prompt %q.", input.id), acquired.Explanation, err)
|
||||
}
|
||||
if acquired.Value == nil {
|
||||
return definitionFailure(errors.New("PromptKit prompt inspection returned nil"), acquired.Diagnostics)
|
||||
return definitionFailure("PromptKit prompt inspection returned no result.", acquired.Explanation, nil)
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure(fmt.Sprintf("Inspection of PromptKit prompt %q was canceled.", input.id), acquired.Explanation, err)
|
||||
}
|
||||
}
|
||||
return renderPromptReport(invocation, promptReportFromInspection(acquired.Value), input.format, acquired.Diagnostics)
|
||||
return renderPromptReport(invocation, acquired.Value, input.format, acquired.Explanation)
|
||||
}
|
||||
|
||||
request := ProfileRequest{Selection: input.selection, ID: input.id}
|
||||
acquired, err := suite.InspectProfile(invocation.Context, request, input.selected, state)
|
||||
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure(fmt.Sprintf("Unable to inspect PromptKit profile %q.", input.id), acquired.Explanation, err)
|
||||
}
|
||||
if acquired.Value == nil {
|
||||
return definitionFailure(errors.New("PromptKit profile inspection returned nil"), acquired.Diagnostics)
|
||||
return definitionFailure("PromptKit profile inspection returned no result.", acquired.Explanation, nil)
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure(fmt.Sprintf("Inspection of PromptKit profile %q was canceled.", input.id), acquired.Explanation, err)
|
||||
}
|
||||
}
|
||||
return renderProfileReport(invocation, profileReportFromInspection(acquired.Value), input.format, acquired.Diagnostics)
|
||||
return renderProfileReport(invocation, acquired.Value, input.format, acquired.Explanation)
|
||||
}
|
||||
|
||||
type promptInputReport struct {
|
||||
@@ -624,7 +626,7 @@ func profileReportFromInspection(inspection *promptkit.ProfileInspection) profil
|
||||
func runRequirementsInspection[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *requirementsInput[I]) commandkit.Outcome {
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
return definitionFailure("PromptKit requirements inspection was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
}
|
||||
var state S
|
||||
@@ -633,56 +635,69 @@ func runRequirementsInspection[I, S any](invocation commandkit.Invocation, suite
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
return definitionFailure("PromptKit requirements inspection was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
}
|
||||
|
||||
request := RequirementsRequest{Selection: input.selection, Inspection: input.inspection}
|
||||
acquired, err := suite.InspectRequirements(invocation.Context, request, input.selected, state)
|
||||
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure(fmt.Sprintf("Unable to inspect requirements for PromptKit prompt %q.", input.inspection.PromptID), acquired.Explanation, err)
|
||||
}
|
||||
if acquired.Value == nil {
|
||||
return definitionFailure(errors.New("PromptKit requirements inspection returned nil"), acquired.Diagnostics)
|
||||
return definitionFailure("PromptKit requirements inspection returned no result.", acquired.Explanation, nil)
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure(fmt.Sprintf("Requirements inspection for PromptKit prompt %q was canceled.", input.inspection.PromptID), acquired.Explanation, err)
|
||||
}
|
||||
}
|
||||
output, err := encodePromptYAMLBytes(requirementsReportFromInspection(acquired.Value))
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
}
|
||||
return writePromptReport(invocation, output, acquired.Diagnostics)
|
||||
return renderPreparedPromptReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||
return encodePromptYAMLBytes(requirementsReportFromInspection(acquired.Value))
|
||||
})
|
||||
}
|
||||
|
||||
func renderPromptReport(invocation commandkit.Invocation, report promptInspectionReport, format string, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
||||
var output bytes.Buffer
|
||||
if format == "yaml" {
|
||||
if err := encodePromptYAML(&output, report); err != nil {
|
||||
return definitionFailure(err, diagnostics)
|
||||
func renderPromptReport(invocation commandkit.Invocation, inspection *promptkit.PromptInspection, format string, explanation commandkit.Explanation) commandkit.Outcome {
|
||||
return renderPreparedPromptReport(invocation, explanation, func() ([]byte, error) {
|
||||
report := promptReportFromInspection(inspection)
|
||||
if format == "yaml" {
|
||||
return encodePromptYAMLBytes(report)
|
||||
}
|
||||
} else {
|
||||
output.WriteString(formatPromptText(report))
|
||||
}
|
||||
return writePromptReport(invocation, output.Bytes(), diagnostics)
|
||||
return []byte(formatPromptText(report)), nil
|
||||
})
|
||||
}
|
||||
|
||||
func renderProfileReport(invocation commandkit.Invocation, report profileInspectionReport, format string, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
||||
var output bytes.Buffer
|
||||
if format == "yaml" {
|
||||
if err := encodePromptYAML(&output, report); err != nil {
|
||||
return definitionFailure(err, diagnostics)
|
||||
func renderProfileReport(invocation commandkit.Invocation, inspection *promptkit.ProfileInspection, format string, explanation commandkit.Explanation) commandkit.Outcome {
|
||||
return renderPreparedPromptReport(invocation, explanation, func() ([]byte, error) {
|
||||
report := profileReportFromInspection(inspection)
|
||||
if format == "yaml" {
|
||||
return encodePromptYAMLBytes(report)
|
||||
}
|
||||
} else {
|
||||
text, err := formatProfileText(report)
|
||||
return []byte(text), err
|
||||
})
|
||||
}
|
||||
|
||||
// These reports were already prepared before delivery. Keep that boundary
|
||||
// inside managed rendering, with cancellation owned by the command outcome.
|
||||
func renderPreparedPromptReport(invocation commandkit.Invocation, explanation commandkit.Explanation, prepare func() ([]byte, error)) commandkit.Outcome {
|
||||
var canceled error
|
||||
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
|
||||
output, err := prepare()
|
||||
if err != nil {
|
||||
return definitionFailure(err, diagnostics)
|
||||
return err
|
||||
}
|
||||
output.WriteString(text)
|
||||
if canceled = promptKitContextError(invocation.Context); canceled != nil {
|
||||
return nil
|
||||
}
|
||||
_, err = writer.Write(output)
|
||||
return err
|
||||
})
|
||||
if canceled != nil {
|
||||
return definitionFailure("PromptKit report preparation was canceled.", explanation, canceled)
|
||||
}
|
||||
return writePromptReport(invocation, output.Bytes(), diagnostics)
|
||||
return commandkit.Success()
|
||||
}
|
||||
|
||||
func formatPromptText(report promptInspectionReport) string {
|
||||
@@ -771,30 +786,33 @@ func encodePromptYAMLBytes(value any) ([]byte, error) {
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
|
||||
func writePromptReport(invocation commandkit.Invocation, output []byte, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, diagnostics)
|
||||
func queuePromptDiagnostics(reporter *commandkit.Reporter, diagnostics []commandkit.Diagnostic) {
|
||||
deferred := reporter.AtEnd()
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.Write == nil {
|
||||
continue
|
||||
}
|
||||
switch diagnostic.Severity {
|
||||
case commandkit.SeverityInfo:
|
||||
_ = deferred.Info("", diagnostic.Write)
|
||||
case commandkit.SeverityDebug:
|
||||
_ = deferred.Debug("", diagnostic.Write)
|
||||
case commandkit.SeverityProgress:
|
||||
_ = reporter.Progress("", diagnostic.Write)
|
||||
default:
|
||||
_ = deferred.Warn("", diagnostic.Write)
|
||||
}
|
||||
}
|
||||
written, err := invocation.Streams.Out.Write(output)
|
||||
if err == nil && written != len(output) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
if err != nil {
|
||||
outcome := definitionFailure(err, diagnostics)
|
||||
outcome.ReportIncomplete = true
|
||||
outcome.DeliveryError = err
|
||||
return outcome
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
}
|
||||
|
||||
func definitionFailure(cause error, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
||||
outcome := commandkit.Failure(cause)
|
||||
outcome.Diagnostics = diagnostics
|
||||
func definitionFailure(message string, explanation commandkit.Explanation, cause error) commandkit.Outcome {
|
||||
outcome := commandkit.Failure(message)
|
||||
if explanation.Message != "" || len(explanation.Details) > 0 {
|
||||
outcome = outcome.WithExplanation(explanation.Message, explanation.Details...)
|
||||
}
|
||||
if cause != nil {
|
||||
outcome = outcome.WithCause(cause)
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func newPromptTestApp(t *testing.T, commands ...commandkit.Command) *commandkit.App {
|
||||
func newPromptTestApp(t *testing.T, declarations ...commandkit.Declaration) *commandkit.App {
|
||||
t.Helper()
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, commands...)
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, declarations...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -255,7 +255,7 @@ func TestCatalogCommandTreesAreIndependentAndOmitShow(t *testing.T) {
|
||||
app := newPromptTestApp(t, test.cmd)
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"help", "promptkit", "list"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "list") || !strings.Contains(output.String(), test.want) || strings.Contains(output.String(), test.omit) || strings.Contains(output.String(), "show") {
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "list") || !strings.Contains(output.String(), test.want) || strings.Contains(output.String(), "\n "+test.omit+" ") || strings.Contains(output.String(), "\n show ") {
|
||||
t.Fatalf("catalog-only help = (%#v, %q)", outcome, output.String())
|
||||
}
|
||||
})
|
||||
@@ -701,41 +701,39 @@ func TestCatalogRejectsOperandsAndUnavailableSelectors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogFailuresRetainDiagnosticsAndDeliveryState(t *testing.T) {
|
||||
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "catalog warning\n")
|
||||
return err
|
||||
}}
|
||||
hasCatalogWarning := func(diagnostics []commandkit.Diagnostic) bool {
|
||||
for _, got := range diagnostics {
|
||||
if got.Severity != diagnostic.Severity || got.Write == nil {
|
||||
continue
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if got.Write(&output) == nil && output.String() == "catalog warning\n" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func TestCatalogFailureReportsWarningAndNativeExplanationSeparately(t *testing.T) {
|
||||
diagnostic := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "catalog warning")
|
||||
providerErr := errors.New("catalog provider failed")
|
||||
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ListPrompts: func(context.Context, CatalogRequest, struct{}, struct{}) (commandkit.Acquired[[]promptkit.PromptIdentity], error) {
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, providerErr
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{
|
||||
Diagnostics: []commandkit.Diagnostic{diagnostic},
|
||||
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "PromptKit lookup context\n")
|
||||
return err
|
||||
}}},
|
||||
}, providerErr
|
||||
}}))
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "list", "prompts"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, providerErr) || !hasCatalogWarning(outcome.Diagnostics) {
|
||||
var output, diagnosticOutput bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "list", "prompts", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, providerErr) {
|
||||
t.Fatalf("provider failure outcome = %#v", outcome)
|
||||
}
|
||||
warningAt := strings.Index(diagnosticOutput.String(), "catalog warning")
|
||||
explanationAt := strings.Index(diagnosticOutput.String(), "PromptKit lookup context")
|
||||
if warningAt < 0 || explanationAt < 0 || warningAt >= explanationAt || strings.Count(diagnosticOutput.String(), "catalog warning") != 1 {
|
||||
t.Fatalf("warning/native terminal context were not independently ordered once: %q", diagnosticOutput.String())
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
canceled := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ListPrompts: func(context.Context, CatalogRequest, struct{}, struct{}) (commandkit.Acquired[[]promptkit.PromptIdentity], error) {
|
||||
cancel()
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: []promptkit.PromptIdentity{{ID: "briefing", Version: testStringPointer("1")}}, Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
|
||||
}}))
|
||||
var output bytes.Buffer
|
||||
outcome = canceled.Execute(ctx, []string{"promptkit", "list", "prompts"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || !hasCatalogWarning(outcome.Diagnostics) || output.Len() != 0 {
|
||||
t.Fatalf("post-provider cancellation outcome = %#v output=%q", outcome, output.String())
|
||||
output.Reset()
|
||||
diagnosticOutput.Reset()
|
||||
outcome = canceled.Execute(ctx, []string{"promptkit", "list", "prompts", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || !strings.Contains(diagnosticOutput.String(), "catalog warning") || output.Len() != 0 {
|
||||
t.Fatalf("post-provider cancellation outcome = %#v output=%q err=%q", outcome, output.String(), diagnosticOutput.String())
|
||||
}
|
||||
|
||||
writeErr := errors.New("catalog report writer failed")
|
||||
@@ -752,13 +750,96 @@ func TestCatalogFailuresRetainDiagnosticsAndDeliveryState(t *testing.T) {
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: []promptkit.PromptIdentity{{ID: "briefing", Version: testStringPointer("1")}}, Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
|
||||
}}))
|
||||
outcome := writerApp.Execute(context.Background(), []string{"promptkit", "list", "prompts"}, commandkit.Streams{Out: test.writer, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.DeliveryError, test.want) || !errors.Is(outcome.Cause, test.want) || !outcome.ReportIncomplete || !hasCatalogWarning(outcome.Diagnostics) {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.WriteError, test.want) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete {
|
||||
t.Fatalf("catalog delivery outcome = %#v", outcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogAndInspectionRespectSuccessfulReportingModes(t *testing.T) {
|
||||
for _, mode := range []string{"ordinary", "quiet", "verbose", "debug"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
calls := 0
|
||||
suite := Suite[struct{}, struct{}]{
|
||||
ListPrompts: func(context.Context, CatalogRequest, struct{}, struct{}) (commandkit.Acquired[[]promptkit.PromptIdentity], error) {
|
||||
calls++
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: []promptkit.PromptIdentity{{ID: "briefing"}}}, nil
|
||||
},
|
||||
InspectPrompt: func(context.Context, PromptRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) {
|
||||
calls++
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{Value: &promptkit.PromptInspection{PromptID: "briefing"}}, nil
|
||||
},
|
||||
}
|
||||
app := newPromptTestApp(t, Command(suite))
|
||||
args := []string{"promptkit", "list", "prompts"}
|
||||
if mode != "ordinary" {
|
||||
args = append(args, "--"+mode)
|
||||
}
|
||||
var output, diagnosticOutput bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || calls != 1 {
|
||||
t.Fatalf("%s listing = %#v after %d calls", mode, outcome, calls)
|
||||
}
|
||||
if mode == "quiet" {
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("quiet listing wrote result output %q", output.String())
|
||||
}
|
||||
} else if !strings.Contains(output.String(), "briefing") {
|
||||
t.Fatalf("%s listing omitted result: %q", mode, output.String())
|
||||
}
|
||||
if mode == "debug" && !strings.Contains(diagnosticOutput.String(), "DEBUG:") {
|
||||
t.Fatalf("debug listing omitted core debug events: %q", diagnosticOutput.String())
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
diagnosticOutput.Reset()
|
||||
args = []string{"promptkit", "show", "prompt", "briefing"}
|
||||
if mode != "ordinary" {
|
||||
args = append(args, "--"+mode)
|
||||
}
|
||||
outcome = app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || calls != 2 {
|
||||
t.Fatalf("%s inspection = %#v after %d callbacks", mode, outcome, calls)
|
||||
}
|
||||
if mode == "quiet" {
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("quiet inspection wrote result output %q", output.String())
|
||||
}
|
||||
} else if !strings.Contains(output.String(), "Prompt ID: \"briefing\"") {
|
||||
t.Fatalf("%s inspection omitted result: %q", mode, output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuietInspectionStillFindsReportEncodingFailure(t *testing.T) {
|
||||
called := false
|
||||
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{InspectProfile: func(context.Context, ProfileRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.ProfileInspection], error) {
|
||||
called = true
|
||||
return commandkit.Acquired[*promptkit.ProfileInspection]{Value: &promptkit.ProfileInspection{
|
||||
ProfileID: "broken",
|
||||
EffectiveModelParams: promptkit.ExecutionTarget{ExtraParams: map[string]any{"unsupported": func() {}}},
|
||||
}}, nil
|
||||
}}))
|
||||
result := &countingWriter{}
|
||||
var diagnosticOutput bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "show", "profile", "broken", "--quiet"}, commandkit.Streams{Out: result, Err: &diagnosticOutput})
|
||||
if !called || result.calls != 0 {
|
||||
t.Fatalf("quiet rendering called=%t destination writes=%d", called, result.calls)
|
||||
}
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.Presentation.Output.RenderError == nil {
|
||||
t.Fatalf("quiet serialization failure outcome = %#v diagnostics=%q", outcome, diagnosticOutput.String())
|
||||
}
|
||||
}
|
||||
|
||||
type countingWriter struct{ calls int }
|
||||
|
||||
func (writer *countingWriter) Write(p []byte) (int, error) {
|
||||
writer.calls++
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func TestCommandUsesInstalledNamespaceCollisionChecks(t *testing.T) {
|
||||
command := Command(Suite[struct{}, struct{}]{InspectPrompt: func(context.Context, PromptRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) {
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{}, nil
|
||||
@@ -1008,7 +1089,7 @@ func TestNativeLookupErrorsAndBlankIDsReachCallbacks(t *testing.T) {
|
||||
}
|
||||
output.Reset()
|
||||
diagnosticOutput.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"promptkit", "show", "prompt", ""}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||
outcome = app.Execute(context.Background(), []string{"promptkit", "show", "prompt", "", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, nativeErr) || calls != 1 || output.Len() != 0 || !strings.Contains(diagnosticOutput.String(), "lookup context") {
|
||||
t.Fatalf("native lookup failure = (%#v, calls=%d, out=%q, err=%q)", outcome, calls, output.String(), diagnosticOutput.String())
|
||||
}
|
||||
@@ -1055,7 +1136,7 @@ func TestDefinitionFormatPreflightAndReportDeliveryFailure(t *testing.T) {
|
||||
|
||||
writeErr := errors.New("report writer failed")
|
||||
outcome = app.Execute(context.Background(), []string{"promptkit", "show", "profile", "default"}, commandkit.Streams{Out: partialReportWriter{err: writeErr}, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, writeErr) || outcome.DeliveryError != writeErr || !outcome.ReportIncomplete || calls != 1 {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete || calls != 1 {
|
||||
t.Fatalf("report delivery outcome = %#v calls=%d", outcome, calls)
|
||||
}
|
||||
}
|
||||
|
||||
20
integrations/ckprompt/completion.go
Normal file
20
integrations/ckprompt/completion.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package ckprompt
|
||||
|
||||
import commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
|
||||
func completionChoices(values ...string) commandkit.CompletionHint {
|
||||
choices := make([]commandkit.CompletionChoice, len(values))
|
||||
for i, value := range values {
|
||||
choices[i].Value = value
|
||||
}
|
||||
return commandkit.CompleteChoices(choices...)
|
||||
}
|
||||
|
||||
func smokeKindCompletion() commandkit.CompletionHint {
|
||||
kinds := aggregateSmokeKinds()
|
||||
values := make([]string, 0, len(kinds)+1)
|
||||
for _, kind := range kinds {
|
||||
values = append(values, string(kind))
|
||||
}
|
||||
return completionChoices(append(values, "all")...)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
@@ -66,6 +67,80 @@ func TestPublicDefinitionCommandsCaptureSelectorHandles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptKitAliasesComposeWithInstalledOperations(t *testing.T) {
|
||||
type input struct{ Campaign commandkit.Supplied[string] }
|
||||
selector := commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign"}, (*string)(nil),
|
||||
func(value string) (string, error) { return value, nil },
|
||||
func(target *input, value commandkit.Supplied[string]) { target.Campaign = value })
|
||||
var collectionInput input
|
||||
var inspectionInput input
|
||||
var inspectedID string
|
||||
var smokeInput input
|
||||
var smokeKind promptkit.SmokeTestKind
|
||||
callbacks := 0
|
||||
suite := ckprompt.Suite[input, struct{}]{
|
||||
Selectors: []commandkit.Flag[input]{selector},
|
||||
ListCollections: func(_ context.Context, _ ckconfig.Selection, value input, _ struct{}) (commandkit.Acquired[[]promptkit.CollectionInfo], error) {
|
||||
callbacks++
|
||||
collectionInput = value
|
||||
return commandkit.Acquired[[]promptkit.CollectionInfo]{Value: []promptkit.CollectionInfo{{Namespace: "spring"}}}, nil
|
||||
},
|
||||
InspectPrompt: func(_ context.Context, request ckprompt.PromptRequest, value input, _ struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) {
|
||||
callbacks++
|
||||
inspectionInput, inspectedID = value, request.ID
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{Value: &promptkit.PromptInspection{PromptID: request.ID}}, nil
|
||||
},
|
||||
SmokeTest: func(_ context.Context, request ckprompt.SmokeRequest, value input, _ struct{}) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
|
||||
callbacks++
|
||||
smokeInput, smokeKind = value, request.Request.Kind
|
||||
return commandkit.Acquired[*promptkit.SmokeTestReport]{Value: &promptkit.SmokeTestReport{Kind: request.Request.Kind, Supported: true, Completed: true}}, nil
|
||||
},
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"},
|
||||
ckprompt.Command(suite),
|
||||
commandkit.Alias("collect", "promptkit list collections"),
|
||||
commandkit.Alias("inspect", "promptkit show prompt"),
|
||||
commandkit.Alias("smoke", "promptkit test"),
|
||||
commandkit.FlagAliasFor("promptkit list collections", "--camp", "--campaign"),
|
||||
commandkit.FlagAliasFor("promptkit show prompt", "--camp", "--campaign"),
|
||||
commandkit.FlagAlias("--hlep", "--help"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"collect", "--camp=spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !collectionInput.Campaign.Set || collectionInput.Campaign.Value != "spring" || !strings.Contains(output.String(), "spring") {
|
||||
t.Fatalf("collection shortcut outcome/input/report = (%#v, %#v, %q)", outcome, collectionInput, output.String())
|
||||
}
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"inspect", "briefing", "--camp", "spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !inspectionInput.Campaign.Set || inspectionInput.Campaign.Value != "spring" || inspectedID != "briefing" {
|
||||
t.Fatalf("inspection shortcut outcome/input/id = (%#v, %#v, %q)", outcome, inspectionInput, inspectedID)
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"smoke", "--hlep"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "--help") || callbacks != 2 {
|
||||
t.Fatalf("global help alias outcome/report/callbacks = (%#v, %q, %d)", outcome, output.String(), callbacks)
|
||||
}
|
||||
|
||||
if err := app.Register(commandkit.Alias("promptkit test text-inference", "promptkit test authentication")); err == nil {
|
||||
t.Fatal("accepted a command alias that maps one smoke operand to another")
|
||||
}
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"promptkit", "test", "authentication", "--campaign=spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || smokeKind != promptkit.SmokeTestAuthentication || !smokeInput.Campaign.Set || smokeInput.Campaign.Value != "spring" || !strings.Contains(output.String(), "kind: authentication") || !strings.Contains(output.String(), "completed: true") {
|
||||
t.Fatalf("canonical smoke outcome/request/report = (%#v, %#v, %q)", outcome, smokeKind, output.String())
|
||||
}
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"smoke", "text-inference", "--campaign=spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || smokeKind != promptkit.SmokeTestKind("text-inference") || callbacks != 4 || !strings.Contains(output.String(), "kind: text-inference") {
|
||||
t.Fatalf("smoke shortcut outcome/kind/report/callbacks = (%#v, %#v, %q, %d)", outcome, smokeKind, output.String(), callbacks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicConfigurationAndDefinitionFamiliesRemainIndependent(t *testing.T) {
|
||||
inputs, err := promptkit.FilesystemInputs("composition fixture", fstest.MapFS{
|
||||
"config/prompts.yml": &fstest.MapFile{Data: []byte(`prompts:
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Package ckprompt provides optional PromptKit configuration comparison,
|
||||
// prompt catalogs, named inspections, requirements discovery and validation,
|
||||
// and native smoke testing for CommandKit. Importing ckprompt installs neither
|
||||
// family; callers compose its declarations with core and ckconfig explicitly.
|
||||
// and native smoke testing for CommandKit. Its command factories return
|
||||
// canonical commandkit.Command declarations. Applications can install
|
||||
// commandkit.RoutingRule aliases alongside them through commandkit.New or
|
||||
// App.Register. Importing ckprompt installs neither family; callers compose its
|
||||
// declarations with core and ckconfig explicitly. Provider diagnostics remain
|
||||
// passive until the owning command submits them through the invocation
|
||||
// reporter, separately from any terminal explanation.
|
||||
package ckprompt
|
||||
|
||||
@@ -15,8 +15,11 @@ go run . promptkit list profiles --config fixtures/after.yml --campaign autumn -
|
||||
go run . promptkit list backends --campaign spring
|
||||
go run . promptkit list namespaces --campaign spring
|
||||
go run . promptkit list collections --campaign spring
|
||||
go run . list collections --campaign spring
|
||||
go run . pk list bundles --campaign spring
|
||||
go run . promptkit list prompts --namespace field-notes --campaign spring
|
||||
go run . promptkit show prompt briefing --campaign spring --format yaml
|
||||
go run . pk show prompt briefing --campaign spring --fmt yaml
|
||||
go run . promptkit show profile spring --campaign spring
|
||||
go run . promptkit requirements show briefing --campaign spring
|
||||
go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml
|
||||
@@ -24,10 +27,35 @@ go run . promptkit requirements validate briefing --campaign spring --input topi
|
||||
go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt --var mode=full --input notes=fixtures/notes.txt --format yaml
|
||||
go run . promptkit requirements validate briefing --campaign spring
|
||||
go run . promptkit test text_inference --campaign spring --prompt briefing --profile spring
|
||||
go run . smoke text_inference --campaign spring --prompt briefing --profile spring
|
||||
go run . promptkit test all --campaign spring --prompt briefing --profile spring
|
||||
go run . pk --hlep
|
||||
go run . session restore session-42 --campaign spring
|
||||
```
|
||||
|
||||
`newApp` installs application-owned command and flag aliases alongside the
|
||||
complete `ckconfig` and `ckprompt` declarations. `list collections` and
|
||||
`promptkit list bundles` both reach the canonical collection command; `pk`
|
||||
shortens the PromptKit group. `--fmt` is scoped to named prompt inspection and
|
||||
`--hlep` is available globally. The `smoke` shortcut retains the canonical
|
||||
`promptkit test KIND` operand, including the special `all` aggregate.
|
||||
|
||||
The example installs `Completion("composed-example")`. Build it as
|
||||
`go build -o composed-example .`. Initialize `bash-completion` first if using
|
||||
Bash, then load a shell script as described in the
|
||||
[CLI reference](../../../../docs/cli.md#shell-completion). Try completion after
|
||||
`composed-example pk show prompt briefing --fmt=` or after the filename prefix
|
||||
in `composed-example config show --config=fixtures/`. The already-entered `pk`
|
||||
alias selects the PromptKit command context without loading an engine.
|
||||
|
||||
The composition calls `ckconfig.Command(suite)` and `ckprompt.Command(definitions)`
|
||||
directly in a `[]commandkit.Declaration` with root-installed routing rules.
|
||||
Group children remain canonical `Command` values. For command-only slices,
|
||||
convert each `Command` to `Declaration` before appending aliases. The
|
||||
[consumer guide](../../../../docs/consumers/guide.md#application-owned-aliases)
|
||||
includes minimal direct-call and slice-conversion examples; this README documents
|
||||
the maintained complete consumer.
|
||||
|
||||
The sources and session store are small in-memory fixtures. `--campaign` is a
|
||||
typed selector shared by the app and PromptKit configuration scopes. Their
|
||||
`ckconfig.Suite[*inspectionInput, *runCache]` uses `NewInput` to initialize each
|
||||
@@ -92,3 +120,12 @@ required `topic` artifact is not supplied. The builder defaults variables from
|
||||
the selected campaign but leaves artifact bindings to the operator. The
|
||||
`requirements show` commands remain discovery reports: partial discovery can
|
||||
succeed even when a concrete request would fail validation.
|
||||
|
||||
The alias examples use the local CommandKit source because the composed
|
||||
module's published core dependency, v0.2.0, predates alias declarations. From
|
||||
the repository root, add the three local modules to `go.work` as described in
|
||||
the [developer guide](../../../../docs/development.md#documentation-validation),
|
||||
then run `go test ./...` from this example directory. This checks the example
|
||||
against the current local core and integration sources; it does not claim that
|
||||
published v0.2.0 supports aliases. The next core dependency adoption is handled
|
||||
after the core release is prepared.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
@@ -65,6 +66,8 @@ type session struct {
|
||||
Summary string
|
||||
}
|
||||
|
||||
var errSessionNotFound = errors.New("saved session was not found")
|
||||
|
||||
func campaignSelector() commandkit.Flag[*inspectionInput] {
|
||||
return commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Usage: "Select campaign inputs."}, (*string)(nil), parseString,
|
||||
func(input **inspectionInput, value commandkit.Supplied[string]) { (*input).Campaign = value })
|
||||
@@ -200,6 +203,14 @@ func newApp() (*commandkit.App, error) {
|
||||
}
|
||||
|
||||
func newAppWithLLMClient(client promptkit.LLMClient) (*commandkit.App, error) {
|
||||
return newAppWithEngineLoader(client, loadPromptEngine, nil)
|
||||
}
|
||||
|
||||
func newAppWithEngineLoader(
|
||||
client promptkit.LLMClient,
|
||||
engineLoader func(context.Context, ckconfig.Selection, *inspectionInput, *runCache) (*promptkit.Engine, error),
|
||||
smokeTest func(context.Context, ckprompt.SmokeRequest, *inspectionInput, *runCache) (commandkit.Acquired[*promptkit.SmokeTestReport], error),
|
||||
) (*commandkit.App, error) {
|
||||
if client == nil {
|
||||
client = &exampleLLMClient{}
|
||||
}
|
||||
@@ -210,7 +221,7 @@ func newAppWithLLMClient(client promptkit.LLMClient) (*commandkit.App, error) {
|
||||
stateFactory := func() *runCache { return &runCache{llmClient: client} }
|
||||
|
||||
appScope := ckconfig.FromConfigKit("app", "Application settings.", loadApplicationConfig)
|
||||
engineScope := ckprompt.ConfigScope(loadPromptEngine)
|
||||
engineScope := ckprompt.ConfigScope(engineLoader)
|
||||
configCommand := ckconfig.Command(ckconfig.Suite[*inspectionInput, *runCache]{
|
||||
NewInput: inputFactory,
|
||||
NewState: stateFactory,
|
||||
@@ -227,12 +238,15 @@ func newAppWithLLMClient(client promptkit.LLMClient) (*commandkit.App, error) {
|
||||
Scopes: []ckconfig.Scope[*inspectionInput, *runCache]{appScope, engineScope},
|
||||
})
|
||||
|
||||
definitions := ckprompt.FromEngine(loadPromptEngine)
|
||||
definitions := ckprompt.FromEngine(engineLoader)
|
||||
definitions.NewInput = inputFactory
|
||||
definitions.NewState = stateFactory
|
||||
definitions.Selectors = []commandkit.Flag[*inspectionInput]{campaignSelector()}
|
||||
definitions = ckprompt.WithRequestValidation(definitions, loadPromptEngine, buildValidationRequest)
|
||||
definitions = ckprompt.WithSmokeTesting(definitions, loadPromptEngine, nil)
|
||||
definitions = ckprompt.WithRequestValidation(definitions, engineLoader, buildValidationRequest)
|
||||
definitions = ckprompt.WithSmokeTesting(definitions, engineLoader, nil)
|
||||
if smokeTest != nil {
|
||||
definitions.SmokeTest = smokeTest
|
||||
}
|
||||
definitionCommand := ckprompt.Command(definitions)
|
||||
|
||||
store := map[string]session{
|
||||
@@ -246,36 +260,46 @@ func newAppWithLLMClient(client promptkit.LLMClient) (*commandkit.App, error) {
|
||||
Arguments: []commandkit.Argument[restoreInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "ID", Usage: "Session identifier."}, true, parseString,
|
||||
func(input *restoreInput, value commandkit.Supplied[string]) { input.Session = value.Value })},
|
||||
Run: func(invocation commandkit.Invocation, input restoreInput) commandkit.Outcome {
|
||||
reporter := invocation.Reporter()
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return commandkit.Failure(err)
|
||||
return commandkit.Failure("Session restore was canceled.").WithCause(err)
|
||||
}
|
||||
application, err := loadApplicationConfig(invocation.Context, ckconfig.Selection{}, nil, nil)
|
||||
if err != nil {
|
||||
return commandkit.Failure(err)
|
||||
return commandkit.Failure("Could not load application configuration.").WithCause(err)
|
||||
}
|
||||
name, _, err := selectedCampaign(application.Config, input.Campaign)
|
||||
if err != nil {
|
||||
return commandkit.Failure(err)
|
||||
return commandkit.Failure(err.Error()).WithCause(err)
|
||||
}
|
||||
record, ok := store[input.Session]
|
||||
if !ok {
|
||||
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 4, Domain: "session_not_found", Cause: errors.New("saved session was not found")}
|
||||
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 4, Domain: "session_not_found", Cause: errSessionNotFound, Explanation: commandkit.Explanation{Message: "Saved session was not found."}}
|
||||
}
|
||||
if record.Campaign != name {
|
||||
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 3, Domain: "campaign_mismatch", Cause: fmt.Errorf("session belongs to campaign %q", record.Campaign)}
|
||||
}
|
||||
if _, err := fmt.Fprintf(invocation.Streams.Out, "restored %s: %s\n", record.ID, record.Summary); err != nil {
|
||||
return commandkit.Failure(err)
|
||||
cause := fmt.Errorf("session belongs to campaign %q", record.Campaign)
|
||||
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 3, Domain: "campaign_mismatch", Cause: cause, Explanation: commandkit.Explanation{Message: "Session campaign does not match the selected campaign."}}
|
||||
}
|
||||
_ = reporter.RenderOutput(func(writer io.Writer) error {
|
||||
_, err := fmt.Fprintf(writer, "restored %s: %s\n", record.ID, record.Summary)
|
||||
return err
|
||||
})
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
return commandkit.New(
|
||||
commandkit.Identity{Name: "composed-example", Version: "1.0"},
|
||||
declarations := []commandkit.Declaration{
|
||||
configCommand,
|
||||
definitionCommand,
|
||||
commandkit.Completion("composed-example"),
|
||||
commandkit.Group("session", "Manage saved sessions.", restore),
|
||||
)
|
||||
commandkit.Alias("list collections", "promptkit list collections"),
|
||||
commandkit.Alias("promptkit list bundles", "promptkit list collections"),
|
||||
commandkit.Alias("pk", "promptkit"),
|
||||
commandkit.Alias("smoke", "promptkit test"),
|
||||
commandkit.FlagAliasFor("promptkit show prompt", "--fmt", "--format"),
|
||||
commandkit.FlagAlias("--hlep", "--help"),
|
||||
}
|
||||
return commandkit.New(commandkit.Identity{Name: "composed-example", Version: "1.0"}, declarations...)
|
||||
}
|
||||
|
||||
func parseString(raw string) (string, error) { return raw, nil }
|
||||
|
||||
@@ -3,8 +3,10 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
@@ -350,6 +352,153 @@ func TestComposedCatalogsAndSmokeUseInjectedClientOnlyForInference(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposedCompletionUsesHintsWithoutLoadingEngine(t *testing.T) {
|
||||
var loads, calls, smokeCalls int
|
||||
loader := func(ctx context.Context, selection ckconfig.Selection, input *inspectionInput, cache *runCache) (*promptkit.Engine, error) {
|
||||
loads++
|
||||
return loadPromptEngine(ctx, selection, input, cache)
|
||||
}
|
||||
smoke := func(context.Context, ckprompt.SmokeRequest, *inspectionInput, *runCache) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
|
||||
smokeCalls++
|
||||
return commandkit.Acquired[*promptkit.SmokeTestReport]{}, nil
|
||||
}
|
||||
app, err := newAppWithEngineLoader(&exampleLLMClient{calls: &calls}, loader, smoke)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"__complete", "promptkit", "test", "re"}, "reachability\n"},
|
||||
{[]string{"__complete", "smoke", "al"}, "all\n"},
|
||||
{[]string{"__complete", "pk", "show", "prompt", "briefing", "--fmt=y"}, "yaml\n"},
|
||||
{[]string{"__complete", "pk", "show", "prompt", "briefing", "--config=fixtures/"}, ":0\n"},
|
||||
} {
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), tc.args, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Status != 0 || !strings.Contains(output.String(), tc.want) {
|
||||
t.Fatalf("query %q outcome=%#v output=%q", tc.args, outcome, output.String())
|
||||
}
|
||||
}
|
||||
if loads != 0 || calls != 0 || smokeCalls != 0 {
|
||||
t.Fatalf("completion loaded engine %d times and invoked client %d times, smoke calls=%d", loads, calls, smokeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposedSmokeReportSurvivesInferenceFailure(t *testing.T) {
|
||||
wantCause := errors.New("local example inference failed")
|
||||
smokeTest := func(_ context.Context, request ckprompt.SmokeRequest, _ *inspectionInput, _ *runCache) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
|
||||
return commandkit.Acquired[*promptkit.SmokeTestReport]{
|
||||
Value: &promptkit.SmokeTestReport{Kind: request.Request.Kind, Completed: true},
|
||||
Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(commandkit.SeverityWarning, "independent smoke warning")},
|
||||
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "Native text inference context.\n")
|
||||
return err
|
||||
}}},
|
||||
}, wantCause
|
||||
}
|
||||
app, err := newAppWithEngineLoader(&exampleLLMClient{}, loadPromptEngine, smokeTest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "text_inference", "--campaign", "spring", "--prompt", "briefing", "--profile", "spring", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnostics})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Cause, wantCause) {
|
||||
t.Fatalf("smoke failure outcome = %#v; report %q; diagnostics %q", outcome, output.String(), diagnostics.String())
|
||||
}
|
||||
if !strings.Contains(output.String(), "kind: text_inference") || !strings.Contains(output.String(), "completed: true") {
|
||||
t.Fatalf("native smoke report was not delivered: %q", output.String())
|
||||
}
|
||||
warningAt := strings.Index(diagnostics.String(), "independent smoke warning")
|
||||
contextAt := strings.Index(diagnostics.String(), "Native text inference context")
|
||||
if warningAt < 0 || contextAt < 0 || warningAt >= contextAt || strings.Contains(diagnostics.String(), wantCause.Error()) {
|
||||
t.Fatalf("terminal explanation/diagnostics = %q / %q", outcome.Explanation.Message, diagnostics.String())
|
||||
}
|
||||
if outcome.Explanation.Message != "" {
|
||||
t.Errorf("unexpected synthesized terminal explanation %q", outcome.Explanation.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposedCustomRestoreUsesSharedReportingAndTerminalFailure(t *testing.T) {
|
||||
app, err := newApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"session", "restore", "session-42", "--campaign", "spring", "--quiet"}, commandkit.Streams{Out: &output, Err: &diagnostics})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || output.Len() != 0 || diagnostics.Len() != 0 {
|
||||
t.Fatalf("quiet custom command outcome/output/diagnostics = %#v / %q / %q", outcome, output.String(), diagnostics.String())
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
diagnostics.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"session", "restore", "missing", "--quiet"}, commandkit.Streams{Out: &output, Err: &diagnostics})
|
||||
if outcome.Kind != commandkit.OutcomeDomain || outcome.Status != 4 || outcome.Domain != "session_not_found" || !errors.Is(outcome.Cause, errSessionNotFound) {
|
||||
t.Fatalf("missing session outcome = %#v", outcome)
|
||||
}
|
||||
if output.Len() != 0 || outcome.Explanation.Message == "" || !strings.Contains(diagnostics.String(), outcome.Explanation.Message) {
|
||||
t.Fatalf("terminal failure output/explanation/diagnostics = %q / %q / %q", output.String(), outcome.Explanation.Message, diagnostics.String())
|
||||
}
|
||||
if strings.Contains(diagnostics.String(), errSessionNotFound.Error()) {
|
||||
t.Errorf("terminal presentation exposed the underlying cause: %q", diagnostics.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposedAliasesReachCanonicalCommands(t *testing.T) {
|
||||
clientCalls := 0
|
||||
engineLoads := 0
|
||||
engineLoader := func(ctx context.Context, selection ckconfig.Selection, input *inspectionInput, cache *runCache) (*promptkit.Engine, error) {
|
||||
engineLoads++
|
||||
return loadPromptEngine(ctx, selection, input, cache)
|
||||
}
|
||||
app, err := newAppWithEngineLoader(&exampleLLMClient{calls: &clientCalls}, engineLoader, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"pk", "--hlep"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "--hlep → --help") || strings.Contains(output.String(), "field-notes") || engineLoads != 0 || clientCalls != 0 {
|
||||
t.Fatalf("group help alias outcome/output/engine loads/client calls = (%#v, %q, %d, %d)", outcome, output.String(), engineLoads, clientCalls)
|
||||
}
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"pk", "--hlep", "--quiet"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || output.Len() != 0 || engineLoads != 0 || clientCalls != 0 {
|
||||
t.Fatalf("quiet aliased help outcome/output/engine loads/client calls = (%#v, %q, %d, %d)", outcome, output.String(), engineLoads, clientCalls)
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"list", "collections", "--campaign", "spring"},
|
||||
{"pk", "list", "bundles", "--campaign", "spring"},
|
||||
{"promptkit", "list", "collections", "--campaign", "spring"},
|
||||
} {
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "field-notes") {
|
||||
t.Fatalf("collection route %q outcome/report = (%#v, %q)", args, outcome, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"pk", "show", "prompt", "briefing", "--campaign", "spring", "--fmt", "yaml"},
|
||||
{"promptkit", "show", "prompt", "briefing", "--campaign", "spring", "--format", "yaml"},
|
||||
} {
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "prompt_id: briefing") {
|
||||
t.Fatalf("prompt inspection route %q outcome/report = (%#v, %q)", args, outcome, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"smoke", "text_inference", "--campaign", "spring", "--prompt", "briefing", "--profile", "spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "kind: text_inference") || !strings.Contains(output.String(), "completed: true") || clientCalls != 1 {
|
||||
t.Fatalf("smoke shortcut outcome/report/client calls = (%#v, %q, %d)", outcome, output.String(), clientCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func composedReportValue(t *testing.T, data []byte, category string, fields ...string) *yaml.Node {
|
||||
t.Helper()
|
||||
var document yaml.Node
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
gitea.maximumdirect.net/eric/CommandKit v0.2.0 h1:UNBjSq6XDZHdPqvZLOL3N5R1E1ol0jnfldgradU7eqw=
|
||||
gitea.maximumdirect.net/eric/CommandKit v0.2.0/go.mod h1:47DQ6Mo/yZqUVn5PAFRmPck91Zhf1jfaHDMoEZvUzsQ=
|
||||
gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig v0.1.0 h1:qqgaCKrraUL//1vcoU+KUlQ5PNX0EtRpqsUraSiUW/w=
|
||||
gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig v0.1.0/go.mod h1:SFtsgbTVQd+BCGQVCFSAopl55pGYhP1sSF442KFpSYA=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0 h1:xCIhYnOHjw7rFvB+vIremPKGeROOxKTomnUEUipuQno=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0/go.mod h1:C82sbyKHYMV1OwaD2GJanFrwTfcZfjlW2JsGze5b0kA=
|
||||
gitea.maximumdirect.net/eric/promptkit v0.16.0 h1:g3dhN0QfzQriaVOBmGUZCw2iBM9Mli2AAU4CbMfzcuY=
|
||||
|
||||
80
integrations/ckprompt/native_explanation_test.go
Normal file
80
integrations/ckprompt/native_explanation_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package ckprompt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
)
|
||||
|
||||
func TestBundledNativeFailuresReachTerminalExplanation(t *testing.T) {
|
||||
engine := adapterEngine(t)
|
||||
load := func(context.Context, ckconfig.Selection, struct{}, struct{}) (*promptkit.Engine, error) {
|
||||
return engine, nil
|
||||
}
|
||||
for _, operation := range []string{"validation", "inspection"} {
|
||||
for _, mode := range []string{"ordinary", "quiet"} {
|
||||
t.Run(operation+"/"+mode, func(t *testing.T) {
|
||||
suite := FromEngine(load)
|
||||
suite = WithRequestValidation(suite, load, nil)
|
||||
var nativeErr error
|
||||
args := []string{"promptkit", "requirements", "validate", "briefing"}
|
||||
if operation == "validation" {
|
||||
original := suite.ValidateRequest
|
||||
suite.ValidateRequest = func(ctx context.Context, request ValidationRequest, input, state struct{}) (commandkit.Acquired[struct{}], error) {
|
||||
acquired, err := original(ctx, request, input, state)
|
||||
nativeErr = err
|
||||
acquired.Diagnostics = append(acquired.Diagnostics, commandkit.MessageDiagnostic(commandkit.SeverityWarning, "independent fixture warning"))
|
||||
return acquired, err
|
||||
}
|
||||
} else {
|
||||
args = []string{"promptkit", "show", "prompt", "missing"}
|
||||
original := suite.InspectPrompt
|
||||
suite.InspectPrompt = func(ctx context.Context, request PromptRequest, input, state struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) {
|
||||
acquired, err := original(ctx, request, input, state)
|
||||
nativeErr = err
|
||||
return acquired, err
|
||||
}
|
||||
}
|
||||
if mode == "quiet" {
|
||||
args = append(args, "--quiet")
|
||||
}
|
||||
var diagnostics bytes.Buffer
|
||||
got := newPromptTestApp(t, Command(suite)).Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: &diagnostics})
|
||||
if nativeErr == nil {
|
||||
t.Fatal("fixture did not supply a native failure")
|
||||
}
|
||||
if got.Status != 1 || !errors.Is(got.Cause, nativeErr) || got.Explanation.Message != nativeErr.Error() {
|
||||
t.Fatalf("native error was not preserved in outcome: %#v, error=%v", got, nativeErr)
|
||||
}
|
||||
if strings.Count(diagnostics.String(), nativeErr.Error()) != 1 || strings.Contains(diagnostics.String(), "independent fixture warning") {
|
||||
t.Fatalf("terminal adaptation or warning visibility: %q", diagnostics.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeOperationExplanationPreservesSuppliedContext(t *testing.T) {
|
||||
nativeErr := errors.New("native operation detail")
|
||||
for _, explanation := range []commandkit.Explanation{
|
||||
{Message: "selected explanation"},
|
||||
{Details: []commandkit.Detail{func(w io.Writer) error { _, err := io.WriteString(w, "native structured explanation\n"); return err }}},
|
||||
} {
|
||||
messages := (promptKitAcquisition{explanation: explanation}).withOperationError(nativeErr)
|
||||
suite := Suite[struct{}, struct{}]{InspectPrompt: func(context.Context, PromptRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) {
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{Explanation: messages.explanation}, nativeErr
|
||||
}}
|
||||
var diagnostics bytes.Buffer
|
||||
got := newPromptTestApp(t, Command(suite)).Execute(context.Background(), []string{"promptkit", "show", "prompt", "fixture", "--quiet"}, commandkit.Streams{Err: &diagnostics})
|
||||
if !errors.Is(got.Cause, nativeErr) || strings.Contains(diagnostics.String(), nativeErr.Error()) || strings.Count(diagnostics.String(), "explanation") != 1 {
|
||||
t.Fatalf("supplied explanation was replaced or duplicated: %#v, %q", got, diagnostics.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,26 @@
|
||||
"product": "CommandKit ckprompt",
|
||||
"module_dir": "integrations/ckprompt",
|
||||
"remote": "origin",
|
||||
"expected_push_url": "https://gitea.maximumdirect.net/eric/CommandKit.git",
|
||||
"expected_push_url": "ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git",
|
||||
"validation": {
|
||||
"shared_files": [
|
||||
"COPYRIGHT",
|
||||
"LICENSE",
|
||||
"docs/oss/attribution.md"
|
||||
]
|
||||
],
|
||||
"go_format": true,
|
||||
"markdown": {},
|
||||
"module_distribution": {
|
||||
"workspace_files": true,
|
||||
"local_replacements": true
|
||||
},
|
||||
"forbidden_files": [".releasekit/gitea-token"]
|
||||
},
|
||||
"delivery": {
|
||||
"forge": "gitea",
|
||||
"base_url": "https://forge.maximumdirect.net",
|
||||
"owner": "base-layer",
|
||||
"repository": "commandkit",
|
||||
"token_file": ".releasekit/gitea-token"
|
||||
}
|
||||
}
|
||||
|
||||
50
integrations/ckprompt/report_cancellation_test.go
Normal file
50
integrations/ckprompt/report_cancellation_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package ckprompt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
)
|
||||
|
||||
type cancelDuringYAML struct{ cancel context.CancelFunc }
|
||||
|
||||
func (value cancelDuringYAML) MarshalYAML() (any, error) {
|
||||
value.cancel()
|
||||
return "prepared", nil
|
||||
}
|
||||
|
||||
func TestInspectionCancellationDuringPreparationPreventsDelivery(t *testing.T) {
|
||||
for _, format := range []string{"text", "yaml"} {
|
||||
for _, mode := range []string{"ordinary", "quiet"} {
|
||||
t.Run(format+"/"+mode, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{
|
||||
InspectProfile: func(context.Context, ProfileRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.ProfileInspection], error) {
|
||||
return commandkit.Acquired[*promptkit.ProfileInspection]{Value: &promptkit.ProfileInspection{
|
||||
ProfileID: "demo",
|
||||
EffectiveModelParams: promptkit.ExecutionTarget{ExtraParams: map[string]any{
|
||||
"controlled": cancelDuringYAML{cancel},
|
||||
}},
|
||||
}}, nil
|
||||
},
|
||||
}))
|
||||
args := []string{"promptkit", "show", "profile", "demo", "--format", format}
|
||||
if mode == "quiet" {
|
||||
args = append(args, "--quiet")
|
||||
}
|
||||
output := &countingWriter{}
|
||||
got := app.Execute(ctx, args, commandkit.Streams{Out: output})
|
||||
if got.Kind != commandkit.OutcomeOperational || got.Status != 1 || !errors.Is(got.Cause, context.Canceled) || output.calls != 0 {
|
||||
t.Fatalf("outcome=%#v writes=%d", got, output.calls)
|
||||
}
|
||||
if got.Presentation.Output.RenderError != nil || got.Presentation.Output.WriteError != nil {
|
||||
t.Fatalf("operation cancellation became a presentation failure: %#v", got.Presentation.Output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,12 +319,12 @@ func TestRequirementsCommandPreservesDiagnosticsCancellationAndDelivery(t *testi
|
||||
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: &promptkit.RequirementInspection{Issues: []promptkit.RequirementIssue{issue}}, Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
|
||||
}}))
|
||||
var report, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing"}, commandkit.Streams{Out: &report, Err: &diagnostics})
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing", "--verbose"}, commandkit.Streams{Out: &report, Err: &diagnostics})
|
||||
var decoded map[string]any
|
||||
if err := yaml.Unmarshal(report.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("decode partial report %q: %v", report.String(), err)
|
||||
}
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 || len(outcome.Diagnostics) != 1 || !strings.Contains(diagnostics.String(), "provider warning") || strings.Contains(diagnostics.String(), "native issue details") {
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete || !strings.Contains(diagnostics.String(), "provider warning") || strings.Contains(diagnostics.String(), "native issue details") {
|
||||
t.Fatalf("partial inspection outcome=%#v report=%q diagnostics=%q", outcome, report.String(), diagnostics.String())
|
||||
}
|
||||
issues, ok := decoded["issues"].([]any)
|
||||
@@ -339,8 +339,8 @@ func TestRequirementsCommandPreservesDiagnosticsCancellationAndDelivery(t *testi
|
||||
return commandkit.Acquired[*promptkit.RequirementInspection]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, providerErr
|
||||
}}))
|
||||
var report, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing"}, commandkit.Streams{Out: &report, Err: &diagnostics})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Cause, providerErr) || len(outcome.Diagnostics) == 0 || !strings.Contains(diagnostics.String(), "provider warning") || report.Len() != 0 {
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing", "--verbose"}, commandkit.Streams{Out: &report, Err: &diagnostics})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Cause, providerErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete || !strings.Contains(diagnostics.String(), "provider warning") || report.Len() != 0 {
|
||||
t.Fatalf("provider failure outcome=%#v report=%q diagnostics=%q", outcome, report.String(), diagnostics.String())
|
||||
}
|
||||
})
|
||||
@@ -351,9 +351,9 @@ func TestRequirementsCommandPreservesDiagnosticsCancellationAndDelivery(t *testi
|
||||
cancel()
|
||||
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: &promptkit.RequirementInspection{Complete: true}, Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
|
||||
}}))
|
||||
var report bytes.Buffer
|
||||
outcome := app.Execute(ctx, []string{"promptkit", "requirements", "show", "briefing"}, commandkit.Streams{Out: &report, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || len(outcome.Diagnostics) == 0 || report.Len() != 0 {
|
||||
var report, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(ctx, []string{"promptkit", "requirements", "show", "briefing", "--verbose"}, commandkit.Streams{Out: &report, Err: &diagnostics})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || !strings.Contains(diagnostics.String(), "provider warning") || report.Len() != 0 {
|
||||
t.Fatalf("post-provider cancellation = %#v report=%q", outcome, report.String())
|
||||
}
|
||||
})
|
||||
@@ -363,7 +363,7 @@ func TestRequirementsCommandPreservesDiagnosticsCancellationAndDelivery(t *testi
|
||||
return commandkit.Acquired[*promptkit.RequirementInspection]{}, nil
|
||||
}}))
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.Cause == nil || !strings.Contains(outcome.Cause.Error(), "returned nil") {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.Cause != nil || !strings.Contains(outcome.Explanation.Message, "returned no result") {
|
||||
t.Fatalf("nil provider result = %#v", outcome)
|
||||
}
|
||||
})
|
||||
@@ -387,7 +387,7 @@ func TestRequirementsCommandPreservesDiagnosticsCancellationAndDelivery(t *testi
|
||||
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: &promptkit.RequirementInspection{PromptID: "briefing"}, Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
|
||||
}}))
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing"}, commandkit.Streams{Out: writer, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, test.want) || !errors.Is(outcome.DeliveryError, test.want) || !outcome.ReportIncomplete || writer.output.Len() == 0 {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.WriteError, test.want) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete || writer.output.Len() == 0 {
|
||||
t.Fatalf("report delivery = %#v, partial=%q", outcome, writer.output.String())
|
||||
}
|
||||
})
|
||||
@@ -401,8 +401,8 @@ func TestRequirementsCommandPreservesDiagnosticsCancellationAndDelivery(t *testi
|
||||
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: &promptkit.RequirementInspection{PromptID: "briefing"}, Diagnostics: []commandkit.Diagnostic{badDiagnostic}}, nil
|
||||
}}))
|
||||
var report bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing"}, commandkit.Streams{Out: &report, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.DeliveryError, diagnosticErr) || outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || len(outcome.Diagnostics) == 0 || report.Len() == 0 {
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing", "--verbose"}, commandkit.Streams{Out: &report, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Diagnostics.RenderError, diagnosticErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete || report.Len() == 0 {
|
||||
t.Fatalf("diagnostic delivery outcome = %#v report=%q", outcome, report.String())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -26,8 +26,9 @@ type SmokeRequest struct {
|
||||
// invocation context. Its complete returned request is passed to PromptKit;
|
||||
// without a builder, the seed is forwarded unchanged. Reports and errors from
|
||||
// PromptKit are both retained. A nil loader is kept as a suite wiring issue and
|
||||
// does not install a smoke-test callback. The callback also retains engine
|
||||
// loading diagnostics with the report and error.
|
||||
// does not install a smoke-test callback. The callback also retains independent
|
||||
// engine diagnostics and terminal context with the report and error. Native
|
||||
// operation errors supply terminal explanations when structured context is absent.
|
||||
func WithSmokeTesting[I, S any](
|
||||
suite Suite[I, S],
|
||||
load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error),
|
||||
@@ -56,16 +57,17 @@ func WithSmokeTesting[I, S any](
|
||||
return commandkit.Acquired[*promptkit.SmokeTestReport]{}, fmt.Errorf("PromptKit smoke test after building: %w", err)
|
||||
}
|
||||
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[*promptkit.SmokeTestReport]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[*promptkit.SmokeTestReport]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
if err := promptKitContextError(ctx); err != nil {
|
||||
return commandkit.Acquired[*promptkit.SmokeTestReport]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit smoke test before native operation: %w", err)
|
||||
return commandkit.Acquired[*promptkit.SmokeTestReport]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit smoke test before native operation: %w", err)
|
||||
}
|
||||
|
||||
report, err := engine.SmokeTest(ctx, concreteRequest)
|
||||
return commandkit.Acquired[*promptkit.SmokeTestReport]{Value: report, Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[*promptkit.SmokeTestReport]{Value: report, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
return suite
|
||||
}
|
||||
|
||||
@@ -28,15 +28,15 @@ func newSmokeTestCommand[I, S any](suite Suite[I, S]) commandkit.Command {
|
||||
return &(*input).captured
|
||||
})
|
||||
flags = append(flags,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) {
|
||||
(*input).selection.Config = value
|
||||
}),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) {
|
||||
(*input).selection.Overlay = value
|
||||
}),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Smoke report format: yaml."}, stringDefault("yaml"), parseDefinitionString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Smoke report format: yaml.", Completion: completionChoices("yaml")}, stringDefault("yaml"), parseDefinitionString,
|
||||
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "backend", Usage: "PromptKit backend ID."}, (*string)(nil), parseDefinitionString,
|
||||
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) {
|
||||
@@ -138,7 +138,7 @@ func newSmokeTestCommand[I, S any](suite Suite[I, S]) commandkit.Command {
|
||||
},
|
||||
Flags: flags,
|
||||
Arguments: []commandkit.Argument[*smokeCommandInput[I]]{
|
||||
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "kind", Usage: "Native smoke-test kind."}, true, parseDefinitionString,
|
||||
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "kind", Usage: "Native smoke-test kind.", Completion: smokeKindCompletion()}, true, parseDefinitionString,
|
||||
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) { (*input).kind = value.Value }),
|
||||
},
|
||||
CheckInput: func(input *smokeCommandInput[I]) error {
|
||||
@@ -184,14 +184,14 @@ func checkSmokeFormat(format string) error {
|
||||
|
||||
func runSmokeTest[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *smokeCommandInput[I]) commandkit.Outcome {
|
||||
if err := promptKitContextError(invocation.Context); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
return definitionFailure("PromptKit smoke testing was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
var state S
|
||||
if suite.NewState != nil {
|
||||
state = suite.NewState()
|
||||
}
|
||||
if err := promptKitContextError(invocation.Context); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
return definitionFailure("PromptKit smoke testing was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
if input.kind == "all" {
|
||||
return runSmokeAggregate(invocation, suite, input, state)
|
||||
@@ -199,6 +199,7 @@ func runSmokeTest[I, S any](invocation commandkit.Invocation, suite Suite[I, S],
|
||||
|
||||
request := SmokeRequest{Selection: input.selection, Request: input.request}
|
||||
acquired, operationErr := suite.SmokeTest(invocation.Context, request, input.selected, state)
|
||||
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
|
||||
if acquired.Value == nil && operationErr == nil {
|
||||
operationErr = errors.New("PromptKit smoke test returned neither a report nor an error")
|
||||
}
|
||||
@@ -210,31 +211,42 @@ func runSmokeTest[I, S any](invocation commandkit.Invocation, suite Suite[I, S],
|
||||
}
|
||||
}
|
||||
if acquired.Value == nil {
|
||||
return definitionFailure(operationErr, acquired.Diagnostics)
|
||||
return definitionFailure(smokeFailureMessage(input.kind), acquired.Explanation, operationErr)
|
||||
}
|
||||
|
||||
output, encodeErr := encodeSmokeReport(acquired.Value)
|
||||
if encodeErr != nil {
|
||||
return smokeDeliveryFailure(operationErr, encodeErr, acquired.Diagnostics, false)
|
||||
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
|
||||
output, err := encodeSmokeReport(acquired.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = writer.Write(output)
|
||||
return err
|
||||
})
|
||||
if operationErr != nil {
|
||||
return definitionFailure(smokeFailureMessage(input.kind), acquired.Explanation, operationErr)
|
||||
}
|
||||
return writeSmokeReport(invocation, output, acquired.Diagnostics, operationErr)
|
||||
return commandkit.Success()
|
||||
}
|
||||
|
||||
func runSmokeAggregate[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *smokeCommandInput[I], state S) commandkit.Outcome {
|
||||
kinds := [...]promptkit.SmokeTestKind{
|
||||
func aggregateSmokeKinds() [4]promptkit.SmokeTestKind {
|
||||
return [...]promptkit.SmokeTestKind{
|
||||
promptkit.SmokeTestReachability,
|
||||
promptkit.SmokeTestAuthentication,
|
||||
promptkit.SmokeTestTextInference,
|
||||
promptkit.SmokeTestStructuredOutput,
|
||||
}
|
||||
}
|
||||
|
||||
func runSmokeAggregate[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *smokeCommandInput[I], state S) commandkit.Outcome {
|
||||
kinds := aggregateSmokeKinds()
|
||||
results := make([]smokeResult, 0, len(kinds))
|
||||
var diagnostics []commandkit.Diagnostic
|
||||
var operationErrors []error
|
||||
var explanations []commandkit.Explanation
|
||||
|
||||
for _, kind := range kinds {
|
||||
if err := promptKitContextError(invocation.Context); err != nil {
|
||||
if len(results) == 0 {
|
||||
return definitionFailure(err, diagnostics)
|
||||
return definitionFailure("PromptKit aggregate smoke testing was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
operationErrors = append(operationErrors, err)
|
||||
break
|
||||
@@ -253,10 +265,13 @@ func runSmokeAggregate[I, S any](invocation commandkit.Invocation, suite Suite[I
|
||||
}
|
||||
}
|
||||
|
||||
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
|
||||
results = append(results, smokeResult{Kind: kind, Report: acquired.Value, Err: operationErr})
|
||||
diagnostics = append(diagnostics, acquired.Diagnostics...)
|
||||
if operationErr != nil {
|
||||
operationErrors = append(operationErrors, operationErr)
|
||||
if acquired.Explanation.Message != "" || len(acquired.Explanation.Details) > 0 {
|
||||
explanations = append(explanations, acquired.Explanation)
|
||||
}
|
||||
}
|
||||
if err := promptKitContextError(invocation.Context); err != nil {
|
||||
break
|
||||
@@ -264,13 +279,57 @@ func runSmokeAggregate[I, S any](invocation commandkit.Invocation, suite Suite[I
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return definitionFailure(errors.Join(operationErrors...), diagnostics)
|
||||
return definitionFailure("PromptKit aggregate smoke testing did not produce any results.", combineSmokeExplanations(explanations), errors.Join(operationErrors...))
|
||||
}
|
||||
output, encodeErr := encodeSmokeResults(results)
|
||||
if encodeErr != nil {
|
||||
return smokeDeliveryFailure(errors.Join(operationErrors...), encodeErr, diagnostics, false)
|
||||
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
|
||||
output, err := encodeSmokeResults(results)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = writer.Write(output)
|
||||
return err
|
||||
})
|
||||
if len(operationErrors) > 0 {
|
||||
return definitionFailure("One or more PromptKit smoke tests failed.", combineSmokeExplanations(explanations), errors.Join(operationErrors...))
|
||||
}
|
||||
return writeSmokeReport(invocation, output, diagnostics, errors.Join(operationErrors...))
|
||||
return commandkit.Success()
|
||||
}
|
||||
|
||||
func smokeFailureMessage(kind string) string {
|
||||
return fmt.Sprintf("PromptKit %s smoke testing failed.", kind)
|
||||
}
|
||||
|
||||
func combineSmokeExplanations(explanations []commandkit.Explanation) commandkit.Explanation {
|
||||
var selected []commandkit.Explanation
|
||||
for _, explanation := range explanations {
|
||||
if explanation.Message != "" || len(explanation.Details) > 0 {
|
||||
selected = append(selected, explanation)
|
||||
}
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
return commandkit.Explanation{}
|
||||
}
|
||||
if len(selected) == 1 {
|
||||
return selected[0]
|
||||
}
|
||||
|
||||
combined := commandkit.Explanation{Message: selected[0].Message}
|
||||
combined.Details = append(combined.Details, selected[0].Details...)
|
||||
for _, explanation := range selected[1:] {
|
||||
if explanation.Message != "" {
|
||||
message := explanation.Message
|
||||
combined.Details = append(combined.Details, func(writer io.Writer) error {
|
||||
return writeSmokeExplanationMessage(writer, message)
|
||||
})
|
||||
}
|
||||
combined.Details = append(combined.Details, explanation.Details...)
|
||||
}
|
||||
return combined
|
||||
}
|
||||
|
||||
func writeSmokeExplanationMessage(writer io.Writer, message string) error {
|
||||
_, err := io.WriteString(writer, message+"\n")
|
||||
return err
|
||||
}
|
||||
|
||||
func smokeRequestForKind(seed promptkit.SmokeTestRequest, kind promptkit.SmokeTestKind) promptkit.SmokeTestRequest {
|
||||
@@ -312,30 +371,3 @@ func copyString(value *string) *string {
|
||||
copy := *value
|
||||
return ©
|
||||
}
|
||||
|
||||
func writeSmokeReport(invocation commandkit.Invocation, output []byte, diagnostics []commandkit.Diagnostic, operationErr error) commandkit.Outcome {
|
||||
written, deliveryErr := invocation.Streams.Out.Write(output)
|
||||
if deliveryErr == nil && written != len(output) {
|
||||
deliveryErr = io.ErrShortWrite
|
||||
}
|
||||
if deliveryErr != nil {
|
||||
return smokeDeliveryFailure(operationErr, deliveryErr, diagnostics, true)
|
||||
}
|
||||
if operationErr != nil {
|
||||
return definitionFailure(operationErr, diagnostics)
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
}
|
||||
|
||||
func smokeDeliveryFailure(operationErr, deliveryErr error, diagnostics []commandkit.Diagnostic, reportStarted bool) commandkit.Outcome {
|
||||
cause := operationErr
|
||||
if cause == nil {
|
||||
cause = deliveryErr
|
||||
}
|
||||
outcome := definitionFailure(cause, diagnostics)
|
||||
outcome.DeliveryError = deliveryErr
|
||||
outcome.ReportIncomplete = reportStarted
|
||||
return outcome
|
||||
}
|
||||
|
||||
@@ -105,16 +105,36 @@ func TestSmokeTestCommandBindsRequestAndRendersFindings(t *testing.T) {
|
||||
|
||||
func TestSmokeTestCommandDeliversReportWithOperationError(t *testing.T) {
|
||||
sentinel := errors.New("native smoke operation failed")
|
||||
warning := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "independent smoke warning")
|
||||
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{
|
||||
SmokeTest: func(context.Context, SmokeRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
|
||||
return commandkit.Acquired[*promptkit.SmokeTestReport]{Value: &promptkit.SmokeTestReport{Kind: promptkit.SmokeTestAuthentication, Completed: true}}, sentinel
|
||||
return commandkit.Acquired[*promptkit.SmokeTestReport]{
|
||||
Value: &promptkit.SmokeTestReport{Kind: promptkit.SmokeTestAuthentication, Completed: true},
|
||||
Diagnostics: []commandkit.Diagnostic{warning},
|
||||
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "Native authentication context\n")
|
||||
return err
|
||||
}}},
|
||||
}, sentinel
|
||||
},
|
||||
}))
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "authentication"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
var output, diagnosticOutput bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "authentication", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, sentinel) || output.Len() == 0 {
|
||||
t.Fatalf("report-plus-error outcome = (%#v, %q)", outcome, output.String())
|
||||
}
|
||||
warningAt := strings.Index(diagnosticOutput.String(), "independent smoke warning")
|
||||
explanationAt := strings.Index(diagnosticOutput.String(), "Native authentication context")
|
||||
if warningAt < 0 || explanationAt < 0 || warningAt >= explanationAt || strings.Contains(diagnosticOutput.String(), sentinel.Error()) {
|
||||
t.Fatalf("smoke warning and native terminal context were not separate and ordered: %q", diagnosticOutput.String())
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
diagnosticOutput.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"promptkit", "test", "authentication", "--quiet"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, sentinel) || output.Len() != 0 || !strings.Contains(diagnosticOutput.String(), "Native authentication context") || strings.Contains(diagnosticOutput.String(), "independent smoke warning") {
|
||||
t.Fatalf("quiet report-plus-error = (%#v, out=%q, err=%q)", outcome, output.String(), diagnosticOutput.String())
|
||||
}
|
||||
|
||||
app = newPromptTestApp(t, Command(Suite[struct{}, struct{}]{
|
||||
SmokeTest: func(context.Context, SmokeRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
|
||||
@@ -154,7 +174,7 @@ func TestSmokeTestCommandRetainsShortReportWrite(t *testing.T) {
|
||||
},
|
||||
}))
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "authentication"}, commandkit.Streams{Out: smokeShortWriter{}, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, io.ErrShortWrite) || !errors.Is(outcome.DeliveryError, io.ErrShortWrite) || !outcome.ReportIncomplete {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.WriteError, io.ErrShortWrite) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete {
|
||||
t.Fatalf("short report delivery = %#v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,10 +105,9 @@ func TestSmokeTestEncodingAndWriterFailuresRetainOutcomeFields(t *testing.T) {
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var diagnosticOutput bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "authentication"}, commandkit.Streams{Out: test.writer, Err: &diagnosticOutput})
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "authentication", "--verbose"}, commandkit.Streams{Out: test.writer, Err: &diagnosticOutput})
|
||||
warningIndex := strings.Index(diagnosticOutput.String(), "smoke warning")
|
||||
operationIndex := strings.Index(diagnosticOutput.String(), operationErr.Error())
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, operationErr) || !errors.Is(outcome.DeliveryError, test.want) || !outcome.ReportIncomplete || outcome.DiagnosticDelivery != commandkit.DeliveryComplete || warningIndex < 0 || operationIndex < warningIndex {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, operationErr) || !errors.Is(outcome.Presentation.Output.WriteError, test.want) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete || warningIndex < 0 || strings.Contains(diagnosticOutput.String(), operationErr.Error()) {
|
||||
t.Fatalf("report delivery = %#v, diagnostics=%q", outcome, diagnosticOutput.String())
|
||||
}
|
||||
})
|
||||
@@ -116,8 +115,8 @@ func TestSmokeTestEncodingAndWriterFailuresRetainOutcomeFields(t *testing.T) {
|
||||
|
||||
diagnosticErr := errors.New("smoke diagnostic writer failed")
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "authentication"}, commandkit.Streams{Out: &output, Err: partialReportWriter{err: diagnosticErr}})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, operationErr) || !errors.Is(outcome.DeliveryError, diagnosticErr) || outcome.ReportIncomplete || outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || output.Len() == 0 {
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "authentication", "--verbose"}, commandkit.Streams{Out: &output, Err: partialReportWriter{err: diagnosticErr}})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, operationErr) || !errors.Is(outcome.Presentation.Diagnostics.WriteError, diagnosticErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete || output.Len() == 0 {
|
||||
t.Fatalf("diagnostic delivery = (%#v, %q)", outcome, output.String())
|
||||
}
|
||||
|
||||
@@ -130,7 +129,7 @@ func TestSmokeTestEncodingAndWriterFailuresRetainOutcomeFields(t *testing.T) {
|
||||
}))
|
||||
output.Reset()
|
||||
outcome = encodeApp.Execute(context.Background(), []string{"promptkit", "test", "authentication"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause == nil || !errors.Is(outcome.DeliveryError, outcome.Cause) || outcome.ReportIncomplete || output.Len() != 0 {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || outcome.Presentation.Output.RenderError == nil || output.Len() != 0 {
|
||||
t.Fatalf("encoding failure = (%#v, %q)", outcome, output.String())
|
||||
}
|
||||
}
|
||||
@@ -151,15 +150,24 @@ func TestSmokeAggregatePreservesMultipleTypedCauses(t *testing.T) {
|
||||
case promptkit.SmokeTestAuthentication:
|
||||
err = secondErr
|
||||
}
|
||||
return commandkit.Acquired[*promptkit.SmokeTestReport]{Value: &promptkit.SmokeTestReport{Kind: request.Request.Kind}}, err
|
||||
acquired := commandkit.Acquired[*promptkit.SmokeTestReport]{Value: &promptkit.SmokeTestReport{Kind: request.Request.Kind}}
|
||||
if err != nil {
|
||||
acquired.Explanation = commandkit.Explanation{Message: string(request.Request.Kind) + " native context"}
|
||||
}
|
||||
return acquired, err
|
||||
},
|
||||
}))
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
var output, diagnosticOutput bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "all"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||
var retained *smokeTypedError
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, firstErr) || !errors.Is(outcome.Cause, secondErr) || !errors.As(outcome.Cause, &retained) || retained != firstErr {
|
||||
t.Fatalf("joined aggregate errors lost identity: %#v, retained=%#v", outcome, retained)
|
||||
}
|
||||
firstContext := strings.Index(diagnosticOutput.String(), "reachability native context")
|
||||
secondContext := strings.Index(diagnosticOutput.String(), "authentication native context")
|
||||
if firstContext < 0 || secondContext < 0 || firstContext >= secondContext {
|
||||
t.Fatalf("aggregate native explanations were not retained in order: %q", diagnosticOutput.String())
|
||||
}
|
||||
var document struct {
|
||||
Tests []struct {
|
||||
Kind string `yaml:"kind"`
|
||||
|
||||
@@ -213,7 +213,7 @@ func TestWithRequestValidationBuilderOutputReachesNativeValidation(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithRequestValidationRetainsNilEngineAndLoadDiagnostics(t *testing.T) {
|
||||
func TestWithRequestValidationRetainsNilEngineAndLoadExplanation(t *testing.T) {
|
||||
request := ValidationRequest{Request: promptkit.RunRequest{PromptID: "empty", ProfileID: "default"}}
|
||||
nilEngineSuite := WithRequestValidation(Suite[struct{}, struct{}]{}, func(context.Context, ckconfig.Selection, struct{}, struct{}) (*promptkit.Engine, error) {
|
||||
return nil, nil
|
||||
@@ -232,8 +232,8 @@ func TestWithRequestValidationRetainsNilEngineAndLoadDiagnostics(t *testing.T) {
|
||||
}, nil)
|
||||
acquired, err = loadFailure.ValidateRequest(context.Background(), request, struct{}{}, struct{}{})
|
||||
var retained *configkit.LoadError
|
||||
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
t.Fatalf("load failure = (%#v, %v), want original ConfigKit error and diagnostics", acquired, err)
|
||||
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
|
||||
t.Fatalf("load failure = (%#v, %v), want original ConfigKit error and terminal context", acquired, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ func TestWithRequestValidationCancellationBoundaries(t *testing.T) {
|
||||
}, nil)
|
||||
acquired, err := suite.ValidateRequest(ctx, request, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 || loaderCalls != 1 {
|
||||
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 || loaderCalls != 1 {
|
||||
t.Fatalf("cancelled load = (%#v, %v), loader calls=%d", acquired, err, loaderCalls)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ package ckprompt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
@@ -28,11 +29,11 @@ func newValidationCommand[I, S any](suite Suite[I, S]) commandkit.Command {
|
||||
return &(*input).captured
|
||||
})
|
||||
flags = append(flags,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||
func(input **validationCommandInput[I], value commandkit.Supplied[string]) {
|
||||
(*input).request.Selection.Config = value
|
||||
}),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||
func(input **validationCommandInput[I], value commandkit.Supplied[string]) {
|
||||
(*input).request.Selection.Overlay = value
|
||||
}),
|
||||
@@ -44,7 +45,7 @@ func newValidationCommand[I, S any](suite Suite[I, S]) commandkit.Command {
|
||||
func(input **validationCommandInput[I], value commandkit.Supplied[string]) {
|
||||
(*input).request.Request.SessionID = value.Value
|
||||
}),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Confirmation format: yaml."}, stringDefault("yaml"), parseDefinitionString,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Confirmation format: yaml.", Completion: completionChoices("yaml")}, stringDefault("yaml"), parseDefinitionString,
|
||||
func(input **validationCommandInput[I], value commandkit.Supplied[string]) {
|
||||
(*input).format = value.Value
|
||||
}),
|
||||
@@ -102,26 +103,25 @@ func newValidationCommand[I, S any](suite Suite[I, S]) commandkit.Command {
|
||||
|
||||
func runRequestValidation[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *validationCommandInput[I]) commandkit.Outcome {
|
||||
if err := promptKitContextError(invocation.Context); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
return definitionFailure("PromptKit request validation was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
var state S
|
||||
if suite.NewState != nil {
|
||||
state = suite.NewState()
|
||||
}
|
||||
if err := promptKitContextError(invocation.Context); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
return definitionFailure("PromptKit request validation was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
|
||||
acquired, err := suite.ValidateRequest(invocation.Context, input.request, input.selected, state)
|
||||
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure(fmt.Sprintf("Unable to validate the PromptKit request for prompt %q.", input.promptID), acquired.Explanation, err)
|
||||
}
|
||||
if err := promptKitContextError(invocation.Context); err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure(fmt.Sprintf("Validation of the PromptKit request for prompt %q was canceled.", input.promptID), acquired.Explanation, err)
|
||||
}
|
||||
output, err := encodePromptYAMLBytes(requestValidationReport{PromptID: input.promptID, Valid: true})
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
}
|
||||
return writePromptReport(invocation, output, acquired.Diagnostics)
|
||||
return renderPreparedPromptReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||
return encodePromptYAMLBytes(requestValidationReport{PromptID: input.promptID, Valid: true})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestValidationCommandIsAvailableIndependentlyAndOwnsItsFamily(t *testing.T)
|
||||
}}))
|
||||
var help bytes.Buffer
|
||||
outcome := validationOnly.Execute(context.Background(), []string{"help", "promptkit", "requirements"}, commandkit.Streams{Out: &help, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(help.String(), "validate") || strings.Contains(help.String(), "show") {
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(help.String(), "validate") || strings.Contains(help.String(), "\n show ") {
|
||||
t.Fatalf("validation-only availability = (%#v, %q)", outcome, help.String())
|
||||
}
|
||||
|
||||
@@ -359,41 +359,32 @@ func TestValidationCommandUsesNativeOutcomesAndMinimalConfirmation(t *testing.T)
|
||||
}
|
||||
|
||||
func TestValidationCommandRetainsProviderDiagnosticsAndClassifiesFailures(t *testing.T) {
|
||||
warning := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "validation warning\n")
|
||||
return err
|
||||
}}
|
||||
withWarning := func(diagnostics []commandkit.Diagnostic) bool {
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.Severity == commandkit.SeverityWarning && diagnostic.Write != nil {
|
||||
var output bytes.Buffer
|
||||
if diagnostic.Write(&output) == nil && output.String() == "validation warning\n" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
warning := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "validation warning")
|
||||
var diagnostics bytes.Buffer
|
||||
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ValidateRequest: func(context.Context, ValidationRequest, struct{}, struct{}) (commandkit.Acquired[struct{}], error) {
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: []commandkit.Diagnostic{warning}}, nil
|
||||
}}))
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: io.Discard, Err: &diagnostics})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 || outcome.DiagnosticDelivery != commandkit.DeliveryComplete || !withWarning(outcome.Diagnostics) || diagnostics.String() != "validation warning\n" {
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing", "--verbose"}, commandkit.Streams{Out: io.Discard, Err: &diagnostics})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete || !strings.Contains(diagnostics.String(), "validation warning") {
|
||||
t.Fatalf("warning-only success = %#v, stderr=%q", outcome, diagnostics.String())
|
||||
}
|
||||
var quietReport, quietDiagnostics bytes.Buffer
|
||||
quietOutcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing", "--quiet"}, commandkit.Streams{Out: &quietReport, Err: &quietDiagnostics})
|
||||
if quietOutcome.Kind != commandkit.OutcomeSuccess || quietOutcome.Presentation.Output.State != commandkit.DeliverySuppressed || quietOutcome.Presentation.Diagnostics.State != commandkit.DeliverySuppressed || quietReport.Len() != 0 || quietDiagnostics.Len() != 0 {
|
||||
t.Fatalf("quiet validation = %#v, report=%q diagnostics=%q", quietOutcome, quietReport.String(), quietDiagnostics.String())
|
||||
}
|
||||
|
||||
providerErr := errors.New("validation provider failed")
|
||||
failedApp := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ValidateRequest: func(context.Context, ValidationRequest, struct{}, struct{}) (commandkit.Acquired[struct{}], error) {
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: []commandkit.Diagnostic{warning}}, providerErr
|
||||
}}))
|
||||
diagnostics.Reset()
|
||||
outcome = failedApp.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: io.Discard, Err: &diagnostics})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Cause, providerErr) || !withWarning(outcome.Diagnostics) || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
|
||||
outcome = failedApp.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing", "--verbose"}, commandkit.Streams{Out: io.Discard, Err: &diagnostics})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Cause, providerErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete {
|
||||
t.Fatalf("provider failure = %#v, stderr=%q", outcome, diagnostics.String())
|
||||
}
|
||||
if strings.Count(diagnostics.String(), "validation warning") != 1 || strings.Count(diagnostics.String(), providerErr.Error()) != 1 {
|
||||
t.Fatalf("provider diagnostics were lost or duplicated: stderr=%q", diagnostics.String())
|
||||
if strings.Count(diagnostics.String(), "validation warning") != 1 || strings.Contains(diagnostics.String(), providerErr.Error()) || !strings.Contains(diagnostics.String(), "Unable to validate the PromptKit request") {
|
||||
t.Fatalf("provider warning or terminal explanation was incorrect: stderr=%q", diagnostics.String())
|
||||
}
|
||||
|
||||
builderErr := errors.New("request builder failed")
|
||||
@@ -418,16 +409,13 @@ func TestValidationCommandRetainsProviderDiagnosticsAndClassifiesFailures(t *tes
|
||||
}, nil)))
|
||||
outcome = loadApp.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
var retained *configkit.LoadError
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Cause, loadErr) || !errors.As(outcome.Cause, &retained) || retained != loadErr || len(outcome.Diagnostics) == 0 {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Cause, loadErr) || !errors.As(outcome.Cause, &retained) || retained != loadErr || outcome.Explanation.Message == "" && len(outcome.Explanation.Details) == 0 {
|
||||
t.Fatalf("loader failure = %#v, want retained ConfigKit error and diagnostics", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationCommandCancellationPreventsAcquisitionAndReport(t *testing.T) {
|
||||
warning := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "validation warning\n")
|
||||
return err
|
||||
}}
|
||||
warning := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "validation warning")
|
||||
calls := 0
|
||||
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ValidateRequest: func(context.Context, ValidationRequest, struct{}, struct{}) (commandkit.Acquired[struct{}], error) {
|
||||
calls++
|
||||
@@ -448,8 +436,9 @@ func TestValidationCommandCancellationPreventsAcquisitionAndReport(t *testing.T)
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: []commandkit.Diagnostic{warning}}, nil
|
||||
}}))
|
||||
output.Reset()
|
||||
outcome = canceledApp.Execute(ctx, []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || !withValidationWarning(outcome.Diagnostics) || output.Len() != 0 {
|
||||
var diagnostics bytes.Buffer
|
||||
outcome = canceledApp.Execute(ctx, []string{"promptkit", "requirements", "validate", "briefing", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnostics})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || !strings.Contains(diagnostics.String(), "validation warning") || output.Len() != 0 {
|
||||
t.Fatalf("post-acquisition cancellation = %#v, output=%q", outcome, output.String())
|
||||
}
|
||||
|
||||
@@ -459,8 +448,9 @@ func TestValidationCommandCancellationPreventsAcquisitionAndReport(t *testing.T)
|
||||
cancel()
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: []commandkit.Diagnostic{warning}}, providerErr
|
||||
}}))
|
||||
outcome = errorApp.Execute(ctx, []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, providerErr) || !withValidationWarning(outcome.Diagnostics) {
|
||||
var errorDiagnostics bytes.Buffer
|
||||
outcome = errorApp.Execute(ctx, []string{"promptkit", "requirements", "validate", "briefing", "--verbose"}, commandkit.Streams{Out: io.Discard, Err: &errorDiagnostics})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, providerErr) || !strings.Contains(errorDiagnostics.String(), "validation warning") {
|
||||
t.Fatalf("provider error after cancellation = %#v", outcome)
|
||||
}
|
||||
}
|
||||
@@ -484,28 +474,16 @@ func TestValidationCommandPreservesReportAndDiagnosticDeliveryFailures(t *testin
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var diagnosticOutput bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: test.writer, Err: &diagnosticOutput})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, test.want) || !errors.Is(outcome.DeliveryError, test.want) || outcome.DiagnosticDelivery != commandkit.DeliveryComplete || !strings.Contains(diagnosticOutput.String(), "validation warning") {
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing", "--verbose"}, commandkit.Streams{Out: test.writer, Err: &diagnosticOutput})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.WriteError, test.want) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete || !strings.Contains(diagnosticOutput.String(), "validation warning") {
|
||||
t.Fatalf("report delivery = %#v, diagnostics=%q", outcome, diagnosticOutput.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
diagnosticErr := errors.New("validation diagnostic writer failed")
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: io.Discard, Err: partialReportWriter{err: diagnosticErr}})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.ReportIncomplete || !errors.Is(outcome.DeliveryError, diagnosticErr) || outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete {
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing", "--verbose"}, commandkit.Streams{Out: io.Discard, Err: partialReportWriter{err: diagnosticErr}})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Diagnostics.WriteError, diagnosticErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete {
|
||||
t.Fatalf("diagnostic delivery = %#v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func withValidationWarning(diagnostics []commandkit.Diagnostic) bool {
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.Severity == commandkit.SeverityWarning && diagnostic.Write != nil {
|
||||
var output bytes.Buffer
|
||||
if diagnostic.Write(&output) == nil && output.String() == "validation warning\n" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
148
internal/alias/machine.go
Normal file
148
internal/alias/machine.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package alias
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||
)
|
||||
|
||||
type symbolKind uint8
|
||||
|
||||
const (
|
||||
literalSymbol symbolKind = iota
|
||||
otherSymbol
|
||||
endSymbol
|
||||
boundarySymbol
|
||||
)
|
||||
|
||||
type symbol struct {
|
||||
kind symbolKind
|
||||
value string
|
||||
}
|
||||
|
||||
type transitionKey struct {
|
||||
control int
|
||||
read symbol
|
||||
}
|
||||
|
||||
type transition struct {
|
||||
next int
|
||||
push []symbol
|
||||
rule int
|
||||
}
|
||||
|
||||
// Machine is a finite-control pushdown rewrite model compiled from a resolver.
|
||||
// Its transitions replace the stack's first symbol with zero to two symbols.
|
||||
type Machine struct {
|
||||
start int
|
||||
halt int
|
||||
transitions map[transitionKey]transition
|
||||
alphabet []symbol
|
||||
literals map[string]struct{}
|
||||
mappings []Mapping
|
||||
}
|
||||
|
||||
// Compile builds a finite stack machine with explicit lookup, restoration,
|
||||
// and rewrite transitions for the mappings.
|
||||
func Compile(mappings []Mapping) (*Resolver, *Machine, error) {
|
||||
resolver, err := NewResolver(mappings)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return resolver, compileMachine(resolver), nil
|
||||
}
|
||||
|
||||
func compileMachine(resolver *Resolver) *Machine {
|
||||
// Trie controls only move to child nodes. Helper controls prepend a fixed
|
||||
// number of saved symbols and then halt or return to the trie root after a
|
||||
// selected mapping has been completed.
|
||||
// End and boundary symbols are only restored as the final item below any
|
||||
// emitted command tokens. No transition places a marker above ordinary
|
||||
// input, so each remains the unique bottom marker of a valid stack.
|
||||
machine := &Machine{
|
||||
start: 0,
|
||||
halt: len(resolver.trie),
|
||||
transitions: make(map[transitionKey]transition),
|
||||
literals: make(map[string]struct{}),
|
||||
mappings: resolver.mappings,
|
||||
}
|
||||
for _, mapping := range resolver.mappings {
|
||||
for _, path := range []commandpath.Path{mapping.Source, mapping.Target} {
|
||||
for _, component := range path {
|
||||
machine.literals[component] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
for literal := range machine.literals {
|
||||
machine.alphabet = append(machine.alphabet, symbol{kind: literalSymbol, value: literal})
|
||||
}
|
||||
sort.Slice(machine.alphabet, func(i, j int) bool { return machine.alphabet[i].value < machine.alphabet[j].value })
|
||||
machine.alphabet = append(machine.alphabet, symbol{kind: otherSymbol}, symbol{kind: endSymbol}, symbol{kind: boundarySymbol})
|
||||
|
||||
nextControl := machine.halt + 1
|
||||
for nodeIndex, node := range resolver.trie {
|
||||
for _, input := range machine.alphabet {
|
||||
if input.kind == literalSymbol {
|
||||
if childIndex, exists := node.children[input.value]; exists {
|
||||
machine.transitions[transitionKey{control: nodeIndex, read: input}] = transition{next: childIndex, rule: -1}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
word, next, rule := machine.lookupOutcome(resolver, nodeIndex, input)
|
||||
machine.addWordTransition(nodeIndex, input, word, next, rule, &nextControl)
|
||||
}
|
||||
}
|
||||
return machine
|
||||
}
|
||||
|
||||
func (machine *Machine) lookupOutcome(resolver *Resolver, nodeIndex int, input symbol) ([]symbol, int, int) {
|
||||
node := resolver.trie[nodeIndex]
|
||||
word := make([]symbol, 0, len(node.path)+1)
|
||||
next := machine.halt
|
||||
rule := -1
|
||||
if node.best >= 0 {
|
||||
mapping := resolver.mappings[node.best]
|
||||
matchedLength := len(mapping.Source)
|
||||
for _, component := range mapping.Target {
|
||||
word = append(word, symbol{kind: literalSymbol, value: component})
|
||||
}
|
||||
for _, component := range node.path[matchedLength:] {
|
||||
word = append(word, symbol{kind: literalSymbol, value: component})
|
||||
}
|
||||
next = machine.start
|
||||
rule = node.best
|
||||
} else {
|
||||
for _, component := range node.path {
|
||||
word = append(word, symbol{kind: literalSymbol, value: component})
|
||||
}
|
||||
}
|
||||
word = append(word, input)
|
||||
return word, next, rule
|
||||
}
|
||||
|
||||
func (machine *Machine) addWordTransition(control int, read symbol, word []symbol, next, rule int, nextControl *int) {
|
||||
key := transitionKey{control: control, read: read}
|
||||
if len(word) <= 2 {
|
||||
machine.transitions[key] = transition{next: next, push: append([]symbol(nil), word...), rule: rule}
|
||||
return
|
||||
}
|
||||
|
||||
helper := *nextControl
|
||||
*nextControl = *nextControl + 1
|
||||
machine.transitions[key] = transition{next: helper, push: append([]symbol(nil), word[len(word)-2:]...), rule: rule}
|
||||
for index := len(word) - 3; index >= 0; index-- {
|
||||
following := next
|
||||
if index > 0 {
|
||||
following = *nextControl
|
||||
*nextControl = *nextControl + 1
|
||||
}
|
||||
expectedTop := word[index+1]
|
||||
machine.transitions[transitionKey{control: helper, read: expectedTop}] = transition{
|
||||
next: following,
|
||||
push: []symbol{word[index], expectedTop},
|
||||
rule: rule,
|
||||
}
|
||||
helper = following
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user