63 Commits

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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.4.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.4.0
```
Core v0.5.0 and ckprompt v0.3.0 have unpublished candidate notes. 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.

View File

@@ -62,6 +62,7 @@ capability proposals in [future work](roadmap/future.md).
| Public facade or adapter boundaries | [Internal overview](internal/overview.md), [ADR 0002](adr/0002-public-facade-and-cli-backend.md), [ADR 0004](adr/0004-optional-integration-modules.md), and the architecture policy |
| 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:

View File

@@ -76,6 +76,8 @@ rendering. A session owns the mutable backend tree for one invocation; parsing
and presentation reuse that session. The adapter returns raw values and neutral
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

View File

@@ -161,8 +161,8 @@ and deferred messages. Core owns shared visibility, formatting, delivery, and
finalization. Applications and integrations choose their report content and
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

View File

@@ -100,10 +100,33 @@ standard offline suite and ReleaseKit’s candidate/note and publication guards.
Prepare dependencies in the selected module before the candidate is finalized.
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
View File

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

View File

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

View File

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

View File

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

View File

@@ -74,7 +74,11 @@ func newApp() (*commandkit.App, error) {
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "scenario", Usage: "demo, recover, warn, warn-error, fail, or partial.", Completion: commandkit.CompleteChoices(
commandkit.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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -60,6 +60,44 @@ func TestDetailedValidationFiltersAndRetainsFailureEvidence(t *testing.T) {
if strings.Contains(diag.String(), "native warnings") || !strings.Contains(diag.String(), "application warning") || !strings.Contains(diag.String(), "terminal failure") {
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})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -91,18 +91,16 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
byName: make(map[string]*preparedScope[I, S], len(suite.Scopes)),
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 {

View File

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

View File

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

View File

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

View File

@@ -66,11 +66,13 @@ func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOpera
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
}
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -85,8 +85,9 @@ root): `docs/policy/meta.md`, `docs/policy/agents.md`,
`docs/policy/documentation.md`, `docs/policy/release.md`, and
`docs/policy/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`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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