67 Commits

Author SHA1 Message Date
88ac6e76e4 Prepare ckprompt v0.5.0 with aligned release dependencies 2026-11-08 00:33:46 +00:00
5c96053255 Prepare ckconfig v0.5.0 with released core dependencies 2026-11-08 00:31:21 +00:00
49f2d09ccd Fix reporting contract link 2026-11-07 23:58:23 +00:00
af7cc1da3e Trim release profile tests to module and publication safeguards 2026-11-07 23:52:39 +00:00
ee4e226914 Clarify dependency upgrades between module releases 2026-11-07 23:43:35 +00:00
c648d1de33 Refresh operator documentation and CLI help 2026-11-07 23:31:14 +00:00
69fe35c480 Record the documentation refresh roadmap 2026-11-07 23:04:44 +00:00
68825de200 Write release notes for CommandKit v0.5.0 2026-11-07 19:08:24 +00:00
0435a632b3 Remove completed audit and remediation records 2026-11-07 17:08:32 +00:00
aac0e11ae6 Reuse the declaration validation path across siblings 2026-11-07 17:00:12 +00:00
e46d28a924 Record the remaining declaration validation inefficiency 2026-11-07 16:52:58 +00:00
1cba65fffe Record audit remediation verification 2026-11-07 16:03:39 +00:00
d1102e12c5 Correct reporting and release navigation 2026-11-07 16:01:14 +00:00
e950f61c7f Decode native diff paths without revalidation 2026-11-07 16:00:28 +00:00
c4b5bcdbc2 Simplify typed input preparation 2026-11-07 15:59:28 +00:00
b66a00da26 Separate configuration suite capture 2026-11-07 15:58:16 +00:00
a62f1f056b Separate configuration operation preparation 2026-11-07 15:57:32 +00:00
0a81be84f6 Format declaration paths only for issues 2026-11-07 15:56:25 +00:00
83386217db Reuse alias tree for discovery and help 2026-11-07 15:55:43 +00:00
0d3f7299cc Memoize flag alias resolution by context 2026-11-07 15:54:36 +00:00
02cd49d6b6 Share flag consumption between parsing and completion 2026-11-07 15:53:32 +00:00
51a147a6e5 Remove unused parser resource plumbing 2026-11-07 15:51:19 +00:00
f2822f055c Share diagnostic adaptation across integrations 2026-11-07 15:49:49 +00:00
27500dbc6c Expose ConfigKit diagnostic adaptation 2026-11-07 15:48:57 +00:00
3b75ae7d2e Document flag declaration rules 2026-11-07 15:47:54 +00:00
52f1d99994 Clarify configuration evidence providers 2026-11-07 15:47:17 +00:00
ac113291fc Share reporter detail rendering 2026-11-07 15:45:37 +00:00
8dbb25aba9 Release reporter destination references after finalization 2026-11-07 15:44:55 +00:00
7c7d1df13f Repair completion and reporting examples 2026-11-07 15:43:59 +00:00
4346b840db Separate show cancellation from rendering errors 2026-11-07 15:42:22 +00:00
552b91c0a2 Reject missing prepared requests 2026-11-07 15:41:18 +00:00
1fc6409f95 Close deferred reporting before final delivery 2026-11-07 15:40:01 +00:00
c82235100e Isolate parsed provider parameters 2026-11-07 15:38:06 +00:00
b66d265032 Preserve cancellation in aggregate smoke tests 2026-11-07 15:36:23 +00:00
a806a9a62e Clarify continuous execution of the remediation plan 2026-11-07 15:33:51 +00:00
118b49ee55 Plan the audit remediation sprint 2026-11-07 15:30:02 +00:00
d492c77471 Remove the completed shared reporting remediation plan 2026-11-07 15:20:25 +00:00
264b82bd57 Add the audit remediation roadmap 2026-11-07 15:16:03 +00:00
d131999c7c Finalize audit remediation decisions for implementation planning 2026-11-07 15:09:19 +00:00
c7f898e56e Record alias tree decision and private refactoring analysis 2026-11-07 14:52:56 +00:00
485a27a662 Record flag consumption decision and joint alias analysis 2026-11-07 14:49:56 +00:00
9e0f2d28cd Record diagnostic adapter decision and flag consumption analysis 2026-11-07 14:45:44 +00:00
2a93b45419 Record flag naming decision and diagnostic adapter analysis 2026-11-07 14:40:52 +00:00
9d4c963c53 Add audit remediation analysis and flag naming recommendation 2026-11-07 14:36:56 +00:00
1798dcde2e Record audit remediation decisions 2026-11-07 14:11:19 +00:00
f00fe1909d Consolidate and prioritize the audit findings 2026-11-07 13:48:22 +00:00
db732e3a1d Record the architectural responsibility review 2026-11-07 13:33:33 +00:00
4a91f29bcd Record the documentation consistency audit 2026-11-07 13:20:54 +00:00
efb5f77417 Record the resource management audit 2026-11-07 13:06:37 +00:00
8fe8b67b29 Record the concurrency audit 2026-11-07 12:58:48 +00:00
2ea26afcc5 Record the complexity and maintainability audit 2026-11-07 12:50:55 +00:00
1e4fb73d15 Record the algorithmic efficiency audit 2026-11-07 12:43:07 +00:00
d8da761a21 Record the functional correctness audit 2026-11-07 12:35:25 +00:00
7b2dc2f2c9 Prepare subtree filter prefixes once per discovery 2026-11-07 12:14:11 +00:00
7bb7326ed1 Reconstruct and merge ordered inputs without general sorting 2026-11-07 12:10:40 +00:00
d72c9a788f Build validation filter sets once per invocation 2026-11-07 12:07:50 +00:00
3223b5cb82 Preserve YAML string types and explicit numeric evidence 2026-11-07 12:06:50 +00:00
d7a85f5f35 Preserve ConfigKit result evidence alongside loader errors 2026-11-07 12:04:46 +00:00
07e1634e79 Retain captured PromptKit evidence after cancellation 2026-11-07 12:03:23 +00:00
c61e125649 Render configuration explanations from acquired reports 2026-11-07 12:01:53 +00:00
3a7e2d4ce7 Record algorithmic audit findings 2026-11-07 11:56:34 +00:00
f5b2d1f80c Record reporting and evidence review findings 2026-11-07 11:32:11 +00:00
f83866e44a Retire the completed adoption roadmap 2026-11-07 11:24:19 +00:00
2f43f78f76 Align ckprompt with released dependencies 2026-11-07 11:23:15 +00:00
668c609062 Record the ckconfig source release 2026-11-07 11:17:32 +00:00
bd115c8bee Prepare the ckconfig release candidate 2026-11-07 11:12:23 +00:00
f66502cb23 Record the core source release 2026-11-07 11:09:46 +00:00
88 changed files with 2759 additions and 4043 deletions

View File

