144 Commits

Author SHA1 Message Date
e256eb1cc0 Migrate core to the vanity module path 2026-11-04 03:49:38 +00:00
881dae27c4 Plan vanity migration and integration releases 2026-11-04 03:44:22 +00:00
c8d659dfe5 Plan CommandKit vanity path migration 2026-11-04 03:37:44 +00:00
d7cbaed3ed Plan integration dependency alignment 2026-11-04 03:28:25 +00:00
289a171e97 Complete ReleaseKit adoption roadmap 2026-11-04 03:04:15 +00:00
2cde616ce5 Record integration release validation 2026-11-04 03:03:44 +00:00
42d1774019 Record root release validation 2026-11-04 03:03:09 +00:00
bdc3fd8fa3 Update ReleaseKit documentation and attribution 2026-11-04 03:02:16 +00:00
eb974b6d29 Upgrade ReleaseKit to version 0.9.0 2026-11-04 03:01:25 +00:00
0dc4ac41f0 Make completion alias assertion order independent 2026-11-04 03:00:58 +00:00
129f95be0f Plan ReleaseKit v0.9.0 implementation 2026-11-04 02:52:09 +00:00
87eb84ccb4 Record release code retention decisions 2026-11-04 02:44:34 +00:00
0fc00039bd Update ReleaseKit adoption plan after remediation 2026-11-04 02:38:06 +00:00
4d92ee4ee6 Restore optional shell handling in launcher tests 2026-11-04 02:34:30 +00:00
7eac3fea65 Reject invalid legacy release selectors 2026-11-04 02:29:09 +00:00
12a6fa66fa Plan ReleaseKit v0.9.0 adoption 2026-11-04 02:26:33 +00:00
98e7e99333 Record release tooling review findings 2026-11-03 15:53:45 +00:00
124a9fa57f Complete release tooling roadmap 2026-11-03 15:47:34 +00:00
75c0d5bce2 Record release tooling validation 2026-11-03 15:47:18 +00:00
007eae4ed0 Document release delivery workflow 2026-11-03 15:46:20 +00:00
48fd2dc7bc Test release launcher wiring 2026-11-03 15:45:45 +00:00
b94ed6665b Centralize release tool invocation 2026-11-03 15:45:15 +00:00
7344994198 Configure release checks and delivery 2026-11-03 15:44:56 +00:00
cb40f1d9f8 Finalize release tooling implementation plan 2026-11-03 15:36:23 +00:00
3d396b1a66 Plan hosted Gitea release delivery 2026-11-03 15:26:02 +00:00
09bb51d1c3 Plan release tooling simplification 2026-11-03 15:21:51 +00:00
a74fcb2004 Upgrade ReleaseKit and align release profiles with SSH 2026-11-03 15:09:19 +00:00
3934038b98 Retire completed shell completion roadmaps 2026-11-03 14:47:36 +00:00
0acbbf45ad Skip sorting flag aliases during completion 2026-11-03 13:15:21 +00:00
f4ed4c4839 Index completion metadata by canonical node 2026-11-03 13:13:20 +00:00
5227049de6 Suggest PromptKit smoke test kinds 2026-11-03 13:13:01 +00:00
8964ad02ad Document Bash completion setup 2026-11-03 13:12:36 +00:00
f79f446726 Restore comparability of completion metadata 2026-11-03 13:12:22 +00:00
b83c0a0dfc Separate completion metadata from literal choices 2026-11-03 13:12:05 +00:00
f39f27b5cd Use parsed replacement prefixes in shell completion 2026-11-03 13:11:07 +00:00
8b48115328 Keep completion words out of shell evaluation 2026-11-03 13:09:00 +00:00
c205903900 Record shell completion efficiency findings 2026-11-03 12:54:34 +00:00
c87bba3d2e Record shell completion interaction finding 2026-11-03 12:50:29 +00:00
d3d40acea0 Record shell completion audit findings 2026-11-03 12:48:52 +00:00
0450ecfc16 Validate shell completion across supported shells 2026-11-03 04:16:39 +00:00
96250d1887 Add completion hints to maintained commands 2026-11-03 04:09:27 +00:00
d1f31d2931 Deliver shell completion query replies 2026-11-03 04:06:05 +00:00
4c47a0ca0b Interpret flags and operands for completion 2026-11-03 03:54:22 +00:00
8bb5383169 Resolve command context for completion 2026-11-03 03:51:26 +00:00
fc2f6d5346 Install shell completion as an optional command 2026-11-03 03:50:12 +00:00
0bed76a6f7 Capture completion hints in command metadata 2026-11-03 03:47:23 +00:00
1d193c87fe Verify shell completion transport 2026-11-03 03:45:47 +00:00
10a799a2b7 Plan shell completion implementation 2026-11-03 03:32:18 +00:00
230187296f Stop flag interpretation at unknown flags 2026-10-26 14:37:33 +00:00
473fb027a1 Avoid retaining hidden simple deferred messages 2026-10-26 14:35:44 +00:00
135d8da2b9 Resolve flag aliases in failed command routes 2026-10-26 14:33:31 +00:00
de2266af75 Restore cancellation checks before report delivery 2026-10-26 14:32:03 +00:00
13a4d4eacd Explain invocation presentation failures 2026-10-26 14:28:58 +00:00
215fc34d51 Preserve joined rendering error evidence 2026-10-26 14:27:24 +00:00
3338b902a2 Present native PromptKit operation failures 2026-10-26 14:18:21 +00:00
41c6ca8070 Record reporting efficiency opportunities 2026-10-26 14:14:50 +00:00
ee76b0f5f1 Record reporting integration audit findings 2026-10-26 14:01:28 +00:00
1e532cdfa5 Record reporting audit findings 2026-10-26 12:41:36 +00:00
7c035ccc54 Validate invocation reporting implementation 2026-10-26 03:59:28 +00:00
96aa6b5ca0 Document invocation reporting contracts 2026-10-26 03:57:11 +00:00
a6c0fb5b0d Reconcile reporting tests and boundaries 2026-10-26 03:51:14 +00:00
6264a87183 Migrate maintained examples to invocation reporting 2026-10-26 03:44:19 +00:00
b6f22462d3 Add a core reporting example 2026-10-26 03:38:48 +00:00
401232a1b0 Migrate PromptKit validation and smoke reporting 2026-10-26 03:35:39 +00:00
4ace616bc2 Fix PromptKit inspection reporter assertions 2026-10-26 03:34:50 +00:00
7c010e945a Update requirements inspection reporting tests 2026-10-26 03:31:06 +00:00
7fc89768ef Migrate PromptKit inspection reports to the reporter 2026-10-26 03:30:19 +00:00
37dfea5b25 Separate PromptKit messages from terminal context 2026-10-26 03:26:14 +00:00
c0e0010683 Migrate ConfigKit reports to the reporter 2026-10-26 03:20:17 +00:00
5f37aac70f Render ConfigKit show and validate through reporter 2026-10-26 03:16:02 +00:00
9a4d1db955 Separate ConfigKit messages from explanations 2026-10-26 03:10:18 +00:00
f3e9326544 Add debug events to command execution 2026-10-26 03:03:57 +00:00
7a1067d387 Route help and version through the reporter 2026-10-26 03:02:08 +00:00
49a10690c6 Connect invocation execution to one reporter 2026-10-26 02:59:28 +00:00
df21b75f9c Preserve reporting flag occurrence order 2026-10-26 02:55:08 +00:00
f3bd6dc2ae Install shared reporting flags 2026-10-26 02:50:54 +00:00
6ba9338c0a Add deferred reporting finalization 2026-10-26 02:44:41 +00:00
5df88a6d94 Add atomic reporting and message categories 2026-10-26 02:42:22 +00:00
f5a5bbac58 Add managed reporter output state 2026-10-26 02:39:16 +00:00
e56537690b Define invocation outcome reporting types 2026-10-26 02:37:19 +00:00
2a1096bd1f Plan invocation reporting implementation 2026-10-26 01:39:40 +00:00
1d29cdd029 Clarify provider diagnostics and reporting edge cases 2026-10-26 01:25:11 +00:00
e48187d822 Accept invocation reporting architecture and clarify error ownership 2026-10-26 01:18:28 +00:00
e951a48902 Specify invocation reporting API signatures 2026-10-26 00:10:36 +00:00
976ed89403 Clarify reporting lifecycle and concurrency contracts 2026-10-26 00:00:25 +00:00
a5a92c7f08 Settle reporting delivery lifecycle and quiet rendering 2026-10-24 02:01:59 +00:00
514c4d47bb Settle failure presentation and reporting retention boundaries 2026-10-24 01:56:41 +00:00
ec29d658b7 Clarify application error handling and reporting ownership 2026-10-24 01:50:38 +00:00
085c296d89 Define terminal failure presentation for invocation reporting 2026-10-24 01:19:16 +00:00
0869b17b43 Select unified invocation reporting API direction 2026-10-24 01:01:48 +00:00
810333e376 Record progress controls and reporting prefixes 2026-10-24 00:42:35 +00:00
2e337155ce Settle reporting flag grammar and core debug guidance 2026-10-23 18:42:21 +00:00
fa0aeace36 Expand reporting categories and define failure-bound errors 2026-10-23 18:08:13 +00:00
09d4d7992a Draft output policy and invocation reporting roadmap 2026-10-23 14:08:10 +00:00
c70e29d3fd Remove completed alias interaction remediation document 2026-10-20 04:12:02 +00:00
e47ae799d7 Keep empty and dash operands with runnable parents 2026-10-20 01:07:23 +00:00
5c26d80228 Retain discovery prefixes in usage diagnostics 2026-10-20 01:04:56 +00:00
bafd365115 Discover help routes through intermediate aliases 2026-10-20 01:03:54 +00:00
4ddf960836 Record alias interaction audit findings 2026-10-20 00:39:24 +00:00
08ad7427e2 Remove completed alias planning and remediation documents 2026-10-20 00:33:38 +00:00
f42aefe02d Reconcile alias documentation with the completed implementation 2026-10-20 00:30:53 +00:00
520d7e125b Retain entered flag spellings in parser failures 2026-10-20 00:29:49 +00:00
eb92f7be7f Preserve double dash values during flag alias parsing 2026-10-20 00:28:38 +00:00
744b8135f2 Show aliases beneath empty command groups 2026-10-20 00:26:07 +00:00
c231a41665 Compose discovery help through group aliases 2026-10-20 00:25:33 +00:00
fa09edf8cc Require exact resolved paths for explicit help 2026-10-20 00:23:19 +00:00
7dad20680e Keep command routing independent of operand counts 2026-10-20 00:22:25 +00:00
ccf8acd861 Protect canonical operand prefixes from aliases 2026-10-20 00:20:40 +00:00
c225a66c97 Record alias implementation review findings 2026-10-20 00:03:54 +00:00
a54a9354b1 Verify and complete alias implementation 2026-10-19 20:02:07 +00:00
42d9989533 Prepare core alias release notes 2026-10-19 20:00:09 +00:00
333a630cf1 Document application-owned alias contracts 2026-10-19 19:59:41 +00:00
666c006a89 Add aliases to maintained examples 2026-10-19 19:56:33 +00:00
c3bb3941d3 Verify typed alias composition in integrations 2026-10-19 19:53:33 +00:00
f34ab12ca5 Verify public alias registration flows 2026-10-19 19:48:57 +00:00
1fa026e5bd Preserve alias context in usage errors 2026-10-19 19:46:22 +00:00
a504efbad7 Render help through command aliases 2026-10-19 19:40:55 +00:00
451a0c483f Expose canonical paths on command invocations 2026-10-19 19:33:49 +00:00
75fc45f072 Interpret command flag aliases in the parser 2026-10-19 19:31:23 +00:00
58b71c390f Resolve entered command routes to canonical paths 2026-10-19 17:37:59 +00:00
6ea8c4dd3f Compile effective flag aliases during installation 2026-10-19 17:33:39 +00:00
2ab87e9333 Install command aliases atomically 2026-10-19 17:27:17 +00:00
7a4efddab8 Add captured alias declaration types 2026-10-19 17:23:32 +00:00
854426a0c9 Validate command alias termination 2026-10-19 15:16:07 +00:00
80d4d6e1bd Model command alias prefix rewriting 2026-10-19 15:11:43 +00:00
b3fb795224 Add shared command path primitives 2026-10-19 15:08:00 +00:00
d7cc12a347 Plan application alias implementation 2026-10-19 14:41:10 +00:00
f3b8a129d9 Correct alias examples to respect smoke-test operand grammar 2026-10-19 14:26:49 +00:00
fae8c4dafa Accept ADR 0013 for application alias routing 2026-10-19 14:21:12 +00:00
ca85d5b012 Record future shell completion direction for aliases 2026-10-19 14:16:05 +00:00
93ee328779 Clarify alias routing interactions and implementation checks 2026-10-19 14:06:10 +00:00
c6c4b01902 Resolve alias declaration API and shared path handling 2026-10-19 13:48:07 +00:00
55330d2817 Settle short alias grammar and empty-value correction 2026-10-19 12:19:30 +00:00
fbef0f2ecc Record flag alias chains short spellings and help decisions 2026-10-19 01:44:38 +00:00
573a22a38e Record flag alias scope and target availability decisions 2026-10-19 01:21:33 +00:00
f526198059 Record alias invocation metadata decision 2026-10-19 01:18:13 +00:00
4b53c96cf3 Record alias help and discovery decisions 2026-10-19 01:16:50 +00:00
50c54c229e Record alias chain and group composition decisions 2026-10-19 00:20:47 +00:00
0dfeac3022 Record alias prefix and operand collision decisions 2026-10-19 00:19:28 +00:00
8d5966a868 Record alias registration conventions in roadmap 2026-10-18 23:51:39 +00:00
a4cf55f7a5 Record canonical collision and deterministic alias rules 2026-10-18 21:12:53 +00:00
1f3d1594c9 Add flag alias direction to alias roadmap 2026-10-18 16:09:15 +00:00
ae6af1dc65 Record application alias routing roadmap 2026-10-18 15:40:31 +00:00
df6ed89a97 Record published dependencies for ckprompt 2026-10-18 13:54:21 +00:00
137 changed files with 15153 additions and 2310 deletions

