Compare commits
64 Commits
integratio
...
integratio
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c96053255 | |||
| 49f2d09ccd | |||
| af7cc1da3e | |||
| ee4e226914 | |||
| c648d1de33 | |||
| 69fe35c480 | |||
| 68825de200 | |||
| 0435a632b3 | |||
| aac0e11ae6 | |||
| e46d28a924 | |||
| 1cba65fffe | |||
| d1102e12c5 | |||
| e950f61c7f | |||
| c4b5bcdbc2 | |||
| b66a00da26 | |||
| a62f1f056b | |||
| 0a81be84f6 | |||
| 83386217db | |||
| 0d3f7299cc | |||
| 02cd49d6b6 | |||
| 51a147a6e5 | |||
| f2822f055c | |||
| 27500dbc6c | |||
| 3b75ae7d2e | |||
| 52f1d99994 | |||
| ac113291fc | |||
| 8dbb25aba9 | |||
| 7c7d1df13f | |||
| 4346b840db | |||
| 552b91c0a2 | |||
| 1fc6409f95 | |||
| c82235100e | |||
| b66d265032 | |||
| a806a9a62e | |||
| 118b49ee55 | |||
| d492c77471 | |||
| 264b82bd57 | |||
| d131999c7c | |||
| c7f898e56e | |||
| 485a27a662 | |||
| 9e0f2d28cd | |||
| 2a93b45419 | |||
| 9d4c963c53 | |||
| 1798dcde2e | |||
| f00fe1909d | |||
| db732e3a1d | |||
| 4a91f29bcd | |||
| efb5f77417 | |||
| 8fe8b67b29 | |||
| 2ea26afcc5 | |||
| 1e4fb73d15 | |||
| d8da761a21 | |||
| 7b2dc2f2c9 | |||
| 7bb7326ed1 | |||
| d72c9a788f | |||
| 3223b5cb82 | |||
| d7a85f5f35 | |||
| 07e1634e79 | |||
| c61e125649 | |||
| 3a7e2d4ce7 | |||
| f5b2d1f80c | |||
| f83866e44a | |||
| 2f43f78f76 | |||
| 668c609062 |
36
README.md
36
README.md
@@ -1,34 +1,26 @@
|
||||
# CommandKit
|
||||
|
||||
CommandKit is a Go library for typed command declarations, shared CLI behavior,
|
||||
and application-owned execution. The core `commandkit` module is usable by
|
||||
itself. ConfigKit and PromptKit support lives in separately versioned optional
|
||||
modules, so applications choose which upstream libraries they add.
|
||||
CommandKit helps Go applications build consistent command-line interfaces. It
|
||||
provides typed flags and arguments, help, output formatting, and error reporting.
|
||||
Optional integrations add commands for inspecting configuration and working with
|
||||
PromptKit.
|
||||
|
||||
The core captures reusable command declarations, validates installation batches
|
||||
before changing an application's registry, converts typed inputs before
|
||||
constructing them, and returns outcomes without exiting the process. Its
|
||||
[consumer guide](docs/consumers/guide.md) covers declaration, 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.
|
||||
CommandKit is in alpha. Start with the [consumer guide](docs/consumers/guide.md#core-commands)
|
||||
to define a command, or run the [core example](examples/core/README.md).
|
||||
Applications supply their own command handlers and decide when to exit;
|
||||
CommandKit returns the result of each invocation.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Package | Provides |
|
||||
| --- | --- | --- |
|
||||
| `go.maximumdirect.net/commandkit` | `commandkit` | Typed commands, bindings, execution reporting and outcomes, help, and version. |
|
||||
| `go.maximumdirect.net/ckconfig` | `ckconfig` | ConfigKit-backed `config` scopes, including configuration source evidence. |
|
||||
| `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/commandkit` | `commandkit` | Commands, typed inputs, shared output and error reporting, help, and version. |
|
||||
| `go.maximumdirect.net/ckconfig` | `ckconfig` | Commands to view, validate, explain, and compare ConfigKit configuration. |
|
||||
| `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. Core v0.4.0 is published;
|
||||
the published ckconfig and ckprompt v0.2.0 modules use core v0.3.0. The published
|
||||
ckconfig v0.3.0 uses ConfigKit v1.1.0 and does not compose with
|
||||
ckprompt v0.2.0; the published v0.2.0 pair remains matched. Current ckconfig
|
||||
source uses core v0.4.0. Current ckprompt source requires a newer
|
||||
ckconfig release and is developed in an aligned workspace. See the
|
||||
[consumer guide](docs/consumers/guide.md#module-selection) for dependency details.
|
||||
Importing an integration does not install commands. Core and the integrations
|
||||
are versioned separately; see the [consumer guide](docs/consumers/guide.md#module-selection)
|
||||
for published versions, compatibility, and installation choices.
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
79
binding.go
79
binding.go
@@ -3,7 +3,6 @@ package commandkit
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
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
|
||||
// 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] {
|
||||
binding := flag.binding
|
||||
binding.meta.Name = name
|
||||
@@ -239,17 +239,6 @@ func (p preparedInput[I]) build() I {
|
||||
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) {
|
||||
if 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
|
||||
value, err := binding.prepare(flagValues[i])
|
||||
if err != nil {
|
||||
var occurrenceErr *flagOccurrenceError
|
||||
if errors.As(err, &occurrenceErr) {
|
||||
spelling := "--" + binding.meta.Name
|
||||
if occurrenceErr.index >= 0 && occurrenceErr.index < len(flagSpellings[i]) && flagSpellings[i][occurrenceErr.index] != "" {
|
||||
spelling = flagSpellings[i][occurrenceErr.index]
|
||||
}
|
||||
return preparedInput[I]{}, &flagConversionError{
|
||||
binding: binding.meta.Name, spelling: spelling,
|
||||
occurrence: occurrenceErr.index + 1, cause: occurrenceErr.cause,
|
||||
}
|
||||
}
|
||||
return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err)
|
||||
return preparedInput[I]{}, attributeFlagConversionError(binding.meta.Name, flagSpellings[i], err)
|
||||
}
|
||||
preparedFlags[i] = value
|
||||
for index, occurrence := range value.ordered {
|
||||
@@ -298,7 +276,14 @@ func prepareInputWithOccurrences[I any](spec CommandSpec[I], flagValues, flagSpe
|
||||
fallbackOrdinal++
|
||||
}
|
||||
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 == "" {
|
||||
@@ -308,26 +293,50 @@ func prepareInputWithOccurrences[I any](spec CommandSpec[I], flagValues, flagSpe
|
||||
if 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 {
|
||||
return preparedInput[I]{}, err
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
if occurrence.apply != nil {
|
||||
preparedValues = append(preparedValues, preparedValue[I]{apply: occurrence.apply})
|
||||
}
|
||||
preparedValues = append(preparedValues, value)
|
||||
}
|
||||
preparedValues = append(preparedValues, arguments...)
|
||||
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 allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) {
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
|
||||
if input.message.Value != "" || !input.message.Set {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -207,7 +207,7 @@ func TestRepeatArgumentAllocationReservesLaterMinimums(t *testing.T) {
|
||||
if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -237,10 +237,10 @@ func TestIncompletePreparationSkipsMissingRequirementsAndInputFactory(t *testing
|
||||
if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
if factoryCalls != 0 {
|
||||
|
||||
@@ -32,8 +32,8 @@ func (a *App) builtinCommands() []Declaration {
|
||||
help := captureCommand(CommandSpec[helpInput]{
|
||||
Name: "help",
|
||||
Summary: "Show help for commands.",
|
||||
Description: "Describe registered commands and their inputs.",
|
||||
Arguments: []Argument[helpInput]{RepeatArgument(ArgumentMeta{Name: "COMMAND", Usage: "registered command path"}, 0, -1,
|
||||
Description: "Show available commands, flags, and arguments.",
|
||||
Arguments: []Argument[helpInput]{RepeatArgument(ArgumentMeta{Name: "COMMAND", Usage: "Command path to describe."}, 0, -1,
|
||||
func(value string) (string, error) { return value, nil },
|
||||
func(input *helpInput, value Supplied[[]string]) { input.Targets = value })},
|
||||
}, func(current commandExecution, input helpInput) Outcome {
|
||||
@@ -43,9 +43,9 @@ func (a *App) builtinCommands() []Declaration {
|
||||
version := NewCommand(CommandSpec[versionInput]{
|
||||
Name: "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]{
|
||||
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 {
|
||||
identity := a.identity
|
||||
|
||||
77
command.go
77
command.go
@@ -90,10 +90,14 @@ type Supplied[T any] struct {
|
||||
|
||||
// FlagMeta describes a command flag. Required applies when execution prepares
|
||||
// the selected command; help reports the metadata without requiring the value.
|
||||
// Invalid declarations are reported when installed.
|
||||
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
|
||||
// Short is an optional one-byte spelling.
|
||||
// Short is an optional one-byte spelling other than '-' or the reserved 'h'.
|
||||
Short string
|
||||
// Usage briefly describes the flag.
|
||||
Usage string
|
||||
@@ -352,6 +356,7 @@ func Alias(path, target string) RoutingRule {
|
||||
// wherever its canonical target flag is available and contributes nothing on
|
||||
// commands without that flag. Identical mappings are accepted; different
|
||||
// targets for the same source spelling in this scope are installation errors.
|
||||
// Long and short spelling forms follow the grammar described by [FlagMeta].
|
||||
func FlagAlias(spelling, target string) RoutingRule {
|
||||
record := &routingRuleRecord{
|
||||
kind: globalFlagAliasRule,
|
||||
@@ -368,6 +373,7 @@ func FlagAlias(spelling, target string) RoutingRule {
|
||||
// available in the complete installation. Scoped mappings take precedence over
|
||||
// application-wide mappings with the same source spelling. Identical mappings
|
||||
// are accepted; competing targets in the same scope are installation errors.
|
||||
// Long and short spelling forms follow the grammar described by [FlagMeta].
|
||||
func FlagAliasFor(commandPath, spelling, target string) RoutingRule {
|
||||
record := &routingRuleRecord{
|
||||
kind: scopedFlagAliasRule,
|
||||
@@ -437,39 +443,46 @@ func validateCommandDeclaration(command Command) error {
|
||||
|
||||
func validateCommandPlacement(command Command) 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...)
|
||||
}
|
||||
|
||||
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 {
|
||||
return commandpath.ValidateComponent(name)
|
||||
}
|
||||
|
||||
@@ -53,44 +53,26 @@ func walkCompletionFlags(tokens []string, lookup commandmeta.FlagLookup) complet
|
||||
state.ended = true
|
||||
case commandmeta.OperandToken:
|
||||
state.operands = append(state.operands, token)
|
||||
case commandmeta.LongFlagToken:
|
||||
spelling, _, attached := commandmeta.SplitLongFlag(token)
|
||||
flag, ok := lookup.Find(spelling)
|
||||
if !ok {
|
||||
state.invalid = true
|
||||
return state
|
||||
}
|
||||
state.supplied[flag.Name] = true
|
||||
if !flag.Boolean && !attached {
|
||||
if index+1 == len(tokens) {
|
||||
state.pending = &flag
|
||||
return state
|
||||
}
|
||||
index++
|
||||
}
|
||||
case commandmeta.ShortFlagToken:
|
||||
for offset := 1; offset < len(token); offset++ {
|
||||
flag, ok := lookup.Find("-" + token[offset:offset+1])
|
||||
if !ok {
|
||||
case commandmeta.LongFlagToken, commandmeta.ShortFlagToken:
|
||||
for offset := 1; ; {
|
||||
fact := commandmeta.ConsumeFlag(lookup, token, offset)
|
||||
if !fact.Found {
|
||||
state.invalid = true
|
||||
return state
|
||||
}
|
||||
state.supplied[flag.Name] = true
|
||||
suffix := token[offset+1:]
|
||||
if flag.Boolean {
|
||||
if strings.HasPrefix(suffix, "=") {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if suffix == "" {
|
||||
state.supplied[fact.Flag.Name] = true
|
||||
if fact.NeedsNext {
|
||||
if index+1 == len(tokens) {
|
||||
flag := fact.Flag
|
||||
state.pending = &flag
|
||||
return state
|
||||
}
|
||||
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)}
|
||||
}
|
||||
if strings.HasPrefix(current, "--") {
|
||||
spelling, value, attached := commandmeta.SplitLongFlag(current)
|
||||
if attached {
|
||||
if flag, ok := lookup.Find(spelling); ok {
|
||||
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: spelling + "=", valuePrefix: value}
|
||||
fact := commandmeta.ConsumeFlag(lookup, current, 1)
|
||||
if fact.Attached {
|
||||
if fact.Found {
|
||||
return completionTarget{kind: completionTargetValue, hint: fact.Flag.Completion, prefix: fact.Spelling + "=", valuePrefix: fact.Value}
|
||||
}
|
||||
return completionTarget{}
|
||||
}
|
||||
@@ -145,25 +127,26 @@ func completionOptionTarget(current string, flags []commandmeta.Flag, lookup com
|
||||
for name, value := range supplied {
|
||||
used[name] = value
|
||||
}
|
||||
for offset := 1; offset < len(current); offset++ {
|
||||
flag, ok := lookup.Find("-" + current[offset:offset+1])
|
||||
if !ok {
|
||||
for offset := 1; offset < len(current); {
|
||||
fact := commandmeta.ConsumeFlag(lookup, current, offset)
|
||||
if !fact.Found {
|
||||
return completionTarget{}
|
||||
}
|
||||
used[flag.Name] = true
|
||||
suffix := current[offset+1:]
|
||||
if flag.Boolean {
|
||||
if strings.HasPrefix(suffix, "=") {
|
||||
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: current[:offset+2], valuePrefix: suffix[1:]}
|
||||
used[fact.Flag.Name] = true
|
||||
if fact.Flag.Boolean && fact.Attached {
|
||||
return completionTarget{kind: completionTargetValue, hint: fact.Flag.Completion, prefix: current[:offset+2], valuePrefix: fact.Value}
|
||||
}
|
||||
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 strings.HasPrefix(suffix, "=") {
|
||||
prefix += "="
|
||||
suffix = suffix[1:]
|
||||
if fact.NextOffset == 0 {
|
||||
break
|
||||
}
|
||||
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: prefix, valuePrefix: suffix}
|
||||
offset = fact.NextOffset
|
||||
}
|
||||
var choices []commandmeta.CompletionChoice
|
||||
for _, flag := range flags {
|
||||
|
||||
@@ -32,9 +32,9 @@ func TestCompletionConsumerInShells(t *testing.T) {
|
||||
directory bool
|
||||
}{
|
||||
{"canonical command", "probe ru", []string{"run"}, false},
|
||||
{"entered alias", "probe shortcut --format=ya", []string{"shortcut", "--format=yaml"}, false},
|
||||
{"choice", "probe run --format=te", []string{"run", "--format=text"}, false},
|
||||
{"no file fallback", `probe run --format=./partial\ f`, []string{"run", "--format=./partial f"}, false},
|
||||
{"entered alias", "probe shortcut --choice=ya", []string{"shortcut", "--choice=yaml"}, false},
|
||||
{"choice", "probe run --choice=te", []string{"run", "--choice=text"}, 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},
|
||||
{"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},
|
||||
@@ -42,7 +42,7 @@ func TestCompletionConsumerInShells(t *testing.T) {
|
||||
{"separated directory", "probe run --directory ./partial", []string{"run", "--directory", "./partial dir"}, true},
|
||||
{"attached directory", "probe run --directory=./partial", []string{"run", "--directory=./partial dir"}, true},
|
||||
{"empty operand", "probe run ", []string{"run", "alpha"}, false},
|
||||
{"leading dash", "probe run --for", []string{"run", "--format"}, false},
|
||||
{"leading dash", "probe run --cho", []string{"run", "--choice"}, false},
|
||||
}
|
||||
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||
generator := exec.Command(app, "completion", shell)
|
||||
@@ -82,7 +82,7 @@ func TestCompletionConsumerInShells(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")
|
||||
output, err := terminal.CombinedOutput()
|
||||
if err != nil || !strings.Contains(string(output), "Readable text") || !strings.Contains(string(output), "YAML output") {
|
||||
|
||||
@@ -79,3 +79,40 @@ func TestCompletionChoicesDoNotRestrictExecution(t *testing.T) {
|
||||
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
16
doc.go
@@ -1,8 +1,10 @@
|
||||
// Package commandkit provides the core instance-owned CLI dispatcher, typed
|
||||
// command declarations, application-owned command and flag aliases, input
|
||||
// bindings, invocation-scoped reporting, and outcomes with separate operation
|
||||
// causes and presentation observations. New and App.Register accept canonical
|
||||
// Command declarations and RoutingRule declarations together. Optional
|
||||
// integration modules provide ConfigKit and PromptKit-specific commands;
|
||||
// importing those modules does not install their commands.
|
||||
// Package commandkit helps Go applications build consistent command-line
|
||||
// interfaces with typed flags and arguments, help, and shared output formatting.
|
||||
// Define commands with NewCommand and install them with New or App.Register.
|
||||
// Commands and command or flag aliases can be installed together.
|
||||
//
|
||||
// App.Execute runs a command and returns an Outcome; the application decides
|
||||
// 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
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
The structured-report formatting extension is accepted but not yet implemented;
|
||||
the [adoption roadmap](../roadmap/promptkit-v0.20.0.md#shared-core-report-output-prerequisite)
|
||||
tracks that work.
|
||||
The structured-report formatting extension is implemented; its adoption record
|
||||
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).
|
||||
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
|
||||
|
||||
|
||||
870
docs/cli.md
870
docs/cli.md
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,46 @@
|
||||
# Consumer guide
|
||||
|
||||
CommandKit is distributed as three Go modules. Start with the core and add an
|
||||
integration module only when the application uses that library. Core and
|
||||
compatible integration command factories return canonical `commandkit.Command` values.
|
||||
Applications may install those alongside application-owned `RoutingRule`
|
||||
declarations through `commandkit.New` or `App.Register`; routing rules are
|
||||
installed at the application root, not as group children.
|
||||
Start with core to build commands with typed inputs and shared output
|
||||
formatting. Add an integration when your application uses ConfigKit or
|
||||
PromptKit. The [core command example](#core-commands) below shows the basic
|
||||
API; the [maintained examples](../../README.md#examples) show complete
|
||||
applications.
|
||||
|
||||
## Module selection
|
||||
|
||||
| Import | Use when |
|
||||
| --- | --- |
|
||||
| `go.maximumdirect.net/commandkit` | The application needs typed command declarations and common CLI behavior. |
|
||||
| `go.maximumdirect.net/ckconfig` | The application wants ConfigKit-backed configuration scopes and reports. |
|
||||
| `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/commandkit` | You need commands, typed inputs, help, and common output handling. |
|
||||
| `go.maximumdirect.net/ckconfig` | You want operators to view, validate, explain, and compare ConfigKit configuration. |
|
||||
| `go.maximumdirect.net/ckprompt` | You want operators to inspect PromptKit resources, check and preview requests, or run smoke tests. |
|
||||
|
||||
Core v0.4.0 is published. The previously published matched releases are core
|
||||
v0.3.0, ckconfig v0.2.0, and ckprompt v0.2.0.
|
||||
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 the current core with `go get go.maximumdirect.net/commandkit@v0.4.0`.
|
||||
For the older matched integration set, select core v0.3.0 and the corresponding
|
||||
ckconfig or ckprompt v0.2.0 release together.
|
||||
The following distinguishes published combinations from the current checkout:
|
||||
|
||||
The ckconfig v0.4.0 candidate uses published core v0.4.0 and ConfigKit v1.1.0.
|
||||
The current ckprompt source targets core v0.4.0, ckconfig v0.3.0, ConfigKit
|
||||
v1.1.0, and PromptKit v0.20.0. Its APIs require the upcoming ckconfig release;
|
||||
a Go 1.27.0 workspace composes the local modules until that dependency is aligned.
|
||||
| Selection | Core | ckconfig | ckprompt | Upstream libraries and status |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Published core | v0.5.0 | — | — | Usable independently. |
|
||||
| Published ConfigKit integration | v0.4.0 | v0.4.0 | — | ConfigKit v1.1.0. |
|
||||
| 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. |
|
||||
| Current source | Local | Local | Local | ConfigKit v1.1.0 and PromptKit v0.20.0; use an aligned workspace until release dependencies are updated. |
|
||||
|
||||
The two integrations are independent optional imports. `ckprompt` uses the
|
||||
public scope and error contracts from `ckconfig`; importing it does not add the
|
||||
`config` family. An application that wants both families passes both returned
|
||||
declarations to core. The [maintained examples](../../README.md#examples) show
|
||||
core-only, ConfigKit-only, and composed applications.
|
||||
Install the published core with:
|
||||
|
||||
```sh
|
||||
go get go.maximumdirect.net/commandkit@v0.5.0
|
||||
```
|
||||
|
||||
The integration releases being prepared are ckconfig v0.5.0 and ckprompt v0.5.0. Current
|
||||
ckprompt source calls `ckconfig.AdaptDiagnostics`, which is absent from its
|
||||
selected released ckconfig v0.4.0 dependency. It therefore needs local
|
||||
ckconfig source in the workspace until [release
|
||||
alignment](../roadmap/future.md#pending-release-and-integration-work) is
|
||||
complete. 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
|
||||
|
||||
@@ -55,7 +63,6 @@ default from an explicitly supplied empty, false, or zero value.
|
||||
package example
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
@@ -79,8 +86,12 @@ func Greeting() commandkit.Command {
|
||||
),
|
||||
},
|
||||
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
|
||||
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil {
|
||||
return commandkit.Failure("Could not write the greeting.").WithCause(err)
|
||||
greetings := []string{}
|
||||
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()
|
||||
},
|
||||
@@ -88,6 +99,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,
|
||||
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
|
||||
@@ -132,13 +147,13 @@ installation alongside aliases and integration commands.
|
||||
|
||||
## Invocation reporting
|
||||
|
||||
`Invocation.Reporter()` supplies the reporter for the current handler. Use
|
||||
`Report()` for formatted substantive values and optional text templates;
|
||||
use `Output()` or `RenderOutput()` for deliberate raw output. Use `Warn`, `Info`,
|
||||
`Progress`, and `Debug` for categorized messages. Queue a summary with
|
||||
`AtEnd().Info` when it belongs after the handler returns. The
|
||||
[CLI reference](../cli.md#reporting) owns flag grammar, visibility, prefixes,
|
||||
and status behavior.
|
||||
Use `Invocation.Reporter().Report` to print a value in the selected output
|
||||
format. Pass ordinary Go values and, optionally, a `text/template` for text
|
||||
output. Use `Warn`, `Info`, `Progress`, and `Debug` for categorized messages.
|
||||
Queue a summary with `AtEnd().Info` when it belongs after the handler returns.
|
||||
Deferred submissions are accepted until finalization begins; later submissions
|
||||
return `ErrReporterClosed`. The [CLI reference](../cli.md#reporting) owns flag
|
||||
grammar, visibility, prefixes, and status behavior.
|
||||
|
||||
Reports use JSON serialization to establish the common value representation
|
||||
for YAML and JSON. A text template receives the original value; without one,
|
||||
@@ -155,22 +170,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
|
||||
reporter.
|
||||
|
||||
For example, a handler can report a count as a small mapping:
|
||||
|
||||
```go
|
||||
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
|
||||
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil {
|
||||
return commandkit.Failure("Could not write the greeting.").WithCause(err)
|
||||
}
|
||||
return commandkit.Success()
|
||||
if err := invocation.Reporter().Report(commandkit.Report{
|
||||
Value: map[string]int{"count": input.Count.Value},
|
||||
}); err != nil {
|
||||
return commandkit.Failure("Could not write the count.").WithCause(err)
|
||||
}
|
||||
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
|
||||
operation error; `Presentation.Output` and `Presentation.Diagnostics` report
|
||||
delivery state and the first rendering or write errors for their destinations.
|
||||
CommandKit does not retain a message transcript. Reporter operations for one
|
||||
invocation are safe concurrently, but concurrent submission order is not
|
||||
specified. The caller owns the writers passed to `Execute`; CommandKit does not
|
||||
close them.
|
||||
Joined operation errors support `errors.Is` and `errors.As` where the
|
||||
underlying errors do. CommandKit does not retain a message transcript.
|
||||
Reporter operations for one invocation are safe concurrently, but concurrent
|
||||
submission order is not specified. The caller owns the writers passed to
|
||||
`Execute`; CommandKit does not close them.
|
||||
|
||||
## Application-owned aliases
|
||||
|
||||
@@ -231,18 +253,22 @@ values.
|
||||
|
||||
Use `ckconfig.Command(ckconfig.Suite[I, S]{...})` when the application needs
|
||||
multiple named scopes, selector flags, scope-aware input checks, or shared
|
||||
acquisition state or a custom comparison provider. A declared `Diff` capability
|
||||
uses `Scope.Compare`; `ckconfig.FromConfigKit` and `AppCommand` provide the
|
||||
ConfigKit-backed implementation. `Selector.AgainstName` opts a selector into an
|
||||
independent right-side flag. Each comparison side gets its own initialized and
|
||||
checked input, with only its supplied selectors applied; state is shared by the
|
||||
invocation. Factory results
|
||||
remain caller-owned, so separate initialization calls do not guarantee distinct
|
||||
objects. See
|
||||
acquisition state or a custom comparison provider. A declared `Diff`
|
||||
capability uses `Scope.Compare`; `ckconfig.FromConfigKit` and `AppCommand`
|
||||
provide the ConfigKit-backed implementation. `Selector.AgainstName` opts a
|
||||
selector into an independent right-side flag. Each comparison side gets its
|
||||
own initialized and checked input, with only its supplied selectors applied;
|
||||
state is shared by the invocation. Factory results remain caller-owned, so
|
||||
separate initialization calls do not guarantee distinct objects. See
|
||||
[ckconfig.Suite's GoDoc](../../integrations/ckconfig/contracts.go) for factory
|
||||
timing, omitted-factory zero values (including nil pointers), and ownership.
|
||||
The application owns the state type and what it caches. `ckconfig.FromConfigKit`
|
||||
adapts a typed ConfigKit loader without calling it during construction.
|
||||
The application owns the state type and what it caches.
|
||||
`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,
|
||||
source inventory, path explanation, and ConfigKit-backed comparison for the
|
||||
@@ -251,6 +277,11 @@ owns operator syntax and report details. `Scope.Features` declares optional mode
|
||||
`View.Metadata` and `View.Findings` carry native path metadata and diagnostic
|
||||
records when available. `Findings.Messages` contains presentations associated
|
||||
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
|
||||
`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
|
||||
@@ -265,6 +296,8 @@ it does not install PromptKit catalog, definition, or requirements commands.
|
||||
Its override feature is disabled because the released PromptKit configuration
|
||||
loader does not accept ConfigKit override records.
|
||||
|
||||
### Install resource commands and optional checks
|
||||
|
||||
`ckprompt.FromEngine(loadEngine)` supplies nine callbacks: prompt, profile,
|
||||
backend, namespace, and collection catalogs; named prompt, profile, and backend
|
||||
inspection; and native requirements discovery. Concrete request validation,
|
||||
@@ -299,6 +332,8 @@ suite := ckprompt.WithRequestValidation(
|
||||
validationCommand := ckprompt.Command(suite)
|
||||
```
|
||||
|
||||
### Request builders and loading
|
||||
|
||||
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
|
||||
engine loader and receives the seed request, selection, typed input, shared
|
||||
@@ -348,6 +383,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
|
||||
subset. The application decides how to apply the resulting values to its request.
|
||||
|
||||
### Enable namespace flags
|
||||
|
||||
`ckprompt.WithNamespaces` enables standard resource namespace selection from
|
||||
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.
|
||||
|
||||
@@ -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 |
|
||||
| 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) |
|
||||
| Documentation refresh record | [Documentation roadmap](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 |
|
||||
| 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) |
|
||||
@@ -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
|
||||
[maintainer release procedure](release.md) owns current release operations.
|
||||
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),
|
||||
[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).
|
||||
@@ -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
|
||||
proposals separately from current contracts.
|
||||
|
||||
The [ckprompt release errata](../integrations/ckprompt/docs/releases/errata.md)
|
||||
corrects navigation in a historical release note.
|
||||
|
||||
## Documentation validation
|
||||
|
||||
For current prose work, inspect relevant diffs, check local links and fragment
|
||||
@@ -101,11 +107,21 @@ Sibling-checkout references in retired planning records are historical review
|
||||
evidence, not build dependencies. Implemented contracts belong in CommandKit
|
||||
under their canonical owners.
|
||||
|
||||
The root module does not traverse nested modules. Preserve the ignored `go.work`
|
||||
and `go.work.sum`; use `GOWORK=off GOTOOLCHAIN=local` for dependency preparation
|
||||
and independent module suites once released requirements are aligned. ckconfig
|
||||
can be validated independently against released core v0.4.0; ckprompt still
|
||||
requires the aligned workspace until its ckconfig requirement is updated.
|
||||
The root module does not traverse nested modules. With Go 1.27.0 on `PATH`,
|
||||
create the local workspace from the repository root if `go.work` is absent:
|
||||
|
||||
```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. The integrations select released core v0.4.0;
|
||||
ckprompt also selects released ckconfig v0.4.0.
|
||||
Current ckprompt source requires a ckconfig helper absent from that release, so
|
||||
its independent suite remains pending release and dependency alignment; the
|
||||
aligned workspace exercises the current source together.
|
||||
Run `GOWORK=off GOTOOLCHAIN=local go test ./...` separately from each module
|
||||
directory. From the repository root, a workspace containing all three modules can
|
||||
also run:
|
||||
|
||||
@@ -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
|
||||
syntax failures to core. It does not own the registry, typed input preparation,
|
||||
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.
|
||||
|
||||
## Optional modules
|
||||
|
||||
@@ -161,8 +161,8 @@ and deferred messages. Core owns shared visibility, formatting, delivery, and
|
||||
finalization. Applications and integrations choose their report content and
|
||||
messages; callers choose the destinations.
|
||||
[ADR 0006](../adr/0006-invocation-reporting.md) records this reporting and outcome
|
||||
ownership model. Its accepted structured-report extension remains planned in the
|
||||
[adoption roadmap](../roadmap/promptkit-v0.20.0.md#shared-core-report-output-prerequisite).
|
||||
ownership model. The [CLI reference](../cli.md#reporting) describes the
|
||||
implemented structured-report behavior.
|
||||
|
||||
For that shared report handoff, core owns format selection, serialization,
|
||||
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.
|
||||
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
|
||||
failure explanations after normal reporting finishes, avoid duplicate presentation
|
||||
through core's own paths, and retain the explanation and any supplied cause in
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
root; for integrations, run it from that module directory. Commit any resulting
|
||||
`go.mod` or `go.sum` changes with the release candidate. The sum files must
|
||||
reflect dependencies actually downloaded for that module; do not fabricate
|
||||
future sums.
|
||||
root; for integrations, run it from that module directory.
|
||||
|
||||
Before downloading dependencies, update each integration to the newly published
|
||||
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. In the current release
|
||||
sequence, ckprompt needs a ckconfig release containing `AdaptDiagnostics`;
|
||||
ckconfig v0.4.0 does not contain it. 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
|
||||
changes on `main`, and publish the candidate commit to `origin`. Set one
|
||||
|
||||
43
docs/releases/v0.5.0.md
Normal file
43
docs/releases/v0.5.0.md
Normal 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)
|
||||
253
docs/roadmap/documentation-refresh.md
Normal file
253
docs/roadmap/documentation-refresh.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# Documentation refresh
|
||||
|
||||
Status: implemented and verified. Release/dependency alignment remains separate
|
||||
work in [future.md](future.md#pending-release-and-integration-work).
|
||||
|
||||
## Purpose
|
||||
|
||||
Refresh CommandKit's documentation for the next minor release. Operators should
|
||||
be able to find a command, understand what it does, and interpret its results
|
||||
without learning the implementation. Application developers should have a short
|
||||
path from choosing a module to using its public API. Maintainer documentation
|
||||
and exact API contracts can retain technical detail where it is useful.
|
||||
|
||||
This roadmap captures the accepted documentation review recommendations. The
|
||||
[documentation policy](../policy/documentation.md) remains authoritative for
|
||||
audience, canonical ownership, examples, and document lifecycle. No new policy,
|
||||
documentation framework, or ADR is needed.
|
||||
|
||||
## Scope and boundaries
|
||||
|
||||
The sprint covers current READMEs, the CLI reference, consumer guidance,
|
||||
maintained example instructions, generated help descriptions, package
|
||||
introductions, and release notes being prepared for publication.
|
||||
|
||||
Preserve API and CLI behavior: command and flag names, defaults, format
|
||||
selection, report fields, status semantics, availability, and ownership rules.
|
||||
Source edits for help text and GoDoc are in scope; runtime refactoring and new
|
||||
features are not. If review exposes a behavioral defect or an unresolved
|
||||
contract, record it separately rather than silently changing behavior through
|
||||
an editorial revision.
|
||||
|
||||
Use existing documentation owners. Move useful detail to its appropriate owner
|
||||
or link to the existing explanation; do not duplicate contracts. Keep exact
|
||||
ownership, callback lifetime, error identity, and concurrency rules in GoDoc.
|
||||
Internal documentation and maintainer procedures need only changes justified by
|
||||
clarity or affected navigation, not a wholesale simplification.
|
||||
|
||||
Preserve accepted ADR decisions, historical published release records, license
|
||||
texts, and attribution. Apply release-note editing to unpublished candidates
|
||||
after checking their publication status under the
|
||||
[release policy](../policy/release.md). Dependency alignment and publication
|
||||
remain separate work under the [release procedure](../release.md).
|
||||
|
||||
## Audience and writing approach
|
||||
|
||||
| Audience | Starting points | Approach |
|
||||
| --- | --- | --- |
|
||||
| Prospective users | Root and integration READMEs | Explain purpose, useful capabilities, and where to start. |
|
||||
| CLI operators | CLI reference and generated help | Lead with tasks, commands, results, and relevant limitations. |
|
||||
| Go application developers | Consumer guide, examples, package introductions | Show a small useful integration first; explain choices and responsibilities next. |
|
||||
| API users and maintainers | Symbol GoDoc, internal docs, development and release guides | Retain precise technical detail needed for correct use and maintenance. |
|
||||
|
||||
Use short, direct sentences and familiar terms. Define necessary domain terms
|
||||
where first used; avoid a separate glossary unless one proves useful. For
|
||||
example, explain a configuration scope as a named configuration such as `app`
|
||||
or `promptkit`.
|
||||
|
||||
Where meaning permits, prefer “load configuration” to “acquire configuration,”
|
||||
“command output” to “substantive results,” and “files and other sources used to
|
||||
load configuration” to “source inventory captured during acquisition.” These
|
||||
are editorial examples, not a mechanical replacement list. Keep technical names
|
||||
when referring to an API symbol or a distinction that matters to the reader.
|
||||
|
||||
## Work areas
|
||||
|
||||
### 1. Product and module introductions
|
||||
|
||||
Update the [root README](../../README.md), [ckconfig README](../../integrations/ckconfig/README.md),
|
||||
and [ckprompt README](../../integrations/ckprompt/README.md).
|
||||
|
||||
- Explain what the library helps applications and operators do before describing
|
||||
declarations, adapters, or reporting internals.
|
||||
- Keep the module overview, explicit installation distinction, and navigation.
|
||||
Link to the consumer guide for compatibility and integration details.
|
||||
- Provide the shortest useful starting example or a direct link to it, rather
|
||||
than a catalog of callback counts or implementation responsibilities.
|
||||
|
||||
An illustrative root opening is: “CommandKit helps Go applications build
|
||||
consistent command-line interfaces. It provides typed flags and arguments,
|
||||
help, output formatting, and error reporting. Optional integrations add commands
|
||||
for inspecting configuration and working with PromptKit.” Final wording can
|
||||
evolve during implementation.
|
||||
|
||||
### 2. CLI tasks and reference detail
|
||||
|
||||
Refresh the [CLI reference](../cli.md), particularly reporting, configuration,
|
||||
prepared-request preview, requirements, and smoke testing.
|
||||
|
||||
- Begin each operation with its purpose and a small command example. Explain
|
||||
results and important limitations before detailed report fields.
|
||||
- Replace installation and callback introductions with an operator explanation
|
||||
of command availability; link to the consumer guide for application wiring.
|
||||
- Move API-only material such as callback construction, writer ownership, and
|
||||
`Outcome.Presentation` access to its existing developer-facing owner where
|
||||
appropriate. Retain observable output, failure, and status behavior here.
|
||||
- Organize detailed report shapes and edge cases into reference subsections so
|
||||
they remain available without interrupting the ordinary task.
|
||||
- Make application-defined flags and optional convenience aliases identifiable;
|
||||
examples must not imply every application installs them.
|
||||
|
||||
Retain distinctions operators need: omission versus an explicit empty value,
|
||||
concise versus detailed reports, independent comparison sides, redaction,
|
||||
partial evidence on failure, and which operations can contact providers.
|
||||
Explain that requirements discovery, request validation, prepared preview, and
|
||||
smoke testing answer different questions. Successful preparation does not
|
||||
guarantee later execution, and a smoke command's successful status does not
|
||||
mean every reported observation is positive.
|
||||
|
||||
### 3. Guided examples
|
||||
|
||||
Refresh the [core example](../../examples/core/README.md),
|
||||
[ConfigKit example](../../integrations/ckconfig/examples/simple/README.md), and
|
||||
[composed example](../../integrations/ckprompt/examples/composed/README.md).
|
||||
|
||||
- State the working directory, required Go version, and applicable workspace or
|
||||
independent-module setup before commands.
|
||||
- Start with a few useful commands and explain expected results beside them.
|
||||
Group additional examples by task rather than presenting one long command block.
|
||||
- Label intentional failures beside the relevant command, including how to
|
||||
interpret the result. Distinguish application status from `go run`'s own exit
|
||||
behavior when explaining shell results.
|
||||
- Explain early that the composed example uses local fixtures and requires no
|
||||
model service or credentials; do not imply this about every consuming app.
|
||||
- Put alias wiring, shared state, and cache explanations after the walkthrough,
|
||||
linking to API contracts rather than repeating them.
|
||||
|
||||
### 4. Consumer guide and public API orientation
|
||||
|
||||
Update the [consumer guide](../consumers/guide.md) and the package introductions
|
||||
in [core](../../doc.go), [ckconfig](../../integrations/ckconfig/doc.go), and
|
||||
[ckprompt](../../integrations/ckprompt/doc.go).
|
||||
|
||||
- Demonstrate `Reporter.Report` with a small value so the recommended path to
|
||||
shared `--format` support is visible. Explain raw output as a separate choice.
|
||||
- Make the initial greeting example's name, description, and output agree; it
|
||||
currently prints the count rather than a greeting.
|
||||
- Present module compatibility in a compact table that distinguishes published
|
||||
releases from current source requirements. Keep exact version guidance in
|
||||
this owner and link from module introductions.
|
||||
- Lead package introductions with purpose and primary entry points. Keep exact
|
||||
symbol contracts in GoDoc, including factory timing, supplied values, error
|
||||
handling, and object ownership.
|
||||
|
||||
Recheck compatibility statements when implementing the refresh. Current source
|
||||
and released dependencies can differ; the
|
||||
[pending alignment work](future.md#pending-release-and-integration-work) owns
|
||||
the outstanding release work. Do not present workspace success as proof that
|
||||
the selected published dependencies support an independent build.
|
||||
|
||||
### 5. Generated help
|
||||
|
||||
Review command summaries, descriptions, flag usage, and argument usage across
|
||||
core and both integrations. These strings are operator documentation even when
|
||||
stored in Go source.
|
||||
|
||||
Use action-oriented descriptions and explain choices where useful. In
|
||||
particular, the [smoke command](../../integrations/ckprompt/smoke_command.go)
|
||||
should explain that it can run one test or all tests and identify accepted kinds;
|
||||
“Run one PromptKit smoke test” and “Native smoke-test kind” are insufficient.
|
||||
The [request flag descriptions](../../integrations/ckprompt/request_flags.go)
|
||||
can use direct wording such as “Use this model.” Preserve flag names, grammar,
|
||||
defaults, and supported operations.
|
||||
|
||||
### 6. Release notes
|
||||
|
||||
Review the [core v0.5.0 note](../releases/v0.5.0.md) and applicable unpublished
|
||||
integration candidates for the upcoming releases. Explain user benefits before
|
||||
implementation mechanisms: accepted deferred messages are no longer lost,
|
||||
YAML values retain their intended types, and installation and help avoid repeated
|
||||
work. Remove unnecessary discussion of scratch paths, chain suffixes, and batch
|
||||
detachment from the release summary.
|
||||
|
||||
Retain compatibility guidance, required consumer actions, meaningful API names,
|
||||
and license/attribution links. Verify candidate dependencies against the versions
|
||||
actually selected for publication. Do not invent release dates or performance
|
||||
guarantees, or revise published historical notes as part of this refresh.
|
||||
|
||||
## Suggested sprint sequence
|
||||
|
||||
1. Refresh README introductions and the CLI reference together to establish
|
||||
vocabulary and the operator/developer split.
|
||||
2. Update example walkthroughs and generated help to match that language.
|
||||
3. Polish consumer guidance, package introductions, and candidate release notes.
|
||||
4. Review the changed documentation together for consistency and validate the
|
||||
affected examples, help, and navigation.
|
||||
|
||||
No unresolved product decision blocks implementation planning. Exact wording,
|
||||
section placement, and illustrative examples are ordinary editorial choices.
|
||||
|
||||
## Validation and completion checklist
|
||||
|
||||
Use the existing [documentation checks](../development.md#documentation-validation)
|
||||
and [testing policy](../policy/testing.md). Review prose and local links; run
|
||||
affected maintained examples and inspect representative help when those change.
|
||||
For Go example or help-source edits, run the relevant existing package tests.
|
||||
Do not add wording snapshots, readability gates, or a new checker framework.
|
||||
Record workspace and independent-module results separately, including any
|
||||
dependency limitation. This work adds no release gates.
|
||||
|
||||
- [x] README openings explain purpose and give a clear starting path.
|
||||
- [x] CLI sections explain operator tasks before reference detail; necessary
|
||||
behavior, report fields, status rules, and limitations remain discoverable.
|
||||
- [x] Example setup is explicit, commands have nearby explanations, and intended
|
||||
failures are labeled.
|
||||
- [x] Consumer guidance demonstrates formatted reporting, has a coherent initial
|
||||
example, and distinguishes published compatibility from current source needs.
|
||||
- [x] Package introductions and generated help use clear, accurate language;
|
||||
precise API contracts remain intact.
|
||||
- [x] Unpublished release notes explain benefits and consumer impact while
|
||||
historical records and required notices remain preserved.
|
||||
- [x] Changed commands, snippets, and help have appropriate verification; local
|
||||
links and Markdown whitespace checks pass.
|
||||
- [x] Final review confirms consistent terminology, canonical ownership, and no
|
||||
unintended behavior or contract changes.
|
||||
|
||||
## Implementation and verification
|
||||
|
||||
Completed the six work areas above. READMEs and package introductions now lead
|
||||
with purpose and starting points; the CLI reference separates operator tasks
|
||||
from report detail and developer wiring. Examples are grouped by task with
|
||||
setup, expected results, and intentional failures explained nearby. The consumer
|
||||
guide demonstrates shared report formatting and distinguishes published modules
|
||||
from current source. Help strings and unpublished candidate notes use the same
|
||||
plain language. Exact symbol contracts and runtime behavior were preserved.
|
||||
|
||||
Verification used Go 1.27.0 and existing cached dependencies:
|
||||
|
||||
- The aligned workspace suites passed for core, ckconfig, and ckprompt, including
|
||||
their maintained examples.
|
||||
- Independent core and ckconfig suites passed with `GOWORK=off`.
|
||||
- Independent ckprompt compilation confirmed the existing dependency limitation:
|
||||
released ckconfig v0.4.0 lacks `AdaptDiagnostics`. No module requirements,
|
||||
checksums, or workspace files were changed to hide it. Its README, example,
|
||||
consumer guidance, and candidate note identify the pending alignment.
|
||||
- All 48 `go run` invocations in the three example READMEs were exercised with
|
||||
binaries built from the aligned workspace, from the documented directories.
|
||||
Results matched the walkthroughs, including two failed-load examples, missing
|
||||
request input, and comparison status 3. Shared help and completion behavior
|
||||
also passed the existing suites; representative rendered help was inspected.
|
||||
- Both new consumer-guide report snippets compiled in a temporary consumer using
|
||||
local core. Six checks confirmed greeting counts, empty-list output, and YAML
|
||||
and JSON formatting.
|
||||
- Local Markdown links, including incoming links to renamed sections, and
|
||||
whitespace were checked. Go source changes are limited to help strings,
|
||||
package comments, and formatting; no new tests or validation framework were
|
||||
added.
|
||||
- The authoritative remote had neither the core v0.5.0 nor ckprompt v0.3.0 tag
|
||||
when their candidate notes were revised. Published notes, accepted ADRs,
|
||||
policies, and license/attribution records were left intact.
|
||||
|
||||
The updated ckprompt candidate still requires dependency guidance to be finalized
|
||||
when release alignment occurs. This is release preparation, not unfinished
|
||||
editorial work or evidence of an independently buildable candidate.
|
||||
@@ -1,16 +1,22 @@
|
||||
# Future work
|
||||
|
||||
Status: pending release and integration work, plus future capability proposals.
|
||||
Status: pending release/dependency alignment, optional shell validation, and
|
||||
future capability proposals.
|
||||
No delivery dates are selected here.
|
||||
|
||||
## 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
|
||||
[release policy](../policy/release.md#release-documentation-and-preparation).
|
||||
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
|
||||
available in the
|
||||
[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,
|
||||
and remediation records remain available at the
|
||||
[retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap).
|
||||
@@ -24,53 +30,46 @@ an idea here does not select its syntax, API, priority, or delivery date.
|
||||
|
||||
## Pending release and integration work
|
||||
|
||||
### Audit remediation release alignment
|
||||
|
||||
The audit remediation is complete in source. The released ckconfig v0.4.0 does
|
||||
not include `AdaptDiagnostics`, which current ckprompt source uses. Select and
|
||||
publish appropriate releases in dependency order, align module requirements and
|
||||
checksums, and rerun independent module suites under the
|
||||
[release procedure](../release.md). No publication is implied by source completion.
|
||||
|
||||
### ckprompt release
|
||||
|
||||
The PromptKit v0.18.0 adoption is complete. The
|
||||
The PromptKit v0.20.0 adoption is complete. The
|
||||
[ckprompt v0.3.0 candidate](../../integrations/ckprompt/docs/releases/v0.3.0.md)
|
||||
remains unpublished. Publication and hosted delivery remain separate operations
|
||||
remains unpublished and needs revalidation against the current source and aligned
|
||||
dependencies. Publication and hosted delivery remain separate operations
|
||||
under the [release procedure](../release.md).
|
||||
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,
|
||||
the shared core reporting/input prerequisites, operator CLI additions for
|
||||
PromptKit and ConfigKit, recorded decisions, and completion checklist. It targets
|
||||
a clean alpha adoption without backward-compatibility provisions or consumer
|
||||
migration guidance. Implementation has not started. Use released upstream
|
||||
contracts only; do not inspect or monitor unreleased work in anticipation of
|
||||
adoption.
|
||||
Zsh and Fish interactive completion checks remain unverified because those
|
||||
executables were unavailable during remediation. Script generation for Bash,
|
||||
Zsh and Fish and the Bash interactive checks passed. Run the existing opt-in
|
||||
backend and consumer checks when those prerequisites are available, following
|
||||
the [developer guide](../development.md#shell-completion-validation).
|
||||
|
||||
## Future capability targets
|
||||
|
||||
### 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.
|
||||
Revisit standardized syntax and binding support for explicitly replacing a
|
||||
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
|
||||
or public helper API is selected.
|
||||
|
||||
### Configuration inspection extensions
|
||||
|
||||
The [working roadmap](promptkit-v0.20.0.md#shared-configuration-capabilities)
|
||||
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.
|
||||
Persistent configuration editing remains deferred.
|
||||
|
||||
Discovery, import defaults, collection identities, and field-level resolution
|
||||
recording remain available through application loaders. Discovery tracing,
|
||||
@@ -81,28 +80,8 @@ application loading and startup.
|
||||
|
||||
### PromptKit inspection and selection extensions
|
||||
|
||||
The [PromptKit v0.20.0 roadmap](promptkit-v0.20.0.md#targeted-end-state)
|
||||
owns native prompt/profile/backend resource inspection, concrete request preview,
|
||||
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.
|
||||
General-purpose helpers for application-internal PromptKit use remain outside
|
||||
CommandKit's ownership boundary. The following CLI extensions are deferred.
|
||||
|
||||
- **Output-contract override flags.** Deferred from the current adoption. A
|
||||
possible future operator task is validating or previewing one prompt with a
|
||||
@@ -124,11 +103,6 @@ released upstream identity requirement; it does not change prompt dependencies.
|
||||
Deferring flags does not remove native appended messages from preview reports
|
||||
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
|
||||
|
||||
The delivered [shell completion feature](../cli.md#shell-completion) provides
|
||||
@@ -145,15 +119,8 @@ steps. No delivery date or final API is selected here.
|
||||
|
||||
### Reporting extensions
|
||||
|
||||
The [shared core report output prerequisite](promptkit-v0.20.0.md#shared-core-report-output-prerequisite)
|
||||
owns application-selected content, core rendering, universal/automatic format
|
||||
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.
|
||||
XML is outside current consideration; Markdown and other renderers remain
|
||||
unselected.
|
||||
|
||||
Error-conversion helpers such as `WarnError` remain deferred pending demonstrated
|
||||
value; applications can already use `report.Warn(err.Error())`. Terminal redraw
|
||||
@@ -183,9 +150,8 @@ operator or consumer need and an appropriate ownership/behavior decision:
|
||||
and output-file management or reloadable export.
|
||||
- Shell completion extensions, including alias discovery described above and
|
||||
dynamic discovery of configured or remote values.
|
||||
- Persistent configuration mutation. Transient CLI overrides are selected under
|
||||
roadmap OD-11; field metadata and provenance inspection are scoped under
|
||||
[configuration inspection extensions](#configuration-inspection-extensions).
|
||||
- Persistent configuration mutation. Transient CLI overrides and field metadata
|
||||
inspection are available through the standard configuration suite.
|
||||
- Credential diagnostics beyond PromptKit's native smoke checks and additional
|
||||
live provider probes.
|
||||
- Additional component identity registration and domain artifact catalogs.
|
||||
@@ -194,10 +160,7 @@ operator or consumer need and an appropriate ownership/behavior decision:
|
||||
- Backend selection or replacement, a plugin framework, general dependency
|
||||
injection, and concurrent-dispatch guarantees.
|
||||
|
||||
Execution, serving, and session operations remain application-owned. The
|
||||
[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.
|
||||
Execution, serving, and session operations remain application-owned.
|
||||
The [architecture policy](../policy/architecture.md#command-and-application-ownership)
|
||||
recommends shared vocabulary; the [CLI reference](../cli.md#registered-command-syntax)
|
||||
owns implemented grammar.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,70 @@
|
||||
# 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
|
||||
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
|
||||
# ws is a shortcut for the workspace group.
|
||||
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 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
|
||||
./core-example completion bash
|
||||
```
|
||||
|
||||
The example installs a canonical `workspace greet` command together with a
|
||||
command shortcut, a group shortcut, a scoped flag alias, and a global help
|
||||
alias. Its declaration slice mixes the canonical group and routing rules as
|
||||
`[]commandkit.Declaration`. The handler prints `Invocation.EnteredPath()` and
|
||||
`Invocation.CanonicalPath()` so the alternate route and its canonical target
|
||||
are visible in the output.
|
||||
This prints the Bash completion script. Initialize `bash-completion`, then load
|
||||
the script as described in the [CLI reference](../../docs/cli.md#shell-completion).
|
||||
Use the executable basename `core-example` and try completing
|
||||
`core-example report d` or `core-example ws gr`. Other supported shells are
|
||||
listed in the reference.
|
||||
|
||||
The example also installs `Completion("core-example")` and gives the `report`
|
||||
scenario argument static choices. Initialize `bash-completion` first if using
|
||||
Bash. Build it with the basename shown above, load
|
||||
the script for your shell using the [CLI reference](../../docs/cli.md#shell-completion),
|
||||
and try completing `core-example report d` or `core-example ws gr`.
|
||||
## How it is wired
|
||||
|
||||
Compatible integration factories also return `commandkit.Command` and can be
|
||||
passed directly to `New` beside `commandkit.Alias` or flag-alias declarations.
|
||||
The matched ckconfig and ckprompt releases use the new core identity. For a
|
||||
command-only `[]commandkit.Command`, convert its elements into a
|
||||
`[]commandkit.Declaration` before appending routing rules; Go cannot expand the
|
||||
command slice as `...commandkit.Declaration`. The
|
||||
The example installs `workspace greet` alongside command and flag aliases in a
|
||||
`[]commandkit.Declaration`. It also installs `Completion("core-example")` and
|
||||
provides static choices for the reporting scenario argument. The
|
||||
[consumer guide](../../docs/consumers/guide.md#application-owned-aliases)
|
||||
shows both forms, and the [composed consumer](../../integrations/ckprompt/examples/composed)
|
||||
is the maintained integration example.
|
||||
explains declaration composition; the [composed example](../../integrations/ckprompt/examples/composed)
|
||||
adds integration commands.
|
||||
|
||||
These aliases require core v0.3.0. Repository contributors can run its test with
|
||||
`go test ./examples/core` from the root.
|
||||
Run `GOWORK=off GOTOOLCHAIN=local go test ./examples/core` from the repository
|
||||
root to check this example.
|
||||
|
||||
@@ -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.CompletionChoice{Value: "demo"}, commandkit.CompletionChoice{Value: "recover"}, commandkit.CompletionChoice{Value: "warn"},
|
||||
commandkit.CompletionChoice{Value: "warn-error"}, commandkit.CompletionChoice{Value: "fail"}, commandkit.CompletionChoice{Value: "partial"})}, false, func(value string) (string, error) { return value, nil },
|
||||
func(input *reportInput, value commandkit.Supplied[string]) { input.Scenario = value.Value }),
|
||||
func(input *reportInput, value commandkit.Supplied[string]) {
|
||||
if value.Set {
|
||||
input.Scenario = value.Value
|
||||
}
|
||||
}),
|
||||
},
|
||||
CheckInput: func(input reportInput) error {
|
||||
switch input.Scenario {
|
||||
|
||||
11
execute.go
11
execute.go
@@ -80,10 +80,8 @@ func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outco
|
||||
type execution struct {
|
||||
app *App
|
||||
ctx context.Context
|
||||
streams Streams
|
||||
reporter *Reporter
|
||||
debugEvents []string
|
||||
options commandmeta.ParseOptions
|
||||
snapshot *routingSnapshot
|
||||
session *internalcobra.Session
|
||||
route routeResult
|
||||
@@ -116,13 +114,10 @@ func (current commandExecution) renderHelpTargets(targets []string) Outcome {
|
||||
}
|
||||
|
||||
func newExecution(app *App, ctx context.Context, streams Streams) *execution {
|
||||
streams = normalizeStreams(streams)
|
||||
current := &execution{
|
||||
app: app,
|
||||
ctx: ctx,
|
||||
streams: streams,
|
||||
reporter: newReporter(streams),
|
||||
options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
|
||||
}
|
||||
if app != nil {
|
||||
current.snapshot = app.routeSnapshot()
|
||||
@@ -147,7 +142,7 @@ func (current *execution) prepareSession() {
|
||||
if current.snapshot == nil {
|
||||
current.snapshot = current.app.routeSnapshot()
|
||||
}
|
||||
options := current.options
|
||||
options := commandmeta.ParseOptions{}
|
||||
if current.snapshot != nil {
|
||||
switch current.route.kind {
|
||||
case routeCanonical:
|
||||
@@ -201,11 +196,11 @@ func (current *execution) renderHelpTargets(targets []string) Outcome {
|
||||
// Explicit help targets are paths, not command invocations with operands.
|
||||
// Discovery routes have no canonical path and retain only trailing options.
|
||||
if route.kind == routeUnknown || len(route.args) != len(route.canonicalPath) {
|
||||
session := internalcobra.Prepare(snapshot.metadata, current.options)
|
||||
session := internalcobra.Prepare(snapshot.metadata, commandmeta.ParseOptions{})
|
||||
view := commandmeta.HelpView{EnteredPath: targets, CanonicalPath: targets}
|
||||
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
|
||||
}
|
||||
options := current.options
|
||||
options := commandmeta.ParseOptions{}
|
||||
switch route.kind {
|
||||
case routeCanonical:
|
||||
options.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()
|
||||
|
||||
103
flag_aliases.go
103
flag_aliases.go
@@ -66,6 +66,7 @@ func (spelling capturedFlagSpelling) token() string {
|
||||
|
||||
func compileFlagAliases(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
|
||||
Resolve(commandpath.Path) commandpath.Path
|
||||
ProperSourcePrefixes() []commandpath.Path
|
||||
}) (map[string]resolvedFlagAliases, map[string]resolvedFlagAliases, error) {
|
||||
canonicalNodes := indexCanonicalNodes(roots)
|
||||
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 {
|
||||
Resolve(commandpath.Path) commandpath.Path
|
||||
ProperSourcePrefixes() []commandpath.Path
|
||||
}, canonicalNodes map[string]*canonicalNode) map[string]flagAliasContext {
|
||||
commandSources := make(map[string]struct{})
|
||||
prefixes := make(map[string]commandpath.Path)
|
||||
@@ -178,26 +180,20 @@ func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode,
|
||||
commandSources[rule.record.sourcePath.Key()] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if rule.record.kind != commandAliasRule {
|
||||
for _, prefix := range aliases.ProperSourcePrefixes() {
|
||||
key := prefix.Key()
|
||||
if canonicalNodes[key] != nil {
|
||||
continue
|
||||
}
|
||||
for length := 1; length < len(rule.record.sourcePath); length++ {
|
||||
prefix := rule.record.sourcePath[:length].Clone()
|
||||
key := prefix.Key()
|
||||
if canonicalNodes[key] != nil {
|
||||
continue
|
||||
}
|
||||
if _, exactAlias := commandSources[key]; exactAlias {
|
||||
continue
|
||||
}
|
||||
resolved := aliases.Resolve(prefix)
|
||||
trace := traceCanonicalPath(roots, resolved)
|
||||
if trace.exact != nil || (trace.operandOwner != nil && acceptsOperandPrefix(trace.operandOwner.command, len(resolved)-len(trace.operandOwner.path))) {
|
||||
continue
|
||||
}
|
||||
prefixes[key] = prefix
|
||||
if _, exactAlias := commandSources[key]; exactAlias {
|
||||
continue
|
||||
}
|
||||
resolved := aliases.Resolve(prefix)
|
||||
trace := traceCanonicalPath(roots, resolved)
|
||||
if trace.exact != nil || (trace.operandOwner != nil && acceptsOperandPrefix(trace.operandOwner.command, len(resolved)-len(trace.operandOwner.path))) {
|
||||
continue
|
||||
}
|
||||
prefixes[key] = prefix
|
||||
}
|
||||
contexts := make(map[string]flagAliasContext, len(prefixes))
|
||||
for key, path := range prefixes {
|
||||
@@ -230,14 +226,54 @@ func resolveFlagAliases(context flagAliasContext, mappings map[string]flagAliasM
|
||||
sources = append(sources, source)
|
||||
}
|
||||
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 {
|
||||
mapping := mappings[sourceKey]
|
||||
source := spellingFromKey(sourceKey)
|
||||
target, active, err := resolveFlagTarget(source, mapping.target, mappings, context.canonical)
|
||||
terminal, err := resolve(mapping.target)
|
||||
if err != nil {
|
||||
return resolvedFlagAliases{}, fmt.Errorf("%s: %w", flagContextDescription(context), err)
|
||||
}
|
||||
if !active {
|
||||
if !terminal.active {
|
||||
if mapping.scoped {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
func resolveFlagTarget(source, target capturedFlagSpelling, mappings map[string]flagAliasMapping, canonical map[string]capturedFlagSpelling) (capturedFlagSpelling, bool, error) {
|
||||
visited := map[string]int{flagSpellingKey(source): 0}
|
||||
chain := []capturedFlagSpelling{source}
|
||||
current := target
|
||||
for {
|
||||
key := flagSpellingKey(current)
|
||||
if endpoint, exists := canonical[key]; exists {
|
||||
return endpoint, true, nil
|
||||
}
|
||||
if start, exists := visited[key]; exists {
|
||||
cycle := append(append([]capturedFlagSpelling(nil), chain[start:]...), current)
|
||||
spellings := make([]string, 0, len(cycle))
|
||||
for _, spelling := range cycle {
|
||||
spellings = append(spellings, spelling.token())
|
||||
}
|
||||
return capturedFlagSpelling{}, false, fmt.Errorf("flag alias cycle: %s", strings.Join(spellings, " → "))
|
||||
}
|
||||
visited[key] = len(chain)
|
||||
chain = append(chain, current)
|
||||
next, exists := mappings[key]
|
||||
if !exists {
|
||||
return capturedFlagSpelling{}, false, nil
|
||||
}
|
||||
current = next.target
|
||||
}
|
||||
}
|
||||
|
||||
func spellingFromKey(key string) capturedFlagSpelling {
|
||||
if strings.HasPrefix(key, "short:") {
|
||||
return capturedFlagSpelling{name: strings.TrimPrefix(key, "short:"), short: true}
|
||||
|
||||
@@ -295,3 +295,21 @@ func capturedSpelling(token string) capturedFlagSpelling {
|
||||
issues := []error(nil)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
var ordered []preparedOccurrence[I]
|
||||
for _, prepared := range preparedValues {
|
||||
var ordered occurrenceHeap[I]
|
||||
for index, prepared := range preparedValues {
|
||||
if prepared.apply != nil {
|
||||
prepared.apply(target)
|
||||
}
|
||||
for _, occurrence := range prepared.ordered {
|
||||
if occurrence.apply != nil {
|
||||
ordered = append(ordered, occurrence)
|
||||
}
|
||||
stream := occurrenceStream[I]{events: prepared.ordered, order: index}
|
||||
stream.skipUnapplied()
|
||||
if len(stream.events) > 0 {
|
||||
ordered = append(ordered, stream)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(ordered, func(i, j int) bool { return *ordered[i].ordinal < *ordered[j].ordinal })
|
||||
for _, occurrence := range ordered {
|
||||
occurrence.apply(target)
|
||||
// One selected stream already has the required order.
|
||||
if len(ordered) == 1 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if errors.Is(err, ErrFlagWiring) {
|
||||
return Failure(err.Error()).WithCause(err)
|
||||
|
||||
@@ -36,11 +36,8 @@ func routeHelpAliases(snapshot *routingSnapshot, route routeResult) []commandmet
|
||||
}
|
||||
bySource := make(map[string]commandmeta.CommandAlias)
|
||||
for _, prefix := range snapshot.aliases.ResolutionPaths(route.enteredPath) {
|
||||
for _, alias := range snapshot.commandAliases {
|
||||
source := commandpath.FromTokens(alias.Source)
|
||||
if len(source) <= len(prefix) || !source.HasPrefix(prefix) {
|
||||
continue
|
||||
}
|
||||
for _, alias := range snapshot.aliases.Descendants(prefix) {
|
||||
source := alias.Source
|
||||
suffix := source[len(prefix):]
|
||||
entered := append(route.enteredPath.Clone(), suffix...)
|
||||
target := snapshot.aliases.Resolve(entered)
|
||||
|
||||
@@ -1,36 +1,30 @@
|
||||
# CommandKit ckconfig
|
||||
|
||||
`ckconfig` is the optional ConfigKit integration for CommandKit. It adapts
|
||||
ConfigKit loaders and scopes into reusable `commandkit.Command` declarations;
|
||||
installing the integration is explicit and does not modify an application by
|
||||
itself.
|
||||
`ckconfig` adds configuration inspection commands to Go applications that use
|
||||
CommandKit and ConfigKit. Operators can view settings, validate configuration,
|
||||
find where values came from, and compare two configurations.
|
||||
|
||||
The application chooses which commands to install. Importing the package alone
|
||||
does not install commands.
|
||||
|
||||
## Module
|
||||
|
||||
The module path is `go.maximumdirect.net/ckconfig`. The v0.4.0 candidate requires Go
|
||||
1.27.0 and uses `go.maximumdirect.net/commandkit v0.4.0` and
|
||||
`go.maximumdirect.net/configkit v1.1.0`; it does not import PromptKit. See the
|
||||
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#configkit-scopes)
|
||||
for exact callback and lifetime contracts.
|
||||
The module path is `go.maximumdirect.net/ckconfig`. It does not import PromptKit.
|
||||
See the [consumer guide](https://forge.maximumdirect.net/base-layer/commandkit/src/branch/main/docs/consumers/guide.md#module-selection)
|
||||
for compatible releases and Go requirements.
|
||||
|
||||
## Example
|
||||
|
||||
For one application configuration scope, install the convenience declaration:
|
||||
To inspect one application configuration, pass your loader to `AppCommand`:
|
||||
|
||||
```go
|
||||
app, err := commandkit.New(identity, ckconfig.AppCommand(loadAppConfig))
|
||||
```
|
||||
|
||||
Use `ckconfig.Command(ckconfig.Suite[Input, State]{...})` to define named
|
||||
scopes, selectors, input checks, and state shared by the selected acquisitions
|
||||
or comparison in a config invocation. `ckconfig.FromConfigKit` adapts a typed
|
||||
ConfigKit loader. The `config` command family exposes scope discovery and the
|
||||
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.
|
||||
For multiple named configurations or application-specific flags, use
|
||||
`ckconfig.Command` with a `ckconfig.Suite`. The
|
||||
[integration guide](https://forge.maximumdirect.net/base-layer/commandkit/src/branch/main/docs/consumers/guide.md#configkit-scopes)
|
||||
explains loader setup, inputs, and shared state.
|
||||
|
||||
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)
|
||||
|
||||
@@ -43,17 +43,24 @@ func FromConfigKit[I, S, T any](
|
||||
acquired.Value.Sources = loadError
|
||||
acquired.Value.Findings = &Findings{Records: records, Messages: messages}
|
||||
acquired.Explanation = explanation
|
||||
return acquired, err
|
||||
}
|
||||
return acquired, err
|
||||
}
|
||||
if result == nil {
|
||||
if err != nil {
|
||||
return acquired, err
|
||||
}
|
||||
return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
|
||||
}
|
||||
metadata := result.Metadata()
|
||||
records := result.Diagnostics()
|
||||
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
|
||||
if err != nil {
|
||||
return acquired, err
|
||||
}
|
||||
metadata := result.Metadata()
|
||||
acquired.Value.Effective, acquired.Value.Explanation, acquired.Value.Metadata = result, result, &metadata
|
||||
return acquired, nil
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
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 {
|
||||
return nil, commandkit.Explanation{}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,9 @@ type ComparisonRequest[I any] struct {
|
||||
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 {
|
||||
WriteEffective(io.Writer) error
|
||||
}
|
||||
@@ -73,14 +75,12 @@ type SourceReader interface {
|
||||
Sources() []configkit.SourceInfo
|
||||
}
|
||||
|
||||
// ExplanationReader reads and writes a configuration's native path explanation.
|
||||
// ExplanationReader acquires a configuration's native path explanation.
|
||||
type ExplanationReader interface {
|
||||
Explain(configkit.Path) (configkit.Explanation, bool)
|
||||
WriteExplanation(io.Writer, configkit.Path) error
|
||||
}
|
||||
|
||||
// View exposes native configuration facets without reconstructing their
|
||||
// reports.
|
||||
// View exposes native configuration evidence used to build reports.
|
||||
type View struct {
|
||||
Effective EffectiveWriter
|
||||
Sources SourceReader
|
||||
|
||||
@@ -117,13 +117,6 @@ func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
|
||||
if _, ok := acquired.Value.Explanation.Explain(path); !ok {
|
||||
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) {
|
||||
@@ -140,8 +133,12 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
||||
if loadErr == nil {
|
||||
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) {
|
||||
return nil, loadErr
|
||||
return partial, loadErr
|
||||
})
|
||||
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||
if gotErr != loadErr {
|
||||
@@ -157,6 +154,9 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
||||
if acquired.Explanation.Message != "" || len(acquired.Explanation.Details) != 1 {
|
||||
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
|
||||
if err := acquired.Value.Findings.Messages[0].Write(&warning); err != nil {
|
||||
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"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loadErr := errors.New("loader returned a value with an error")
|
||||
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
|
||||
return result, loadErr
|
||||
})
|
||||
|
||||
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||
if gotErr != loadErr {
|
||||
t.Fatalf("load error identity = %v, want %v", gotErr, loadErr)
|
||||
for _, cause := range []error{errors.New("post-load check failed"), context.Canceled} {
|
||||
t.Run(cause.Error(), func(t *testing.T) {
|
||||
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{}{})
|
||||
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("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 {
|
||||
t.Fatalf("failed load exposed result facets: %#v", acquired.Value)
|
||||
empty, err := configkit.Load[map[string]any](configkit.Mapping("empty", map[string]any{}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(acquired.Explanation.Details) != 0 {
|
||||
t.Fatalf("warning-only result acquired terminal context: %#v", acquired.Explanation)
|
||||
for _, result := range []*configkit.Result[map[string]any]{nil, empty} {
|
||||
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) {
|
||||
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 {
|
||||
t.Fatalf("warning diagnostics = %#v", warning)
|
||||
}
|
||||
if noExplanation.Message != "" || len(noExplanation.Details) != 0 {
|
||||
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.SeverityError, Message: "safe error"},
|
||||
})
|
||||
if len(withWarning) != 1 || withWarning[0].Severity != commandkit.SeverityWarning || len(withExplanation.Details) != 1 {
|
||||
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) {
|
||||
t.Fatalf("short writer error = %v, want %v", err, io.ErrShortWrite)
|
||||
@@ -243,10 +283,11 @@ func TestConfigKitTerminalContextRetainsRelatedLocationsAndSnapshots(t *testing.
|
||||
Primary: primary,
|
||||
Related: related,
|
||||
}}
|
||||
_, explanation := configKitDiagnostics("app", sources, records)
|
||||
_, explanation := AdaptDiagnostics("Configuration scope \"app\":\n", sources, records)
|
||||
primary.SourceID = "mutated-primary"
|
||||
related[0].SourceID = "mutated-related"
|
||||
sources[0].Path = "mutated.yml"
|
||||
records[0].Message = "mutated message"
|
||||
|
||||
if len(explanation.Details) != 1 {
|
||||
t.Fatalf("explanation = %#v", explanation)
|
||||
@@ -260,7 +301,7 @@ func TestConfigKitTerminalContextRetainsRelatedLocationsAndSnapshots(t *testing.
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
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)
|
||||
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})
|
||||
|
||||
@@ -8,39 +8,17 @@ import (
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
func decodeJSONPointer(pointer string) ([]string, error) {
|
||||
func decodeJSONPointer(path configkit.Path) []string {
|
||||
pointer := path.String()
|
||||
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:], "/")
|
||||
decoder := strings.NewReplacer("~1", "/", "~0", "~")
|
||||
for index, part := range parts {
|
||||
var decoded strings.Builder
|
||||
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()
|
||||
parts[index] = decoder.Replace(part)
|
||||
}
|
||||
return parts, nil
|
||||
return parts
|
||||
}
|
||||
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
tokens, err := decodeJSONPointer(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("difference at %q: %w", path, err)
|
||||
}
|
||||
tokens := decodeJSONPointer(difference.Path)
|
||||
value, err := commandkit.DecodeYAMLReport(strings.NewReader(difference.Right.YAML))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("difference at %q: %w", path, err)
|
||||
|
||||
@@ -3,6 +3,8 @@ package ckconfig
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
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: "/a~1b/c~0d", want: []string{"a/b", "c~d"}},
|
||||
{pointer: "/~01", want: []string{"~1"}},
|
||||
{pointer: "/café", want: []string{"café"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got, err := decodeJSONPointer(test.pointer)
|
||||
path, err := configkit.ParsePath(test.pointer)
|
||||
if err != nil {
|
||||
t.Fatalf("decode %q: %v", test.pointer, err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := decodeJSONPointer(path)
|
||||
if !reflect.DeepEqual(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) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -16,7 +17,7 @@ func TestExplainDiscoveryModesAndMetadata(t *testing.T) {
|
||||
loads, inputs, states := 0, 0, 0
|
||||
scope := FromConfigKit("app", "", func(_ context.Context, selection Selection, _, _ struct{}) (*configkit.Result[map[string]any], error) {
|
||||
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
|
||||
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")
|
||||
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)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
@@ -48,11 +53,46 @@ func TestExplainDiscoveryModesAndMetadata(t *testing.T) {
|
||||
t.Fatalf("escaped paths: %#v", report.Paths)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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")
|
||||
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)
|
||||
}
|
||||
removed := false
|
||||
@@ -64,8 +104,8 @@ func TestExplainDiscoveryModesAndMetadata(t *testing.T) {
|
||||
if !removed {
|
||||
t.Fatalf("missing historical entry: %q", out)
|
||||
}
|
||||
result, out = run("--list", "--historical", "--explicit=false", "--set=/gone=null", "--format=json")
|
||||
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil {
|
||||
result, out = run("--list", "--under=/gone", "--historical", "--explicit=false", "--set=/gone=null", "--format=json")
|
||||
if result.Kind != commandkit.OutcomeSuccess || decode(out) != nil {
|
||||
t.Fatalf("explicit filter: %#v %q", result, out)
|
||||
}
|
||||
for _, entry := range report.Paths {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// Package ckconfig, provided by go.maximumdirect.net/ckconfig, provides
|
||||
// CommandKit's optional ConfigKit v1.1.0 integration, requiring Go 1.27.0. Its
|
||||
// canonical Command declaration installs the standard config command family
|
||||
// through the core commandkit package. Applications can install CommandKit
|
||||
// RoutingRule declarations alongside it through commandkit.New or
|
||||
// App.Register. Source reports preserve ConfigKit's destination, placement,
|
||||
// version, and empty-import evidence. Invocation reporting applies CommandKit's shared
|
||||
// visibility and delivery policy; provider warnings remain independent of
|
||||
// failure explanations. Importing ckconfig alone installs no commands.
|
||||
// Package ckconfig adds commands to view, validate, explain, and compare
|
||||
// ConfigKit configuration in CommandKit applications.
|
||||
//
|
||||
// Use AppCommand for one application configuration loader, or Command with a
|
||||
// Suite for multiple named configurations and application-specific flags.
|
||||
// Install the returned command with commandkit.New or App.Register.
|
||||
// Importing this package alone installs no commands.
|
||||
//
|
||||
// ConfigKit owns configuration loading and interpretation. CommandKit formats
|
||||
// reports and messages using its shared reporting policy.
|
||||
package ckconfig
|
||||
|
||||
39
integrations/ckconfig/docs/releases/v0.5.0.md
Normal file
39
integrations/ckconfig/docs/releases/v0.5.0.md
Normal 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)
|
||||
@@ -1,37 +1,90 @@
|
||||
# Simple consumer
|
||||
# ConfigKit example
|
||||
|
||||
This example imports `go.maximumdirect.net/ckconfig` and
|
||||
`go.maximumdirect.net/commandkit`; the ckconfig v0.4.0 candidate uses core
|
||||
v0.4.0, ConfigKit v1.1.0, and Go 1.27.0. Set `GOWORK=off GOTOOLCHAIN=local`
|
||||
for the commands below.
|
||||
This example adds configuration inspection and a custom `greet` command to a
|
||||
small application. It uses local fixtures and in-memory defaults, with no
|
||||
external services. See [module selection](../../../../docs/consumers/guide.md#module-selection)
|
||||
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
|
||||
cd integrations/ckconfig/examples/simple
|
||||
export GOWORK=off GOTOOLCHAIN=local
|
||||
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
|
||||
# Change the greeting for this invocation and show just that value.
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
# Intentional failure: still show the source recorded before loading failed.
|
||||
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 --count=0 --tag=first --tag second
|
||||
```
|
||||
|
||||
The `config` family comes from one typed ConfigKit scope. The custom `greet`
|
||||
command uses CommandKit's typed bindings and shows the difference between an
|
||||
omitted `--count` and an explicitly supplied zero, plus ordered repeated tags.
|
||||
With no `--config`, application settings use the in-memory default. A supplied
|
||||
configuration and overlay are passed to ConfigKit unchanged. The comparison
|
||||
command loads the two local fixtures and reports `changes.greeting: Goodbye`.
|
||||
The before fixture uses schema version 1, which ConfigKit reports as a provider
|
||||
warning in verbose mode. Ordinary mode hides the warning while preserving the
|
||||
effective YAML report; quiet mode suppresses that report.
|
||||
The invalid fixture demonstrates a failed-load report and captured source
|
||||
inventory; `source-1` is the ID from that fixture's acquisition. The scope opts
|
||||
in to transient overrides and passes them into its normal ConfigKit load. The
|
||||
diff example compares an overridden left side with ordinary configuration on
|
||||
the right and returns status 3 because the values differ.
|
||||
The handler reports its greeting, count, and tags. It distinguishes an omitted
|
||||
count from an explicitly supplied zero and preserves repeated tag order.
|
||||
|
||||
## How it is wired
|
||||
|
||||
One ConfigKit scope supplies the standard `config` commands. Without `--config`,
|
||||
its loader uses an in-memory default. Supplied configuration and overlay values
|
||||
are passed to ConfigKit unchanged. The scope enables temporary overrides and
|
||||
passes them into its ordinary ConfigKit load. The
|
||||
[consumer guide](../../../../docs/consumers/guide.md#configkit-scopes) explains
|
||||
loader and scope setup.
|
||||
|
||||
Run `GOWORK=off GOTOOLCHAIN=local go test ./...` from this example directory to
|
||||
check it against its declared dependencies.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
@@ -18,6 +17,7 @@ type explanationReport struct {
|
||||
History []explanationChangeDTO `json:"history"`
|
||||
SecretReference *secretReferenceDTO `json:"secret_reference"`
|
||||
Metadata *explanationMetadata `json:"metadata,omitempty"`
|
||||
Sources []validationSource `json:"sources,omitempty"`
|
||||
}
|
||||
|
||||
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 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 {
|
||||
Present bool `json:"present"`
|
||||
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))
|
||||
}
|
||||
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() {
|
||||
if plan.under != nil && !pathWithin(path, *plan.under) {
|
||||
pointer := path.String()
|
||||
if plan.under != nil && !pathWithin(pointer, prefix) {
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
entry := pathDiscoveryEntry{Path: path.String(), Present: present}
|
||||
entry := pathDiscoveryEntry{Path: pointer, Present: present}
|
||||
if present {
|
||||
entry.Metadata = &pathMetadata{Explicit: info.Explicit}
|
||||
}
|
||||
@@ -157,31 +185,27 @@ func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *conf
|
||||
report.Metadata = metadata
|
||||
}
|
||||
}
|
||||
text := template.Must(commandkit.ParseTextTemplate("explanation", "{{render .}}", template.FuncMap{
|
||||
"render": func(report explanationReport) (string, error) {
|
||||
var output bytes.Buffer
|
||||
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)
|
||||
}
|
||||
return output.String(), err
|
||||
},
|
||||
}))
|
||||
renderErr := reporter.Report(commandkit.Report{Value: report, TextTemplate: text})
|
||||
if acquired.Value.Sources != nil {
|
||||
for _, source := range acquired.Value.Sources.Sources() {
|
||||
report.Sources = append(report.Sources, validationSource{ID: source.ID, Label: source.Label, Path: source.Path})
|
||||
}
|
||||
}
|
||||
renderErr := reporter.Report(commandkit.Report{Value: report, TextTemplate: explanationTemplate})
|
||||
if renderErr != nil && invocation.Context.Err() != nil {
|
||||
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err())
|
||||
}
|
||||
return commandkit.Success()
|
||||
}
|
||||
|
||||
func pathWithin(path, parent configkit.Path) bool {
|
||||
tokens := func(value string) []string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(strings.TrimPrefix(value, "/"), "/")
|
||||
func pointerTokens(value string) []string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
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) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
bindingsByOperation: make(map[Operation][]commandkit.Flag[I]),
|
||||
}
|
||||
selectors := make([]Selector[I], len(suite.Selectors))
|
||||
against := make([]commandkit.Flag[I], len(suite.Selectors))
|
||||
for index, selector := range suite.Selectors {
|
||||
selector.Scopes = append([]string(nil), selector.Scopes...)
|
||||
selectors[index] = selector
|
||||
if selector.AgainstName != "" {
|
||||
against[index] = commandkit.RenameFlag(selector.Binding, selector.AgainstName)
|
||||
}
|
||||
}
|
||||
seenNames := captureConfigScopes(prepared, suite.Scopes)
|
||||
selectors, against := captureConfigSelectors(prepared, suite.Selectors, seenNames)
|
||||
captureOperationBindings(prepared, selectors, against)
|
||||
captureScopeInventory(prepared)
|
||||
return prepared
|
||||
}
|
||||
|
||||
seenNames := make(map[string]struct{}, len(suite.Scopes))
|
||||
for _, original := range suite.Scopes {
|
||||
func captureConfigScopes[I, S any](prepared *preparedSuite[I, S], scopes []Scope[I, S]) map[string]struct{} {
|
||||
seenNames := make(map[string]struct{}, len(scopes))
|
||||
for _, original := range scopes {
|
||||
scope := original
|
||||
scope.Operations = normalizeOperations(scope.Operations, &prepared.issues, scope.Name)
|
||||
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)
|
||||
|
||||
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 {
|
||||
seen := make(map[string]struct{}, 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 _, scope := range prepared.scopes {
|
||||
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 {
|
||||
prepared.inventory.Scopes = append(prepared.inventory.Scopes, scopeMetadata{
|
||||
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),
|
||||
})
|
||||
}
|
||||
return prepared
|
||||
}
|
||||
|
||||
func normalizeOperations(operations []Operation, issues *[]error, scope string) []Operation {
|
||||
|
||||
@@ -3,10 +3,12 @@ package ckconfig
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -478,18 +480,55 @@ func TestExplainParsesBeforeAcquisitionAndPreservesNativeSafeHistory(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplainTracksNativeRenderFailureWithoutReplacingOperationCause(t *testing.T) {
|
||||
renderErr := errors.New("native explanation renderer failed")
|
||||
reader := &failingNativeExplanation{err: renderErr}
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{Value: View{Explanation: reader}}, nil
|
||||
},
|
||||
}}}))
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "explain", "--path", "/token"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.RenderError, renderErr) || output.Len() != 0 {
|
||||
t.Fatalf("native explanation failure outcome/output = %#v / %q", outcome, output.String())
|
||||
func TestExplainUsesAcquiredEvidenceAcrossFormats(t *testing.T) {
|
||||
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"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := configkit.Keys("token")
|
||||
explanation, found := result.Explain(path)
|
||||
if !found {
|
||||
t.Fatal("missing fixture explanation")
|
||||
}
|
||||
metadata := result.Metadata()
|
||||
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
|
||||
}
|
||||
|
||||
type failingNativeExplanation struct{ err error }
|
||||
|
||||
func (reader *failingNativeExplanation) Explain(configkit.Path) (configkit.Explanation, bool) {
|
||||
return configkit.Explanation{}, true
|
||||
}
|
||||
|
||||
func (reader *failingNativeExplanation) WriteExplanation(writer io.Writer, _ configkit.Path) error {
|
||||
if _, err := io.WriteString(writer, "partial native explanation\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
return reader.err
|
||||
}
|
||||
|
||||
func (reader *nativeExplanation) Explain(path configkit.Path) (configkit.Explanation, bool) {
|
||||
reader.path = path
|
||||
return reader.value, reader.found
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
func (writer failingOutput) Write(value []byte) (int, error) {
|
||||
|
||||
@@ -3,7 +3,7 @@ module go.maximumdirect.net/ckconfig
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
go.maximumdirect.net/commandkit v0.4.0
|
||||
go.maximumdirect.net/commandkit v0.5.0
|
||||
go.maximumdirect.net/configkit v1.1.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
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/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||
|
||||
@@ -73,38 +73,27 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
|
||||
scopeChoices = append(scopeChoices, commandkit.CompletionChoice{Value: "all"})
|
||||
}
|
||||
flags := []commandkit.Flag[*configCommandInput[I, S]]{
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported.", 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 }),
|
||||
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]) {
|
||||
(*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]) {
|
||||
(*input).selection.Overlay = value
|
||||
}),
|
||||
}
|
||||
if operation == Diff {
|
||||
flags = append(flags,
|
||||
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
|
||||
}),
|
||||
)
|
||||
flags = append(flags, comparisonOperationFlags[I, S]()...)
|
||||
}
|
||||
if operation == Validate {
|
||||
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]) {
|
||||
(*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
|
||||
}),
|
||||
)
|
||||
@@ -125,16 +114,16 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
|
||||
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.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.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 {
|
||||
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 {
|
||||
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] {
|
||||
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 {
|
||||
if operation == Explain {
|
||||
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:
|
||||
return "explain", "Explain a configuration path."
|
||||
case Diff:
|
||||
return "diff", "Compare two configuration selections."
|
||||
return "diff", "Compare two configurations."
|
||||
default:
|
||||
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) {
|
||||
return fmt.Errorf("--severity and --code require --details")
|
||||
return false, fmt.Errorf("--severity and --code require --details")
|
||||
}
|
||||
if operation == Explain {
|
||||
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) {
|
||||
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 {
|
||||
input.plan = &configOperationPlan[I, S]{prepared: prepared, operation: operation, help: true}
|
||||
return nil
|
||||
return true, 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"
|
||||
if input.scope.Set {
|
||||
scopeName = input.scope.Value
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return fmt.Errorf("path-selected show requires one scope")
|
||||
return "", nil, fmt.Errorf("path-selected show requires one scope")
|
||||
}
|
||||
|
||||
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 {
|
||||
selected = []*preparedScope[I, S]{scope}
|
||||
} 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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
if (operation == Explain || operation == Show) && input.path.Set {
|
||||
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)),
|
||||
}
|
||||
if operation == Diff {
|
||||
scope := selected[0]
|
||||
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},
|
||||
},
|
||||
if err := prepareComparisonInputs(prepared, input, selected[0], plan); err != nil {
|
||||
return err
|
||||
}
|
||||
input.plan = plan
|
||||
return nil
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -67,3 +68,39 @@ func TestBufferedReportDoesNotRejectCancellationAfterDeliveryStarts(t *testing.T
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,11 +66,13 @@ func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOpera
|
||||
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
var value any
|
||||
var canceled error
|
||||
err := reporter.RenderOutput(func(io.Writer) error {
|
||||
values := make(map[string]any, len(documents))
|
||||
for _, document := range documents {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return err
|
||||
canceled = err
|
||||
return nil
|
||||
}
|
||||
var native bytes.Buffer
|
||||
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
|
||||
})
|
||||
if canceled != nil {
|
||||
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, canceled)
|
||||
}
|
||||
if err != nil {
|
||||
if canceled := invocation.Context.Err(); canceled != nil {
|
||||
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, canceled)
|
||||
|
||||
@@ -3,7 +3,6 @@ package ckconfig
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"text/template"
|
||||
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
@@ -62,6 +61,14 @@ func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *confi
|
||||
reporter := invocation.Reporter()
|
||||
report := validationReport{Scopes: make([]validationScope, 0, len(plan.scopes))}
|
||||
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 {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
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 {
|
||||
entry := validationScope{Scope: name, Outcome: "failed"}
|
||||
if plan.details {
|
||||
entry.Details = projectValidationDetails(View{}, plan.severities, plan.codes)
|
||||
entry.Details = projectValidationDetails(View{}, severities, codes)
|
||||
}
|
||||
report.Scopes = append(report.Scopes, entry)
|
||||
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"}
|
||||
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 {
|
||||
entry.Outcome = "canceled"
|
||||
@@ -114,7 +121,7 @@ func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *confi
|
||||
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{}}
|
||||
if view.Findings == nil {
|
||||
return details
|
||||
@@ -126,10 +133,10 @@ func projectValidationDetails(view View, severities []configkit.Severity, codes
|
||||
details.State = "filtered"
|
||||
references := make(map[string]bool)
|
||||
for _, record := range view.Findings.Records {
|
||||
if len(severities) > 0 && !slices.Contains(severities, record.Severity) {
|
||||
if len(severities) > 0 && !severities[record.Severity] {
|
||||
continue
|
||||
}
|
||||
if len(codes) > 0 && !slices.Contains(codes, string(record.Code)) {
|
||||
if len(codes) > 0 && !codes[string(record.Code)] {
|
||||
continue
|
||||
}
|
||||
finding := validationFinding{Code: string(record.Code), Severity: string(record.Severity), Path: record.Path.String(), Message: record.Message, Related: locationReports(record.Related)}
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
# CommandKit ckprompt
|
||||
|
||||
`ckprompt` is the optional PromptKit integration for CommandKit. It adapts
|
||||
PromptKit engine configuration, native comparison, five catalogs, named
|
||||
inspection, requirements discovery and validation, and native smoke tests.
|
||||
Importing it does not install either command family.
|
||||
`ckprompt` adds PromptKit inspection and testing commands to Go applications
|
||||
that use CommandKit. Operators can list prompts, profiles, and backends, inspect
|
||||
their settings, discover required inputs, validate and preview requests, and
|
||||
run smoke tests.
|
||||
|
||||
The application chooses which commands to install. Importing the package alone
|
||||
does not install commands.
|
||||
|
||||
## Module
|
||||
|
||||
The module path is `go.maximumdirect.net/ckprompt`. This source uses Go 1.27.0,
|
||||
`go.maximumdirect.net/commandkit v0.4.0`, `go.maximumdirect.net/ckconfig v0.3.0`,
|
||||
`go.maximumdirect.net/configkit v1.1.0`, and `go.maximumdirect.net/promptkit v0.20.0`. See the
|
||||
[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 a new ckconfig
|
||||
release supplies its APIs.
|
||||
The module path is `go.maximumdirect.net/ckprompt`. See the
|
||||
[consumer guide](https://forge.maximumdirect.net/base-layer/commandkit/src/branch/main/docs/consumers/guide.md#module-selection)
|
||||
for compatible releases, Go requirements, and the current source's dependency
|
||||
alignment requirement.
|
||||
|
||||
## Example
|
||||
|
||||
Add `ckprompt.ConfigScope(loadEngine)` to the scopes passed to
|
||||
`ckconfig.Command` when the application wants PromptKit engine configuration
|
||||
and native comparison in the `config` family. Install
|
||||
`ckprompt.Command(ckprompt.FromEngine(loadEngine))` separately for catalogs,
|
||||
named inspections, and requirements discovery. `FromEngine` supplies nine
|
||||
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,
|
||||
and smoke testing explicitly with `WithRequestValidation`,
|
||||
`WithRequestPreparation`, and `WithSmokeTesting`. Inspection,
|
||||
requirements, and catalog operations do not execute prompts or contact model
|
||||
providers; application loaders and callbacks retain their own effects. Smoke
|
||||
testing invokes the selected native PromptKit operation; the
|
||||
application retains ownership of client and configuration choices. See the
|
||||
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#promptkit-smoke-testing)
|
||||
for operator commands and report semantics. The maintained
|
||||
[composed example](examples/composed) shows complete application wiring.
|
||||
Pass your engine loader to `FromEngine` to add resource listings, inspection,
|
||||
and requirements discovery:
|
||||
|
||||
```go
|
||||
app, err := commandkit.New(identity, ckprompt.Command(ckprompt.FromEngine(loadEngine)))
|
||||
```
|
||||
|
||||
Add request validation, preview, and smoke tests with `WithRequestValidation`,
|
||||
`WithRequestPreparation`, and `WithSmokeTesting`. To expose engine configuration
|
||||
under `config`, add `ckprompt.ConfigScope(loadEngine)` to a `ckconfig.Suite`.
|
||||
The [integration guide](https://forge.maximumdirect.net/base-layer/commandkit/src/branch/main/docs/consumers/guide.md#promptkit-configuration-definitions-and-requirements)
|
||||
explains these choices; the [composed example](examples/composed) shows them together.
|
||||
|
||||
Inspection commands do not call the model. Smoke tests can use the application's
|
||||
configured client or network probes. Applications own engine setup, credentials,
|
||||
and any effects of their loaders and callbacks. See the
|
||||
[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
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"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 {
|
||||
acquired.Value.Sources = loadError
|
||||
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 {
|
||||
acquired.Diagnostics = messages.diagnostics
|
||||
}
|
||||
@@ -289,9 +291,9 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S)
|
||||
metadata := inspection.Metadata()
|
||||
return commandkit.Acquired[ckconfig.View]{
|
||||
Value: ckconfig.View{
|
||||
Effective: inspection,
|
||||
Sources: inspection,
|
||||
Explanation: inspection,
|
||||
Effective: *inspection,
|
||||
Sources: *inspection,
|
||||
Explanation: *inspection,
|
||||
Metadata: &metadata,
|
||||
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)
|
||||
}
|
||||
|
||||
differences, err := leftInspection.Diff(rightInspection)
|
||||
differences, err := leftInspection.Diff(*rightInspection)
|
||||
messages = messages.withOperationError(err)
|
||||
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)
|
||||
@@ -471,31 +473,31 @@ func loadPromptKitEngine[I, S any](
|
||||
state S,
|
||||
side string,
|
||||
load engineLoader[I, S],
|
||||
) (*promptkit.Engine, promptkit.ConfigurationInspection, promptKitAcquisition, error) {
|
||||
) (*promptkit.Engine, *promptkit.ConfigurationInspection, promptKitAcquisition, error) {
|
||||
engine, err := load(ctx, selection, input, state)
|
||||
if err != nil {
|
||||
var loadErr *configkit.LoadError
|
||||
if errors.As(err, &loadErr) {
|
||||
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 {
|
||||
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()
|
||||
messages := promptKitDiagnostics("promptkit", side, inspection.Sources(), inspection.Diagnostics())
|
||||
messages = messages.withOperationError(err)
|
||||
if err != nil {
|
||||
return nil, inspection, messages, err
|
||||
return nil, nil, messages, err
|
||||
}
|
||||
if ctx != 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 {
|
||||
@@ -508,61 +510,10 @@ func appendPromptKitAcquisition(left, right promptKitAcquisition) promptKitAcqui
|
||||
}
|
||||
|
||||
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)
|
||||
if side != "" {
|
||||
heading += " (" + side + ")"
|
||||
}
|
||||
if _, err := fmt.Fprintf(writer, "%s:\n", heading); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
if _, err := io.WriteString(writer, "Source IDs:\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, source := range sources {
|
||||
if _, err := fmt.Fprintf(writer, " %s: label=%s, path=%s\n", strconv.Quote(source.ID), strconv.Quote(source.Label), strconv.Quote(source.Path)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return configkit.WriteDiagnostics(writer, records)
|
||||
diagnostics, explanation := ckconfig.AdaptDiagnostics(heading+":\n", sources, records)
|
||||
return promptKitAcquisition{diagnostics: diagnostics, explanation: explanation}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"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())
|
||||
defer cancel()
|
||||
engine := adapterEngine(t)
|
||||
scope := ConfigScope(func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
|
||||
scope := ConfigScope(func(context.Context, ckconfig.Selection, struct{}, struct{}) (*promptkit.Engine, error) {
|
||||
cancel()
|
||||
return engine, nil
|
||||
})
|
||||
|
||||
acquired, err := scope.Acquire(ctx, ckconfig.Request{Operation: ckconfig.Show}, 0, 0)
|
||||
if !errors.Is(err, context.Canceled) || acquired.Value.Effective != nil {
|
||||
t.Fatalf("cancelled scope acquisition = (%#v, %v), want cancellation and no value", acquired, err)
|
||||
acquired, err := scope.Acquire(ctx, ckconfig.Request{}, struct{}{}, struct{}{})
|
||||
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 {
|
||||
t.Fatalf("canceled acquisition = %#v, %v", 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())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ func buildCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.Declaration
|
||||
showCommands = append(showCommands, newPreparationCommand(suite, aliases))
|
||||
}
|
||||
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)
|
||||
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"))
|
||||
}
|
||||
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)
|
||||
if suite.InspectRequirements != nil {
|
||||
@@ -125,7 +125,7 @@ func buildCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.Declaration
|
||||
if suite.SmokeTest != nil {
|
||||
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 {
|
||||
command = commandkit.WithError(command, issue)
|
||||
}
|
||||
@@ -140,16 +140,16 @@ func newCatalogListCommand[I, S any](suite Suite[I, S], name string) commandkit.
|
||||
return &(*input).captured
|
||||
})
|
||||
flags = append(flags,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", 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 }),
|
||||
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 }),
|
||||
)
|
||||
if name == "prompts" || name == "profiles" || name == "backends" {
|
||||
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 }),
|
||||
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 }),
|
||||
)
|
||||
}
|
||||
@@ -243,9 +243,9 @@ func newDefinitionCommand[I, S any](suite Suite[I, S], name string, aliases *[]c
|
||||
return &(*input).captured
|
||||
})
|
||||
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 }),
|
||||
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]) {
|
||||
(*input).selection.Overlay = value
|
||||
}),
|
||||
@@ -288,11 +288,11 @@ func newRequirementsCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.D
|
||||
return &(*input).captured
|
||||
})
|
||||
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]) {
|
||||
(*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]) {
|
||||
(*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]]{
|
||||
Name: "show",
|
||||
Summary: "Inspect requirements for a named PromptKit prompt.",
|
||||
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.",
|
||||
Summary: "Discover the inputs and variables a prompt needs.",
|
||||
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] {
|
||||
return &requirementsInput[I]{}
|
||||
},
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// Package ckprompt, provided by go.maximumdirect.net/ckprompt, provides optional
|
||||
// PromptKit configuration comparison,
|
||||
// prompt catalogs, named inspections, requirements discovery and validation,
|
||||
// prepared-request preview, and native smoke testing for CommandKit. Command
|
||||
// returns a canonical declaration; Declarations adds scoped flag aliases.
|
||||
// Applications can install those alongside other declarations through
|
||||
// commandkit.New or App.Register. Importing ckprompt installs neither family; callers compose its
|
||||
// declarations with core and ckconfig explicitly. Provider diagnostics remain
|
||||
// passive until the owning command submits them through the invocation
|
||||
// reporter, separately from any terminal explanation. This module uses
|
||||
// PromptKit v0.20.0 and its native engine inventory, including configured
|
||||
// bundled resources.
|
||||
// Package ckprompt adds PromptKit inspection and testing commands to CommandKit
|
||||
// applications. Operators can list resources, inspect definitions, discover
|
||||
// required inputs, validate and preview requests, and run smoke tests.
|
||||
//
|
||||
// Start with FromEngine and add optional operations with WithRequestValidation,
|
||||
// WithRequestPreparation, and WithSmokeTesting. Command returns the command
|
||||
// family; Declarations also includes its convenience flag aliases. Install
|
||||
// these with commandkit.New or App.Register.
|
||||
//
|
||||
// ConfigScope separately exposes engine configuration through ckconfig.
|
||||
// Importing this package installs neither command family. Applications own
|
||||
// engine construction, credentials, and client configuration.
|
||||
package ckprompt
|
||||
|
||||
@@ -85,8 +85,9 @@ root): `docs/policy/meta.md`, `docs/policy/agents.md`,
|
||||
`docs/policy/documentation.md`, `docs/policy/release.md`, and
|
||||
`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
|
||||
the core module. The release policy uses baseline
|
||||
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses baseline
|
||||
the core module. The documentation policy uses baseline
|
||||
`beb5f191470a9daef2383981a6bfd02232490ab4`; the release policy uses
|
||||
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses
|
||||
`d84a38ebab26f048cafbe59a2246700c35d29843`.
|
||||
|
||||
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) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
|
||||
[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/e833faddf120c789a9b45348edaf75720de16348).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4).
|
||||
|
||||
## go-yaml v4
|
||||
|
||||
Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`.
|
||||
|
||||
4
integrations/ckprompt/docs/releases/errata.md
Normal file
4
integrations/ckprompt/docs/releases/errata.md
Normal 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).
|
||||
@@ -1,33 +1,40 @@
|
||||
# CommandKit ckprompt v0.3.0
|
||||
|
||||
## Summary
|
||||
This unpublished candidate adds PromptKit v0.20.0 support, including request
|
||||
preview, namespace selection, and shared text, YAML, and JSON output. It uses
|
||||
ConfigKit v1.1.0 and requires Go 1.27.0.
|
||||
|
||||
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.
|
||||
## Operator features
|
||||
|
||||
## Migration
|
||||
- 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.
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
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.
|
||||
## Compatibility and preparation
|
||||
|
||||
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.
|
||||
This candidate 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.
|
||||
|
||||
The current source requires `ckconfig.AdaptDiagnostics`, which is absent from
|
||||
its selected ckconfig v0.4.0 dependency. Before publication, complete
|
||||
[dependency alignment](../../../../docs/roadmap/future.md#pending-release-and-integration-work)
|
||||
and update this candidate's dependency guidance to the selected releases.
|
||||
The [module selection table](../../../../docs/consumers/guide.md#module-selection)
|
||||
distinguishes published combinations from the current workspace.
|
||||
|
||||
[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) |
|
||||
|
||||
@@ -1,144 +1,172 @@
|
||||
# Composed consumer
|
||||
# ConfigKit and PromptKit example
|
||||
|
||||
This example is part of the ckprompt module and uses core v0.4.0, ckconfig
|
||||
v0.3.0, ConfigKit v1.1.0, and PromptKit v0.20.0. It requires Go 1.27.0.
|
||||
The current work uses the aligned local workspace until the required ckconfig
|
||||
release is published.
|
||||
This example combines application and PromptKit configuration, resource
|
||||
inspection, request checks, and smoke tests. It uses local fixtures and an
|
||||
in-memory model client. **No model service or credentials are required.**
|
||||
|
||||
This example composes application configuration, PromptKit configuration,
|
||||
PromptKit comparison and catalogs, definition and requirements inspection, and
|
||||
one application-owned command:
|
||||
Use Go 1.27.0 on `PATH` and an aligned workspace containing all three local
|
||||
modules. Follow the [workspace setup](../../../../docs/development.md#documentation-validation)
|
||||
from the repository root first. Current ckprompt source needs a helper in local
|
||||
ckconfig source that its released dependency does not yet contain; independent
|
||||
builds remain pending [release alignment](../../../../docs/roadmap/future.md#pending-release-and-integration-work).
|
||||
The [consumer guide](../../../../docs/consumers/guide.md#module-selection) owns
|
||||
version and compatibility details.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
cd integrations/ckprompt/examples/composed
|
||||
export GOWORK=auto GOTOOLCHAIN=local
|
||||
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 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 . 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 backends --campaign spring
|
||||
go run . promptkit list namespaces --campaign spring
|
||||
go run . promptkit list collections --campaign spring
|
||||
go run . list collections --campaign spring
|
||||
go run . pk list bundles --campaign spring
|
||||
go run . promptkit list prompts --namespace field-notes --campaign spring
|
||||
go run . promptkit show prompt briefing --campaign spring --format yaml
|
||||
go run . 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
|
||||
```
|
||||
|
||||
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 --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 --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
|
||||
```
|
||||
|
||||
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 . 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
|
||||
```
|
||||
|
||||
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 . 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
|
||||
```
|
||||
|
||||
`newApp` installs application-owned command and flag aliases alongside the
|
||||
complete `ckconfig` and `ckprompt` declarations. `list collections` and
|
||||
`promptkit list bundles` both reach the canonical collection command; `pk`
|
||||
shortens the PromptKit group. `--fmt` is scoped to named prompt inspection and
|
||||
`--hlep` is available globally. The `smoke` shortcut retains the canonical
|
||||
`promptkit test KIND` operand, including the special `all` aggregate.
|
||||
The first two commands reach `promptkit list collections`. `pk` shortens the
|
||||
PromptKit group; `--fmt` is an alias on prompt inspection and `--hlep` is a global
|
||||
help alias. `smoke` shortens `promptkit test` while leaving the test kind as an
|
||||
argument. The session command uses an in-memory store. `request inspect` shows
|
||||
how an application command can reuse canonical `--llm-*` flag collections without
|
||||
installing PromptKit's short aliases.
|
||||
|
||||
The example installs `Completion("composed-example")`. Build it as
|
||||
`go build -o composed-example .`. Initialize `bash-completion` first if using
|
||||
Bash, then load a shell script as described in the
|
||||
[CLI reference](../../../../docs/cli.md#shell-completion). Try completion after
|
||||
`composed-example pk show prompt briefing --fmt=` or after the filename prefix
|
||||
in `composed-example config show --config=fixtures/`. The already-entered `pk`
|
||||
alias selects the PromptKit command context without loading an engine.
|
||||
## Try shell completion
|
||||
|
||||
The composition installs `ckconfig.Command(suite)` and the complete
|
||||
`ckprompt.Declarations(definitions)` in a `[]commandkit.Declaration` with
|
||||
root-installed routing rules. The PromptKit declarations include scoped aliases;
|
||||
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
|
||||
})
|
||||
```sh
|
||||
go build -o composed-example .
|
||||
./composed-example completion bash
|
||||
```
|
||||
|
||||
The example injects a small in-memory `promptkit.LLMClient` through its shared
|
||||
run state. Catalog, inspection, and requirements commands do not call it;
|
||||
inference smoke commands do. No model service or credentials are required.
|
||||
Native HTTP checks may retain unsupported or not-applicable observations because
|
||||
the selected local backend does not configure a network probe. An aggregate smoke command can
|
||||
return an operational outcome for a native diagnostic while still delivering
|
||||
the complete aggregate report.
|
||||
Initialize `bash-completion` if using Bash, then load the generated script using
|
||||
the [CLI reference](../../../../docs/cli.md#shell-completion). Try completing
|
||||
`composed-example pk show prompt briefing --fmt=` or
|
||||
`composed-example config show --config=fixtures/`. The already-entered `pk` alias
|
||||
selects the command context without loading an engine.
|
||||
|
||||
The first requirements command reports incomplete discovery: PromptKit records
|
||||
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.
|
||||
## How it is wired
|
||||
|
||||
The validation and preview commands exercise concrete PromptKit request preparation.
|
||||
The first uses the builder's campaign-specific default topic variable and the
|
||||
operator's topic file. The second also selects the notes branch and supplies its
|
||||
file. Validation reports only `prompt_id` and `valid: true`; preview returns the
|
||||
prepared request, including rendered messages and effective provider settings.
|
||||
Neither executes the prompt. The validation command without inputs intentionally
|
||||
fails because the prompt's
|
||||
required `topic` artifact is not supplied. The builder defaults variables from
|
||||
the selected campaign but leaves artifact bindings to the operator. The
|
||||
`requirements show` commands remain discovery reports: partial discovery can
|
||||
succeed even when a concrete request would fail validation.
|
||||
The example installs `ckconfig.Command(suite)` and
|
||||
`ckprompt.Declarations(definitions)` with application-owned aliases in a
|
||||
`[]commandkit.Declaration`. `NewInput` initializes scope inputs; `NewState`
|
||||
creates a cache shared within an invocation. The loaders cache successful
|
||||
configuration results by the complete configuration/overlay selection,
|
||||
overrides, and supplied campaign, including whether each value was supplied.
|
||||
These are example choices, not a CommandKit cache policy.
|
||||
|
||||
Use the aligned local workspace to test the three current sources, as described in
|
||||
the [developer guide](../../../../docs/development.md#documentation-validation).
|
||||
Once released dependency requirements are aligned, `GOWORK=off go test ./...`
|
||||
from this example directory checks it against its declared dependencies.
|
||||
Smoke testing uses `WithSmokeTesting` with a nil builder, forwarding the request
|
||||
selected by flags. Applications can supply a builder to customize that request.
|
||||
See the [consumer guide](../../../../docs/consumers/guide.md#promptkit-configuration-definitions-and-requirements)
|
||||
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. Once dependency alignment is complete, also use
|
||||
`GOWORK=off GOTOOLCHAIN=local go test ./...` to check the declared releases.
|
||||
|
||||
@@ -3,7 +3,7 @@ module go.maximumdirect.net/ckprompt
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
go.maximumdirect.net/ckconfig v0.3.0
|
||||
go.maximumdirect.net/ckconfig v0.4.0
|
||||
go.maximumdirect.net/commandkit v0.4.0
|
||||
go.maximumdirect.net/configkit v1.1.0
|
||||
go.maximumdirect.net/promptkit v0.20.0
|
||||
|
||||
@@ -10,10 +10,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/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
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.3.0/go.mod h1:NKFBVr2TyDNGGIWgwijGqg5WFoSUL2F8FiKRlNo9TLw=
|
||||
go.maximumdirect.net/commandkit v0.3.0 h1:RCmg2GS8uzlLSJCIqarpn1uFDec0W3ylNNRRPT6kUFE=
|
||||
go.maximumdirect.net/commandkit v0.3.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
|
||||
go.maximumdirect.net/ckconfig v0.4.0 h1:7MpI/jIy+3BZrVME5rhKQibh5U9IA3s97BUJ2EOG41s=
|
||||
go.maximumdirect.net/ckconfig v0.4.0/go.mod h1:Mk6QOfHrFy00uE0/gw9h6/Y9LcdTShVEzM/oI/HGDns=
|
||||
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/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA=
|
||||
go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
|
||||
go.maximumdirect.net/promptkit v0.20.0 h1:ZpwbjiCZWXdnnZ26RuWKoOco0jFnaAX8m2tQAX7ZlnM=
|
||||
|
||||
@@ -39,9 +39,9 @@ func namespaceFlags[I any](destination func(*I) *namespaceInput, kinds ...string
|
||||
}
|
||||
|
||||
var namespaceFlagDefinitions = struct{ prompt, profile, backend requestFlagDefinition }{
|
||||
prompt: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-prompt-namespace", Usage: "Prompt resource namespace."}, []string{"prompt-namespace"}},
|
||||
profile: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-profile-namespace", Usage: "Profile resource namespace."}, []string{"profile-namespace"}},
|
||||
backend: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-backend-namespace", Usage: "Backend resource namespace."}, []string{"backend-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: "Use this namespace for unqualified profile IDs."}, []string{"profile-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 {
|
||||
|
||||
@@ -100,6 +100,9 @@ func runRequestPreparation[I, S any](invocation commandkit.Invocation, suite Sui
|
||||
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)
|
||||
}
|
||||
if acquired.Value == nil {
|
||||
return definitionFailure("PromptKit request preparation returned no result.", acquired.Explanation, nil)
|
||||
}
|
||||
_ = invocation.Reporter().Report(commandkit.Report{Value: acquired.Value})
|
||||
return commandkit.Success()
|
||||
}
|
||||
|
||||
@@ -105,3 +105,43 @@ func TestNativePreparationBuilderAndErrorIdentity(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,24 +35,24 @@ var requestFlags = struct {
|
||||
InputText requestFlagDefinition
|
||||
InputFile requestFlagDefinition
|
||||
}{
|
||||
Endpoint: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-endpoint", Usage: "Execution endpoint override."}, []string{"endpoint"}},
|
||||
Model: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-model", Usage: "Execution model override."}, []string{"model"}},
|
||||
Temperature: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-temperature", Usage: "Execution temperature override."}, []string{"temperature"}},
|
||||
MaxTokens: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-max-tokens", Usage: "Execution maximum tokens override."}, []string{"max-tokens"}},
|
||||
TopP: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-top-p", Usage: "Execution top-p override."}, []string{"top-p"}},
|
||||
TimeoutSeconds: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-timeout-seconds", Usage: "Execution timeout override in seconds."}, []string{"timeout-seconds"}},
|
||||
ServiceTier: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-service-tier", Usage: "Execution service tier."}, []string{"service-tier"}},
|
||||
ReasoningEffort: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-reasoning-effort", Usage: "Execution reasoning effort override."}, []string{"reasoning-effort"}},
|
||||
APIKeyEnv: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-api-key-env", Usage: "Execution API key environment variable."}, []string{"api-key-env"}},
|
||||
TransientRetries: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-transient-retries", Usage: "Inference transient retry count."}, []string{"transient-retries"}},
|
||||
ExtraParams: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-extra-params", Usage: "Execution provider parameters as a JSON object."}, []string{"extra-params"}},
|
||||
Prompt: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-prompt", Usage: "PromptKit prompt ID."}, []string{"prompt"}},
|
||||
Profile: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-profile", Usage: "PromptKit profile ID."}, []string{"profile"}},
|
||||
Backend: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-backend", Usage: "PromptKit backend ID."}, []string{"backend"}},
|
||||
SessionID: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-session-id", Usage: "PromptKit session ID."}, []string{"session-id"}},
|
||||
Var: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-var", Usage: "Caller variable assignment 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"}},
|
||||
InputFile: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-input-file", Usage: "Prompt input file assignment NAME=PATH."}, []string{"input-file"}},
|
||||
Endpoint: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-endpoint", Usage: "Use this endpoint."}, []string{"endpoint"}},
|
||||
Model: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-model", Usage: "Use this model."}, []string{"model"}},
|
||||
Temperature: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-temperature", Usage: "Use this temperature."}, []string{"temperature"}},
|
||||
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: "Use this top-p value."}, []string{"top-p"}},
|
||||
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: "Use this service tier."}, []string{"service-tier"}},
|
||||
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: "Name the environment variable containing the API key."}, []string{"api-key-env"}},
|
||||
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: "Supply provider parameters as a JSON object."}, []string{"extra-params"}},
|
||||
Prompt: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-prompt", Usage: "Select a prompt by ID."}, []string{"prompt"}},
|
||||
Profile: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-profile", Usage: "Select a profile by ID."}, []string{"profile"}},
|
||||
Backend: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-backend", Usage: "Select a backend by ID."}, []string{"backend"}},
|
||||
SessionID: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-session-id", Usage: "Supply the session ID."}, []string{"session-id"}},
|
||||
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: "Supply text for a prompt input as NAME=TEXT."}, []string{"input-text", "llm-input", "input"}},
|
||||
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.
|
||||
@@ -85,6 +85,7 @@ func (f ExecutionFlags[I]) bindings() []requestBinding[I] {
|
||||
}
|
||||
|
||||
// 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] {
|
||||
target := func(input *I) *promptkit.ExecutionTargetOverride {
|
||||
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 }),
|
||||
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 }),
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,3 +76,33 @@ func TestValidationForwardsExecutionControls(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ package ckprompt
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
@@ -144,3 +219,33 @@ func TestSmokeAggregateCancellationRetainsPartialResults(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +29,11 @@ func newSmokeTestCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.Decl
|
||||
return &(*input).captured
|
||||
})
|
||||
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]) {
|
||||
(*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]) {
|
||||
(*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]]{
|
||||
Name: "test",
|
||||
Summary: "Run one PromptKit smoke test.",
|
||||
Name: "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] {
|
||||
return &smokeCommandInput[I]{}
|
||||
},
|
||||
Flags: flags,
|
||||
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 }),
|
||||
},
|
||||
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 len(results) < len(kinds) && !errors.Is(errors.Join(operationErrors...), err) {
|
||||
operationErrors = append(operationErrors, err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -229,6 +233,7 @@ func smokeRequestForKind(seed promptkit.SmokeTestRequest, kind promptkit.SmokeTe
|
||||
request.Kind = kind
|
||||
if seed.Execution != nil {
|
||||
execution := *seed.Execution
|
||||
execution.ExtraParams = copyProviderParams(seed.Execution.ExtraParams)
|
||||
execution.Temperature = copyFloat64(seed.Execution.Temperature)
|
||||
execution.MaxTokens = copyInt(seed.Execution.MaxTokens)
|
||||
execution.TopP = copyFloat64(seed.Execution.TopP)
|
||||
|
||||
@@ -31,11 +31,11 @@ func newConcreteRequestSpec[I, S any](suite Suite[I, S], path string, aliases *[
|
||||
return &(*input).captured
|
||||
})
|
||||
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]) {
|
||||
(*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]) {
|
||||
(*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 {
|
||||
spec := newConcreteRequestSpec(suite, "promptkit requirements validate", aliases)
|
||||
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 {
|
||||
return runRequestValidation(invocation, suite, input)
|
||||
}
|
||||
|
||||
@@ -182,3 +182,50 @@ func (resolver *Resolver) ResolveWithProvenance(path commandpath.Path) (commandp
|
||||
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
|
||||
}
|
||||
|
||||
@@ -314,3 +314,34 @@ func (machine *Machine) classify(token string) symbol {
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package cobra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
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:]...)
|
||||
break
|
||||
}
|
||||
if kind == commandmeta.LongFlagToken {
|
||||
original, value, attached := commandmeta.SplitLongFlag(token)
|
||||
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 {
|
||||
if kind == commandmeta.LongFlagToken || kind == commandmeta.ShortFlagToken {
|
||||
offset := 1
|
||||
consumedNext := false
|
||||
for offset := 1; offset < len(token); {
|
||||
original := "-" + token[offset:offset+1]
|
||||
flag, ok := lookup.Find(original)
|
||||
if !ok {
|
||||
interpreted = append(interpreted, "-"+token[offset:])
|
||||
for {
|
||||
fact := commandmeta.ConsumeFlag(lookup, token, offset)
|
||||
if !fact.Found {
|
||||
if kind == commandmeta.ShortFlagToken {
|
||||
interpreted = append(interpreted, "-"+token[offset:])
|
||||
} else {
|
||||
interpreted = append(interpreted, token)
|
||||
}
|
||||
return interpreted, spellings, occurrences, nil
|
||||
}
|
||||
flag, original := fact.Flag, fact.Spelling
|
||||
spellings[flag.Name] = append(spellings[flag.Name], original)
|
||||
suffix := token[offset+1:]
|
||||
if flag.Boolean {
|
||||
if strings.HasPrefix(suffix, "=") {
|
||||
value := suffix[1:]
|
||||
interpreted = append(interpreted, "--"+flag.Name+"="+value)
|
||||
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
|
||||
break
|
||||
value := fact.Value
|
||||
if fact.NeedsNext {
|
||||
if index+1 >= len(argv) {
|
||||
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)
|
||||
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
|
||||
value = argv[index+1]
|
||||
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
|
||||
}
|
||||
return interpreted, spellings, occurrences, &commandmeta.Failure{Message: fmt.Sprintf("flag needs an argument: %s", original)}
|
||||
offset = fact.NextOffset
|
||||
}
|
||||
if !consumedNext {
|
||||
index++
|
||||
if consumedNext {
|
||||
index++
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -4,7 +4,6 @@ package cobra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
@@ -35,8 +34,7 @@ func Prepare(root *commandmeta.Command, options commandmeta.ParseOptions) *Sessi
|
||||
session.metadata = root
|
||||
session.flagAliases = append([]commandmeta.FlagAlias(nil), options.FlagAliases...)
|
||||
session.flagAliasesForPath = options.FlagAliasesForPath
|
||||
options = normalizeOptions(options)
|
||||
session.root, session.declarations = buildTree(root, options)
|
||||
session.root, session.declarations = buildTree(root)
|
||||
return session
|
||||
}
|
||||
|
||||
@@ -289,23 +287,7 @@ func Parse(root *commandmeta.Command, argv []string, options commandmeta.ParseOp
|
||||
return Prepare(root, options).Parse(argv)
|
||||
}
|
||||
|
||||
func normalizeOptions(options commandmeta.ParseOptions) commandmeta.ParseOptions {
|
||||
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) {
|
||||
func buildTree(root *commandmeta.Command) (*spfcobra.Command, map[*spfcobra.Command]*commandmeta.Command) {
|
||||
nodes := make(map[*spfcobra.Command]*commandmeta.Command)
|
||||
var build func(*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 {
|
||||
backend.Run = func(*spfcobra.Command, []string) {}
|
||||
}
|
||||
backend.SetContext(options.Context)
|
||||
backend.SetIn(options.In)
|
||||
backend.SetOut(options.Out)
|
||||
backend.SetErr(options.Err)
|
||||
backend.SetOut(io.Discard)
|
||||
backend.SetErr(io.Discard)
|
||||
backend.SetHelpFunc(func(*spfcobra.Command, []string) {})
|
||||
backend.SetFlagErrorFunc(func(_ *spfcobra.Command, err error) error { return err })
|
||||
backend.SetUsageTemplate(usageTemplate)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package cobra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
@@ -182,7 +180,7 @@ func TestParseReturnsNeutralFailuresAndFreshState(t *testing.T) {
|
||||
tree := &Command{Name: "app", Children: []*Command{
|
||||
{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)
|
||||
second := Parse(tree, []string{"run"}, options)
|
||||
if first.Failure != nil || second.Failure != nil {
|
||||
@@ -247,8 +245,7 @@ func TestSelectedPathsResolveThroughBackendParents(t *testing.T) {
|
||||
|
||||
func TestSessionReusesTreeForParseHelpAndUsageWithFreshState(t *testing.T) {
|
||||
tree := &Command{Name: "app", Children: []*Command{{Name: "run", Runnable: true, Flags: []Flag{{Name: "value"}}}}}
|
||||
var output, errors bytes.Buffer
|
||||
session := Prepare(tree, ParseOptions{Context: context.Background(), Out: &output, Err: &errors})
|
||||
session := Prepare(tree, ParseOptions{})
|
||||
first := session.Parse([]string{"run", "--value=old"})
|
||||
if first.Failure != nil || !reflect.DeepEqual(first.FlagValues, [][]string{{"old"}}) {
|
||||
t.Fatalf("first session parse = %#v", first)
|
||||
@@ -261,19 +258,15 @@ func TestSessionReusesTreeForParseHelpAndUsageWithFreshState(t *testing.T) {
|
||||
if failure != nil || !strings.Contains(line, "run") {
|
||||
t.Fatalf("session usage = %q, %v", line, failure)
|
||||
}
|
||||
if output.Len() != 0 || errors.Len() != 0 {
|
||||
t.Fatalf("help escaped session delivery control: out=%q err=%q", output.String(), errors.String())
|
||||
if session.root.OutOrStdout() != io.Discard || session.root.ErrOrStderr() != io.Discard {
|
||||
t.Fatal("backend destinations are not discarded")
|
||||
}
|
||||
|
||||
var secondOutput, secondErrors bytes.Buffer
|
||||
second := Prepare(tree, ParseOptions{Context: context.Background(), Out: &secondOutput, Err: &secondErrors})
|
||||
second := Prepare(tree, ParseOptions{})
|
||||
secondResult := second.Parse([]string{"run"})
|
||||
if secondResult.Failure != nil || !reflect.DeepEqual(secondResult.FlagValues, [][]string{nil}) {
|
||||
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) {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package cobra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -11,7 +9,7 @@ func BenchmarkPerformanceParse(b *testing.B) {
|
||||
wide := performanceFlatTree(128)
|
||||
wideArgs := []string{"wide127"}
|
||||
deep, deepArgs := performanceNestedTree(24)
|
||||
options := ParseOptions{Context: context.Background(), Out: io.Discard, Err: io.Discard}
|
||||
options := ParseOptions{}
|
||||
|
||||
for _, benchmark := range []struct {
|
||||
name string
|
||||
@@ -41,7 +39,7 @@ func BenchmarkPerformanceParse(b *testing.B) {
|
||||
func BenchmarkPerformanceSessionParseAndHelp(b *testing.B) {
|
||||
root := performanceFlatTree(8)
|
||||
args := []string{"wide007"}
|
||||
session := Prepare(root, ParseOptions{Context: context.Background(), Out: io.Discard, Err: io.Discard})
|
||||
session := Prepare(root, ParseOptions{})
|
||||
result := session.Parse(args)
|
||||
if result.Failure != nil {
|
||||
b.Fatalf("fixture parse failed: %s", result.Failure.Message)
|
||||
|
||||
@@ -65,3 +65,47 @@ func (lookup FlagLookup) Find(spelling string) (Flag, bool) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
// its command-line backend.
|
||||
package commandmeta
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Key identifies one command inside one routing snapshot.
|
||||
type Key uint64
|
||||
|
||||
@@ -75,7 +70,7 @@ type CompletionResult struct {
|
||||
}
|
||||
|
||||
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: "debug", Usage: "show debugging messages", Default: "false", Boolean: true},
|
||||
{Name: "progress", Usage: "show progress messages", Default: "false", Boolean: true},
|
||||
@@ -181,14 +176,10 @@ type Result struct {
|
||||
Failure *Failure
|
||||
}
|
||||
|
||||
// ParseOptions makes parser context and streams explicit.
|
||||
// ParseOptions selects flag aliases for parser contexts.
|
||||
type ParseOptions struct {
|
||||
// FlagAliasesForPath selects aliases for the actual parser context when
|
||||
// routing has not already selected one. It overrides FlagAliases when set.
|
||||
FlagAliasesForPath func([]string) []FlagAlias
|
||||
Context context.Context
|
||||
In io.Reader
|
||||
Out io.Writer
|
||||
Err io.Writer
|
||||
FlagAliases []FlagAlias
|
||||
}
|
||||
|
||||
@@ -150,3 +150,65 @@ func TestOccurrenceRequiredAndAliasedConversion(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,10 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
"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 {
|
||||
selection := commandmeta.ReportingSelection{}
|
||||
for index, occurrence := range result.FlagOccurrences {
|
||||
|
||||
@@ -34,15 +34,14 @@ func TestAppParseUsesRegisteredBindingsWithTheAdapter(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
options := commandmeta.ParseOptions{Context: context.Background()}
|
||||
result := app.parse([]string{"config", "show", "one", "--scope", "app"}, options)
|
||||
result := parseReportingArgs(app, []string{"config", "show", "one", "--scope", "app"})
|
||||
if result.Failure != nil {
|
||||
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"}) {
|
||||
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 {
|
||||
t.Fatal("accepted a child flag before the complete command path")
|
||||
}
|
||||
@@ -55,7 +54,7 @@ func TestRoutingSnapshotPairsParsedKeyWithItsRunner(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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"})
|
||||
if parsed.Failure != nil {
|
||||
t.Fatalf("parse failed: %s", parsed.Failure.Message)
|
||||
|
||||
@@ -32,8 +32,10 @@ func normalizeReport(value any) (any, error) {
|
||||
}
|
||||
|
||||
// DecodeYAMLReport decodes one YAML document from safe inspection output into
|
||||
// JSON-compatible values. Unsupported YAML meanings return an error; the result
|
||||
// is not a lossless representation of the input document.
|
||||
// JSON-compatible values. Multiple documents, unsupported tags and non-string
|
||||
// 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) {
|
||||
decoder := yaml.NewDecoder(reader)
|
||||
var doc yaml.Node
|
||||
@@ -104,18 +106,6 @@ func yamlReportValue(node *yaml.Node, visiting map[*yaml.Node]bool) (any, error)
|
||||
case yaml.ScalarNode:
|
||||
switch node.ShortTag() {
|
||||
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
|
||||
case "!!null":
|
||||
return nil, nil
|
||||
@@ -188,7 +178,14 @@ func reportYAMLNode(value any) (*yaml.Node, error) {
|
||||
if strings.ContainsAny(string(v), ".eE") {
|
||||
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:
|
||||
node := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
|
||||
for _, item := range v {
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestNormalizeReport(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestYAMLReportValues(t *testing.T) {
|
||||
input := `large: 123456789012345678901234567890
|
||||
input := `large: !!int 123456789012345678901234567890
|
||||
hex: 0xff
|
||||
fraction: .125
|
||||
text: "false"
|
||||
@@ -94,8 +94,8 @@ func TestDecodeYAMLReportRejectsUnsupportedValues(t *testing.T) {
|
||||
|
||||
func TestYAMLReportLargeNumericScalars(t *testing.T) {
|
||||
for _, tc := range []struct{ input, want string }{
|
||||
{"1e9999", "1e9999"}, {"0x100000000000000000000", "1208925819614629174706176"},
|
||||
{"+01.2500e+9999", "1.2500e+9999"},
|
||||
{"!!float 1e9999", "1e9999"}, {"!!int 0x100000000000000000000", "1208925819614629174706176"},
|
||||
{"!!float +01.2500e+9999", "1.2500e+9999"},
|
||||
} {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
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))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -133,3 +133,37 @@ func TestReportYAMLNodePreservesNumericLookingStrings(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
86
reporter.go
86
reporter.go
@@ -36,15 +36,16 @@ const (
|
||||
)
|
||||
|
||||
type reportingState struct {
|
||||
mu sync.Mutex
|
||||
output destinationState
|
||||
diagnostics destinationState
|
||||
closed bool
|
||||
mode reportingMode
|
||||
format Format
|
||||
progress bool
|
||||
progressSet bool
|
||||
deferred []deferredMessage
|
||||
mu sync.Mutex
|
||||
output destinationState
|
||||
diagnostics destinationState
|
||||
closed bool
|
||||
deferredClosed bool
|
||||
mode reportingMode
|
||||
format Format
|
||||
progress bool
|
||||
progressSet bool
|
||||
deferred []deferredMessage
|
||||
}
|
||||
|
||||
type destinationState struct {
|
||||
@@ -78,8 +79,8 @@ type Reporter struct {
|
||||
}
|
||||
|
||||
// DeferredReporter queues messages for delivery during invocation finalization.
|
||||
// Copies share the same invocation reporting state. Submissions made after
|
||||
// finalization return ErrReporterClosed.
|
||||
// Copies share the same invocation reporting state. Submissions return
|
||||
// ErrReporterClosed once finalization begins, before the accepted batch is delivered.
|
||||
type DeferredReporter struct {
|
||||
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
|
||||
// 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 {
|
||||
if r == nil {
|
||||
return DeferredReporter{}
|
||||
@@ -258,7 +260,7 @@ func (r DeferredReporter) enqueue(category messageCategory, message string, deta
|
||||
state := r.state
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
if state.closed {
|
||||
if state.closed || state.deferredClosed {
|
||||
return ErrReporterClosed
|
||||
}
|
||||
if !hasDetails(details) {
|
||||
@@ -290,6 +292,7 @@ func (r *Reporter) finalize(outcome Outcome, completion func(*Reporter)) Outcome
|
||||
state.mu.Unlock()
|
||||
return normalizeOutcome(outcome)
|
||||
}
|
||||
state.deferredClosed = true
|
||||
deferred := state.deferred
|
||||
state.deferred = nil
|
||||
state.mu.Unlock()
|
||||
@@ -324,6 +327,8 @@ func (r *Reporter) finalize(outcome Outcome, completion func(*Reporter)) Outcome
|
||||
state.closed = true
|
||||
state.deferred = nil
|
||||
outcome.Presentation = state.presentationLocked()
|
||||
state.output.writer = nil
|
||||
state.diagnostics.writer = nil
|
||||
state.mu.Unlock()
|
||||
return normalizeOutcome(outcome)
|
||||
}
|
||||
@@ -400,21 +405,7 @@ func renderTerminalLocked(state *reportingState, explanation Explanation) error
|
||||
writeErr = writer.writeError
|
||||
}
|
||||
if writeErr == nil {
|
||||
for _, detail := range explanation.Details {
|
||||
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
|
||||
}
|
||||
}
|
||||
renderErr, writeErr = renderDetailsLocked(state, explanation.Details, true)
|
||||
}
|
||||
if renderErr == nil && writeErr == nil {
|
||||
destination.delivered = true
|
||||
@@ -464,6 +455,14 @@ func (state *reportingState) submitMessageLocked(category messageCategory, messa
|
||||
if visible && writeErr != nil {
|
||||
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 {
|
||||
if detail == nil {
|
||||
continue
|
||||
@@ -484,10 +483,7 @@ func (state *reportingState) submitMessageLocked(category messageCategory, messa
|
||||
writeErr = detailWriteErr
|
||||
}
|
||||
}
|
||||
if visible && renderErr == nil && writeErr == nil {
|
||||
destination.delivered = true
|
||||
}
|
||||
return combineOperationErrors(renderErr, writeErr)
|
||||
return renderErr, writeErr
|
||||
}
|
||||
|
||||
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 {
|
||||
current := state.destination(destination)
|
||||
result := DeliveryResult{RenderError: current.renderError, WriteError: current.writeError}
|
||||
@@ -664,23 +654,3 @@ func (state *reportingState) deliveryResultLocked(destination reportingDestinati
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestHiddenSimpleDeferredMessagesDoNotAccumulate(t *testing.T) {
|
||||
for _, mode := range []reportingMode{modeOrdinary, modeQuiet} {
|
||||
t.Run(fmt.Sprint(mode), func(t *testing.T) {
|
||||
reporter := newReporter(Streams{})
|
||||
reporter.setVisibility(mode, false, false)
|
||||
setReporterVisibility(reporter, mode, false, false)
|
||||
for index := 0; index < 256; index++ {
|
||||
if err := reporter.AtEnd().Debug(fmt.Sprintf("hidden %d", index), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -45,7 +45,7 @@ func TestHiddenSimpleDeferredMessagesDoNotAccumulate(t *testing.T) {
|
||||
func TestHiddenDeferredDetailsRemainDeferredAndCaptured(t *testing.T) {
|
||||
var diagnostics bytes.Buffer
|
||||
reporter := newReporter(Streams{Err: &diagnostics})
|
||||
reporter.setVisibility(modeQuiet, false, false)
|
||||
setReporterVisibility(reporter, modeQuiet, false, false)
|
||||
failure := errors.New("hidden rendering failed")
|
||||
var order []string
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestReporterExplainsItsOwnFailuresWithoutExposingCauses(t *testing.T) {
|
||||
var diagnostics bytes.Buffer
|
||||
reporter := newReporter(Streams{Err: &diagnostics})
|
||||
if quiet {
|
||||
reporter.setVisibility(modeQuiet, false, false)
|
||||
setReporterVisibility(reporter, modeQuiet, false, false)
|
||||
}
|
||||
failure := errors.New("private rendering detail")
|
||||
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"))
|
||||
destination := &reporterTestWriter{err: writeErr}
|
||||
reporter := newReporter(Streams{Out: destination, Err: destination})
|
||||
reporter.setVisibility(modeVerbose, false, false)
|
||||
setReporterVisibility(reporter, modeVerbose, false, false)
|
||||
render := func(w io.Writer) error {
|
||||
_, err := io.WriteString(w, "report")
|
||||
return test.combine(renderErr, err)
|
||||
|
||||
@@ -2,10 +2,13 @@ package commandkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
)
|
||||
|
||||
type reporterTestWriter struct {
|
||||
@@ -77,7 +80,7 @@ func TestReporterLeavesCallerWriterOpen(t *testing.T) {
|
||||
func TestReporterQuietOutputDiscardsSuccessfully(t *testing.T) {
|
||||
var destination bytes.Buffer
|
||||
reporter := newReporter(Streams{Out: &destination})
|
||||
reporter.setVisibility(modeQuiet, false, false)
|
||||
setReporterVisibility(reporter, modeQuiet, false, false)
|
||||
writer := reporter.Output()
|
||||
|
||||
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
|
||||
reporter := newReporter(Streams{Out: &destination})
|
||||
writer := reporter.Output()
|
||||
reporter.setVisibility(modeQuiet, false, false)
|
||||
reporter.close()
|
||||
setReporterVisibility(reporter, modeQuiet, false, false)
|
||||
_ = reporter.finalize(Success(), nil)
|
||||
|
||||
if n, err := writer.Write([]byte("late")); n != 0 || !errors.Is(err, ErrReporterClosed) {
|
||||
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 {
|
||||
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 {
|
||||
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) {
|
||||
var output bytes.Buffer
|
||||
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.Warn("not used")
|
||||
_ = reporter.Debug("DEBUG")
|
||||
@@ -234,7 +237,7 @@ func TestReporterMessageVisibilityAndProgressPrecedence(t *testing.T) {
|
||||
func TestReporterFormatsMessagesAndPreservesDetailOrder(t *testing.T) {
|
||||
var diagnostics bytes.Buffer
|
||||
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,
|
||||
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 {
|
||||
@@ -255,7 +258,7 @@ func TestReporterFormatsMessagesAndPreservesDetailOrder(t *testing.T) {
|
||||
func TestReporterHiddenRenderersRunAgainstDiscardAndRetainErrors(t *testing.T) {
|
||||
var diagnostics bytes.Buffer
|
||||
reporter := newReporter(Streams{Err: &diagnostics})
|
||||
reporter.setVisibility(modeQuiet, false, false)
|
||||
setReporterVisibility(reporter, modeQuiet, false, false)
|
||||
renderErr := errors.New("native renderer failed")
|
||||
calls := 0
|
||||
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")
|
||||
diagnosticErr := errors.New("diagnostic renderer failed")
|
||||
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.Warn("warning", func(io.Writer) error { return diagnosticErr })
|
||||
|
||||
@@ -294,7 +297,7 @@ func TestReporterRecordsIgnoredRenderErrors(t *testing.T) {
|
||||
func TestReporterRenderOutputRunsInQuietAndRetainsFailure(t *testing.T) {
|
||||
destination := &reporterTestWriter{}
|
||||
reporter := newReporter(Streams{Out: destination})
|
||||
reporter.setVisibility(modeQuiet, false, false)
|
||||
setReporterVisibility(reporter, modeQuiet, false, false)
|
||||
renderErr := errors.New("quiet encoder failed")
|
||||
calls := 0
|
||||
_ = 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 {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -406,7 +409,7 @@ func TestReporterConcurrentOperationsRemainWhole(t *testing.T) {
|
||||
t.Run("render callback and message", func(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
reporter := newReporter(Streams{Out: &output, Err: &output})
|
||||
reporter.setVisibility(modeVerbose, false, false)
|
||||
setReporterVisibility(reporter, modeVerbose, false, false)
|
||||
start := make(chan struct{})
|
||||
ready := 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) {
|
||||
var output bytes.Buffer
|
||||
reporter := newReporter(Streams{Out: &output, Err: &output})
|
||||
reporter.setVisibility(modeVerbose, false, false)
|
||||
setReporterVisibility(reporter, modeVerbose, false, false)
|
||||
start := make(chan struct{})
|
||||
ready := make(chan struct{}, 2)
|
||||
done := make(chan struct{}, 2)
|
||||
@@ -478,7 +481,7 @@ func TestReporterConcurrentOperationsRemainWhole(t *testing.T) {
|
||||
func TestReporterEmptyHeadingDoesNotAddPrefixOrBlankLine(t *testing.T) {
|
||||
var diagnostics bytes.Buffer
|
||||
reporter := newReporter(Streams{Err: &diagnostics})
|
||||
reporter.setVisibility(modeVerbose, false, false)
|
||||
setReporterVisibility(reporter, modeVerbose, false, false)
|
||||
if err := reporter.Warn("", func(w io.Writer) error {
|
||||
_, err := io.WriteString(w, "native multiline\nsecond line\n")
|
||||
return err
|
||||
@@ -496,7 +499,7 @@ func TestReporterEmptyHeadingDoesNotAddPrefixOrBlankLine(t *testing.T) {
|
||||
func TestReporterFinalizesDeferredMessagesBeforeCompletionAndExplanation(t *testing.T) {
|
||||
var output, diagnostics bytes.Buffer
|
||||
reporter := newReporter(Streams{Out: &output, Err: &diagnostics})
|
||||
reporter.setVisibility(modeDebug, false, false)
|
||||
setReporterVisibility(reporter, modeDebug, false, false)
|
||||
if err := reporter.Info("started"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -522,7 +525,7 @@ func TestReporterFinalizesDeferredMessagesBeforeCompletionAndExplanation(t *test
|
||||
func TestReporterDeferredSubmissionCopiesDetailsAndRetainsDeferredFailures(t *testing.T) {
|
||||
var diagnostics bytes.Buffer
|
||||
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 })
|
||||
writeSecond := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "second detail\n"); return err })
|
||||
details := []Detail{writeFirst}
|
||||
@@ -542,7 +545,7 @@ func TestReporterDeferredSubmissionCopiesDetailsAndRetainsDeferredFailures(t *te
|
||||
|
||||
renderErr := errors.New("deferred renderer failed")
|
||||
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 {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
|
||||
const tool = "go.maximumdirect.net/releasekit/cmd/releasekit"
|
||||
|
||||
var modules = []struct{ selector, dir, profile, product string }{
|
||||
{"core", ".", "releasekit.json", "CommandKit"},
|
||||
{"ckconfig", "integrations/ckconfig", "integrations/ckconfig/releasekit.json", "CommandKit ckconfig"},
|
||||
{"ckprompt", "integrations/ckprompt", "integrations/ckprompt/releasekit.json", "CommandKit ckprompt"},
|
||||
var modules = []struct{ selector, dir, profile string }{
|
||||
{"core", ".", "releasekit.json"},
|
||||
{"ckconfig", "integrations/ckconfig", "integrations/ckconfig/releasekit.json"},
|
||||
{"ckprompt", "integrations/ckprompt", "integrations/ckprompt/releasekit.json"},
|
||||
}
|
||||
|
||||
type fixture struct{ root, outside, log string }
|
||||
@@ -90,38 +90,28 @@ func lines(t *testing.T, path string) []string {
|
||||
}
|
||||
|
||||
func TestProfiles(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
for _, m := range modules {
|
||||
t.Run(m.selector, func(t *testing.T) {
|
||||
var p struct {
|
||||
Product string `json:"product"`
|
||||
Module string `json:"module_dir"`
|
||||
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"`
|
||||
Module string `json:"module_dir"`
|
||||
URL string `json:"expected_push_url"`
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(f.root, m.profile))
|
||||
b, err := os.ReadFile(filepath.Join("..", m.profile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &p); err != nil {
|
||||
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" {
|
||||
t.Fatalf("profile identity: %+v", p)
|
||||
// The profile must agree with the launcher's module selection.
|
||||
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"}) {
|
||||
t.Fatalf("validation: %+v", p.Validation)
|
||||
}
|
||||
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"}) {
|
||||
t.Fatalf("delivery: %v", p.Delivery)
|
||||
// The release procedure selects this exact publication endpoint.
|
||||
// An empty or different value would weaken or misdirect that guard.
|
||||
const pushURL = "ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git"
|
||||
if p.URL != pushURL {
|
||||
t.Errorf("expected_push_url = %q, want %q", p.URL, pushURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
2
testdata/completion/main.go
vendored
2
testdata/completion/main.go
vendored
@@ -29,7 +29,7 @@ func main() {
|
||||
Name: "run", Summary: "Run a fixture command.",
|
||||
Flags: []commandkit.Flag[struct{}]{
|
||||
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: "directory", Short: "d", Usage: "Directory path.", Completion: commandkit.CompleteDirectories()}),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user