@@ -1,34 +1,26 @@
# CommandKit # CommandKit
CommandKit is a Go library for typed command declarations, shared CLI behavior, CommandKit helps Go applications build consistent command-line interfaces. It
and application-owned execution. The core `commandkit` module is usable by provides typed flags and arguments, help, output formatting, and error reporting.
itself. ConfigKit and PromptKit support lives in separately versioned optional Optional integrations add commands for inspecting configuration and working with
modules, so applications choose which upstream libraries they add. PromptKit.
The core captures reusable command declarations, validates installation batches CommandKit is in alpha. Start with the [consumer guide](docs/consumers/guide.md#core-commands)
before changing an application's registry, converts typed inputs before to define a command, or run the [core example](examples/core/README.md).
constructing them, and returns outcomes without exiting the process. Its Applications supply their own command handlers and decide when to exit;
[consumer guide](docs/consumers/guide.md) covers declaration, invocation CommandKit returns the result of each invocation.
and reporting. 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 ## Modules
| Module | Package | Provides | | Module | Package | Provides |
| --- | --- | --- | | --- | --- | --- |
| `go.maximumdirect.net/commandkit` | `commandkit` | Typed commands, bindings, execution reporting and outcomes, help, and version. | | `go.maximumdirect.net/commandkit` | `commandkit` | Commands, typed inputs, shared output and error reporting, help, and version. |
| `go.maximumdirect.net/ckconfig` | `ckconfig` | ConfigKit-backed `config` scopes, including configuration source evidence. | | `go.maximumdirect.net/ckconfig` | `ckconfig` | Commands to view, validate, explain, and compare ConfigKit configuration. |
| `go.maximumdirect.net/ckprompt` | `ckprompt` | PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, concrete request validation, and native smoke tests. | | `go.maximumdirect.net/ckprompt` | `ckprompt` | Commands to list and inspect PromptKit resources, discover required inputs, validate and preview requests, and run smoke tests. |
Importing an integration does not install commands. The published ckconfig and Importing an integration does not install commands. Core and the integrations
ckprompt v0.2.0 modules use core v0.3.0. The published are versioned separately; see the [consumer guide](docs/consumers/guide.md#module-selection)
ckconfig v0.3.0 uses ConfigKit v1.1.0 and does not compose with for published versions, compatibility, and installation choices.
ckprompt v0.2.0; the published v0.2.0 pair remains matched. Current integration
sources use new core and ckconfig APIs and are developed in an aligned workspace
until their release dependencies are available. See the
[consumer guide](docs/consumers/guide.md#module-selection) for dependency details.
## Examples ## Examples

View File

@@ -3,7 +3,6 @@ package commandkit
import ( import (
"errors" "errors"
"fmt" "fmt"
"sort"
"strconv" "strconv"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta" commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
@@ -65,6 +64,7 @@ func OccurrenceFlag[I, V any](meta FlagMeta, parse func(string) (V, error), set
// RenameFlag returns an independent binding with a new long name and no short // RenameFlag returns an independent binding with a new long name and no short
// spelling. Parsing, defaults, completion, requiredness, and setters are retained. // spelling. Parsing, defaults, completion, requiredness, and setters are retained.
// The name follows the declaration rules in [FlagMeta].
func RenameFlag[I any](flag Flag[I], name string) Flag[I] { func RenameFlag[I any](flag Flag[I], name string) Flag[I] {
binding := flag.binding binding := flag.binding
binding.meta.Name = name binding.meta.Name = name
@@ -239,17 +239,6 @@ func (p preparedInput[I]) build() I {
return input return input
} }
// prepareInput parses all supplied bindings into typed closures before it
// 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) {
return prepareInputWithOccurrences(spec, flagValues, flagSpellings, nil, operands, enforceRequired)
}
func prepareInputWithOccurrences[I any](spec CommandSpec[I], flagValues, flagSpellings [][]string, occurrences []commandmeta.FlagOccurrence, operands []string, enforceRequired bool) (preparedInput[I], error) { func prepareInputWithOccurrences[I any](spec CommandSpec[I], flagValues, flagSpellings [][]string, occurrences []commandmeta.FlagOccurrence, operands []string, enforceRequired bool) (preparedInput[I], error) {
if len(flagValues) != len(spec.Flags) { if len(flagValues) != len(spec.Flags) {
return preparedInput[I]{}, fmt.Errorf("received %d flag value sets for %d bindings", len(flagValues), len(spec.Flags)) return preparedInput[I]{}, fmt.Errorf("received %d flag value sets for %d bindings", len(flagValues), len(spec.Flags))
@@ -272,18 +261,7 @@ func prepareInputWithOccurrences[I any](spec CommandSpec[I], flagValues, flagSpe
binding := flag.binding binding := flag.binding
value, err := binding.prepare(flagValues[i]) value, err := binding.prepare(flagValues[i])
if err != nil { if err != nil {
var occurrenceErr *flagOccurrenceError return preparedInput[I]{}, attributeFlagConversionError(binding.meta.Name, flagSpellings[i], err)
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 preparedFlags[i] = value
for index, occurrence := range value.ordered { for index, occurrence := range value.ordered {
@@ -298,7 +276,14 @@ func prepareInputWithOccurrences[I any](spec CommandSpec[I], flagValues, flagSpe
fallbackOrdinal++ fallbackOrdinal++
} }
if occurrence.apply != nil { if occurrence.apply != nil {
ordered = append(ordered, occurrence) if occurrences == nil {
ordered = append(ordered, occurrence)
} else {
if ordered == nil {
ordered = make([]preparedOccurrence[I], len(occurrences))
}
ordered[*occurrence.ordinal] = occurrence
}
} }
} }
if enforceRequired && binding.meta.Required && !binding.deferredRequired && !value.supplied && missingRequired == "" { if enforceRequired && binding.meta.Required && !binding.deferredRequired && !value.supplied && missingRequired == "" {
@@ -308,26 +293,50 @@ func prepareInputWithOccurrences[I any](spec CommandSpec[I], flagValues, flagSpe
if missingRequired != "" { if missingRequired != "" {
return preparedInput[I]{}, fmt.Errorf("required flag --%s was not supplied", missingRequired) return preparedInput[I]{}, fmt.Errorf("required flag --%s was not supplied", missingRequired)
} }
argumentValues, err := allocateOperands(operands, spec.Arguments, enforceRequired) arguments, err := prepareOperands(operands, spec.Arguments, enforceRequired)
if err != nil { if err != nil {
return preparedInput[I]{}, err return preparedInput[I]{}, err
} }
preparedValues := append([]preparedValue[I](nil), preparedFlags...) preparedValues := append([]preparedValue[I](nil), preparedFlags...)
sort.SliceStable(ordered, func(i, j int) bool { return *ordered[i].ordinal < *ordered[j].ordinal }) // Parsed occurrences use dense parser positions; without a parser log the
// fallback ordinals were assigned in the same order as the append above.
for _, occurrence := range ordered { for _, occurrence := range ordered {
preparedValues = append(preparedValues, preparedValue[I]{apply: occurrence.apply}) if occurrence.apply != nil {
} preparedValues = append(preparedValues, preparedValue[I]{apply: occurrence.apply})
for i, argument := range spec.Arguments {
binding := argument.binding
value, err := binding.prepare(argumentValues[i])
if err != nil {
return preparedInput[I]{}, fmt.Errorf("argument %q: %w", binding.meta.Name, err)
} }
preparedValues = append(preparedValues, value)
} }
preparedValues = append(preparedValues, arguments...)
return preparedInput[I]{newInput: spec.NewInput, values: preparedValues}, nil return preparedInput[I]{newInput: spec.NewInput, values: preparedValues}, nil
} }
func attributeFlagConversionError(name string, spellings []string, err error) error {
var occurrenceErr *flagOccurrenceError
if errors.As(err, &occurrenceErr) {
spelling := "--" + name
if occurrenceErr.index >= 0 && occurrenceErr.index < len(spellings) && spellings[occurrenceErr.index] != "" {
spelling = spellings[occurrenceErr.index]
}
return &flagConversionError{binding: name, spelling: spelling, occurrence: occurrenceErr.index + 1, cause: occurrenceErr.cause}
}
return fmt.Errorf("flag --%s: %w", name, err)
}
func prepareOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([]preparedValue[I], error) {
values, err := allocateOperands(operands, definitions, enforceRequired)
if err != nil {
return nil, err
}
prepared := make([]preparedValue[I], 0, len(definitions))
for i, argument := range definitions {
value, err := argument.binding.prepare(values[i])
if err != nil {
return nil, fmt.Errorf("argument %q: %w", argument.binding.meta.Name, err)
}
prepared = append(prepared, value)
}
return prepared, nil
}
func newBindingIdentity() *bindingIdentity { return &bindingIdentity{marker: 1} } func newBindingIdentity() *bindingIdentity { return &bindingIdentity{marker: 1} }
func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) { func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) {

View File

@@ -36,7 +36,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
defaultMessage = "changed after declaration" defaultMessage = "changed after declaration"
prepared, err := prepareInput(spec, [][]string{nil, nil, nil}, nil, true) prepared, err := prepareInputWithOccurrences(spec, [][]string{nil, nil, nil}, nil, nil, nil, true)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -60,7 +60,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
t.Fatalf("NewInput runtime value = %q, want caller value", input.runtime) t.Fatalf("NewInput runtime value = %q, want caller value", input.runtime)
} }
prepared, err = prepareInput(spec, [][]string{{"", "last"}, {"0"}, {"true", "false"}}, nil, true) prepared, err = prepareInputWithOccurrences(spec, [][]string{{"", "last"}, {"0"}, {"true", "false"}}, nil, nil, nil, true)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -75,7 +75,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
t.Fatalf("enabled = %#v, want explicit false", input.enabled) t.Fatalf("enabled = %#v, want explicit false", input.enabled)
} }
prepared, err = prepareInput(spec, [][]string{{""}, nil, nil}, nil, true) prepared, err = prepareInputWithOccurrences(spec, [][]string{{""}, nil, nil}, nil, nil, nil, true)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -83,7 +83,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
if input.message.Value != "" || !input.message.Set { if input.message.Value != "" || !input.message.Set {
t.Fatalf("message = %#v, want explicitly supplied empty string", input.message) t.Fatalf("message = %#v, want explicitly supplied empty string", input.message)
} }
prepared, err = prepareInput(spec, [][]string{nil, nil, nil}, nil, true) prepared, err = prepareInputWithOccurrences(spec, [][]string{nil, nil, nil}, nil, nil, nil, true)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -207,7 +207,7 @@ func TestRepeatArgumentAllocationReservesLaterMinimums(t *testing.T) {
if err := validateCommandDeclaration(NewCommand(spec)); err != nil { if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
prepared, err := prepareInput(spec, nil, []string{"a", "b", "id-1", "x", "y"}, true) prepared, err := prepareInputWithOccurrences(spec, nil, nil, nil, []string{"a", "b", "id-1", "x", "y"}, true)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -237,10 +237,10 @@ func TestIncompletePreparationSkipsMissingRequirementsAndInputFactory(t *testing
if err := validateCommandDeclaration(NewCommand(spec)); err != nil { if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := prepareInput(spec, [][]string{nil}, nil, true); err == nil { if _, err := prepareInputWithOccurrences(spec, [][]string{nil}, nil, nil, nil, true); err == nil {
t.Fatal("strict preparation accepted missing requirements") t.Fatal("strict preparation accepted missing requirements")
} }
if _, err := prepareInput(spec, [][]string{nil}, nil, false); err != nil { if _, err := prepareInputWithOccurrences(spec, [][]string{nil}, nil, nil, nil, false); err != nil {
t.Fatalf("help preparation rejected omitted execution inputs: %v", err) t.Fatalf("help preparation rejected omitted execution inputs: %v", err)
} }
if factoryCalls != 0 { if factoryCalls != 0 {

View File

@@ -32,8 +32,8 @@ func (a *App) builtinCommands() []Declaration {
help := captureCommand(CommandSpec[helpInput]{ help := captureCommand(CommandSpec[helpInput]{
Name: "help", Name: "help",
Summary: "Show help for commands.", Summary: "Show help for commands.",
Description: "Describe registered commands and their inputs.", Description: "Show available commands, flags, and arguments.",
Arguments: []Argument[helpInput]{RepeatArgument(ArgumentMeta{Name: "COMMAND", Usage: "registered command path"}, 0, -1, Arguments: []Argument[helpInput]{RepeatArgument(ArgumentMeta{Name: "COMMAND", Usage: "Command path to describe."}, 0, -1,
func(value string) (string, error) { return value, nil }, func(value string) (string, error) { return value, nil },
func(input *helpInput, value Supplied[[]string]) { input.Targets = value })}, func(input *helpInput, value Supplied[[]string]) { input.Targets = value })},
}, func(current commandExecution, input helpInput) Outcome { }, func(current commandExecution, input helpInput) Outcome {
@@ -43,9 +43,9 @@ func (a *App) builtinCommands() []Declaration {
version := NewCommand(CommandSpec[versionInput]{ version := NewCommand(CommandSpec[versionInput]{
Name: "version", Name: "version",
Summary: "Show application or CommandKit version.", Summary: "Show application or CommandKit version.",
Description: "Report identity and build metadata without loading application configuration.", Description: "Show the name, version, and revision without loading application configuration.",
Flags: []Flag[versionInput]{ Flags: []Flag[versionInput]{
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: "scope", Usage: "Show the app or CommandKit version.", Completion: CompleteChoices(CompletionChoice{Value: "app"}, CompletionChoice{Value: "commandkit"})}, &scopeDefault, parseVersionScope, func(input *versionInput, value Supplied[string]) { input.Scope = value.Value }),
}, },
Run: func(invocation Invocation, input versionInput) Outcome { Run: func(invocation Invocation, input versionInput) Outcome {
identity := a.identity identity := a.identity

View File

@@ -90,10 +90,14 @@ type Supplied[T any] struct {
// FlagMeta describes a command flag. Required applies when execution prepares // FlagMeta describes a command flag. Required applies when execution prepares
// the selected command; help reports the metadata without requiring the value. // the selected command; help reports the metadata without requiring the value.
// Invalid declarations are reported when installed.
type FlagMeta struct { type FlagMeta struct {
// Name is the flag name without leading dashes. // Name is a nonempty long name without leading dashes. It consists of
// Unicode lowercase letters or digits in segments separated by single
// hyphens; digit-leading and numeric-only names are valid. The names help,
// quiet, verbose, debug, progress and format are reserved.
Name string Name string
// Short is an optional one-byte spelling. // Short is an optional one-byte spelling other than '-' or the reserved 'h'.
Short string Short string
// Usage briefly describes the flag. // Usage briefly describes the flag.
Usage string Usage string
@@ -352,6 +356,7 @@ func Alias(path, target string) RoutingRule {
// wherever its canonical target flag is available and contributes nothing on // wherever its canonical target flag is available and contributes nothing on
// commands without that flag. Identical mappings are accepted; different // commands without that flag. Identical mappings are accepted; different
// targets for the same source spelling in this scope are installation errors. // targets for the same source spelling in this scope are installation errors.
// Long and short spelling forms follow the grammar described by [FlagMeta].
func FlagAlias(spelling, target string) RoutingRule { func FlagAlias(spelling, target string) RoutingRule {
record := &routingRuleRecord{ record := &routingRuleRecord{
kind: globalFlagAliasRule, kind: globalFlagAliasRule,
@@ -368,6 +373,7 @@ func FlagAlias(spelling, target string) RoutingRule {
// available in the complete installation. Scoped mappings take precedence over // available in the complete installation. Scoped mappings take precedence over
// application-wide mappings with the same source spelling. Identical mappings // application-wide mappings with the same source spelling. Identical mappings
// are accepted; competing targets in the same scope are installation errors. // are accepted; competing targets in the same scope are installation errors.
// Long and short spelling forms follow the grammar described by [FlagMeta].
func FlagAliasFor(commandPath, spelling, target string) RoutingRule { func FlagAliasFor(commandPath, spelling, target string) RoutingRule {
record := &routingRuleRecord{ record := &routingRuleRecord{
kind: scopedFlagAliasRule, kind: scopedFlagAliasRule,
@@ -437,39 +443,46 @@ func validateCommandDeclaration(command Command) error {
func validateCommandPlacement(command Command) error { func validateCommandPlacement(command Command) error {
var issues []error var issues []error
collectDeclarationIssues(command, nil, true, &issues) // Keep the grown slice header across recursive calls so siblings reuse capacity.
var path []string
var collectDeclarationIssues func(Command, bool)
collectDeclarationIssues = func(command Command, root bool) {
if command.record == nil {
issues = append(issues, errors.New("invalid zero command declaration"))
return
}
record := command.record
length := len(path)
path = append(path, record.name)
defer func() { path = path[:length] }()
var label string
if len(record.issues) > 0 || (record.rootOnly && !root) {
label = commandpath.Path(path).String()
}
for _, issue := range record.issues {
if label != "" {
issues = append(issues, fmt.Errorf("command %q: %w", label, issue))
} else {
issues = append(issues, issue)
}
}
if record.rootOnly && !root {
issues = append(issues, fmt.Errorf("root-only command %q is nested beneath another command", label))
}
if record.invalid || record.absent {
return
}
for _, child := range record.children {
if child.record == nil {
continue
}
collectDeclarationIssues(child, false)
}
}
collectDeclarationIssues(command, true)
return errors.Join(issues...) return errors.Join(issues...)
} }
func collectDeclarationIssues(command Command, parents []string, root bool, issues *[]error) {
if command.record == nil {
*issues = append(*issues, errors.New("invalid zero command declaration"))
return
}
record := command.record
path := append(append([]string(nil), parents...), record.name)
label := commandpath.FromTokens(path).String()
for _, issue := range record.issues {
if label != "" {
*issues = append(*issues, fmt.Errorf("command %q: %w", label, issue))
} else {
*issues = append(*issues, issue)
}
}
if record.rootOnly && !root {
*issues = append(*issues, fmt.Errorf("root-only command %q is nested beneath another command", label))
}
if record.invalid || record.absent {
return
}
for _, child := range record.children {
if child.record == nil {
continue
}
collectDeclarationIssues(child, path, false, issues)
}
}
func validateCommandName(name string) error { func validateCommandName(name string) error {
return commandpath.ValidateComponent(name) return commandpath.ValidateComponent(name)
} }

View File

@@ -53,44 +53,26 @@ func walkCompletionFlags(tokens []string, lookup commandmeta.FlagLookup) complet
state.ended = true state.ended = true
case commandmeta.OperandToken: case commandmeta.OperandToken:
state.operands = append(state.operands, token) state.operands = append(state.operands, token)
case commandmeta.LongFlagToken: case commandmeta.LongFlagToken, commandmeta.ShortFlagToken:
spelling, _, attached := commandmeta.SplitLongFlag(token) for offset := 1; ; {
flag, ok := lookup.Find(spelling) fact := commandmeta.ConsumeFlag(lookup, token, offset)
if !ok { if !fact.Found {
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 state.invalid = true
return state return state
} }
state.supplied[flag.Name] = true state.supplied[fact.Flag.Name] = true
suffix := token[offset+1:] if fact.NeedsNext {
if flag.Boolean {
if strings.HasPrefix(suffix, "=") {
break
}
continue
}
if suffix == "" {
if index+1 == len(tokens) { if index+1 == len(tokens) {
flag := fact.Flag
state.pending = &flag state.pending = &flag
return state return state
} }
index++ index++
} }
break if fact.NextOffset == 0 {
break
}
offset = fact.NextOffset
} }
} }
} }
@@ -132,10 +114,10 @@ func completionOptionTarget(current string, flags []commandmeta.Flag, lookup com
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)} return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)}
} }
if strings.HasPrefix(current, "--") { if strings.HasPrefix(current, "--") {
spelling, value, attached := commandmeta.SplitLongFlag(current) fact := commandmeta.ConsumeFlag(lookup, current, 1)
if attached { if fact.Attached {
if flag, ok := lookup.Find(spelling); ok { if fact.Found {
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: spelling + "=", valuePrefix: value} return completionTarget{kind: completionTargetValue, hint: fact.Flag.Completion, prefix: fact.Spelling + "=", valuePrefix: fact.Value}
} }
return completionTarget{} return completionTarget{}
} }
@@ -145,25 +127,26 @@ func completionOptionTarget(current string, flags []commandmeta.Flag, lookup com
for name, value := range supplied { for name, value := range supplied {
used[name] = value used[name] = value
} }
for offset := 1; offset < len(current); offset++ { for offset := 1; offset < len(current); {
flag, ok := lookup.Find("-" + current[offset:offset+1]) fact := commandmeta.ConsumeFlag(lookup, current, offset)
if !ok { if !fact.Found {
return completionTarget{} return completionTarget{}
} }
used[flag.Name] = true used[fact.Flag.Name] = true
suffix := current[offset+1:] if fact.Flag.Boolean && fact.Attached {
if flag.Boolean { return completionTarget{kind: completionTargetValue, hint: fact.Flag.Completion, prefix: current[:offset+2], valuePrefix: fact.Value}
if strings.HasPrefix(suffix, "=") { }
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: current[:offset+2], valuePrefix: suffix[1:]} if !fact.Flag.Boolean {
prefix := current[:offset+1]
if strings.HasPrefix(current[offset+1:], "=") {
prefix += "="
} }
continue return completionTarget{kind: completionTargetValue, hint: fact.Flag.Completion, prefix: prefix, valuePrefix: fact.Value}
} }
prefix := current[:offset+1] if fact.NextOffset == 0 {
if strings.HasPrefix(suffix, "=") { break
prefix += "="
suffix = suffix[1:]
} }
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: prefix, valuePrefix: suffix} offset = fact.NextOffset
} }
var choices []commandmeta.CompletionChoice var choices []commandmeta.CompletionChoice
for _, flag := range flags { for _, flag := range flags {

View File

@@ -32,9 +32,9 @@ func TestCompletionConsumerInShells(t *testing.T) {
directory bool directory bool
}{ }{
{"canonical command", "probe ru", []string{"run"}, false}, {"canonical command", "probe ru", []string{"run"}, false},
{"entered alias", "probe shortcut --format=ya", []string{"shortcut", "--format=yaml"}, false}, {"entered alias", "probe shortcut --choice=ya", []string{"shortcut", "--choice=yaml"}, false},
{"choice", "probe run --format=te", []string{"run", "--format=text"}, false}, {"choice", "probe run --choice=te", []string{"run", "--choice=text"}, false},
{"no file fallback", `probe run --format=./partial\ f`, []string{"run", "--format=./partial f"}, false}, {"no file fallback", `probe run --choice=./partial\ f`, []string{"run", "--choice=./partial f"}, false},
{"separated file", `probe run --config ./partial\ f`, []string{"run", "--config", "./partial file"}, 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}, {"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 attached file", `probe run -c./partial\ f`, []string{"run", "-c./partial file"}, false},
@@ -42,7 +42,7 @@ func TestCompletionConsumerInShells(t *testing.T) {
{"separated directory", "probe run --directory ./partial", []string{"run", "--directory", "./partial dir"}, true}, {"separated directory", "probe run --directory ./partial", []string{"run", "--directory", "./partial dir"}, true},
{"attached 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}, {"empty operand", "probe run ", []string{"run", "alpha"}, false},
{"leading dash", "probe run --for", []string{"run", "--format"}, false}, {"leading dash", "probe run --cho", []string{"run", "--choice"}, false},
} }
for _, shell := range []string{"bash", "zsh", "fish"} { for _, shell := range []string{"bash", "zsh", "fish"} {
generator := exec.Command(app, "completion", shell) generator := exec.Command(app, "completion", shell)
@@ -82,7 +82,7 @@ func TestCompletionConsumerInShells(t *testing.T) {
}) })
} }
t.Run(shell+"/descriptions", func(t *testing.T) { t.Run(shell+"/descriptions", func(t *testing.T) {
terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, "probe run --format=") terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, "probe run --choice=")
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") 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() output, err := terminal.CombinedOutput()
if err != nil || !strings.Contains(string(output), "Readable text") || !strings.Contains(string(output), "YAML output") { if err != nil || !strings.Contains(string(output), "Readable text") || !strings.Contains(string(output), "YAML output") {

View File

@@ -79,3 +79,40 @@ func TestCompletionChoicesDoNotRestrictExecution(t *testing.T) {
t.Fatalf("outcome=%#v input=%#v", outcome, received) t.Fatalf("outcome=%#v input=%#v", outcome, received)
} }
} }
func TestCompletionConsumesFlagGrammarAndKeepsPendingValues(t *testing.T) {
flags := []commandmeta.Flag{{Name: "verbose", Short: "v", Boolean: true}, {Name: "choice", Short: "c", Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionChoices}}}
lookup := commandmeta.NewFlagLookup(flags, []commandmeta.FlagAlias{{Source: "-x", Target: "--choice"}})
for _, tc := range []struct {
name string
tokens []string
pending, operand string
invalid bool
}{
{name: "cluster attached empty", tokens: []string{"-vc="}, operand: ""},
{name: "option-looking value", tokens: []string{"-vx", "--other"}, operand: ""},
{name: "pending", tokens: []string{"-vx"}, pending: "choice"},
{name: "unknown cluster", tokens: []string{"-vz"}, invalid: true},
{name: "terminator", tokens: []string{"--", "--choice"}, operand: "--choice"},
} {
t.Run(tc.name, func(t *testing.T) {
state := walkCompletionFlags(tc.tokens, lookup)
if state.invalid != tc.invalid {
t.Fatalf("state=%#v", state)
}
if tc.pending != "" && (state.pending == nil || state.pending.Name != tc.pending) {
t.Fatalf("pending=%#v", state.pending)
}
if tc.pending == "" && state.pending != nil {
t.Fatalf("unexpected pending=%#v", state.pending)
}
if tc.operand != "" && (len(state.operands) != 1 || state.operands[0] != tc.operand) {
t.Fatalf("operands=%v", state.operands)
}
})
}
target := completionOptionTarget("-vc=", flags, lookup, nil)
if target.kind != completionTargetValue || target.prefix != "-vc=" || target.valuePrefix != "" {
t.Fatalf("partial target = %#v", target)
}
}

16
doc.go
View File

@@ -1,8 +1,10 @@
// Package commandkit provides the core instance-owned CLI dispatcher, typed // Package commandkit helps Go applications build consistent command-line
// command declarations, application-owned command and flag aliases, input // interfaces with typed flags and arguments, help, and shared output formatting.
// bindings, invocation-scoped reporting, and outcomes with separate operation // Define commands with NewCommand and install them with New or App.Register.
// causes and presentation observations. New and App.Register accept canonical // Commands and command or flag aliases can be installed together.
// Command declarations and RoutingRule declarations together. Optional //
// integration modules provide ConfigKit and PromptKit-specific commands; // App.Execute runs a command and returns an Outcome; the application decides
// importing those modules does not install their commands. // when to exit. Handlers use Invocation.Reporter for output and messages.
// Optional ckconfig and ckprompt modules add inspection and testing commands;
// applications install those commands explicitly.
package commandkit package commandkit

View File

@@ -3,9 +3,11 @@
## Status ## Status
Accepted. Accepted.
The structured-report formatting extension is accepted but not yet implemented; The structured-report formatting extension is implemented; its adoption record
the [adoption roadmap](../roadmap/promptkit-v0.20.0.md#shared-core-report-output-prerequisite) is preserved in [Git history](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/2f43f78f76439f5d11ef313cd379bec036543504/docs/roadmap/promptkit-v0.20.0.md#shared-core-report-output-prerequisite).
tracks that work. The future-tense implementation and roadmap references in the decision and
consequences below are historical. Current contracts are owned by the
[reporting GoDoc](../../report.go) and [CLI reference](../cli.md#reporting).
## Date ## Date

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +1,45 @@
# Consumer guide # Consumer guide
CommandKit is distributed as three Go modules. Start with the core and add an Start with core to build commands with typed inputs and shared output
integration module only when the application uses that library. Core and formatting. Add an integration when your application uses ConfigKit or
compatible integration command factories return canonical `commandkit.Command` values. PromptKit. The [core command example](#core-commands) below shows the basic
Applications may install those alongside application-owned `RoutingRule` API; the [maintained examples](../../README.md#examples) show complete
declarations through `commandkit.New` or `App.Register`; routing rules are applications.
installed at the application root, not as group children.
## Module selection ## Module selection
| Import | Use when | | Import | Use when |
| --- | --- | | --- | --- |
| `go.maximumdirect.net/commandkit` | The application needs typed command declarations and common CLI behavior. | | `go.maximumdirect.net/commandkit` | You need commands, typed inputs, help, and common output handling. |
| `go.maximumdirect.net/ckconfig` | The application wants ConfigKit-backed configuration scopes and reports. | | `go.maximumdirect.net/ckconfig` | You want operators to view, validate, explain, and compare ConfigKit configuration. |
| `go.maximumdirect.net/ckprompt` | The application wants PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, request validation, or native smoke testing. | | `go.maximumdirect.net/ckprompt` | You want operators to inspect PromptKit resources, check and preview requests, or run smoke tests. |
The published matched releases are core v0.3.0, ckconfig v0.2.0, and ckprompt v0.2.0. The following lists the current release set and earlier module combinations:
The published ckconfig v0.3.0 uses ConfigKit v1.1.0 and does not compose
with ckprompt v0.2.0, which retains ckconfig v0.2.0 and ConfigKit v0.8.0.
Install core with
`go get go.maximumdirect.net/commandkit@v0.3.0` and either integration with
`go get go.maximumdirect.net/ckconfig@v0.2.0` or
`go get go.maximumdirect.net/ckprompt@v0.2.0`.
The current ckprompt source targets ckconfig v0.3.0, ConfigKit v1.1.0, and | Selection | Core | ckconfig | ckprompt | Upstream libraries and status |
PromptKit v0.20.0 using their `go.maximumdirect.net` identities. Its v0.3.0 | --- | --- | --- | --- | --- |
release is a candidate, not a published release. A Go 1.27.0 workspace may | Published core | v0.5.0 | — | — | Usable independently. |
compose all three local modules. The current integration source uses APIs that | ConfigKit integration | v0.5.0 | v0.5.0 | — | ConfigKit v1.1.0. |
require the upcoming core and ckconfig releases; independent validation follows | PromptKit integration | v0.5.0 | v0.5.0 | v0.5.0 | ConfigKit v1.1.0 and PromptKit v0.20.0. |
their publication and dependency alignment. | Earlier matched integration set | v0.3.0 | v0.2.0 | v0.2.0 | ConfigKit v0.8.0 and PromptKit v0.16.0, using their earlier import paths. |
The two integrations are independent optional imports. `ckprompt` uses the Install the published core with:
public scope and error contracts from `ckconfig`; importing it does not add the
`config` family. An application that wants both families passes both returned ```sh
declarations to core. The [maintained examples](../../README.md#examples) show go get go.maximumdirect.net/commandkit@v0.5.0
core-only, ConfigKit-only, and composed applications. ```
Select either integration with `go get go.maximumdirect.net/ckconfig@v0.5.0`
or `go get go.maximumdirect.net/ckprompt@v0.5.0`. The integrations select core
v0.5.0, and ckprompt selects ckconfig v0.5.0, which includes `AdaptDiagnostics`.
They build independently against these released dependencies; a local workspace
is optional for development. Published ckconfig v0.3.0 also uses ConfigKit v1.1.0
and does not compose with ckprompt v0.2.0's earlier ConfigKit dependency.
Importing a module does not install its commands. Pass commands to
`commandkit.New` or `App.Register` explicitly. ckprompt depends on ckconfig's
public APIs, but you can install its `promptkit` commands without installing
the `config` family.
## Go compatibility ## Go compatibility
@@ -57,7 +62,6 @@ default from an explicitly supplied empty, false, or zero value.
package example package example
import ( import (
"fmt"
"strconv" "strconv"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
@@ -81,8 +85,12 @@ func Greeting() commandkit.Command {
), ),
}, },
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome { Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil { greetings := []string{}
return commandkit.Failure("Could not write the greeting.").WithCause(err) for i := 0; i < input.Count.Value; i++ {
greetings = append(greetings, "Hello!")
}
if err := invocation.Reporter().Report(commandkit.Report{Value: greetings}); err != nil {
return commandkit.Failure("Could not write the greetings.").WithCause(err)
} }
return commandkit.Success() return commandkit.Success()
}, },
@@ -90,6 +98,10 @@ func Greeting() commandkit.Command {
} }
``` ```
Install `Greeting()` with `commandkit.New(identity, Greeting())`. By default,
`greet` prints `Hello!`; `greet --count=2` prints it twice. Add `--format
json` to receive a JSON list. A count of zero produces an empty list.
The application supplies `context.Context`, argv without the executable name, The application supplies `context.Context`, argv without the executable name,
and output streams to `App.Execute`; it decides how the returned status maps to and output streams to `App.Execute`; it decides how the returned status maps to
process exit. `New` installs generated help/version and caller declarations as process exit. `New` installs generated help/version and caller declarations as
@@ -134,13 +146,13 @@ installation alongside aliases and integration commands.
## Invocation reporting ## Invocation reporting
`Invocation.Reporter()` supplies the reporter for the current handler. Use Use `Invocation.Reporter().Report` to print a value in the selected output
`Report()` for formatted substantive values and optional text templates; format. Pass ordinary Go values and, optionally, a `text/template` for text
use `Output()` or `RenderOutput()` for deliberate raw output. Use `Warn`, `Info`, output. Use `Warn`, `Info`, `Progress`, and `Debug` for categorized messages.
`Progress`, and `Debug` for categorized messages. Queue a summary with Queue a summary with `AtEnd().Info` when it belongs after the handler returns.
`AtEnd().Info` when it belongs after the handler returns. The Deferred submissions are accepted until finalization begins; later submissions
[CLI reference](../cli.md#reporting) owns flag grammar, visibility, prefixes, return `ErrReporterClosed`. The [CLI reference](../cli.md#reporting) owns flag
and status behavior. grammar, visibility, prefixes, and status behavior.
Reports use JSON serialization to establish the common value representation Reports use JSON serialization to establish the common value representation
for YAML and JSON. A text template receives the original value; without one, for YAML and JSON. A text template receives the original value; without one,
@@ -157,22 +169,29 @@ write to their supplied writer only while the callback runs; they must not
submit another message or start another rendering operation through the same submit another message or start another rendering operation through the same
reporter. reporter.
For example, a handler can report a count as a small mapping:
```go ```go
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome { if err := invocation.Reporter().Report(commandkit.Report{
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil { Value: map[string]int{"count": input.Count.Value},
return commandkit.Failure("Could not write the greeting.").WithCause(err) }); err != nil {
} return commandkit.Failure("Could not write the count.").WithCause(err)
return commandkit.Success()
} }
return commandkit.Success()
``` ```
This uses YAML automatically and supports `--format json` without a separate
encoder. Use `Output()` or `RenderOutput()` instead when you deliberately need
raw output; `--format` does not reformat those bytes.
After `App.Execute`, inspect the returned `Outcome`. Its `Cause` retains the After `App.Execute`, inspect the returned `Outcome`. Its `Cause` retains the
operation error; `Presentation.Output` and `Presentation.Diagnostics` report operation error; `Presentation.Output` and `Presentation.Diagnostics` report
delivery state and the first rendering or write errors for their destinations. delivery state and the first rendering or write errors for their destinations.
CommandKit does not retain a message transcript. Reporter operations for one Joined operation errors support `errors.Is` and `errors.As` where the
invocation are safe concurrently, but concurrent submission order is not underlying errors do. CommandKit does not retain a message transcript.
specified. The caller owns the writers passed to `Execute`; CommandKit does not Reporter operations for one invocation are safe concurrently, but concurrent
close them. submission order is not specified. The caller owns the writers passed to
`Execute`; CommandKit does not close them.
## Application-owned aliases ## Application-owned aliases
@@ -233,18 +252,22 @@ values.
Use `ckconfig.Command(ckconfig.Suite[I, S]{...})` when the application needs Use `ckconfig.Command(ckconfig.Suite[I, S]{...})` when the application needs
multiple named scopes, selector flags, scope-aware input checks, or shared multiple named scopes, selector flags, scope-aware input checks, or shared
acquisition state or a custom comparison provider. A declared `Diff` capability acquisition state or a custom comparison provider. A declared `Diff`
uses `Scope.Compare`; `ckconfig.FromConfigKit` and `AppCommand` provide the capability uses `Scope.Compare`; `ckconfig.FromConfigKit` and `AppCommand`
ConfigKit-backed implementation. `Selector.AgainstName` opts a selector into an provide the ConfigKit-backed implementation. `Selector.AgainstName` opts a
independent right-side flag. Each comparison side gets its own initialized and selector into an independent right-side flag. Each comparison side gets its
checked input, with only its supplied selectors applied; state is shared by the own initialized and checked input, with only its supplied selectors applied;
invocation. Factory results state is shared by the invocation. Factory results remain caller-owned, so
remain caller-owned, so separate initialization calls do not guarantee distinct separate initialization calls do not guarantee distinct objects. See
objects. See
[ckconfig.Suite's GoDoc](../../integrations/ckconfig/contracts.go) for factory [ckconfig.Suite's GoDoc](../../integrations/ckconfig/contracts.go) for factory
timing, omitted-factory zero values (including nil pointers), and ownership. timing, omitted-factory zero values (including nil pointers), and ownership.
The application owns the state type and what it caches. `ckconfig.FromConfigKit` The application owns the state type and what it caches.
adapts a typed ConfigKit loader without calling it during construction. `ckconfig.FromConfigKit` adapts a typed ConfigKit loader without calling it
during construction. The bundled comparison adapters call the loader for the
left side before the right, retain left diagnostics if right loading fails,
and collect diagnostics from both loads before native comparison. Custom
comparison callbacks own their loading and diagnostics; loader calls need not
reread sources if the application caches them.
The `config` family provides scope discovery, validation, safe effective configuration display, The `config` family provides scope discovery, validation, safe effective configuration display,
source inventory, path explanation, and ConfigKit-backed comparison for the source inventory, path explanation, and ConfigKit-backed comparison for the
@@ -253,6 +276,11 @@ owns operator syntax and report details. `Scope.Features` declares optional mode
`View.Metadata` and `View.Findings` carry native path metadata and diagnostic `View.Metadata` and `View.Findings` carry native path metadata and diagnostic
records when available. `Findings.Messages` contains presentations associated records when available. `Findings.Messages` contains presentations associated
with those records, while `Acquired.Diagnostics` carries independent messages. with those records, while `Acquired.Diagnostics` carries independent messages.
Custom explanation providers implement `Explain`; CommandKit renders the
acquired evidence. See `EffectiveWriter` and `ExplanationReader` in the
[ckconfig GoDoc](../../integrations/ckconfig/contracts.go) for the provider boundary.
`ckconfig.AdaptDiagnostics` captures native diagnostic presentation evidence
for applications providing their own ConfigKit-backed CLI integration.
Set `Scope.Features.Overrides` only when the loader passes Set `Scope.Features.Overrides` only when the loader passes
`Selection.Overrides` to ConfigKit as part of its normal load. The integration `Selection.Overrides` to ConfigKit as part of its normal load. The integration
parses CLI records but cannot add them to an already-loaded result. The parses CLI records but cannot add them to an already-loaded result. The
@@ -267,6 +295,8 @@ it does not install PromptKit catalog, definition, or requirements commands.
Its override feature is disabled because the released PromptKit configuration Its override feature is disabled because the released PromptKit configuration
loader does not accept ConfigKit override records. loader does not accept ConfigKit override records.
### Install resource commands and optional checks
`ckprompt.FromEngine(loadEngine)` supplies nine callbacks: prompt, profile, `ckprompt.FromEngine(loadEngine)` supplies nine callbacks: prompt, profile,
backend, namespace, and collection catalogs; named prompt, profile, and backend backend, namespace, and collection catalogs; named prompt, profile, and backend
inspection; and native requirements discovery. Concrete request validation, inspection; and native requirements discovery. Concrete request validation,
@@ -301,6 +331,8 @@ suite := ckprompt.WithRequestValidation(
validationCommand := ckprompt.Command(suite) validationCommand := ckprompt.Command(suite)
``` ```
### Request builders and loading
If the loader is nil, the returned suite retains a wiring issue for installation If the loader is nil, the returned suite retains a wiring issue for installation
to report, even if capabilities are narrowed later. A builder runs before the to report, even if capabilities are narrowed later. A builder runs before the
engine loader and receives the seed request, selection, typed input, shared engine loader and receives the seed request, selection, typed input, shared
@@ -350,6 +382,8 @@ same canonical request flags into their own input types. Each collection exposes
individual handles and an `All` method, so a command can choose its applicable individual handles and an `All` method, so a command can choose its applicable
subset. The application decides how to apply the resulting values to its request. subset. The application decides how to apply the resulting values to its request.
### Enable namespace flags
`ckprompt.WithNamespaces` enables standard resource namespace selection from `ckprompt.WithNamespaces` enables standard resource namespace selection from
application-supplied defaults. Loaders used with this option should leave view application-supplied defaults. Loaders used with this option should leave view
selection to the adapter. Without it, a loader can supply its own engine view. selection to the adapter. Without it, a loader can supply its own engine view.

View File

@@ -62,6 +62,7 @@ capability proposals in [future work](roadmap/future.md).
| Public facade or adapter boundaries | [Internal overview](internal/overview.md), [ADR 0002](adr/0002-public-facade-and-cli-backend.md), [ADR 0004](adr/0004-optional-integration-modules.md), and the architecture policy | | Public facade or adapter boundaries | [Internal overview](internal/overview.md), [ADR 0002](adr/0002-public-facade-and-cli-backend.md), [ADR 0004](adr/0004-optional-integration-modules.md), and the architecture policy |
| Module ownership and public contracts | [Consumer guide](consumers/guide.md), [internal overview](internal/overview.md), [core GoDoc](../doc.go), [ckconfig GoDoc](../integrations/ckconfig/doc.go), [ckprompt GoDoc](../integrations/ckprompt/doc.go), and applicable ADRs | | Module ownership and public contracts | [Consumer guide](consumers/guide.md), [internal overview](internal/overview.md), [core GoDoc](../doc.go), [ckconfig GoDoc](../integrations/ckconfig/doc.go), [ckprompt GoDoc](../integrations/ckprompt/doc.go), and applicable ADRs |
| Unfinished work and capability proposals | [Future work](roadmap/future.md) | | Unfinished work and capability proposals | [Future work](roadmap/future.md) |
| Completed documentation refresh | [Retired refresh record](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/5c960532552037d38119941389e558f78bf73aad/docs/roadmap/documentation-refresh.md) |
| Completed planning and implementation history | [Completed dependency upgrades, PromptKit validation, discovery, smoke testing, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/a6278a6d9f940948ec7745955d745523aaff0734/docs/roadmap), [earlier ConfigKit and PromptKit adoption and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap), [earlier retired records](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap), and [completed module implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/bb2528532b78d30e52f5082e491a5e1fd9b0fb99/docs/roadmap) in Git history | | Completed planning and implementation history | [Completed dependency upgrades, PromptKit validation, discovery, smoke testing, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/a6278a6d9f940948ec7745955d745523aaff0734/docs/roadmap), [earlier ConfigKit and PromptKit adoption and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap), [earlier retired records](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap), and [completed module implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/bb2528532b78d30e52f5082e491a5e1fd9b0fb99/docs/roadmap) in Git history |
| Release tooling | [Maintainer release procedure](release.md) | | Release tooling | [Maintainer release procedure](release.md) |
| Performance maintenance | [Internal overview](internal/overview.md), existing benchmarks, and [possible further efficiency work](roadmap/future.md#possible-further-efficiency-work) | | Performance maintenance | [Internal overview](internal/overview.md), existing benchmarks, and [possible further efficiency work](roadmap/future.md#possible-further-efficiency-work) |
@@ -72,7 +73,9 @@ Read all applicable rows for cross-cutting work. Go declarations and GoDoc own
current exported contracts; the CLI reference owns operator behavior. The current exported contracts; the CLI reference owns operator behavior. The
[maintainer release procedure](release.md) owns current release operations. [maintainer release procedure](release.md) owns current release operations.
Completed roadmaps and implementation evidence remain available in Git history, Completed roadmaps and implementation evidence remain available in Git history,
including [PromptKit v0.18.0 adoption and implementation](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/540e2359d66d604a563df0be1c9fc961588f5b9c/docs/roadmap), including [audit remediation and its declaration-path follow-up](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/aac0e11ae67c78a35745ed9a25688aa51c15e793/docs/roadmap),
[shared reporting and PromptKit v0.20.0 implementation](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/2f43f78f76439f5d11ef313cd379bec036543504/docs/roadmap),
[PromptKit v0.18.0 adoption and implementation](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/540e2359d66d604a563df0be1c9fc961588f5b9c/docs/roadmap),
[ConfigKit adoption, reporting, release-tooling, and remediation records](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap), [ConfigKit adoption, reporting, release-tooling, and remediation records](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap),
[application-owned alias planning, implementation, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/f42aefe02d84e154855ac922173e6f367180929f/docs/roadmap) [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). and [PromptKit requirements implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/40499a43608bf60d1104cd8ee24caf18ac0e9e66/docs/roadmap).
@@ -80,6 +83,9 @@ These records are not assignments to restart completed work. The
[future work record](roadmap/future.md) preserves unfinished work and unselected [future work record](roadmap/future.md) preserves unfinished work and unselected
proposals separately from current contracts. proposals separately from current contracts.
The [ckprompt release errata](../integrations/ckprompt/docs/releases/errata.md)
corrects navigation in a historical release note.
## Documentation validation ## Documentation validation
For current prose work, inspect relevant diffs, check local links and fragment For current prose work, inspect relevant diffs, check local links and fragment
@@ -101,10 +107,20 @@ Sibling-checkout references in retired planning records are historical review
evidence, not build dependencies. Implemented contracts belong in CommandKit evidence, not build dependencies. Implemented contracts belong in CommandKit
under their canonical owners. under their canonical owners.
The root module does not traverse nested modules. Preserve the ignored `go.work` The root module does not traverse nested modules. With Go 1.27.0 on `PATH`,
and `go.work.sum`; use `GOWORK=off GOTOOLCHAIN=local` for dependency preparation create the local workspace from the repository root if `go.work` is absent:
and independent module suites once released requirements are aligned. Until then,
the integrations require a Go 1.27.0 workspace containing all three modules. ```sh
go work init . ./integrations/ckconfig ./integrations/ckprompt
```
If a workspace already exists, ensure it includes these three modules. Preserve
the ignored `go.work` and `go.work.sum`; do not commit them. Use
`GOWORK=off GOTOOLCHAIN=local` for dependency preparation
and independent module suites. Both integrations select released core v0.5.0;
ckprompt also selects released ckconfig v0.5.0. Their declared dependencies
support independent builds. The aligned workspace exercises the local sources
together.
Run `GOWORK=off GOTOOLCHAIN=local go test ./...` separately from each module Run `GOWORK=off GOTOOLCHAIN=local go test ./...` separately from each module
directory. From the repository root, a workspace containing all three modules can directory. From the repository root, a workspace containing all three modules can
also run: also run:

View File

@@ -76,6 +76,8 @@ rendering. A session owns the mutable backend tree for one invocation; parsing
and presentation reuse that session. The adapter returns raw values and neutral and presentation reuse that session. The adapter returns raw values and neutral
syntax failures to core. It does not own the registry, typed input preparation, syntax failures to core. It does not own the registry, typed input preparation,
integration declarations, provider acquisition, or native report rendering. integration declarations, provider acquisition, or native report rendering.
Parser options carry flag-alias selection; invocation context and streams stay
with core, while backend output destinations discard and help uses a local buffer.
Cobra and pflag imports remain inside this package. Cobra and pflag imports remain inside this package.
## Optional modules ## Optional modules

View File

@@ -161,8 +161,8 @@ and deferred messages. Core owns shared visibility, formatting, delivery, and
finalization. Applications and integrations choose their report content and finalization. Applications and integrations choose their report content and
messages; callers choose the destinations. messages; callers choose the destinations.
[ADR 0006](../adr/0006-invocation-reporting.md) records this reporting and outcome [ADR 0006](../adr/0006-invocation-reporting.md) records this reporting and outcome
ownership model. Its accepted structured-report extension remains planned in the ownership model. The [CLI reference](../cli.md#reporting) describes the
[adoption roadmap](../roadmap/promptkit-v0.20.0.md#shared-core-report-output-prerequisite). implemented structured-report behavior.
For that shared report handoff, core owns format selection, serialization, For that shared report handoff, core owns format selection, serialization,
template execution, and delivery. Applications and integrations select logical template execution, and delivery. Applications and integrations select logical
@@ -187,7 +187,7 @@ handles its own errors, including routing, parsing, and presentation failures.
Preserve application-defined domain classifications and supplied error identity. Preserve application-defined domain classifications and supplied error identity.
Report rendering and delivery failures separately from the command's explanation Report rendering and delivery failures separately from the command's explanation
and cause, applying the [CLI reporting contract](../cli.md#presentation-failures). and cause, applying the [CLI reporting contract](../cli.md#output-failures).
Intentionally suppressed output is not a delivery failure. Present terminal Intentionally suppressed output is not a delivery failure. Present terminal
failure explanations after normal reporting finishes, avoid duplicate presentation failure explanations after normal reporting finishes, avoid duplicate presentation
through core's own paths, and retain the explanation and any supplied cause in through core's own paths, and retain the explanation and any supplied cause in

View File

@@ -100,10 +100,33 @@ standard offline suite and ReleaseKit’s candidate/note and publication guards.
Prepare dependencies in the selected module before the candidate is finalized. Prepare dependencies in the selected module before the candidate is finalized.
Do this after the preceding CommandKit module has been published and before the Do this after the preceding CommandKit module has been published and before the
selected candidate suite is run. For `core`, run the command at the repository selected candidate suite is run. For `core`, run the command at the repository
root; for integrations, run it from that module directory. Commit any resulting root; for integrations, run it from that module directory.
`go.mod` or `go.sum` changes with the release candidate. The sum files must
reflect dependencies actually downloaded for that module; do not fabricate Before downloading dependencies, update each integration to the newly published
future sums. CommandKit versions. `go mod download` downloads the versions already selected;
it does not upgrade requirements. Replace the version placeholders below with
the actual published versions, which may differ between modules:
```sh
# After publishing core, prepare ckconfig from its module directory.
GOWORK=off GOTOOLCHAIN=local go get go.maximumdirect.net/commandkit@vCORE_VERSION
# After publishing ckconfig, prepare ckprompt from its module directory.
GOWORK=off GOTOOLCHAIN=local go get \
go.maximumdirect.net/commandkit@vCORE_VERSION \
go.maximumdirect.net/ckconfig@vCKCONFIG_VERSION
```
Run only the command for the integration being prepared. ckprompt needs a
ckconfig release containing `AdaptDiagnostics`; v0.5.0 contains it, while v0.4.0
does not. Validate the integration with the workspace disabled against its
updated requirements, using the selected module's release validation below.
An aligned workspace can hide an outdated dependency.
Review and commit the resulting `go.mod` and `go.sum` changes with the release
candidate, and make its release note agree with the selected versions. The sum
files must reflect dependencies actually downloaded for that module; do not
fabricate future sums.
Then commit the selected module's release note and all intended candidate Then commit the selected module's release note and all intended candidate
changes on `main`, and publish the candidate commit to `origin`. Set one changes on `main`, and publish the candidate commit to `origin`. Set one

43
docs/releases/v0.5.0.md Normal file
View File

@@ -0,0 +1,43 @@
# CommandKit v0.5.0
CommandKit v0.5.0 fixes lost deferred messages and preserves YAML value types
more accurately. It also reduces repeated work when installing commands and
showing alias help, and refreshes documentation and help text.
## Changes
- Deferred messages accepted before finalization are retained for final reporting;
they can no longer be silently lost when a handler finishes. Later submissions return
`ErrReporterClosed`.
- Finished invocations release their direct references to output and diagnostic
writers while keeping delivery results available. Applications still own the
writers.
- `DecodeYAMLReport` preserves numeric-looking strings as strings. YAML report
output also preserves explicitly tagged numbers beyond the YAML resolver's
ordinary numeric range.
- Command installation, alias help, and typed input handling avoid repeated
work. Parsing and completion share flag handling while retaining their
different treatment of errors and unfinished input.
- The reporting example now works when its optional scenario is omitted, and
the interactive completion fixture no longer conflicts with `--format`.
`FlagMeta` documents existing naming and reserved-spelling rules.
- READMEs, CLI instructions, examples, and generated help use clearer language
and explain common tasks before implementation details.
## Compatibility and upgrade
The public core Go API, CLI grammar, and Go 1.26.0 consumer minimum are unchanged.
The maintainer release toolchain remains Go 1.27.0.
Applications using background producers should coordinate deferred submissions
before the handler returns and handle `ErrReporterClosed` once finalization
begins. See the [invocation reporting guide](../consumers/guide.md#invocation-reporting).
Callers supplying YAML with numbers beyond the resolver's ordinary range should
use explicit `!!int` or `!!float` tags when numeric meaning is intended. Untagged
values resolved as strings now retain that type. See
[`DecodeYAMLReport`](../../report_value.go) for the conversion contract.
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.5.0/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.5.0/LICENSE) |
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.5.0/docs/oss/attribution.md)

View File

@@ -1,16 +1,23 @@
# Future work # Future work
Status: pending release and integration work, plus future capability proposals. Status: pending ckprompt v0.5.0 publication, optional shell validation, and
future capability proposals.
No delivery dates are selected here. No delivery dates are selected here.
## Purpose and authority ## Purpose and authority
Completed roadmaps, implementation plans, and review findings have been retired. Earlier completed roadmaps, implementation plans, and review findings have been retired.
This document preserves unfinished work and unselected ideas under the This document preserves unfinished work and unselected ideas under the
[release policy](../policy/release.md#release-documentation-and-preparation). [release policy](../policy/release.md#release-documentation-and-preparation).
The completed [documentation refresh](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/5c960532552037d38119941389e558f78bf73aad/docs/roadmap/documentation-refresh.md) remains available in Git history.
The completed audits, consolidated remediation, implementation plan, and
declaration-path follow-up remain available in the
[audit remediation retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/aac0e11ae67c78a35745ed9a25688aa51c15e793/docs/roadmap).
The completed PromptKit v0.18.0 adoption roadmap and implementation plan remain The completed PromptKit v0.18.0 adoption roadmap and implementation plan remain
available in the available in the
[PromptKit retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/540e2359d66d604a563df0be1c9fc961588f5b9c/docs/roadmap). [PromptKit retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/540e2359d66d604a563df0be1c9fc961588f5b9c/docs/roadmap).
The completed PromptKit v0.20.0 and shared reporting implementation is recorded
in its [retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/2f43f78f76439f5d11ef313cd379bec036543504/docs/roadmap).
The completed ConfigKit adoption, reporting, dependency alignment, release-tooling, The completed ConfigKit adoption, reporting, dependency alignment, release-tooling,
and remediation records remain available at the and remediation records remain available at the
[retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap). [retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap).
@@ -26,51 +33,33 @@ an idea here does not select its syntax, API, priority, or delivery date.
### ckprompt release ### ckprompt release
The PromptKit v0.18.0 adoption is complete. The Core and ckconfig v0.5.0 are published. The ckprompt v0.5.0 candidate now selects
[ckprompt v0.3.0 candidate](../../integrations/ckprompt/docs/releases/v0.3.0.md) both releases and replaces the unpublished v0.3.0 draft. Complete validation and
remains unpublished. Publication and hosted delivery remain separate operations publication using the [release procedure](../release.md). Hosted delivery is a
under the [release procedure](../release.md). separate operation.
The published ckprompt v0.2.0 retains its earlier dependencies; see the
[consumer guide](../consumers/guide.md#module-selection) for module selection.
### PromptKit v0.20.0 adoption ### Optional shell completion validation
The [PromptKit v0.20.0 roadmap](promptkit-v0.20.0.md) owns the agreed adoption, Zsh and Fish interactive completion checks remain unverified because those
the shared core reporting/input prerequisites, operator CLI additions for executables were unavailable during remediation. Script generation for Bash,
PromptKit and ConfigKit, recorded decisions, and completion checklist. It targets Zsh and Fish and the Bash interactive checks passed. Run the existing opt-in
a clean alpha adoption without backward-compatibility provisions or consumer backend and consumer checks when those prerequisites are available, following
migration guidance. Implementation has not started. Use released upstream the [developer guide](../development.md#shell-completion-validation).
contracts only; do not inspect or monitor unreleased work in anticipation of
adoption.
## Future capability targets ## Future capability targets
### Comparison extensions ### Comparison extensions
The [working roadmap](promptkit-v0.20.0.md#additional-configkit-operator-workflows)
owns independent comparison selections (OD-9), assertion outcomes (OD-10), and
optional detailed reports (OD-5). These accepted plans do not change currently
implemented behavior.
General collection-clearing support is deferred pending a concrete consumer need. General collection-clearing support is deferred pending a concrete consumer need.
Revisit standardized syntax and binding support for explicitly replacing a Revisit standardized syntax and binding support for explicitly replacing a
nonempty application-default selector collection with an empty collection. nonempty application-default selector collection with an empty collection.
This is separate from clearing configuration lists through OD-11's transient This is separate from clearing configuration lists through transient
overrides and does not reinstate comparison inheritance. No clearing grammar overrides and does not reinstate comparison inheritance. No clearing grammar
or public helper API is selected. or public helper API is selected.
### Configuration inspection extensions ### Configuration inspection extensions
The [working roadmap](promptkit-v0.20.0.md#shared-configuration-capabilities) Persistent configuration editing remains deferred.
owns metadata/path discovery (OD-4), detailed comparison (OD-5), failed-load
evidence and aggregate validation (OD-6), focused inspection/filters (OD-8), and
transient overrides (OD-11). The six-command suite, selected output behavior,
and pass-through override policy are defined there. Implementation belongs in
`ckconfig`, with PromptKit exposing native evidence through its configuration
scope. Use the shared core reporting prerequisite and thin projections;
presentation refinements should follow implementation and beta feedback.
All numbered decisions are settled; exact APIs and wiring remain implementation
work. Persistent configuration editing remains deferred.
Discovery, import defaults, collection identities, and field-level resolution Discovery, import defaults, collection identities, and field-level resolution
recording remain available through application loaders. Discovery tracing, recording remain available through application loaders. Discovery tracing,
@@ -81,28 +70,8 @@ application loading and startup.
### PromptKit inspection and selection extensions ### PromptKit inspection and selection extensions
The [PromptKit v0.20.0 roadmap](promptkit-v0.20.0.md#targeted-end-state) General-purpose helpers for application-internal PromptKit use remain outside
owns native prompt/profile/backend resource inspection, concrete request preview, CommandKit's ownership boundary. The following CLI extensions are deferred.
applicable request flags, namespace selection, and the shared configuration
inspection extensions. The [resource inspection plan](promptkit-v0.20.0.md#resource-inspection)
includes backend inspection through the released upstream API. Execution flags
will be public reusable `ckprompt` collections for standard and application-owned
CLI commands, composed through existing core bindings.
[Roadmap OD-2](promptkit-v0.20.0.md#od-2-request-controls-and-provider-parameter-grammar)
records the agreed canonical naming, scoped convenience aliases, integration
installation collection, selected text/file input forms, and CLI JSON number
policy. Numeric conversion uses ordinary Go types and a small internal helper
with policy comments, behind a narrow public core CLI JSON parser. Collections
and aliases use existing declarations; cross-flag text/file precedence uses the
roadmap's ordered typed input prerequisite.
Output-contract overrides and appended-message flags are deferred below.
These are CLI features; general-purpose helpers for
application-internal PromptKit use are outside CommandKit's ownership boundary.
[Roadmap OD-3](promptkit-v0.20.0.md#od-3-namespace-flags-and-loader-defaults)
records the agreed namespace-selector applicability, including the limited role
of profile selection in requirements inspection. That selection follows the
released upstream identity requirement; it does not change prompt dependencies.
- **Output-contract override flags.** Deferred from the current adoption. A - **Output-contract override flags.** Deferred from the current adoption. A
possible future operator task is validating or previewing one prompt with a possible future operator task is validating or previewing one prompt with a
@@ -124,11 +93,6 @@ released upstream identity requirement; it does not change prompt dependencies.
Deferring flags does not remove native appended messages from preview reports Deferring flags does not remove native appended messages from preview reports
when an application builder supplies them. when an application builder supplies them.
[Roadmap OD-2](promptkit-v0.20.0.md#od-2-request-controls-and-provider-parameter-grammar)
selects inline text and explicit file input for single-prompt validation and
preview. Native API availability alone does not select further CLI syntax or
require a convenience API for application execution.
### Alias completion and discovery ### Alias completion and discovery
The delivered [shell completion feature](../cli.md#shell-completion) provides The delivered [shell completion feature](../cli.md#shell-completion) provides
@@ -145,15 +109,8 @@ steps. No delivery date or final API is selected here.
### Reporting extensions ### Reporting extensions
The [shared core report output prerequisite](promptkit-v0.20.0.md#shared-core-report-output-prerequisite) XML is outside current consideration; Markdown and other renderers remain
owns application-selected content, core rendering, universal/automatic format unselected.
selection, JSON representation conventions, text templates, and safe YAML input
conversion. Text, YAML, and JSON land together; new core formats work through the
same report API. The [output inventory](promptkit-v0.20.0.md#output-implementation-inventory)
identifies report paths and preserves explicit raw/help/diagnostic boundaries.
OD-6 defines one aggregate validation report and its changed delivery timing.
XML is outside current consideration; Markdown and other renderers are not
selected for this adoption.
Error-conversion helpers such as `WarnError` remain deferred pending demonstrated Error-conversion helpers such as `WarnError` remain deferred pending demonstrated
value; applications can already use `report.Warn(err.Error())`. Terminal redraw value; applications can already use `report.Warn(err.Error())`. Terminal redraw
@@ -183,9 +140,8 @@ operator or consumer need and an appropriate ownership/behavior decision:
and output-file management or reloadable export. and output-file management or reloadable export.
- Shell completion extensions, including alias discovery described above and - Shell completion extensions, including alias discovery described above and
dynamic discovery of configured or remote values. dynamic discovery of configured or remote values.
- Persistent configuration mutation. Transient CLI overrides are selected under - Persistent configuration mutation. Transient CLI overrides and field metadata
roadmap OD-11; field metadata and provenance inspection are scoped under inspection are available through the standard configuration suite.
[configuration inspection extensions](#configuration-inspection-extensions).
- Credential diagnostics beyond PromptKit's native smoke checks and additional - Credential diagnostics beyond PromptKit's native smoke checks and additional
live provider probes. live provider probes.
- Additional component identity registration and domain artifact catalogs. - Additional component identity registration and domain artifact catalogs.
@@ -194,10 +150,7 @@ operator or consumer need and an appropriate ownership/behavior decision:
- Backend selection or replacement, a plugin framework, general dependency - Backend selection or replacement, a plugin framework, general dependency
injection, and concurrent-dispatch guarantees. injection, and concurrent-dispatch guarantees.
Execution, serving, and session operations remain application-owned. The Execution, serving, and session operations remain application-owned.
[request preview proposal](promptkit-v0.20.0.md#preview-and-request-flags)
exposes native preparation and rendering as an agreed operator inspection target;
implementation has not started.
The [architecture policy](../policy/architecture.md#command-and-application-ownership) The [architecture policy](../policy/architecture.md#command-and-application-ownership)
recommends shared vocabulary; the [CLI reference](../cli.md#registered-command-syntax) recommends shared vocabulary; the [CLI reference](../cli.md#registered-command-syntax)
owns implemented grammar. owns implemented grammar.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +1,70 @@
# Core example # Core example
Run the example from the repository root: This example shows typed flags, command shortcuts, shared reporting, and shell
completion. It needs no external service. See the [consumer guide](../../docs/consumers/guide.md#go-compatibility)
for Go requirements; maintainers use the toolchain in the
[developer guide](../../docs/development.md#documentation-validation).
Run from the repository root, with that Go toolchain on `PATH`:
```sh ```sh
export GOWORK=off GOTOOLCHAIN=local
go run ./examples/core workspace greet --count=2
```
The result shows the selected count and whether it was supplied. It also shows
the entered command path and the original command path, which differ when you
use a shortcut.
## Try shortcuts and explicit values
```sh
# The hello shortcut runs workspace greet; --times means --count.
go run ./examples/core hello --times=0 --tag=first go run ./examples/core hello --times=0 --tag=first
# ws is a shortcut for the workspace group.
go run ./examples/core ws greet --count=2 go run ./examples/core ws greet --count=2
# Inspect the group or use the example's global help alias.
go run ./examples/core ws --help go run ./examples/core ws --help
go run ./examples/core hello --hlep go run ./examples/core hello --hlep
```
An explicit zero remains a supplied value. The handler prints both
`Invocation.EnteredPath()` and `Invocation.CanonicalPath()` so you can see how
shortcuts resolve.
## Try reporting
```sh
go run ./examples/core report
go run ./examples/core report --verbose
go run ./examples/core report --quiet
```
The default scenario succeeds and writes its result. Verbose mode also shows
warnings and information; quiet mode hides the result. The
[CLI reference](../../docs/cli.md#reporting) defines the shared visibility rules.
## Try shell completion
```sh
go build -o core-example ./examples/core go build -o core-example ./examples/core
./core-example completion bash ./core-example completion bash
``` ```
The example installs a canonical `workspace greet` command together with a This prints the Bash completion script. Initialize `bash-completion`, then load
command shortcut, a group shortcut, a scoped flag alias, and a global help the script as described in the [CLI reference](../../docs/cli.md#shell-completion).
alias. Its declaration slice mixes the canonical group and routing rules as Use the executable basename `core-example` and try completing
`[]commandkit.Declaration`. The handler prints `Invocation.EnteredPath()` and `core-example report d` or `core-example ws gr`. Other supported shells are
`Invocation.CanonicalPath()` so the alternate route and its canonical target listed in the reference.
are visible in the output.
The example also installs `Completion("core-example")` and gives the `report` ## How it is wired
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 The example installs `workspace greet` alongside command and flag aliases in a
passed directly to `New` beside `commandkit.Alias` or flag-alias declarations. `[]commandkit.Declaration`. It also installs `Completion("core-example")` and
The matched ckconfig and ckprompt releases use the new core identity. For a provides static choices for the reporting scenario argument. The
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) [consumer guide](../../docs/consumers/guide.md#application-owned-aliases)
shows both forms, and the [composed consumer](../../integrations/ckprompt/examples/composed) explains declaration composition; the [composed example](../../integrations/ckprompt/examples/composed)
is the maintained integration example. adds integration commands.
These aliases require core v0.3.0. Repository contributors can run its test with Run `GOWORK=off GOTOOLCHAIN=local go test ./examples/core` from the repository
`go test ./examples/core` from the root. root to check this example.

View File

@@ -74,7 +74,11 @@ func newApp() (*commandkit.App, error) {
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "scenario", Usage: "demo, recover, warn, warn-error, fail, or partial.", Completion: commandkit.CompleteChoices( 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: "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 }, 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 }), func(input *reportInput, value commandkit.Supplied[string]) {
if value.Set {
input.Scenario = value.Value
}
}),
}, },
CheckInput: func(input reportInput) error { CheckInput: func(input reportInput) error {
switch input.Scenario { switch input.Scenario {

View File

@@ -80,10 +80,8 @@ func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outco
type execution struct { type execution struct {
app *App app *App
ctx context.Context ctx context.Context
streams Streams
reporter *Reporter reporter *Reporter
debugEvents []string debugEvents []string
options commandmeta.ParseOptions
snapshot *routingSnapshot snapshot *routingSnapshot
session *internalcobra.Session session *internalcobra.Session
route routeResult route routeResult
@@ -116,13 +114,10 @@ func (current commandExecution) renderHelpTargets(targets []string) Outcome {
} }
func newExecution(app *App, ctx context.Context, streams Streams) *execution { func newExecution(app *App, ctx context.Context, streams Streams) *execution {
streams = normalizeStreams(streams)
current := &execution{ current := &execution{
app: app, app: app,
ctx: ctx, ctx: ctx,
streams: streams,
reporter: newReporter(streams), reporter: newReporter(streams),
options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
} }
if app != nil { if app != nil {
current.snapshot = app.routeSnapshot() current.snapshot = app.routeSnapshot()
@@ -147,7 +142,7 @@ func (current *execution) prepareSession() {
if current.snapshot == nil { if current.snapshot == nil {
current.snapshot = current.app.routeSnapshot() current.snapshot = current.app.routeSnapshot()
} }
options := current.options options := commandmeta.ParseOptions{}
if current.snapshot != nil { if current.snapshot != nil {
switch current.route.kind { switch current.route.kind {
case routeCanonical: case routeCanonical:
@@ -201,11 +196,11 @@ func (current *execution) renderHelpTargets(targets []string) Outcome {
// Explicit help targets are paths, not command invocations with operands. // Explicit help targets are paths, not command invocations with operands.
// Discovery routes have no canonical path and retain only trailing options. // Discovery routes have no canonical path and retain only trailing options.
if route.kind == routeUnknown || len(route.args) != len(route.canonicalPath) { if route.kind == routeUnknown || len(route.args) != len(route.canonicalPath) {
session := internalcobra.Prepare(snapshot.metadata, current.options) session := internalcobra.Prepare(snapshot.metadata, commandmeta.ParseOptions{})
view := commandmeta.HelpView{EnteredPath: targets, CanonicalPath: targets} view := commandmeta.HelpView{EnteredPath: targets, CanonicalPath: targets}
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session) return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
} }
options := current.options options := commandmeta.ParseOptions{}
switch route.kind { switch route.kind {
case routeCanonical: case routeCanonical:
options.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords() options.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()

View File

@@ -66,6 +66,7 @@ func (spelling capturedFlagSpelling) token() string {
func compileFlagAliases(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface { func compileFlagAliases(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
Resolve(commandpath.Path) commandpath.Path Resolve(commandpath.Path) commandpath.Path
ProperSourcePrefixes() []commandpath.Path
}) (map[string]resolvedFlagAliases, map[string]resolvedFlagAliases, error) { }) (map[string]resolvedFlagAliases, map[string]resolvedFlagAliases, error) {
canonicalNodes := indexCanonicalNodes(roots) canonicalNodes := indexCanonicalNodes(roots)
global := make(map[string]flagAliasMapping) global := make(map[string]flagAliasMapping)
@@ -170,6 +171,7 @@ func canonicalFlagSpellings(command Command) map[string]capturedFlagSpelling {
func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface { func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
Resolve(commandpath.Path) commandpath.Path Resolve(commandpath.Path) commandpath.Path
ProperSourcePrefixes() []commandpath.Path
}, canonicalNodes map[string]*canonicalNode) map[string]flagAliasContext { }, canonicalNodes map[string]*canonicalNode) map[string]flagAliasContext {
commandSources := make(map[string]struct{}) commandSources := make(map[string]struct{})
prefixes := make(map[string]commandpath.Path) prefixes := make(map[string]commandpath.Path)
@@ -178,26 +180,20 @@ func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode,
commandSources[rule.record.sourcePath.Key()] = struct{}{} commandSources[rule.record.sourcePath.Key()] = struct{}{}
} }
} }
for _, rule := range rules { for _, prefix := range aliases.ProperSourcePrefixes() {
if rule.record.kind != commandAliasRule { key := prefix.Key()
if canonicalNodes[key] != nil {
continue continue
} }
for length := 1; length < len(rule.record.sourcePath); length++ { if _, exactAlias := commandSources[key]; exactAlias {
prefix := rule.record.sourcePath[:length].Clone() continue
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
} }
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)) contexts := make(map[string]flagAliasContext, len(prefixes))
for key, path := range prefixes { for key, path := range prefixes {
@@ -230,14 +226,54 @@ func resolveFlagAliases(context flagAliasContext, mappings map[string]flagAliasM
sources = append(sources, source) sources = append(sources, source)
} }
sort.Strings(sources) sort.Strings(sources)
type terminal struct {
spelling capturedFlagSpelling
active bool
}
resolved := make(map[string]terminal)
visiting := make(map[string]int)
var stack []capturedFlagSpelling
var resolve func(capturedFlagSpelling) (terminal, error)
resolve = func(current capturedFlagSpelling) (terminal, error) {
key := flagSpellingKey(current)
if endpoint, exists := context.canonical[key]; exists {
return terminal{spelling: endpoint, active: true}, nil
}
if result, exists := resolved[key]; exists {
return result, nil
}
if start, exists := visiting[key]; exists {
cycle := append(append([]capturedFlagSpelling(nil), stack[start:]...), current)
spellings := make([]string, 0, len(cycle))
for _, spelling := range cycle {
spellings = append(spellings, spelling.token())
}
return terminal{}, fmt.Errorf("flag alias cycle: %s", strings.Join(spellings, " → "))
}
mapping, exists := mappings[key]
if !exists {
resolved[key] = terminal{}
return terminal{}, nil
}
visiting[key] = len(stack)
stack = append(stack, current)
result, err := resolve(mapping.target)
stack = stack[:len(stack)-1]
delete(visiting, key)
if err != nil {
return terminal{}, err
}
resolved[key] = result
return result, nil
}
for _, sourceKey := range sources { for _, sourceKey := range sources {
mapping := mappings[sourceKey] mapping := mappings[sourceKey]
source := spellingFromKey(sourceKey) source := spellingFromKey(sourceKey)
target, active, err := resolveFlagTarget(source, mapping.target, mappings, context.canonical) terminal, err := resolve(mapping.target)
if err != nil { if err != nil {
return resolvedFlagAliases{}, fmt.Errorf("%s: %w", flagContextDescription(context), err) return resolvedFlagAliases{}, fmt.Errorf("%s: %w", flagContextDescription(context), err)
} }
if !active { if !terminal.active {
if mapping.scoped { if mapping.scoped {
return resolvedFlagAliases{}, fmt.Errorf("scoped flag alias %s has no canonical destination in %s", source.token(), flagContextDescription(context)) return resolvedFlagAliases{}, fmt.Errorf("scoped flag alias %s has no canonical destination in %s", source.token(), flagContextDescription(context))
} }
@@ -246,38 +282,11 @@ func resolveFlagAliases(context flagAliasContext, mappings map[string]flagAliasM
if _, collision := context.canonical[sourceKey]; collision { if _, collision := context.canonical[sourceKey]; collision {
return resolvedFlagAliases{}, fmt.Errorf("flag alias %s conflicts with a canonical spelling in %s", source.token(), flagContextDescription(context)) 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} result.bySpelling[sourceKey] = resolvedFlagAlias{source: source, target: terminal.spelling}
} }
return result, nil 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 { func spellingFromKey(key string) capturedFlagSpelling {
if strings.HasPrefix(key, "short:") { if strings.HasPrefix(key, "short:") {
return capturedFlagSpelling{name: strings.TrimPrefix(key, "short:"), short: true} return capturedFlagSpelling{name: strings.TrimPrefix(key, "short:"), short: true}

View File

@@ -295,3 +295,21 @@ func capturedSpelling(token string) capturedFlagSpelling {
issues := []error(nil) issues := []error(nil)
return captureFlagSpelling("test", token, &issues) return captureFlagSpelling("test", token, &issues)
} }
func TestFlagAliasSharedSuffixAndUnavailableDestination(t *testing.T) {
app, err := New(Identity{Name: "Example"}, flagAliasCommand("active", stringFlag("color", "")), flagAliasCommand("inactive"),
FlagAlias("--first", "--common"), FlagAlias("--second", "--common"), FlagAlias("--common", "--color"))
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"first", "second", "common"} {
key := flagSpellingKey(capturedFlagSpelling{name: name})
active := app.flagAliases[commandpath.Path{"active"}.Key()].bySpelling
if alias, ok := active[key]; !ok || alias.target.token() != "--color" {
t.Fatalf("active %s = %#v", name, alias)
}
if _, ok := app.flagAliases[commandpath.Path{"inactive"}.Key()].bySpelling[key]; ok {
t.Fatalf("inactive %s was installed", name)
}
}
}

View File

@@ -1,9 +1,9 @@
package commandkit package commandkit
import ( import (
"container/heap"
"errors" "errors"
"fmt" "fmt"
"sort"
) )
// ErrFlagInput identifies a missing required captured flag selected for an input. // ErrFlagInput identifies a missing required captured flag selected for an input.
@@ -108,24 +108,74 @@ func (values FlagValues[I]) applyTo(target *I, suppliedOnly bool, selected ...Fl
} }
preparedValues = append(preparedValues, prepared) preparedValues = append(preparedValues, prepared)
} }
var ordered []preparedOccurrence[I] var ordered occurrenceHeap[I]
for _, prepared := range preparedValues { for index, prepared := range preparedValues {
if prepared.apply != nil { if prepared.apply != nil {
prepared.apply(target) prepared.apply(target)
} }
for _, occurrence := range prepared.ordered { stream := occurrenceStream[I]{events: prepared.ordered, order: index}
if occurrence.apply != nil { stream.skipUnapplied()
ordered = append(ordered, occurrence) if len(stream.events) > 0 {
} ordered = append(ordered, stream)
} }
} }
sort.SliceStable(ordered, func(i, j int) bool { return *ordered[i].ordinal < *ordered[j].ordinal }) // One selected stream already has the required order.
for _, occurrence := range ordered { if len(ordered) == 1 {
occurrence.apply(target) for _, event := range ordered[0].events {
if event.apply != nil {
event.apply(target)
}
}
return nil
}
heap.Init(&ordered)
for len(ordered) > 0 {
stream := &ordered[0]
stream.events[0].apply(target)
stream.events = stream.events[1:]
stream.skipUnapplied()
if len(stream.events) == 0 {
heap.Pop(&ordered)
} else {
heap.Fix(&ordered, 0)
}
} }
return nil return nil
} }
// Each binding supplies a monotone stream. The selection index breaks ordinal
// ties in the same order as concatenation followed by a stable sort.
type occurrenceStream[I any] struct {
events []preparedOccurrence[I]
order int
}
func (stream *occurrenceStream[I]) skipUnapplied() {
for len(stream.events) > 0 && stream.events[0].apply == nil {
stream.events = stream.events[1:]
}
}
type occurrenceHeap[I any] []occurrenceStream[I]
func (h occurrenceHeap[I]) Len() int { return len(h) }
func (h occurrenceHeap[I]) Less(i, j int) bool {
left, right := *h[i].events[0].ordinal, *h[j].events[0].ordinal
if left == right {
return h[i].order < h[j].order
}
return left < right
}
func (h occurrenceHeap[I]) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *occurrenceHeap[I]) Push(value any) { *h = append(*h, value.(occurrenceStream[I])) }
func (h *occurrenceHeap[I]) Pop() any {
last := len(*h) - 1
value := (*h)[last]
(*h)[last] = occurrenceStream[I]{}
*h = (*h)[:last]
return value
}
func classifyInputError(err error) Outcome { func classifyInputError(err error) Outcome {
if errors.Is(err, ErrFlagWiring) { if errors.Is(err, ErrFlagWiring) {
return Failure(err.Error()).WithCause(err) return Failure(err.Error()).WithCause(err)

View File

@@ -36,11 +36,8 @@ func routeHelpAliases(snapshot *routingSnapshot, route routeResult) []commandmet
} }
bySource := make(map[string]commandmeta.CommandAlias) bySource := make(map[string]commandmeta.CommandAlias)
for _, prefix := range snapshot.aliases.ResolutionPaths(route.enteredPath) { for _, prefix := range snapshot.aliases.ResolutionPaths(route.enteredPath) {
for _, alias := range snapshot.commandAliases { for _, alias := range snapshot.aliases.Descendants(prefix) {
source := commandpath.FromTokens(alias.Source) source := alias.Source
if len(source) <= len(prefix) || !source.HasPrefix(prefix) {
continue
}
suffix := source[len(prefix):] suffix := source[len(prefix):]
entered := append(route.enteredPath.Clone(), suffix...) entered := append(route.enteredPath.Clone(), suffix...)
target := snapshot.aliases.Resolve(entered) target := snapshot.aliases.Resolve(entered)

View File

@@ -1,36 +1,30 @@
# CommandKit ckconfig # CommandKit ckconfig
`ckconfig` is the optional ConfigKit integration for CommandKit. It adapts `ckconfig` adds configuration inspection commands to Go applications that use
ConfigKit loaders and scopes into reusable `commandkit.Command` declarations; CommandKit and ConfigKit. Operators can view settings, validate configuration,
installing the integration is explicit and does not modify an application by find where values came from, and compare two configurations.
itself.
The application chooses which commands to install. Importing the package alone
does not install commands.
## Module ## Module
The module path is `go.maximumdirect.net/ckconfig`. Version v0.3.0 requires Go The module path is `go.maximumdirect.net/ckconfig`. It does not import PromptKit.
1.27.0 and uses `go.maximumdirect.net/commandkit v0.3.0` and See the [consumer guide](https://forge.maximumdirect.net/base-layer/commandkit/src/branch/main/docs/consumers/guide.md#module-selection)
`go.maximumdirect.net/configkit v1.1.0`; it does not import PromptKit. See the for compatible releases and Go requirements.
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#configkit-scopes)
for exact callback and lifetime contracts.
## Example ## Example
For one application configuration scope, install the convenience declaration: To inspect one application configuration, pass your loader to `AppCommand`:
```go ```go
app, err := commandkit.New(identity, ckconfig.AppCommand(loadAppConfig)) app, err := commandkit.New(identity, ckconfig.AppCommand(loadAppConfig))
``` ```
Use `ckconfig.Command(ckconfig.Suite[Input, State]{...})` to define named For multiple named configurations or application-specific flags, use
scopes, selectors, input checks, and state shared by the selected acquisitions `ckconfig.Command` with a `ckconfig.Suite`. The
or comparison in a config invocation. `ckconfig.FromConfigKit` adapts a typed [integration guide](https://forge.maximumdirect.net/base-layer/commandkit/src/branch/main/docs/consumers/guide.md#configkit-scopes)
ConfigKit loader. The `config` command family exposes scope discovery and the explains loader setup, inputs, and shared state.
validate, show, sources, explain, and diff operations supported by the declared
scopes. Source reports include native destination, placement, destination
fallback, version evidence, and empty-import selections. Comparison returns
ConfigKit's native records to shared report rendering; the
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#compare-configuration-selections)
owns its operator details.
The maintained [simple example](examples/simple) shows a complete consumer. The maintained [simple example](examples/simple) shows a complete consumer.
The [CommandKit CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#configuration-scopes) The [CommandKit CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#configuration-scopes)

View File

@@ -43,17 +43,24 @@ func FromConfigKit[I, S, T any](
acquired.Value.Sources = loadError acquired.Value.Sources = loadError
acquired.Value.Findings = &Findings{Records: records, Messages: messages} acquired.Value.Findings = &Findings{Records: records, Messages: messages}
acquired.Explanation = explanation acquired.Explanation = explanation
return acquired, err
} }
return acquired, err
} }
if result == nil { if result == nil {
if err != nil {
return acquired, err
}
return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name) return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
} }
metadata := result.Metadata()
records := result.Diagnostics() records := result.Diagnostics()
messages, explanation := configKitDiagnostics(name, result.Sources(), records) messages, explanation := configKitDiagnostics(name, result.Sources(), records)
acquired.Value = View{Effective: result, Sources: result, Explanation: result, Metadata: &metadata, Findings: &Findings{Records: records, Messages: messages}} acquired.Value = View{Sources: result, Findings: &Findings{Records: records, Messages: messages}}
acquired.Explanation = explanation acquired.Explanation = explanation
if err != nil {
return acquired, err
}
metadata := result.Metadata()
acquired.Value.Effective, acquired.Value.Explanation, acquired.Value.Metadata = result, result, &metadata
return acquired, nil return acquired, nil
} }
scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) { scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
@@ -120,14 +127,17 @@ func loadComparisonSide[I, S, T any](
} }
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) { func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+":\n", sources, records) return AdaptDiagnostics("Configuration scope "+strconv.Quote(scope)+":\n", sources, records)
} }
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) { 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) return AdaptDiagnostics("Configuration scope "+strconv.Quote(scope)+" "+side+":\n", sources, records)
} }
func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) { // AdaptDiagnostics captures ConfigKit diagnostics as CLI presentation evidence.
// It does not print, acquire configuration, determine outcomes, or schedule messages.
// The heading is used as supplied when the captured evidence is rendered.
func AdaptDiagnostics(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
if len(records) == 0 { if len(records) == 0 {
return nil, commandkit.Explanation{} return nil, commandkit.Explanation{}
} }

View File

@@ -63,7 +63,9 @@ type ComparisonRequest[I any] struct {
Right ComparisonSide[I] Right ComparisonSide[I]
} }
// EffectiveWriter writes a configuration's native effective representation. // EffectiveWriter writes one safe YAML document for conversion by
// commandkit.DecodeYAMLReport. Its formatting, comments, and framing are not
// passed through to the resulting report.
type EffectiveWriter interface { type EffectiveWriter interface {
WriteEffective(io.Writer) error WriteEffective(io.Writer) error
} }
@@ -73,14 +75,12 @@ type SourceReader interface {
Sources() []configkit.SourceInfo Sources() []configkit.SourceInfo
} }
// ExplanationReader reads and writes a configuration's native path explanation. // ExplanationReader acquires a configuration's native path explanation.
type ExplanationReader interface { type ExplanationReader interface {
Explain(configkit.Path) (configkit.Explanation, bool) Explain(configkit.Path) (configkit.Explanation, bool)
WriteExplanation(io.Writer, configkit.Path) error
} }
// View exposes native configuration facets without reconstructing their // View exposes native configuration evidence used to build reports.
// reports.
type View struct { type View struct {
Effective EffectiveWriter Effective EffectiveWriter
Sources SourceReader Sources SourceReader

View File

@@ -117,13 +117,6 @@ func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
if _, ok := acquired.Value.Explanation.Explain(path); !ok { if _, ok := acquired.Value.Explanation.Explain(path); !ok {
t.Fatal("native explanation facet did not resolve /name") t.Fatal("native explanation facet did not resolve /name")
} }
var explanation bytes.Buffer
if err := acquired.Value.Explanation.WriteExplanation(&explanation, path); err != nil {
t.Fatal(err)
}
if explanation.Len() == 0 {
t.Fatal("native explanation writer produced no output")
}
} }
func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) { func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
@@ -140,8 +133,12 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
if loadErr == nil { if loadErr == nil {
t.Fatal("fixture did not produce a ConfigKit load error") t.Fatal("fixture did not produce a ConfigKit load error")
} }
partial, err := configkit.Load[struct{}](configkit.Mapping("unrelated result", map[string]any{}))
if err != nil {
t.Fatal(err)
}
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[struct{}], error) { scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[struct{}], error) {
return nil, loadErr return partial, loadErr
}) })
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{}) acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
if gotErr != loadErr { if gotErr != loadErr {
@@ -157,6 +154,9 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
if acquired.Explanation.Message != "" || len(acquired.Explanation.Details) != 1 { if acquired.Explanation.Message != "" || len(acquired.Explanation.Details) != 1 {
t.Fatalf("failure explanation = %#v", acquired.Explanation) t.Fatalf("failure explanation = %#v", acquired.Explanation)
} }
if !reflect.DeepEqual(acquired.Value.Sources.Sources(), typed.Sources()) || !reflect.DeepEqual(acquired.Value.Findings.Records, typed.Diagnostics()) {
t.Fatal("LoadError snapshot was replaced or duplicated")
}
var warning, terminal bytes.Buffer var warning, terminal bytes.Buffer
if err := acquired.Value.Findings.Messages[0].Write(&warning); err != nil { if err := acquired.Value.Findings.Messages[0].Write(&warning); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -175,43 +175,83 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
} }
} }
func TestFromConfigKitDoesNotExposeResultFromFailedLoad(t *testing.T) { func TestFromConfigKitRetainsResultEvidenceWithApplicationFailure(t *testing.T) {
result, err := adapterSettingsLoad(configkit.Mapping("partial source", map[string]any{"greeting": "Hello"})) result, err := adapterSettingsLoad(configkit.Mapping("partial source", map[string]any{"greeting": "Hello"}))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
loadErr := errors.New("loader returned a value with an error") for _, cause := range []error{errors.New("post-load check failed"), context.Canceled} {
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) { t.Run(cause.Error(), func(t *testing.T) {
return result, loadErr scope := FromConfigKit("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
}) return result, cause
})
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{}) acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
if gotErr != loadErr { if gotErr != cause || acquired.Value.Effective != nil || acquired.Value.Metadata != nil || acquired.Value.Explanation != nil || acquired.Value.Sources == nil || acquired.Value.Findings == nil {
t.Fatalf("load error identity = %v, want %v", gotErr, loadErr) t.Fatalf("failed acquisition = %#v, %v", acquired, gotErr)
}
if !reflect.DeepEqual(acquired.Value.Sources.Sources(), result.Sources()) || !reflect.DeepEqual(acquired.Value.Findings.Records, result.Diagnostics()) || len(acquired.Value.Findings.Messages) != 1 || len(acquired.Explanation.Details) != 0 {
t.Fatalf("lost or duplicated result evidence: %#v", acquired)
}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
for _, args := range [][]string{{"config", "sources", "--format=json"}, {"config", "validate", "--details", "--format=json"}, {"config", "validate", "--verbose"}} {
var out, diag bytes.Buffer
got := app.Execute(context.Background(), args, commandkit.Streams{Out: &out, Err: &diag})
if !errors.Is(got.Cause, cause) {
t.Fatalf("lost cause: %#v", got)
}
if args[1] == "sources" {
if !strings.Contains(out.String(), "partial source") {
t.Fatalf("lost sources: %s", out.String())
}
} else if strings.Contains(strings.Join(args, " "), "--details") {
if !strings.Contains(out.String(), result.Diagnostics()[0].Message) || strings.Contains(diag.String(), result.Diagnostics()[0].Message) {
t.Fatalf("detailed evidence: output=%s diag=%s", out.String(), diag.String())
}
} else if !strings.Contains(diag.String(), result.Diagnostics()[0].Message) {
t.Fatalf("lost ordinary warning: %s", diag.String())
}
}
writeErr := errors.New("output failed")
got := app.Execute(context.Background(), []string{"config", "validate", "--details"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
if !errors.Is(got.Cause, cause) || !errors.Is(got.Presentation.Output.WriteError, writeErr) {
t.Fatalf("lost operation/presentation cause: %#v", got)
}
})
} }
if acquired.Value.Effective != nil || acquired.Value.Metadata != nil || acquired.Value.Sources != nil || acquired.Value.Explanation != nil || acquired.Value.Findings != nil { empty, err := configkit.Load[map[string]any](configkit.Mapping("empty", map[string]any{}))
t.Fatalf("failed load exposed result facets: %#v", acquired.Value) if err != nil {
t.Fatal(err)
} }
if len(acquired.Explanation.Details) != 0 { for _, result := range []*configkit.Result[map[string]any]{nil, empty} {
t.Fatalf("warning-only result acquired terminal context: %#v", acquired.Explanation) cause := errors.New("application failure")
scope := FromConfigKit("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[map[string]any], error) {
return result, cause
})
got, err := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
if err != cause || (got.Value.Findings == nil) != (result == nil) {
t.Fatalf("unavailable/empty evidence: %#v %v", got, err)
}
} }
} }
func TestConfigKitDiagnosticsMapSeverityAndPropagateWriterFailures(t *testing.T) { func TestConfigKitDiagnosticsMapSeverityAndPropagateWriterFailures(t *testing.T) {
warning, noExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}}) warning, noExplanation := AdaptDiagnostics("scope:\n", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}})
if len(warning) != 1 || warning[0].Severity != commandkit.SeverityWarning { if len(warning) != 1 || warning[0].Severity != commandkit.SeverityWarning {
t.Fatalf("warning diagnostics = %#v", warning) t.Fatalf("warning diagnostics = %#v", warning)
} }
if noExplanation.Message != "" || len(noExplanation.Details) != 0 { if noExplanation.Message != "" || len(noExplanation.Details) != 0 {
t.Fatalf("warning-only explanation = %#v", noExplanation) t.Fatalf("warning-only explanation = %#v", noExplanation)
} }
withWarning, withExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{ withWarning, withExplanation := AdaptDiagnostics("scope:\n", nil, []configkit.Diagnostic{
{Severity: configkit.SeverityWarning, Message: "safe warning"}, {Severity: configkit.SeverityWarning, Message: "safe warning"},
{Severity: configkit.SeverityError, Message: "safe error"}, {Severity: configkit.SeverityError, Message: "safe error"},
}) })
if len(withWarning) != 1 || withWarning[0].Severity != commandkit.SeverityWarning || len(withExplanation.Details) != 1 { if len(withWarning) != 1 || withWarning[0].Severity != commandkit.SeverityWarning || len(withExplanation.Details) != 1 {
t.Fatalf("mixed diagnostics = %#v, explanation = %#v", withWarning, withExplanation) t.Fatalf("mixed diagnostics = %#v, explanation = %#v", withWarning, withExplanation)
} }
if empty, explanation := AdaptDiagnostics("unused", nil, nil); len(empty) != 0 || len(explanation.Details) != 0 {
t.Fatalf("empty evidence = %#v, %#v", empty, explanation)
}
if err := withWarning[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) t.Fatalf("short writer error = %v, want %v", err, io.ErrShortWrite)
@@ -243,10 +283,11 @@ func TestConfigKitTerminalContextRetainsRelatedLocationsAndSnapshots(t *testing.
Primary: primary, Primary: primary,
Related: related, Related: related,
}} }}
_, explanation := configKitDiagnostics("app", sources, records) _, explanation := AdaptDiagnostics("Configuration scope \"app\":\n", sources, records)
primary.SourceID = "mutated-primary" primary.SourceID = "mutated-primary"
related[0].SourceID = "mutated-related" related[0].SourceID = "mutated-related"
sources[0].Path = "mutated.yml" sources[0].Path = "mutated.yml"
records[0].Message = "mutated message"
if len(explanation.Details) != 1 { if len(explanation.Details) != 1 {
t.Fatalf("explanation = %#v", explanation) t.Fatalf("explanation = %#v", explanation)
@@ -260,7 +301,7 @@ func TestConfigKitTerminalContextRetainsRelatedLocationsAndSnapshots(t *testing.
t.Errorf("terminal context %q omits %q", 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") { if strings.Contains(output.String(), "mutated-") || strings.Contains(output.String(), "mutated.yml") || strings.Contains(output.String(), "mutated message") {
t.Fatalf("terminal renderer retained caller mutations: %q", output.String()) t.Fatalf("terminal renderer retained caller mutations: %q", output.String())
} }
} }

View File

@@ -60,6 +60,44 @@ func TestDetailedValidationFiltersAndRetainsFailureEvidence(t *testing.T) {
if strings.Contains(diag.String(), "native warnings") || !strings.Contains(diag.String(), "application warning") || !strings.Contains(diag.String(), "terminal failure") { if strings.Contains(diag.String(), "native warnings") || !strings.Contains(diag.String(), "application warning") || !strings.Contains(diag.String(), "terminal failure") {
t.Fatalf("diagnostics=%q", diag.String()) t.Fatalf("diagnostics=%q", diag.String())
} }
for _, tc := range []struct {
name string
flags []string
first, second []string
}{
{"any within filters", []string{"--severity=warning", "--severity=error", "--code=chosen", "--code=other", "--code=failed"}, []string{"selected warning", "other warning"}, []string{"failed finding"}},
{"duplicates and unknown", []string{"--severity=warning", "--severity=warning", "--code=chosen", "--code=chosen", "--code=future-code"}, []string{"selected warning"}, nil},
{"both filters", []string{"--severity=error", "--code=chosen", "--code=failed"}, nil, []string{"failed finding"}},
{"no matching code", []string{"--code=future-code"}, nil, nil},
} {
t.Run(tc.name, func(t *testing.T) {
var out bytes.Buffer
args := append([]string{"config", "validate", "--scope=all", "--details", "--format=json"}, tc.flags...)
got := app.Execute(context.Background(), args, commandkit.Streams{Out: &out, Err: io.Discard})
var report validationReport
if err := json.Unmarshal(out.Bytes(), &report); err != nil {
t.Fatal(err)
}
if !errors.Is(got.Cause, cause) || len(report.Scopes) != 2 {
t.Fatalf("outcome=%#v report=%#v", got, report)
}
for index, want := range [][]string{tc.first, tc.second} {
details := report.Scopes[index].Details
if len(details.Findings) != len(want) {
t.Fatalf("findings=%#v want=%v", details.Findings, want)
}
for i, message := range want {
if details.Findings[i].Message != message {
t.Fatalf("finding order=%#v want=%v", details.Findings, want)
}
}
if len(want) == 0 && details.State != "filtered" {
t.Fatalf("state=%q", details.State)
}
}
})
}
before := len(calls) before := len(calls)
for _, args := range [][]string{{"--severity=warning"}, {"--code=x"}, {"--details", "--severity=unknown"}} { for _, args := range [][]string{{"--severity=warning"}, {"--code=x"}, {"--details", "--severity=unknown"}} {
result = app.Execute(context.Background(), append([]string{"config", "validate"}, args...), commandkit.Streams{Out: io.Discard, Err: io.Discard}) result = app.Execute(context.Background(), append([]string{"config", "validate"}, args...), commandkit.Streams{Out: io.Discard, Err: io.Discard})

View File

@@ -8,39 +8,17 @@ import (
configkit "go.maximumdirect.net/configkit" configkit "go.maximumdirect.net/configkit"
) )
func decodeJSONPointer(pointer string) ([]string, error) { func decodeJSONPointer(path configkit.Path) []string {
pointer := path.String()
if pointer == "" { if pointer == "" {
return nil, nil return nil
} }
if pointer[0] != '/' {
return nil, fmt.Errorf("decode JSON pointer %q: path must start with /", pointer)
}
parts := strings.Split(pointer[1:], "/") parts := strings.Split(pointer[1:], "/")
decoder := strings.NewReplacer("~1", "/", "~0", "~")
for index, part := range parts { for index, part := range parts {
var decoded strings.Builder parts[index] = decoder.Replace(part)
decoded.Grow(len(part))
for offset := 0; offset < len(part); offset++ {
if part[offset] != '~' {
decoded.WriteByte(part[offset])
continue
}
if offset+1 >= len(part) {
return nil, fmt.Errorf("decode JSON pointer %q: incomplete escape in token %d", pointer, index)
}
offset++
switch part[offset] {
case '0':
decoded.WriteByte('~')
case '1':
decoded.WriteByte('/')
default:
return nil, fmt.Errorf("decode JSON pointer %q: invalid escape in token %d", pointer, index)
}
}
parts[index] = decoded.String()
} }
return parts, nil return parts
} }
func placeReportValue(root any, path []string, value any) (any, error) { func placeReportValue(root any, path []string, value any) (any, error) {
@@ -135,10 +113,7 @@ func renderDiffReport(differences []configkit.Difference) (any, error) {
if !difference.Right.Present { if !difference.Right.Present {
continue continue
} }
tokens, err := decodeJSONPointer(path) tokens := decodeJSONPointer(difference.Path)
if err != nil {
return nil, fmt.Errorf("difference at %q: %w", path, err)
}
value, err := commandkit.DecodeYAMLReport(strings.NewReader(difference.Right.YAML)) value, err := commandkit.DecodeYAMLReport(strings.NewReader(difference.Right.YAML))
if err != nil { if err != nil {
return nil, fmt.Errorf("difference at %q: %w", path, err) return nil, fmt.Errorf("difference at %q: %w", path, err)

View File

@@ -3,6 +3,8 @@ package ckconfig
import ( import (
"reflect" "reflect"
"testing" "testing"
configkit "go.maximumdirect.net/configkit"
) )
func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) { func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) {
@@ -16,21 +18,18 @@ func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) {
{pointer: "/0/../a.b/50%", want: []string{"0", "..", "a.b", "50%"}}, {pointer: "/0/../a.b/50%", want: []string{"0", "..", "a.b", "50%"}},
{pointer: "/a~1b/c~0d", want: []string{"a/b", "c~d"}}, {pointer: "/a~1b/c~0d", want: []string{"a/b", "c~d"}},
{pointer: "/~01", want: []string{"~1"}}, {pointer: "/~01", want: []string{"~1"}},
{pointer: "/café", want: []string{"café"}},
} }
for _, test := range tests { for _, test := range tests {
got, err := decodeJSONPointer(test.pointer) path, err := configkit.ParsePath(test.pointer)
if err != nil { if err != nil {
t.Fatalf("decode %q: %v", test.pointer, err) t.Fatal(err)
} }
got := decodeJSONPointer(path)
if !reflect.DeepEqual(got, test.want) { if !reflect.DeepEqual(got, test.want) {
t.Errorf("decode %q = %#v, want %#v", test.pointer, got, test.want) t.Errorf("decode %q = %#v, want %#v", test.pointer, got, test.want)
} }
} }
for _, pointer := range []string{"missing-leading-slash", "/bad~", "/bad~2escape"} {
if _, err := decodeJSONPointer(pointer); err == nil {
t.Errorf("decode %q unexpectedly succeeded", pointer)
}
}
} }
func TestPlaceReportValue(t *testing.T) { func TestPlaceReportValue(t *testing.T) {

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"io" "io"
"reflect"
"strings" "strings"
"testing" "testing"
@@ -16,7 +17,7 @@ func TestExplainDiscoveryModesAndMetadata(t *testing.T) {
loads, inputs, states := 0, 0, 0 loads, inputs, states := 0, 0, 0
scope := FromConfigKit("app", "", func(_ context.Context, selection Selection, _, _ struct{}) (*configkit.Result[map[string]any], error) { scope := FromConfigKit("app", "", func(_ context.Context, selection Selection, _, _ struct{}) (*configkit.Result[map[string]any], error) {
loads++ loads++
return configkit.Load(configkit.Mapping("fixture", map[string]any{"a/b": map[string]any{"x": 1}, "ab": 2, "abacus": 3, "gone": map[string]any{"x": 1}}), configkit.Options[map[string]any]{Overrides: selection.Overrides}) return configkit.Load(configkit.Mapping("fixture", map[string]any{"a/b": map[string]any{"x": 1}, "a~b": 4, "ab": 2, "abacus": 3, "gone": map[string]any{"x": 1}}), configkit.Options[map[string]any]{Overrides: selection.Overrides})
}) })
scope.Features.Overrides = true scope.Features.Overrides = true
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{NewInput: func() struct{} { inputs++; return struct{}{} }, NewState: func() struct{} { states++; return struct{}{} }, Scopes: []Scope[struct{}, struct{}]{scope}})) app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{NewInput: func() struct{} { inputs++; return struct{}{} }, NewState: func() struct{} { states++; return struct{}{} }, Scopes: []Scope[struct{}, struct{}]{scope}}))
@@ -37,7 +38,11 @@ func TestExplainDiscoveryModesAndMetadata(t *testing.T) {
} }
result, out = run("--list", "--under=/a~1b", "--format=json") result, out = run("--list", "--under=/a~1b", "--format=json")
var report pathDiscoveryReport var report pathDiscoveryReport
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil || len(report.Paths) != 2 { decode := func(output string) error {
report = pathDiscoveryReport{}
return json.Unmarshal([]byte(output), &report)
}
if result.Kind != commandkit.OutcomeSuccess || decode(out) != nil || len(report.Paths) != 2 {
t.Fatalf("escaped subtree: %#v %q", result, out) t.Fatalf("escaped subtree: %#v %q", result, out)
} }
seen := map[string]bool{} seen := map[string]bool{}
@@ -48,11 +53,46 @@ func TestExplainDiscoveryModesAndMetadata(t *testing.T) {
t.Fatalf("escaped paths: %#v", report.Paths) t.Fatalf("escaped paths: %#v", report.Paths)
} }
result, out = run("--list", "--under=/ab", "--format=json") result, out = run("--list", "--under=/ab", "--format=json")
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil || len(report.Paths) != 1 || report.Paths[0].Path != "/ab" { if result.Kind != commandkit.OutcomeSuccess || decode(out) != nil || len(report.Paths) != 1 || report.Paths[0].Path != "/ab" {
t.Fatalf("token boundary: %#v %q", result, out) t.Fatalf("token boundary: %#v %q", result, out)
} }
for _, tc := range []struct {
prefix string
paths []string
}{
{"/a~1b/x", []string{"/a~1b/x"}},
{"/a~0b", []string{"/a~0b"}},
{"/a", nil},
{"/a~1b/x/unknown", nil},
{"/ab/" + strings.Repeat("too-deep/", 20) + "unknown", nil},
} {
result, out = run("--list", "--under="+tc.prefix, "--format=json")
if result.Status != 0 || decode(out) != nil {
t.Fatalf("prefix %q: %#v %s", tc.prefix, result, out)
}
var paths []string
for _, entry := range report.Paths {
paths = append(paths, entry.Path)
}
if !reflect.DeepEqual(paths, tc.paths) {
t.Fatalf("prefix %q: paths=%v want=%v", tc.prefix, paths, tc.paths)
}
}
result, baseline := run("--list", "--format=json")
if result.Status != 0 {
t.Fatalf("unfiltered discovery=%#v", result)
}
var all pathDiscoveryReport
if err := json.Unmarshal([]byte(baseline), &all); err != nil {
t.Fatal(err)
}
result, out = run("--list", "--under=", "--format=json")
if result.Status != 0 || decode(out) != nil || !reflect.DeepEqual(report, all) {
t.Fatalf("root subtree changed discovery: %#v %s", result, out)
}
result, out = run("--list", "--historical", "--set=/gone=null", "--format=json") result, out = run("--list", "--historical", "--set=/gone=null", "--format=json")
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil { if result.Kind != commandkit.OutcomeSuccess || decode(out) != nil {
t.Fatalf("historical: %#v %q", result, out) t.Fatalf("historical: %#v %q", result, out)
} }
removed := false removed := false
@@ -64,8 +104,8 @@ func TestExplainDiscoveryModesAndMetadata(t *testing.T) {
if !removed { if !removed {
t.Fatalf("missing historical entry: %q", out) t.Fatalf("missing historical entry: %q", out)
} }
result, out = run("--list", "--historical", "--explicit=false", "--set=/gone=null", "--format=json") result, out = run("--list", "--under=/gone", "--historical", "--explicit=false", "--set=/gone=null", "--format=json")
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil { if result.Kind != commandkit.OutcomeSuccess || decode(out) != nil {
t.Fatalf("explicit filter: %#v %q", result, out) t.Fatalf("explicit filter: %#v %q", result, out)
} }
for _, entry := range report.Paths { for _, entry := range report.Paths {

View File

@@ -1,10 +1,11 @@
// Package ckconfig, provided by go.maximumdirect.net/ckconfig, provides // Package ckconfig adds commands to view, validate, explain, and compare
// CommandKit's optional ConfigKit v1.1.0 integration, requiring Go 1.27.0. Its // ConfigKit configuration in CommandKit applications.
// canonical Command declaration installs the standard config command family //
// through the core commandkit package. Applications can install CommandKit // Use AppCommand for one application configuration loader, or Command with a
// RoutingRule declarations alongside it through commandkit.New or // Suite for multiple named configurations and application-specific flags.
// App.Register. Source reports preserve ConfigKit's destination, placement, // Install the returned command with commandkit.New or App.Register.
// version, and empty-import evidence. Invocation reporting applies CommandKit's shared // Importing this package alone installs no commands.
// visibility and delivery policy; provider warnings remain independent of //
// failure explanations. Importing ckconfig alone installs no commands. // ConfigKit owns configuration loading and interpretation. CommandKit formats
// reports and messages using its shared reporting policy.
package ckconfig package ckconfig

View File

@@ -116,8 +116,9 @@ root): `docs/policy/meta.md`, `docs/policy/agents.md`,
`docs/policy/documentation.md`, `docs/policy/release.md`, and `docs/policy/documentation.md`, `docs/policy/release.md`, and
`docs/policy/oss.md`; the root `AGENTS.md` orientation after the knowledge-graph `docs/policy/oss.md`; the root `AGENTS.md` orientation after the knowledge-graph
instructions; and the Policy Reading Guide in `docs/development.md`, adapted for instructions; and the Policy Reading Guide in `docs/development.md`, adapted for
the core module. The release policy uses baseline the core module. The documentation policy uses baseline
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses baseline `beb5f191470a9daef2383981a6bfd02232490ab4`; the release policy uses
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses
`d84a38ebab26f048cafbe59a2246700c35d29843`. `d84a38ebab26f048cafbe59a2246700c35d29843`.
Copyright (C) 2026 Eric Rakestraw Copyright (C) 2026 Eric Rakestraw
@@ -128,12 +129,16 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) | [COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) | [LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) | [COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE). [LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843). [Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348). [Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4).
## go-yaml v3 ## go-yaml v3
Selected module: `gopkg.in/yaml.v3` at `v3.0.1` (transitive dependency). Selected module: `gopkg.in/yaml.v3` at `v3.0.1` (transitive dependency).

View File

@@ -0,0 +1,24 @@
# CommandKit ckconfig v0.4.0
CommandKit ckconfig v0.4.0 requires `go.maximumdirect.net/commandkit v0.4.0`,
ConfigKit v1.1.0, and Go 1.27.0. Configuration commands use core's shared
text, YAML, and JSON reporting and universal `--format` flag.
Scopes can expose metadata-backed path discovery and focused values, filtered
source evidence, and detailed validation and comparison reports. Validation
retains available evidence after a failed load and reports reached scopes in
one result. Aggregate configuration display is one scope-keyed mapping.
Applications opt into transient CLI overrides only when their loader forwards
them to ConfigKit; `--set` and `--against-set` select the comparison sides.
Application-declared right-side selectors let each side use independent
inputs. `config diff --check` returns status 3 when native differences exist.
This pre-1.0 minor release changes configuration report formatting and
aggregate framing, and comparison selectors now apply independently to each
side. Update callers and report consumers to the current
[CLI reference](../../../../docs/cli.md#configuration-scopes) and
[consumer guide](../../../../docs/consumers/guide.md#configkit-scopes).
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.4.0/integrations/ckconfig/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.4.0/integrations/ckconfig/LICENSE) |
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.4.0/integrations/ckconfig/docs/oss/attribution.md)

View File

@@ -0,0 +1,39 @@
# CommandKit ckconfig v0.5.0
CommandKit ckconfig v0.5.0 improves configuration explanations, preserves
available inspection evidence after loader failures, and uses CommandKit core
v0.5.0 for reporting and input handling. ConfigKit remains at v1.1.0, and the
consumer minimum remains Go 1.27.0.
## Changes
- Configuration explanations render the same acquired evidence in text, YAML,
and JSON, including available field metadata, history, and source labels.
- Failed loads retain available ConfigKit result evidence for inspection.
Cancellation during aggregate configuration display remains an operation
failure rather than being reported as a rendering failure.
- The new `AdaptDiagnostics` helper captures native ConfigKit diagnostics for
CLI presentation. Both standard integrations use the same adapter.
- Validation and path discovery avoid rebuilding filter sets and subtree
prefixes repeatedly. Scope setup and operation preparation are simpler,
preserving independent comparison inputs and existing CLI behavior.
## Compatibility and upgrade
Select `go.maximumdirect.net/ckconfig v0.5.0` with CommandKit core v0.5.0.
Custom `ExplanationReader` implementations need only `Explain`; the interface
no longer requires `WriteExplanation`. CommandKit renders the acquired evidence
instead of invoking a provider's text writer. Applications that customized that
writer should review their text output.
Effective configuration writers supply one safe YAML document. Conversion
preserves resolved scalar types rather than formatting or comments. Core
v0.5.0 preserves numeric-looking strings and explicit numeric tags; review
callers that relied on automatic conversion of strings into numbers.
See the [configuration CLI reference](../../../../docs/cli.md#configuration-scopes)
and [consumer guide](../../../../docs/consumers/guide.md#configkit-scopes).
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.5.0/integrations/ckconfig/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.5.0/integrations/ckconfig/LICENSE) |
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.5.0/integrations/ckconfig/docs/oss/attribution.md)

View File

@@ -1,37 +1,90 @@
# Simple consumer # ConfigKit example
This example imports `go.maximumdirect.net/ckconfig` and This example adds configuration inspection and a custom `greet` command to a
`go.maximumdirect.net/commandkit`; use ckconfig v0.3.0 with core small application. It uses local fixtures and in-memory defaults, with no
v0.3.0, ConfigKit v1.1.0, and Go 1.27.0. Set `GOWORK=off GOTOOLCHAIN=local` external services. See [module selection](../../../../docs/consumers/guide.md#module-selection)
for the commands below. for dependencies and Go requirements. Use Go 1.27.0 on `PATH` for this checkout.
Run the maintained example with: From the repository root:
```sh ```sh
cd integrations/ckconfig/examples/simple
export GOWORK=off GOTOOLCHAIN=local
go run . config show go run . config show
go run . config show --config fixtures/before.yml --verbose ```
go run . config show --config fixtures/before.yml --quiet
The output includes the default greeting, `Hello`, and schema version 2.
This example can run against its declared released dependencies with the
workspace disabled.
## Inspect and override a value
```sh
# Discover configuration paths under schema.
go run . config explain --list --under /schema go run . config explain --list --under /schema
# Change the greeting for this invocation and show just that value.
go run . config show --path /greeting --set '/greeting="Hi"' go run . config show --path /greeting --set '/greeting="Hi"'
```
The second command prints `Hi`. It does not edit a file.
## Compare configurations
```sh
go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml
```
The report includes `changes.greeting: Goodbye`.
```sh
# Expected status 3: the overridden greeting differs from the ordinary default.
go run . config diff --set '/greeting="Hi"' --details --check go run . config diff --set '/greeting="Hi"' --details --check
```
This compares the overridden left side with ordinary configuration on the
right. `go run` prints `exit status 3` and itself exits with status 1; a built
executable returns the application's status 3 directly.
## Inspect warnings and failed loads
```sh
# Schema version 1 produces a warning, visible in verbose mode.
go run . config show --config fixtures/before.yml --verbose
# Quiet mode hides the configuration report and warning.
go run . config show --config fixtures/before.yml --quiet
```
Ordinary mode hides the warning but still displays configuration.
```sh
# Intentional failure: show detailed validation findings for the invalid fixture.
go run . config validate --config fixtures/invalid.yml --details --severity error go run . config validate --config fixtures/invalid.yml --details --severity error
# Intentional failure: still show the source recorded before loading failed.
go run . config sources --config fixtures/invalid.yml --source-id source-1 go run . config sources --config fixtures/invalid.yml --source-id source-1
```
Both commands retain the load failure. `source-1` belongs to this fixture's
load; other configurations can have different source IDs. See the
[CLI reference](../../../../docs/cli.md#configuration-scopes) for report and status rules.
## Try a custom command
```sh
go run . greet go run . greet
go run . greet --count=0 --tag=first --tag second go run . greet --count=0 --tag=first --tag second
``` ```
The `config` family comes from one typed ConfigKit scope. The custom `greet` The handler reports its greeting, count, and tags. It distinguishes an omitted
command uses CommandKit's typed bindings and shows the difference between an count from an explicitly supplied zero and preserves repeated tag order.
omitted `--count` and an explicitly supplied zero, plus ordered repeated tags.
With no `--config`, application settings use the in-memory default. A supplied ## How it is wired
configuration and overlay are passed to ConfigKit unchanged. The comparison
command loads the two local fixtures and reports `changes.greeting: Goodbye`. One ConfigKit scope supplies the standard `config` commands. Without `--config`,
The before fixture uses schema version 1, which ConfigKit reports as a provider its loader uses an in-memory default. Supplied configuration and overlay values
warning in verbose mode. Ordinary mode hides the warning while preserving the are passed to ConfigKit unchanged. The scope enables temporary overrides and
effective YAML report; quiet mode suppresses that report. passes them into its ordinary ConfigKit load. The
The invalid fixture demonstrates a failed-load report and captured source [consumer guide](../../../../docs/consumers/guide.md#configkit-scopes) explains
inventory; `source-1` is the ID from that fixture's acquisition. The scope opts loader and scope setup.
in to transient overrides and passes them into its normal ConfigKit load. The
diff example compares an overridden left side with ordinary configuration on Run `GOWORK=off GOTOOLCHAIN=local go test ./...` from this example directory to
the right and returns status 3 because the values differ. check it against its declared dependencies.

View File

@@ -1,7 +1,6 @@
package ckconfig package ckconfig
import ( import (
"bytes"
"fmt" "fmt"
"strings" "strings"
"text/template" "text/template"
@@ -18,6 +17,7 @@ type explanationReport struct {
History []explanationChangeDTO `json:"history"` History []explanationChangeDTO `json:"history"`
SecretReference *secretReferenceDTO `json:"secret_reference"` SecretReference *secretReferenceDTO `json:"secret_reference"`
Metadata *explanationMetadata `json:"metadata,omitempty"` Metadata *explanationMetadata `json:"metadata,omitempty"`
Sources []validationSource `json:"sources,omitempty"`
} }
type explanationMetadata struct { type explanationMetadata struct {
@@ -48,6 +48,29 @@ type pathMetadata struct {
var pathDiscoveryTemplate = template.Must(commandkit.ParseTextTemplate("paths", "{{range .Paths}}{{printf \"%q\" .Path}} {{if .Present}}{{if .Metadata.Explicit}}explicit{{else}}non-explicit{{end}}{{else}}historical{{end}}\n{{end}}", nil)) var pathDiscoveryTemplate = template.Must(commandkit.ParseTextTemplate("paths", "{{range .Paths}}{{printf \"%q\" .Path}} {{if .Present}}{{if .Metadata.Explicit}}explicit{{else}}non-explicit{{end}}{{else}}historical{{end}}\n{{end}}", nil))
var explanationTemplate = template.Must(commandkit.ParseTextTemplate("explanation", `Scope: {{.Scope}}
Path: {{printf "%q" .Path}}
Effective: {{template "snapshot" .Effective}}
Origin: {{template "origin" .Origin}}
History:
{{range .History}} Before: {{template "snapshot" .Before}}
After: {{template "snapshot" .After}}
Origin: {{template "origin" .Origin}}
Reasons: {{range .Reasons}}{{printf "%q " .}}{{end}}
Dependencies: {{range .Dependencies}}{{printf "%q " .}}{{end}}
{{end}}{{with .SecretReference}}Secret reference: {{.Name}}
{{end}}{{with .Metadata}}{{if .Explicit}}Explicit: {{.Explicit}}
Null: {{.Null}}
{{end}}{{range .History}}Metadata history: {{printf "%q" .Path}}
{{range .Previous}} Previous origin: {{template "origin" .}}
{{end}}{{end}}{{end}}{{with .Sources}}Sources:
{{range .}} {{printf "%q" .ID}}: label={{printf "%q" .Label}} path={{printf "%q" .Path}}
{{end}}{{end}}{{define "snapshot"}}present={{.Present}} redacted={{.Redacted}} yaml={{printf "%q" .YAML}}{{end}}
{{- define "locations"}}{{range .}} {{printf "%q" .SourceID}}:{{.Line}}:{{.Column}}{{end}}{{end}}
{{- define "origin"}}layer={{printf "%q" .Layer}} operation={{printf "%q" .Operation}} locations:{{template "locations" .Locations}}
{{range .Inheritance}} Inheritance: from={{printf "%q" .From}} to={{printf "%q" .To}} reference={{printf "%q" .Reference}} locations:{{template "locations" .Locations}}
{{end}}{{end}}`, nil))
type snapshotDTO struct { type snapshotDTO struct {
Present bool `json:"present"` Present bool `json:"present"`
Redacted bool `json:"redacted"` Redacted bool `json:"redacted"`
@@ -107,8 +130,13 @@ func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *conf
return configFailure("Configuration scope did not provide path metadata.", acquired.Explanation, fmt.Errorf("configuration scope %q declared path discovery without metadata", name)) return configFailure("Configuration scope did not provide path metadata.", acquired.Explanation, fmt.Errorf("configuration scope %q declared path discovery without metadata", name))
} }
report := pathDiscoveryReport{Scope: name, Paths: []pathDiscoveryEntry{}} report := pathDiscoveryReport{Scope: name, Paths: []pathDiscoveryEntry{}}
var prefix []string
if plan.under != nil {
prefix = pointerTokens(plan.under.String())
}
for _, path := range acquired.Value.Metadata.HistoricalPaths() { for _, path := range acquired.Value.Metadata.HistoricalPaths() {
if plan.under != nil && !pathWithin(path, *plan.under) { pointer := path.String()
if plan.under != nil && !pathWithin(pointer, prefix) {
continue continue
} }
info, present := acquired.Value.Metadata.Lookup(path) info, present := acquired.Value.Metadata.Lookup(path)
@@ -118,7 +146,7 @@ func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *conf
if present && plan.explicit.Set && info.Explicit != plan.explicit.Value { if present && plan.explicit.Set && info.Explicit != plan.explicit.Value {
continue continue
} }
entry := pathDiscoveryEntry{Path: path.String(), Present: present} entry := pathDiscoveryEntry{Path: pointer, Present: present}
if present { if present {
entry.Metadata = &pathMetadata{Explicit: info.Explicit} entry.Metadata = &pathMetadata{Explicit: info.Explicit}
} }
@@ -157,31 +185,27 @@ func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *conf
report.Metadata = metadata report.Metadata = metadata
} }
} }
text := template.Must(commandkit.ParseTextTemplate("explanation", "{{render .}}", template.FuncMap{ if acquired.Value.Sources != nil {
"render": func(report explanationReport) (string, error) { for _, source := range acquired.Value.Sources.Sources() {
var output bytes.Buffer report.Sources = append(report.Sources, validationSource{ID: source.ID, Label: source.Label, Path: source.Path})
err := acquired.Value.Explanation.WriteExplanation(&output, plan.path) }
if err == nil && report.Metadata != nil && report.Metadata.Explicit != nil { }
_, err = fmt.Fprintf(&output, "Explicit: %t\nNull: %t\n", *report.Metadata.Explicit, *report.Metadata.Null) renderErr := reporter.Report(commandkit.Report{Value: report, TextTemplate: explanationTemplate})
}
return output.String(), err
},
}))
renderErr := reporter.Report(commandkit.Report{Value: report, TextTemplate: text})
if renderErr != nil && invocation.Context.Err() != nil { if renderErr != nil && invocation.Context.Err() != nil {
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err()) return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err())
} }
return commandkit.Success() return commandkit.Success()
} }
func pathWithin(path, parent configkit.Path) bool { func pointerTokens(value string) []string {
tokens := func(value string) []string { if value == "" {
if value == "" { return nil
return nil
}
return strings.Split(strings.TrimPrefix(value, "/"), "/")
} }
child, prefix := tokens(path.String()), tokens(parent.String()) return strings.Split(strings.TrimPrefix(value, "/"), "/")
}
func pathWithin(path string, prefix []string) bool {
child := pointerTokens(path)
if len(prefix) > len(child) { if len(prefix) > len(child) {
return false return false
} }

View File

@@ -91,18 +91,16 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
byName: make(map[string]*preparedScope[I, S], len(suite.Scopes)), byName: make(map[string]*preparedScope[I, S], len(suite.Scopes)),
bindingsByOperation: make(map[Operation][]commandkit.Flag[I]), bindingsByOperation: make(map[Operation][]commandkit.Flag[I]),
} }
selectors := make([]Selector[I], len(suite.Selectors)) seenNames := captureConfigScopes(prepared, suite.Scopes)
against := make([]commandkit.Flag[I], len(suite.Selectors)) selectors, against := captureConfigSelectors(prepared, suite.Selectors, seenNames)
for index, selector := range suite.Selectors { captureOperationBindings(prepared, selectors, against)
selector.Scopes = append([]string(nil), selector.Scopes...) captureScopeInventory(prepared)
selectors[index] = selector return prepared
if selector.AgainstName != "" { }
against[index] = commandkit.RenameFlag(selector.Binding, selector.AgainstName)
}
}
seenNames := make(map[string]struct{}, len(suite.Scopes)) func captureConfigScopes[I, S any](prepared *preparedSuite[I, S], scopes []Scope[I, S]) map[string]struct{} {
for _, original := range suite.Scopes { seenNames := make(map[string]struct{}, len(scopes))
for _, original := range scopes {
scope := original scope := original
scope.Operations = normalizeOperations(scope.Operations, &prepared.issues, scope.Name) scope.Operations = normalizeOperations(scope.Operations, &prepared.issues, scope.Name)
scope.issues = append([]error(nil), original.issues...) scope.issues = append([]error(nil), original.issues...)
@@ -146,6 +144,20 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
} }
prepared.scopeChoices = joinNames(names) prepared.scopeChoices = joinNames(names)
return seenNames
}
func captureConfigSelectors[I, S any](prepared *preparedSuite[I, S], originals []Selector[I], seenNames map[string]struct{}) ([]Selector[I], []commandkit.Flag[I]) {
selectors := make([]Selector[I], len(originals))
against := make([]commandkit.Flag[I], len(originals))
for index, selector := range originals {
selector.Scopes = append([]string(nil), selector.Scopes...)
selectors[index] = selector
if selector.AgainstName != "" {
against[index] = commandkit.RenameFlag(selector.Binding, selector.AgainstName)
}
}
for index, selector := range selectors { for index, selector := range selectors {
seen := make(map[string]struct{}, len(selector.Scopes)) seen := make(map[string]struct{}, len(selector.Scopes))
uniqueScopes := make([]string, 0, len(selector.Scopes)) uniqueScopes := make([]string, 0, len(selector.Scopes))
@@ -181,6 +193,10 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
} }
} }
return selectors, against
}
func captureOperationBindings[I, S any](prepared *preparedSuite[I, S], selectors []Selector[I], against []commandkit.Flag[I]) {
for _, operation := range []Operation{Validate, Show, Sources, Explain, Diff} { for _, operation := range []Operation{Validate, Show, Sources, Explain, Diff} {
for _, scope := range prepared.scopes { for _, scope := range prepared.scopes {
if containsOperation(scope.scope.Operations, operation) { if containsOperation(scope.scope.Operations, operation) {
@@ -200,6 +216,9 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
} }
} }
} }
}
func captureScopeInventory[I, S any](prepared *preparedSuite[I, S]) {
for _, scope := range prepared.scopes { for _, scope := range prepared.scopes {
prepared.inventory.Scopes = append(prepared.inventory.Scopes, scopeMetadata{ prepared.inventory.Scopes = append(prepared.inventory.Scopes, scopeMetadata{
Name: scope.scope.Name, Name: scope.scope.Name,
@@ -207,7 +226,6 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
Features: featureNames(scope.scope.Features), Features: featureNames(scope.scope.Features),
}) })
} }
return prepared
} }
func normalizeOperations(operations []Operation, issues *[]error, scope string) []Operation { func normalizeOperations(operations []Operation, issues *[]error, scope string) []Operation {

View File

@@ -3,10 +3,12 @@ package ckconfig
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"reflect" "reflect"
"slices"
"strings" "strings"
"testing" "testing"
@@ -478,18 +480,55 @@ func TestExplainParsesBeforeAcquisitionAndPreservesNativeSafeHistory(t *testing.
} }
} }
func TestExplainTracksNativeRenderFailureWithoutReplacingOperationCause(t *testing.T) { func TestExplainUsesAcquiredEvidenceAcrossFormats(t *testing.T) {
renderErr := errors.New("native explanation renderer failed") result, err := configkit.Load(configkit.Mapping("original source", map[string]any{"token": "before"}), configkit.Options[map[string]any]{Overrides: []configkit.Override{{Path: configkit.Keys("token"), Value: "after", Flag: "--fixture-set"}}})
reader := &failingNativeExplanation{err: renderErr} if err != nil {
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{ t.Fatal(err)
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 path := configkit.Keys("token")
}, explanation, found := result.Explain(path)
}}})) if !found {
var output bytes.Buffer t.Fatal("missing fixture explanation")
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.Len() != 0 { metadata := result.Metadata()
t.Fatalf("native explanation failure outcome/output = %#v / %q", outcome, output.String()) reader := &nativeExplanation{value: explanation, found: true}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "tenant", Operations: []Operation{Explain, Show}, Features: ScopeFeatures{PathShow: true}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Explanation: reader, Metadata: &metadata, Sources: result}}, nil
}}}}))
var focused bytes.Buffer
show := app.Execute(context.Background(), []string{"config", "show", "--scope=tenant", "--path=/token", "--format=json"}, commandkit.Streams{Out: &focused, Err: io.Discard})
if show.Kind != commandkit.OutcomeSuccess || strings.TrimSpace(focused.String()) != `"after"` {
t.Fatalf("focused show = %#v %q", show, focused.String())
}
for _, format := range []string{"text", "yaml", "json"} {
t.Run(format, func(t *testing.T) {
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "explain", "--scope=tenant", "--path=/token", "--format=" + format}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Status != 0 {
t.Fatalf("outcome=%#v output=%s", outcome, output.String())
}
for _, evidence := range []string{"tenant", "/token", "before", "after", "original source", "--fixture-set"} {
if !strings.Contains(output.String(), evidence) {
t.Errorf("missing %q: %s", evidence, output.String())
}
}
if format == "json" {
var report explanationReport
if err := json.Unmarshal(output.Bytes(), &report); err != nil {
t.Fatal(err)
}
if report.Metadata == nil || report.Metadata.Explicit == nil || !*report.Metadata.Explicit || !slices.ContainsFunc(report.Metadata.History, func(record metadataHistoryRecord) bool { return len(record.Previous) > 0 }) {
t.Fatalf("missing metadata: %#v", report.Metadata)
}
} else if format == "text" && (!strings.Contains(output.String(), "Previous origin:") || !strings.Contains(output.String(), "Explicit: true")) {
t.Fatalf("missing text metadata: %s", output.String())
}
writeErr := errors.New("output failed")
outcome = app.Execute(context.Background(), []string{"config", "explain", "--scope=tenant", "--path=/token", "--format=" + format}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
if outcome.Status != 1 || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) {
t.Fatalf("delivery failure = %#v", outcome)
}
})
} }
} }
@@ -552,30 +591,11 @@ type nativeExplanation struct {
found bool 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) { func (reader *nativeExplanation) Explain(path configkit.Path) (configkit.Explanation, bool) {
reader.path = path reader.path = path
return reader.value, reader.found return reader.value, reader.found
} }
func (reader *nativeExplanation) WriteExplanation(writer io.Writer, path configkit.Path) error {
reader.path = path
_, err := fmt.Fprintf(writer, "Explanation for %q\n", path.String())
return err
}
type failingOutput struct{ err error } type failingOutput struct{ err error }
func (writer failingOutput) Write(value []byte) (int, error) { func (writer failingOutput) Write(value []byte) (int, error) {

View File

@@ -3,7 +3,7 @@ module go.maximumdirect.net/ckconfig
go 1.27.0 go 1.27.0
require ( require (
go.maximumdirect.net/commandkit v0.3.0 go.maximumdirect.net/commandkit v0.5.0
go.maximumdirect.net/configkit v1.1.0 go.maximumdirect.net/configkit v1.1.0
go.yaml.in/yaml/v4 v4.0.0-rc.6 go.yaml.in/yaml/v4 v4.0.0-rc.6
) )

View File

@@ -6,8 +6,10 @@ github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 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/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.maximumdirect.net/commandkit v0.3.0 h1:RCmg2GS8uzlLSJCIqarpn1uFDec0W3ylNNRRPT6kUFE= go.maximumdirect.net/commandkit v0.4.0 h1:bWqflDWDn98FUCgh+0e+w6LGmvWIkaeJK519H7PjD2Y=
go.maximumdirect.net/commandkit v0.3.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc= go.maximumdirect.net/commandkit v0.4.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
go.maximumdirect.net/commandkit v0.5.0 h1:Ln+bCp+FwCg4tZD3NaMKQXUDB3mPusgwsnRZmb0+3cM=
go.maximumdirect.net/commandkit v0.5.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA= go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA=
go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA= go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=

View File

@@ -73,38 +73,27 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
scopeChoices = append(scopeChoices, commandkit.CompletionChoice{Value: "all"}) scopeChoices = append(scopeChoices, commandkit.CompletionChoice{Value: "all"})
} }
flags := []commandkit.Flag[*configCommandInput[I, S]]{ flags := []commandkit.Flag[*configCommandInput[I, S]]{
commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported.", Completion: commandkit.CompleteChoices(scopeChoices...)}, &defaultScope, parseConfigString, commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Select a named configuration, or all where supported.", Completion: commandkit.CompleteChoices(scopeChoices...)}, &defaultScope, parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).scope = value }), func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).scope = value }),
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString, commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Select the configuration source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
(*input).selection.Config = value (*input).selection.Config = value
}), }),
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Overlay source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString, commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Select the configuration overlay.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
(*input).selection.Overlay = value (*input).selection.Overlay = value
}), }),
} }
if operation == Diff { if operation == Diff {
flags = append(flags, flags = append(flags, comparisonOperationFlags[I, S]()...)
commandkit.BoolFlag(commandkit.FlagMeta{Name: "details", Usage: "Show native before and after difference evidence."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).details = value }),
commandkit.BoolFlag(commandkit.FlagMeta{Name: "check", Usage: "Return status 3 when differences are found."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).check = value }),
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.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
(*input).against.Overlay = value
}),
)
} }
if operation == Validate { if operation == Validate {
flags = append(flags, flags = append(flags,
commandkit.BoolFlag(commandkit.FlagMeta{Name: "details", Usage: "Show native validation findings."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).details = value }), commandkit.BoolFlag(commandkit.FlagMeta{Name: "details", Usage: "Show detailed validation findings."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).details = value }),
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "severity", Usage: "Filter detailed findings by error or warning."}, []configkit.Severity(nil), parseValidationSeverity, func(input **configCommandInput[I, S], value commandkit.Supplied[[]configkit.Severity]) { commandkit.RepeatFlag(commandkit.FlagMeta{Name: "severity", Usage: "Filter detailed findings by error or warning."}, []configkit.Severity(nil), parseValidationSeverity, func(input **configCommandInput[I, S], value commandkit.Supplied[[]configkit.Severity]) {
(*input).severities = value.Value (*input).severities = value.Value
}), }),
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "code", Usage: "Filter detailed findings by exact native code."}, []string(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[[]string]) { commandkit.RepeatFlag(commandkit.FlagMeta{Name: "code", Usage: "Filter detailed findings by exact diagnostic code."}, []string(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[[]string]) {
(*input).codes = value.Value (*input).codes = value.Value
}), }),
) )
@@ -125,16 +114,16 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
flags = append(flags, flags = append(flags,
commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value }), commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value }),
commandkit.BoolFlag(commandkit.FlagMeta{Name: "list", Usage: "List configuration paths."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).list = value }), commandkit.BoolFlag(commandkit.FlagMeta{Name: "list", Usage: "List configuration paths."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).list = value }),
commandkit.BoolFlag(commandkit.FlagMeta{Name: "historical", Usage: "Include historical-only paths when listing."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).historical = value }), commandkit.BoolFlag(commandkit.FlagMeta{Name: "historical", Usage: "Include paths that exist only in configuration history."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).historical = value }),
commandkit.ValueFlag(commandkit.FlagMeta{Name: "under", Usage: "List paths under a JSON Pointer."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).under = value }), commandkit.ValueFlag(commandkit.FlagMeta{Name: "under", Usage: "List paths under a JSON Pointer."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).under = value }),
commandkit.BoolFlag(commandkit.FlagMeta{Name: "explicit", Usage: "Filter listed current paths by explicitness."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).explicit = value }), commandkit.BoolFlag(commandkit.FlagMeta{Name: "explicit", Usage: "Filter current paths by whether ConfigKit marks them explicit."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).explicit = value }),
) )
} }
if operation == Show { if operation == Show {
flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer to one effective value."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value })) flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer to one effective value."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value }))
} }
if operation == Sources { if operation == Sources {
flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "source-id", Usage: "Select one source ID from this acquisition."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).sourceID = value })) flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "source-id", Usage: "Show one source ID from this configuration load."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).sourceID = value }))
} }
flags = append(flags, commandkit.CaptureFlags(prepared.bindingsByOperation[operation], func(input **configCommandInput[I, S]) *commandkit.FlagValues[I] { flags = append(flags, commandkit.CaptureFlags(prepared.bindingsByOperation[operation], func(input **configCommandInput[I, S]) *commandkit.FlagValues[I] {
if input == nil || *input == nil { if input == nil || *input == nil {
@@ -169,6 +158,21 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
}) })
} }
func comparisonOperationFlags[I, S any]() []commandkit.Flag[*configCommandInput[I, S]] {
return []commandkit.Flag[*configCommandInput[I, S]]{
commandkit.BoolFlag(commandkit.FlagMeta{Name: "details", Usage: "Show before and after values with presence and redaction details."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).details = value }),
commandkit.BoolFlag(commandkit.FlagMeta{Name: "check", Usage: "Return status 3 when differences are found."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).check = value }),
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.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
(*input).against.Overlay = value
}),
}
}
func operationDescription(operation Operation) string { func operationDescription(operation Operation) string {
if operation == Explain { if operation == Explain {
return "Use --list to discover paths or --path POINTER to explain one path." return "Use --list to discover paths or --path POINTER to explain one path."
@@ -187,41 +191,44 @@ func operationDeclaration(operation Operation) (string, string) {
case Explain: case Explain:
return "explain", "Explain a configuration path." return "explain", "Explain a configuration path."
case Diff: case Diff:
return "diff", "Compare two configuration selections." return "diff", "Compare two configurations."
default: default:
return string(operation), "Inspect configuration." return string(operation), "Inspect configuration."
} }
} }
func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation Operation, input *configCommandInput[I, S]) error { func checkConfigOperationOptions[I, S any](operation Operation, input *configCommandInput[I, S]) (bool, error) {
if operation == Validate && !input.details.Value && (len(input.severities) > 0 || len(input.codes) > 0) { if operation == Validate && !input.details.Value && (len(input.severities) > 0 || len(input.codes) > 0) {
return fmt.Errorf("--severity and --code require --details") return false, fmt.Errorf("--severity and --code require --details")
} }
if operation == Explain { if operation == Explain {
if input.list.Value && input.path.Set { if input.list.Value && input.path.Set {
return fmt.Errorf("--list and --path cannot be combined") return false, fmt.Errorf("--list and --path cannot be combined")
} }
if !input.list.Value && (input.historical.Set || input.under.Set || input.explicit.Set) { if !input.list.Value && (input.historical.Set || input.under.Set || input.explicit.Set) {
return fmt.Errorf("--historical, --under, and --explicit require --list") return false, fmt.Errorf("--historical, --under, and --explicit require --list")
} }
if !input.list.Value && !input.path.Set { if !input.list.Value && !input.path.Set {
input.plan = &configOperationPlan[I, S]{prepared: prepared, operation: operation, help: true} return true, nil
return nil
} }
} }
return false, nil
}
func selectConfigScopes[I, S any](prepared *preparedSuite[I, S], operation Operation, input *configCommandInput[I, S]) (string, []*preparedScope[I, S], error) {
scopeName := "app" scopeName := "app"
if input.scope.Set { if input.scope.Set {
scopeName = input.scope.Value scopeName = input.scope.Value
} }
if scopeName == "" { if scopeName == "" {
return fmt.Errorf("configuration scope must not be empty") return "", nil, fmt.Errorf("configuration scope must not be empty")
} }
if scopeName == "all" && operation != Validate && operation != Show { if scopeName == "all" && operation != Validate && operation != Show {
return fmt.Errorf("scope all is supported only for validate and show") return "", nil, fmt.Errorf("scope all is supported only for validate and show")
} }
if scopeName == "all" && operation == Show && input.path.Set { if scopeName == "all" && operation == Show && input.path.Set {
return fmt.Errorf("path-selected show requires one scope") return "", nil, fmt.Errorf("path-selected show requires one scope")
} }
var selected []*preparedScope[I, S] var selected []*preparedScope[I, S]
@@ -230,24 +237,62 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O
} else if scope, exists := prepared.byName[scopeName]; exists { } else if scope, exists := prepared.byName[scopeName]; exists {
selected = []*preparedScope[I, S]{scope} selected = []*preparedScope[I, S]{scope}
} else { } else {
return fmt.Errorf("unknown configuration scope %q (available: %s)", scopeName, prepared.scopeChoices) return "", nil, fmt.Errorf("unknown configuration scope %q (available: %s)", scopeName, prepared.scopeChoices)
} }
for _, scope := range selected { for _, scope := range selected {
if !containsOperation(scope.scope.Operations, operation) { if !containsOperation(scope.scope.Operations, operation) {
return fmt.Errorf("configuration scope %q does not support %s (available: %s)", scope.scope.Name, operation, joinOperations(scope.scope.Operations)) return "", nil, fmt.Errorf("configuration scope %q does not support %s (available: %s)", scope.scope.Name, operation, joinOperations(scope.scope.Operations))
} }
if (len(input.selection.Overrides) > 0 || len(input.against.Overrides) > 0) && !scope.scope.Features.Overrides { if (len(input.selection.Overrides) > 0 || len(input.against.Overrides) > 0) && !scope.scope.Features.Overrides {
return fmt.Errorf("configuration scope %q does not support overrides", scope.scope.Name) return "", nil, fmt.Errorf("configuration scope %q does not support overrides", scope.scope.Name)
} }
if operation == Explain && input.list.Value && !scope.scope.Features.PathDiscovery { if operation == Explain && input.list.Value && !scope.scope.Features.PathDiscovery {
return fmt.Errorf("configuration scope %q does not support path discovery", scope.scope.Name) return "", nil, fmt.Errorf("configuration scope %q does not support path discovery", scope.scope.Name)
} }
if operation == Show && input.path.Set && !scope.scope.Features.PathShow { if operation == Show && input.path.Set && !scope.scope.Features.PathShow {
return fmt.Errorf("configuration scope %q does not support path-selected show", scope.scope.Name) return "", nil, fmt.Errorf("configuration scope %q does not support path-selected show", scope.scope.Name)
} }
} }
return scopeName, selected, nil
}
func prepareComparisonInputs[I, S any](prepared *preparedSuite[I, S], input *configCommandInput[I, S], scope *preparedScope[I, S], plan *configOperationPlan[I, S]) error {
input.selection = cloneSelection(input.selection)
input.against = cloneSelection(input.against)
left, err := prepareScopeInput(prepared, input.values, scope, scope.bindings, input.selection, true)
if err != nil {
return fmt.Errorf("configuration scope %q left input: %w", scope.scope.Name, err)
}
right, err := prepareScopeInput(prepared, input.againstValues, scope, scope.againstBindings, input.against, true)
if err != nil {
return fmt.Errorf("configuration scope %q right input: %w", scope.scope.Name, err)
}
plan.comparison = &preparedComparison[I, S]{
scope: scope,
request: ComparisonRequest[I]{
Left: ComparisonSide[I]{Selection: input.selection, Input: left},
Right: ComparisonSide[I]{Selection: input.against, Input: right},
},
}
return nil
}
func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation Operation, input *configCommandInput[I, S]) error {
help, err := checkConfigOperationOptions(operation, input)
if err != nil {
return err
}
if help {
input.plan = &configOperationPlan[I, S]{prepared: prepared, operation: operation, help: true}
return nil
}
scopeName, selected, err := selectConfigScopes(prepared, operation, input)
if err != nil {
return err
}
var path configkit.Path var path configkit.Path
if (operation == Explain || operation == Show) && input.path.Set { if (operation == Explain || operation == Show) && input.path.Set {
parsed, err := configkit.ParsePath(input.path.Value) parsed, err := configkit.ParsePath(input.path.Value)
@@ -283,23 +328,8 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O
scopes: make([]selectedScopeInput[I, S], 0, len(selected)), scopes: make([]selectedScopeInput[I, S], 0, len(selected)),
} }
if operation == Diff { if operation == Diff {
scope := selected[0] if err := prepareComparisonInputs(prepared, input, selected[0], plan); err != nil {
input.selection = cloneSelection(input.selection) return err
input.against = cloneSelection(input.against)
left, err := prepareScopeInput(prepared, input.values, scope, scope.bindings, input.selection, true)
if err != nil {
return fmt.Errorf("configuration scope %q left input: %w", scope.scope.Name, err)
}
right, err := prepareScopeInput(prepared, input.againstValues, scope, scope.againstBindings, input.against, true)
if err != nil {
return fmt.Errorf("configuration scope %q right input: %w", scope.scope.Name, err)
}
plan.comparison = &preparedComparison[I, S]{
scope: scope,
request: ComparisonRequest[I]{
Left: ComparisonSide[I]{Selection: input.selection, Input: left},
Right: ComparisonSide[I]{Selection: input.against, Input: right},
},
} }
input.plan = plan input.plan = plan
return nil return nil

View File

@@ -1,6 +1,7 @@
package ckconfig package ckconfig
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"io" "io"
@@ -67,3 +68,39 @@ func TestBufferedReportDoesNotRejectCancellationAfterDeliveryStarts(t *testing.T
t.Fatalf("post-delivery cancellation changed the operation outcome: %#v", got) t.Fatalf("post-delivery cancellation changed the operation outcome: %#v", got)
} }
} }
type effectiveWriterFunc func(io.Writer) error
func (write effectiveWriterFunc) WriteEffective(w io.Writer) error { return write(w) }
func TestShowCancellationDuringBufferedPreparation(t *testing.T) {
renderErr := errors.New("native rendering failed")
for _, tc := range []struct {
name string
renderErr error
}{{name: "cancellation"}, {name: "cancellation and rendering failure", renderErr: renderErr}} {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
acquired, secondWrites := 0, 0
scope := func(name string, writer EffectiveWriter) Scope[struct{}, struct{}] {
return Scope[struct{}, struct{}]{Name: name, Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
acquired++
return commandkit.Acquired[View]{Value: View{Effective: writer}}, nil
}}
}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{
scope("app", effectiveWriterFunc(func(w io.Writer) error { _, _ = io.WriteString(w, "name: app\n"); cancel(); return tc.renderErr })),
scope("promptkit", effectiveWriterFunc(func(w io.Writer) error { secondWrites++; _, err := io.WriteString(w, "name: promptkit\n"); return err })),
}}))
var output bytes.Buffer
got := app.Execute(ctx, []string{"config", "show", "--scope=all"}, commandkit.Streams{Out: &output, Err: io.Discard})
if acquired != 2 || secondWrites != 0 || output.Len() != 0 || !errors.Is(got.Cause, context.Canceled) || got.Presentation.Output.WriteError != nil {
t.Fatalf("acquired=%d second=%d output=%q outcome=%#v", acquired, secondWrites, output.String(), got)
}
if tc.renderErr == nil && got.Presentation.Output.RenderError != nil || tc.renderErr != nil && !errors.Is(got.Presentation.Output.RenderError, tc.renderErr) {
t.Fatalf("render evidence = %#v", got.Presentation.Output)
}
})
}
}