2
.gitignore vendored
View File

@@ -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

View File

@@ -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
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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])
}
}

View File

@@ -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
View 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())
}
}

View File

@@ -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",
},
{

View File

@@ -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
View 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
View 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
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}
}

View 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
View File

@@ -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

View 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.

View 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.

View File

@@ -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`.

View File

@@ -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

View File

@@ -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$' .
```

View File

@@ -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`

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
View 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)

View File

@@ -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

View 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.

View 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.

View 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.

View 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
View 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
View 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
View 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.

View File

@@ -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
View 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()
}

View File

@@ -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
}

View File

@@ -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
View 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
View 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
View 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)
}

View File

@@ -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
View File

@@ -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
View File

@@ -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
View 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
}

View File

@@ -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 }

View File

@@ -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())
}
}

View 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...)
}

View 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)
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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{}

View File

@@ -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)
})
}

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -1 +1,3 @@
schema:
version: 2
greeting: Goodbye

View File

@@ -1 +1,3 @@
schema:
version: 1
greeting: Hello

View File

@@ -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()
},
})

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}),

View File

@@ -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()
}

View File

@@ -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"
}
}

View 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)
}
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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 }

View File

@@ -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
}

View File

@@ -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)
}
}

View 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")...)
}

View File

@@ -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:

View File

@@ -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

View File

@@ -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.

View File

@@ -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 }

View File

@@ -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

View File

@@ -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=

View 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())
}
}
}

View File

@@ -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"
}
}

View 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)
}
})
}
}
}

View File

@@ -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())
}
})

View File

@@ -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
}

View File

@@ -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 &copy
}
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
}

View File

@@ -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)
}
}

View File

@@ -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"`

View File

@@ -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)
}
})

View File

@@ -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})
})
}

View File

@@ -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
View 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