View File

@@ -66,11 +66,13 @@ func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOpera
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err) return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
} }
var value any var value any
var canceled error
err := reporter.RenderOutput(func(io.Writer) error { err := reporter.RenderOutput(func(io.Writer) error {
values := make(map[string]any, len(documents)) values := make(map[string]any, len(documents))
for _, document := range documents { for _, document := range documents {
if err := invocation.Context.Err(); err != nil { if err := invocation.Context.Err(); err != nil {
return err canceled = err
return nil
} }
var native bytes.Buffer var native bytes.Buffer
if err := document.view.Effective.WriteEffective(&native); err != nil { if err := document.view.Effective.WriteEffective(&native); err != nil {
@@ -89,6 +91,9 @@ func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOpera
} }
return nil return nil
}) })
if canceled != nil {
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, canceled)
}
if err != nil { if err != nil {
if canceled := invocation.Context.Err(); canceled != nil { if canceled := invocation.Context.Err(); canceled != nil {
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, canceled) return configFailure("Configuration show was canceled.", commandkit.Explanation{}, canceled)

View File

@@ -3,7 +3,6 @@ package ckconfig
import ( import (
"errors" "errors"
"fmt" "fmt"
"slices"
"text/template" "text/template"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
@@ -62,6 +61,14 @@ func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *confi
reporter := invocation.Reporter() reporter := invocation.Reporter()
report := validationReport{Scopes: make([]validationScope, 0, len(plan.scopes))} report := validationReport{Scopes: make([]validationScope, 0, len(plan.scopes))}
outcome := commandkit.Success() outcome := commandkit.Success()
severities := make(map[configkit.Severity]bool, len(plan.severities))
for _, severity := range plan.severities {
severities[severity] = true
}
codes := make(map[string]bool, len(plan.codes))
for _, code := range plan.codes {
codes[code] = true
}
for _, selected := range plan.scopes { for _, selected := range plan.scopes {
if err := invocation.Context.Err(); err != nil { if err := invocation.Context.Err(); err != nil {
outcome = configFailure("Configuration validation was canceled.", commandkit.Explanation{}, err) outcome = configFailure("Configuration validation was canceled.", commandkit.Explanation{}, err)
@@ -71,7 +78,7 @@ func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *confi
if selected.scope.scope.Acquire == nil { if selected.scope.scope.Acquire == nil {
entry := validationScope{Scope: name, Outcome: "failed"} entry := validationScope{Scope: name, Outcome: "failed"}
if plan.details { if plan.details {
entry.Details = projectValidationDetails(View{}, plan.severities, plan.codes) entry.Details = projectValidationDetails(View{}, severities, codes)
} }
report.Scopes = append(report.Scopes, entry) report.Scopes = append(report.Scopes, entry)
outcome = commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", name)) outcome = commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", name))
@@ -84,7 +91,7 @@ func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *confi
} }
entry := validationScope{Scope: name, Outcome: "valid"} entry := validationScope{Scope: name, Outcome: "valid"}
if plan.details { if plan.details {
entry.Details = projectValidationDetails(acquired.Value, plan.severities, plan.codes) entry.Details = projectValidationDetails(acquired.Value, severities, codes)
} }
if canceled := invocation.Context.Err(); canceled != nil { if canceled := invocation.Context.Err(); canceled != nil {
entry.Outcome = "canceled" entry.Outcome = "canceled"
@@ -114,7 +121,7 @@ func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *confi
return outcome return outcome
} }
func projectValidationDetails(view View, severities []configkit.Severity, codes []string) *validationDetails { func projectValidationDetails(view View, severities map[configkit.Severity]bool, codes map[string]bool) *validationDetails {
details := &validationDetails{State: "unavailable", Findings: []validationFinding{}, Sources: []validationSource{}} details := &validationDetails{State: "unavailable", Findings: []validationFinding{}, Sources: []validationSource{}}
if view.Findings == nil { if view.Findings == nil {
return details return details
@@ -126,10 +133,10 @@ func projectValidationDetails(view View, severities []configkit.Severity, codes
details.State = "filtered" details.State = "filtered"
references := make(map[string]bool) references := make(map[string]bool)
for _, record := range view.Findings.Records { for _, record := range view.Findings.Records {
if len(severities) > 0 && !slices.Contains(severities, record.Severity) { if len(severities) > 0 && !severities[record.Severity] {
continue continue
} }
if len(codes) > 0 && !slices.Contains(codes, string(record.Code)) { if len(codes) > 0 && !codes[string(record.Code)] {
continue continue
} }
finding := validationFinding{Code: string(record.Code), Severity: string(record.Severity), Path: record.Path.String(), Message: record.Message, Related: locationReports(record.Related)} finding := validationFinding{Code: string(record.Code), Severity: string(record.Severity), Path: record.Path.String(), Message: record.Message, Related: locationReports(record.Related)}

View File

@@ -1,39 +1,39 @@
# CommandKit ckprompt # CommandKit ckprompt
`ckprompt` is the optional PromptKit integration for CommandKit. It adapts `ckprompt` adds PromptKit inspection and testing commands to Go applications
PromptKit engine configuration, native comparison, five catalogs, named that use CommandKit. Operators can list prompts, profiles, and backends, inspect
inspection, requirements discovery and validation, and native smoke tests. their settings, discover required inputs, validate and preview requests, and
Importing it does not install either command family. run smoke tests.
The application chooses which commands to install. Importing the package alone
does not install commands.
## Module ## Module
The module path is `go.maximumdirect.net/ckprompt`. This source uses Go 1.27.0, The module path is `go.maximumdirect.net/ckprompt`. See the
`go.maximumdirect.net/commandkit v0.3.0`, `go.maximumdirect.net/ckconfig v0.3.0`, [consumer guide](https://forge.maximumdirect.net/base-layer/commandkit/src/branch/main/docs/consumers/guide.md#module-selection)
`go.maximumdirect.net/configkit v1.1.0`, and `go.maximumdirect.net/promptkit v0.20.0`. See the for compatible releases and Go requirements.
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#promptkit-configuration-definitions-and-requirements)
for callback, input, state, and acquisition contracts.
The current source requires the aligned local workspace until new core and
ckconfig releases supply its APIs.
## Example ## Example
Add `ckprompt.ConfigScope(loadEngine)` to the scopes passed to Pass your engine loader to `FromEngine` to add resource listings, inspection,
`ckconfig.Command` when the application wants PromptKit engine configuration and requirements discovery:
and native comparison in the `config` family. Install
`ckprompt.Command(ckprompt.FromEngine(loadEngine))` separately for catalogs, ```go
named inspections, and requirements discovery. `FromEngine` supplies nine app, err := commandkit.New(identity, ckprompt.Command(ckprompt.FromEngine(loadEngine)))
PromptKit-backed callbacks: four inspections and five catalogs. Applications ```
may narrow, wrap, or replace them before capture or provide custom callbacks
with `ckprompt.Suite`. Add concrete request validation, prepared-request preview, Add request validation, preview, and smoke tests with `WithRequestValidation`,
and smoke testing explicitly with `WithRequestValidation`, `WithRequestPreparation`, and `WithSmokeTesting`. To expose engine configuration
`WithRequestPreparation`, and `WithSmokeTesting`. Inspection, under `config`, add `ckprompt.ConfigScope(loadEngine)` to a `ckconfig.Suite`.
requirements, and catalog operations do not execute prompts or contact model The [integration guide](https://forge.maximumdirect.net/base-layer/commandkit/src/branch/main/docs/consumers/guide.md#promptkit-configuration-definitions-and-requirements)
providers; application loaders and callbacks retain their own effects. Smoke explains these choices; the [composed example](examples/composed) shows them together.
testing invokes the selected native PromptKit operation; the
application retains ownership of client and configuration choices. See the Inspection commands do not call the model. Smoke tests can use the application's
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#promptkit-smoke-testing) configured client or network probes. Applications own engine setup, credentials,
for operator commands and report semantics. The maintained and any effects of their loaders and callbacks. See the
[composed example](examples/composed) shows complete application wiring. [CLI reference](https://forge.maximumdirect.net/base-layer/commandkit/src/branch/main/docs/cli.md#promptkit-smoke-testing)
for commands and results.
## License and attribution ## License and attribution

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"strconv" "strconv"
"go.maximumdirect.net/ckconfig" "go.maximumdirect.net/ckconfig"
@@ -281,6 +280,9 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S)
if errors.As(err, &loadError) && loadError != nil { if errors.As(err, &loadError) && loadError != nil {
acquired.Value.Sources = loadError acquired.Value.Sources = loadError
acquired.Value.Findings = &ckconfig.Findings{Records: loadError.Diagnostics(), Messages: messages.diagnostics} acquired.Value.Findings = &ckconfig.Findings{Records: loadError.Diagnostics(), Messages: messages.diagnostics}
} else if inspection != nil {
acquired.Value.Sources = *inspection
acquired.Value.Findings = &ckconfig.Findings{Records: inspection.Diagnostics(), Messages: messages.diagnostics}
} else { } else {
acquired.Diagnostics = messages.diagnostics acquired.Diagnostics = messages.diagnostics
} }
@@ -289,9 +291,9 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S)
metadata := inspection.Metadata() metadata := inspection.Metadata()
return commandkit.Acquired[ckconfig.View]{ return commandkit.Acquired[ckconfig.View]{
Value: ckconfig.View{ Value: ckconfig.View{
Effective: inspection, Effective: *inspection,
Sources: inspection, Sources: *inspection,
Explanation: inspection, Explanation: *inspection,
Metadata: &metadata, Metadata: &metadata,
Findings: &ckconfig.Findings{Records: inspection.Diagnostics(), Messages: messages.diagnostics}, Findings: &ckconfig.Findings{Records: inspection.Diagnostics(), Messages: messages.diagnostics},
}, },
@@ -322,7 +324,7 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S)
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, 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) differences, err := leftInspection.Diff(*rightInspection)
messages = messages.withOperationError(err) messages = messages.withOperationError(err)
if err != nil { if err != nil {
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]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q native configuration diff: %w", scope.Name, err)
@@ -471,31 +473,31 @@ func loadPromptKitEngine[I, S any](
state S, state S,
side string, side string,
load engineLoader[I, S], load engineLoader[I, S],
) (*promptkit.Engine, promptkit.ConfigurationInspection, promptKitAcquisition, error) { ) (*promptkit.Engine, *promptkit.ConfigurationInspection, promptKitAcquisition, error) {
engine, err := load(ctx, selection, input, state) engine, err := load(ctx, selection, input, state)
if err != nil { if err != nil {
var loadErr *configkit.LoadError var loadErr *configkit.LoadError
if errors.As(err, &loadErr) { if errors.As(err, &loadErr) {
messages := promptKitDiagnostics("promptkit", side, loadErr.Sources(), loadErr.Diagnostics()) messages := promptKitDiagnostics("promptkit", side, loadErr.Sources(), loadErr.Diagnostics())
return nil, promptkit.ConfigurationInspection{}, messages, err return nil, nil, messages, err
} }
return nil, promptkit.ConfigurationInspection{}, promptKitAcquisition{}, err return nil, nil, promptKitAcquisition{}, err
} }
if engine == nil { if engine == nil {
return nil, promptkit.ConfigurationInspection{}, promptKitAcquisition{}, errors.New("PromptKit configuration loader returned a nil engine") return nil, nil, promptKitAcquisition{}, errors.New("PromptKit configuration loader returned a nil engine")
} }
inspection, err := engine.InspectConfiguration() inspection, err := engine.InspectConfiguration()
messages := promptKitDiagnostics("promptkit", side, inspection.Sources(), inspection.Diagnostics()) messages := promptKitDiagnostics("promptkit", side, inspection.Sources(), inspection.Diagnostics())
messages = messages.withOperationError(err) messages = messages.withOperationError(err)
if err != nil { if err != nil {
return nil, inspection, messages, err return nil, nil, messages, err
} }
if ctx != nil { if ctx != nil {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return nil, promptkit.ConfigurationInspection{}, messages, err return nil, &inspection, messages, err
} }
} }
return engine, inspection, messages, nil return engine, &inspection, messages, nil
} }
func appendPromptKitAcquisition(left, right promptKitAcquisition) promptKitAcquisition { func appendPromptKitAcquisition(left, right promptKitAcquisition) promptKitAcquisition {
@@ -508,61 +510,10 @@ func appendPromptKitAcquisition(left, right promptKitAcquisition) promptKitAcqui
} }
func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) promptKitAcquisition { 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...)
records = append([]configkit.Diagnostic(nil), records...)
for index := range records {
if records[index].Primary != nil {
primary := *records[index].Primary
records[index].Primary = &primary
}
records[index].Related = append([]configkit.Location(nil), records[index].Related...)
}
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) heading := "Configuration scope " + strconv.Quote(scope)
if side != "" { if side != "" {
heading += " (" + side + ")" heading += " (" + side + ")"
} }
if _, err := fmt.Fprintf(writer, "%s:\n", heading); err != nil { diagnostics, explanation := ckconfig.AdaptDiagnostics(heading+":\n", sources, records)
return err return promptKitAcquisition{diagnostics: diagnostics, explanation: explanation}
}
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)
} }

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"reflect" "reflect"
"slices" "slices"
@@ -858,18 +859,79 @@ func TestFromEngineRetainsConfigKitLoadExplanation(t *testing.T) {
} }
} }
func TestConfigScopeHonorsCancellationAfterLoad(t *testing.T) { func TestConfigScopeRetainsCapturedEvidenceAfterCancellation(t *testing.T) {
engine := adapterEngine(t)
native, err := engine.InspectConfiguration()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
engine := adapterEngine(t) scope := ConfigScope(func(context.Context, ckconfig.Selection, struct{}, struct{}) (*promptkit.Engine, error) {
scope := ConfigScope(func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
cancel() cancel()
return engine, nil return engine, nil
}) })
acquired, err := scope.Acquire(ctx, ckconfig.Request{}, struct{}{}, struct{}{})
acquired, err := scope.Acquire(ctx, ckconfig.Request{Operation: ckconfig.Show}, 0, 0) if !errors.Is(err, context.Canceled) || acquired.Value.Effective != nil || acquired.Value.Metadata != nil || acquired.Value.Explanation != nil || acquired.Value.Sources == nil || acquired.Value.Findings == nil {
if !errors.Is(err, context.Canceled) || acquired.Value.Effective != nil { t.Fatalf("canceled acquisition = %#v, %v", acquired, err)
t.Fatalf("cancelled scope acquisition = (%#v, %v), want cancellation and no value", acquired, err) }
if !reflect.DeepEqual(acquired.Value.Sources.Sources(), native.Sources()) || !reflect.DeepEqual(acquired.Value.Findings.Records, native.Diagnostics()) {
t.Fatalf("lost captured evidence: %#v", acquired.Value)
}
for _, operation := range []string{"sources", "validate"} {
for _, failOutput := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/failOutput=%t", operation, failOutput), func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
scope := ConfigScope(func(context.Context, ckconfig.Selection, struct{}, struct{}) (*promptkit.Engine, error) {
cancel()
return engine, nil
})
unvisited := 0
app, err := commandkit.New(commandkit.Identity{Name: "fixture"}, ckconfig.Command(ckconfig.Suite[struct{}, struct{}]{Scopes: []ckconfig.Scope[struct{}, struct{}]{
{Name: "app", Operations: []ckconfig.Operation{ckconfig.Validate}, Acquire: func(context.Context, ckconfig.Request, struct{}, struct{}) (commandkit.Acquired[ckconfig.View], error) {
return commandkit.Acquired[ckconfig.View]{}, nil
}},
scope,
{Name: "zeta", Operations: []ckconfig.Operation{ckconfig.Validate}, Acquire: func(context.Context, ckconfig.Request, struct{}, struct{}) (commandkit.Acquired[ckconfig.View], error) {
unvisited++
return commandkit.Acquired[ckconfig.View]{}, nil
}},
}}))
if err != nil {
t.Fatal(err)
}
args := []string{"config", operation, "--scope=promptkit", "--format=json"}
if operation == "validate" {
args = []string{"config", "validate", "--scope=all", "--details", "--format=json"}
}
var out bytes.Buffer
var writer io.Writer = &out
writeErr := errors.New("output failed")
if failOutput {
writer = failingDiagnosticWriter{err: writeErr}
}
got := app.Execute(ctx, args, commandkit.Streams{Out: writer, Err: io.Discard})
if !errors.Is(got.Cause, context.Canceled) || unvisited != 0 {
t.Fatalf("outcome=%#v unvisited=%d", got, unvisited)
}
if failOutput {
if !errors.Is(got.Presentation.Output.WriteError, writeErr) {
t.Fatalf("lost presentation failure: %#v", got)
}
} else {
if operation == "sources" {
for _, source := range native.Sources() {
if !strings.Contains(out.String(), source.ID) {
t.Fatalf("missing acquired source: %s", out.String())
}
}
} else if !strings.Contains(out.String(), `"state": "empty"`) || !strings.Contains(out.String(), `"scope": "app"`) || strings.Contains(out.String(), `"scope": "zeta"`) {
t.Fatalf("reached-scope evidence: %s", out.String())
}
}
})
}
} }
} }

View File

@@ -91,7 +91,7 @@ func buildCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.Declaration
showCommands = append(showCommands, newPreparationCommand(suite, aliases)) showCommands = append(showCommands, newPreparationCommand(suite, aliases))
} }
if len(showCommands) != 0 { if len(showCommands) != 0 {
rootCommands = append(rootCommands, commandkit.Group("show", "Show a named PromptKit definition.", showCommands...)) rootCommands = append(rootCommands, commandkit.Group("show", "Show PromptKit definitions or preview a prepared request.", showCommands...))
} }
listCommands := make([]commandkit.Command, 0, 5) listCommands := make([]commandkit.Command, 0, 5)
if suite.ListPrompts != nil { if suite.ListPrompts != nil {
@@ -110,7 +110,7 @@ func buildCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.Declaration
listCommands = append(listCommands, newCatalogListCommand(suite, "collections")) listCommands = append(listCommands, newCatalogListCommand(suite, "collections"))
} }
if len(listCommands) != 0 { if len(listCommands) != 0 {
rootCommands = append(rootCommands, commandkit.Group("list", "List PromptKit catalogs.", listCommands...)) rootCommands = append(rootCommands, commandkit.Group("list", "List available PromptKit resources.", listCommands...))
} }
requirementsCommands := make([]commandkit.Command, 0, 2) requirementsCommands := make([]commandkit.Command, 0, 2)
if suite.InspectRequirements != nil { if suite.InspectRequirements != nil {
@@ -125,7 +125,7 @@ func buildCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.Declaration
if suite.SmokeTest != nil { if suite.SmokeTest != nil {
rootCommands = append(rootCommands, newSmokeTestCommand(suite, aliases)) rootCommands = append(rootCommands, newSmokeTestCommand(suite, aliases))
} }
command := commandkit.RootGroup("promptkit", "Inspect, list, validate requests, and run PromptKit smoke tests.", rootCommands...) command := commandkit.RootGroup("promptkit", "Inspect PromptKit resources, check requests, and run smoke tests.", rootCommands...)
for _, issue := range suite.issues { for _, issue := range suite.issues {
command = commandkit.WithError(command, issue) command = commandkit.WithError(command, issue)
} }
@@ -140,16 +140,16 @@ func newCatalogListCommand[I, S any](suite Suite[I, S], name string) commandkit.
return &(*input).captured return &(*input).captured
}) })
flags = append(flags, flags = append(flags,
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Select the configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }), func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }),
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Select the configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Overlay = value }), func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Overlay = value }),
) )
if name == "prompts" || name == "profiles" || name == "backends" { if name == "prompts" || name == "profiles" || name == "backends" {
flags = append(flags, flags = append(flags,
commandkit.ValueFlag(commandkit.FlagMeta{Name: "namespace", Usage: "Select one native namespace."}, (*string)(nil), parseDefinitionString, commandkit.ValueFlag(commandkit.FlagMeta{Name: "namespace", Usage: "List resources in one namespace."}, (*string)(nil), parseDefinitionString,
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).namespace = value }), func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).namespace = value }),
commandkit.BoolFlag(commandkit.FlagMeta{Name: "all-namespaces", Usage: "Select identities from every namespace."}, false, commandkit.BoolFlag(commandkit.FlagMeta{Name: "all-namespaces", Usage: "List resources from every namespace."}, false,
func(input **catalogInput[I], value commandkit.Supplied[bool]) { (*input).allNamespaces = value }), func(input **catalogInput[I], value commandkit.Supplied[bool]) { (*input).allNamespaces = value }),
) )
} }
@@ -243,9 +243,9 @@ func newDefinitionCommand[I, S any](suite Suite[I, S], name string, aliases *[]c
return &(*input).captured return &(*input).captured
}) })
flags = append(flags, flags = append(flags,
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Select the configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
func(input **definitionInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }), func(input **definitionInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }),
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Select the configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
func(input **definitionInput[I], value commandkit.Supplied[string]) { func(input **definitionInput[I], value commandkit.Supplied[string]) {
(*input).selection.Overlay = value (*input).selection.Overlay = value
}), }),
@@ -288,11 +288,11 @@ func newRequirementsCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.D
return &(*input).captured return &(*input).captured
}) })
flags = append(flags, flags = append(flags,
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Select the configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
func(input **requirementsInput[I], value commandkit.Supplied[string]) { func(input **requirementsInput[I], value commandkit.Supplied[string]) {
(*input).selection.Config = value (*input).selection.Config = value
}), }),
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Select the configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
func(input **requirementsInput[I], value commandkit.Supplied[string]) { func(input **requirementsInput[I], value commandkit.Supplied[string]) {
(*input).selection.Overlay = value (*input).selection.Overlay = value
}), }),
@@ -308,8 +308,8 @@ func newRequirementsCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.D
spec := commandkit.CommandSpec[*requirementsInput[I]]{ spec := commandkit.CommandSpec[*requirementsInput[I]]{
Name: "show", Name: "show",
Summary: "Inspect requirements for a named PromptKit prompt.", Summary: "Discover the inputs and variables a prompt needs.",
Description: "Profile selection establishes the reported profile identity, not a different set of requirements. A profile namespace qualifies an explicitly supplied short profile ID, not the prompt’s stored default reference.", Description: "List required inputs without running the prompt. Choosing a profile changes the reported profile ID, not the requirements. A profile namespace applies to a short profile ID supplied here; it does not change the prompt’s stored default reference.",
NewInput: func() *requirementsInput[I] { NewInput: func() *requirementsInput[I] {
return &requirementsInput[I]{} return &requirementsInput[I]{}
}, },

View File

@@ -1,13 +1,13 @@
// Package ckprompt, provided by go.maximumdirect.net/ckprompt, provides optional // Package ckprompt adds PromptKit inspection and testing commands to CommandKit
// PromptKit configuration comparison, // applications. Operators can list resources, inspect definitions, discover
// prompt catalogs, named inspections, requirements discovery and validation, // required inputs, validate and preview requests, and run smoke tests.
// prepared-request preview, and native smoke testing for CommandKit. Command //
// returns a canonical declaration; Declarations adds scoped flag aliases. // Start with FromEngine and add optional operations with WithRequestValidation,
// Applications can install those alongside other declarations through // WithRequestPreparation, and WithSmokeTesting. Command returns the command
// commandkit.New or App.Register. Importing ckprompt installs neither family; callers compose its // family; Declarations also includes its convenience flag aliases. Install
// declarations with core and ckconfig explicitly. Provider diagnostics remain // these with commandkit.New or App.Register.
// passive until the owning command submits them through the invocation //
// reporter, separately from any terminal explanation. This module uses // ConfigScope separately exposes engine configuration through ckconfig.
// PromptKit v0.20.0 and its native engine inventory, including configured // Importing this package installs neither command family. Applications own
// bundled resources. // engine construction, credentials, and client configuration.
package ckprompt package ckprompt

View File

@@ -85,8 +85,9 @@ root): `docs/policy/meta.md`, `docs/policy/agents.md`,
`docs/policy/documentation.md`, `docs/policy/release.md`, and `docs/policy/documentation.md`, `docs/policy/release.md`, and
`docs/policy/oss.md`; the root `AGENTS.md` orientation after the knowledge-graph `docs/policy/oss.md`; the root `AGENTS.md` orientation after the knowledge-graph
instructions; and the Policy Reading Guide in `docs/development.md`, adapted for instructions; and the Policy Reading Guide in `docs/development.md`, adapted for
the core module. The release policy uses baseline the core module. The documentation policy uses baseline
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses baseline `beb5f191470a9daef2383981a6bfd02232490ab4`; the release policy uses
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses
`d84a38ebab26f048cafbe59a2246700c35d29843`. `d84a38ebab26f048cafbe59a2246700c35d29843`.
Copyright (C) 2026 Eric Rakestraw Copyright (C) 2026 Eric Rakestraw
@@ -97,12 +98,16 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) | [COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) | [LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) | [COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE). [LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843). [Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348). [Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4).
## go-yaml v4 ## go-yaml v4
Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`. Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`.

View File

@@ -0,0 +1,4 @@
# ckprompt release errata
The v0.2.0 release note links to the wrong consumer-guide section. The correct
pinned section is [PromptKit configuration, definitions, and requirements](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.2.0/docs/consumers/guide.md#promptkit-configuration-definitions-and-requirements).

View File

@@ -1,34 +0,0 @@
# CommandKit ckprompt v0.3.0
## Summary
This candidate adopts `go.maximumdirect.net/promptkit v0.18.0`,
`go.maximumdirect.net/configkit v1.1.0`, and
`go.maximumdirect.net/ckconfig v0.3.0`, while retaining `go.maximumdirect.net/commandkit v0.3.0`. It requires
Go 1.27.0. Existing configuration, catalog, inspection, validation, and smoke
operations continue to forward the selected engine's native results.
## Migration
Change PromptKit and ConfigKit imports to `go.maximumdirect.net/promptkit` and
`go.maximumdirect.net/configkit`. Update loaders and consumers together because
values from the old and new paths have distinct Go type identities. Migrate
PromptKit prompt, profile, and backend maps to lists with explicit IDs, and
update source builders, inheritance, import requiredness, and source-root
controls as needed. See the pinned [upstream migration guide](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/d9f6aad30509e538fc73ea16710df3a3ec084502/docs/consumers/configuration-migration.md).
Prompt catalogs, prompt and requirements inspections, and smoke targets no
longer include descriptive prompt versions. Update report consumers accordingly;
collection release labels and source version evidence remain. Configuration
inspection now exposes native normalized values and source evidence, and
native comparisons treat lists atomically.
PromptKit's default OpenRouter bundle supplies a backend and root profiles.
They appear in catalog and configuration reports when enabled. Applications
that need an application-only inventory may choose
`promptkit.WithBundledOpenRouter(false)` in their engine loader. Review smoke
selections and configure appropriate clients and HTTP probes for the application.
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.3.0/integrations/ckprompt/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.3.0/integrations/ckprompt/LICENSE) |
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.3.0/integrations/ckprompt/docs/oss/attribution.md)

View File

@@ -0,0 +1,52 @@
# CommandKit ckprompt v0.5.0
CommandKit ckprompt v0.5.0 adds PromptKit v0.20.0 support, including request
preview, namespace selection, and shared text, YAML, and JSON output. It requires
CommandKit core v0.5.0, ckconfig v0.5.0, ConfigKit v1.1.0, and Go 1.27.0.
## Operator features
- Inspect prompts, profiles, and backends, and list available resources.
- Preview rendered requests without calling the model, or validate that a
request can be prepared with the supplied inputs.
- Choose namespaces and override request settings for supported commands.
- Run smoke tests and inspect their results, including incomplete or unsupported
checks and reports returned alongside errors.
- Inspect engine configuration through `config`, including detailed validation
findings, source information after failed loads, and configuration comparisons.
Applications choose which commands to enable. Reusable flag collections also
let application commands offer the same request flags. `ckprompt.Declarations`
adds the standard commands with their shorter flag aliases. Literal and file
inputs keep their command-line order.
## Correctness improvements
Aggregate smoke testing retains cancellation when it stops remaining tests,
including previously returned errors and reports. A prepared-request preview
fails if the provider returns no prepared request without an error. Parsed
provider-parameter objects are copied between independent request destinations,
so mutations in one callback do not change later requests. Configuration
inspection shares ckconfig's native diagnostic adapter.
## Compatibility and upgrade
This pre-1.0 minor release changes resource report shapes, input flags,
formatting, and configuration comparison behavior. Review the current
[CLI reference](../../../../docs/cli.md#promptkit-definition-inspection) and
[consumer guide](../../../../docs/consumers/guide.md#promptkit-configuration-definitions-and-requirements)
when updating declarations or code that reads reports. Applications continue to
own engine construction, credentials, and execution.
Upgrade all three CommandKit modules to v0.5.0 together. This release follows
published ckprompt v0.2.0 and adopts the vanity imports
`go.maximumdirect.net/configkit` and `go.maximumdirect.net/promptkit`.
Replace earlier upstream import paths consistently; types from different
module identities cannot be mixed. Review callers of the former prompt
version-report API and use native prompt, profile, and backend inspections.
The [module selection table](../../../../docs/consumers/guide.md#module-selection)
records the release dependencies.
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.5.0/integrations/ckprompt/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.5.0/integrations/ckprompt/LICENSE) |
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.5.0/integrations/ckprompt/docs/oss/attribution.md)

View File

@@ -1,144 +1,171 @@
# Composed consumer # ConfigKit and PromptKit example
This example is part of the ckprompt module and uses core v0.3.0, ckconfig This example combines application and PromptKit configuration, resource
v0.3.0, ConfigKit v1.1.0, and PromptKit v0.20.0. It requires Go 1.27.0. inspection, request checks, and smoke tests. It uses local fixtures and an
The current work uses the aligned local workspace until the required core and in-memory model client. **No model service or credentials are required.**
ckconfig releases are published.
This example composes application configuration, PromptKit configuration, Use Go 1.27.0 on `PATH`. An aligned workspace containing all three local modules
PromptKit comparison and catalogs, definition and requirements inspection, and exercises their current sources; follow the [workspace setup](../../../../docs/development.md#documentation-validation)
one application-owned command: from the repository root. The example also builds independently with
`GOWORK=off` against its module's released dependencies.
The [consumer guide](../../../../docs/consumers/guide.md#module-selection) owns
version and compatibility details.
From the repository root:
```sh ```sh
cd integrations/ckprompt/examples/composed
export GOWORK=auto GOTOOLCHAIN=local
go run . config scopes go run . config scopes
go run . promptkit list prompts --campaign spring
go run . promptkit show prompt briefing --campaign spring --format yaml
```
The first command lists `app` and `promptkit` configurations and their available
operations. The next commands find and inspect the `briefing` prompt.
`--campaign` is a selector supplied by this example, not a standard PromptKit flag.
## Validate and compare configuration
```sh
go run . config validate --scope all --campaign spring --limit=0 go run . config validate --scope all --campaign spring --limit=0
go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml
go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring --limit=0
go run . config diff --scope app --campaign spring --limit=0 --against-campaign autumn --against-limit=0 --set '/campaigns/spring/model="left"' --against-set '/campaigns/spring/model="right"' --details
go run . config diff --scope promptkit --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring --against-campaign spring go run . config diff --scope promptkit --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring --against-campaign spring
go run . promptkit list prompts --config fixtures/before.yml --campaign spring ```
The application scope requires `--limit=0` when `--campaign` is supplied.
The app comparison reports a changed default campaign and `campaigns.spring.model`.
The PromptKit comparison selects spring on both sides and reports the changed
profiles list as one value.
The two sides have independent selectors and overrides:
```sh
go run . config diff --scope app --campaign spring --limit=0 --against-campaign autumn --against-limit=0 --set '/campaigns/spring/model="left"' --against-set '/campaigns/spring/model="right"' --details
```
Only the application scope supports `--set` here. The released PromptKit loader
has no override handoff, so its configuration scope does not enable the feature.
An aggregate operation that includes that scope rejects overrides.
## List resources and select namespaces
```sh
go run . promptkit list profiles --config fixtures/after.yml --campaign autumn --format yaml go run . promptkit list profiles --config fixtures/after.yml --campaign autumn --format yaml
go run . promptkit list backends --campaign spring go run . promptkit list backends --campaign spring
go run . promptkit list namespaces --campaign spring go run . promptkit list namespaces --campaign spring
go run . promptkit list collections --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 list prompts --namespace field-notes --campaign spring
go run . promptkit show prompt briefing --campaign spring --format yaml
go run . promptkit show prompt briefing --campaign spring --prompt-namespace field-notes go run . promptkit show prompt briefing --campaign spring --prompt-namespace field-notes
go run . promptkit show prepared briefing --campaign spring --input topic=sprint --profile spring --extra-params '{"campaign":"preview"}'
go run . pk show prompt briefing --campaign spring --fmt yaml
go run . promptkit show profile spring --campaign spring go run . promptkit show profile spring --campaign spring
```
The profile list includes the selected `autumn` profile. PromptKit's default
bundle contributes additional resources. The example also supplies a
`field-notes` collection containing a backend, profile, and prompt. Listing
filters and resource namespace flags let you inspect that collection.
## Discover inputs, then check a request
```sh
go run . promptkit requirements show briefing --campaign spring go run . promptkit requirements show briefing --campaign spring
go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml
```
The first report is incomplete: the `notes` input is conditional and `mode` is
missing. Supplying `mode=full` selects that branch and makes `notes` required.
Both commands succeed; discovery alone does not establish that a request can run.
```sh
go run . promptkit requirements validate briefing --campaign spring --llm-input-file topic=fixtures/topic.txt go run . promptkit requirements validate briefing --campaign spring --llm-input-file topic=fixtures/topic.txt
go run . promptkit requirements validate briefing --campaign spring --llm-input-file topic=fixtures/topic.txt --llm-var mode=full --llm-input-file notes=fixtures/notes.txt --format yaml go run . promptkit requirements validate briefing --campaign spring --llm-input-file topic=fixtures/topic.txt --llm-var mode=full --llm-input-file notes=fixtures/notes.txt --format yaml
```
Both commands check request preparation and report `prompt_id` and `valid: true`.
The builder provides campaign-specific variable defaults; the operator supplies
the input files. For comparison, this command intentionally fails because the
required `topic` input is missing:
```sh
# Intentional failure: missing topic input.
go run . promptkit requirements validate briefing --campaign spring go run . promptkit requirements validate briefing --campaign spring
```
Preview a request with literal input instead of a file:
```sh
go run . promptkit show prepared briefing --campaign spring --input topic=sprint --profile spring --extra-params '{"campaign":"preview"}'
```
The preview includes rendered messages and effective provider settings.
Validation and preview do not call the model or guarantee later execution.
## Run smoke tests
```sh
go run . promptkit test text_inference --campaign spring --prompt briefing --profile spring go run . promptkit test text_inference --campaign spring --prompt briefing --profile spring
go run . smoke text_inference --campaign spring --llm-prompt briefing --llm-profile spring # Some diagnostics can fail or be unsupported; inspect the retained report.
go run . promptkit test all --campaign spring --llm-prompt briefing --llm-profile spring go run . promptkit test all --campaign spring --llm-prompt briefing --llm-profile spring
```
Inference uses the example's in-memory client. HTTP checks may be unsupported
or not applicable because the local backend has no network probe. The aggregate
can return a failure while still showing results. Read the facts in the report;
a successful command status does not mean every observation is positive.
For any command that returns nonzero, `go run` prints `exit status N` and itself
exits with status 1. A built executable returns the application's status directly.
The [CLI reference](../../../../docs/cli.md#promptkit-smoke-testing) explains the
standard test kinds, results, and statuses.
## Try aliases and application commands
```sh
go run . list collections --campaign spring
go run . pk list bundles --campaign spring
go run . pk show prompt briefing --campaign spring --fmt yaml
go run . smoke text_inference --campaign spring --llm-prompt briefing --llm-profile spring
go run . pk --hlep go run . pk --hlep
go run . session restore session-42 --campaign spring go run . session restore session-42 --campaign spring
go run . request inspect --llm-prompt briefing --llm-profile= --llm-model example --llm-input-text topic=sprint --format json go run . request inspect --llm-prompt briefing --llm-profile= --llm-model example --llm-input-text topic=sprint --format json
``` ```
`newApp` installs application-owned command and flag aliases alongside the The first two commands reach `promptkit list collections`. `pk` shortens the
complete `ckconfig` and `ckprompt` declarations. `list collections` and PromptKit group; `--fmt` is an alias on prompt inspection and `--hlep` is a global
`promptkit list bundles` both reach the canonical collection command; `pk` help alias. `smoke` shortens `promptkit test` while leaving the test kind as an
shortens the PromptKit group. `--fmt` is scoped to named prompt inspection and argument. The session command uses an in-memory store. `request inspect` shows
`--hlep` is available globally. The `smoke` shortcut retains the canonical how an application command can reuse canonical `--llm-*` flag collections without
`promptkit test KIND` operand, including the special `all` aggregate. installing PromptKit's short aliases.
The example installs `Completion("composed-example")`. Build it as ## Try shell completion
`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 installs `ckconfig.Command(suite)` and the complete ```sh
`ckprompt.Declarations(definitions)` in a `[]commandkit.Declaration` with go build -o composed-example .
root-installed routing rules. The PromptKit declarations include scoped aliases; ./composed-example completion bash
the application-owned `request inspect` command reuses the flag collections
with their canonical names and does not install those aliases.
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
selected scope's input separately and `NewState` to create one cache shared by
that invocation's acquisitions. Prompt/profile inspection prepares its input and
cache independently for its invocation, using the same application and engine
loader functions. See the
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md)
and the suites' GoDoc for initialization timing and ownership.
The application-scope comparison reports the changed default campaign and the
updated `campaigns.spring.model`. The PromptKit-scope comparison selects spring
on both sides and reports the native engine change in the atomic profiles list.
The application input check requires `--limit=0` when `--campaign` is supplied.
Catalogs use the same selection-aware loader: the prompt catalog includes
`briefing`, and the profile catalog includes the selected `autumn` profile.
Prompt inspection uses an ID only. The default PromptKit bundle also contributes
profiles and a backend to the engine inventory and source reports. The synthetic `field-notes` collection
provides one backend, profile, and prompt for collection discovery and native
namespace filtering. The example's shared run cache keys successful results by
the complete supplied configuration/overlay selection, CLI overrides, and the
supplied campaign, including presence. The campaign override and application
scope's `--set` records are applied through ConfigKit before loading. The
PromptKit configuration scope does not enable `--set` because its released
configuration loader has no override handoff. An aggregate operation therefore
rejects overrides when that scope is selected. These are choices for this
example's loaders and do not define a CommandKit cache policy.
Smoke testing is explicitly wired with `ckprompt.WithSmokeTesting` and its
optional request builder is nil, so CommandKit forwards the selected native
request. Applications that need to adjust the request can provide a typed
builder that starts with the seed and returns the chosen native request:
```go
definitions = ckprompt.WithSmokeTesting(definitions, loadEngine,
func(ctx context.Context, seed ckprompt.SmokeRequest, input *inspectionInput, state *runCache) (promptkit.SmokeTestRequest, error) {
request := seed.Request
request.ProfileID = "spring"
return request, nil
})
``` ```
The example injects a small in-memory `promptkit.LLMClient` through its shared Initialize `bash-completion` if using Bash, then load the generated script using
run state. Catalog, inspection, and requirements commands do not call it; the [CLI reference](../../../../docs/cli.md#shell-completion). Try completing
inference smoke commands do. No model service or credentials are required. `composed-example pk show prompt briefing --fmt=` or
Native HTTP checks may retain unsupported or not-applicable observations because `composed-example config show --config=fixtures/`. The already-entered `pk` alias
the selected local backend does not configure a network probe. An aggregate smoke command can selects the command context without loading an engine.
return an operational outcome for a native diagnostic while still delivering
the complete aggregate report.
The first requirements command reports incomplete discovery: PromptKit records ## How it is wired
the conditional `notes` input and a missing `mode` issue. Supplying `mode=full`
selects that branch, classifies the undeclared `notes` input as required, and
completes discovery. Both commands return successful reports; the result does
not execute the prompt or establish input, credential, or execution readiness.
See the [CLI reference](../../../../docs/cli.md#promptkit-requirements) for the
command and report contract.
The validation and preview commands exercise concrete PromptKit request preparation. The example installs `ckconfig.Command(suite)` and
The first uses the builder's campaign-specific default topic variable and the `ckprompt.Declarations(definitions)` with application-owned aliases in a
operator's topic file. The second also selects the notes branch and supplies its `[]commandkit.Declaration`. `NewInput` initializes scope inputs; `NewState`
file. Validation reports only `prompt_id` and `valid: true`; preview returns the creates a cache shared within an invocation. The loaders cache successful
prepared request, including rendered messages and effective provider settings. configuration results by the complete configuration/overlay selection,
Neither executes the prompt. The validation command without inputs intentionally overrides, and supplied campaign, including whether each value was supplied.
fails because the prompt's These are example choices, not a CommandKit cache policy.
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.
Use the aligned local workspace to test the three current sources, as described in Smoke testing uses `WithSmokeTesting` with a nil builder, forwarding the request
the [developer guide](../../../../docs/development.md#documentation-validation). selected by flags. Applications can supply a builder to customize that request.
Once released dependency requirements are aligned, `GOWORK=off go test ./...` See the [consumer guide](../../../../docs/consumers/guide.md#promptkit-configuration-definitions-and-requirements)
from this example directory checks it against its declared dependencies. for suite composition, builders, and ownership, and the [source](main.go) for
the complete application.
Run `GOWORK=auto GOTOOLCHAIN=local go test ./...` from this example directory
for an aligned workspace check. Use
`GOWORK=off GOTOOLCHAIN=local go test ./...` to check the declared releases.

View File

@@ -3,8 +3,8 @@ module go.maximumdirect.net/ckprompt
go 1.27.0 go 1.27.0
require ( require (
go.maximumdirect.net/ckconfig v0.3.0 go.maximumdirect.net/ckconfig v0.5.0
go.maximumdirect.net/commandkit v0.3.0 go.maximumdirect.net/commandkit v0.5.0
go.maximumdirect.net/configkit v1.1.0 go.maximumdirect.net/configkit v1.1.0
go.maximumdirect.net/promptkit v0.20.0 go.maximumdirect.net/promptkit v0.20.0
go.yaml.in/yaml/v4 v4.0.0-rc.6 go.yaml.in/yaml/v4 v4.0.0-rc.6

View File

@@ -10,10 +10,14 @@ github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 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/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.maximumdirect.net/ckconfig v0.3.0 h1:1FceT13Qe9J7V5knWPiJhwSNwxrZnYPH8HoloYpWLgs= go.maximumdirect.net/ckconfig v0.4.0 h1:7MpI/jIy+3BZrVME5rhKQibh5U9IA3s97BUJ2EOG41s=
go.maximumdirect.net/ckconfig v0.3.0/go.mod h1:NKFBVr2TyDNGGIWgwijGqg5WFoSUL2F8FiKRlNo9TLw= go.maximumdirect.net/ckconfig v0.4.0/go.mod h1:Mk6QOfHrFy00uE0/gw9h6/Y9LcdTShVEzM/oI/HGDns=
go.maximumdirect.net/commandkit v0.3.0 h1:RCmg2GS8uzlLSJCIqarpn1uFDec0W3ylNNRRPT6kUFE= go.maximumdirect.net/ckconfig v0.5.0 h1:5YCPFySTFIFR+gzd0FjqkTPEVB7zkGa8kOEYh4aBvWc=
go.maximumdirect.net/commandkit v0.3.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc= go.maximumdirect.net/ckconfig v0.5.0/go.mod h1:BONXRSQlHdJvYVw9mN6lMV4G4ldlV/5fRO9EEApA99s=
go.maximumdirect.net/commandkit v0.4.0 h1:bWqflDWDn98FUCgh+0e+w6LGmvWIkaeJK519H7PjD2Y=
go.maximumdirect.net/commandkit v0.4.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
go.maximumdirect.net/commandkit v0.5.0 h1:Ln+bCp+FwCg4tZD3NaMKQXUDB3mPusgwsnRZmb0+3cM=
go.maximumdirect.net/commandkit v0.5.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA= go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA=
go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA= go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
go.maximumdirect.net/promptkit v0.20.0 h1:ZpwbjiCZWXdnnZ26RuWKoOco0jFnaAX8m2tQAX7ZlnM= go.maximumdirect.net/promptkit v0.20.0 h1:ZpwbjiCZWXdnnZ26RuWKoOco0jFnaAX8m2tQAX7ZlnM=

View File

@@ -39,9 +39,9 @@ func namespaceFlags[I any](destination func(*I) *namespaceInput, kinds ...string
} }
var namespaceFlagDefinitions = struct{ prompt, profile, backend requestFlagDefinition }{ var namespaceFlagDefinitions = struct{ prompt, profile, backend requestFlagDefinition }{
prompt: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-prompt-namespace", Usage: "Prompt resource namespace."}, []string{"prompt-namespace"}}, prompt: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-prompt-namespace", Usage: "Use this namespace for unqualified prompt IDs."}, []string{"prompt-namespace"}},
profile: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-profile-namespace", Usage: "Profile resource namespace."}, []string{"profile-namespace"}}, profile: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-profile-namespace", Usage: "Use this namespace for unqualified profile IDs."}, []string{"profile-namespace"}},
backend: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-backend-namespace", Usage: "Backend resource namespace."}, []string{"backend-namespace"}}, backend: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-backend-namespace", Usage: "Use this namespace for unqualified backend IDs."}, []string{"backend-namespace"}},
} }
func selectedNamespaces(defaults *promptkit.Namespaces, selected namespaceInput) *promptkit.Namespaces { func selectedNamespaces(defaults *promptkit.Namespaces, selected namespaceInput) *promptkit.Namespaces {

View File

@@ -100,6 +100,9 @@ func runRequestPreparation[I, S any](invocation commandkit.Invocation, suite Sui
if err := promptKitContextError(invocation.Context); err != nil { if err := promptKitContextError(invocation.Context); err != nil {
return definitionFailure(fmt.Sprintf("Preparation of the PromptKit request for prompt %q was canceled.", input.promptID), acquired.Explanation, err) return definitionFailure(fmt.Sprintf("Preparation of the PromptKit request for prompt %q was canceled.", input.promptID), acquired.Explanation, err)
} }
if acquired.Value == nil {
return definitionFailure("PromptKit request preparation returned no result.", acquired.Explanation, nil)
}
_ = invocation.Reporter().Report(commandkit.Report{Value: acquired.Value}) _ = invocation.Reporter().Report(commandkit.Report{Value: acquired.Value})
return commandkit.Success() return commandkit.Success()
} }

View File

@@ -105,3 +105,43 @@ func TestNativePreparationBuilderAndErrorIdentity(t *testing.T) {
t.Fatalf("native error = %v", err) t.Fatalf("native error = %v", err)
} }
} }
func TestPreparationCommandRejectsMissingResultAndRetainsEvidence(t *testing.T) {
cause := errors.New("provider failure")
for _, tc := range []struct {
name string
providerErr error
cancel bool
}{
{name: "missing result"}, {name: "provider error", providerErr: cause}, {name: "cancellation", cancel: true},
} {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{PrepareRequest: func(context.Context, PreparationRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.PreparedRun], error) {
if tc.cancel {
cancel()
}
acquired := commandkit.Acquired[*promptkit.PreparedRun]{
Diagnostics: []commandkit.Diagnostic{{Severity: commandkit.SeverityWarning, Write: func(w io.Writer) error { _, err := io.WriteString(w, "provider diagnostic\n"); return err }}},
Explanation: commandkit.Explanation{Message: "provider explanation"},
}
if tc.providerErr != nil {
acquired.Value = &promptkit.PreparedRun{PromptID: "briefing"}
}
return acquired, tc.providerErr
}}))
var output, diagnostics bytes.Buffer
result := app.Execute(ctx, []string{"promptkit", "show", "prepared", "briefing", "--format=json", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnostics})
if result.Kind != commandkit.OutcomeOperational || output.Len() != 0 || !bytes.Contains(diagnostics.Bytes(), []byte("provider diagnostic")) || !bytes.Contains(diagnostics.Bytes(), []byte("provider explanation")) {
t.Fatalf("result=%#v output=%q diagnostics=%q", result, output.String(), diagnostics.String())
}
if tc.providerErr != nil && !errors.Is(result.Cause, tc.providerErr) {
t.Fatalf("provider cause = %v", result.Cause)
}
if tc.cancel && !errors.Is(result.Cause, context.Canceled) {
t.Fatalf("cancellation cause = %v", result.Cause)
}
})
}
}

View File

@@ -35,24 +35,24 @@ var requestFlags = struct {
InputText requestFlagDefinition InputText requestFlagDefinition
InputFile requestFlagDefinition InputFile requestFlagDefinition
}{ }{
Endpoint: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-endpoint", Usage: "Execution endpoint override."}, []string{"endpoint"}}, Endpoint: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-endpoint", Usage: "Use this endpoint."}, []string{"endpoint"}},
Model: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-model", Usage: "Execution model override."}, []string{"model"}}, Model: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-model", Usage: "Use this model."}, []string{"model"}},
Temperature: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-temperature", Usage: "Execution temperature override."}, []string{"temperature"}}, Temperature: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-temperature", Usage: "Use this temperature."}, []string{"temperature"}},
MaxTokens: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-max-tokens", Usage: "Execution maximum tokens override."}, []string{"max-tokens"}}, MaxTokens: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-max-tokens", Usage: "Use this maximum token count."}, []string{"max-tokens"}},
TopP: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-top-p", Usage: "Execution top-p override."}, []string{"top-p"}}, TopP: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-top-p", Usage: "Use this top-p value."}, []string{"top-p"}},
TimeoutSeconds: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-timeout-seconds", Usage: "Execution timeout override in seconds."}, []string{"timeout-seconds"}}, TimeoutSeconds: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-timeout-seconds", Usage: "Set the request timeout in seconds."}, []string{"timeout-seconds"}},
ServiceTier: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-service-tier", Usage: "Execution service tier."}, []string{"service-tier"}}, ServiceTier: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-service-tier", Usage: "Use this service tier."}, []string{"service-tier"}},
ReasoningEffort: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-reasoning-effort", Usage: "Execution reasoning effort override."}, []string{"reasoning-effort"}}, ReasoningEffort: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-reasoning-effort", Usage: "Use this reasoning effort."}, []string{"reasoning-effort"}},
APIKeyEnv: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-api-key-env", Usage: "Execution API key environment variable."}, []string{"api-key-env"}}, APIKeyEnv: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-api-key-env", Usage: "Name the environment variable containing the API key."}, []string{"api-key-env"}},
TransientRetries: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-transient-retries", Usage: "Inference transient retry count."}, []string{"transient-retries"}}, TransientRetries: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-transient-retries", Usage: "Set the retry count for transient inference failures."}, []string{"transient-retries"}},
ExtraParams: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-extra-params", Usage: "Execution provider parameters as a JSON object."}, []string{"extra-params"}}, ExtraParams: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-extra-params", Usage: "Supply provider parameters as a JSON object."}, []string{"extra-params"}},
Prompt: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-prompt", Usage: "PromptKit prompt ID."}, []string{"prompt"}}, Prompt: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-prompt", Usage: "Select a prompt by ID."}, []string{"prompt"}},
Profile: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-profile", Usage: "PromptKit profile ID."}, []string{"profile"}}, Profile: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-profile", Usage: "Select a profile by ID."}, []string{"profile"}},
Backend: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-backend", Usage: "PromptKit backend ID."}, []string{"backend"}}, Backend: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-backend", Usage: "Select a backend by ID."}, []string{"backend"}},
SessionID: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-session-id", Usage: "PromptKit session ID."}, []string{"session-id"}}, SessionID: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-session-id", Usage: "Supply the session ID."}, []string{"session-id"}},
Var: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-var", Usage: "Caller variable assignment KEY=VALUE."}, []string{"var"}}, Var: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-var", Usage: "Supply a prompt variable as KEY=VALUE."}, []string{"var"}},
InputText: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-input-text", Usage: "Literal prompt input assignment NAME=TEXT."}, []string{"input-text", "llm-input", "input"}}, InputText: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-input-text", Usage: "Supply text for a prompt input as NAME=TEXT."}, []string{"input-text", "llm-input", "input"}},
InputFile: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-input-file", Usage: "Prompt input file assignment NAME=PATH."}, []string{"input-file"}}, InputFile: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-input-file", Usage: "Supply a file for a prompt input as NAME=PATH."}, []string{"input-file"}},
} }
// ExecutionInput contains request-scoped execution controls supplied on the CLI. // ExecutionInput contains request-scoped execution controls supplied on the CLI.
@@ -85,6 +85,7 @@ func (f ExecutionFlags[I]) bindings() []requestBinding[I] {
} }
// NewExecutionFlags binds execution controls to a field of the caller's input. // NewExecutionFlags binds execution controls to a field of the caller's input.
// Parsed provider parameters are copied when the binding is applied.
func NewExecutionFlags[I any](destination func(*I) *ExecutionInput) ExecutionFlags[I] { func NewExecutionFlags[I any](destination func(*I) *ExecutionInput) ExecutionFlags[I] {
target := func(input *I) *promptkit.ExecutionTargetOverride { target := func(input *I) *promptkit.ExecutionTargetOverride {
value := destination(input) value := destination(input)
@@ -104,7 +105,36 @@ func NewExecutionFlags[I any](destination func(*I) *ExecutionInput) ExecutionFla
ReasoningEffort: executionFlag(requestFlags.ReasoningEffort.meta, parseDefinitionString, func(i *I, v string) { target(i).ReasoningEffort = &v }), ReasoningEffort: executionFlag(requestFlags.ReasoningEffort.meta, parseDefinitionString, func(i *I, v string) { target(i).ReasoningEffort = &v }),
APIKeyEnv: executionFlag(requestFlags.APIKeyEnv.meta, parseDefinitionString, func(i *I, v string) { target(i).APIKeyEnv = v }), APIKeyEnv: executionFlag(requestFlags.APIKeyEnv.meta, parseDefinitionString, func(i *I, v string) { target(i).APIKeyEnv = v }),
TransientRetries: executionFlag(requestFlags.TransientRetries.meta, strconv.Atoi, func(i *I, v int) { destination(i).TransientRetries = &v }), TransientRetries: executionFlag(requestFlags.TransientRetries.meta, strconv.Atoi, func(i *I, v int) { destination(i).TransientRetries = &v }),
ExtraParams: executionFlag(requestFlags.ExtraParams.meta, parseExtraParams, func(i *I, v map[string]any) { target(i).ExtraParams = v }), ExtraParams: executionFlag(requestFlags.ExtraParams.meta, parseExtraParams, func(i *I, v map[string]any) { target(i).ExtraParams = copyProviderParams(v) }),
}
}
func copyProviderParams(params map[string]any) map[string]any {
if params == nil {
return nil
}
result := make(map[string]any, len(params))
for key, value := range params {
result[key] = copyProviderValue(value)
}
return result
}
func copyProviderValue(value any) any {
switch value := value.(type) {
case map[string]any:
return copyProviderParams(value)
case []any:
if value == nil {
return []any(nil)
}
result := make([]any, len(value))
for i, element := range value {
result[i] = copyProviderValue(element)
}
return result
default:
return value
} }
} }

View File

@@ -76,3 +76,33 @@ func TestValidationForwardsExecutionControls(t *testing.T) {
t.Fatalf("result/request = %#v %#v", result, received) t.Fatalf("result/request = %#v %#v", result, received)
} }
} }
func TestExecutionExtraParamsCapturedApplicationsAreIndependent(t *testing.T) {
type target struct{ execution ckprompt.ExecutionInput }
type outer struct{ captured commandkit.FlagValues[target] }
flag := ckprompt.NewExecutionFlags(func(i *target) *ckprompt.ExecutionInput { return &i.execution }).ExtraParams
captured := commandkit.CaptureFlags([]commandkit.Flag[target]{flag}, func(i *outer) *commandkit.FlagValues[target] { return &i.captured })
command := commandkit.NewCommand(commandkit.CommandSpec[outer]{Name: "run", Flags: captured, Run: func(_ commandkit.Invocation, input outer) commandkit.Outcome {
var first, second target
if err := input.captured.ApplyTo(&first, flag); err != nil {
t.Fatal(err)
}
first.execution.Execution.ExtraParams["nested"].(map[string]any)["array"].([]any)[0].(map[string]any)["number"] = int64(9)
if err := input.captured.ApplySuppliedTo(&second, flag); err != nil {
t.Fatal(err)
}
value := second.execution.Execution.ExtraParams["nested"].(map[string]any)["array"].([]any)[0].(map[string]any)["number"]
if value != int64(1) {
t.Errorf("second value = %#v", value)
}
return commandkit.Success()
}})
app, err := commandkit.New(commandkit.Identity{Name: "app"}, command)
if err != nil {
t.Fatal(err)
}
result := app.Execute(context.Background(), []string{"run", `--llm-extra-params={"nested":{"array":[{"number":1}]}}`}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess {
t.Fatal(result)
}
}

View File

@@ -3,9 +3,11 @@ package ckprompt
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"io" "io"
"reflect" "reflect"
"strings"
"testing" "testing"
"go.maximumdirect.net/ckconfig" "go.maximumdirect.net/ckconfig"
@@ -109,6 +111,79 @@ func TestSmokeAggregateCoordinatesConcreteRequestsAndRetainsErrors(t *testing.T)
} }
} }
type cancelProgressWriter struct {
bytes.Buffer
cancel context.CancelFunc
}
func (writer *cancelProgressWriter) Write(p []byte) (int, error) {
writer.cancel()
return writer.Buffer.Write(p)
}
func TestSmokeAggregateCancellationDuringProgress(t *testing.T) {
providerErr := errors.New("provider failed")
for _, test := range []struct {
name string
err error
cancelAt int
}{
{name: "partial success", cancelAt: 1},
{name: "partial provider failure", err: providerErr, cancelAt: 1},
{name: "last completed test", cancelAt: 4},
} {
t.Run(test.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var kinds []promptkit.SmokeTestKind
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{
SmokeTest: func(_ context.Context, request SmokeRequest, _, _ struct{}) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
kinds = append(kinds, request.Request.Kind)
acquired := commandkit.Acquired[*promptkit.SmokeTestReport]{Value: &promptkit.SmokeTestReport{Kind: request.Request.Kind, Completed: true}}
if len(kinds) == test.cancelAt {
acquired.Diagnostics = []commandkit.Diagnostic{{Severity: commandkit.SeverityProgress, Write: func(w io.Writer) error { _, err := io.WriteString(w, "progress evidence\n"); return err }}}
acquired.Explanation = commandkit.Explanation{Message: "provider explanation"}
return acquired, test.err
}
return acquired, nil
},
}))
var output bytes.Buffer
diagnostics := &cancelProgressWriter{cancel: cancel}
outcome := app.Execute(ctx, []string{"promptkit", "test", "all", "--progress", "--format=json"}, commandkit.Streams{Out: &output, Err: diagnostics})
if len(kinds) != test.cancelAt || !strings.Contains(diagnostics.String(), "progress evidence") {
t.Fatalf("kinds=%v diagnostics=%q", kinds, diagnostics.String())
}
if test.cancelAt < 4 {
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || (test.err != nil && (!errors.Is(outcome.Cause, test.err) || !strings.Contains(diagnostics.String(), "provider explanation"))) {
t.Fatalf("partial outcome=%#v diagnostics=%q", outcome, diagnostics.String())
}
} else if outcome.Kind != commandkit.OutcomeSuccess || outcome.Cause != nil {
t.Fatalf("completed outcome=%#v", outcome)
}
var report struct {
Tests []struct {
Kind string `json:"kind"`
Report *struct {
Completed bool `json:"completed"`
} `json:"report"`
} `json:"tests"`
}
if err := json.Unmarshal(output.Bytes(), &report); err != nil {
t.Fatal(err)
}
if len(report.Tests) != len(kinds) {
t.Fatalf("report=%s", output.String())
}
for i, entry := range report.Tests {
if entry.Kind != string(kinds[i]) || entry.Report == nil || !entry.Report.Completed {
t.Fatalf("report entry=%#v", entry)
}
}
})
}
}
func TestSmokeAggregateCancellationRetainsPartialResults(t *testing.T) { func TestSmokeAggregateCancellationRetainsPartialResults(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel) t.Cleanup(cancel)
@@ -144,3 +219,33 @@ func TestSmokeAggregateCancellationRetainsPartialResults(t *testing.T) {
t.Fatalf("partial aggregate entries = %#v", document.Tests) t.Fatalf("partial aggregate entries = %#v", document.Tests)
} }
} }
func TestSmokeAggregateIsolatesProviderParameters(t *testing.T) {
calls := 0
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{
SmokeTest: func(_ context.Context, request SmokeRequest, _, _ struct{}) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
calls++
params := request.Request.Execution.ExtraParams
value := params["nested"].(map[string]any)["array"].([]any)[0].(map[string]any)
if value["number"] != int64(1) {
t.Errorf("call %d: %#v", calls, value)
}
value["number"] = int64(9)
return commandkit.Acquired[*promptkit.SmokeTestReport]{Value: &promptkit.SmokeTestReport{Kind: request.Request.Kind}}, nil
},
}))
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "all", `--llm-extra-params={"nested":{"array":[{"number":1}]}}`}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || calls != 4 {
t.Fatalf("outcome=%#v calls=%d", outcome, calls)
}
}
func TestCopyProviderParamsPreservesEmptyAndNilValues(t *testing.T) {
if copyProviderParams(nil) != nil {
t.Fatal("nil object became non-nil")
}
copied := copyProviderParams(map[string]any{"emptyObject": map[string]any{}, "nilObject": map[string]any(nil), "emptyArray": []any{}, "nilArray": []any(nil), "null": nil, "decimal": float64(1.5)})
if copied["emptyObject"].(map[string]any) == nil || copied["nilObject"].(map[string]any) != nil || copied["emptyArray"].([]any) == nil || copied["nilArray"].([]any) != nil || copied["null"] != nil || copied["decimal"] != float64(1.5) {
t.Fatalf("copied = %#v", copied)
}
}

View File

@@ -29,11 +29,11 @@ func newSmokeTestCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.Decl
return &(*input).captured return &(*input).captured
}) })
flags = append(flags, flags = append(flags,
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Select the configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) { func(input **smokeCommandInput[I], value commandkit.Supplied[string]) {
(*input).selection.Config = value (*input).selection.Config = value
}), }),
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Select the configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) { func(input **smokeCommandInput[I], value commandkit.Supplied[string]) {
(*input).selection.Overlay = value (*input).selection.Overlay = value
}), }),
@@ -53,14 +53,15 @@ func newSmokeTestCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.Decl
} }
return commandkit.NewCommand(commandkit.CommandSpec[*smokeCommandInput[I]]{ return commandkit.NewCommand(commandkit.CommandSpec[*smokeCommandInput[I]]{
Name: "test", Name: "test",
Summary: "Run one PromptKit smoke test.", Summary: "Run one or all PromptKit smoke tests.",
Description: "Tests use the configured client or probes and may contact a provider. The all option runs the four tests in order. Read the report: a successful command status does not mean every observation is positive.",
NewInput: func() *smokeCommandInput[I] { NewInput: func() *smokeCommandInput[I] {
return &smokeCommandInput[I]{} return &smokeCommandInput[I]{}
}, },
Flags: flags, Flags: flags,
Arguments: []commandkit.Argument[*smokeCommandInput[I]]{ Arguments: []commandkit.Argument[*smokeCommandInput[I]]{
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "kind", Usage: "Native smoke-test kind.", Completion: smokeKindCompletion()}, true, parseDefinitionString, commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "kind", Usage: "Test kind: reachability, authentication, text_inference, structured_output, or all.", Completion: smokeKindCompletion()}, true, parseDefinitionString,
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) { (*input).kind = value.Value }), func(input **smokeCommandInput[I], value commandkit.Supplied[string]) { (*input).kind = value.Value }),
}, },
CheckInput: func(input *smokeCommandInput[I]) error { CheckInput: func(input *smokeCommandInput[I]) error {
@@ -173,6 +174,9 @@ func runSmokeAggregate[I, S any](invocation commandkit.Invocation, suite Suite[I
} }
} }
if err := promptKitContextError(invocation.Context); err != nil { if err := promptKitContextError(invocation.Context); err != nil {
if len(results) < len(kinds) && !errors.Is(errors.Join(operationErrors...), err) {
operationErrors = append(operationErrors, err)
}
break break
} }
} }
@@ -229,6 +233,7 @@ func smokeRequestForKind(seed promptkit.SmokeTestRequest, kind promptkit.SmokeTe
request.Kind = kind request.Kind = kind
if seed.Execution != nil { if seed.Execution != nil {
execution := *seed.Execution execution := *seed.Execution
execution.ExtraParams = copyProviderParams(seed.Execution.ExtraParams)
execution.Temperature = copyFloat64(seed.Execution.Temperature) execution.Temperature = copyFloat64(seed.Execution.Temperature)
execution.MaxTokens = copyInt(seed.Execution.MaxTokens) execution.MaxTokens = copyInt(seed.Execution.MaxTokens)
execution.TopP = copyFloat64(seed.Execution.TopP) execution.TopP = copyFloat64(seed.Execution.TopP)

View File

@@ -31,11 +31,11 @@ func newConcreteRequestSpec[I, S any](suite Suite[I, S], path string, aliases *[
return &(*input).captured return &(*input).captured
}) })
flags = append(flags, flags = append(flags,
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Select the configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
func(input **concreteRequestInput[I], value commandkit.Supplied[string]) { func(input **concreteRequestInput[I], value commandkit.Supplied[string]) {
(*input).request.Selection.Config = value (*input).request.Selection.Config = value
}), }),
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Select the configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
func(input **concreteRequestInput[I], value commandkit.Supplied[string]) { func(input **concreteRequestInput[I], value commandkit.Supplied[string]) {
(*input).request.Selection.Overlay = value (*input).request.Selection.Overlay = value
}), }),
@@ -93,7 +93,7 @@ func newConcreteRequestSpec[I, S any](suite Suite[I, S], path string, aliases *[
func newValidationCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.Declaration) commandkit.Command { func newValidationCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.Declaration) commandkit.Command {
spec := newConcreteRequestSpec(suite, "promptkit requirements validate", aliases) spec := newConcreteRequestSpec(suite, "promptkit requirements validate", aliases)
spec.Name = "validate" spec.Name = "validate"
spec.Summary = "Validate a concrete PromptKit request without executing it." spec.Summary = "Check whether a request can be prepared without calling the model."
spec.Run = func(invocation commandkit.Invocation, input *concreteRequestInput[I]) commandkit.Outcome { spec.Run = func(invocation commandkit.Invocation, input *concreteRequestInput[I]) commandkit.Outcome {
return runRequestValidation(invocation, suite, input) return runRequestValidation(invocation, suite, input)
} }

View File

@@ -182,3 +182,50 @@ func (resolver *Resolver) ResolveWithProvenance(path commandpath.Path) (commandp
origins = nextOrigins origins = nextOrigins
} }
} }
// ProperSourcePrefixes returns distinct nonempty proper prefixes of source paths.
// Returned paths are owned by the caller.
func (resolver *Resolver) ProperSourcePrefixes() []commandpath.Path {
if resolver == nil {
return nil
}
var paths []commandpath.Path
for index := 1; index < len(resolver.trie); index++ {
node := &resolver.trie[index]
if len(node.children) != 0 {
paths = append(paths, node.path.Clone())
}
}
return paths
}
// Descendants returns owned mappings whose sources are strictly below prefix.
func (resolver *Resolver) Descendants(prefix commandpath.Path) []Mapping {
if resolver == nil {
return nil
}
index := 0
for _, component := range prefix {
next, ok := resolver.trie[index].children[component]
if !ok {
return nil
}
index = next
}
var mappings []Mapping
var visit func(int)
visit = func(index int) {
node := &resolver.trie[index]
if node.mapping >= 0 {
mapping := resolver.mappings[node.mapping]
mappings = append(mappings, Mapping{Source: mapping.Source.Clone(), Target: mapping.Target.Clone()})
}
for _, child := range node.children {
visit(child)
}
}
for _, child := range resolver.trie[index].children {
visit(child)
}
return mappings
}

View File

@@ -314,3 +314,34 @@ func (machine *Machine) classify(token string) symbol {
} }
return symbol{kind: otherSymbol} return symbol{kind: otherSymbol}
} }
func TestResolverSourcePrefixesAndDescendantsAreOwned(t *testing.T) {
resolver, err := NewResolver([]Mapping{
{Source: commandpath.Path{"a"}, Target: commandpath.Path{"root"}},
{Source: commandpath.Path{"a", "b", "one"}, Target: commandpath.Path{"root", "one"}},
{Source: commandpath.Path{"a", "b", "two"}, Target: commandpath.Path{"root", "two"}},
{Source: commandpath.Path{"other"}, Target: commandpath.Path{"root"}},
})
if err != nil {
t.Fatal(err)
}
prefixes := resolver.ProperSourcePrefixes()
if len(prefixes) != 2 || !prefixes[0].Equal(commandpath.Path{"a"}) || !prefixes[1].Equal(commandpath.Path{"a", "b"}) {
t.Fatalf("prefixes = %#v", prefixes)
}
prefixes[0][0] = "mutated"
descendants := resolver.Descendants(commandpath.Path{"a"})
if len(descendants) != 2 {
t.Fatalf("descendants = %#v", descendants)
}
for _, mapping := range descendants {
if len(mapping.Source) != 3 || mapping.Source[0] != "a" {
t.Fatalf("mapping = %#v", mapping)
}
}
descendants[0].Source[0] = "mutated"
descendants[0].Target[0] = "mutated"
if !resolver.Resolve(commandpath.Path{"a", "b", "one"}).Equal(commandpath.Path{"root", "one"}) || len(resolver.Descendants(commandpath.Path{"missing"})) != 0 {
t.Fatal("resolver was mutated or missing branch returned mappings")
}
}

View File

@@ -2,7 +2,6 @@ package cobra
import ( import (
"fmt" "fmt"
"strings"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta" commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
) )
@@ -20,78 +19,43 @@ func interpretFlagTokens(argv []string, flags []commandmeta.Flag, aliases []comm
interpreted = append(interpreted, argv[index:]...) interpreted = append(interpreted, argv[index:]...)
break break
} }
if kind == commandmeta.LongFlagToken { if kind == commandmeta.LongFlagToken || kind == commandmeta.ShortFlagToken {
original, value, attached := commandmeta.SplitLongFlag(token) offset := 1
flag, ok := lookup.Find(original)
if !ok {
interpreted = append(interpreted, token)
return interpreted, spellings, occurrences, nil
}
spellings[flag.Name] = append(spellings[flag.Name], original)
if attached {
interpreted = append(interpreted, "--"+flag.Name+"="+value)
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
index++
continue
}
if !flag.Boolean && index+1 < len(argv) {
value := argv[index+1]
interpreted = append(interpreted, "--"+flag.Name+"="+value)
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
index += 2
continue
}
if !flag.Boolean {
return interpreted, spellings, occurrences, &commandmeta.Failure{Message: fmt.Sprintf("flag needs an argument: %s", original)}
}
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: "true"})
interpreted = append(interpreted, "--"+flag.Name)
index++
continue
}
if kind == commandmeta.ShortFlagToken {
consumedNext := false consumedNext := false
for offset := 1; offset < len(token); { for {
original := "-" + token[offset:offset+1] fact := commandmeta.ConsumeFlag(lookup, token, offset)
flag, ok := lookup.Find(original) if !fact.Found {
if !ok { if kind == commandmeta.ShortFlagToken {
interpreted = append(interpreted, "-"+token[offset:]) interpreted = append(interpreted, "-"+token[offset:])
} else {
interpreted = append(interpreted, token)
}
return interpreted, spellings, occurrences, nil return interpreted, spellings, occurrences, nil
} }
flag, original := fact.Flag, fact.Spelling
spellings[flag.Name] = append(spellings[flag.Name], original) spellings[flag.Name] = append(spellings[flag.Name], original)
suffix := token[offset+1:] value := fact.Value
if flag.Boolean { if fact.NeedsNext {
if strings.HasPrefix(suffix, "=") { if index+1 >= len(argv) {
value := suffix[1:] return interpreted, spellings, occurrences, &commandmeta.Failure{Message: fmt.Sprintf("flag needs an argument: %s", original)}
interpreted = append(interpreted, "--"+flag.Name+"="+value)
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
break
} }
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: "true"}) value = argv[index+1]
interpreted = append(interpreted, "--"+flag.Name)
offset++
continue
}
if suffix != "" {
value := suffix
if strings.HasPrefix(value, "=") {
value = value[1:]
}
interpreted = append(interpreted, "--"+flag.Name+"="+value)
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
break
}
if index+1 < len(argv) {
value := argv[index+1]
interpreted = append(interpreted, "--"+flag.Name+"="+value)
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
index += 2
consumedNext = true consumedNext = true
}
if !fact.Attached && !fact.NeedsNext {
value = "true"
interpreted = append(interpreted, "--"+flag.Name)
} else {
interpreted = append(interpreted, "--"+flag.Name+"="+value)
}
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
if fact.NextOffset == 0 {
break break
} }
return interpreted, spellings, occurrences, &commandmeta.Failure{Message: fmt.Sprintf("flag needs an argument: %s", original)} offset = fact.NextOffset
} }
if !consumedNext { index++
if consumedNext {
index++ index++
} }
continue continue

View File

@@ -4,7 +4,6 @@ package cobra
import ( import (
"bytes" "bytes"
"context"
"fmt" "fmt"
"io" "io"
"strconv" "strconv"
@@ -35,8 +34,7 @@ func Prepare(root *commandmeta.Command, options commandmeta.ParseOptions) *Sessi
session.metadata = root session.metadata = root
session.flagAliases = append([]commandmeta.FlagAlias(nil), options.FlagAliases...) session.flagAliases = append([]commandmeta.FlagAlias(nil), options.FlagAliases...)
session.flagAliasesForPath = options.FlagAliasesForPath session.flagAliasesForPath = options.FlagAliasesForPath
options = normalizeOptions(options) session.root, session.declarations = buildTree(root)
session.root, session.declarations = buildTree(root, options)
return session return session
} }
@@ -289,23 +287,7 @@ func Parse(root *commandmeta.Command, argv []string, options commandmeta.ParseOp
return Prepare(root, options).Parse(argv) return Prepare(root, options).Parse(argv)
} }
func normalizeOptions(options commandmeta.ParseOptions) commandmeta.ParseOptions { func buildTree(root *commandmeta.Command) (*spfcobra.Command, map[*spfcobra.Command]*commandmeta.Command) {
if options.Context == nil {
options.Context = context.Background()
}
if options.In == nil {
options.In = strings.NewReader("")
}
if options.Out == nil {
options.Out = io.Discard
}
if options.Err == nil {
options.Err = io.Discard
}
return options
}
func buildTree(root *commandmeta.Command, options commandmeta.ParseOptions) (*spfcobra.Command, map[*spfcobra.Command]*commandmeta.Command) {
nodes := make(map[*spfcobra.Command]*commandmeta.Command) nodes := make(map[*spfcobra.Command]*commandmeta.Command)
var build func(*commandmeta.Command) *spfcobra.Command var build func(*commandmeta.Command) *spfcobra.Command
build = func(declaration *commandmeta.Command) *spfcobra.Command { build = func(declaration *commandmeta.Command) *spfcobra.Command {
@@ -321,10 +303,8 @@ func buildTree(root *commandmeta.Command, options commandmeta.ParseOptions) (*sp
if declaration.Runnable { if declaration.Runnable {
backend.Run = func(*spfcobra.Command, []string) {} backend.Run = func(*spfcobra.Command, []string) {}
} }
backend.SetContext(options.Context) backend.SetOut(io.Discard)
backend.SetIn(options.In) backend.SetErr(io.Discard)
backend.SetOut(options.Out)
backend.SetErr(options.Err)
backend.SetHelpFunc(func(*spfcobra.Command, []string) {}) backend.SetHelpFunc(func(*spfcobra.Command, []string) {})
backend.SetFlagErrorFunc(func(_ *spfcobra.Command, err error) error { return err }) backend.SetFlagErrorFunc(func(_ *spfcobra.Command, err error) error { return err })
backend.SetUsageTemplate(usageTemplate) backend.SetUsageTemplate(usageTemplate)

View File

@@ -1,8 +1,6 @@
package cobra package cobra
import ( import (
"bytes"
"context"
"fmt" "fmt"
"io" "io"
"reflect" "reflect"
@@ -182,7 +180,7 @@ func TestParseReturnsNeutralFailuresAndFreshState(t *testing.T) {
tree := &Command{Name: "app", Children: []*Command{ tree := &Command{Name: "app", Children: []*Command{
{Name: "run", Runnable: true, Flags: []Flag{{Name: "value"}}}, {Name: "run", Runnable: true, Flags: []Flag{{Name: "value"}}},
}} }}
options := ParseOptions{Context: context.Background(), In: strings.NewReader("unused"), Out: io.Discard, Err: io.Discard} options := ParseOptions{}
first := Parse(tree, []string{"run", "--value=old"}, options) first := Parse(tree, []string{"run", "--value=old"}, options)
second := Parse(tree, []string{"run"}, options) second := Parse(tree, []string{"run"}, options)
if first.Failure != nil || second.Failure != nil { if first.Failure != nil || second.Failure != nil {
@@ -247,8 +245,7 @@ func TestSelectedPathsResolveThroughBackendParents(t *testing.T) {
func TestSessionReusesTreeForParseHelpAndUsageWithFreshState(t *testing.T) { func TestSessionReusesTreeForParseHelpAndUsageWithFreshState(t *testing.T) {
tree := &Command{Name: "app", Children: []*Command{{Name: "run", Runnable: true, Flags: []Flag{{Name: "value"}}}}} tree := &Command{Name: "app", Children: []*Command{{Name: "run", Runnable: true, Flags: []Flag{{Name: "value"}}}}}
var output, errors bytes.Buffer session := Prepare(tree, ParseOptions{})
session := Prepare(tree, ParseOptions{Context: context.Background(), Out: &output, Err: &errors})
first := session.Parse([]string{"run", "--value=old"}) first := session.Parse([]string{"run", "--value=old"})
if first.Failure != nil || !reflect.DeepEqual(first.FlagValues, [][]string{{"old"}}) { if first.Failure != nil || !reflect.DeepEqual(first.FlagValues, [][]string{{"old"}}) {
t.Fatalf("first session parse = %#v", first) t.Fatalf("first session parse = %#v", first)
@@ -261,19 +258,15 @@ func TestSessionReusesTreeForParseHelpAndUsageWithFreshState(t *testing.T) {
if failure != nil || !strings.Contains(line, "run") { if failure != nil || !strings.Contains(line, "run") {
t.Fatalf("session usage = %q, %v", line, failure) t.Fatalf("session usage = %q, %v", line, failure)
} }
if output.Len() != 0 || errors.Len() != 0 { if session.root.OutOrStdout() != io.Discard || session.root.ErrOrStderr() != io.Discard {
t.Fatalf("help escaped session delivery control: out=%q err=%q", output.String(), errors.String()) t.Fatal("backend destinations are not discarded")
} }
var secondOutput, secondErrors bytes.Buffer second := Prepare(tree, ParseOptions{})
second := Prepare(tree, ParseOptions{Context: context.Background(), Out: &secondOutput, Err: &secondErrors})
secondResult := second.Parse([]string{"run"}) secondResult := second.Parse([]string{"run"})
if secondResult.Failure != nil || !reflect.DeepEqual(secondResult.FlagValues, [][]string{nil}) { if secondResult.Failure != nil || !reflect.DeepEqual(secondResult.FlagValues, [][]string{nil}) {
t.Fatalf("fresh session state = %#v", secondResult) t.Fatalf("fresh session state = %#v", secondResult)
} }
if output.Len() != 0 || errors.Len() != 0 || secondOutput.Len() != 0 || secondErrors.Len() != 0 {
t.Fatal("prepared session streams were unexpectedly used")
}
} }
func TestMissingSessionTreeReturnsNeutralFailures(t *testing.T) { func TestMissingSessionTreeReturnsNeutralFailures(t *testing.T) {

View File

@@ -1,9 +1,7 @@
package cobra package cobra
import ( import (
"context"
"fmt" "fmt"
"io"
"testing" "testing"
) )
@@ -11,7 +9,7 @@ func BenchmarkPerformanceParse(b *testing.B) {
wide := performanceFlatTree(128) wide := performanceFlatTree(128)
wideArgs := []string{"wide127"} wideArgs := []string{"wide127"}
deep, deepArgs := performanceNestedTree(24) deep, deepArgs := performanceNestedTree(24)
options := ParseOptions{Context: context.Background(), Out: io.Discard, Err: io.Discard} options := ParseOptions{}
for _, benchmark := range []struct { for _, benchmark := range []struct {
name string name string
@@ -41,7 +39,7 @@ func BenchmarkPerformanceParse(b *testing.B) {
func BenchmarkPerformanceSessionParseAndHelp(b *testing.B) { func BenchmarkPerformanceSessionParseAndHelp(b *testing.B) {
root := performanceFlatTree(8) root := performanceFlatTree(8)
args := []string{"wide007"} args := []string{"wide007"}
session := Prepare(root, ParseOptions{Context: context.Background(), Out: io.Discard, Err: io.Discard}) session := Prepare(root, ParseOptions{})
result := session.Parse(args) result := session.Parse(args)
if result.Failure != nil { if result.Failure != nil {
b.Fatalf("fixture parse failed: %s", result.Failure.Message) b.Fatalf("fixture parse failed: %s", result.Failure.Message)

View File

@@ -65,3 +65,47 @@ func (lookup FlagLookup) Find(spelling string) (Flag, bool) {
} }
return flag, ok return flag, ok
} }
// FlagConsumption describes one flag within an option token. NextOffset is zero
// when the token is exhausted; otherwise it identifies the next short spelling.
type FlagConsumption struct {
Flag Flag
Spelling string
Found bool
Value string
Attached bool
NeedsNext bool
NextOffset int
}
// ConsumeFlag reads a long option or a short option at offset in a cluster.
func ConsumeFlag(lookup FlagLookup, token string, offset int) FlagConsumption {
var result FlagConsumption
if ClassifyFlagToken(token) == LongFlagToken {
result.Spelling, result.Value, result.Attached = SplitLongFlag(token)
result.Flag, result.Found = lookup.Find(result.Spelling)
result.NeedsNext = result.Found && !result.Flag.Boolean && !result.Attached
return result
}
result.Spelling = "-" + token[offset:offset+1]
result.Flag, result.Found = lookup.Find(result.Spelling)
if !result.Found {
return result
}
suffix := token[offset+1:]
if result.Flag.Boolean {
if strings.HasPrefix(suffix, "=") {
result.Attached, result.Value = true, suffix[1:]
} else if suffix != "" {
result.NextOffset = offset + 1
}
return result
}
if suffix == "" {
result.NeedsNext = true
return result
}
result.Attached = true
result.Value = strings.TrimPrefix(suffix, "=")
return result
}

View File

@@ -2,11 +2,6 @@
// its command-line backend. // its command-line backend.
package commandmeta package commandmeta
import (
"context"
"io"
)
// Key identifies one command inside one routing snapshot. // Key identifies one command inside one routing snapshot.
type Key uint64 type Key uint64
@@ -75,7 +70,7 @@ type CompletionResult struct {
} }
var reportingFlags = [...]Flag{ var reportingFlags = [...]Flag{
{Name: "quiet", Usage: "suppress substantive output and optional messages; keep failures visible", Default: "false", Boolean: true}, {Name: "quiet", Usage: "hide command output and optional messages; keep failures visible", Default: "false", Boolean: true},
{Name: "verbose", Usage: "show warning and informational messages", Default: "false", Boolean: true}, {Name: "verbose", Usage: "show warning and informational messages", Default: "false", Boolean: true},
{Name: "debug", Usage: "show debugging messages", Default: "false", Boolean: true}, {Name: "debug", Usage: "show debugging messages", Default: "false", Boolean: true},
{Name: "progress", Usage: "show progress messages", Default: "false", Boolean: true}, {Name: "progress", Usage: "show progress messages", Default: "false", Boolean: true},
@@ -181,14 +176,10 @@ type Result struct {
Failure *Failure Failure *Failure
} }
// ParseOptions makes parser context and streams explicit. // ParseOptions selects flag aliases for parser contexts.
type ParseOptions struct { type ParseOptions struct {
// FlagAliasesForPath selects aliases for the actual parser context when // FlagAliasesForPath selects aliases for the actual parser context when
// routing has not already selected one. It overrides FlagAliases when set. // routing has not already selected one. It overrides FlagAliases when set.
FlagAliasesForPath func([]string) []FlagAlias FlagAliasesForPath func([]string) []FlagAlias
Context context.Context
In io.Reader
Out io.Writer
Err io.Writer
FlagAliases []FlagAlias FlagAliases []FlagAlias
} }

View File

@@ -150,3 +150,65 @@ func TestOccurrenceRequiredAndAliasedConversion(t *testing.T) {
t.Fatalf("help: %#v", help) t.Fatalf("help: %#v", help)
} }
} }
func TestNestedCapturedOccurrencesMergeSelections(t *testing.T) {
calls := 0
a := occurrenceFor("alpha", false, &calls)
b := RenameFlag(occurrenceFor("beta", false, &calls), "beta")
c := RenameFlag(occurrenceFor("gamma", false, &calls), "gamma")
type middle struct{ values FlagValues[occurrenceInput] }
type outer struct{ values FlagValues[middle] }
inner := CaptureFlags([]Flag[occurrenceInput]{a, b, c}, func(m *middle) *FlagValues[occurrenceInput] { return &m.values })
flags := CaptureFlags(inner, func(o *outer) *FlagValues[middle] { return &o.values })
var captured FlagValues[occurrenceInput]
app, err := New(Identity{Name: "Example"}, NewCommand(CommandSpec[outer]{Name: "run", Flags: flags, Run: func(_ Invocation, input outer) Outcome {
var m middle
if err := input.values.ApplyTo(&m, inner...); err != nil {
return Failure("middle replay failed").WithCause(err)
}
captured = m.values
return Success()
}}))
if err != nil {
t.Fatal(err)
}
for _, args := range [][]string{
{"run", "--gamma=c1", "--alpha=a1", "--beta=b1", "--alpha=a2", "--gamma=c2", "--beta=b2"},
{"run"},
} {
result := app.Execute(t.Context(), args, Streams{})
if result.Status != 0 {
t.Fatalf("capture=%#v", result)
}
for _, tc := range []struct {
name string
flags []Flag[occurrenceInput]
want []string
}{
{"all reversed", []Flag[occurrenceInput]{c, b, a}, []string{"gamma=c1", "alpha=a1", "beta=b1", "alpha=a2", "gamma=c2", "beta=b2"}},
{"subset", []Flag[occurrenceInput]{a, c}, []string{"gamma=c1", "alpha=a1", "alpha=a2", "gamma=c2"}},
{"single", []Flag[occurrenceInput]{b}, []string{"beta=b1", "beta=b2"}},
{"duplicate handle", []Flag[occurrenceInput]{b, a, b}, []string{"alpha=a1", "beta=b1", "beta=b1", "alpha=a2", "beta=b2", "beta=b2"}},
{"empty", nil, nil},
} {
for _, suppliedOnly := range []bool{false, true} {
var got occurrenceInput
if suppliedOnly {
err = captured.ApplySuppliedTo(&got, tc.flags...)
} else {
err = captured.ApplyTo(&got, tc.flags...)
}
want := tc.want
if len(args) == 1 {
want = nil
}
if err != nil || !reflect.DeepEqual(got.events, want) {
t.Fatalf("%s suppliedOnly=%t args=%v events=%v want=%v err=%v", tc.name, suppliedOnly, args, got.events, want, err)
}
}
}
}
if calls != 6 {
t.Fatalf("replay reconverted captured input: %d parses", calls)
}
}

View File

@@ -5,19 +5,10 @@ import (
"sort" "sort"
"strconv" "strconv"
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta" commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
"go.maximumdirect.net/commandkit/internal/commandpath" "go.maximumdirect.net/commandkit/internal/commandpath"
) )
func (a *App) parse(args []string, options commandmeta.ParseOptions) commandmeta.Result {
if a == nil {
return commandmeta.Result{Failure: &commandmeta.Failure{Message: "cannot parse commands on a nil app"}}
}
result := internalcobra.Parse(a.routeSnapshot().metadata, args, options)
return reduceReportingOccurrences(result)
}
func reduceReportingOccurrences(result commandmeta.Result) commandmeta.Result { func reduceReportingOccurrences(result commandmeta.Result) commandmeta.Result {
selection := commandmeta.ReportingSelection{} selection := commandmeta.ReportingSelection{}
for index, occurrence := range result.FlagOccurrences { for index, occurrence := range result.FlagOccurrences {

View File

@@ -34,15 +34,14 @@ func TestAppParseUsesRegisteredBindingsWithTheAdapter(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
options := commandmeta.ParseOptions{Context: context.Background()} result := parseReportingArgs(app, []string{"config", "show", "one", "--scope", "app"})
result := app.parse([]string{"config", "show", "one", "--scope", "app"}, options)
if result.Failure != nil { if result.Failure != nil {
t.Fatalf("parse failed: %s", result.Failure.Message) t.Fatalf("parse failed: %s", result.Failure.Message)
} }
if !reflect.DeepEqual(result.Path, []string{"config", "show"}) || !reflect.DeepEqual(result.FlagValues, [][]string{{"app"}}) || !reflect.DeepEqual(result.Operands, []string{"one"}) { if !reflect.DeepEqual(result.Path, []string{"config", "show"}) || !reflect.DeepEqual(result.FlagValues, [][]string{{"app"}}) || !reflect.DeepEqual(result.Operands, []string{"one"}) {
t.Fatalf("parse result = %#v", result) t.Fatalf("parse result = %#v", result)
} }
result = app.parse([]string{"config", "--scope", "app", "show"}, options) result = parseReportingArgs(app, []string{"config", "--scope", "app", "show"})
if result.Failure == nil { if result.Failure == nil {
t.Fatal("accepted a child flag before the complete command path") t.Fatal("accepted a child flag before the complete command path")
} }
@@ -55,7 +54,7 @@ func TestRoutingSnapshotPairsParsedKeyWithItsRunner(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
snapshot := app.routeSnapshot() snapshot := app.routeSnapshot()
session := internalcobra.Prepare(snapshot.metadata, commandmeta.ParseOptions{Context: context.Background()}) session := internalcobra.Prepare(snapshot.metadata, commandmeta.ParseOptions{})
parsed := session.Parse([]string{"run"}) parsed := session.Parse([]string{"run"})
if parsed.Failure != nil { if parsed.Failure != nil {
t.Fatalf("parse failed: %s", parsed.Failure.Message) t.Fatalf("parse failed: %s", parsed.Failure.Message)

View File

@@ -32,8 +32,10 @@ func normalizeReport(value any) (any, error) {
} }
// DecodeYAMLReport decodes one YAML document from safe inspection output into // DecodeYAMLReport decodes one YAML document from safe inspection output into
// JSON-compatible values. Unsupported YAML meanings return an error; the result // JSON-compatible values. Multiple documents, unsupported tags and non-string
// is not a lossless representation of the input document. // mapping keys return an error. The result does not preserve formatting, comments
// or framing. Scalar types follow resolved YAML tags; numeric-looking strings
// remain strings.
func DecodeYAMLReport(reader io.Reader) (any, error) { func DecodeYAMLReport(reader io.Reader) (any, error) {
decoder := yaml.NewDecoder(reader) decoder := yaml.NewDecoder(reader)
var doc yaml.Node var doc yaml.Node
@@ -104,18 +106,6 @@ func yamlReportValue(node *yaml.Node, visiting map[*yaml.Node]bool) (any, error)
case yaml.ScalarNode: case yaml.ScalarNode:
switch node.ShortTag() { switch node.ShortTag() {
case "!!str": case "!!str":
// The YAML resolver falls back to a string when a numeric scalar is
// outside its machine-number range. Preserve explicit and quoted
// strings, but do not silently turn a plain numeric scalar into text.
if node.Style == 0 {
plain := strings.ReplaceAll(node.Value, "_", "")
if yamlInteger.MatchString(plain) {
return yamlReportNumber(node.Value, "!!int")
}
if yamlNumericPlain.MatchString(plain) {
return yamlReportNumber(node.Value, "!!float")
}
}
return node.Value, nil return node.Value, nil
case "!!null": case "!!null":
return nil, nil return nil, nil
@@ -188,7 +178,14 @@ func reportYAMLNode(value any) (*yaml.Node, error) {
if strings.ContainsAny(string(v), ".eE") { if strings.ContainsAny(string(v), ".eE") {
tag = "!!float" tag = "!!float"
} }
return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: string(v)}, nil node := &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: string(v)}
// The YAML emitter can omit numeric tags even when its resolver would
// classify an overflowing token as a string. Retain the numeric evidence
// explicitly; ParseFloat selects only the style, never the stored value.
if _, err := strconv.ParseFloat(string(v), 64); err != nil {
node.Style = yaml.TaggedStyle
}
return node, nil
case []any: case []any:
node := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} node := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
for _, item := range v { for _, item := range v {

View File

@@ -39,7 +39,7 @@ func TestNormalizeReport(t *testing.T) {
} }
func TestYAMLReportValues(t *testing.T) { func TestYAMLReportValues(t *testing.T) {
input := `large: 123456789012345678901234567890 input := `large: !!int 123456789012345678901234567890
hex: 0xff hex: 0xff
fraction: .125 fraction: .125
text: "false" text: "false"
@@ -94,8 +94,8 @@ func TestDecodeYAMLReportRejectsUnsupportedValues(t *testing.T) {
func TestYAMLReportLargeNumericScalars(t *testing.T) { func TestYAMLReportLargeNumericScalars(t *testing.T) {
for _, tc := range []struct{ input, want string }{ for _, tc := range []struct{ input, want string }{
{"1e9999", "1e9999"}, {"0x100000000000000000000", "1208925819614629174706176"}, {"!!float 1e9999", "1e9999"}, {"!!int 0x100000000000000000000", "1208925819614629174706176"},
{"+01.2500e+9999", "1.2500e+9999"}, {"!!float +01.2500e+9999", "1.2500e+9999"},
} { } {
t.Run(tc.input, func(t *testing.T) { t.Run(tc.input, func(t *testing.T) {
got, err := DecodeYAMLReport(strings.NewReader(tc.input)) got, err := DecodeYAMLReport(strings.NewReader(tc.input))
@@ -104,7 +104,7 @@ func TestYAMLReportLargeNumericScalars(t *testing.T) {
} }
}) })
} }
for _, input := range []string{`"1e9999"`, `!!str 1e9999`, `.`, `hello`} { for _, input := range []string{`1e9999`, `"1e9999"`, `!!str 1e9999`, `.`, `hello`} {
got, err := DecodeYAMLReport(strings.NewReader(input)) got, err := DecodeYAMLReport(strings.NewReader(input))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -133,3 +133,37 @@ func TestReportYAMLNodePreservesNumericLookingStrings(t *testing.T) {
t.Fatalf("%s: got %#v, want %#v", data, got, value) t.Fatalf("%s: got %#v, want %#v", data, got, value)
} }
} }
func TestDecodeYAMLReportPreservesPlainNativeStrings(t *testing.T) {
// Native ConfigKit safe output can contain an unquoted numeric-looking string
// when the YAML resolver considers the token outside the numeric range.
value, err := DecodeYAMLReport(strings.NewReader(`1e400: 1e400
quoted: "1e400"
explicit: !!str 1e400
number: !!float 1e400
`))
if err != nil {
t.Fatal(err)
}
want := map[string]any{"1e400": "1e400", "quoted": "1e400", "explicit": "1e400", "number": json.Number("1e400")}
if !reflect.DeepEqual(value, want) {
t.Fatalf("decoded=%#v want=%#v", value, want)
}
for _, format := range []Format{FormatJSON, FormatYAML} {
var output bytes.Buffer
if err := RenderReport(&output, Report{Value: value}, format); err != nil {
t.Fatal(err)
}
var got any
if format == FormatJSON {
decoder := json.NewDecoder(&output)
decoder.UseNumber()
err = decoder.Decode(&got)
} else {
got, err = DecodeYAMLReport(&output)
}
if err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("format %v: got=%#v err=%v", format, got, err)
}
}
}

View File

@@ -36,15 +36,16 @@ const (
) )
type reportingState struct { type reportingState struct {
mu sync.Mutex mu sync.Mutex
output destinationState output destinationState
diagnostics destinationState diagnostics destinationState
closed bool closed bool
mode reportingMode deferredClosed bool
format Format mode reportingMode
progress bool format Format
progressSet bool progress bool
deferred []deferredMessage progressSet bool
deferred []deferredMessage
} }
type destinationState struct { type destinationState struct {
@@ -78,8 +79,8 @@ type Reporter struct {
} }
// DeferredReporter queues messages for delivery during invocation finalization. // DeferredReporter queues messages for delivery during invocation finalization.
// Copies share the same invocation reporting state. Submissions made after // Copies share the same invocation reporting state. Submissions return
// finalization return ErrReporterClosed. // ErrReporterClosed once finalization begins, before the accepted batch is delivered.
type DeferredReporter struct { type DeferredReporter struct {
state *reportingState state *reportingState
} }
@@ -222,7 +223,8 @@ func (r *Reporter) Progress(message string, details ...Detail) error {
// AtEnd returns a view for messages delivered after the handler returns and // AtEnd returns a view for messages delivered after the handler returns and
// before terminal explanation delivery. The deferred view shares this // before terminal explanation delivery. The deferred view shares this
// invocation's state. // invocation's state. Acceptance ends when finalization begins, before queued
// messages are delivered.
func (r *Reporter) AtEnd() DeferredReporter { func (r *Reporter) AtEnd() DeferredReporter {
if r == nil { if r == nil {
return DeferredReporter{} return DeferredReporter{}
@@ -258,7 +260,7 @@ func (r DeferredReporter) enqueue(category messageCategory, message string, deta
state := r.state state := r.state
state.mu.Lock() state.mu.Lock()
defer state.mu.Unlock() defer state.mu.Unlock()
if state.closed { if state.closed || state.deferredClosed {
return ErrReporterClosed return ErrReporterClosed
} }
if !hasDetails(details) { if !hasDetails(details) {
@@ -290,6 +292,7 @@ func (r *Reporter) finalize(outcome Outcome, completion func(*Reporter)) Outcome
state.mu.Unlock() state.mu.Unlock()
return normalizeOutcome(outcome) return normalizeOutcome(outcome)
} }
state.deferredClosed = true
deferred := state.deferred deferred := state.deferred
state.deferred = nil state.deferred = nil
state.mu.Unlock() state.mu.Unlock()
@@ -324,6 +327,8 @@ func (r *Reporter) finalize(outcome Outcome, completion func(*Reporter)) Outcome
state.closed = true state.closed = true
state.deferred = nil state.deferred = nil
outcome.Presentation = state.presentationLocked() outcome.Presentation = state.presentationLocked()
state.output.writer = nil
state.diagnostics.writer = nil
state.mu.Unlock() state.mu.Unlock()
return normalizeOutcome(outcome) return normalizeOutcome(outcome)
} }
@@ -400,21 +405,7 @@ func renderTerminalLocked(state *reportingState, explanation Explanation) error
writeErr = writer.writeError writeErr = writer.writeError
} }
if writeErr == nil { if writeErr == nil {
for _, detail := range explanation.Details { renderErr, writeErr = renderDetailsLocked(state, explanation.Details, true)
if detail == nil {
continue
}
writer := &coordinatedWriter{state: state, destination: destinationDiagnostics}
detailErr := detail(writer)
detailWriteErr := writer.writeError
detailRenderErr := recordRendererErrorLocked(state, destinationDiagnostics, detailErr, detailWriteErr)
if renderErr == nil {
renderErr = detailRenderErr
}
if writeErr == nil {
writeErr = detailWriteErr
}
}
} }
if renderErr == nil && writeErr == nil { if renderErr == nil && writeErr == nil {
destination.delivered = true destination.delivered = true
@@ -464,6 +455,14 @@ func (state *reportingState) submitMessageLocked(category messageCategory, messa
if visible && writeErr != nil { if visible && writeErr != nil {
return writeErr return writeErr
} }
renderErr, writeErr = renderDetailsLocked(state, details, visible)
if visible && renderErr == nil && writeErr == nil {
destination.delivered = true
}
return combineOperationErrors(renderErr, writeErr)
}
func renderDetailsLocked(state *reportingState, details []Detail, visible bool) (renderErr, writeErr error) {
for _, detail := range details { for _, detail := range details {
if detail == nil { if detail == nil {
continue continue
@@ -484,10 +483,7 @@ func (state *reportingState) submitMessageLocked(category messageCategory, messa
writeErr = detailWriteErr writeErr = detailWriteErr
} }
} }
if visible && renderErr == nil && writeErr == nil { return renderErr, writeErr
destination.delivered = true
}
return combineOperationErrors(renderErr, writeErr)
} }
func (state *reportingState) visible(category messageCategory) bool { func (state *reportingState) visible(category messageCategory) bool {
@@ -643,12 +639,6 @@ func (state *reportingState) presentationLocked() PresentationResult {
} }
} }
func (state *reportingState) presentation() PresentationResult {
state.mu.Lock()
defer state.mu.Unlock()
return state.presentationLocked()
}
func (state *reportingState) deliveryResultLocked(destination reportingDestination) DeliveryResult { func (state *reportingState) deliveryResultLocked(destination reportingDestination) DeliveryResult {
current := state.destination(destination) current := state.destination(destination)
result := DeliveryResult{RenderError: current.renderError, WriteError: current.writeError} result := DeliveryResult{RenderError: current.renderError, WriteError: current.writeError}
@@ -664,23 +654,3 @@ func (state *reportingState) deliveryResultLocked(destination reportingDestinati
} }
return result return result
} }
func (r *Reporter) setVisibility(mode reportingMode, progress bool, progressSet bool) {
if r == nil || r.state == nil {
return
}
r.state.mu.Lock()
r.state.mode = mode
r.state.progress = progress
r.state.progressSet = progressSet
r.state.mu.Unlock()
}
func (r *Reporter) close() {
if r == nil || r.state == nil {
return
}
r.state.mu.Lock()
r.state.closed = true
r.state.mu.Unlock()
}

View File

@@ -13,7 +13,7 @@ func TestHiddenSimpleDeferredMessagesDoNotAccumulate(t *testing.T) {
for _, mode := range []reportingMode{modeOrdinary, modeQuiet} { for _, mode := range []reportingMode{modeOrdinary, modeQuiet} {
t.Run(fmt.Sprint(mode), func(t *testing.T) { t.Run(fmt.Sprint(mode), func(t *testing.T) {
reporter := newReporter(Streams{}) reporter := newReporter(Streams{})
reporter.setVisibility(mode, false, false) setReporterVisibility(reporter, mode, false, false)
for index := 0; index < 256; index++ { for index := 0; index < 256; index++ {
if err := reporter.AtEnd().Debug(fmt.Sprintf("hidden %d", index), nil); err != nil { if err := reporter.AtEnd().Debug(fmt.Sprintf("hidden %d", index), nil); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -45,7 +45,7 @@ func TestHiddenSimpleDeferredMessagesDoNotAccumulate(t *testing.T) {
func TestHiddenDeferredDetailsRemainDeferredAndCaptured(t *testing.T) { func TestHiddenDeferredDetailsRemainDeferredAndCaptured(t *testing.T) {
var diagnostics bytes.Buffer var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics}) reporter := newReporter(Streams{Err: &diagnostics})
reporter.setVisibility(modeQuiet, false, false) setReporterVisibility(reporter, modeQuiet, false, false)
failure := errors.New("hidden rendering failed") failure := errors.New("hidden rendering failed")
var order []string var order []string
details := []Detail{func(w io.Writer) error { details := []Detail{func(w io.Writer) error {
@@ -67,3 +67,57 @@ func TestHiddenDeferredDetailsRemainDeferredAndCaptured(t *testing.T) {
t.Fatalf("hidden content leaked or queue retained: %q", diagnostics.String()) t.Fatalf("hidden content leaked or queue retained: %q", diagnostics.String())
} }
} }
type gatedDiagnosticWriter struct {
bytes.Buffer
entered chan struct{}
release chan struct{}
}
func (w *gatedDiagnosticWriter) Write(p []byte) (int, error) {
if w.entered != nil {
close(w.entered)
w.entered = nil
<-w.release
}
return w.Buffer.Write(p)
}
func TestReporterDeferredAcceptanceEndsBeforeDelivery(t *testing.T) {
writer := &gatedDiagnosticWriter{entered: make(chan struct{}), release: make(chan struct{})}
entered := writer.entered
reporter := newReporter(Streams{Err: writer})
setReporterVisibility(reporter, modeVerbose, false, false)
deferred := reporter.AtEnd()
if err := deferred.Warn("accepted"); err != nil {
t.Fatal(err)
}
completionEntered, finishCompletion := make(chan struct{}), make(chan struct{})
done := make(chan Outcome, 1)
go func() {
done <- reporter.finalize(Failure("terminal"), func(r *Reporter) {
close(completionEntered)
<-finishCompletion
_ = r.Info("completion")
})
}()
<-entered
late := make(chan error, 1)
go func() { late <- deferred.Warn("late") }()
close(writer.release)
<-completionEntered
if err := <-late; !errors.Is(err, ErrReporterClosed) {
t.Errorf("late submission = %v", err)
}
if err := deferred.Debug(""); !errors.Is(err, ErrReporterClosed) {
t.Errorf("empty hidden submission = %v", err)
}
close(finishCompletion)
outcome := <-done
if got := writer.String(); got != "WARN: accepted\ncompletion\nERROR: terminal\n" {
t.Errorf("diagnostics = %q", got)
}
if outcome.Presentation.Diagnostics.State != DeliveryComplete {
t.Fatalf("outcome = %#v", outcome)
}
}

View File

@@ -16,7 +16,7 @@ func TestReporterExplainsItsOwnFailuresWithoutExposingCauses(t *testing.T) {
var diagnostics bytes.Buffer var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics}) reporter := newReporter(Streams{Err: &diagnostics})
if quiet { if quiet {
reporter.setVisibility(modeQuiet, false, false) setReporterVisibility(reporter, modeQuiet, false, false)
} }
failure := errors.New("private rendering detail") failure := errors.New("private rendering detail")
render := func(io.Writer) error { return failure } render := func(io.Writer) error { return failure }
@@ -84,7 +84,7 @@ func TestReporterPreservesJoinedRendererEvidence(t *testing.T) {
writeErr := errors.Join(errors.New("sink failure"), errors.New("sink detail")) writeErr := errors.Join(errors.New("sink failure"), errors.New("sink detail"))
destination := &reporterTestWriter{err: writeErr} destination := &reporterTestWriter{err: writeErr}
reporter := newReporter(Streams{Out: destination, Err: destination}) reporter := newReporter(Streams{Out: destination, Err: destination})
reporter.setVisibility(modeVerbose, false, false) setReporterVisibility(reporter, modeVerbose, false, false)
render := func(w io.Writer) error { render := func(w io.Writer) error {
_, err := io.WriteString(w, "report") _, err := io.WriteString(w, "report")
return test.combine(renderErr, err) return test.combine(renderErr, err)

View File

@@ -2,10 +2,13 @@ package commandkit
import ( import (
"bytes" "bytes"
"context"
"errors" "errors"
"io" "io"
"strings" "strings"
"testing" "testing"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
) )
type reporterTestWriter struct { type reporterTestWriter struct {
@@ -77,7 +80,7 @@ func TestReporterLeavesCallerWriterOpen(t *testing.T) {
func TestReporterQuietOutputDiscardsSuccessfully(t *testing.T) { func TestReporterQuietOutputDiscardsSuccessfully(t *testing.T) {
var destination bytes.Buffer var destination bytes.Buffer
reporter := newReporter(Streams{Out: &destination}) reporter := newReporter(Streams{Out: &destination})
reporter.setVisibility(modeQuiet, false, false) setReporterVisibility(reporter, modeQuiet, false, false)
writer := reporter.Output() writer := reporter.Output()
if n, err := writer.Write([]byte("hidden")); err != nil || n != len("hidden") { if n, err := writer.Write([]byte("hidden")); err != nil || n != len("hidden") {
@@ -174,8 +177,8 @@ func TestReporterClosedHandlesReturnClosedErrorIncludingQuiet(t *testing.T) {
var destination bytes.Buffer var destination bytes.Buffer
reporter := newReporter(Streams{Out: &destination}) reporter := newReporter(Streams{Out: &destination})
writer := reporter.Output() writer := reporter.Output()
reporter.setVisibility(modeQuiet, false, false) setReporterVisibility(reporter, modeQuiet, false, false)
reporter.close() _ = reporter.finalize(Success(), nil)
if n, err := writer.Write([]byte("late")); n != 0 || !errors.Is(err, ErrReporterClosed) { if n, err := writer.Write([]byte("late")); n != 0 || !errors.Is(err, ErrReporterClosed) {
t.Fatalf("late Write() = %d, %v", n, err) t.Fatalf("late Write() = %d, %v", n, err)
@@ -193,7 +196,7 @@ func TestNewReporterNormalizesNilDestinationsAndStartsOrdinary(t *testing.T) {
if reporter.state.mode != modeOrdinary || reporter.state.progress || reporter.state.progressSet { if reporter.state.mode != modeOrdinary || reporter.state.progress || reporter.state.progressSet {
t.Fatalf("initial visibility = mode %v, progress %v (set %v)", reporter.state.mode, reporter.state.progress, reporter.state.progressSet) t.Fatalf("initial visibility = mode %v, progress %v (set %v)", reporter.state.mode, reporter.state.progress, reporter.state.progressSet)
} }
reporter.setVisibility(modeDebug, true, true) setReporterVisibility(reporter, modeDebug, true, true)
if reporter.state.mode != modeDebug || !reporter.state.progress || !reporter.state.progressSet { if reporter.state.mode != modeDebug || !reporter.state.progress || !reporter.state.progressSet {
t.Fatalf("visibility was not retained: mode %v, progress %v (set %v)", reporter.state.mode, reporter.state.progress, reporter.state.progressSet) t.Fatalf("visibility was not retained: mode %v, progress %v (set %v)", reporter.state.mode, reporter.state.progress, reporter.state.progressSet)
} }
@@ -219,7 +222,7 @@ func TestReporterMessageVisibilityAndProgressPrecedence(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
var output bytes.Buffer var output bytes.Buffer
reporter := newReporter(Streams{Err: &output}) reporter := newReporter(Streams{Err: &output})
reporter.setVisibility(test.mode, test.progress, test.progressSet) setReporterVisibility(reporter, test.mode, test.progress, test.progressSet)
_ = reporter.Info("not used") _ = reporter.Info("not used")
_ = reporter.Warn("not used") _ = reporter.Warn("not used")
_ = reporter.Debug("DEBUG") _ = reporter.Debug("DEBUG")
@@ -234,7 +237,7 @@ func TestReporterMessageVisibilityAndProgressPrecedence(t *testing.T) {
func TestReporterFormatsMessagesAndPreservesDetailOrder(t *testing.T) { func TestReporterFormatsMessagesAndPreservesDetailOrder(t *testing.T) {
var diagnostics bytes.Buffer var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics}) reporter := newReporter(Streams{Err: &diagnostics})
reporter.setVisibility(modeVerbose, false, false) setReporterVisibility(reporter, modeVerbose, false, false)
if err := reporter.Warn("Native warning\nwith second line\n", nil, if err := reporter.Warn("Native warning\nwith second line\n", nil,
func(w io.Writer) error { _, err := io.WriteString(w, "detail one\n"); return err }, func(w io.Writer) error { _, err := io.WriteString(w, "detail one\n"); return err },
func(w io.Writer) error { _, err := io.WriteString(w, "detail two\n"); return err }); err != nil { func(w io.Writer) error { _, err := io.WriteString(w, "detail two\n"); return err }); err != nil {
@@ -255,7 +258,7 @@ func TestReporterFormatsMessagesAndPreservesDetailOrder(t *testing.T) {
func TestReporterHiddenRenderersRunAgainstDiscardAndRetainErrors(t *testing.T) { func TestReporterHiddenRenderersRunAgainstDiscardAndRetainErrors(t *testing.T) {
var diagnostics bytes.Buffer var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics}) reporter := newReporter(Streams{Err: &diagnostics})
reporter.setVisibility(modeQuiet, false, false) setReporterVisibility(reporter, modeQuiet, false, false)
renderErr := errors.New("native renderer failed") renderErr := errors.New("native renderer failed")
calls := 0 calls := 0
err := reporter.Warn("hidden warning", func(w io.Writer) error { err := reporter.Warn("hidden warning", func(w io.Writer) error {
@@ -281,7 +284,7 @@ func TestReporterRecordsIgnoredRenderErrors(t *testing.T) {
renderErr := errors.New("output renderer failed") renderErr := errors.New("output renderer failed")
diagnosticErr := errors.New("diagnostic renderer failed") diagnosticErr := errors.New("diagnostic renderer failed")
reporter := newReporter(Streams{Out: io.Discard, Err: io.Discard}) reporter := newReporter(Streams{Out: io.Discard, Err: io.Discard})
reporter.setVisibility(modeVerbose, false, false) setReporterVisibility(reporter, modeVerbose, false, false)
_ = reporter.RenderOutput(func(io.Writer) error { return renderErr }) _ = reporter.RenderOutput(func(io.Writer) error { return renderErr })
_ = reporter.Warn("warning", func(io.Writer) error { return diagnosticErr }) _ = reporter.Warn("warning", func(io.Writer) error { return diagnosticErr })
@@ -294,7 +297,7 @@ func TestReporterRecordsIgnoredRenderErrors(t *testing.T) {
func TestReporterRenderOutputRunsInQuietAndRetainsFailure(t *testing.T) { func TestReporterRenderOutputRunsInQuietAndRetainsFailure(t *testing.T) {
destination := &reporterTestWriter{} destination := &reporterTestWriter{}
reporter := newReporter(Streams{Out: destination}) reporter := newReporter(Streams{Out: destination})
reporter.setVisibility(modeQuiet, false, false) setReporterVisibility(reporter, modeQuiet, false, false)
renderErr := errors.New("quiet encoder failed") renderErr := errors.New("quiet encoder failed")
calls := 0 calls := 0
_ = reporter.RenderOutput(func(w io.Writer) error { _ = reporter.RenderOutput(func(w io.Writer) error {
@@ -318,7 +321,7 @@ func TestReporterCountsSuccessfulZeroByteRenderAsSubmitted(t *testing.T) {
if err := reporter.RenderOutput(func(io.Writer) error { return nil }); err != nil { if err := reporter.RenderOutput(func(io.Writer) error { return nil }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
reporter.setVisibility(modeVerbose, false, false) setReporterVisibility(reporter, modeVerbose, false, false)
if err := reporter.Warn("", func(io.Writer) error { return nil }); err != nil { if err := reporter.Warn("", func(io.Writer) error { return nil }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -406,7 +409,7 @@ func TestReporterConcurrentOperationsRemainWhole(t *testing.T) {
t.Run("render callback and message", func(t *testing.T) { t.Run("render callback and message", func(t *testing.T) {
var output bytes.Buffer var output bytes.Buffer
reporter := newReporter(Streams{Out: &output, Err: &output}) reporter := newReporter(Streams{Out: &output, Err: &output})
reporter.setVisibility(modeVerbose, false, false) setReporterVisibility(reporter, modeVerbose, false, false)
start := make(chan struct{}) start := make(chan struct{})
ready := make(chan struct{}, 2) ready := make(chan struct{}, 2)
done := make(chan struct{}, 2) done := make(chan struct{}, 2)
@@ -440,7 +443,7 @@ func TestReporterConcurrentOperationsRemainWhole(t *testing.T) {
t.Run("message details and render callback", func(t *testing.T) { t.Run("message details and render callback", func(t *testing.T) {
var output bytes.Buffer var output bytes.Buffer
reporter := newReporter(Streams{Out: &output, Err: &output}) reporter := newReporter(Streams{Out: &output, Err: &output})
reporter.setVisibility(modeVerbose, false, false) setReporterVisibility(reporter, modeVerbose, false, false)
start := make(chan struct{}) start := make(chan struct{})
ready := make(chan struct{}, 2) ready := make(chan struct{}, 2)
done := make(chan struct{}, 2) done := make(chan struct{}, 2)
@@ -478,7 +481,7 @@ func TestReporterConcurrentOperationsRemainWhole(t *testing.T) {
func TestReporterEmptyHeadingDoesNotAddPrefixOrBlankLine(t *testing.T) { func TestReporterEmptyHeadingDoesNotAddPrefixOrBlankLine(t *testing.T) {
var diagnostics bytes.Buffer var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics}) reporter := newReporter(Streams{Err: &diagnostics})
reporter.setVisibility(modeVerbose, false, false) setReporterVisibility(reporter, modeVerbose, false, false)
if err := reporter.Warn("", func(w io.Writer) error { if err := reporter.Warn("", func(w io.Writer) error {
_, err := io.WriteString(w, "native multiline\nsecond line\n") _, err := io.WriteString(w, "native multiline\nsecond line\n")
return err return err
@@ -496,7 +499,7 @@ func TestReporterEmptyHeadingDoesNotAddPrefixOrBlankLine(t *testing.T) {
func TestReporterFinalizesDeferredMessagesBeforeCompletionAndExplanation(t *testing.T) { func TestReporterFinalizesDeferredMessagesBeforeCompletionAndExplanation(t *testing.T) {
var output, diagnostics bytes.Buffer var output, diagnostics bytes.Buffer
reporter := newReporter(Streams{Out: &output, Err: &diagnostics}) reporter := newReporter(Streams{Out: &output, Err: &diagnostics})
reporter.setVisibility(modeDebug, false, false) setReporterVisibility(reporter, modeDebug, false, false)
if err := reporter.Info("started"); err != nil { if err := reporter.Info("started"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -522,7 +525,7 @@ func TestReporterFinalizesDeferredMessagesBeforeCompletionAndExplanation(t *test
func TestReporterDeferredSubmissionCopiesDetailsAndRetainsDeferredFailures(t *testing.T) { func TestReporterDeferredSubmissionCopiesDetailsAndRetainsDeferredFailures(t *testing.T) {
var diagnostics bytes.Buffer var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics}) reporter := newReporter(Streams{Err: &diagnostics})
reporter.setVisibility(modeVerbose, false, false) setReporterVisibility(reporter, modeVerbose, false, false)
writeFirst := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "first detail\n"); return err }) writeFirst := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "first detail\n"); return err })
writeSecond := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "second detail\n"); return err }) writeSecond := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "second detail\n"); return err })
details := []Detail{writeFirst} details := []Detail{writeFirst}
@@ -542,7 +545,7 @@ func TestReporterDeferredSubmissionCopiesDetailsAndRetainsDeferredFailures(t *te
renderErr := errors.New("deferred renderer failed") renderErr := errors.New("deferred renderer failed")
failedReporter := newReporter(Streams{Err: io.Discard}) failedReporter := newReporter(Streams{Err: io.Discard})
failedReporter.setVisibility(modeQuiet, false, false) setReporterVisibility(failedReporter, modeQuiet, false, false)
if err := failedReporter.AtEnd().Warn("hidden", func(io.Writer) error { return renderErr }); err != nil { if err := failedReporter.AtEnd().Warn("hidden", func(io.Writer) error { return renderErr }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -695,3 +698,58 @@ func TestReporterFinalizationClosesOutputAndDeferredHandles(t *testing.T) {
t.Fatalf("late output emitted: result=%q diagnostics=%q", output.String(), diagnostics.String()) t.Fatalf("late output emitted: result=%q diagnostics=%q", output.String(), diagnostics.String())
} }
} }
func setReporterVisibility(reporter *Reporter, mode reportingMode, progress, progressSet bool) {
selection := commandmeta.ReportingSelection{Progress: progress, ProgressSet: progressSet}
switch mode {
case modeQuiet:
selection.Mode = commandmeta.ReportingModeQuiet
case modeVerbose:
selection.Mode = commandmeta.ReportingModeVerbose
case modeDebug:
selection.Mode = commandmeta.ReportingModeDebug
}
reporter.applySelection(selection)
}
func TestReporterFinalizationReleasesBorrowedDestinations(t *testing.T) {
cause := errors.New("operation failed")
for _, tc := range []struct {
name string
outcome Outcome
cause error
}{
{name: "success", outcome: Success()},
{name: "failure", outcome: Failure("failed").WithCause(cause), cause: cause},
{name: "cancellation", outcome: Failure("canceled").WithCause(context.Canceled), cause: context.Canceled},
} {
t.Run(tc.name, func(t *testing.T) {
out, diagnostics := &callerOwnedWriter{}, &callerOwnedWriter{}
reporter := newReporter(Streams{Out: out, Err: diagnostics})
output, deferred := reporter.Output(), reporter.AtEnd()
result := reporter.finalize(tc.outcome, nil)
if reporter.state.output.writer != nil || reporter.state.diagnostics.writer != nil || out.closeCalls != 0 || diagnostics.closeCalls != 0 || tc.cause != nil && !errors.Is(result.Cause, tc.cause) {
t.Fatalf("outcome=%#v state=%#v", result, reporter.state)
}
outLen, diagnosticLen := out.Len(), diagnostics.Len()
if _, err := output.Write([]byte("late")); !errors.Is(err, ErrReporterClosed) {
t.Errorf("output error=%v", err)
}
if err := reporter.Warn("late"); !errors.Is(err, ErrReporterClosed) {
t.Errorf("reporter error=%v", err)
}
if err := deferred.Warn("late"); !errors.Is(err, ErrReporterClosed) {
t.Errorf("deferred error=%v", err)
}
if out.Len() != outLen || diagnostics.Len() != diagnosticLen {
t.Fatal("late operation touched destinations")
}
})
}
}
func (state *reportingState) presentation() PresentationResult {
state.mu.Lock()
defer state.mu.Unlock()
return state.presentationLocked()
}

View File

@@ -13,10 +13,10 @@ import (
const tool = "go.maximumdirect.net/releasekit/cmd/releasekit" const tool = "go.maximumdirect.net/releasekit/cmd/releasekit"
var modules = []struct{ selector, dir, profile, product string }{ var modules = []struct{ selector, dir, profile string }{
{"core", ".", "releasekit.json", "CommandKit"}, {"core", ".", "releasekit.json"},
{"ckconfig", "integrations/ckconfig", "integrations/ckconfig/releasekit.json", "CommandKit ckconfig"}, {"ckconfig", "integrations/ckconfig", "integrations/ckconfig/releasekit.json"},
{"ckprompt", "integrations/ckprompt", "integrations/ckprompt/releasekit.json", "CommandKit ckprompt"}, {"ckprompt", "integrations/ckprompt", "integrations/ckprompt/releasekit.json"},
} }
type fixture struct{ root, outside, log string } type fixture struct{ root, outside, log string }
@@ -90,38 +90,28 @@ func lines(t *testing.T, path string) []string {
} }
func TestProfiles(t *testing.T) { func TestProfiles(t *testing.T) {
f := newFixture(t)
for _, m := range modules { for _, m := range modules {
t.Run(m.selector, func(t *testing.T) { t.Run(m.selector, func(t *testing.T) {
var p struct { var p struct {
Product string `json:"product"` Module string `json:"module_dir"`
Module string `json:"module_dir"` URL string `json:"expected_push_url"`
Remote string `json:"remote"`
URL string `json:"expected_push_url"`
Validation struct {
Shared []string `json:"shared_files"`
Format bool `json:"go_format"`
Markdown map[string]any `json:"markdown"`
Distribution map[string]bool `json:"module_distribution"`
Forbidden []string `json:"forbidden_files"`
} `json:"validation"`
Delivery map[string]string `json:"delivery"`
} }
b, err := os.ReadFile(filepath.Join(f.root, m.profile)) b, err := os.ReadFile(filepath.Join("..", m.profile))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := json.Unmarshal(b, &p); err != nil { if err := json.Unmarshal(b, &p); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if p.Product != m.product || p.Module != m.dir || p.Remote != "origin" || p.URL != "ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git" { // The profile must agree with the launcher's module selection.
t.Fatalf("profile identity: %+v", p) if p.Module != m.dir {
t.Errorf("module_dir = %q, want %q", p.Module, m.dir)
} }
if !reflect.DeepEqual(p.Validation.Shared, []string{"COPYRIGHT", "LICENSE", "docs/oss/attribution.md"}) || !p.Validation.Format || p.Validation.Markdown == nil || len(p.Validation.Markdown) != 0 || !reflect.DeepEqual(p.Validation.Distribution, map[string]bool{"workspace_files": true, "local_replacements": true}) || !reflect.DeepEqual(p.Validation.Forbidden, []string{".releasekit/gitea-token"}) { // The release procedure selects this exact publication endpoint.
t.Fatalf("validation: %+v", p.Validation) // An empty or different value would weaken or misdirect that guard.
} const pushURL = "ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git"
if !reflect.DeepEqual(p.Delivery, map[string]string{"forge": "gitea", "base_url": "https://forge.maximumdirect.net", "owner": "base-layer", "repository": "commandkit", "token_file": ".releasekit/gitea-token"}) { if p.URL != pushURL {
t.Fatalf("delivery: %v", p.Delivery) t.Errorf("expected_push_url = %q, want %q", p.URL, pushURL)
} }
}) })
} }

View File

@@ -29,7 +29,7 @@ func main() {
Name: "run", Summary: "Run a fixture command.", Name: "run", Summary: "Run a fixture command.",
Flags: []commandkit.Flag[struct{}]{ Flags: []commandkit.Flag[struct{}]{
commandkit.BoolFlag(commandkit.FlagMeta{Name: "switch", Short: "s"}, false, func(*struct{}, commandkit.Supplied[bool]) {}), commandkit.BoolFlag(commandkit.FlagMeta{Name: "switch", Short: "s"}, false, func(*struct{}, commandkit.Supplied[bool]) {}),
flag(commandkit.FlagMeta{Name: "format", Usage: "Report format.", Completion: commandkit.CompleteChoices(commandkit.CompletionChoice{Value: "text", Description: "Readable text"}, commandkit.CompletionChoice{Value: "yaml", Description: "YAML output"})}), flag(commandkit.FlagMeta{Name: "choice", Usage: "Fixture choice.", Completion: commandkit.CompleteChoices(commandkit.CompletionChoice{Value: "text", Description: "Readable text"}, commandkit.CompletionChoice{Value: "yaml", Description: "YAML output"})}),
flag(commandkit.FlagMeta{Name: "config", Short: "c", Usage: "Configuration path.", Completion: commandkit.CompleteFiles()}), flag(commandkit.FlagMeta{Name: "config", Short: "c", Usage: "Configuration path.", Completion: commandkit.CompleteFiles()}),
flag(commandkit.FlagMeta{Name: "directory", Short: "d", Usage: "Directory path.", Completion: commandkit.CompleteDirectories()}), flag(commandkit.FlagMeta{Name: "directory", Short: "d", Usage: "Directory path.", Completion: commandkit.CompleteDirectories()}),
}, },