88 Commits

Author SHA1 Message Date
bd115c8bee Prepare the ckconfig release candidate 2026-11-07 11:12:23 +00:00
f66502cb23 Record the core source release 2026-11-07 11:09:46 +00:00
7416bf6fdc Prepare the core release candidate 2026-11-07 05:08:12 +00:00
d14fa366f5 Align reporting contracts and documentation 2026-11-07 05:06:47 +00:00
1289868ed0 Update the composed PromptKit consumer 2026-11-07 05:01:30 +00:00
42af1da142 Update the simple configuration consumer 2026-11-07 04:54:44 +00:00
0b70f37267 Add detailed validation evidence 2026-11-07 04:52:58 +00:00
1986c44f49 Add detailed comparison assertions 2026-11-07 04:49:40 +00:00
65b454b2ab Add focused configuration reports 2026-11-07 04:47:23 +00:00
b38130548e Add configuration path discovery 2026-11-07 04:44:01 +00:00
aeb70e5873 Add opt-in configuration overrides 2026-11-07 04:39:34 +00:00
85ae55bf42 Separate comparison side selectors 2026-11-07 04:36:05 +00:00
da120c2dd4 Expose configuration metadata and findings 2026-11-07 04:32:24 +00:00
0183022a28 Add scoped PromptKit flag aliases 2026-11-07 04:27:28 +00:00
644e6c365b Add opt-in PromptKit namespaces 2026-11-07 04:23:39 +00:00
8e5e64c6e9 Add prepared request preview 2026-11-07 04:19:58 +00:00
c3d5f551c3 Add reusable PromptKit request flags 2026-11-07 04:16:09 +00:00
6378ceb3c5 Migrate PromptKit reports to shared rendering 2026-11-07 04:10:47 +00:00
5ff0dd4ea2 Adopt native PromptKit resource inspection 2026-11-07 04:05:51 +00:00
6c35da3ab6 Move configuration reports to shared rendering 2026-11-07 03:57:10 +00:00
7159f52f4d Preserve ordered flag occurrences 2026-11-07 03:49:34 +00:00
d748056652 Add CLI JSON value parsing 2026-11-07 03:46:25 +00:00
924692b541 Add shared report format flag 2026-11-07 03:45:12 +00:00
3a6c66f550 Connect report rendering to invocation outcomes 2026-11-07 03:41:21 +00:00
190bfbd77b Add shared report rendering 2026-11-07 03:40:09 +00:00
7693482a8b Add shared report value conversion 2026-11-07 03:38:22 +00:00
105bb741cc Plan shared reporting and PromptKit adoption 2026-11-07 03:33:32 +00:00
cb7e05284c Clarify native preview reporting and cancellation ownership 2026-11-07 03:17:23 +00:00
022055dc26 Retarget adoption roadmap to PromptKit v0.20.0 2026-11-07 03:05:00 +00:00
e908213318 Record ownership and Go-native principles in architecture policy 2026-11-07 02:49:13 +00:00
5d90d59996 Consolidate and renumber architectural decision records 2026-11-07 02:42:54 +00:00
76c00e32d0 Update documentation policy from upstream 2026-11-07 02:27:07 +00:00
9b85c7cf60 Consolidate adoption roadmap and finalize core prerequisites 2026-11-07 02:04:57 +00:00
ee3cb21ebc Settle override syntax and pass-through loading policy 2026-11-07 01:46:09 +00:00
93c16bd57e Close comparison collection scope and defer general clearing support 2026-11-07 01:36:07 +00:00
ca454624cc Apply ordinary Go numeric policy to configuration overrides 2026-11-07 01:32:44 +00:00
90d9706f6d Plan independent defaults for both configuration comparison sides 2026-11-07 01:19:05 +00:00
f494088124 Record namespace selector applicability and profile inspection behavior 2026-11-07 01:11:59 +00:00
5010f9a102 Record ordinary Go numeric types for CLI JSON inputs 2026-11-07 00:54:51 +00:00
3b6614605a Use a scope-keyed mapping for aggregate configuration reports 2026-11-07 00:41:56 +00:00
e7b00efc07 Simplify safe YAML conversion into shared report values 2026-11-07 00:32:51 +00:00
647a9d5372 Record ownership principles at the top of the roadmap 2026-11-06 22:02:32 +00:00
c52034f3fe Define original-value text templates and YAML fallback 2026-11-06 21:40:52 +00:00
408383db60 Adopt JSON conventions for shared report representation 2026-11-06 21:09:19 +00:00
e82101434f Select report formats automatically from content and templates 2026-11-06 20:05:24 +00:00
ffe568b6f0 Record universal report formats and optional command defaults 2026-11-06 19:49:36 +00:00
756954f056 Plan shared core report output before integration additions 2026-11-06 19:34:26 +00:00
b6a8f1f2cc Set initial report designs and lightweight YAML implementation guidance 2026-11-06 19:00:02 +00:00
d15926a878 Record remaining adoption scope and CLI decisions 2026-11-06 18:50:16 +00:00
7f2fdce8e7 Record optional validation details and failure evidence 2026-11-06 18:40:55 +00:00
491f0b49f4 Record optional detailed configuration diff report 2026-11-06 18:37:32 +00:00
61bcd80171 Record config explain modes and default help behavior 2026-11-06 18:27:39 +00:00
9076cb2c9c Record agreed namespace default composition 2026-11-06 18:21:14 +00:00
f63af12097 Defer appended-message flags and preserve operator use cases 2026-11-06 16:39:51 +00:00
111f963c67 Record explicit prompt input flags and text aliases 2026-11-06 13:56:45 +00:00
66a33756c3 Record reusable LLM flags and scoped convenience aliases 2026-11-06 12:29:25 +00:00
ff9f840edd Record agreed prepared request preview command 2026-11-06 03:52:51 +00:00
f6f41ee6aa Fold configuration discovery and diagnostics into existing commands 2026-11-06 03:38:12 +00:00
df33e8aefc Plan reusable PromptKit CLI flag collections 2026-11-06 03:28:21 +00:00
6830b96821 Expand operator roadmap with ConfigKit CLI features 2026-11-06 03:03:31 +00:00
42af12cb0e Plan PromptKit v0.19.0 adoption and operator CLI additions 2026-11-06 02:42:15 +00:00
0b4e47c420 Record PromptKit roadmap scope and backend inspection deferral 2026-11-06 02:34:43 +00:00
ad16bfc521 Retire completed PromptKit planning documents 2026-11-06 01:18:55 +00:00
540e2359d6 Complete PromptKit adoption validation 2026-11-06 01:05:51 +00:00
e28ba63b59 Document PromptKit adoption and release candidate 2026-11-06 01:03:55 +00:00
7f327758d8 Migrate composed PromptKit consumer 2026-11-06 01:01:13 +00:00
1e3879e880 Verify native bundle and configuration reports 2026-11-06 00:59:56 +00:00
33cc36ff3b Migrate native PromptKit test fixtures 2026-11-06 00:59:05 +00:00
983c22be77 Remove obsolete prompt version reports 2026-11-06 00:57:50 +00:00
f933f056d7 Align PromptKit dependency identities 2026-11-06 00:55:48 +00:00
cf50b135f2 Plan PromptKit adoption implementation 2026-11-05 19:54:05 +00:00
ee0debe4fb Record PromptKit adoption scope and completion criteria 2026-11-04 17:09:47 +00:00
4f851371da Record PromptKit capability options and scope decisions 2026-11-04 16:54:29 +00:00
69cc6429ff Plan PromptKit 0.18 adoption 2026-11-04 16:35:03 +00:00
eeae10fa05 Record ckconfig release completion 2026-11-04 16:17:49 +00:00
bc1d1d5844 Retire completed roadmaps and collect future work 2026-11-04 16:04:32 +00:00
260256d722 Complete ConfigKit adoption validation 2026-11-04 15:11:05 +00:00
5a79b56e3c Align ckconfig dependency attribution 2026-11-04 15:07:05 +00:00
5ccdcb91c9 Document ConfigKit adoption 2026-11-04 15:05:42 +00:00
42176a7ac4 Test configuration source evidence 2026-11-04 15:02:06 +00:00
72099958d1 Expose current configuration source evidence 2026-11-04 15:00:04 +00:00
2904eadf4f Adopt ConfigKit module and APIs 2026-11-04 14:58:20 +00:00
1c778d7ad3 Plan ConfigKit adoption sprint 2026-11-04 14:42:23 +00:00
16584084b1 Record ConfigKit adoption scope and deferred inspection work 2026-11-04 14:32:02 +00:00
ce8ab3be38 Expand ConfigKit roadmap with API coverage findings 2026-11-04 13:25:20 +00:00
7a9e3a1f7c Plan ConfigKit 1.1 adoption 2026-11-04 13:14:31 +00:00
60841227b7 Complete vanity migration roadmap 2026-11-04 03:56:33 +00:00
3699c6c5e6 Record ckprompt source release 2026-11-04 03:55:50 +00:00
155 changed files with 9201 additions and 5896 deletions

View File

@@ -9,7 +9,7 @@ The core captures reusable command declarations, validates installation batches
before changing an application's registry, converts typed inputs before before changing an application's registry, converts typed inputs before
constructing them, and returns outcomes without exiting the process. Its constructing them, and returns outcomes without exiting the process. Its
[consumer guide](docs/consumers/guide.md) covers declaration, invocation [consumer guide](docs/consumers/guide.md) covers declaration, invocation
reporting, and migration. Each handler receives one invocation reporter for and reporting. Each handler receives one invocation reporter for
substantive output, categorized messages, and terminal explanations; `Execute` substantive output, categorized messages, and terminal explanations; `Execute`
returns delivery observations with the outcome. The [CLI reference](docs/cli.md) returns delivery observations with the outcome. The [CLI reference](docs/cli.md)
defines the shared visibility policy. defines the shared visibility policy.
@@ -22,9 +22,13 @@ defines the shared visibility policy.
| `go.maximumdirect.net/ckconfig` | `ckconfig` | ConfigKit-backed `config` scopes, including configuration source evidence. | | `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/ckprompt` | `ckprompt` | PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, concrete request validation, and native smoke tests. |
Importing an integration does not install commands. The matched ckconfig and Importing an integration does not install commands. Core v0.4.0 is published;
ckprompt v0.2.0 modules use core v0.3.0. See the the published ckconfig and ckprompt v0.2.0 modules use core v0.3.0. The published
[consumer guide](docs/consumers/guide.md#module-selection) for migration details. 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.
## Examples ## Examples
@@ -41,7 +45,7 @@ ckprompt v0.2.0 modules use core v0.3.0. See the
- [Maintainer release procedure](docs/release.md) - [Maintainer release procedure](docs/release.md)
- [Architecture policy](docs/policy/architecture.md) - [Architecture policy](docs/policy/architecture.md)
- [Policy provenance](docs/policy/meta.md) - [Policy provenance](docs/policy/meta.md)
- [Deferred capabilities and proposals](docs/roadmap/deferred.md) - [Future work and proposals](docs/roadmap/future.md)
CommandKit is licensed under the [LGPL v3.0 or later](COPYRIGHT). CommandKit is licensed under the [LGPL v3.0 or later](COPYRIGHT).

View File

@@ -3,6 +3,7 @@ package commandkit
import ( import (
"errors" "errors"
"fmt" "fmt"
"sort"
"strconv" "strconv"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta" commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
@@ -38,6 +39,48 @@ func ValueFlag[I, V any](meta FlagMeta, defaultValue *V, parse func(string) (V,
return Flag[I]{binding: binding} return Flag[I]{binding: binding}
} }
// OccurrenceFlag declares a repeatable flag whose setter runs once for each
// supplied occurrence. Occurrence setters run in command-line order across
// participating bindings; an omitted flag does not call its setter.
func OccurrenceFlag[I, V any](meta FlagMeta, parse func(string) (V, error), set func(*I, Supplied[V])) Flag[I] {
binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, repeated: true}
if parse == nil || set == nil {
return Flag[I]{binding: binding}
}
binding.valid = true
binding.prepare = func(values []string) (preparedValue[I], error) {
result := preparedValue[I]{identity: binding.identity, supplied: len(values) > 0}
for index, raw := range values {
value, err := parse(raw)
if err != nil {
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
}
ordinal := index
result.ordered = append(result.ordered, preparedOccurrence[I]{ordinal: &ordinal, apply: func(input *I) { set(input, Supplied[V]{Value: value, Set: true}) }})
}
return result, nil
}
return Flag[I]{binding: binding}
}
// RenameFlag returns an independent binding with a new long name and no short
// spelling. Parsing, defaults, completion, requiredness, and setters are retained.
func RenameFlag[I any](flag Flag[I], name string) Flag[I] {
binding := flag.binding
binding.meta.Name = name
binding.meta.Short = ""
binding.identity = newBindingIdentity()
if prepare := binding.prepare; prepare != nil {
identity := binding.identity
binding.prepare = func(values []string) (preparedValue[I], error) {
value, err := prepare(values)
value.identity = identity
return value, err
}
}
return Flag[I]{binding: binding}
}
// BoolFlag declares a boolean flag. A bare flag is supplied by the parser as // BoolFlag declares a boolean flag. A bare flag is supplied by the parser as
// "true"; explicit values are converted with strconv.ParseBool. Set distinguishes // "true"; explicit values are converted with strconv.ParseBool. Set distinguishes
// an omitted flag from an explicit false value. // an omitted flag from an explicit false value.
@@ -189,7 +232,9 @@ func (p preparedInput[I]) build() I {
input = p.newInput() input = p.newInput()
} }
for _, value := range p.values { for _, value := range p.values {
value.apply(&input) if value.apply != nil {
value.apply(&input)
}
} }
return input return input
} }
@@ -202,6 +247,10 @@ func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []
} }
func prepareInputWithSpellings[I any](spec CommandSpec[I], flagValues, flagSpellings [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) { 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) { if len(flagValues) != len(spec.Flags) {
return preparedInput[I]{}, fmt.Errorf("received %d flag value sets for %d bindings", len(flagValues), len(spec.Flags)) return preparedInput[I]{}, fmt.Errorf("received %d flag value sets for %d bindings", len(flagValues), len(spec.Flags))
} }
@@ -212,6 +261,12 @@ func prepareInputWithSpellings[I any](spec CommandSpec[I], flagValues, flagSpell
return preparedInput[I]{}, fmt.Errorf("received %d flag spelling sets for %d bindings", len(flagSpellings), len(spec.Flags)) return preparedInput[I]{}, fmt.Errorf("received %d flag spelling sets for %d bindings", len(flagSpellings), len(spec.Flags))
} }
preparedFlags := make([]preparedValue[I], len(spec.Flags)) preparedFlags := make([]preparedValue[I], len(spec.Flags))
ordinals := make(map[string][]int)
for index, occurrence := range occurrences {
ordinals[occurrence.Name] = append(ordinals[occurrence.Name], index)
}
var ordered []preparedOccurrence[I]
fallbackOrdinal := 0
missingRequired := "" missingRequired := ""
for i, flag := range spec.Flags { for i, flag := range spec.Flags {
binding := flag.binding binding := flag.binding
@@ -231,6 +286,21 @@ func prepareInputWithSpellings[I any](spec CommandSpec[I], flagValues, flagSpell
return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err) return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err)
} }
preparedFlags[i] = value preparedFlags[i] = value
for index, occurrence := range value.ordered {
if occurrences != nil {
positions := ordinals[binding.meta.Name]
if index >= len(positions) {
return preparedInput[I]{}, fmt.Errorf("missing occurrence position for flag --%s", binding.meta.Name)
}
*occurrence.ordinal = positions[index]
} else {
*occurrence.ordinal = fallbackOrdinal
fallbackOrdinal++
}
if occurrence.apply != nil {
ordered = append(ordered, occurrence)
}
}
if enforceRequired && binding.meta.Required && !binding.deferredRequired && !value.supplied && missingRequired == "" { if enforceRequired && binding.meta.Required && !binding.deferredRequired && !value.supplied && missingRequired == "" {
missingRequired = binding.meta.Name missingRequired = binding.meta.Name
} }
@@ -243,6 +313,10 @@ func prepareInputWithSpellings[I any](spec CommandSpec[I], flagValues, flagSpell
return preparedInput[I]{}, err return preparedInput[I]{}, err
} }
preparedValues := append([]preparedValue[I](nil), preparedFlags...) preparedValues := append([]preparedValue[I](nil), preparedFlags...)
sort.SliceStable(ordered, func(i, j int) bool { return *ordered[i].ordinal < *ordered[j].ordinal })
for _, occurrence := range ordered {
preparedValues = append(preparedValues, preparedValue[I]{apply: occurrence.apply})
}
for i, argument := range spec.Arguments { for i, argument := range spec.Arguments {
binding := argument.binding binding := argument.binding
value, err := binding.prepare(argumentValues[i]) value, err := binding.prepare(argumentValues[i])

View File

@@ -4,10 +4,10 @@ import (
"fmt" "fmt"
"io" "io"
"runtime/debug" "runtime/debug"
"text/template"
internalcobra "go.maximumdirect.net/commandkit/internal/cobra" internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta" commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
yaml "go.yaml.in/yaml/v4"
) )
const commandKitModulePath = "go.maximumdirect.net/commandkit" const commandKitModulePath = "go.maximumdirect.net/commandkit"
@@ -17,16 +17,17 @@ type helpInput struct {
} }
type versionInput struct { type versionInput struct {
Scope string Scope string
Format string
} }
type versionDocument struct { type versionDocument struct {
Name string `yaml:"name"` Name string `json:"name"`
Version string `yaml:"version"` Version string `json:"version"`
Revision string `yaml:"revision"` Revision string `json:"revision"`
} }
var versionTextTemplate = template.Must(ParseTextTemplate("version", "{{.Name}} {{.Version}}{{if .Revision}} ({{.Revision}}){{end}}\n", nil))
func (a *App) builtinCommands() []Declaration { func (a *App) builtinCommands() []Declaration {
help := captureCommand(CommandSpec[helpInput]{ help := captureCommand(CommandSpec[helpInput]{
Name: "help", Name: "help",
@@ -39,14 +40,12 @@ func (a *App) builtinCommands() []Declaration {
return current.renderHelpTargets(input.Targets.Value) return current.renderHelpTargets(input.Targets.Value)
}) })
scopeDefault := "app" scopeDefault := "app"
formatDefault := "text"
version := NewCommand(CommandSpec[versionInput]{ version := NewCommand(CommandSpec[versionInput]{
Name: "version", Name: "version",
Summary: "Show application or CommandKit version.", Summary: "Show application or CommandKit version.",
Description: "Report identity and build metadata without loading application configuration.", Description: "Report identity and build metadata without loading application configuration.",
Flags: []Flag[versionInput]{ Flags: []Flag[versionInput]{
ValueFlag(FlagMeta{Name: "scope", Usage: "version subject", Completion: CompleteChoices(CompletionChoice{Value: "app"}, CompletionChoice{Value: "commandkit"})}, &scopeDefault, parseVersionScope, func(input *versionInput, value Supplied[string]) { input.Scope = value.Value }), ValueFlag(FlagMeta{Name: "scope", Usage: "version subject", Completion: CompleteChoices(CompletionChoice{Value: "app"}, CompletionChoice{Value: "commandkit"})}, &scopeDefault, parseVersionScope, func(input *versionInput, value Supplied[string]) { input.Scope = value.Value }),
ValueFlag(FlagMeta{Name: "format", Usage: "output format", Completion: CompleteChoices(CompletionChoice{Value: "text"}, CompletionChoice{Value: "yaml"})}, &formatDefault, parseVersionFormat, func(input *versionInput, value Supplied[string]) { input.Format = value.Value }),
}, },
Run: func(invocation Invocation, input versionInput) Outcome { Run: func(invocation Invocation, input versionInput) Outcome {
identity := a.identity identity := a.identity
@@ -56,7 +55,7 @@ func (a *App) builtinCommands() []Declaration {
if identity.Version == "" { if identity.Version == "" {
identity.Version = "dev" identity.Version = "dev"
} }
return renderVersionOutcome(identity, input.Format, invocation.Reporter()) return renderVersionOutcome(identity, invocation.Reporter())
}, },
}) })
return []Declaration{help, version} return []Declaration{help, version}
@@ -69,13 +68,6 @@ func parseVersionScope(value string) (string, error) {
return value, nil return value, nil
} }
func parseVersionFormat(value string) (string, error) {
if value != "text" && value != "yaml" {
return "", fmt.Errorf("format must be text or yaml, got %q", value)
}
return value, nil
}
func (a *App) renderHelpViewOutcomeWithSession(view commandmeta.HelpView, reporter *Reporter, session *internalcobra.Session) Outcome { func (a *App) renderHelpViewOutcomeWithSession(view commandmeta.HelpView, reporter *Reporter, session *internalcobra.Session) Outcome {
content, failure := session.RenderHelpView(view) content, failure := session.RenderHelpView(view)
if failure != nil { if failure != nil {
@@ -91,36 +83,14 @@ func (a *App) renderHelpViewOutcomeWithSession(view commandmeta.HelpView, report
return Success() return Success()
} }
func renderVersionOutcome(identity Identity, format string, reporter *Reporter) Outcome { func renderVersionOutcome(identity Identity, reporter *Reporter) Outcome {
if identity.Version == "" { if identity.Version == "" {
identity.Version = "dev" identity.Version = "dev"
} }
switch format { _ = reporter.Report(Report{Value: versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision}, TextTemplate: versionTextTemplate})
case "text":
line := identity.Name + " " + identity.Version
if identity.Revision != "" {
line += " (" + identity.Revision + ")"
}
_ = reporter.RenderOutput(func(w io.Writer) error {
_, err := io.WriteString(w, line+"\n")
return err
})
case "yaml":
_ = reporter.RenderOutput(func(w io.Writer) error {
return encodeYAML(w, versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision})
})
default:
return Usage(fmt.Sprintf("unsupported version format %q", format))
}
return Success() return Success()
} }
func encodeYAML(writer io.Writer, document any) error {
encoder := yaml.NewEncoder(writer)
encoder.SetIndent(2)
return encoder.Encode(document)
}
func readBuildInfo() *debug.BuildInfo { func readBuildInfo() *debug.BuildInfo {
info, ok := debug.ReadBuildInfo() info, ok := debug.ReadBuildInfo()
if !ok { if !ok {

76
cli_json.go Normal file
View File

@@ -0,0 +1,76 @@
package commandkit
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
)
// ParseCLIJSON parses exactly one JSON value for use in CLI inputs. Integer
// tokens become int64, or uint64 for larger nonnegative integers; decimal and
// exponent tokens become float64. Nested values follow the same rules. Syntax
// errors and numbers outside the selected Go types' ranges are returned.
func ParseCLIJSON(input string) (any, error) {
decoder := json.NewDecoder(strings.NewReader(input))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return nil, err
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
if err != nil {
return nil, err
}
return nil, fmt.Errorf("unexpected additional JSON value")
}
return convertCLIJSON(value)
}
// CLI inputs use ordinary Go numbers for upstream consumers. Classification
// follows the token's syntax, so 1.0 and 1e0 remain float64. Unlike report
// serialization, this boundary deliberately accepts float64 approximation.
func convertCLIJSON(value any) (any, error) {
switch v := value.(type) {
case json.Number:
token := string(v)
if strings.ContainsAny(token, ".eE") {
result, err := strconv.ParseFloat(token, 64)
if err != nil {
return nil, fmt.Errorf("JSON number %q: %w", token, err)
}
return result, nil
}
result, err := strconv.ParseInt(token, 10, 64)
if err == nil {
return result, nil
}
if !strings.HasPrefix(token, "-") {
unsigned, unsignedErr := strconv.ParseUint(token, 10, 64)
if unsignedErr == nil {
return unsigned, nil
}
return nil, fmt.Errorf("JSON integer %q: %w", token, unsignedErr)
}
return nil, fmt.Errorf("JSON integer %q: %w", token, err)
case []any:
for index, item := range v {
converted, err := convertCLIJSON(item)
if err != nil {
return nil, err
}
v[index] = converted
}
case map[string]any:
for key, item := range v {
converted, err := convertCLIJSON(item)
if err != nil {
return nil, err
}
v[key] = converted
}
}
return value, nil
}

44
cli_json_test.go Normal file
View File

@@ -0,0 +1,44 @@
package commandkit
import (
"errors"
"math"
"reflect"
"strconv"
"testing"
)
func TestParseCLIJSONValues(t *testing.T) {
for _, tc := range []struct {
input string
want any
}{
{`{"a":[null,true,"x",-9223372036854775808,9223372036854775807,9223372036854775808,18446744073709551615],"b":{"n":1.0,"exp":1e3}}`, map[string]any{
"a": []any{nil, true, "x", int64(math.MinInt64), int64(math.MaxInt64), uint64(1 << 63), uint64(math.MaxUint64)},
"b": map[string]any{"n": float64(1), "exp": float64(1000)},
}},
{`0`, int64(0)}, {`-0`, int64(0)}, {`42.0`, float64(42)}, {`1e0`, float64(1)},
{`0.1`, float64(0.1)}, {`"1"`, "1"}, {`false`, false}, {`null`, nil},
} {
t.Run(tc.input, func(t *testing.T) {
got, err := ParseCLIJSON(tc.input)
if err != nil || !reflect.DeepEqual(got, tc.want) {
t.Fatalf("got %#v, %v; want %#v", got, err, tc.want)
}
})
}
}
func TestParseCLIJSONRejectsInvalidInput(t *testing.T) {
for _, input := range []string{"", "[", "01", `1 2`, `true false`, `{} garbage`, `-9223372036854775809`, `18446744073709551616`, `1e400`, `[-1e400]`} {
t.Run(input, func(t *testing.T) {
if _, err := ParseCLIJSON(input); err == nil {
t.Fatalf("accepted %q", input)
}
})
}
_, err := ParseCLIJSON(`18446744073709551616`)
if !errors.Is(err, strconv.ErrRange) {
t.Fatalf("range error = %v", err)
}
}

View File

@@ -127,6 +127,13 @@ type preparedValue[I any] struct {
identity *bindingIdentity identity *bindingIdentity
supplied bool supplied bool
apply func(*I) apply func(*I)
ordered []preparedOccurrence[I]
}
type preparedOccurrence[I any] struct {
// Captured bindings share this position with the outer prepared occurrence.
ordinal *int
apply func(*I)
} }
type flagBinding[I any] struct { type flagBinding[I any] struct {
@@ -186,7 +193,7 @@ type commandPayload interface {
hasRun() bool hasRun() bool
bridgeFlags() []commandmeta.Flag bridgeFlags() []commandmeta.Flag
bridgeArguments() []commandmeta.Argument bridgeArguments() []commandmeta.Argument
prepare([][]string, [][]string, []string, bool) (func(*execution) Outcome, error) prepare([][]string, [][]string, []commandmeta.FlagOccurrence, []string, bool) (func(*execution) Outcome, error)
} }
type typedCommand[I any] struct { type typedCommand[I any] struct {
@@ -235,8 +242,8 @@ func (c typedCommand[I]) bridgeArguments() []commandmeta.Argument {
return arguments return arguments
} }
func (c typedCommand[I]) prepare(flagValues, flagSpellings [][]string, operands []string, enforceRequired bool) (func(*execution) Outcome, error) { func (c typedCommand[I]) prepare(flagValues, flagSpellings [][]string, occurrences []commandmeta.FlagOccurrence, operands []string, enforceRequired bool) (func(*execution) Outcome, error) {
prepared, err := prepareInputWithSpellings(c.spec, flagValues, flagSpellings, operands, enforceRequired) prepared, err := prepareInputWithOccurrences(c.spec, flagValues, flagSpellings, occurrences, operands, enforceRequired)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -43,6 +43,7 @@ func TestCompletionQueryDispatch(t *testing.T) {
{"alias value", []string{"__complete", "shortcut", "--colour=bl"}, "blue\tcool color\n", false}, {"alias value", []string{"__complete", "shortcut", "--colour=bl"}, "blue\tcool color\n", false},
{"quiet is data", []string{"__complete", "run", "--quiet", "--color=bl"}, "blue\tcool color\n", false}, {"quiet is data", []string{"__complete", "run", "--quiet", "--color=bl"}, "blue\tcool color\n", false},
{"debug is data", []string{"__complete", "run", "--debug", "--color=bl"}, "blue\tcool color\n", false}, {"debug is data", []string{"__complete", "run", "--debug", "--color=bl"}, "blue\tcool color\n", false},
{"format value", []string{"__complete", "run", "--format", "j"}, "json\n", false},
{"short attached choice", []string{"__complete", "run", "-cfoo=ba"}, "foo=bar\n", false}, {"short attached choice", []string{"__complete", "run", "-cfoo=ba"}, "foo=bar\n", false},
{"unknown route", []string{"__complete", "missing", ""}, emptyReply, true}, {"unknown route", []string{"__complete", "missing", ""}, emptyReply, true},
} { } {

View File

@@ -54,7 +54,7 @@ func TestCompletionHintsFollowCapturedBindings(t *testing.T) {
t.Fatalf("no completion: %#v", got) t.Fatalf("no completion: %#v", got)
} }
for _, flag := range append(commandmeta.ReportingFlags(), commandmeta.HelpFlag()) { for _, flag := range append(commandmeta.ReportingFlags(), commandmeta.HelpFlag()) {
if !reflect.DeepEqual(flag.Completion.Choices, []commandmeta.CompletionChoice{{Value: "true"}, {Value: "false"}}) { if flag.Boolean && !reflect.DeepEqual(flag.Completion.Choices, []commandmeta.CompletionChoice{{Value: "true"}, {Value: "false"}}) {
t.Fatalf("reporting flag choices: %#v", flag) t.Fatalf("reporting flag choices: %#v", flag)
} }
} }

View File

@@ -18,15 +18,15 @@ func TestPublicInstallationAcceptsMixedDeclarations(t *testing.T) {
Name: "show", Name: "show",
Summary: "Show a value.", Summary: "Show a value.",
Flags: []commandkit.Flag[struct{}]{ Flags: []commandkit.Flag[struct{}]{
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*struct{}, commandkit.Supplied[string]) {}), commandkit.ValueFlag(commandkit.FlagMeta{Name: "style"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*struct{}, commandkit.Supplied[string]) {}),
}, },
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }, Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() },
}) })
declarations := []commandkit.Declaration{ declarations := []commandkit.Declaration{
command, command,
commandkit.Alias("inspect", "show"), commandkit.Alias("inspect", "show"),
commandkit.FlagAlias("--fmt", "--format"), commandkit.FlagAlias("--fmt", "--style"),
commandkit.FlagAliasFor("show", "-f", "--format"), commandkit.FlagAliasFor("show", "-f", "--style"),
} }
app, err := commandkit.New(commandkit.Identity{Name: "Example"}, declarations...) app, err := commandkit.New(commandkit.Identity{Name: "Example"}, declarations...)
if err != nil { if err != nil {
@@ -84,7 +84,7 @@ func TestPublicAliasesPreserveCommandsAcrossDeclarationOrders(t *testing.T) {
func(value string) (string, error) { optionalParses++; return value, nil }, func(value string) (string, error) { optionalParses++; return value, nil },
func(target *showInput, value commandkit.Supplied[string]) { target.profile = value }, func(target *showInput, value commandkit.Supplied[string]) { target.profile = value },
)}, )},
Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "format"}, (*string)(nil), Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "style"}, (*string)(nil),
func(value string) (string, error) { return value, nil }, func(value string) (string, error) { return value, nil },
func(target *showInput, value commandkit.Supplied[string]) { target.format = value }, func(target *showInput, value commandkit.Supplied[string]) { target.format = value },
)}, )},
@@ -99,7 +99,7 @@ func TestPublicAliasesPreserveCommandsAcrossDeclarationOrders(t *testing.T) {
commandkit.Alias("r full", "inspect"), commandkit.Alias("r full", "inspect"),
commandkit.Alias("inspect-shortcut", "inspect"), commandkit.Alias("inspect-shortcut", "inspect"),
commandkit.Alias("i", "inspect-shortcut"), commandkit.Alias("i", "inspect-shortcut"),
commandkit.FlagAlias("--fmt", "--format"), commandkit.FlagAlias("--fmt", "--style"),
} }
declarations := append([]commandkit.Declaration(nil), commands...) declarations := append([]commandkit.Declaration(nil), commands...)
if test.rulesFirst { if test.rulesFirst {
@@ -154,7 +154,7 @@ func TestPublicRootGroupRegistrationAndFailedBatchRecovery(t *testing.T) {
var formats []string var formats []string
show := commandkit.NewCommand(commandkit.CommandSpec[showInput]{ show := commandkit.NewCommand(commandkit.CommandSpec[showInput]{
Name: "show", Name: "show",
Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "format"}, (*string)(nil), Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "style"}, (*string)(nil),
func(value string) (string, error) { formatParses++; return value, nil }, func(value string) (string, error) { formatParses++; return value, nil },
func(target *showInput, value commandkit.Supplied[string]) { target.format = value }, func(target *showInput, value commandkit.Supplied[string]) { target.format = value },
)}, )},
@@ -181,7 +181,7 @@ func TestPublicRootGroupRegistrationAndFailedBatchRecovery(t *testing.T) {
if err := app.Register(family, if err := app.Register(family,
commandkit.Alias("cfg-show", "config show"), commandkit.Alias("cfg-show", "config show"),
commandkit.Alias("cs", "cfg-show"), commandkit.Alias("cs", "cfg-show"),
commandkit.FlagAlias("--fmt", "--format"), commandkit.FlagAlias("--fmt", "--style"),
); err != nil { ); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -1,209 +1,78 @@
# ADR 0001: Own dispatch through an explicit registry and typed inputs # ADR 0001: Own dispatch through captured declarations and typed inputs
## Status ## Status
Accepted. Accepted.
[ADR 0011](0011-captured-declarations-and-installation.md) refines
declaration construction, installation, optional-family namespace reservation,
and integration input/state lifetimes.
## Date ## Date
2026-10-06 2026-11-07; consolidates decisions made on 2026-10-06 and 2026-10-10.
## Context ## Context
Several downstream consumers of the ConfigKit and PromptKit libraries include Applications repeat routing, flag parsing, help, and inspection commands while
duplicated or near-duplicated code relating to configuration inspection, also needing their own domain commands. Sharing only isolated command handlers
routing, parsing, help, and error handling. However, their command vocabulary would leave much of that infrastructure and its inconsistent behavior with each
and syntax have diverged. CommandKit aims to provide a single, unified CLI application. Optional integrations also need to assemble commands through the
implementation that will reduce duplication, provide consistent vocabulary and same public mechanisms as applications.
grammar, and reduce the amount of code each individual application must own.
Applications have genuine domain commands, such as Narratio's `session restore`,
that remain outside the standard suite. Merely forwarding standard families from
separate application dispatchers would leave custom-command mechanics and root
help dependent on each application's implementation.
Input handling also needs a common boundary. Raw argv handlers or string-based
flag retrieval leave repeated parsing and input assembly with applications.
Presence matters for configuration overrides: omitted values differ from
explicitly supplied empty strings, false, and zero.
## Decision ## Decision
### Ownership and namespace structure CommandKit owns dispatch, shared parsing, presence tracking, help, syntax
failures, and standard command mechanics. Applications own their domain commands,
validation, effects, runtime construction, and executable lifecycle. CommandKit
accepts caller context and streams and returns outcomes rather than terminating
the process.
CommandKit owns the dispatcher, shared parsing and presence tracking, help, Use an explicitly constructed, per-instance registry. Applications and
syntax diagnostics, and standard-family handlers and outcomes. Applications own integrations supply captured declarations containing routing, parsing, help
custom command names, domain meaning, validation, effects, artifacts, and metadata, and callback references. One declaration supplies those views of the
operational outcomes. command. Captured metadata is insulated from later edits to its source;
application objects referenced by callbacks remain caller-owned.
Use namespace-first syntax for the standard suite, which provides a clear Declarations share one installation boundary. Construction issues are retained
family ownership boundary. The initial standard command families are `config` for installation, and each installation batch is validated before registry
for ConfigKit inspection and validation, and `promptkit` for PromptKit inspection mutation. Collisions are errors rather than order-dependent replacement.
and utilities. CommandKit integrates these libraries through their supported public Optional standard families claim their namespaces when installed, not when
semantics. imported. Their canonical operations remain integration-owned; application
commands live outside installed standard families.
CommandKit accepts custom commands outside its reserved standard families and Handlers receive typed inputs through explicit flag and operand bindings.
reports registration errors for attempts to replace standard handlers or add Presence is separate from value so omission differs from supplied empty, false,
private operations beneath them. Its input extension mechanism accepts context or zero values. Domain parsers and validation remain application-owned. Core
selectors and typed overrides for standard operations. provides typed composition for integrations without exposing parser state or
requiring a second argument parser.
CommandKit accepts caller-supplied context, streams, handlers, and inspection Parsed state is independent between invocations. Integration inputs for selected
providers and returns invocation outcomes. It leaves process termination to the scopes or comparison sides are distinct from invocation-wide acquisition state.
caller. Executable startup, signals, runtime construction, schemas, acquisition, Applications own the objects and caches returned by their factories. CommandKit
and domain resolution remain application choices. coordinates its own preparation and calls without promising arbitrary deep copies
or concurrent dispatch.
### Explicit registry and capability installation Construction, installation, help, version, and capability discovery use metadata
without acquiring application state. The selected operation acquires what it
Each explicitly constructed CommandKit instance owns a registry. Its API accepts needs after applicable input checks. Caller startup and callback effects retain
individual commands and composable groups. Registration uses explicit instance their own lifecycle.
state with caller-controlled construction and composition.
Definitions provide paths, descriptions, flags and defaults, repetition and
presence metadata, operand requirements, and execution handlers. One definition
supplies routing, parsing, and help. Duplicate paths and attempts to replace
reserved commands return registration errors; order does not select a winner.
Help and version are installed automatically using supplied application identity
and build metadata. CommandKit assembles standard families from declared provider
capabilities, installing their supported handlers and metadata together. Each
family defines its own resource model, supported verbs, and per-operation data
contracts. Configuration scopes declare their available inspection operations.
The API allows supported capabilities to be integrated independently. Adding
comparison, catalogs, or other inspection features extends the installed surface
according to the supplied providers. Shared verbs have consistent purposes across
families, while the available subsets reflect their resources and capabilities.
Help and discovery expose those subsets. Registration validates the wiring of
declared capabilities and reports unsupported selections before acquisition.
CommandKit registration validates declarations with acquisition callbacks left
uninvoked. Its construction, help, and version paths use metadata. The selected
handler requests dependencies when its operation needs them. Invalid configuration
returned by an acquisition callback is an invocation-time failure. Caller code
executed before registration and effects inside callbacks remain caller-owned.
CommandKit installs handlers for supplied capabilities and describes actual
availability. Each enabled operation retains its standard meaning and output
contract. The exact operation inventory and remaining command contracts are
separate design decisions.
### Typed inputs with explicit bindings
The public binding contract accepts application-defined input structs for custom
commands and uses CommandKit-defined inputs for standard handlers. Explicit
registration binds flags and operands to fields and supplies parsing and help
metadata. CommandKit delivers parsed typed inputs to registered handlers.
Bindings support ordinary typed fields and presence-aware fields where omission
has a separate meaning. CommandKit records supplied state independently of values
and defaults, preserving explicitly supplied empty, false, and zero values.
CommandKit keeps parsed values, collections, and presence from one invocation out
of subsequent invocations. Storage allocation and reuse are implementation
choices. Handler closures and application state retain their own lifecycle;
concurrent dispatch is a separate contract.
The invocation API supplies context and streams separately from CLI inputs.
CommandKit creates its declarations and bindings from metadata and defers its
acquisition calls to the selected handler. The binding system accepts domain value
parsers; domain interpretation and validation belong to registered callbacks.
The public factory, binding, registration, and handler signatures and presence
type names remain API design choices. No illustrative declaration establishes
an exact public API.
### Outcomes and domain behavior
Resolve the command and handle help/version before loading application state.
For execution, prepare independent invocation inputs, apply shared parsing and
operand checks, then invoke the typed handler. Return its invocation outcome to
the application.
CommandKit classifies shared usage errors and standard-family outcomes. Its
handler result contract accepts application-specific outcomes and preserves their
supplied classification. Scriptorium output validation and Notarius artifact
outcomes, for example, remain domain results. CommandKit translates results from
explicit classification and emits usage guidance for usage failures.
Common mechanics do not require a common success-report format for custom
commands. The application owns its artifact and operational output contracts.
Exact result/error interfaces and unresolved grammar choices are separate design
work, not approvals implied by this decision.
### Contract scope and consumer guidance
This decision governs CommandKit's public contracts and implemented behavior.
Each operation specifies the inputs, result semantics, callback lifetimes, and
metadata its provider supplies. Consumers choose how to implement those callbacks
and how to structure loading, execution, startup, and process status mapping.
Sharing acquisition and resolution between inspection and execution, and keeping
acquisition focused on producing inspection views, are recommended practices.
CommandKit supports those patterns through reusable inputs and lazy provider
hooks. Its API preserves declared results and coordinates its own calls; arbitrary
application internals remain application-owned.
Cobra isolation applies to CommandKit's exports and internal adapter. Independent
consumer use of another CLI library remains an application choice. Additional
families and source models can use capability contracts appropriate to their
resources, with standard meanings preserved for operations that are installed.
## Alternatives considered ## Alternatives considered
### Dispatcher and ownership - Application dispatchers forwarding standard families would share handlers but
leave custom-command parsing, root help, and diagnostics duplicated.
| Alternative | Analysis | - Raw-argument handlers or string-keyed input access would leave typed input
| --- | --- | assembly and presence handling with every consumer.
| Application-owned dispatcher forwarding standard families | Small integration surface and convenient migration, but custom syntax, root help, and errors still need cross-dispatcher coordination; removes less infrastructure. | - Reflection, struct tags, or generated bindings could shorten declarations but
| Standard definitions mounted into an application's framework | Reuses routing infrastructure, but application configuration can preserve inconsistent behavior outside mounted families. Internal framework reuse remains compatible with this decision. | add another declaration language or toolchain.
| Shared metadata/help with separate routing | Unifies discovery but adds synchronization among metadata, routing, and parsing. One dispatcher avoids it. | - Import-time registries or a separate integration registration framework would
| Both full dispatch and embeddable family handlers | Serves established external CLI architectures but adds integration surfaces and maintenance. Add embedding only for a concrete need. | introduce hidden state or parallel assembly conventions.
| Raw-argument handlers for custom commands | Eases migration but leaves parsing and help responsibilities with applications. A future transition adapter would need to describe its separate parsing and help contract. |
| Universal application runtime framework | Could absorb startup and operational services, but expands beyond the demonstrated CLI boundary and domain ownership. |
| Verb-first standard paths | Groups actions, but distributes namespace ownership among individual routes. Namespace/resource-first paths keep family ownership clear. |
| Reserve root `prompt` and `profile` for PromptKit | Provides short resource paths but consumes application namespaces. A single `promptkit` family gives the library one ownership boundary. |
### Registration
| Alternative | Analysis |
| --- | --- |
| Declarative tree passed to construction | Makes assembly visible together; larger applications still need group composition. May become a convenience over the same registry. |
| Constructor options for every command/group | Compact setup, but excessive options can obscure commands versus application settings. Option conventions remain API design work. |
| Global registry populated by `init` | Less entry-point code, but hidden import effects, shared mutable state, and unclear ownership. Explicit instances avoid those costs. |
### Handler inputs
| Alternative | Analysis |
| --- | --- |
| Parsed-value accessor object | Uniform handlers, but string-based lookup, runtime type errors, and repeated assembly remain. |
| Individual typed parameters | Clear for small commands; grows into long signatures and adapter machinery. Typed structs scale more naturally. |
| Reflection and struct tags | Compact declarations, but adds a tag language, field-discovery rules, and runtime declaration validation. Explicit bindings keep declarations visible. |
| Typed command objects | Can organize complex handlers. CommandKit's invocation-state contract remains separate from caller-owned object lifetimes; objects are an application option. |
| Generated bindings/adapters | Can combine compact authoring and typing, but adds tooling and generated-file maintenance without demonstrated need. |
| Raw arguments | Simple routing integration, but returns common parsing and help to applications. |
## Consequences ## Consequences
One command system removes repeated routing, parser setup, help assembly, and Applications use one command system while retaining domain ownership. Explicit
shared error plumbing as well as standard inspection handlers. Domain handlers bindings and captured declarations require deliberate assembly, but integrations
and their declarations remain recognizable application code. can supply complete families through the same public contracts.
The public registration surface is larger than family delegation, and explicit [GoDoc](../../command.go) owns declaration, installation, and callback contracts;
bindings require connecting declarations to the correct fields. Those costs the [consumer guide](../consumers/guide.md) explains their use. Exact grammar,
support consistent mechanics without requiring reflection or generated code. commands, and outcomes belong in the [CLI reference](../cli.md). Binding identity,
absence handling, factory defaults, and operation-specific preparation timing
Shared parsing defects can affect every adopter. Consumer and backend integration can evolve within this architecture through deliberate API changes.
evidence must protect selected semantics, including ownership conflicts, help
without configuration, presence, independent invocation inputs, cancellation,
and domain outcomes.
CommandKit releases document intentional compatibility changes. Coordinated
application documentation and subprocess-caller updates are recommended migration
practices.
The [architecture policy](../policy/architecture.md) owns normative boundaries.
[ADR 0002](0002-cobra-backend.md) selects the backend, and
[ADR 0003](0003-public-facade.md) defines its public/internal boundary.

View File

@@ -1,78 +0,0 @@
# ADR 0002: Use Cobra and pflag as the initial internal CLI backend
## Status
Accepted.
## Date
2026-10-06
## Context
CommandKit's selected dispatcher model needs nested routing, flags and operands,
generated help, usage diagnostics, and invocation execution. Reimplementing this
generic infrastructure would add work beyond the suite-specific inspection and
consistency behavior that motivates the library.
## Decision
Use Cobra with pflag as the sole initial CLI backend. CommandKit translates its
registrations into backend command trees, flag bindings, and execution callbacks.
Reuse their routing, option parsing, help, context, and stream mechanisms and add
adaptation where selected CommandKit semantics require it.
CommandKit retains its own public registration API, typed inputs, standard
families, and observable contracts. Its public registration and handler APIs
use CommandKit-owned definitions and typed inputs. Cobra commands and pflag
getters stay within CommandKit's internal adapter. Independent consumer framework
use remains application-owned. [ADR 0003](0003-public-facade.md) records the
adapter and root-owned public contracts.
Invocations do not inherit parsed values or presence from previous invocations.
Rebuilding the backend command tree and input storage from registered definitions
is the initial implementation strategy; safe reuse remains an implementation
choice. CommandKit's tree construction uses registered metadata and leaves
acquisition callbacks uninvoked. Application startup and callback effects retain
their own lifecycle.
Implement one concrete backend. There is no initial public backend selector or
pluggable framework. A future replacement can change internal implementation
while preserving the public API and selected behavioral contracts.
## Alternatives considered
| Alternative | Advantages | Reason not selected |
| --- | --- | --- |
| urfave/cli v3 | Destination bindings for flags and named operands, presence queries, and duplicate controls fit the chosen input model closely. | Credible alternative; Cobra's broader adoption is the preferred maintenance tradeoff. |
| pflag alone | Established parsing with a smaller integration surface. | Leaves more routing, help integration, and command infrastructure to implement. |
| Kong | Compact typed-struct and tag-driven declarations. | Its main declaration model differs from the explicit-binding API; adaptation reduces its benefit. |
| Standard `flag` alone | Familiar variable binding and custom value parsing. | First-operand stopping requires adaptation for the intended syntax, with more custom command infrastructure. |
| Expose Cobra as the public API | Less adapter code and the full framework surface. | Couples consumers to backend structures and framework-specific configuration. |
| Interchangeable backends immediately | Future integration flexibility. | Adds abstraction and behavior surfaces without a current requirement. |
## Consequences
The framework supplies established generic CLI machinery. CommandKit still owns
registration conflicts, reserved families, typed bindings and presence, standard
help and diagnostics, provider integration, and selected reports and outcomes.
The adapter must accommodate explicit empty/false/zero values, independent parsed
state, selected repetition and operand rules, and help without configuration.
Backend features do not expand the standard automatically: for example,
completion commands remain deferred unless deliberately selected.
Backend error handling must preserve application outcomes, avoid duplicate
diagnostics, and return control to the application's entry point.
The [architecture policy](../policy/architecture.md) owns normative boundaries.
[ADR 0001](0001-command-dispatch-and-inputs.md) records the dispatcher and input
model.
Reviewed primary references from the selection:
- [Cobra API](https://pkg.go.dev/github.com/spf13/cobra).
- [Cobra command and help guide](https://github.com/spf13/cobra/blob/main/site/content/user_guide.md).
- [Cobra flag bindings](https://cobra.dev/docs/how-to-guides/working-with-flags/).
- [pflag syntax and extension points](https://github.com/spf13/pflag).
- [urfave/cli v3 API](https://pkg.go.dev/github.com/urfave/cli/v3).

View File

@@ -0,0 +1,68 @@
# ADR 0002: Own public contracts behind an internal CLI backend
## Status
Accepted.
## Date
2026-11-07; consolidates decisions made on 2026-10-06 and 2026-10-09.
## Context
CommandKit needs established routing, parsing, and help machinery while retaining
control of its consumer contracts. Exposing a backend framework directly would
couple applications and integration handlers to that framework's objects and
configuration model.
## Decision
Use the root `commandkit` package as the core module's public facade. Optional
integrations own their public contracts in separate modules under
[ADR 0004](0004-optional-integration-modules.md).
Use Cobra with pflag as the internal CLI backend. Confine their types and
operations to an internal adapter that translates CommandKit declarations and
selected semantics into backend machinery. Registry logic, shared definitions,
and standard handlers remain independent of backend structures. Do not mirror
the complete backend API through renamed wrappers.
Public types and handles expose deliberate contracts and keep implementation
state private. Exports must not reveal internal or backend types through aliases,
embedding, signatures, method sets, loosely typed values, or backend-owned
syntax errors. Translate those syntax failures into CommandKit diagnostics and
outcomes. Caller-supplied objects and errors retain their documented handling;
this boundary does not require transforming arbitrary application content.
Standard Go contracts remain appropriate. Deliberate upstream public types may
be exposed by integrations that own those dependencies. Implementation may live
in the owning public package; introduce internal layers and conversions where
they simplify the code or protect the backend boundary.
There is one concrete backend, without a public backend-selection framework.
A replacement must preserve public contracts or accompany deliberate
compatibility changes. Backend defaults do not independently define CommandKit
behavior.
## Alternatives considered
- Exposing Cobra directly would reduce adapter work but make the framework part
of the consumer contract.
- A forwarding-only facade with duplicate internal representations would enforce
layering at the cost of conversions without a demonstrated need.
- pflag alone or the standard flag package would leave more routing and help
machinery to implement. Other full CLI frameworks are credible alternatives;
Cobra's adoption was the preferred maintenance tradeoff.
- Interchangeable backends would add public assembly and behavior contracts
before a concrete need exists.
## Consequences
Consumers depend on CommandKit's selected contracts while internal implementation
can evolve. The adapter still needs to preserve typed inputs, presence, public
error identity, and invocation lifetimes; package placement alone is insufficient.
The [architecture policy](../policy/architecture.md) owns dependency constraints,
GoDoc owns exact exports, and the [internal overview](../internal/overview.md)
owns package mechanics. Backend construction and reuse strategies are
implementation choices within the invocation-isolation contract.

View File

@@ -0,0 +1,61 @@
# ADR 0003: Expose configuration scopes through declared capabilities
## Status
Accepted.
## Date
2026-11-07; consolidates decisions made on 2026-10-06.
## Context
An application may expose its own configuration and configurations belonging to
integrated libraries. Repeating a configuration command tree for each one would
duplicate operator mechanics. Requiring identical inspection capabilities would
exclude useful integrations whose upstream APIs provide only a subset.
## Decision
Expose independently meaningful configurations as named scopes through a shared
configuration command family. A scope identifies the configuration being
inspected; a command family defines operations on its resources. An integration
may expose either or both without requiring the other.
Each scope declares its supported operations and providers. Assemble standard
commands from the available capabilities and describe actual availability through
metadata-driven help and discovery. An unavailable capability does not prevent
using the scope's other operations.
Validate required provider wiring against declared capabilities. Check selected
operation availability before acquisition. Aggregate inspection must account for
its requested coverage rather than silently dropping unsupported scopes.
Use narrow integration contracts, reusing upstream public types where useful.
Applications supply acquisition and domain context; integrations adapt supported
inspection APIs under [ADR 0004](0004-optional-integration-modules.md). Scope and
comparison inputs follow the input/state separation in
[ADR 0001](0001-command-dispatch-and-inputs.md).
## Alternatives considered
- A separate command tree per configuration would make the target part of the
path but repeat shared operations and help.
- Requiring every operation for every scope would make one missing capability
block otherwise useful inspection.
- One universal operation set across library families would assign operations
to resources for which they have little meaning.
## Consequences
Operators use a common configuration family while providers can expose supported
features incrementally. Capability declarations and help must describe actual
coverage, and provider abstractions must remain proportionate to integration
needs.
The [CLI reference](../cli.md#configuration-scopes) owns command inventory, scope
names and defaults, selector syntax, aggregate applicability, and report ordering.
The [consumer guide](../consumers/guide.md#configkit-scopes) and
[GoDoc](../../integrations/ckconfig/contracts.go) own provider wiring and precise
input/acquisition lifetimes. Those details do not require separate ADRs merely
because they are public contracts.

View File

@@ -1,104 +0,0 @@
# ADR 0003: Own public contracts through a root facade and internal CLI adapter
## Status
Partially superseded by [ADR 0010](0010-optional-integration-modules.md).
ADR 0010 narrows the root facade and public-type ownership decision to the core
module and places optional library dependencies in integration modules. The
remaining principles continue to apply; this record's decision text is retained.
## Date
2026-10-06
## Context
CommandKit needs room to change its internal implementation details while
consumers retain access to a stable external API. Exposing backend assembly or
extension structures would let applications ossify around implementation
details that maintainers may later decide to replace.
ConfigKit, PromptKit, and ReleaseKit demonstrate facades that own public types
and delegate through private state and explicit conversion. ConfigKit also
translates upstream YAML parser structures into an independent internal document
model.
The selection of Cobra as an internal dependency is compatible with this
decision. Public API independence needs a genuine contract boundary, and
keeping backend structures out of shared internal representations also makes
future replacement easier.
## Decision
Use the module root `commandkit` as the sole initial supported public facade.
Define CommandKit's public named types there and expose only selected operations,
extension contracts, and documented methods. Handles keep implementation state in
private fields.
Straightforward implementation may live in the root package without expanding
its exports. Introduce internal packages, interfaces, and conversions where they
simplify implementation or protect the backend boundary. Package organization and
the amount of internal layering follow implementation needs. Additional public
packages require a deliberate decision based on a durable consumer responsibility.
Do not alias public types to internal or backend types, anonymously embed those
types to promote methods, or expose implementation bridge methods. Exported
fields, signatures, variables, callbacks, interface methods, constraints, and
method sets must not reveal internal or Cobra/pflag structures. CommandKit does
not expose its internal backend objects or backend-specific syntax errors through
its public API, including through loosely typed values or returned errors.
Caller-supplied values and errors retain their documented handling; this boundary
does not require inspecting or transforming arbitrary application objects.
Standard contracts such as `context.Context` and `io.Writer` remain appropriate.
Public types from integration libraries, such as ConfigKit and PromptKit, may
be selected as deliberate dependencies. Application input types remain
application-owned. Go declarations and GoDoc own exact exported contracts.
Confine Cobra/pflag types and operations to an internal adapter. Registry logic,
standard handlers, and shared definitions remain independent of backend structures.
The adapter translates paths, descriptions, bindings, presence, callbacks, and
selected help/outcome semantics into backend machinery. Do not reproduce Cobra's
complete API through renamed wrappers.
Preserve independent parsed state per invocation under
[ADR 0001](0001-command-dispatch-and-inputs.md). Factories, callbacks, and shared
representations remain API and implementation design choices.
Translate backend-owned syntax failures into CommandKit diagnostics and outcomes.
Preserve useful public identity for application handler errors and cancellation
according to the invocation contract. Any conversions must preserve value
ownership, callback lifetime, presence, and supported error semantics.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Keep all machinery in the public root | Less forwarding, but harder consumer navigation and easier accidental public expansion. |
| Require a forwarding-only root and separate internal representations | Enforces a uniform layering scheme. It can add duplicate types and callback plumbing before those mechanisms simplify implementation. |
| Alias or embed implementation types | Less conversion code, but couples public type identity, fields, or method sets to internal choices. |
| Hide Cobra only behind the public facade | Hides types from applications but allows backend structures to spread through internal logic, complicating replacement. |
| Mirror Cobra's API with wrappers | Removes dependency names while preserving its conceptual coupling and compatibility surface. |
| Public packages for registry, bindings, backend, and other mechanisms | Adds supported imports and assembly contracts without a distinct consumer responsibility. |
| Selectable/pluggable backends immediately | Adds selection and extension machinery before a concrete need exists. |
## Consequences
Consumers depend on deliberate root contracts while internal algorithms,
collaborators, and package boundaries can evolve. Useful application registration
capabilities still need a proportionate API; minimizing accidental exports must
not recreate repetitive consumer infrastructure.
Forwarding and conversion add maintenance where used. Proportionate checks should
protect the public surface, backend isolation, and consumer behavior through the
real adapter. The implementation determines which checks provide useful evidence;
package relocation alone does not establish those properties.
Backend replacement can preserve the public Go API but must also preserve selected
CLI behavior or follow the release policy for intentional incompatibilities.
Backend defaults do not independently establish new CommandKit contracts.
The [architecture policy](../policy/architecture.md) owns normative boundaries.
[ADR 0001](0001-command-dispatch-and-inputs.md) records the dispatcher/input model,
and [ADR 0002](0002-cobra-backend.md) selects the initial backend.

View File

@@ -0,0 +1,71 @@
# ADR 0004: Separate optional CLI integrations from the core
## Status
Accepted.
## Date
2026-11-07; consolidates decisions made on 2026-10-09 and 2026-10-11.
## Context
Applications need CommandKit with different combinations of library integrations,
including none. A core-only consumer should not acquire optional libraries in its
module graph. Integrations also need a clear boundary between exposing operator
tasks and taking over the upstream libraries' domain functionality.
## Decision
Keep the core and optional integrations in separate Go modules with their own
public package contracts. Core owns shared CLI infrastructure. Integrations own
standard operator-facing commands, provider contracts, content projections, and
adapters for their libraries. They are not convenience libraries for
application-internal use of upstream APIs.
Dependencies point from integrations toward core. Core's API, implementation,
tests, examples, tools, and module requirements remain independent of optional
integration libraries. An integration may depend on another integration when
its responsibility requires it; document that dependency for consumers.
Applications explicitly install integration declarations through public core
contracts. Importing a module installs nothing. Integrations supply convenient
complete-family assembly and supported composition without accessing private
core state or backend structures. Add narrow public core capabilities for actual
integration needs rather than a universal provider or plugin framework.
The integrated libraries own configuration resolution, prompt preparation,
comparison, validation, and other domain algorithms. CommandKit invokes their
supported public APIs and preserves returned meaning, supported error identity,
and safe-disclosure rules. The adopted upstream version governs those semantics;
CommandKit does not maintain a parallel domain specification or reconstruct
comparison from rendered text or redacted snapshots.
Integrations choose report content from suitable inspection evidence, preserving
upstream confidentiality and presence distinctions. CommandKit may summarize or
project that safe evidence for operator needs. Applications retain ownership of
schemas, loading, engine construction, credentials, domain execution, and startup.
Arbitrary application content is not implicitly safe for disclosure.
## Alternatives considered
- Keeping integrations in core, or in packages within its module, would retain
their dependencies in the core module graph.
- Requiring applications to implement standard integration handlers would repeat
command and reporting infrastructure.
- Universal core provider types would introduce abstraction and conversion work
before concrete integrations justify it.
- Reimplementing upstream algorithms would duplicate domain ownership and could
lose evidence already withheld from safe inspection values.
## Consequences
Consumers can adopt core independently and add integrations as needed. Separate
modules require coordinated dependency, validation, and release work. Integrations
remain maintained parts of CommandKit with shared CLI conventions.
Upstream changes require adapter and documentation updates against the adopted
public contract. The [CLI reference](../cli.md) owns operator behavior, GoDoc owns
provider contracts, and [ADR 0006](0006-invocation-reporting.md) owns the shared
presentation boundary. Report formats, comparison statuses, and individual
operation inventories can evolve without changing domain ownership.

View File

@@ -1,79 +0,0 @@
# ADR 0004: Define standard inspection operations by family and capability
## Status
Accepted.
## Date
2026-10-06
## Context
CommandKit serves applications with shared ConfigKit and PromptKit inspection
needs. A defined operator surface establishes the infrastructure CommandKit
provides. Different integrations expose different resources and inspection
capabilities, while shared verbs benefit from consistent meanings.
## Decision
CommandKit supplies help and version reporting and defines ConfigKit and
PromptKit inspection operations. Each family defines its own supported set of
operations. Registration installs handlers for the capabilities supplied by the
integration and exposes their availability through help and discovery metadata.
| Standard operation | Responsibility |
| --- | --- |
| `help` | Describe registered standard and application commands. |
| `version` | Report application version by default; report embedded CommandKit version when explicitly selected. |
| `config scopes` | List registered configuration scopes and supported operations. |
| `config validate` | Validate the selected configuration and its configured definitions. |
| `config show` | Display final effective configuration. |
| `config sources` | Describe the acquired and synthetic input source inventory. |
| `config explain` | Explain effective or historical values and provenance at a selected path. |
| `config diff` | Compare effective configuration selections semantically. |
The optional PromptKit suite supplies `promptkit list prompts`,
`promptkit list profiles`, `promptkit show prompt`, and `promptkit show profile`.
These operations expose captured definition catalogs, prompt metadata, and
backend-completed named execution targets. PromptKit definition capabilities and
its configuration scope are registered according to their available provider
operations. Either can be integrated independently.
Adoption can use the supported subset under
[ADR 0001](0001-command-dispatch-and-inputs.md). Missing comparison or catalog
capabilities leave other inspection operations available.
Shared verbs keep consistent purposes: `list` enumerates resources, `show`
displays a selected resource, and `diff` compares supported representations. Each
family specializes those purposes for its own resources.
Version subjects are independent of configuration scopes and loading providers.
CommandKit owns vocabulary and observable contracts within its standard families.
Custom execution, rendering, serving, sessions, and artifact workflows retain
their application-owned meanings.
The [CLI reference](../cli.md) owns exact flags, operands, defaults, and report
schemas for implemented operations.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Initial operations limited to validation and display | Accelerates first integrations. Source explanation, comparison, and discovery remain repeated application infrastructure. |
| Require every operation for every integration | Gives a uniform inventory. One unavailable capability blocks other useful operations and future resource families. |
| Definition inspection through configuration operations alone | Source values and provenance share one path. Captured catalogs and completed execution targets require additional interpretation. |
| Standardize execution and mutation alongside inspection | Expands shared vocabulary to more workflows. Additional domain and state-management contracts require their own evidence and design. |
| Display dependency identities in ordinary application version output | Makes dependency information immediately visible. Application identity becomes coupled to dependency reporting. |
## Consequences
CommandKit supplies shared handlers with predictable meanings and discovers their
availability from registration. Each enabled operation needs its own data
contract: comparison needs supported native comparison results, catalogs need
enumeration, and profile display needs safe execution-target inspection.
The [scope model](0009-configuration-scopes.md) and
[comparison policy](0008-semantic-configuration-diff.md) describe related
capability and behavioral decisions.

View File

@@ -0,0 +1,65 @@
# ADR 0005: Separate application alias routing from canonical command ownership
## Status
Accepted.
## Date
2026-11-07; original decision made on 2026-10-19.
## Context
Applications need alternate command paths and flag spellings that suit their
operator vocabulary. These conveniences should reuse the behavior supplied by
application commands and optional integrations. Canonical command families
already establish ownership of handlers, namespaces, inputs, and invocation
behavior.
A routing layer allows applications to choose alternate spellings while
preserving those ownership boundaries. Commands and routing rules have distinct
roles, and their relationships require validation during application assembly.
## Decision
CommandKit core owns alias declaration, resolution, and coordination with parsing
and help. Applications choose their alias mappings. Alias infrastructure is
available independently of optional integrations under
[ADR 0004](0004-optional-integration-modules.md).
Aliases belong to an application routing layer that directs invocations to
canonical commands and flags. Canonical definitions retain ownership of their
handlers, inputs, and namespaces. An alternate path within an integration's
namespace remains an application routing rule; the integration continues to own
the canonical command family and its operations.
Canonical commands and routing rules are distinct declaration kinds installed
through the common assembly boundary in [ADR 0001](0001-command-dispatch-and-inputs.md).
Installation validates their relationships together and preserves canonical
ownership. Declaration capture, retained errors, and batch validation follow that
shared model.
An invocation through an alias uses the canonical target's parsing, typed
bindings, validation, and ordinary invocation lifecycle. Execution behavior
remains with the canonical command and its owning application or integration.
Core coordinates alternate routing and presentation through the same command
infrastructure.
## Alternatives considered
- Wrapper commands would introduce additional nodes and handler delegation into
namespace ownership and invocation lifetimes.
- Application argument rewriting would repeat routing infrastructure and separate
alias handling from target validation, parsing, and help.
- A separate alias assembly API would split validation of commands and their
alternate routes across installation boundaries.
## Consequences
Applications can tailor vocabulary while reusing canonical behavior. Core must
coordinate routing, parsing, and help across those layers, while optional
integrations retain ownership of their command families.
GoDoc and the [CLI reference](../cli.md#application-owned-aliases) own exact API
shapes, resolution rules, and presentation choices. Those details can evolve
within this architectural separation.

View File

@@ -1,78 +0,0 @@
# ADR 0005: Return explicit outcomes and deliver reports through defined channels
## Status
Accepted.
## Date
2026-10-06
## Context
An invocation crosses routing, parsing, provider acquisition, a standard or
application handler, report writing, and the application entry point. Operators
and subprocess consumers need consistent classifications for those stages.
Applications also have meaningful domain outcomes of their own.
## Decision
CommandKit returns an invocation outcome with an explicit classification and
recommended process status. The public model distinguishes successful operation,
usage error, operational failure, and application domain outcome. Failures retain
their consumer-facing cause and supported `errors.Is`/`errors.As` identity.
Exact Go declarations remain part of facade API design under
[ADR 0003](0003-public-facade.md).
Use these mappings for CommandKit-owned commands:
| Status | Meaning |
| --- | --- |
| `0` | The requested operation completed successfully. |
| `1` | A syntactically valid operation failed during loading, validation, lookup, inspection, cancellation, diagnostics delivery, or report writing. |
| `2` | The invocation failed command, flag, scope, format, operand, or input-combination validation. |
CommandKit's handler result contract accepts domain classifications and suggested
process statuses supplied by the caller. CommandKit preserves that classification
when returning the handler's result. A custom domain status and a shared usage
status may have the same numeric value; their explicit classifications retain
their distinct meanings. The application entry point controls final process
termination and status mapping.
CommandKit writes standard reports to the supplied report stream, conventionally
stdout, and its warnings and errors to the supplied diagnostic stream,
conventionally stderr. Callers choose the streams. CommandKit assigns responsibility
for presenting its diagnostics, avoids duplicate presentation through its own
paths, and reports write failures. Caller callbacks retain control of their own
output. CommandKit adapts backend syntax failures into its public classification
and diagnostic presentation. Usage guidance accompanies usage errors; operational
failures receive diagnostics appropriate to the failed operation.
Warning severity alone permits a successful operation. A diagnostic writer
failure is an operational failure.
A writer failure can leave a partial report and returns operational failure. The
outcome identifies incomplete delivery. Custom handler output remains
application-owned. [ADR 0007](0007-native-yaml-reports.md) covers `config show` report
preparation; [ADR 0008](0008-semantic-configuration-diff.md) defines successful
comparison outcomes.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| A single failure classification | Simplifies outcome handling. Callers derive syntax, operational, and domain distinctions through additional interpretation. |
| Backend errors and output as the public outcome model | Reuses framework machinery directly. Backend conventions become dependencies for applications and subprocess consumers. |
| One common mapping for application domain failures | Produces uniform numeric statuses. Applications adapt existing artifact and execution outcomes to shared categories. |
| Handler-owned diagnostics for every failure | Gives each handler full presentation control. Shared diagnostic consistency requires repeated ownership and formatting coordination. |
## Consequences
The outcome model supports both Go callers and executable entry points. Explicit
classification keeps domain identity intact through adapter and facade conversion.
Single diagnostic ownership supports predictable presentation.
The public result/error design must preserve cause and delivery failures across
handlers, writers, and the backend adapter. Diagnostic delivery bookkeeping
remains API design work. Acceptance establishes the compatibility meaning of the
status values and delivery rules across standard commands.

View File

@@ -1,77 +0,0 @@
# ADR 0006: Apply common command grammar and recommend shared vocabulary
## Status
Accepted.
## Date
2026-10-06
## Context
CommandKit owns routing, parsing, and help for standard and registered application
commands. A concrete grammar makes option placement, operands, repetition, and
help predictable across the application suite. Shared vocabulary also reduces
the need to remember different verbs for equivalent operations.
## Decision
CommandKit's standard grammar and vocabulary are part of the contract for
CommandKit-owned commands. Its shared dispatcher and binding APIs implement that
grammar, so application-owned commands registered through those APIs receive the
same parsing mechanics. Applications retain ownership of their command names,
operand meanings, and domain behavior.
CommandKit's documentation recommends the same grammar and vocabulary for
application-owned commands, including commands implemented outside its dispatcher.
Applications are encouraged to use standard verbs such as `help`, `list`, and
`show` where their operations have the corresponding purposes described in
[ADR 0004](0004-standard-inspection-surface.md).
CommandKit parses options after the complete registered command path. Within the
selected command, options and declared operands may be interspersed. Operand
counts and types come from registration.
Standard options use lowercase names with hyphen-separated words and the `--`
prefix. Value options accept `--name value` and `--name=value`. Boolean options
accept their documented presence form and explicit values such as `--name=false`.
The `--` token ends option parsing; subsequent tokens are operands.
Registered metadata identifies each flag as singleton or repeatable. For a
singleton, the last occurrence supplies its value, including when the invocation
mixes supported spellings. A repeatable flag accumulates values in invocation order.
Presence follows the typed-input contract in
[ADR 0001](0001-command-dispatch-and-inputs.md).
CommandKit validates paths, flag names, conversions, operand counts, and declared
input combinations through the shared parser. Those failures use the usage
classification in [ADR 0005](0005-invocation-outcomes.md).
Root help is available through `app`, `app help`, `app --help`, and `app -h`.
Invoking a command group with no registered execution handler displays group help.
A runnable command follows its registered handler and operand requirements,
including when it also has subcommands. CommandKit-owned groups such as `config`
display help on bare invocation. `app help COMMAND ...` and a resolved command's
`--help` display the same registered help. Required execution operands are checked
for execution requests; help uses registered metadata. Invalid paths and flag
names receive usage diagnostics.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Flags before all operands | Gives argument order a simple positional rule. Operators organize values around that rule when assembling commands. |
| Common flags before or between command-path components | Supports familiar global-option styles. Routing and help need rules for options across several command levels. |
| Reject repeated singleton flags | Detects repeated assignments, but prevents callers from assembling defaults followed by explicit overrides. |
| Application-selected parser conventions | Supports existing CLI implementations. Operators and shared help need per-application syntax knowledge. |
## Consequences
Operators learn one option placement and repetition policy for adopting
commands using CommandKit. Shared vocabulary guidance encourages consistent
names for equivalent application-owned operations.
The internal adapter must preserve the selected grammar across backend changes.
The [CLI reference](../cli.md) owns operation-specific command signatures and
examples for implemented commands.

View File

@@ -0,0 +1,78 @@
# ADR 0006: Share invocation reporting while preserving application outcomes
## 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.
## Date
2026-11-07; consolidates decisions made on 2026-10-06 and 2026-10-25
and the accepted shared-format ownership extension.
## Context
An invocation crosses routing, parsing, acquisition, command execution, and output
delivery. Applications need consistent presentation and useful returned outcomes
without giving a CLI library control of domain recovery or process termination.
Separate reporting facilities would repeat visibility and failure-handling rules.
## Decision
Core provides one reporting facility per invocation for command results,
immediate messages, deferred messages, and final presentation. Callers choose
result and diagnostic destinations. Applications and integrations choose content,
messages, and domain outcomes; core coordinates visibility, delivery, and its
own parsing and presentation failures.
Applications decide whether a domain error warrants recovery, a warning, or a
failed invocation. They supply explicit outcomes and explanations, optionally
with an error cause. A cause alone does not select an outcome or create an
operator-facing message. Preserve supplied causes and their supported public
identity. Integrations adapt upstream evidence without taking ownership of its
domain meaning or disclosure rules.
Return explicit outcome classifications that distinguish shared usage and
operational failures from application-defined domain outcomes. Preserve domain
classification and keep presentation failures separately observable from the
operation's explanation and cause. Delivery failure must be visible without
silently discarding the application's result. The application entry point owns
final process status mapping and termination.
Present terminal failure explanations after ordinary reporting finishes. Core
coordinates its own diagnostic paths to avoid duplicate presentation. Reporting
state serves delivery and the returned outcome; logging and message history
remain application responsibilities.
The accepted structured-report extension centralizes format selection,
serialization, and rendering in core. Applications and integrations hand off
selected content, including their chosen detail level and optional text template;
core determines how to output it. New core formats should work through that
shared handoff without each command maintaining encoders. This extends the same
invocation lifecycle, with a writer API sharing rendering outside invocations.
Implementation status and initial format choices remain in the roadmap above.
## Alternatives considered
- Handler-owned presentation would maximize local control but repeat output,
visibility, and delivery-error infrastructure.
- Letting core interpret domain errors would give it recovery decisions that
depend on application behavior.
- One failure category or numeric status alone would lose meaningful distinctions
between usage, operational, and domain results.
- Separate facilities for results and messages, or encoders in each integration,
would spread shared behavior across independent implementations.
## Consequences
Commands share presentation while retaining ownership of their content and
outcomes. Core must keep operation and presentation evidence distinguishable,
including when output is partial. Caller writers retain their own lifecycle.
GoDoc and the [CLI reference](../cli.md#reporting) own implemented APIs, numeric
statuses, visibility controls, and exact delivery behavior. Report layouts,
format defaults, YAML framing, and scope ordering are presentation contracts,
not separate architectural decisions. The shared-format extension remains
planned until its implementation and canonical contracts land.

View File

@@ -1,73 +0,0 @@
# ADR 0007: Use native YAML and ordered documents for inspection reports
## Status
Accepted.
## Date
2026-10-06
## Context
ConfigKit and PromptKit provide native YAML configuration inspection. Reusing
their writers preserves captured value semantics, ordering, and redaction. YAML
also supports automated consumption of structured reports.
## Decision
The initial standard uses text for concise operator reports and YAML for
structured consumption.
A single-scope `config show` writes the owning library's complete native redacted
YAML. Its output consists of the inspection document itself. The native writer
preserves effective presence, null and container distinctions, stable mapping
order, and sequence order according to its public contract.
`config show --scope all` emits one native document per registered scope in the
order defined by [ADR 0009](0009-configuration-scopes.md). CommandKit supplies
document separators and scope-label comments around native writer output. Each
scope retains its own value namespace and native rendering; the documents can
be decoded individually.
For `config show`, CommandKit checks operation availability for selected scopes
before acquisition. It acquires all selected views and completes validation
required by those provider contracts before report output begins. A failure
returns its diagnostic outcome before report writing. Other inspection operations
define their own output timing. After writing begins, a writer failure may leave
partial bytes and returns operational failure under
[ADR 0005](0005-invocation-outcomes.md).
Report confidentiality follows the owning libraries' sensitivity classifications.
Their safe writers and records supply redacted content. CommandKit preserves
those confidentiality semantics; arbitrary caller content retains the caller's
disclosure policy.
Additional families select formats appropriate to their resources. Custom handler
formats remain application-owned. The [CLI reference](../cli.md) owns exact
per-operation formats, framing syntax, and report shapes for implemented commands.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| A scope-keyed YAML wrapper | Carries scope identity as parsed mapping keys. Rendering needs an additional wrapper representation and indentation of native documents. |
| JSON alongside YAML initially | Serves JSON-specific tools. The library maintains another serializer and representation contract. |
| YAML as every report's default | Provides uniform structured output. Interactive help, tables, and explanation workflows use structured documents for routine discovery. |
| Write `config show` documents as each scope is acquired | Starts reporting earlier. A later acquisition failure can leave a successful prefix representing only part of the selection. |
| Marshal application runtime structs | Reuses application data types. Presence, captured resolution, ordering, and safe historical inspection need a separate rendering lifecycle. |
## Consequences
Native display preserves owning-library behavior and keeps rendering focused.
Combined inspection requires multi-document YAML consumption and an ordered scope
contract.
Loading all selected scopes before `config show` output adds preparation time and
allows a failure in one scope to prevent combined display. Explicit single-scope
display remains available for a narrower view.
Additional structured schemas are compatibility contracts owned by CommandKit.
Native concatenation can use existing writers; semantic comparison uses the owning
library's supported comparison API under
[ADR 0008](0008-semantic-configuration-diff.md).

View File

@@ -1,84 +0,0 @@
# ADR 0008: Delegate effective configuration comparison to owning libraries
## Status
Accepted.
## Date
2026-10-06; revised 2026-10-11.
## Context
ConfigKit provides a native configuration comparison capability. Exposing it
through CommandKit gives applications a consistent operator interface and reduces
repeated command, selection, and reporting infrastructure. ConfigKit remains the
authority for the meaning and disclosure of its comparison results.
## Decision
### Comparison ownership
For ConfigKit-backed scopes declaring comparison support, `config diff` exposes
ConfigKit's native comparison API. CommandKit checks capability availability before
acquisition and coordinates selection, acquisition, adaptation, presentation,
and invocation outcomes.
Integrations delegate comparison to their owning libraries' supported public
APIs. ConfigKit owns its comparison semantics and disclosure rules. CommandKit
follows the public contract of the ConfigKit version it adopts. When an adopted
upstream revision changes those semantics, the changes flow through to
CommandKit's integration. Upstream remains authoritative as the capability evolves.
Application-supplied providers retain ownership of their implementation and
loading effects. [ADR 0012](0012-typed-configuration-comparison-pairs.md) governs
paired selections, provider contracts, and typed input/shared-state lifetimes.
### Safe disclosure and presentation
Adapt and present native comparison results in accordance with the adopted
upstream API's disclosure contract. The integration carries the upstream results
and relevant metadata through its public provider boundary.
CommandKit owns report presentation and may summarize or transform the returned
safe comparison data for different operator needs. Reports may vary in format,
organization, and detail while respecting safe disclosure and the evidence
provided by the comparison. Each report's documented meaning defines the
conclusions an operator can draw from it.
Report formats, schemas, and rendering choices belong in the
[CLI reference](../cli.md) when implemented. They can evolve independently of
this ownership decision under the [release policy](../policy/release.md).
### Invocation outcomes
A completed comparison returns success regardless of whether differences are
reported. Load, comparison, and writer
failures use the operational failure classification in
[ADR 0005](0005-invocation-outcomes.md). Applications may use comparison results
to make their own domain decisions.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Compare safe snapshots in CommandKit | Gives CommandKit direct control over comparison, but requires it to maintain equality and sensitivity logic and account for values already withheld from snapshots. Native comparison keeps those responsibilities with the owning library. |
| Compare rendered configuration text | Reuses existing output, but formatting and masking affect the apparent differences. Native comparison supplies evidence grounded in the library's effective-value model. |
| Leave comparison commands entirely to applications | Preserves application flexibility, but repeats selection, acquisition coordination, and presentation infrastructure across consumers. Integration providers retain application control within the common command system. |
| Return a distinct status when differences are found | Supports assertion-oriented scripts, but couples successful inspection with a policy judgment about the result. The selected outcome model leaves that judgment to consumers. |
## Consequences
CommandKit supplies consistent comparison commands while relying on owning
libraries for comparison semantics and safe disclosure. Dependency adoption
includes adapting the integration and its documentation to upstream changes.
New library integrations
are assessed through their supported comparison contracts; each scope can expose
its available capabilities independently under
[ADR 0009](0009-configuration-scopes.md).
Reports can serve different operator needs using the same native comparison
data. Their level of detail may differ, and sensitivity rules may limit what
can be shown. Maintaining the boundary between comparison evidence and report
presentation allows those reports to evolve while preserving library ownership
and consumer control.

View File

@@ -1,80 +0,0 @@
# ADR 0009: Select named configuration scopes with declared inspection capabilities
## Status
Accepted.
## Date
2026-10-06
## Context
An application can expose its own configuration, PromptKit engine configuration,
and other independently meaningful configurations through the `config` family.
A scope identifies the configuration targeted by an operation. Different
providers may support different inspection operations.
Configuration scopes and command families are distinct. A scope targets
configuration; a family defines operations on its own resources. A library
integration can expose either or both.
## Decision
Use stable semantic scope identifiers. The `app` scope describes application
configuration; an optional `promptkit` scope describes captured engine
configuration. Registration also accepts additional independently meaningful
configuration scopes with unique identifiers.
Each scope declares its available inspection operations and their providers.
Capabilities can include validation, native effective YAML, source inventory,
path explanation, and safe semantic comparison data. CommandKit validates declared
wiring and registers operations supplied by at least one scope under
[ADR 0004](0004-standard-inspection-surface.md). Help and scope discovery report
actual capabilities from metadata, independently of file loading.
Define narrow integration contracts, reusing upstream public types where useful
and introducing CommandKit-owned abstractions where they simplify integration.
Preserve supported upstream values, provenance, and confidentiality. Select
provider representations against concrete integration needs; exact Go declarations
remain API design work under
[ADR 0003](0003-public-facade.md).
`--scope NAME` selects one configuration. `all` denotes all registered scopes for
`config validate` and `config show`; `sources`, `explain`, and `diff` select one.
An aggregate selection retains its all-scope meaning. CommandKit checks operation
availability across the requested selection before acquiring views. An unavailable
operation yields a usage diagnostic identifying the scope and available choices,
so an aggregate result accurately describes the requested coverage.
`show`, `sources`, `explain`, `diff`, and `validate` default to `app`. Explicit
`--scope all` requests aggregate validation or display.
Scope order is `app` first when registered, `promptkit` next when registered,
then additional scopes sorted by identifier. `config scopes` and combined YAML
show use that order. [ADR 0007](0007-native-yaml-reports.md) defines combined
presentation. The [CLI reference](../cli.md) owns implemented selector syntax;
the [consumer guide](../consumers/guide.md) explains acquisition integration.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Require every configuration inspection operation per scope | Gives one capability set. Missing comparison or provenance blocks other useful inspection operations. |
| A separate command tree for each configuration | Makes the target part of the path. Repeats common operations and help. |
| One verb set for every library family | Gives a uniform inventory. Resource domains acquire operations with little useful meaning. |
| Executable names as scope identifiers | Relates scopes to application branding. Shared scripts need application-specific identifiers. |
| Validation defaults to `all` | Checks every scope implicitly, but adding a scope without validation support can make bare validation unavailable. |
| Skip unsupported scopes during aggregate inspection | Produces available reports. The meaning and coverage of `all` become less apparent. |
| Require CommandKit-owned provider types throughout | Gives a uniform abstraction boundary, but can duplicate upstream APIs and require unnecessary adapters. |
## Consequences
A shared configuration family serves multiple configurations while help and
inventory make operation availability explicit. Individual capabilities can be
integrated before other upstream inspection APIs exist. Default and ordering
rules remain observable CommandKit behavior.
An `all` request can report unsupported coverage; default application inspection
remains independent of other scopes' capabilities. Provider design must balance
simple wiring against dependency coupling as concrete integrations are developed.

View File

@@ -1,100 +0,0 @@
# ADR 0010: Separate the core facade from optional integration modules
## Status
Accepted. Partially supersedes
[ADR 0003](0003-public-facade.md) as described below.
Partially superseded by [ADR 0011](0011-captured-declarations-and-installation.md)
for the consumer-migration consequence during the unreleased prototype
transition. It also supplies the registration and invocation-lifetime
decisions left open here. The module
boundary and remaining principles continue to apply; this record's decision
text is retained.
## Date
2026-10-09
## Context
CommandKit serves consumers that need its command infrastructure with different
combinations of library integrations, including none. A consumer using only the
core should not acquire dependencies belonging to optional integrations.
ADR 0003 establishes deliberate public contracts and an internal CLI backend
boundary. Those principles remain appropriate. Its initial single root facade,
however, combines core and integration responsibilities. Separate packages within
one module would separate imports while retaining shared module requirements.
## Decision
Retain the root `commandkit` package as the core module's public facade. It owns
core command declarations, registration, typed inputs, dispatch, help/version,
shared syntax, diagnostics, and invocation outcomes.
Provide optional library integrations as separate Go modules with their own
public package contracts. Each integration owns the standard command handlers,
provider contracts, reports, and adapters appropriate to its responsibility.
Applications select integrations and explicitly register their commands; importing
an integration does not install a command family.
Integrations provide convenient registration of their standard command families,
with standard declarations and handlers supplied by the integration. Applications
provide acquisition and domain-specific context without assembling individual
standard commands or repeating adapter plumbing. More specialized composition
remains available where concrete consumer needs justify it.
Dependencies point from integrations toward the core. The core's public API,
implementation, and module requirements remain independent of optional
integration libraries. An integration may depend on another integration when
its responsibility requires that relationship. Document such dependencies so
consumers can understand the modules they select.
Integrations compose through supported public core contracts and preserve
CommandKit's standard grammar, command ownership, help, and outcome conventions.
They do not access private core state or backend structures. Add public contracts
where concrete integration needs justify them, without requiring a general plugin
or backend-selection framework.
### Relationship to ADR 0003
The root-facade and public-type ownership provisions of ADR 0003 now apply to
the core module. Integration-owned public types and operations belong in their
respective public packages. The permission to expose deliberately selected
upstream library types applies within the integrations that depend on them;
it does not justify optional integration dependencies in the core.
ADR 0003's remaining principles continue to apply across the core and
integrations: deliberate exports, private implementation state, proportionate
internal layering, no exposure of internal or backend types, and preservation of
documented ownership, presence, callback lifetime, and error semantics. Application
input types and caller-supplied values and errors retain their existing handling.
Cobra/pflag remain behind the core's internal adapter.
This decision changes the scope of the single-facade decision rather than
replacing its contract discipline. ADR 0003 is partially superseded and its
original decision text remains intact.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Keep integrations in the root package | Preserves existing imports and simplifies releases, but core consumers acquire optional library dependencies. |
| Use separate packages within one module | Separates compiled imports and retains one release unit, but keeps integration requirements in the shared module graph. |
| Have applications implement integration handlers | Keeps the core independent, but recreates duplicated inspection commands and weakens consistent operator behavior. |
| Introduce universal provider contracts in the core | Could support several libraries through a common model, but adds abstractions and conversion obligations before concrete needs justify them. |
## Consequences
Consumers can adopt the core independently and add supported integrations as
needed. Integrations remain maintained parts of CommandKit, with shared CLI
conventions and owning-library semantics. Dependency independence does not mean
the core has no external implementation dependencies.
Moving existing integration APIs requires a documented consumer migration.
Integration modules add module-specific dependency, validation, and release work.
Core tests, examples, and tools must not reintroduce the optional dependencies
that this boundary removes.
Exact registration mechanics and release cadence are not selected here.
Current GoDoc and CLI contracts continue to own implemented behavior.

View File

@@ -1,172 +0,0 @@
# ADR 0011: Capture command declarations and install optional families explicitly
## Status
Accepted. Refines [ADR 0001](0001-command-dispatch-and-inputs.md) for declaration
construction, installation, optional-family namespace reservation, and integration
input/state lifetimes. Partially supersedes
[ADR 0010](0010-optional-integration-modules.md) only for its consumer-migration
consequence during the unreleased prototype transition, and refines its
convenient composition principle.
Refined by [ADR 0012](0012-typed-configuration-comparison-pairs.md) for
two-selection configuration comparison and its input/state lifetime. The
single-selection lifecycle and other decisions remain in force.
## Date
2026-10-10
## Context
Applications declare domain commands, while optional integrations assemble
standard command families. A common construction and installation model reduces
repeated error checks and registration plumbing. Integrations need public typed
composition contracts to assemble those families while preserving core and
backend boundaries.
Declaration and installation have different responsibilities. An optional family
claims a namespace when installed; selecting a dependency alone should leave the
application's command surface unchanged. During execution, scope-specific CLI
inputs and application-owned shared state also have distinct lifetimes. Making
those lifetimes explicit avoids accidental aliasing between selected scopes.
At the time of this decision, the implementation was an unreleased prototype
with no downstream consumers. Its API shapes carried no compatibility obligations
that would constrain the selected design.
## Decision
### Declaration capture and reuse
Applications and integrations construct the same opaque core command declarations
and install them through core application construction or registration. Initial
integrations provide complete-family factories and focused conveniences through
that common installation API. Suite and provider types remain integration-owned.
Factories eagerly capture metadata, bindings, capability selections, selector
applicability, and callback references. They copy the declaration collections
needed to isolate returned commands from later edits to source metadata.
Construction errors remain attached for installation to report. This model uses
eager declarations with public typed composition; a deferred preparation lifecycle
and untyped integration payload are outside the selected API.
Declarations may be reused across applications with independent registry and
invocation state. Callback references and captured application objects retain
caller ownership. Reuse provides no deep-copy or concurrent-dispatch guarantee.
Application helpers performing other fallible work may still return errors.
### Installation and retained errors
Core validates each installation batch before registry mutation, checking
retained errors, bindings, placement, and collisions. Failed application
construction returns no usable application; failed later registration preserves
the existing registry. Atomicity covers one installation call. Separate calls
and arbitrary callback effects retain their own lifetimes.
Report retained errors with useful command context and preserve supported wrapped
error identity. The aggregate-error representation remains an implementation
choice.
An explicit absence declaration contributes no command, help entry, or namespace.
Installation checks attached errors before skipping it. A zero command declaration
remains invalid. Integrations define their own empty-suite semantics: the initial
configuration suite needs at least one named scope, and metadata-only scopes
remain discoverable. A definition suite with no capabilities contributes nothing.
Binding validation applies to generated command declarations. Unused selector
bindings in an absent family are ignored; explicitly retained adapter-construction
errors still reach installation. A nil loader supplied to an adapter remains
invalid wiring even if its capabilities are subsequently cleared.
### Namespace ownership and placement
Core construction reserves help and version. An optional standard family owns
its complete root namespace upon installation. Imports and absent declarations
claim nothing. An application may occupy an uninstalled family name; installing
a conflicting declaration then fails in either registration order. Installation
preserves existing handlers and group ownership. Private application operations
remain outside installed standard families.
Standard families carry root-only placement metadata, which core enforces when
they are nested beneath another group. Placement is a public declaration property;
its enforcement is independent of package identity or a list of integration names.
A real empty root-only group claims its namespace and obeys placement checks.
Explicit absence contributes no node to place.
### Input and shared-state lifetimes
Integration suites distinguish typed CLI input from typed shared acquisition
state. Ordinary configuration inspection initializes one input per selected scope,
applies its applicable selector bindings, and initializes shared state once for
the inspection invocation. Input validation receives the scope identity and its
typed input. Named definition inspection uses one input and one shared state.
Providers receive both explicitly. ADR 0012 refines this lifecycle for comparison
pairs with two selections of one scope.
Availability preflight and applicable input checks precede shared-state
initialization and acquisition. Failures may stop later input factories.
Construction, installation, help, version, and scope discovery leave suite
input/state factories and acquisition providers uninvoked.
An omitted factory supplies its type's zero value, including nil pointers.
Applications provide initialization needed by their accessors and providers;
providers may intentionally accept nil. Allocation of application objects,
cloning, and aliases returned by factories remain caller responsibilities.
Core-owned parsed collections stay independent when applied to separate inputs.
Caller-owned objects and callback effects retain their own lifetimes.
### Typed binding composition
Core provides a narrow typed contract to capture converted values and apply a
selected binding subset to initialized input. It preserves binding identity,
defaults, and presence while keeping private setters, parser state, and backend
objects behind their existing boundaries. A copied binding retains its identity;
a newly constructed lookalike has its own identity.
Execution-required checks apply to the selected subset. Missing required input
is a usage failure; invalid binding selection or destination is a wiring failure
with operational classification at invocation. These categories remain identifiable
through supported wrapping and stop execution before shared-state initialization
and acquisition. Metadata defects detectable during assembly of generated
commands remain installation errors under the validation boundary above.
### Relationship to earlier decisions
For the unreleased prototype transition covered by this decision, compatibility
wrappers and a downstream migration guide were unnecessary. This exception to
ADR 0010's migration consequence is limited to that transition. Changes to
published contracts follow the [release policy](../policy/release.md).
ADR 0001 retains dispatcher, registry, typed-input, and application-ownership
principles. ADR 0010 governs module boundaries and dependency direction, with
[ADR 0003](0003-public-facade.md) governing deliberate exports and backend isolation
within that scope. ADRs 0004 through 0009 retain their operation, grammar, report,
and outcome decisions. Go declarations and GoDoc own exact implemented signatures.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Fallible constructors plus integration-specific registration helpers | Makes each construction failure immediate, but creates several assembly conventions and repeated consumer checks. Captured errors keep one installation boundary. |
| Deferred preparation callbacks | Allows installation to construct a family, but adds lifecycle and error timing beyond eager capture. No demonstrated assembly need requires it. |
| Reserve all known optional family names at core construction | Prevents later name conflicts, but consumes namespaces for integrations the application has not selected. Installed-family ownership keeps conflicts explicit without that restriction. |
| Separate family/plugin registration framework | Could encode ownership and composition, but a captured command with root-only placement and batch validation expresses the required behavior without another installation model. |
| One shared input/state object | Uses fewer type parameters and can share a cache through references in separate input values, but leaves the distinct lifetimes implicit and scope-input isolation to application conventions. Explicit input and state types make those lifetimes visible. |
| Clone hooks or automatic pointer allocation | Can hide input construction, but adds object-graph and initialization responsibilities that belong to the application. Explicit factories keep those choices visible. |
| Public binding getters/setters or an untyped value bag | Enables integration assembly, but exposes private mechanics or returns typed-input assembly to string lookup. Narrow typed composition addresses the actual need. |
## Consequences
Consumers assemble domain commands and optional families through one installation
boundary. Eager assembly errors reach that boundary before provider acquisition.
Namespace collisions remain explicit choices for the application to resolve.
Separate input and state types make initialization and sharing visible. Focused
conveniences support simple applications, while typed composition gives
integrations the public contracts needed to assemble their families.
The [architecture policy](../policy/architecture.md) owns normative boundaries;
roadmaps own implementation status and sequencing. Application loading, cache
design, startup, domain validation, callback effects, and process exit remain
consumer responsibilities.

View File

@@ -1,108 +0,0 @@
# ADR 0012: Prepare typed selection pairs for configuration comparison
## Status
Accepted. Refines [ADR 0011](0011-captured-declarations-and-installation.md) for
two-selection configuration comparison; its other decisions remain in force.
[ADR 0008](0008-semantic-configuration-diff.md) governs native comparison and
report presentation.
## Date
2026-10-11
## Context
Native effective-configuration comparison needs two completed loads. ConfigKit
requires results of the same configuration type, which an ordinary inspection
view does not expose for pairwise comparison. A generic adapter can retain that
type without introducing result assertions or a universal core provider model.
ADR 0011 separates a selected scope's typed input from shared acquisition state.
A comparison selects the same scope twice, potentially with different roots or
overlays. Reusing one input obscures which selection a provider receives; creating
separate shared state loses the invocation-wide lifetime chosen for inspection.
## Decision
### Selections and provider ownership
Comparison selects one scope and prepares an earlier/left selection and a
later/right selection. Omitted right selection fields inherit the corresponding
left field and supplied state. An explicit right override, including an empty
value, replaces that field. Providers retain interpretation of omitted defaults
and explicit empty values. Applicable domain selector values are shared between
the two sides initially; independent per-side domain selectors may be considered
for a concrete consumer need.
Equal selections are allowed. CommandKit does not skip acquisition merely because
their selection values are equal: caller-owned loading may produce different
effective results. Exact flag spellings belong in the CLI reference when
implemented.
Use a narrow optional comparison provider owned by the integration. Its request
groups each side's selection with its typed input and supplies one shared state
value separately. The provider returns acquired native comparison results
with diagnostics and an error through the existing acquisition convention.
Reuse useful upstream public record types rather than duplicating them solely
to rename fields. Exact exported signatures belong in Go declarations and GoDoc.
The typed ConfigKit adapter invokes the supplied loader for each reached side
and passes the successful typed results to `configkit.Diff`. It retains the
configuration type inside that adapter, without reflection, result type
assertions, private core access, or a comparison-facet framework. Applications
may supply their own pair provider through the same integration contract.
Declared capabilities determine required wiring. A comparison-only scope needs
its comparison provider, without an unrelated ordinary acquisition callback.
Ordinary operations continue to use their ordinary acquisition provider.
Retained adapter construction errors still apply when capabilities are narrowed.
Other scopes and families need not support comparison under
[ADR 0009](0009-configuration-scopes.md).
### Typed input and shared state lifetime
Capture comparison declarations and applicable selector bindings under ADR 0011.
Convert supplied flag values through core once, including on help paths; applying
the values to both inputs does not parse them again.
After availability preflight, initialize each reached side's typed input
separately, apply the same applicable captured selector values, and check that
input with the selected scope identity and that side's selection. Finish both
input checks before initializing shared state once and invoking the pair
provider. Failures may stop later factories or loads. Construction, installation,
help, version, and scope discovery invoke neither input/state factories nor
acquisition/comparison providers.
Existing factory ownership rules remain unchanged: an omitted factory supplies
its type's zero value, including nil pointers. Separate factory calls do not
guarantee distinct caller-owned objects. CommandKit does not allocate arbitrary
pointer types, clone application graphs, repair caller aliases, or supply or
repair application caches. Core-owned parsed collections remain independent
when applied to the two inputs; callback effects remain caller-owned.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Symmetric independently resolved left/right selectors | Makes each side explicit and avoids inheritance. It adds vocabulary and repetition for common base-versus-overlay comparisons. |
| One typed input for both sides | Requires fewer factory calls, but obscures side-specific selection checks and leaves mutation isolation implicit. |
| Acquire a comparison facet with each ordinary view | Shares single-side acquisition across operations, but requires a public compatibility contract between heterogeneous facets and additional handle or assertion machinery. |
| Separate shared state for each side | Isolates acquisition state, but loses the invocation-wide sharing already selected for inspection. Applications can choose isolation within their own pair provider if needed. |
## Consequences
Pair preparation has explicit input and state lifetimes while ordinary inspection
keeps its existing lifecycle. Generic result handling stays inside the integration
adapter; core gains no owning-library dependency or universal comparison model.
Shared state permits application-owned caches. A cache used by a pair provider
should distinguish selections and any input values affecting loading to avoid
reusing the left result for a different right selection. This is integration
guidance, not a CommandKit cache policy or guarantee about caller callbacks.
The [architecture policy](../policy/architecture.md) owns normative boundaries.
ADR 0008 governs delegation to upstream comparison semantics, safe disclosure,
and CommandKit's presentation responsibility. This decision does not add a new
equality engine or strengthen application
loading, object ownership, or concurrency contracts.

View File

@@ -1,68 +0,0 @@
# ADR 0013: Separate application alias routing from canonical command ownership
## Status
Accepted. Refines [ADR 0011](0011-captured-declarations-and-installation.md)
by extending its shared installation boundary to application routing rules.
Its canonical command ownership and declaration principles remain in force.
## Date
2026-10-19
## Context
Applications need alternate command paths and flag spellings that suit their
operator vocabulary. These conveniences should reuse the behavior supplied by
application commands and optional integrations. Canonical command families
already establish ownership of handlers, namespaces, inputs, and invocation
behavior.
A routing layer allows applications to choose alternate spellings while
preserving those ownership boundaries. Commands and routing rules have distinct
roles, and their relationships require validation during application assembly.
## Decision
CommandKit core owns alias declaration, resolution, and coordination with parsing
and help. Applications choose their alias mappings. Alias infrastructure is
available independently of optional integrations under the module boundaries
established by [ADR 0010](0010-optional-integration-modules.md).
Aliases belong to an application routing layer that directs invocations to
canonical commands and flags. Canonical definitions retain ownership of their
handlers, inputs, and namespaces. An alternate path within an integration's
namespace remains an application routing rule; the integration continues to own
the canonical command family and its operations.
Canonical commands and routing rules are distinct declaration kinds installed
through a common core assembly boundary. Installation validates their
relationships together, preserves canonical definitions and namespace ownership,
and applies the batch-atomicity and retained-error model of ADR 0011 to both
layers. Declaration capture and reuse follow that existing model.
An invocation through an alias uses the canonical target's parsing, typed
bindings, validation, and ordinary invocation lifecycle. Execution behavior
remains with the canonical command and its owning application or integration.
Core coordinates alternate routing and presentation through the same command
infrastructure.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Install aliases as wrapper commands | Reuses the command tree for discovery, but introduces additional command nodes and handler delegation into namespace ownership and invocation lifetimes. |
| Have applications rewrite arguments before dispatch | Gives applications direct control over shortcuts, but repeats routing infrastructure and separates alias handling from target validation, parsing, and help. |
| Register aliases through a separate assembly API | Makes routing declarations explicit, but splits validation of commands and their alternate routes across installation boundaries. Distinct declaration kinds express the separation within one atomic batch. |
## Consequences
Applications can tailor operator vocabulary while reusing canonical behavior.
Core maintains the relationship between routing rules and canonical definitions
as declarations are installed. Optional integrations continue to supply their
command families through the existing ownership model.
Routing, parsing, and help must coordinate across the two layers. Exact API
shapes, resolution rules, and presentation choices belong in GoDoc and the
[CLI reference](../cli.md) when implemented. Those details can evolve within
this architectural separation.

View File

@@ -1,79 +0,0 @@
# ADR 0014: Share command-line presentation within each invocation
## Status
Accepted. Extends [ADR 0005](0005-invocation-outcomes.md) with shared reporting
for each invocation. Its outcome classifications, standard status meanings,
output channels, error-cause preservation, and application ownership of process
exit remain in force.
## Date
2026-10-25
## Context
Applications need consistent control over command results, messages shown during
a run, and messages shown at its end. Sharing that control reduces repeated
output code across applications and integrations.
An application decides what an error means for its work. It may recover silently,
show a warning, or end the invocation. CommandKit needs the chosen outcome and
messages to present that decision to the operator.
## Decision
CommandKit core provides one reporting facility for each invocation. Application
commands, integration commands, and core commands use it for results, immediate
messages, and deferred messages. Core applies shared rules for visibility,
formatting, and delivery, and completes reporting when the invocation ends.
Callers choose the output destinations.
Applications decide how to respond to domain-specific errors and choose their
outcomes and messages. CommandKit may provide shared helpers for carrying,
inspecting, and presenting errors while leaving those decisions with the
application.
Applications supply explicit outcomes and explanations for failures. They may
also supply a Go error cause. CommandKit keeps that error unchanged for the caller
to inspect. Supplying a cause alone does not select an outcome or generate an
operator-facing message. CommandKit remains responsible for handling its own
errors, including routing, parsing, and presentation failures.
Integrations adapt upstream results and diagnostics into command outcomes and
messages. The upstream libraries retain ownership of their meanings and rules
for safe disclosure.
An Error message explains a failed invocation with a non-zero status. Its
explanation is presented at the end of the invocation, after normal reporting
finishes. The returned outcome carries that explanation. The application entry
point decides how to exit the process.
CommandKit reports rendering and delivery failures separately from the command's
explanation. A presentation failure produces an operational failure for standard
outcomes under ADR 0005. Application-defined domain classifications are preserved,
along with an already non-zero domain status. A successful domain outcome receives
a non-zero status if presentation fails. The original explanation and any supplied
cause remain available to the caller. Intentionally suppressed output is not a
delivery failure.
Reporting keeps the state needed to deliver messages and return the invocation
outcome. Logging and message history remain application concerns.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Let each application manage all presentation | Gives applications full control, but repeats visibility and delivery code across commands and integrations. |
| Have core decide whether domain-specific errors require recovery, a warning, or failure | Reduces some caller code, but gives core decisions that depend on application behavior. |
| Use separate facilities for results, immediate messages, and deferred messages | Lets each facility serve one purpose, but spreads visibility and final delivery across several paths. |
## Consequences
Applications and integrations share presentation behavior while choosing their
own results and messages. Core handles reporting failures consistently and keeps
them visible in the returned outcome.
GoDoc and the [CLI reference](../cli.md) define the implemented API, flags,
message formats, and delivery details. Those details can evolve within this
ownership model.

14
docs/adr/README.md Normal file
View File

@@ -0,0 +1,14 @@
# Architectural decision records
These records explain major structural and ownership decisions. The
[architecture policy](../policy/architecture.md) owns normative boundaries;
GoDoc and the [CLI reference](../cli.md) own implemented contracts.
| ADR | Decision |
| --- | --- |
| [0001](0001-command-dispatch-and-inputs.md) | Dispatch, captured declarations, and typed inputs |
| [0002](0002-public-facade-and-cli-backend.md) | Public facade and internal CLI backend |
| [0003](0003-configuration-scopes.md) | Configuration scopes and declared capabilities |
| [0004](0004-optional-integration-modules.md) | Optional integration modules and upstream ownership |
| [0005](0005-application-alias-routing.md) | Alias routing and canonical command ownership |
| [0006](0006-invocation-reporting.md) | Invocation reporting, outcomes, and presentation ownership |

View File

@@ -46,7 +46,7 @@ The supported flags are:
| Flag | Values | Default | Meaning | | Flag | Values | Default | Meaning |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `--scope` | `app`, `commandkit` | `app` | Select the caller's application identity or the embedded CommandKit module identity. | | `--scope` | `app`, `commandkit` | `app` | Select the caller's application identity or the embedded CommandKit module identity. |
| `--format` | `text`, `yaml` | `text` | Select the output format. | | `--format` | `text`, `yaml`, `json` | automatic | Select the output format; automatic uses text for version. |
Text output is `NAME VERSION`, followed by ` (REVISION)` when the selected Text output is `NAME VERSION`, followed by ` (REVISION)` when the selected
identity has a revision. An empty application version is displayed as `dev`; identity has a revision. An empty application version is displayed as `dev`;
@@ -58,7 +58,7 @@ versionless replacement, an untagged module, or missing build information
displays `dev`. The CommandKit identity name is `CommandKit`; dependency build displays `dev`. The CommandKit identity name is `CommandKit`; dependency build
metadata does not supply a revision. metadata does not supply a revision.
YAML output uses these explicit fields, including an empty revision: YAML and JSON output use these fields, including an empty revision:
```yaml ```yaml
name: ExampleApp name: ExampleApp
@@ -111,6 +111,14 @@ caller passes result and diagnostic writers to `App.Execute`; substantive
results go to `Streams.Out`, and categorized messages, terminal explanations, results go to `Streams.Out`, and categorized messages, terminal explanations,
and usage guidance go to `Streams.Err`. and usage guidance go to `Streams.Err`.
The shared `--format` flag selects `text`, `yaml`, or `json` for reports
submitted through the core reporter. When omitted, a report's text template
selects text; otherwise scalar values and nonempty string lists use text, while
structured values, empty collections, and null use YAML. Explicit text without a
template uses YAML for structured values. Invalid or empty format values are
usage errors. Raw output, help, completion, and diagnostics are not encoded as
reports.
### Modes and visibility ### Modes and visibility
Each invocation starts in ordinary mode. `--quiet`, `--verbose`, and `--debug` Each invocation starts in ordinary mode. `--quiet`, `--verbose`, and `--debug`
@@ -154,9 +162,9 @@ example version --quiet
Reporting flags do not go before or between command-path components. Tokens Reporting flags do not go before or between command-path components. Tokens
consumed as values and tokens after `--` retain their normal meaning. consumed as values and tokens after `--` retain their normal meaning.
The reserved long spellings are `--quiet`, `--verbose`, `--debug`, and The reserved long spellings are `--quiet`, `--verbose`, `--debug`,
`--progress`; application declarations cannot reuse them. There are no built-in `--progress`, and `--format`; application declarations cannot reuse them. There
short spellings. Applications may define aliases such as `-q → --quiet`, are no built-in short spellings. Applications may define aliases such as `-q → --quiet`,
`-v → --verbose`, and `-d → --debug`; canonical and aliased occurrences share `-v → --verbose`, and `-d → --debug`; canonical and aliased occurrences share
the same ordering. Thus `example export --verbose -q` selects quiet mode. the same ordering. Thus `example export --verbose -q` selects quiet mode.
@@ -289,14 +297,26 @@ example config scopes
example config scopes --format yaml example config scopes --format yaml
``` ```
Text output uses one `scope: operation, operation` line per scope. YAML uses a Text output uses one `scope: operation, operation` line per scope and lists any
`scopes` sequence with explicit `name` and `operations` fields. Scope order is declared optional features. Structured output uses a `scopes` sequence with
`name`, `operations`, and any supported `features`. Scope order is
`app`, then `promptkit`, then other names alphabetically. A metadata-only scope `app`, then `promptkit`, then other names alphabetically. A metadata-only scope
can be listed without an acquisition callback. Discovery does not construct a can be listed without an acquisition callback. Discovery does not construct a
runtime context or acquire configuration. It reports the intersection between runtime context or acquire configuration. It reports the intersection between
declared scope capabilities and installed handlers, so an operation is listed declared scope capabilities and installed handlers, so an operation is listed
only when the corresponding command is available. only when the corresponding command is available.
Scopes can opt in to transient overrides. For an opted-in scope, acquisition
commands accept repeatable `--set POINTER=JSON`; comparison also accepts
`--against-set POINTER=JSON` for the right side. Each assignment is passed to
the loader in order, with `--set` or `--against-set` as its source label.
The pointer and value are split at the first `=`, so pointer keys containing
`=` cannot be addressed by these flags; JSON strings can contain `=`. Values
use JSON types, including null, false, zero, and empty containers. ConfigKit
owns override path validation and conflict handling. A selected scope must opt
in before receiving overrides; for `--scope all`, every selected scope must
support them.
Applications can add `ckprompt.ConfigScope(loadEngine)` to the ConfigKit suite Applications can add `ckprompt.ConfigScope(loadEngine)` to the ConfigKit suite
to expose PromptKit engine configuration through these handlers. The scope to expose PromptKit engine configuration through these handlers. The scope
declares validate, show, sources, explain, and native diff. It does not install declares validate, show, sources, explain, and native diff. It does not install
@@ -308,54 +328,67 @@ PromptKit prompt or profile definition commands.
`app`; `--scope all` validates every registered scope and is available when each `app`; `--scope all` validates every registered scope and is available when each
scope declares validation. The command checks all selected capabilities before scope declares validation. The command checks all selected capabilities before
calling scope providers. calling scope providers.
Its default format is text; `--format yaml` emits a `scopes` sequence with Automatic output is concise text; structured formats emit a `scopes` sequence
`name` and `valid: true` for each scope that completed successfully. with each reached scope and its outcome (`valid`, `failed`, or `canceled`).
```text ```text
example config validate example config validate
example config validate --scope promptkit --format yaml example config validate --scope promptkit --format yaml
example config validate --scope all example config validate --scope all
example config validate --scope all --details --severity warning --code schema.version
``` ```
Each successful scope is reported as `SCOPE: valid`. Aggregate validation stops Each successful scope is reported as `SCOPE: valid`. Aggregate validation stops
at the first load or output failure; records from earlier successful scopes may at the first load failure or cancellation, then submits one report containing
already have been written. Provider warnings go to the diagnostic stream and do the reached scopes. Output errors occur after acquisition has stopped. Provider
not alone make validation fail. `--config` and `--overlay` preserve whether an warnings go to the diagnostic stream and do not alone make validation fail.
`--details` includes native findings and the source references needed to
interpret them. Each scope's detail state is `unavailable`, `empty`, `filtered`,
or `present`. Repeat `--severity error|warning` or `--code CODE` to filter
findings: values within a filter match any, and the two filters narrow together.
Filters require `--details` and never change the validation outcome. Detailed
output suppresses ordinary warnings associated with its native findings; other
messages and terminal failure explanations remain visible.
`--config` and `--overlay` preserve whether an
empty value was explicitly supplied, so a scope callback can map those inputs to empty value was explicitly supplied, so a scope callback can map those inputs to
its own ConfigKit load policy. Help and invalid syntax do not call scope its own ConfigKit load policy. Help and invalid syntax do not call scope
providers. providers.
### Show effective configuration ### Show effective configuration
`config show` writes the provider's native effective YAML for one scope. Its `config show` displays the provider's safe effective configuration. The default
default scope is `app`; `--format yaml` is accepted and YAML is the only initial scope is `app`. The native inspection YAML is converted to a logical report
format. Single-scope output is passed through unchanged, without a CommandKit value, preserving meaningful scalar types, empty collections, and redaction.
header or document marker. Automatic formatting uses text for a single scalar or nonempty string list and
YAML for structured values; `--format` selects text, YAML, or JSON.
```text ```text
example config show example config show
example config show --scope promptkit example config show --scope promptkit
example config show --scope all example config show --scope all
example config show --path /server/addr --format json
``` ```
`--scope all` acquires every selected scope and checks each effective writer `--scope all` acquires every selected scope before producing one mapping keyed
before writing output. It then emits one YAML document per scope in discovery by scope name, including when only one scope is registered. A single selected
order, with a `# scope: NAME` comment at each document boundary. For ordinary scope is unwrapped. A provider failure prevents report output. Conversion or
block documents the comment follows the marker. When a native opening marker output failures are recorded in `Outcome.Presentation.Output`.
contains inline content, the comment precedes that marker so it does not
interrupt the native value. Existing native markers and leading comments are For a scope that supports path-selected show, `--path POINTER` displays only
retained; documents without a marker receive one. Scope labels containing line that safe effective value. An explicitly empty pointer selects the root, and a
control characters are quoted to keep them on one comment line. A provider present null remains a value. Missing and historical-only paths fail; path
failure leaves the output stream empty. A rendering or output failure may leave selection cannot be combined with `--scope all`.
an aggregate prefix; `Outcome.Presentation.Output` records incomplete delivery
and the destination or rendering error.
### List configuration sources ### List configuration sources
`config sources` reports the source inventory captured during the selected `config sources` reports the source inventory captured during the selected
scope's acquisition. It reads the source facet once and does not reload scope's acquisition. It reads the source facet once and does not reload
configuration. The default scope is `app`; `--scope all` is unsupported. Text is configuration. The default scope is `app`; `--scope all` is unsupported. Text is
the default format, and `--format yaml` returns a structured report: the automatic format, and `--format yaml` or `--format json` returns a structured report.
`--source-id ID` selects one entry by the ID from that acquisition; an unknown
ID fails. If acquisition fails but returns a captured source inventory, the
command reports that evidence and preserves the acquisition error. Unavailable
inventory is reported as such.
```yaml ```yaml
scope: app scope: app
@@ -363,14 +396,14 @@ sources:
- id: source-1 - id: source-1
label: "application config" label: "application config"
path: /etc/example/config.yml path: /etc/example/config.yml
layer: base layer: main
kind: file kind: file
underlay_index: 0 underlay_index: 0
imported_from: null imported_from: null
destination: /server destination: /server
placement: mapping placement: mapping
destination_unresolved: false destination_is_fallback: false
version_check: version_evidence:
path: /version path: /version
version: version:
present: true present: true
@@ -381,43 +414,59 @@ sources:
line: 2 line: 2
column: 1 column: 1
redacted: false redacted: false
empty_import_selections: []
``` ```
Each source includes `id`, `label`, `path`, `layer`, `kind`, `underlay_index`, Each source includes `id`, `label`, `path`, `layer`, `kind`, `underlay_index`,
and `imported_from`, plus the native destination pointer, placement, unresolved and `imported_from`, plus the native destination pointer, placement,
destination flag, and nullable `version_check`. Placement is `mapping` or `destination_is_fallback`, nullable `version_evidence`, and
`sequence_item`. The import location is null when absent; otherwise it has `empty_import_selections`. Placement is `mapping` or `sequence_item`. The
import location is null when absent; otherwise it has
`source_id`, `line`, and `column`. Version evidence preserves `path`, `present`, `source_id`, `line`, and `column`. Version evidence preserves `path`, `present`,
`null`, `text`, optional source `location`, and `redacted` as returned by `null`, `text`, optional source `location`, and `redacted` as returned by
ConfigKit. Owning values and source order are preserved, including empty fields, ConfigKit. Owning values and source order are preserved, including empty fields,
zero coordinates, false and null observations, and synthetic sources without zero coordinates, false and null observations, and synthetic sources without
paths. An absent version check is null. An empty inventory is `sources: []`. paths. Absent version evidence is null. Each empty-import record has a source-local
`path`, a `location` mapping (`source_id`, `line`, `column`), and a `selector`;
records preserve their returned order. No records are `empty_import_selections: []`.
An empty inventory is `sources: []`.
Text output begins with the scope heading and prints the same source fields, Text output begins with the scope heading and prints the same source fields,
quoting labels and paths. These fields project the selected ConfigKit source quoting labels and paths. These fields project the selected ConfigKit source
records; CommandKit does not reload files or rerun version checks. See the records; CommandKit does not reload files or rerun version checks. See the
[ConfigKit v0.8.0 inspection API](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/inspection.go) [ConfigKit v1.1.0 source records](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/records.go)
for native evidence definitions. for native evidence definitions.
### Explain a configuration path ### Explain a configuration path
`config explain --path POINTER` explains the effective and historical value at a `config explain --path POINTER` explains the effective and historical value at a
JSON Pointer path. `--path` is required; an explicitly empty value selects the JSON Pointer path. An explicitly empty path selects the root. `config explain
root. The default scope is `app`; `--format yaml` selects structured output, and --list` discovers paths when the selected scope supports path discovery. An
text is the default. `--scope all` is unsupported. invocation with neither mode displays help; the two modes cannot be combined.
The default scope is `app`; `--format yaml` or `--format json` selects
structured output, and automatic output uses text. `--scope all` is unsupported.
```text ```text
example config explain --path /server/addr example config explain --path /server/addr
example config explain --path /profiles/team~1summary --format yaml example config explain --path /profiles/team~1summary --format yaml
example config explain --path= --format yaml example config explain --path= --format yaml
example config explain --list --under /profiles --historical
``` ```
Listing normally includes current paths. `--historical` adds paths that only
occurred in history; `--under POINTER` selects a subtree, and `--explicit=true`
or `--explicit=false` filters current paths by native explicitness. Historical-only
paths do not match an explicitness filter. These options require `--list`.
The report contains the scope and each selected path's current presence and,
when present, its explicitness. Path discovery does not include values or history.
Pointer tokens use JSON Pointer escaping: `~1` represents `/` and `~0` Pointer tokens use JSON Pointer escaping: `~1` represents `/` and `~0`
represents `~`. A malformed pointer is a usage error with status `2` and is represents `~`. A malformed pointer is a usage error with status `2` and is
rejected before acquisition. A well-formed path that is unknown in the selected rejected before acquisition. A well-formed path that is unknown in the selected
configuration is an operational error with status `1`. Text output comes from configuration is an operational error with status `1`. Text output includes
the scope's native explanation writer. the scope's native explanation and available field metadata. Structured output
includes metadata when supplied by the scope.
YAML output uses these root fields: Structured output uses these root fields:
```yaml ```yaml
scope: app scope: app
@@ -427,7 +476,7 @@ effective:
redacted: false redacted: false
yaml: "localhost:8080\n" yaml: "localhost:8080\n"
origin: origin:
layer: base layer: main
locations: locations:
- source_id: source-1 - source_id: source-1
line: 4 line: 4
@@ -456,35 +505,49 @@ configuration inspection API. PromptKit owns the meaning of that native
comparison; CommandKit uses the existing `config diff` preparation and report comparison; CommandKit uses the existing `config diff` preparation and report
path. path.
`--config` and `--overlay` select the left side. `--against-config` and `--config` and `--overlay` select the left side. `--against-config` and
`--against-overlay` independently override the right side. When a right-side `--against-overlay` select the right side. An omitted field keeps that side's
field is omitted, it inherits the corresponding left value and supplied state; ordinary application loading behavior and unsupplied state; an explicit empty
an explicit empty string overrides it and remains supplied. Equal selections are string remains supplied. Application selectors affect the left side, while
allowed; their equality alone does not cause CommandKit to skip the provider. declared `--against-*` counterparts affect the right. Equal selections are
allowed and do not cause CommandKit to skip the provider.
YAML is the only format and the default. Explicit `--format yaml` is accepted; Automatic output is YAML. The shared `--format` also accepts YAML, JSON, and
`text` and other formats are usage errors. For example: text; text uses the same structured representation. For example:
```text ```text
example config diff --config before.yml --against-config after.yml example config diff --config before.yml --against-config after.yml
example config diff --config base.yml --against-overlay candidate example config diff --config base.yml --against-overlay candidate
example config diff --config base.yml --overlay current --against-overlay "" example config diff --config base.yml --overlay current --against-overlay ""
example config diff --scope promptkit --config before.yml --against-config after.yml example config diff --scope promptkit --config before.yml --against-config after.yml
example config diff --config before.yml --against-config after.yml --details --check
``` ```
`--details` replaces the concise report with `{scope, differences}`, containing
one entry per native difference. Each entry has `path`, `before`, `after`, and
`hidden_changed`; each snapshot has `present`, `redacted`, and `value`. An
absent snapshot has `present: false` and a null value, distinct from a present
null. Empty comparisons contain `differences: []`.
`--check` returns status `3` when native differences exist and `0` when they do
not. Without it, a completed comparison succeeds even when differences exist.
Usage errors retain status `2`, and operational failures retain status `1`.
The report is still delivered in check mode; a presentation failure remains
separate evidence and does not replace a nonzero difference outcome.
For ConfigKit-backed scopes, ConfigKit compares frozen effective typed values For ConfigKit-backed scopes, ConfigKit compares frozen effective typed values
after defaults and resolution; source or provenance differences alone do not after defaults and resolution; source or provenance differences alone do not
create a change. PromptKit delegates its native comparison to ConfigKit. The create a change. PromptKit delegates its native comparison to ConfigKit. The
report is one YAML document with `additions`, `changes`, and `deletions` in that concise report contains `additions`, `changes`, and `deletions`. Additions and changes
order. Additions and changes preserve semantic paths and contain right-side preserve semantic paths and contain right-side values provided by the native
values provided by the native comparison. Deletions contain canonical JSON comparison. Deletions contain canonical JSON
Pointer paths. Explicit null is a value; it is not a deletion. Changed sequences Pointer paths. Explicit null is a value; it is not a deletion. Changed sequences
and native structural replacement regions stay atomic. and native structural replacement regions stay atomic.
ConfigKit pairs sensitivity across both loads. A concealed change can therefore ConfigKit pairs sensitivity across both loads. A concealed change can therefore
appear even when its two safe snapshots are equal. For those ConfigKit-backed appear even when its two safe snapshots are equal. For those ConfigKit-backed
comparisons, the report omits separate redaction and hidden-change metadata: a comparisons, the concise report omits separate redaction and hidden-change metadata: a
reported masked region changed, but that does not mean every member inside it reported masked region changed, but that does not mean every member inside it
changed. Marker-looking public strings stay ordinary YAML values. An empty changed. Marker-looking public strings stay ordinary strings. An empty
comparison succeeds with this shape: comparison succeeds with this shape:
```yaml ```yaml
@@ -498,7 +561,7 @@ automatically applicable patch. Exceptional whole-root empty/null transitions
can be ambiguous because the report has no root envelope; ordinary field-level can be ambiguous because the report has no root envelope; ordinary field-level
null values and deletions retain their distinction. null values and deletions retain their distinction.
Successful comparisons return status `0`, including empty results. Without `--check`, successful comparisons return status `0`, including empty results.
Unsupported scopes and format errors are usage errors; input checks retain the Unsupported scopes and format errors are usage errors; input checks retain the
existing usage/operational classification. Load and native comparison failures existing usage/operational classification. Load and native comparison failures
are operational and preserve supported error identity. are operational and preserve supported error identity.
@@ -520,76 +583,84 @@ output.
## PromptKit definition inspection ## PromptKit definition inspection
Applications can install prompt and profile inspection independently with Applications can install prompt, profile, and backend inspection independently
`ckprompt.Command(suite)`. The `ckprompt.FromEngine(loadEngine)` adapter with `ckprompt.Command(suite)`. `ckprompt.FromEngine(loadEngine)` supplies these
supplies these callbacks alongside native requirements discovery and the five callbacks alongside requirements discovery and the five catalogs. Its loader
catalogs. Its loader receives invocation context, configuration selection, receives invocation context, configuration selection, typed input, and shared
typed input, and shared state. Direct suite callbacks retain their own state. Direct callbacks retain their own lifecycle.
implementation and lifecycle.
Prompt inspection uses an ID only. PromptKit returns any descriptive version
metadata as an optional report field; there is no `--version` flag for selecting
a prompt version. The prompt format and native selection behavior remain owned by
[PromptKit v0.16.0](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md).
```text ```text
example promptkit show prompt customer/summary --format yaml
example promptkit show prompt customer/summary example promptkit show prompt customer/summary
example promptkit show profile customer-support --format yaml example promptkit show profile customer-support --format json
example promptkit show backend local --format yaml
``` ```
Both commands require one ID operand and accept `--config`, `--overlay`, and Each command takes one resource ID and accepts `--config` and `--overlay`.
`--format text|yaml` (default `text`). These commands have no built-in Reports use the native PromptKit inspection value through the shared reporting
`--scope` selection; their namespace is `promptkit`. formats; automatic and explicit text use YAML for these structured values. Native
JSON field names and omission rules apply. Absent optional fields are omitted;
explicit zero values and empty collections remain present. Prompt messages are
captured content, not rendered requests, and the prompt hash is opaque.
Prompt YAML preserves native input order and uses an optional `version` field. Profile reports describe the selected profile before backend completion or
The key is omitted when PromptKit returns no version and is retained when the request overrides. Backend reports describe configured backend fields. An
version is present, including an empty string. The prompt hash is an opaque `api_key_env` value names an environment variable; it is not a credential
owning-library equality value. value or a claim that one is available. Neither report resolves references or
performs a network probe. See the [PromptKit resource inspection contracts](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/resource_inspection.go).
A missing ID is a usage error; native lookup errors and cancellation retain
supported cause identity.
```yaml ### Resource namespace selection
prompt_id: customer/summary
version: "2" An application can enable resource namespace flags with `ckprompt.WithNamespaces`
prompt_hash: opaque-value and provide prompt, profile, and backend defaults. Omitted flags retain those
default_profile_id: customer-support defaults; an explicitly empty value clears the corresponding default. Qualified
inputs: resource IDs pass through unchanged. Without the option, the application loader
- name: topic controls the engine view and these flags are unavailable.
required: true
content_type: text/plain | Operation | Available namespace flags |
description: Topic to summarize | --- | --- |
output_contract: | `show prompt` | `--llm-prompt-namespace` |
format: json | `show profile` | `--llm-profile-namespace` |
validation_mode: basic | `show backend` | `--llm-backend-namespace` |
schema_path: "" | `requirements show` | `--llm-prompt-namespace`, `--llm-profile-namespace` |
repair_attempts: 2 | `requirements validate`, `show prepared`, `test` | All three, including `--llm-backend-namespace` |
Catalog namespace filters remain separate; configuration inspection has no
resource namespace selectors.
### PromptKit convenience aliases
Applications that install `ckprompt.Declarations(suite)` provide scoped
convenience spellings for the enabled PromptKit commands. Each applicable
`--llm-NAME` flag also accepts `--NAME`; `--llm-input-text` additionally accepts
`--llm-input` and `--input` for literal text. Use `--llm-input-file` or
`--input-file` for a file reference. Canonical and alias spellings share the
same binding, including their occurrence order and explicit-value semantics.
Applications that install `ckprompt.Command(suite)` receive the canonical
spellings only. The aliases do not apply to application-owned commands unless
the application installs them separately.
## PromptKit prepared-request preview
When `Suite.PrepareRequest` is present, `ckprompt.Command(suite)` installs
`promptkit show prepared PROMPT_ID` independently of validation and other
inspection commands. It accepts the same configuration, selector, profile,
session, variable, input, and execution flags as
[request validation](#concrete-request-validation):
```text
example promptkit show prepared briefing --llm-input-text topic=roadmap --format json
``` ```
Profile YAML preserves the effective values reported by PromptKit, including The command returns PromptKit's native prepared-run report using the shared
zero values and nested provider parameters. `api_key_env` is only an formats; automatic and explicit text use YAML. Preparation can read resources
environment-variable name. `api_key_required` reports a direct credential and report rendered application content. It makes no model call and does not
requirement; neither field reports credential availability. A nil provider guarantee a subsequent execution will succeed. Application callbacks and
parameter map is displayed as `{}`. loaders retain their own effects. Native preparation errors retain supported
cause identity. The [PromptKit Prepare API](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/engine.go)
```yaml owns the prepared value and upstream preparation behavior.
profile_id: customer-support
effective_model_params:
backend_id: local
endpoint: https://example.invalid/v1
model: support-model
temperature: 0
max_tokens: 0
top_p: 0
timeout_seconds: 600
service_tier: ""
reasoning_effort: ""
api_key_env: ""
extra_params: {}
api_key_required: true
```
Text uses labeled fields and includes nested `extra_params` values. A missing ID
is a usage error. Definition selection reaches the owning inspection API and
preserves supported error identity and cancellation.
## PromptKit requirements ## PromptKit requirements
@@ -599,8 +670,8 @@ native selection and discovery rules:
```text ```text
example promptkit requirements show briefing --campaign spring example promptkit requirements show briefing --campaign spring
example promptkit requirements show briefing --profile editorial --var mode=full example promptkit requirements show briefing --llm-profile editorial --llm-var mode=full
example promptkit requirements show briefing --session-id session-42 --var note= --format yaml example promptkit requirements show briefing --llm-session-id session-42 --llm-var note= --format yaml
``` ```
The command accepts: The command accepts:
@@ -610,41 +681,42 @@ The command accepts:
| `--config` | path string | omitted | Select the configuration input; explicit presence is retained. | | `--config` | path string | omitted | Select the configuration input; explicit presence is retained. |
| `--overlay` | path string | omitted | Select one overlay; explicit presence is retained. Repeated occurrences use the last value. | | `--overlay` | path string | omitted | Select one overlay; explicit presence is retained. Repeated occurrences use the last value. |
| Suite selectors | application-defined | per selector | The flags captured from `Suite.Selectors`. | | Suite selectors | application-defined | per selector | The flags captured from `Suite.Selectors`. |
| `--profile` | string | empty | Pass the profile ID to PromptKit; empty uses its native selection rule. | | `--llm-profile` | string | empty | Pass the profile ID to PromptKit; empty uses its native selection rule. |
| `--session-id` | string | empty | Pass session context to PromptKit. | | `--llm-session-id` | string | empty | Pass session context to PromptKit. |
| `--var` | repeatable `KEY=VALUE` | none | Supply caller-variable context for native discovery. | | `--llm-var` | repeatable `KEY=VALUE` | none | Supply caller-variable context for native discovery. |
| `--format` | `yaml` | `yaml` | Select the requirements report format. Other formats are usage errors for this operation. | | `--format` | `text`, `yaml`, `json` | automatic | Select the requirements report format; automatic and text use YAML for this structured report. |
The `promptkit` namespace identifies the operation, so there is no built-in The `promptkit` namespace identifies the operation, so there is no built-in
`--scope` flag. An explicitly empty profile or session value retains PromptKit's `--scope` flag. An explicitly empty profile or session value retains PromptKit's
native empty-string meaning. Requirements inspection does not require a profile native empty-string meaning. Profile selection establishes the reported profile
to be execution-ready. See the identity rather than changing the discovered requirements. An enabled profile
[PromptKit v0.16.0 consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md) namespace qualifies an explicitly supplied short profile ID; it does not rewrite
the prompt's stored default reference. Requirements inspection does not require
the selected profile to be execution-ready. See the
[PromptKit v0.20.0 consumer guide](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/docs/consumers/pkg-promptkit.md)
for native configuration, selection, and requirements behavior. The built-in for native configuration, selection, and requirements behavior. The built-in
flags other than `--var` are singleton bindings; when repeated, the last value flags other than `--llm-var` are singleton bindings; when repeated, the last value
is used. Suite selectors retain their declared binding behavior. is used. Suite selectors retain their declared binding behavior.
Each `--var` occurrence must contain `=`. CommandKit splits at the first Each `--llm-var` occurrence must contain `=`. CommandKit splits at the first
separator and preserves the exact key and remaining value. Empty keys and separator and preserves the exact key and remaining value. Empty keys and
values, whitespace, commas, and additional `=` characters are retained. All values, whitespace, commas, and additional `=` characters are retained. All
occurrences are parsed; duplicate keys use the last value, while any malformed occurrences are parsed; duplicate keys use the last value, while any malformed
occurrence is a usage error even if a later assignment is valid. occurrence is a usage error even if a later assignment is valid.
The YAML report contains selected identities, native completeness, and input, The report contains selected identities, native completeness, and input,
variable, and issue inventories. False values and empty inventories are variable, and issue inventories. False values and empty inventories are
retained. Input metadata preserves null versus an empty string. Variable entries retained. Input metadata preserves null versus an empty string. Variable entries
contain `name`, `kind`, `supplied`, and `explanation`. `supplied` reports native contain `name`, `kind`, `supplied`, and `explanation`. `supplied` reports native
exact-key presence, including an explicitly empty value, independently of exact-key presence, including an explicitly empty value, independently of
`kind` and `complete`. Explanations and issue descriptions are upstream-owned `kind` and `complete`. Explanations and issue descriptions are upstream-owned
human-readable summaries, not machine-readable details. The optional `version` human-readable summaries, not machine-readable details.
key is omitted when absent and retained when supplied by PromptKit.
For a prompt with the template `{{if .mode}}{{input "notes"}}{{end}}` and no For a prompt with the template `{{if .mode}}{{input "notes"}}{{end}}` and no
input declarations, omitting `mode` can produce a partial report: input declarations, omitting `mode` can produce a partial report:
```yaml ```yaml
prompt_id: briefing prompt_id: briefing
version: "2"
profile_id: editorial profile_id: editorial
complete: false complete: false
inputs: inputs:
@@ -682,8 +754,8 @@ prompt ID and passes a concrete request to PromptKit's native
`Engine.ValidateRequest` preparation path: `Engine.ValidateRequest` preparation path:
```text ```text
example promptkit requirements validate briefing --campaign spring --input topic=topic.txt example promptkit requirements validate briefing --campaign spring --llm-input-file topic=topic.txt
example promptkit requirements validate briefing --profile editorial --session-id session-42 --var mode=full --input notes=notes.txt example promptkit requirements validate briefing --llm-profile editorial --llm-session-id session-42 --llm-var mode=full --llm-input-file notes=notes.txt
``` ```
The command accepts: The command accepts:
@@ -693,26 +765,31 @@ The command accepts:
| `--config` | path string | omitted | Select the configuration input; explicit presence is retained. | | `--config` | path string | omitted | Select the configuration input; explicit presence is retained. |
| `--overlay` | path string | omitted | Select one overlay; explicit presence is retained. Repeated occurrences use the last value. | | `--overlay` | path string | omitted | Select one overlay; explicit presence is retained. Repeated occurrences use the last value. |
| Suite selectors | application-defined | per selector | The flags captured from `Suite.Selectors`. | | Suite selectors | application-defined | per selector | The flags captured from `Suite.Selectors`. |
| `--profile` | string | empty | Pass the profile ID to PromptKit. | | `--llm-profile` | string | empty | Pass the profile ID to PromptKit. |
| `--session-id` | string | empty | Pass session context to PromptKit. | | `--llm-session-id` | string | empty | Pass session context to PromptKit. |
| `--var` | repeatable `KEY=VALUE` | none | Supply caller variables for the concrete request. | | `--llm-var` | repeatable `KEY=VALUE` | none | Supply caller variables for the concrete request. |
| `--input` | repeatable `NAME=PATH` | none | Bind a file-backed PromptKit artifact. | | `--llm-input-text` | repeatable `NAME=TEXT` | none | Bind a literal PromptKit artifact. |
| `--format` | `yaml` | `yaml` | Select the success confirmation format. Other formats are usage errors. | | `--llm-input-file` | repeatable `NAME=PATH` | none | Bind a file-backed PromptKit artifact. |
| `--format` | `text`, `yaml`, `json` | automatic | Select the success confirmation format; automatic and text use YAML for this structured report. |
The built-in flags other than repeatable `--var` and `--input` are singleton Validation also accepts the execution controls from `--llm-endpoint` through
bindings; when repeated, the last value is used. Explicit empty `--config` or `--llm-extra-params` listed under [smoke testing](#promptkit-smoke-testing).
The built-in flags other than
`--llm-var`, `--llm-input-text`, and `--llm-input-file` are singleton bindings;
when repeated, the last value is used. Explicit empty `--config` or
`--overlay` values keep their supplied-presence information. The profile and `--overlay` values keep their supplied-presence information. The profile and
session fields use native empty-string semantics. PromptKit owns their session fields use native empty-string semantics. PromptKit owns their
interpretation and selection errors. interpretation and selection errors.
Each `--var` and `--input` occurrence must contain `=`. CommandKit splits at the Each `--llm-var`, `--llm-input-text`, and `--llm-input-file` occurrence must
first separator and preserves the exact key/name and remaining value/path. contain `=`. CommandKit splits at the first separator and preserves the exact
Empty variable keys and values and empty input names and paths are passed to key/name and remaining value. Empty names and values are passed to native
native validation. File paths become `promptkit.File(PATH)` references without validation. Text becomes a `promptkit.Inline` reference; file paths become
being read, normalized, expanded, or rebased during argument binding. Duplicate `promptkit.File` references without being read, normalized, expanded, or
keys or names use the last value, but every occurrence is parsed. rebased during argument binding. Every occurrence is parsed; for each input
name the last text or file occurrence wins, regardless of form.
On success, the YAML confirmation contains only the requested prompt ID and On success, the confirmation contains only the requested prompt ID and
`valid: true`: `valid: true`:
```yaml ```yaml
@@ -727,7 +804,7 @@ configuration and credential checks. It does not make a model call or retain a
prepared execution snapshot. A successful report describes this preparation prepared execution snapshot. A successful report describes this preparation
invocation; it does not certify provider acceptance or guarantee later invocation; it does not certify provider acceptance or guarantee later
execution. See PromptKit's execution. See PromptKit's
[`Engine.ValidateRequest` API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/engine.go). [`Engine.ValidateRequest` API](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/engine.go).
Builder, engine-loading, and native validation errors use the operational path Builder, engine-loading, and native validation errors use the operational path
(status `1`) with supported error identity and acquired diagnostics retained. (status `1`) with supported error identity and acquired diagnostics retained.
@@ -750,8 +827,10 @@ example promptkit list collections
``` ```
All catalogs accept `--config`, `--overlay`, applicable flags from All catalogs accept `--config`, `--overlay`, applicable flags from
`Suite.Selectors`, and `--format yaml` (the default). Other formats are usage `Suite.Selectors`, and the shared `--format` flag. Automatic output uses text
errors. Prompt, profile, and backend listings accept namespace selection: for nonempty string lists and YAML for other inventories; explicit `text` uses
the same rendering for structured inventories. Prompt, profile, and backend
listings accept namespace selection:
| Binding | Effect | | Binding | Effect |
| --- | --- | | --- | --- |
@@ -767,11 +846,10 @@ before provider work. Namespace inventory accepts repeatable `--kind KIND`;
omitting `--kind` forwards PromptKit's native default kind selection. Repeated omitting `--kind` forwards PromptKit's native default kind selection. Repeated
values are forwarded in order. Collections have no namespace or kind filter. values are forwarded in order. Collections have no namespace or kind filter.
Each operation writes one bare YAML sequence, retaining provider order and Each operation reports one bare sequence, retaining provider order and native
native identities: identities. YAML examples follow:
- Prompt entries are `{id, version?}`. The optional `version` key is omitted - Prompt entries are `{id}`.
when absent and retained for a present empty or nonempty value.
- Profiles, backends, and namespaces are strings. - Profiles, backends, and namespaces are strings.
- Collection entries are `{namespace, version?}`, where `version` projects - Collection entries are `{namespace, version?}`, where `version` projects
PromptKit's optional release version. PromptKit's optional release version.
@@ -779,7 +857,6 @@ native identities:
```yaml ```yaml
- id: team/briefing - id: team/briefing
version: "2"
``` ```
```yaml ```yaml
@@ -790,7 +867,7 @@ native identities:
A catalog result is an inventory, not a readiness check or execution result. A catalog result is an inventory, not a readiness check or execution result.
The owning provider controls membership and order. Namespace filters are passed The owning provider controls membership and order. Namespace filters are passed
to PromptKit without local membership interpretation. See the to PromptKit without local membership interpretation. See the
[PromptKit v0.16.0 consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md) [PromptKit v0.20.0 consumer guide](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/docs/consumers/pkg-promptkit.md)
for native resource and selection definitions. for native resource and selection definitions.
### PromptKit smoke testing ### PromptKit smoke testing
@@ -803,43 +880,49 @@ each aggregate call changes the seed kind before invoking it. The command
accepts: accepts:
```text ```text
example promptkit test text_inference --campaign spring --prompt briefing --profile spring example promptkit test text_inference --campaign spring --llm-prompt briefing --llm-profile spring
example promptkit test all --campaign spring --prompt briefing --profile spring example promptkit test all --campaign spring --llm-prompt briefing --llm-profile spring
example promptkit test authentication --backend local example promptkit test authentication --llm-backend local
``` ```
| Flag | Values | Default | Meaning | | Flag | Values | Default | Meaning |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `--config`, `--overlay` | path strings | omitted | Select PromptKit configuration; supplied presence is retained. | | `--config`, `--overlay` | path strings | omitted | Select PromptKit configuration; supplied presence is retained. |
| Suite selectors | application-defined | per selector | Captured flags from `Suite.Selectors`. | | Suite selectors | application-defined | per selector | Captured flags from `Suite.Selectors`. |
| `--backend` | string | empty | Native backend ID. | | `--llm-backend` | string | empty | Native backend ID. |
| `--prompt` | string | empty | Native prompt ID. | | `--llm-prompt` | string | empty | Native prompt ID. |
| `--profile` | string | empty | Native profile ID. | | `--llm-profile` | string | empty | Native profile ID. |
| `--endpoint` | string | omitted | Override the execution endpoint. | | `--llm-endpoint` | string | omitted | Override the execution endpoint. |
| `--model` | string | omitted | Override the execution model. | | `--llm-model` | string | omitted | Override the execution model. |
| `--temperature` | float | omitted | Override execution temperature. | | `--llm-temperature` | float | omitted | Override execution temperature. |
| `--max-tokens` | integer | omitted | Override execution maximum tokens. | | `--llm-max-tokens` | integer | omitted | Override execution maximum tokens. |
| `--top-p` | float | omitted | Override execution top-p. | | `--llm-top-p` | float | omitted | Override execution top-p. |
| `--timeout-seconds` | integer | omitted | Override the execution timeout. | | `--llm-timeout-seconds` | integer | omitted | Override the execution timeout. |
| `--service-tier` | string | omitted | Override the execution service tier. | | `--llm-service-tier` | string | omitted | Override the execution service tier. |
| `--reasoning-effort` | string | omitted | Override execution reasoning effort. | | `--llm-reasoning-effort` | string | omitted | Override execution reasoning effort. |
| `--api-key-env` | string | omitted | Supply an API-key environment-variable name to native configuration. | | `--llm-api-key-env` | string | omitted | Supply an API-key environment-variable name to native configuration. |
| `--transient-retries` | integer | omitted | Set the native inference transient retry count. | | `--llm-transient-retries` | integer | omitted | Set the native inference transient retry count. |
| `--format` | `yaml` | `yaml` | Select the report format. Other formats are usage errors. | | `--llm-extra-params` | JSON object | omitted | Supply provider parameters as a whole map. |
| `--format` | `text`, `yaml`, `json` | automatic | Select the report format; automatic and text use YAML for these structured reports. |
The execution flags populate PromptKit's native execution override. The The execution flags populate PromptKit's native execution override. The
`--api-key-env` value is a variable name, not a credential value. Application `--llm-api-key-env` value is a variable name, not a credential value. The
selectors, loader policy, credentials, and client wiring remain application extra-parameters flag accepts one JSON object, including an empty object;
PromptKit owns its native replacement semantics and validation. Integer JSON
numbers use signed or unsigned 64-bit integers where representable; decimal
and exponent numbers use floating point.
Application selectors, loader policy, credentials, and client wiring remain application
owned. The `WithSmokeTesting` helper accepts an optional typed builder. A nil owned. The `WithSmokeTesting` helper accepts an optional typed builder. A nil
builder forwards the seed; a supplied builder's complete native request is builder forwards the seed; a supplied builder's complete native request is
authoritative. The helper retains a native report when PromptKit returns both a authoritative. The helper retains a native report when PromptKit returns both a
report and an error. report and an error.
A single report projects the native smoke report into YAML. It preserves native A single report projects the native smoke report. It preserves native
false, unknown/null, and incomplete values and does not add a readiness score. false, unknown/null, and incomplete values and does not add a readiness score.
The report includes `kind`, `supported`, `applicable`, `completed`, `reason`, The report includes `kind`, `supported`, `applicable`, `completed`, `reason`,
`target`, `facts`, `timing`, `attempts`, `usage`, and `failure`. Its target `target`, `facts`, `timing`, `attempts`, `usage`, and `failure`. Its target
contains selected IDs, optional `version`, selection source, endpoint, effective contains selected IDs, selection source, endpoint, effective
settings, and settings-presence flags. Attempt entries retain their own facts, settings, and settings-presence flags. Attempt entries retain their own facts,
timing, failure, and token-usage evidence. Durations use Go duration strings; timing, failure, and token-usage evidence. Durations use Go duration strings;
token counts preserve `value`, `known`, and `complete`. token counts preserve `value`, `known`, and `complete`.
@@ -853,14 +936,14 @@ applicable: null
completed: true completed: true
reason: http_diagnostics_unavailable reason: http_diagnostics_unavailable
target: target:
backend_id: "" backend_id: local
profile_id: spring profile_id: spring
prompt_id: briefing prompt_id: briefing
version: "1"
default_source_backend_id: "" default_source_backend_id: ""
selection_source: profile selection_source: profile
endpoint: https://example.invalid/v1 endpoint: https://example.invalid/v1
effective_settings: effective_settings:
backend_id: local
endpoint: https://example.invalid/v1 endpoint: https://example.invalid/v1
model: spring-model model: spring-model
temperature: 0 temperature: 0
@@ -905,7 +988,7 @@ usage:
failure: "" failure: ""
``` ```
Aggregate output is one YAML mapping with ordered `tests` entries. Each entry Aggregate output is one mapping with ordered `tests` entries. Each entry
has selected `kind`, nullable `report`, and optional `error` text. The inner has selected `kind`, nullable `report`, and optional `error` text. The inner
report keeps its native `kind`. This cancellation example shows two attempted report keeps its native `kind`. This cancellation example shows two attempted
operations with no reports; later kinds are absent because cancellation stops operations with no reports; later kinds are absent because cancellation stops
@@ -933,9 +1016,9 @@ operation causes. Syntax, operand, unsupported format, and conflicting
namespace selection are usage status `2`. namespace selection are usage status `2`.
Native configuration, selection, and diagnostic meanings remain owned by Native configuration, selection, and diagnostic meanings remain owned by
PromptKit. See the [v0.16.0 consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md), PromptKit. See the [v0.20.0 consumer guide](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/docs/consumers/pkg-promptkit.md),
[smoke API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/smoke_diagnostics.go), [smoke API](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/smoke_diagnostics.go),
and [engine API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/engine.go). and [engine API](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/engine.go).
## Outcome status ## Outcome status

View File

@@ -15,12 +15,18 @@ installed at the application root, not as group children.
| `go.maximumdirect.net/ckconfig` | The application wants ConfigKit-backed configuration scopes and reports. | | `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/ckprompt` | The application wants PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, request validation, or native smoke testing. |
The matched releases are core v0.3.0, ckconfig v0.2.0, and ckprompt v0.2.0. Core v0.4.0 is published. The previously published matched releases are core
Update application imports and dependency versions together; old and new core v0.3.0, ckconfig v0.2.0, and ckprompt v0.2.0.
paths have distinct Go type identities. Install core with The published ckconfig v0.3.0 uses ConfigKit v1.1.0 and does not compose
`go get go.maximumdirect.net/commandkit@v0.3.0` and either integration with with ckprompt v0.2.0, which retains ckconfig v0.2.0 and ConfigKit v0.8.0.
`go get go.maximumdirect.net/ckconfig@v0.2.0` or Install the current core with `go get go.maximumdirect.net/commandkit@v0.4.0`.
`go get go.maximumdirect.net/ckprompt@v0.2.0`. For the older matched integration set, select core v0.3.0 and the corresponding
ckconfig or ckprompt v0.2.0 release together.
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.
The two integrations are independent optional imports. `ckprompt` uses the The two integrations are independent optional imports. `ckprompt` uses the
public scope and error contracts from `ckconfig`; importing it does not add the public scope and error contracts from `ckconfig`; importing it does not add the
@@ -30,12 +36,13 @@ core-only, ConfigKit-only, and composed applications.
## Go compatibility ## Go compatibility
All three modules declare a consumer minimum of Go 1.26.0. The root module also Core declares a consumer minimum of Go 1.26.0; both current integration
modules require Go 1.27.0. The root module also
selects ReleaseKit v0.9.0 (`go.maximumdirect.net/releasekit`) as a Go tool, which selects ReleaseKit v0.9.0 (`go.maximumdirect.net/releasekit`) as a Go tool, which
makes that tool's module graph part of the root consumer module requirements makes that tool's module graph part of the root consumer module requirements
even though production packages do not import ReleaseKit. The even though production packages do not import ReleaseKit. The
[release policy](../policy/release.md#consumer-minimum-and-release-toolchain) [release policy](../policy/release.md#consumer-minimum-and-release-toolchain)
defines the support assumption. Maintainers use Go 1.26.8 for validation and defines the support assumption. Maintainers use Go 1.27.0 for validation and
publication; see the [release procedure](../release.md#release-toolchain). publication; see the [release procedure](../release.md#release-toolchain).
## Core commands ## Core commands
@@ -105,7 +112,7 @@ static choices or is a filesystem path:
```go ```go
commandkit.FlagMeta{ commandkit.FlagMeta{
Name: "format", Name: "view",
Completion: commandkit.CompleteChoices( Completion: commandkit.CompleteChoices(
commandkit.CompletionChoice{Value: "text", Description: "Readable text"}, commandkit.CompletionChoice{Value: "text", Description: "Readable text"},
commandkit.CompletionChoice{Value: "yaml", Description: "YAML output"}, commandkit.CompletionChoice{Value: "yaml", Description: "YAML output"},
@@ -126,12 +133,20 @@ installation alongside aliases and integration commands.
## Invocation reporting ## Invocation reporting
`Invocation.Reporter()` supplies the reporter for the current handler. Use `Invocation.Reporter()` supplies the reporter for the current handler. Use
`Output()` or `RenderOutput()` for substantive results; use `Warn`, `Info`, `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 `Progress`, and `Debug` for categorized messages. Queue a summary with
`AtEnd().Info` when it belongs after the handler returns. The `AtEnd().Info` when it belongs after the handler returns. The
[CLI reference](../cli.md#reporting) owns flag grammar, visibility, prefixes, [CLI reference](../cli.md#reporting) owns flag grammar, visibility, prefixes,
and status behavior. 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,
text uses readable scalar or string-list output and YAML for structured values.
The CLI's shared format selection and the writer-level `RenderReport` use the
same rules. Use `DecodeYAMLReport` only for safe upstream inspection YAML that
must become a report value; ordinary strings are not parsed as YAML.
Return terminal text with `Failure(message, details...)`, `Usage(message, Return terminal text with `Failure(message, details...)`, `Usage(message,
details...)`, or `WithExplanation` on a domain outcome. Attach an optional Go details...)`, or `WithExplanation` on a domain outcome. Attach an optional Go
error separately with `WithCause`. A cause is preserved for inspection, but it error separately with `WithCause`. A cause is preserved for inspection, but it
@@ -157,15 +172,6 @@ invocation are safe concurrently, but concurrent submission order is not
specified. The caller owns the writers passed to `Execute`; CommandKit does not specified. The caller owns the writers passed to `Execute`; CommandKit does not
close them. close them.
### Migrating from the previous reporting API
| Previous API | Current API |
| --- | --- |
| `invocation.Streams.Out` / `.Err` | `invocation.Reporter().Output()` and categorized reporter methods. `Streams` remains the argument to `App.Execute`. |
| `Failure(err)` / `Usage(err)` | `Failure("operator-facing explanation").WithCause(err)` / `Usage("usage explanation").WithCause(err)`. Choose the explanation deliberately; the cause alone is not printed. |
| `Outcome.Diagnostics` or terminal `SeverityError` | Submit independent messages through reporter methods. Put failure context in `Explanation`; provider `Acquired.Diagnostics` remains passive data for the owning integration to submit. |
| `ReportIncomplete`, `DiagnosticDelivery`, or `DeliveryError` | Inspect `Outcome.Presentation.Output` and `.Diagnostics`, including each `DeliveryResult.State`, `RenderError`, and `WriteError`. |
## Application-owned aliases ## Application-owned aliases
Pass commands and aliases together as declarations. Integration factories can Pass commands and aliases together as declarations. Integration factories can
@@ -227,8 +233,10 @@ Use `ckconfig.Command(ckconfig.Suite[I, S]{...})` when the application needs
multiple named scopes, selector flags, scope-aware input checks, or shared multiple named scopes, selector flags, scope-aware input checks, or shared
acquisition state or a custom comparison provider. A declared `Diff` capability acquisition state or a custom comparison provider. A declared `Diff` capability
uses `Scope.Compare`; `ckconfig.FromConfigKit` and `AppCommand` provide the uses `Scope.Compare`; `ckconfig.FromConfigKit` and `AppCommand` provide the
ConfigKit-backed implementation. Each comparison side gets its own initialized ConfigKit-backed implementation. `Selector.AgainstName` opts a selector into an
and checked input, while state is shared by the invocation. Factory results 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 remain caller-owned, so separate initialization calls do not guarantee distinct
objects. See objects. See
[ckconfig.Suite's GoDoc](../../integrations/ckconfig/contracts.go) for factory [ckconfig.Suite's GoDoc](../../integrations/ckconfig/contracts.go) for factory
@@ -236,10 +244,17 @@ timing, omitted-factory zero values (including nil pointers), and ownership.
The application owns the state type and what it caches. `ckconfig.FromConfigKit` The application owns the state type and what it caches. `ckconfig.FromConfigKit`
adapts a typed ConfigKit loader without calling it during construction. adapts a typed ConfigKit loader without calling it during construction.
The `config` family provides scope discovery, validation, native effective YAML, The `config` family provides scope discovery, validation, safe effective configuration display,
source inventory, path explanation, and ConfigKit-backed comparison for the source inventory, path explanation, and ConfigKit-backed comparison for the
capabilities declared by its scopes. The [CLI reference](../cli.md#configuration-scopes) capabilities declared by its scopes. The [CLI reference](../cli.md#configuration-scopes)
owns operator syntax and report details. owns operator syntax and report details. `Scope.Features` declares optional modes; acquired
`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.
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
[CLI reference](../cli.md#configuration-scopes) describes the operator syntax.
## PromptKit configuration, definitions, and requirements ## PromptKit configuration, definitions, and requirements
@@ -247,20 +262,25 @@ owns operator syntax and report details.
engine's captured configuration. Add that scope to a `ckconfig.Suite` to expose engine's captured configuration. Add that scope to a `ckconfig.Suite` to expose
it under `config` with validate/show/sources/explain and native diff operations; it under `config` with validate/show/sources/explain and native diff operations;
it does not install PromptKit catalog, definition, or requirements commands. 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.
`ckprompt.FromEngine(loadEngine)` supplies eight callbacks: prompt, profile, `ckprompt.FromEngine(loadEngine)` supplies nine callbacks: prompt, profile,
backend, namespace, and collection catalogs; named prompt and profile backend, namespace, and collection catalogs; named prompt, profile, and backend
inspection; and native requirements discovery. Concrete request validation and inspection; and native requirements discovery. Concrete request validation,
smoke testing are separate capabilities, added explicitly with prepared-request preview, and smoke testing are separate capabilities, added
`ckprompt.WithRequestValidation` and `ckprompt.WithSmokeTesting`. The suite has with `ckprompt.WithRequestValidation`, `ckprompt.WithRequestPreparation`, and
ten independently optional callbacks in total. `ckprompt.Command(suite)` creates `ckprompt.WithSmokeTesting`. The suite has twelve independently optional
only operations whose callbacks are present; discovery, validation, and smoke callbacks in total. `ckprompt.Command(suite)` creates only operations whose
testing can be installed independently. A composed suite can enable both callbacks are present, with canonical flags. `ckprompt.Declarations(suite)`
additional operations: returns the command and its scoped convenience aliases for installation with
other declarations. These capabilities can be installed independently. A
composed suite can enable the additional operations:
```go ```go
suite := ckprompt.FromEngine(loadEngine) suite := ckprompt.FromEngine(loadEngine)
suite = ckprompt.WithRequestValidation(suite, loadEngine, buildRequest) suite = ckprompt.WithRequestValidation(suite, loadEngine, buildRequest)
suite = ckprompt.WithRequestPreparation(suite, loadEngine, nil)
suite = ckprompt.WithSmokeTesting(suite, loadEngine, nil) suite = ckprompt.WithSmokeTesting(suite, loadEngine, nil)
suite.NewInput = newInput suite.NewInput = newInput
suite.NewState = newState suite.NewState = newState
@@ -290,7 +310,7 @@ absent, so explicitly supplied empty values stay meaningful. Applications that
validate and then execute a request are encouraged to share their request validate and then execute a request are encouraged to share their request
construction logic between those paths. construction logic between those paths.
The eight catalog and inspection callbacks and the two explicit additions are The catalog and inspection callbacks and the explicit additions are
independently optional. For catalogs without named or requirements inspection, independently optional. For catalogs without named or requirements inspection,
narrow the suite before command capture: narrow the suite before command capture:
@@ -298,11 +318,12 @@ narrow the suite before command capture:
definitions := ckprompt.FromEngine(loadEngine) definitions := ckprompt.FromEngine(loadEngine)
definitions.InspectPrompt = nil definitions.InspectPrompt = nil
definitions.InspectProfile = nil definitions.InspectProfile = nil
definitions.InspectBackend = nil
definitions.InspectRequirements = nil definitions.InspectRequirements = nil
definitionCommand := ckprompt.Command(definitions) definitionCommand := ckprompt.Command(definitions)
``` ```
Each selected inspection, requirements, validation, smoke, or catalog operation Each selected inspection, requirements, validation, preparation, smoke, or catalog operation
prepares one input, applies its captured selectors, checks that input, and prepares one input, applies its captured selectors, checks that input, and
initializes one state value when those steps are reached. The bundled validation initializes one state value when those steps are reached. The bundled validation
adapter delegates concrete request preparation to PromptKit and carries its adapter delegates concrete request preparation to PromptKit and carries its
@@ -315,6 +336,24 @@ and state. `FromEngine` uses PromptKit's public listing and inspection methods,
including `Engine.InspectRequirements`; validation must be opted into separately including `Engine.InspectRequirements`; validation must be opted into separately
and uses `Engine.ValidateRequest`. and uses `Engine.ValidateRequest`.
`ckprompt.WithRequestPreparation` adds a separate callback backed by
`Engine.Prepare`. Its optional builder receives the seeded request and returns
the complete request to prepare. The resulting native report can contain
rendered application content. See the [CLI reference](../cli.md#promptkit-prepared-request-preview)
for operator behavior.
Application-owned commands can use `ckprompt.NewExecutionFlags`,
`ckprompt.NewResourceFlags`, and `ckprompt.NewPromptInputFlags` to bind the
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.
`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.
Direct callbacks receive the composed selection in their request. The
[CLI reference](../cli.md#resource-namespace-selection) lists applicable flags.
Catalog, named definition inspection, and requirements discovery do not render Catalog, named definition inspection, and requirements discovery do not render
messages or load request artifacts. Concrete request validation follows native messages or load request artifacts. Concrete request validation follows native
PromptKit preparation, which can resolve prompt/profile resources, load PromptKit preparation, which can resolve prompt/profile resources, load
@@ -335,6 +374,28 @@ combines app and PromptKit config scopes, comparison and catalogs, named and
requirements inspection, request validation, smoke testing, and an requirements inspection, request validation, smoke testing, and an
application-owned `session restore` command. application-owned `session restore` command.
### Namespace views
An application can return a scoped PromptKit view from its own engine loader:
```go
func loadScopedEngine(ctx context.Context, selection ckconfig.Selection, input Input, state State) (*promptkit.Engine, error) {
engine, err := loadEngine(ctx, selection, input, state)
if err != nil {
return nil, err
}
return engine.WithNamespaces(promptkit.Namespaces{
Prompt: "editorial", Profile: "team", Backend: "providers",
})
}
```
These are independent defaults for unqualified caller-supplied prompt, profile,
and backend names; qualified IDs pass through. Stored references and configuration
inspection are unchanged, while namespace and collection inventories remain global.
Catalog `--namespace` filters are separate from these defaults. See the released
[namespace API](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/namespaces.go).
## Outcomes and ownership ## Outcomes and ownership
Core classifies usage and operational failures while preserving application Core classifies usage and operational failures while preserving application
@@ -347,8 +408,8 @@ signal handling, and exit mapping; `Execute` never exits the process.
Go declarations and GoDoc own exact exported contracts. ConfigKit owns Go declarations and GoDoc own exact exported contracts. ConfigKit owns
configuration decoding, validation, and provenance. PromptKit owns prompt configuration decoding, validation, and provenance. PromptKit owns prompt
formats, profile resolution, and inspection details. See the formats, profile resolution, and inspection details. See the
[ConfigKit v0.8.0 inspection API](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/inspection.go), [ConfigKit v1.1.0 source records](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/records.go),
[PromptKit engine methods](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/engine.go), [PromptKit engine methods](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/engine.go),
[configuration inspection](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/configuration_inspection.go), [configuration inspection](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/configuration_inspection.go),
[smoke diagnostics](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/smoke_diagnostics.go), [smoke diagnostics](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/smoke_diagnostics.go),
and [PromptKit v0.16.0 consumer guidance](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md). and [PromptKit v0.20.0 consumer guidance](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/docs/consumers/pkg-promptkit.md).

View File

@@ -15,8 +15,8 @@ canonical documentation owners.
For application-owned aliases, use the For application-owned aliases, use the
[CLI reference](cli.md#application-owned-aliases), [CLI reference](cli.md#application-owned-aliases),
[Go declarations and GoDoc](../command.go), and [Go declarations and GoDoc](../command.go), and
[ADR 0013](adr/0013-application-alias-routing.md). Keep deferred capabilities in [ADR 0005](adr/0005-application-alias-routing.md). Keep unfinished work and
[deferred work](roadmap/deferred.md). capability proposals in [future work](roadmap/future.md).
## Initial orientation ## Initial orientation
@@ -24,7 +24,7 @@ For application-owned aliases, use the
2. Read the [agent policy](policy/agents.md) and select other applicable policies 2. Read the [agent policy](policy/agents.md) and select other applicable policies
using the guide below. using the guide below.
3. Read the owning roadmap for proposals and implementation status, and the 3. Read the owning roadmap for proposals and implementation status, and the
applicable ADR for accepted decisions and rationale. applicable [ADR](adr/README.md) for accepted decisions and rationale.
4. Inspect implementation and relevant consumer contracts when they exist. 4. Inspect implementation and relevant consumer contracts when they exist.
## Policy Reading Guide ## Policy Reading Guide
@@ -54,17 +54,17 @@ For application-owned aliases, use the
| Task | Read | | Task | Read |
| --- | --- | | --- | --- |
| Implemented CLI behavior | [CLI reference](cli.md) | | Implemented CLI behavior | [CLI reference](cli.md) |
| Invocation reporting | [CLI reporting contract](cli.md#reporting), [consumer guide](consumers/guide.md#invocation-reporting), core GoDoc, [reporting roadmap](roadmap/reporting.md), and [ADR 0014](adr/0014-invocation-reporting.md) | | Invocation reporting | [CLI reporting contract](cli.md#reporting), [consumer guide](consumers/guide.md#invocation-reporting), core GoDoc, and [ADR 0006](adr/0006-invocation-reporting.md) |
| Application-owned aliases | [CLI reference](cli.md#application-owned-aliases), [Go declarations and GoDoc](../command.go), [consumer guide](consumers/guide.md#application-owned-aliases), and [ADR 0013](adr/0013-application-alias-routing.md) | | Application-owned aliases | [CLI reference](cli.md#application-owned-aliases), [Go declarations and GoDoc](../command.go), [consumer guide](consumers/guide.md#application-owned-aliases), and [ADR 0005](adr/0005-application-alias-routing.md) |
| Public API usage and standard-suite wiring | [Consumer guide](consumers/guide.md), [ckconfig package](../integrations/ckconfig/doc.go), [ckprompt package](../integrations/ckprompt/doc.go), and applicable ADRs | | Public API usage and standard-suite wiring | [Consumer guide](consumers/guide.md), [ckconfig package](../integrations/ckconfig/doc.go), [ckprompt package](../integrations/ckprompt/doc.go), and applicable ADRs |
| Dispatcher, registration, or typed inputs | [Command declarations](../command.go), [bindings](../binding.go), [invocation](../execute.go), and [ADR 0001](adr/0001-command-dispatch-and-inputs.md), refined by [ADR 0011](adr/0011-captured-declarations-and-installation.md) | | Dispatcher, registration, or typed inputs | [Command declarations](../command.go), [bindings](../binding.go), [invocation](../execute.go), and [ADR 0001](adr/0001-command-dispatch-and-inputs.md) |
| Backend integration | [Internal overview](internal/overview.md), [adapter implementation](../internal/cobra/parse.go), and [ADR 0002](adr/0002-cobra-backend.md) | | Backend integration | [Internal overview](internal/overview.md), [adapter implementation](../internal/cobra/parse.go), and [ADR 0002](adr/0002-public-facade-and-cli-backend.md) |
| Public facade or adapter boundaries | [Internal overview](internal/overview.md), [ADR 0003](adr/0003-public-facade.md) as scoped by [ADR 0010](adr/0010-optional-integration-modules.md), and the architecture policy | | Public facade or adapter boundaries | [Internal overview](internal/overview.md), [ADR 0002](adr/0002-public-facade-and-cli-backend.md), [ADR 0004](adr/0004-optional-integration-modules.md), and the architecture policy |
| Module ownership and public contracts | [Consumer guide](consumers/guide.md), [internal overview](internal/overview.md), [core GoDoc](../doc.go), [ckconfig GoDoc](../integrations/ckconfig/doc.go), [ckprompt GoDoc](../integrations/ckprompt/doc.go), and applicable ADRs | | Module ownership and public contracts | [Consumer guide](consumers/guide.md), [internal overview](internal/overview.md), [core GoDoc](../doc.go), [ckconfig GoDoc](../integrations/ckconfig/doc.go), [ckprompt GoDoc](../integrations/ckprompt/doc.go), and applicable ADRs |
| Deferred capabilities and proposals | [Deferred work](roadmap/deferred.md) | | Unfinished work and capability proposals | [Future work](roadmap/future.md) |
| Completed planning and implementation history | [Completed dependency upgrades, PromptKit validation, discovery, smoke testing, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/a6278a6d9f940948ec7745955d745523aaff0734/docs/roadmap), [earlier ConfigKit and PromptKit adoption and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap), [earlier retired records](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap), and [completed module implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/bb2528532b78d30e52f5082e491a5e1fd9b0fb99/docs/roadmap) in Git history | | Completed planning and implementation history | [Completed dependency upgrades, PromptKit validation, discovery, smoke testing, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/a6278a6d9f940948ec7745955d745523aaff0734/docs/roadmap), [earlier ConfigKit and PromptKit adoption and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap), [earlier retired records](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap), and [completed module implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/bb2528532b78d30e52f5082e491a5e1fd9b0fb99/docs/roadmap) in Git history |
| Release tooling | [Maintainer release procedure](release.md) | | Release tooling | [Maintainer release procedure](release.md) |
| Performance maintenance | [Internal overview](internal/overview.md), existing benchmarks, and [deferred efficiency work](roadmap/deferred.md#possible-further-efficiency-work) | | Performance maintenance | [Internal overview](internal/overview.md), existing benchmarks, and [possible further efficiency work](roadmap/future.md#possible-further-efficiency-work) |
| Licensing or incorporated material | [Project grant](../COPYRIGHT), [license texts](../LICENSE), and [attribution](oss/attribution.md) | | Licensing or incorporated material | [Project grant](../COPYRIGHT), [license texts](../LICENSE), and [attribution](oss/attribution.md) |
| Policy provenance | [Policy provenance](policy/meta.md) | | Policy provenance | [Policy provenance](policy/meta.md) |
@@ -72,11 +72,13 @@ Read all applicable rows for cross-cutting work. Go declarations and GoDoc own
current exported contracts; the CLI reference owns operator behavior. The current exported contracts; the CLI reference owns operator behavior. The
[maintainer release procedure](release.md) owns current release operations. [maintainer release procedure](release.md) owns current release operations.
Completed roadmaps and implementation evidence remain available in Git history, Completed roadmaps and implementation evidence remain available in Git history,
including [application-owned alias planning, implementation, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/f42aefe02d84e154855ac922173e6f367180929f/docs/roadmap) including [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). and [PromptKit requirements implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/40499a43608bf60d1104cd8ee24caf18ac0e9e66/docs/roadmap).
These records are not assignments to restart completed work. The These records are not assignments to restart completed work. The
[deferred-work record](roadmap/deferred.md) preserves future capability targets [future work record](roadmap/future.md) preserves unfinished work and unselected
and unselected proposals separately from current contracts. proposals separately from current contracts.
## Documentation validation ## Documentation validation
@@ -99,25 +101,21 @@ Sibling-checkout references in retired planning records are historical review
evidence, not build dependencies. Implemented contracts belong in CommandKit evidence, not build dependencies. Implemented contracts belong in CommandKit
under their canonical owners. under their canonical owners.
The root module does not traverse nested modules. During development, use a The root module does not traverse nested modules. Preserve the ignored `go.work`
local workspace for all three modules; it is ignored and must not be committed. and `go.work.sum`; use `GOWORK=off GOTOOLCHAIN=local` for dependency preparation
If a local `go.work` already exists, preserve it and add these module directories: 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.
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:
```sh ```sh
if [ -f go.work ]; then GOTOOLCHAIN=local go test ./...
go work use . ./integrations/ckconfig ./integrations/ckprompt GOTOOLCHAIN=local go test ./integrations/ckconfig/...
else GOTOOLCHAIN=local go test ./integrations/ckprompt/...
go work init . ./integrations/ckconfig ./integrations/ckprompt
fi
go test ./...
(cd integrations/ckconfig && go test ./...)
(cd integrations/ckprompt && go test ./...)
``` ```
The workspace checks package behavior. Workspace-off checks with each module's
own dependency graph are separate evidence. For unpublished CommandKit dependency
versions, the [completed integration validation record](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap/implementation.md#stage-12-validate-the-complete-end-state-and-finish)
describes the disposable module-file procedure used during implementation.
Format changed Go files with `gofmt`. The Format changed Go files with `gofmt`. The
[maintainer release procedure](release.md) owns candidate preparation and [maintainer release procedure](release.md) owns candidate preparation and
publication commands. publication commands.
@@ -136,30 +134,31 @@ or publishing.
## Current public examples ## Current public examples
The maintained consumers compile with their owning module suites and run without The maintained consumers run without external services using the aligned local
external services. The PromptKit smoke examples use local fixtures and the workspace. The PromptKit smoke examples use local fixtures and the example's
example's configured client: configured client:
```sh ```sh
go run ./examples/core workspace greet --count=0 --tag=first --tag second GOTOOLCHAIN=local go run ./examples/core workspace greet --count=0 --tag=first --tag second
(cd integrations/ckconfig/examples/simple && go run . config show) (cd integrations/ckconfig/examples/simple && GOTOOLCHAIN=local go run . config show)
(cd integrations/ckconfig/examples/simple && go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml) (cd integrations/ckconfig/examples/simple && GOTOOLCHAIN=local go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml)
(cd integrations/ckprompt/examples/composed && go run . config validate --scope all --campaign spring --limit=0) (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . config validate --scope all --campaign spring --limit=0)
(cd integrations/ckprompt/examples/composed && go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml) (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml)
(cd integrations/ckprompt/examples/composed && go run . config diff --scope promptkit --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring) (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . config diff --scope promptkit --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring)
(cd integrations/ckprompt/examples/composed && go run . promptkit list prompts --config fixtures/before.yml --campaign spring) (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit list prompts --config fixtures/before.yml --campaign spring)
(cd integrations/ckprompt/examples/composed && go run . promptkit list profiles --config fixtures/after.yml --campaign autumn --format yaml) (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit list profiles --config fixtures/after.yml --campaign autumn --format yaml)
(cd integrations/ckprompt/examples/composed && go run . promptkit list backends --namespace team) (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit list backends --namespace team)
(cd integrations/ckprompt/examples/composed && go run . promptkit list namespaces --kind prompt --kind profile) (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit list namespaces --kind prompt --kind profile)
(cd integrations/ckprompt/examples/composed && go run . promptkit list collections) (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit list collections)
(cd integrations/ckprompt/examples/composed && go run . promptkit show prompt briefing --campaign spring --format yaml) (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit show prompt briefing --campaign spring --format yaml)
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements show briefing --campaign spring) (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit show prepared briefing --campaign spring --input topic=sprint --profile spring --extra-params '{"campaign":"preview"}')
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml) (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit requirements show briefing --campaign spring)
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt) (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml)
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt --var mode=full --input notes=fixtures/notes.txt --format yaml) (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit requirements validate briefing --campaign spring --input-file topic=fixtures/topic.txt)
(cd integrations/ckprompt/examples/composed && go run . promptkit test reachability --campaign spring --prompt briefing --profile spring) (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit requirements validate briefing --campaign spring --input-file topic=fixtures/topic.txt --var mode=full --input-file notes=fixtures/notes.txt --format yaml)
(cd integrations/ckprompt/examples/composed && go run . promptkit test all --campaign spring --prompt briefing --profile spring) (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit test reachability --campaign spring --prompt briefing --profile spring)
(cd integrations/ckprompt/examples/composed && go run . session restore session-42 --campaign spring) (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit test all --campaign spring --prompt briefing --profile spring)
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . session restore session-42 --campaign spring)
``` ```
The [consumer guide](consumers/guide.md) explains how their public API usage The [consumer guide](consumers/guide.md) explains how their public API usage

View File

@@ -14,7 +14,7 @@ The core, ckconfig, and ckprompt modules declare
The root facade owns captured canonical command declarations, application-owned The root facade owns captured canonical command declarations, application-owned
routing rules, binding conversion, invocation coordination, the per-App routing rules, binding conversion, invocation coordination, the per-App
registries, standard help/version commands, invocation reporting, and outcome registries, standard help/version commands, shared report rendering, invocation reporting, and outcome
finalization. finalization.
Its dependency graph has no ConfigKit or PromptKit integration dependency. Its dependency graph has no ConfigKit or PromptKit integration dependency.
Applications provide typed callbacks and install routing rules alongside the Applications provide typed callbacks and install routing rules alongside the
@@ -90,8 +90,9 @@ acquisitions or comparison callback.
`integrations/ckprompt` owns PromptKit engine loading, configuration-scope `integrations/ckprompt` owns PromptKit engine loading, configuration-scope
adaptation and native comparison, plus optional prompt/profile/backend/namespace/ adaptation and native comparison, plus optional prompt/profile/backend/namespace/
collection catalogs, named inspection, native requirements discovery, collection catalogs, named inspection, native requirements discovery,
concrete request validation, and native smoke testing. Validation and smoke concrete request validation, prepared-request preview, and native smoke testing.
testing delegate to PromptKit and are independently opted into a suite. Smoke Validation, preview, and smoke testing delegate to PromptKit and are independently
opted into a suite. Smoke
operations use the application's native engine and its configured client or operations use the application's native engine and its configured client or
probes. The integration uses public core and ckconfig contracts plus PromptKit's probes. The integration uses public core and ckconfig contracts plus PromptKit's
public APIs. It does not add a configuration family when its declaration is public APIs. It does not add a configuration family when its declaration is

View File

@@ -119,8 +119,9 @@ Material: `docs/policy/meta.md`, `docs/policy/agents.md`,
`docs/policy/documentation.md`, `docs/policy/release.md`, and `docs/policy/documentation.md`, `docs/policy/release.md`, and
`docs/policy/oss.md`; the root `AGENTS.md` orientation after the knowledge-graph `docs/policy/oss.md`; the root `AGENTS.md` orientation after the knowledge-graph
instructions; and the Policy Reading Guide in `docs/development.md`, adapted for instructions; and the Policy Reading Guide in `docs/development.md`, adapted for
this repository. The release policy uses baseline this repository. The documentation policy uses baseline
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses baseline `beb5f191470a9daef2383981a6bfd02232490ab4`; the release policy uses
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses
`d84a38ebab26f048cafbe59a2246700c35d29843`. `d84a38ebab26f048cafbe59a2246700c35d29843`.
Copyright (C) 2026 Eric Rakestraw Copyright (C) 2026 Eric Rakestraw
@@ -131,12 +132,16 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) | [COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) | [LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) | [COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE). [LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843). [Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348). [Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4).
## ReleaseKit ## ReleaseKit
Selected module: `go.maximumdirect.net/releasekit` at `v0.9.0` (selected Go tool; not a runtime import). Selected module: `go.maximumdirect.net/releasekit` at `v0.9.0` (selected Go tool; not a runtime import).

View File

@@ -56,7 +56,7 @@ invariant, not from the convenience of the selected implementation. Explain the
specific failure it prevents and why a less restrictive implementation cannot specific failure it prevents and why a less restrictive implementation cannot
satisfy the contract. satisfy the contract.
The [deferred-work record](../roadmap/deferred.md) distinguishes future proposals The [future work record](../roadmap/future.md) distinguishes future proposals
from accepted contracts. Do not approve proposed commands, grammar, statuses, or from accepted contracts. Do not approve proposed commands, grammar, statuses, or
illustrative API declarations illustrative API declarations
merely by repeating them in implementation or tests. Cobra/pflag defaults do not merely by repeating them in implementation or tests. Cobra/pflag defaults do not

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 1.3 **Base Policy Version:** 1.3
**Last Updated**: 2026-10-26 **Last Updated**: 2026-11-06
**Upstream Source:** [architecture.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md) **Upstream Source:** [architecture.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
@@ -23,6 +23,22 @@ Exact exported contracts belong in Go declarations and GoDoc; implemented
operator behavior belongs in the CLI reference. Do not describe planned packages operator behavior belongs in the CLI reference. Do not describe planned packages
or APIs as existing implementation. or APIs as existing implementation.
## Ownership Boundaries
Applications and integration handlers choose the operation and report content;
CommandKit core supplies the shared CLI mechanics. Integrated libraries retain
ownership of their domain behavior. These boundaries govern current and planned
capabilities; they do not imply that a planned API is implemented.
| Area | CommandKit Owns | CommandKit Does Not Own |
| --- | --- | --- |
| **CLI output — core** | Format selection, serialization, template execution, and output delivery | Choosing report content or detail level; authoring application-specific text templates |
| **PromptKit/ConfigKit integrations** | Standard commands, flags, and report content that expose useful operator tasks through upstream public APIs | Helpers or convenience interfaces for application-internal use of upstream APIs |
| **CLI inputs** | CLI syntax, parsing, typed bindings, presence tracking, and usage diagnostics | Application business rules or upstream domain-validation semantics |
| **Upstream behavior** | Selecting and invoking supported capabilities, adapting their results, and preserving their meaning | Reimplementing upstream configuration resolution, prompt preparation, validation, or other domain algorithms |
| **Outcomes and errors — core** | Carrying and presenting selected outcomes; handling CommandKit's own parsing, rendering, and delivery failures | Deciding application recovery policy or terminating the process |
| **Application lifecycle** | Command dispatch and invocation reporting lifecycle | Application startup, engine construction, credential provisioning, signal handling, or long-lived application state |
## Success Criteria ## Success Criteria
Evaluate designs and adoption against both criteria: Evaluate designs and adoption against both criteria:
@@ -42,12 +58,29 @@ Replacing parsing and dispatch plumbing with equally repetitive registration or
callback bookkeeping does not satisfy infrastructure reduction. Keep enough callback bookkeeping does not satisfy infrastructure reduction. Keep enough
flexibility to express genuine application requirements within one command model. flexibility to express genuine application requirements within one command model.
## Go Types And Facilities
CommandKit is a Go library for Go applications. Its APIs and implementation use
ordinary Go types, language features, and standard-library facilities. Their
documented semantics and representational limits are accepted design constraints.
CommandKit does not introduce substitute type systems or compatibility machinery
merely to circumvent those limits.
Ordinary parsing, application-defined domain types, and necessary serialization
adapters remain appropriate. Preserve values that the selected Go types can
represent; accepting their limits does not justify accidental rounding or
truncation during conversion. Keep CLI boundary conversions small and focused,
without turning them into general-purpose type-conversion frameworks.
Specific numeric parsing and report serialization contracts remain separate.
A CLI parser's selected numeric types do not establish a blanket restriction on
application-supplied values or silently redefine their serialization contracts.
GoDoc and CLI documentation own implemented behavior; roadmaps own pending work.
## Command And Application Ownership ## Command And Application Ownership
[ADR 0001](../adr/0001-command-dispatch-and-inputs.md) records the selected [ADR 0001](../adr/0001-command-dispatch-and-inputs.md) records the selected
CommandKit-owned dispatcher, per-instance registry, and typed-input model. dispatcher, captured declarations, installation boundary, and typed-input model.
[ADR 0011](../adr/0011-captured-declarations-and-installation.md) refines declaration
capture, installation, optional-family ownership, and invocation input lifetimes.
CommandKit owns dispatch, common parsing, presence tracking, help, shared syntax CommandKit owns dispatch, common parsing, presence tracking, help, shared syntax
diagnostics, and the handlers and CLI contracts of its standard families. diagnostics, and the handlers and CLI contracts of its standard families.
@@ -68,12 +101,12 @@ Applications own their executable entry points, context creation, signals,
stream selection, runtime construction, and final process exit. CommandKit stream selection, runtime construction, and final process exit. CommandKit
returns invocation outcomes and must not terminate the process. Its common returns invocation outcomes and must not terminate the process. Its common
mechanics apply to custom commands without taking ownership of their domain mechanics apply to custom commands without taking ownership of their domain
semantics or success-report formats. semantics or report content.
CommandKit's grammar applies to commands using its dispatcher and binding APIs. CommandKit's grammar applies to commands using its dispatcher and binding APIs.
Its documentation recommends the same grammar and vocabulary for application-owned Its documentation recommends the same grammar and vocabulary for application-owned
commands, including those outside its dispatcher, under commands, including those outside its dispatcher. The
[ADR 0006](../adr/0006-command-grammar.md). [CLI reference](../cli.md#registered-command-syntax) owns implemented grammar.
Use explicit instance construction and composable command registration, not Use explicit instance construction and composable command registration, not
package-global or import-time registries. Applications and integrations supply package-global or import-time registries. Applications and integrations supply
@@ -126,8 +159,25 @@ application-owned.
Use one reporting facility per invocation for command results, immediate messages, Use one reporting facility per invocation for command results, immediate messages,
and deferred messages. Core owns shared visibility, formatting, delivery, and and deferred messages. Core owns shared visibility, formatting, delivery, and
finalization. Applications and integrations choose their report content and finalization. Applications and integrations choose their report content and
messages; callers choose the destinations. [ADR 0014](../adr/0014-invocation-reporting.md) messages; callers choose the destinations.
extends the outcome and delivery model in [ADR 0005](../adr/0005-invocation-outcomes.md). [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).
For that shared report handoff, core owns format selection, serialization,
template execution, and delivery. Applications and integrations select logical
report content and detail level, and author any report-specific text templates.
Changing format must not implicitly change that content or detail level.
Representations and encoders are core implementation choices; commands should
not maintain parallel rendering pipelines or a separate report tree per format.
Use ordinary Go report values and thin projections where upstream data needs
adaptation. Prefer standard serialization and template facilities to a new public
document model, tag convention, or template language. Shared rendering serves
both invocation reporting and direct writer use, while invocation delivery keeps
the existing visibility, outcome, and presentation-failure lifecycle. Exact
serialization conventions, format options, and API signatures belong in the
roadmap until implemented, then in their API and CLI contracts.
Applications decide how to respond to domain-specific errors. CommandKit may Applications decide how to respond to domain-specific errors. CommandKit may
provide shared helpers for carrying, inspecting, and presenting errors while provide shared helpers for carrying, inspecting, and presenting errors while
@@ -137,10 +187,11 @@ handles its own errors, including routing, parsing, and presentation failures.
Preserve application-defined domain classifications and supplied error identity. Preserve application-defined domain classifications and supplied error identity.
Report rendering and delivery failures separately from the command's explanation Report rendering and delivery failures separately from the command's explanation
and cause, applying the outcome rules in ADR 0005 and ADR 0014. Intentionally and cause, applying the [CLI reporting contract](../cli.md#presentation-failures).
suppressed output is not a delivery failure. Present terminal failure explanations Intentionally suppressed output is not a delivery failure. Present terminal
after normal reporting finishes, avoid duplicate presentation through core's own failure explanations after normal reporting finishes, avoid duplicate presentation
paths, and retain the explanation and any supplied cause in the returned outcome. through core's own paths, and retain the explanation and any supplied cause in
the returned outcome.
Keep reporting state scoped to the invocation's presentation and returned outcome. Keep reporting state scoped to the invocation's presentation and returned outcome.
Logging and message history remain application concerns. Logging and message history remain application concerns.
@@ -162,12 +213,14 @@ Factories need not run after a failure and remain uninvoked during construction,
installation, help, version, and scope discovery. installation, help, version, and scope discovery.
For two-selection configuration comparison, prepare and check each side's input For two-selection configuration comparison, prepare and check each side's input
separately with its scope identity and selection, applying the same applicable separately with its scope identity and selection, applying only the selectors
converted selector values to both. Finish both checks before initializing shared supplied for that side. Finish both checks before initializing shared
state once and invoking the integration-owned pair provider under state once and invoking the integration-owned pair provider. The
[ADR 0012](../adr/0012-typed-configuration-comparison-pairs.md). Required provider [consumer guide](../consumers/guide.md#configkit-scopes) and linked GoDoc own the
wiring follows declared capabilities; a comparison-only scope does not require provider lifecycle; the [CLI reference](../cli.md#compare-configuration-selections)
an unrelated ordinary acquisition callback. owns current selector behavior. Required provider wiring follows declared
capabilities; a comparison-only scope does not require an unrelated ordinary
acquisition callback.
Omitted input and state factories supply the respective type's zero value, Omitted input and state factories supply the respective type's zero value,
including nil for pointers. Caller factories and callbacks retain responsibility including nil for pointers. Caller factories and callbacks retain responsibility
@@ -187,8 +240,8 @@ composition does not create a second parser or a raw-argument handler model.
CommandKit keeps parsed values, collections, and presence from one invocation out CommandKit keeps parsed values, collections, and presence from one invocation out
of subsequent runs. Storage allocation and safe reuse are implementation choices; of subsequent runs. Storage allocation and safe reuse are implementation choices;
rebuilding backend state is the initial strategy under the [internal overview](../internal/overview.md#internalcobra) describes the
[ADR 0002](../adr/0002-cobra-backend.md). Caller-owned closures and runtime state current backend lifecycle. Caller-owned closures and runtime state
retain their own lifecycle. Concurrent dispatch is a separate contract. retain their own lifecycle. Concurrent dispatch is a separate contract.
Applications may interpret parsed domain values and provide domain value parsers Applications may interpret parsed domain values and provide domain value parsers
@@ -197,16 +250,18 @@ Keep input declarations available without application initialization.
## Public Facade And Dependency Direction ## Public Facade And Dependency Direction
[ADR 0010](../adr/0010-optional-integration-modules.md) scopes the root [ADR 0004](../adr/0004-optional-integration-modules.md) selects separate optional
`commandkit` facade to the core module and selects separate optional integration integration modules with their own public package contracts. The facade and
modules with their own public package contracts. It partially supersedes backend isolation principles in
[ADR 0003](../adr/0003-public-facade.md), whose remaining facade and internal [ADR 0002](../adr/0002-public-facade-and-cli-backend.md) apply within those module
adapter principles apply to the core and integrations alike. boundaries.
The core owns command declarations, registration, bindings, dispatch, help/version, The core owns command declarations, registration, bindings, dispatch, help/version,
shared syntax, diagnostics, and outcomes. Each integration owns its standard shared syntax, reporting mechanics, diagnostics, and outcomes. Each integration
handlers, provider contracts, reports, adapters, and integration-specific public owns its standard handlers, provider contracts, report content, adapters, and
types. Dependencies point from integrations toward the core; an integration may integration-specific public types. Integrations expose operator-facing CLI tasks;
they do not provide convenience interfaces for application-internal upstream APIs.
Dependencies point from integrations toward the core; an integration may
depend on another integration when its responsibility requires that relationship. depend on another integration when its responsibility requires that relationship.
Document those dependencies. Core public signatures, implementation imports, and Document those dependencies. Core public signatures, implementation imports, and
module requirements remain independent of optional integration libraries, module requirements remain independent of optional integration libraries,
@@ -241,11 +296,11 @@ without exporting private assembly or backend structures.
## Backend And Library Integration ## Backend And Library Integration
[ADR 0002](../adr/0002-cobra-backend.md) selects Cobra with pflag as the sole [ADR 0002](../adr/0002-public-facade-and-cli-backend.md) selects Cobra with pflag
initial backend. Their types and operations stay inside an internal adapter that as the sole initial backend. Their types and operations stay inside an internal
translates CommandKit-owned definitions into backend machinery. Registry logic, adapter that translates CommandKit-owned definitions into backend machinery.
standard handlers, and shared internal representations remain independent of Registry logic, standard handlers, and shared internal representations remain
backend-specific structures. independent of backend-specific structures.
Reuse backend routing and parsing mechanisms, adapting them to selected Reuse backend routing and parsing mechanisms, adapting them to selected
CommandKit semantics. Do not replicate Cobra's entire API through renamed CommandKit semantics. Do not replicate Cobra's entire API through renamed
@@ -253,9 +308,9 @@ wrappers. Introduce narrow internal interfaces where actual interactions justify
them; a public backend selection or plugin framework is not part of the design. them; a public backend selection or plugin framework is not part of the design.
ConfigKit and PromptKit own configuration composition, provenance, inspection ConfigKit and PromptKit own configuration composition, provenance, inspection
semantics, secret classification, and native rendering. Applications provide semantics, secret classification, and their native inspection representations.
schemas, acquisition, domain resolution and validation, and construction of Applications provide schemas, acquisition, domain resolution and validation,
inspection providers. CommandKit exposes those capabilities through supported and construction of inspection providers. CommandKit exposes those capabilities through supported
upstream APIs. The public contract of the adopted library version governs their upstream APIs. The public contract of the adopted library version governs their
meaning and disclosure rules. When CommandKit adopts an upstream revision, meaning and disclosure rules. When CommandKit adopts an upstream revision,
changes to those semantics flow through its integration. Adaptation and changes to those semantics flow through its integration. Adaptation and
@@ -275,15 +330,26 @@ packages or depend on adjacent checkouts.
Use narrow integration contracts, reusing upstream public types where useful and Use narrow integration contracts, reusing upstream public types where useful and
introducing CommandKit-owned abstractions where they simplify integration. introducing CommandKit-owned abstractions where they simplify integration.
Concrete integration needs guide provider representations under Concrete integration needs guide provider representations under
[ADR 0009](../adr/0009-configuration-scopes.md). [ADR 0003](../adr/0003-configuration-scopes.md).
Native report writers and safe inspection data preserve owning-library semantics. Use upstream safe inspection data to preserve owning-library meaning and
Loading all selected views before report output is a `config show` decision under disclosure rules under [ADR 0004](../adr/0004-optional-integration-modules.md).
[ADR 0007](../adr/0007-native-yaml-reports.md); other inspection operations define Do not reconstruct inspection reports from mutable runtime configuration or source
their own output timing. Semantic comparison exposes the adopted upstream files when the upstream inspection API supplies captured, safe evidence. Explicit
comparison API, carrying its results and relevant metadata through the provider adaptation into ordinary report values must preserve the selected evidence and
boundary. CommandKit owns report presentation under report conversion failures rather than silently changing its meaning.
[ADR 0008](../adr/0008-semantic-configuration-diff.md).
CommandKit parses and forwards supplied CLI selections and overrides through the
declared acquisition boundary. Upstream libraries retain path validity,
resolution, precedence, conflict handling, and domain validation. Preserve their
errors instead of inventing a parallel override policy or retrying with altered
values. Application loading and application-owned overrides retain their own
responsibilities.
The [CLI reference](../cli.md#show-effective-configuration) owns current report
formats and operation-specific preparation and delivery timing. Semantic
comparison exposes the adopted upstream comparison API and carries its evidence
through the provider boundary; it does not introduce a CommandKit equality engine.
## Scope And Non-goals ## Scope And Non-goals
@@ -294,7 +360,7 @@ recommended for application-owned commands; their names and domain behavior
remain application choices. remain application choices.
Accepted ADRs and current CLI/API owners define selected contracts; the Accepted ADRs and current CLI/API owners define selected contracts; the
[deferred-work record](../roadmap/deferred.md) preserves remaining proposals. [future work record](../roadmap/future.md) preserves remaining proposals.
Backend defaults and incidental consumer behavior do not establish new contracts. Backend defaults and incidental consumer behavior do not establish new contracts.
A replacement backend must preserve published CLI behavior or follow deliberate compatibility A replacement backend must preserve published CLI behavior or follow deliberate compatibility
changes under the release policy. changes under the release policy.

View File

@@ -2,9 +2,9 @@
**Base Policy Version:** 1.11 **Base Policy Version:** 1.11
**Last Updated**: 2026-10-10 **Last Updated**: 2026-11-07
**Upstream Source:** [documentation.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md) **Upstream Source:** [documentation.md](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4/docs/policy/documentation.md)
## Purpose ## Purpose
@@ -86,7 +86,7 @@ secret values.
| Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. | | Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
| Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. | | Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
| Architectural decision records | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. | | Architectural decision records | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |
| Future work and implementation status | `docs/roadmap/` | Proposed, accepted, deferred, or rejected work; implementation status, sequencing, and task breakdowns. | Implemented behavior reference and architectural decision rationale. | | Future work and implementation status | `docs/roadmap/` | Proposed, accepted, or deferred work; implementation status, sequencing, and task breakdowns. | Implemented behavior reference and architectural decision rationale. |
| Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. | | Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. |
| Complete copyable artifacts | `examples/` | Maintained configuration, inputs, and other copyable or runnable files. | Field-by-field reference, command reference, prose explanation. | | Complete copyable artifacts | `examples/` | Maintained configuration, inputs, and other copyable or runnable files. | Field-by-field reference, command reference, prose explanation. |
@@ -147,14 +147,16 @@ mechanisms only.
## Architecture Decision Records ## Architecture Decision Records
ADRs record durable architectural decisions: responsibility boundaries, ADRs record major architectural decisions, such as responsibility and ownership
ownership, significant constraints, and the reasons for choosing them. A settled boundaries, large-scale architectural features, or significant architectural
implementation or presentation choice does not automatically belong in an ADR. invariants, and the reasons for choosing them. Routine choices, implementation
Ask whether changing the detail would alter the architecture or represent or presentation decisions, and rejected suggestions do not warrant an ADR
reasonable evolution within it. Details that can evolve within the chosen unless they otherwise represent a significant architectural decision. Ask
architecture belong in API or CLI contracts, implementation documentation, or whether changing the detail would alter the architecture or represent reasonable
roadmaps. Describe upstream behavior as context without turning it into an evolution within it. Details that can evolve within the chosen architecture
independent project guarantee. belong in API or CLI contracts, implementation documentation, or roadmaps.
Describe upstream behavior as context without turning it into an independent
project guarantee.
Use sequentially numbered ADR filenames such as Use sequentially numbered ADR filenames such as
`0001-record-architecture-decisions.md`. Follow the lightweight Nygard format: `0001-record-architecture-decisions.md`. Follow the lightweight Nygard format:
@@ -169,8 +171,8 @@ Use sequentially numbered ADR filenames such as
Treat the decision content of an accepted ADR as immutable. When a decision Treat the decision content of an accepted ADR as immutable. When a decision
changes, create a new ADR and update the earlier ADR's status to superseded. changes, create a new ADR and update the earlier ADR's status to superseded.
Rejected architectural alternatives belong in the ADR; rejected product ideas
belong in the roadmap. Roadmaps should preserve plausible future work, not rejected product ideas.
## Maintenance ## Maintenance

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 1.3 **Base Policy Version:** 1.3
**Last Updated**: 2026-10-17 **Last Updated**: 2026-11-07
**Upstream Source:** [testing.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/testing.md) **Upstream Source:** [testing.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/testing.md)
@@ -63,16 +63,19 @@ implemented:
- Help and command discovery without provider loading or runtime construction. - Help and command discovery without provider loading or runtime construction.
- Provider failures, cancellation, output errors, and preservation of application - Provider failures, cancellation, output errors, and preservation of application
outcomes and public error identity. outcomes and public error identity.
- Selected inspection/report contracts, including native YAML and document order - Selected inspection/report contracts, including safe scalar preservation and
for `config show --scope all`. scope-keyed aggregation for `config show --scope all`.
- Public type ownership and the internal Cobra/pflag import boundary selected by - Public type ownership and the internal Cobra/pflag import boundary selected by
[ADR 0003](../adr/0003-public-facade.md). [ADR 0002](../adr/0002-public-facade-and-cli-backend.md).
Use external-package consumer tests for facade semantics and real backend Use external-package consumer tests for facade semantics and real backend
integration where correctness depends on that interaction. Internal package integration where correctness depends on that interaction. Internal package
tests own focused mechanisms. tests own focused mechanisms.
Boundary checks protect selected architectural constraints; behavioral evidence Boundary checks protect selected architectural constraints; behavioral evidence
protects invocation semantics and ownership through conversions. protects invocation semantics and ownership through conversions.
For shared reports, compare logical values and selected evidence rather than
YAML formatting or mapping order. Preserve sequence ordering only where the
relevant contract gives it meaning.
Do not infer concurrent-dispatch support from fresh invocation state. Test Do not infer concurrent-dispatch support from fresh invocation state. Test
concurrency guarantees only if deliberately selected. Coverage targets, race concurrency guarantees only if deliberately selected. Coverage targets, race

View File

@@ -35,20 +35,21 @@ module metadata.
## Release toolchain ## Release toolchain
The selected release toolchain is **Go 1.26.8**. Each module declares a consumer The selected release toolchain is **Go 1.27.0**. Core declares a
minimum of **Go 1.26.0**. Maintainers use Go 1.26.8 for dependency preparation, consumer minimum of **Go 1.26.0**; both integrations require **Go 1.27.0**. Maintainers
candidate validation, and ReleaseKit execution. The use Go 1.27.0 for dependency preparation, candidate validation, and ReleaseKit
execution. The
[consumer guide](consumers/guide.md#go-compatibility) and [consumer guide](consumers/guide.md#go-compatibility) and
[release policy](policy/release.md#consumer-minimum-and-release-toolchain) [release policy](policy/release.md#consumer-minimum-and-release-toolchain)
describe these selections. describe these selections.
Select Go 1.26.8 on `PATH`; the launcher uses `GOTOOLCHAIN=local`. If needed, Select Go 1.27.0 on `PATH`; the launcher uses `GOTOOLCHAIN=local`. If needed,
install it with the standard Go tools: install it with the standard Go tools:
```sh ```sh
GOWORK=off GOTOOLCHAIN=local go install golang.org/dl/go1.26.8@latest GOWORK=off GOTOOLCHAIN=local go install golang.org/dl/go1.27.0@latest
go1.26.8 download go1.27.0 download
export PATH="$(go1.26.8 env GOROOT)/bin:$PATH" export PATH="$(go1.27.0 env GOROOT)/bin:$PATH"
hash -r hash -r
GOWORK=off GOTOOLCHAIN=local go version GOWORK=off GOTOOLCHAIN=local go version
``` ```

32
docs/releases/v0.4.0.md Normal file
View File

@@ -0,0 +1,32 @@
# CommandKit v0.4.0
CommandKit v0.4.0 adds shared structured reporting and reusable CLI input
facilities. Applications and integrations can submit one report value for
automatic or explicit text, YAML, and JSON output. Core selects the format,
renders optional standard text templates, and records presentation failures
through the existing invocation lifecycle. The writer API exposes the same
rendering behavior outside an invocation.
Report values use JSON serialization conventions for structured output. The
safe YAML conversion helper accepts upstream inspection documents without
reconstructing configuration or treating ordinary strings as YAML. Public CLI
JSON parsing and ordered flag occurrences support structured inputs and
interleaved bindings. Applications can reuse and rename flag bindings while
preserving their typed parsing and presence behavior.
This pre-1.0 minor release reserves the long flag `--format` for core and
changes standard version output to use shared reporting. Applications that
declare a local `--format` must remove or rename it; an application alias may
target the core flag. Integration report framing and format availability have
changed with the shared API; select compatible integration releases as they
become available. See the [CLI reference](../cli.md#reporting) and
[consumer guide](../consumers/guide.md#invocation-reporting) for current
contracts and examples.
Core continues to declare a Go 1.26.0 consumer minimum. The maintainer release
toolchain is Go 1.27.0, and core has no ConfigKit or PromptKit integration
dependency.
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.4.0/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.4.0/LICENSE) |
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.4.0/docs/oss/attribution.md)

View File

@@ -1,82 +0,0 @@
# Deferred work
Status: retained future work and unselected proposals; no implementation schedule.
## Purpose and authority
Completed initial delivery, performance, ReleaseKit upgrade, and remediation
records have been retired. This document preserves remaining deferred
capabilities and unselected ideas under the
[release policy](../policy/release.md#release-documentation-and-preparation).
The original records remain available in
[Git history](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap).
Accepted [ADRs](../adr/) and the [architecture policy](../policy/architecture.md)
govern any future implementation. The [CLI reference](../cli.md), public GoDoc,
and [consumer guide](../consumers/guide.md) own implemented behavior. Retaining
an idea here does not select its syntax, API, priority, or delivery date.
## Deferred capability targets
### Comparison extensions
Independent per-side domain selectors and an optional assertion mode remain
unselected extensions to the delivered ConfigKit comparison. Consider them only
for a concrete consumer need and a deliberate API/behavior decision under the
relevant ADRs.
### Alias completion and discovery
The delivered [shell completion feature](../cli.md#shell-completion) provides
canonical suggestions and alias-aware interpretation. Potential future work
includes suggesting command and flag alias spellings and valid continuations of
alias-only prefixes. For example, completion could suggest `inspect` after
`app legacy ` when `legacy inspect` is an installed alias.
Alias discovery is outside the initial scope. This deferral does not establish a
permanent canonical-only completion contract or prohibit future alias discovery.
A later design should determine how alias suggestions are presented alongside
canonical suggestions while ensuring inserted continuations form valid next
steps. No delivery date or final API is selected here.
## Other deferred proposals
These capabilities remain outside the delivered scope and require a concrete
operator or consumer need and an appropriate ownership/behavior decision:
- JSON reports and output-file management or reloadable export.
- Shell completion extensions, including alias discovery described above and
dynamic discovery of configured or remote values.
- Configuration mutation, generic value assignment, and exhaustive field-origin
inventories.
- Credential diagnostics beyond PromptKit's native smoke checks and additional
live provider probes.
- Additional component identity registration and domain artifact catalogs.
- Additional library families, including possible FeedKit integration; families
can define suitable verbs without requiring comparison or configuration scopes.
- Backend selection or replacement, a plugin framework, general dependency
injection, and concurrent-dispatch guarantees.
Execution, rendering, serving, and session operations remain application-owned.
Shared vocabulary is guidance under [ADR 0006](../adr/0006-command-grammar.md).
Backend replacement remains possible behind the accepted facade, subject to the
published API/CLI contracts and deliberate compatibility decisions.
## Possible further efficiency work
Selected-scope memoization and selector-membership indexes were deferred pending
measurements that justify their bookkeeping. Mutable backend reuse across
invocations, segmented aggregate YAML delivery, provider caching, consumer-loader
redesign, and a replacement backend were outside the completed performance
target. Reconsider them only with concrete motivation and review of ownership,
invocation state, native reports, and the affected contracts. Retained measurement
records are historical evidence, not performance guarantees or release gates.
## Consumer adoption
Consumer migrations remain separate from library delivery. Applications can
replace duplicated standard handlers and routing, then update their CLI docs
and subprocess consumers for intentional changes while retaining domain commands.
No migration order or claim about individual applications' adoption is recorded
here; the [consumer guide](../consumers/guide.md) and maintained examples provide
integration guidance.

221
docs/roadmap/future.md Normal file
View File

@@ -0,0 +1,221 @@
# Future work
Status: pending release and integration work, plus future capability proposals.
No delivery dates are selected here.
## Purpose and authority
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 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 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).
[Earlier retired records](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap)
also remain in Git history. Completed records are not assignments to restart work.
Accepted [ADRs](../adr/) and the [architecture policy](../policy/architecture.md)
govern any future implementation. The [CLI reference](../cli.md), public GoDoc,
and [consumer guide](../consumers/guide.md) own implemented behavior. Retaining
an idea here does not select its syntax, API, priority, or delivery date.
## Pending release and integration work
### ckprompt release
The PromptKit v0.18.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
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
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.
## 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
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.
Discovery, import defaults, collection identities, and field-level resolution
recording remain available through application loaders. Discovery tracing,
resource-context inspection, and secrets-directory bootstrap commands remain
deferred. Acquired source records do not provide a complete trace of rejected
discovery candidates; bootstrap and resource contexts primarily serve
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.
- **Output-contract override flags.** Deferred from the current adoption. A
possible future operator task is validating or previewing one prompt with a
replacement output contract without editing its definition. PromptKit uses
whole-contract replacement; a complete-contract file option was discussed,
but no flag spelling or file format is selected. Existing configuration and
application builders remain available. Revisit when the standard `promptkit`
commands have a demonstrated need for this workflow.
- **Appended-message flags.** Deferred from the current adoption. The potential
operator task is reproducing a single request with application-added messages
through `promptkit show prepared` or `promptkit requirements validate`, to
inspect roles, ordering, and content or check preparation. These commands do
not test model responses or application workflows. Appended messages follow
definition messages in supplied order; they are already rendered and do not
satisfy missing named prompt inputs. Revisit when request reproduction is a
demonstrated operator requirement. An ordered message-list file is a candidate,
but no flag syntax, file format, or supported-role selection is decided.
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
canonical suggestions and alias-aware interpretation. Potential future work
includes suggesting command and flag alias spellings and valid continuations of
alias-only prefixes. For example, completion could suggest `inspect` after
`app legacy ` when `legacy inspect` is an installed alias.
Alias discovery is outside the initial scope. This deferral does not establish a
permanent canonical-only completion contract or prohibit future alias discovery.
A later design should determine how alias suggestions are presented alongside
canonical suggestions while ensuring inserted continuations form valid next
steps. No delivery date or final API is selected here.
### 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.
Error-conversion helpers such as `WarnError` remain deferred pending demonstrated
value; applications can already use `report.Warn(err.Error())`. Terminal redraw
and progress-bar presentation were outside the completed reporting scope and
remain unselected. Whole-run buffering to make failed runs show only errors
was also outside that scope. Any later extension needs a deliberate API and
presentation decision rather than assuming those behaviors are already provided.
### Release workflow extensions
Historical hosted-page backfilling and automated CI delivery remain separate
future choices. The current local page-only delivery workflow is implemented;
these extensions are not prerequisites for using it. The release procedure owns
delivery, immutable content, and recovery behavior.
Additional validation options, including race testing, full vet, compilation
matrices, size limits, and more forbidden-file paths, can be considered for
demonstrated needs. None is selected by retaining it here, and the existing
candidate checks do not become general prohibitions on development workspaces.
## Other future proposals
These capabilities remain outside the delivered scope and require a concrete
operator or consumer need and an appropriate ownership/behavior decision:
- Additional report renderers as described under [reporting extensions](#reporting-extensions),
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).
- Credential diagnostics beyond PromptKit's native smoke checks and additional
live provider probes.
- Additional component identity registration and domain artifact catalogs.
- Additional library families, including possible FeedKit integration; families
can define suitable verbs without requiring comparison or configuration scopes.
- 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.
The [architecture policy](../policy/architecture.md#command-and-application-ownership)
recommends shared vocabulary; the [CLI reference](../cli.md#registered-command-syntax)
owns implemented grammar.
Backend replacement remains possible behind the accepted facade, subject to the
published API/CLI contracts and deliberate compatibility decisions.
## Possible further efficiency work
Selected-scope memoization and selector-membership indexes were deferred pending
measurements that justify their bookkeeping. Mutable backend reuse across
invocations, segmented aggregate YAML delivery, provider caching, consumer-loader
redesign, and a replacement backend were outside the completed performance
target. Reconsider them only with concrete motivation and review of ownership,
invocation state, native reports, and the affected contracts. Retained measurement
records are historical evidence, not performance guarantees or release gates.
## Consumer adoption
CommandKit is in alpha with no consumers. No application adoption work or rollout
order is selected. The [consumer guide](../consumers/guide.md) and maintained
examples provide integration guidance for future applications.

File diff suppressed because it is too large Load Diff

View File

@@ -1,208 +0,0 @@
# Integration dependency alignment and vanity-path migration
Status: proposed release-preparation roadmap. This pass adds documentation only;
dependency changes and publication remain future work. The
[implementation plan](implementation.md) owns execution stages, concrete tasks,
and validation tracking; this roadmap owns the target and selected scope.
## Purpose and authority
Align the independently released integrations with the core APIs their current
source already uses, and migrate all three CommandKit modules to the supplied
vanity paths. Publish compatible module versions in dependency order so each
integration builds and validates against its declared, downloaded dependencies
with workspace discovery off.
The [release procedure](../release.md) owns publication commands and release
order. The [release policy](../policy/release.md),
[agent policy](../policy/agents.md), [testing policy](../policy/testing.md),
[documentation policy](../policy/documentation.md), and
[OSS policy](../policy/oss.md) govern the work. This roadmap selects the target
and release-preparation scope; it does not change those policies.
## Baseline and investigation evidence
Reviewed baseline: `289a171e97699ad9aa57ee2cc40bdad1c0b680a9`.
Vanity-path review baseline: `d7cbaed3ed18bc2909c8144f308e2dfd58ade7ec`.
Module declarations and library source were unchanged between these revisions.
- Both integration module declarations require core v0.2.0. The
[ckprompt declaration](../../integrations/ckprompt/go.mod) also requires
ckconfig v0.1.0; the [ckconfig declaration](../../integrations/ckconfig/go.mod)
has no dependency on ckprompt.
- Current integration source uses newer core APIs including `CompletionHint`,
`Explanation`, and `Reporter`. Both independent Go 1.26.8 suites fail to
compile with `GOWORK=off` and their existing read-only module graphs.
- Updating core alone is insufficient for ckprompt: published ckconfig v0.1.0
still uses removed core APIs, including `Invocation.Streams`,
`Outcome.Diagnostics`, and `SeverityError`, plus the old `Failure(error)`
call shape.
- The authoritative `origin` tag listing contains core v0.1.0 and v0.2.0, and
the two integration v0.1.0 tags. The proposed next versions below were unused
at review. Remote `main` was `230187296fae5253cb1773f16c2f1d1a39499ed0`;
the reviewed local changes still need normal publication to the authoritative
branch as part of candidate preparation.
- A checked-in [core v0.3.0 draft](../releases/v0.3.0.md) already describes the
reporting and alias migration. It is an unpublished draft, not proof that
the module version can be downloaded.
The investigation used disposable alternate module files, Go 1.26.8,
`GOWORK=off`, and read-only offline tests. Only temporary dependency selections
changed; the real module declarations, sums, and source remained unchanged.
| Current source under test | Temporary dependency selection | Result |
| --- | --- | --- |
| ckconfig | Current core source | Module suite and maintained example passed. |
| ckprompt | Current core source, published ckconfig v0.1.0 | Compilation failed in the old ckconfig implementation. |
| ckprompt | Current core and current ckconfig source | Module suite and maintained example passed. |
The successful cases retained ConfigKit v0.8.0 and PromptKit v0.16.0. These
results isolate the CommandKit dependency alignment needed here; they do not
prove that future published versions or their downloaded sums already exist.
### Verified vanity discovery
The core endpoint at `https://go.maximumdirect.net/commandkit?go-get=1` serves
the mapping to `https://forge.maximumdirect.net/base-layer/commandkit`. Go direct
discovery finds the existing core v0.1.0 and v0.2.0 tags. Fetching v0.2.0 through
the vanity path reaches its source but reports a module-path mismatch because
that release declares `gitea.maximumdirect.net/eric/CommandKit`.
Both integration endpoints now return HTTP 200 with these `go-import` contents:
| Vanity path | VCS | Repository URL | Subdirectory field |
| --- | --- | --- | --- |
| `go.maximumdirect.net/ckconfig` | `git` | `https://forge.maximumdirect.net/base-layer/commandkit` | `integrations/ckconfig` |
| `go.maximumdirect.net/ckprompt` | `git` | `https://forge.maximumdirect.net/base-layer/commandkit` | `integrations/ckprompt` |
Go 1.26.8 direct discovery finds each integration's v0.1.0 tag. Disposable
consumer fetches reach the correct nested `go.mod` and report its old declared
module path, confirming subdirectory routing. Discovery is working; the
existing tags have not migrated. The optional fourth metadata field is
supported from Go 1.25, within the existing Go 1.26.0 consumer minimum; see the
[Go discovery reference](https://go.dev/ref/mod#vcs-find).
## Targeted end state
### Compatible independently released modules
Use the following proposed next versions, subject to the existing fresh-version
publication guards:
| Module path | Physical module directory | Next release | Required CommandKit dependencies | Full tag |
| --- | --- | --- | --- | --- |
| `go.maximumdirect.net/commandkit` | `.` | v0.3.0 | None | `v0.3.0` |
| `go.maximumdirect.net/ckconfig` | `integrations/ckconfig` | v0.2.0 | `go.maximumdirect.net/commandkit v0.3.0` | `integrations/ckconfig/v0.2.0` |
| `go.maximumdirect.net/ckprompt` | `integrations/ckprompt` | v0.2.0 | `go.maximumdirect.net/commandkit v0.3.0` and `go.maximumdirect.net/ckconfig v0.2.0` | `integrations/ckprompt/v0.2.0` |
Minor integration releases fit the existing pre-1.0 compatibility policy: the
reporting migration changes consumer-facing behavior and adopts a core version
with incompatible API changes. The vanity migration also deliberately changes
Go module and import identities. Migration notes explain the new imports and
dependency set, provider results, invocation reporting, and application changes.
The three modules retain independent versions and source tags. Their candidates
may identify successive commits: core publication need not wait for integration
metadata that depends on that newly published core. Each selected module's
final release suite runs against its actual declared dependencies.
### Module and import identity
Change each module declaration and all CommandKit-owned imports to the matching
new path. Core self-imports, including its `internal` packages, use
`go.maximumdirect.net/commandkit/...`; integration self-imports use
`go.maximumdirect.net/ckconfig/...` or `go.maximumdirect.net/ckprompt/...`.
Cross-module imports and requirements use the complete new paths in the table,
including ckprompt's ckconfig dependency. Core component-version discovery uses
`go.maximumdirect.net/commandkit` as its build-info identity, retaining the
existing `version --scope commandkit` behavior with the new module path.
Update production files, tests, maintained examples, and any import-path-based
test expectations together. A declared vanity module that still imports the
old core path would select a different Go package identity and can violate
`internal` import rules. The matched new releases compose through one core
identity rather than mixing old-path and new-path CommandKit types.
Keep physical directories, repository URLs, and Git tag prefixes as shown. The
Go subdirectory metadata makes the short integration vanity paths resolve to
the existing layout. The existing release profiles select physical directories;
the migration does not rename those directories or flatten integration tags.
Published old-path versions remain immutable. This scope selects the new paths
for new releases and updates consumer guidance; it makes no new promise of
type interoperability between the two identities and adds no old-path forwarding
module or compatibility layer.
### Retained architecture and scope
Preserve exported declaration shapes and runtime behavior while deliberately
changing module/import identities. Retain the integration adapters, Go 1.26.0
consumer minimum, Go 1.26.8 maintainer toolchain, and root-selected ReleaseKit
v0.9.0. The earlier successful diagnostic suites establish the dependency fix
before renaming; the migrated source still needs its own validation. This work
selects no API rollback or shim for old reporting APIs.
Retain ConfigKit v0.8.0, PromptKit v0.16.0, and other dependency selections
unless ordinary tidy changes the resolved graph as a consequence of the
CommandKit updates. Inspect and explain the resulting graph rather than
bundling unrelated upgrades. In particular, transitive tools with different
module paths may coexist; attribution must reflect the actual selection.
Preserve the existing launchers, three release profiles, generic validators,
credential handling, and separate source-publication and hosted-delivery
outcomes. Development workspaces remain useful and ignored. Diagnostic local
replacements and the earlier temporary Go shim remain development evidence;
release validation uses normal downloaded dependencies without that redirection.
## Release-preparation deliverables
The core v0.3.0 draft describes the complete candidate, including completion,
reporting, aliases, tool maintenance, and the vanity-path migration. New
integration notes at `integrations/ckconfig/docs/releases/v0.2.0.md` and
`integrations/ckprompt/docs/releases/v0.2.0.md` explain the matched dependency set
and consumer migration. Their source and licensing links follow the current
release procedure; published notes and tags remain immutable.
Current installation examples, README module selections, consumer/developer
guidance, affected GoDoc, and maintained examples use the implemented new
identities. Import-path-aware tests retain their existing boundary assertions.
Source-hosting and license URLs remain repository links, distinct from Go
import paths. Module-local attribution describes each actual resolved graph.
The final record identifies module paths, versions, candidate revisions,
validation evidence, and independent source-publication outcomes. Current
readiness guidance reflects the aligned state; historical failures remain in
completed investigation records. The implementation plan owns the ordered
migration, dependency preparation, release, and evidence-recording steps.
## Validation and completion boundaries
Review current CommandKit-owned source and dependency selections for stale
self-imports or old direct dependency keys. Historical notes, source URLs, and
unrelated upstream module identities are outside that mechanical rename.
Confirm release identity still pairs each new module path with its existing
physical directory and tag prefix. The verified redirects are preparation
evidence; publishing the renamed modules is needed for real consumers to use
them through those paths.
Use the selected module's ordinary deterministic offline suite with
`GOWORK=off` and read-only metadata after dependency preparation. Its maintained
consumer compiles and runs with that suite. ReleaseKit's publication command
owns the final candidate suite and selected file checks; validation-only checks
remain optional diagnostics. Reuse successful evidence for unchanged candidates
instead of adding a required check-then-publish repetition.
Retrieving a newly published dependency is preparation for the next module's
release. It does not turn proxy availability or consumer installation into a
completion gate for the preceding source release. Continue to resolve failed
or ambiguous publication using the existing policy and upstream recovery flow.
Hosted Gitea delivery keeps its separate outcome; this roadmap adds no CI
polling, live provider calls, or additional publication gate.
Alignment is complete when the matched source versions are published under all
three vanity module paths, owned imports and examples use the new identities,
each integration declares the compatible vanity-path versions with authentic
downloaded sums, each final selected-module suite passes without workspace or
replacement redirection, and current documentation and attribution agree with
that graph. Temporary local-source success alone does not establish that end
state.

File diff suppressed because it is too large Load Diff

View File

@@ -1,198 +0,0 @@
# Release tooling simplification
Status: the selected release-tooling design has been implemented. All four
generic file checks, registered-tool execution, page-only hosted delivery, and
protection of the supplied token path are selected.
The [completed implementation record](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/87eb84ccb462bf54e6250c8b287ac217efe90883/docs/roadmap/implementation.md)
contains stage order, tasks, validation, and completion evidence. Current release
behavior stays with its canonical owners.
## Purpose and authority
Reduce CommandKit's release infrastructure to declarative module profiles,
a small launcher, and focused tests of CommandKit-owned wiring. Delegate
reusable candidate validation and publication mechanics to ReleaseKit while
retaining CommandKit's domain-specific checks in their existing owners. Add
hosted Gitea release pages as a separate local delivery operation after source
publication.
The [agent policy](../policy/agents.md),
[release policy](../policy/release.md#candidate-validation), and
[testing policy](../policy/testing.md#upstream-integration-testing-ownership)
govern implementation. The [release procedure](../release.md) continues to own
the current workflow.
## Targeted end state
### Ownership
CommandKit retains module selectors, product identities, profile locations,
the authoritative endpoint selection, release order, release-note meaning,
compatibility decisions, and attribution accuracy. Its existing public API,
CLI, integration-adaptation, and consumer-example tests remain local and run
through the owning module's suite. No parallel release-only domain suite is
needed unless a concrete CommandKit requirement is otherwise unprotected.
ReleaseKit supplies the standard offline candidate suite, selected generic file
checks, candidate/note and publication guards, tag creation and publication,
hosted delivery, outcome reporting, and recovery. CommandKit's launcher delegates
those operations. Existing source-release completion and immutability rules
continue to apply. Hosted pages are the added delivery channel; binary
deliverables and platform support commitments remain unchanged.
### Profiles
Select these existing ReleaseKit options in each profile:
| Selection | Intended protection |
| --- | --- |
| `validation.go_format: true` | Detect unformatted committed Go source. |
| `validation.markdown: {}` | Detect broken local Markdown destinations and supported heading fragments. |
| `validation.module_distribution.workspace_files: true` | Detect committed development workspace files in selected inputs. |
| `validation.module_distribution.local_replacements: true` | Detect filesystem replacements in selected committed module declarations. |
Also select
`validation.forbidden_files: [".releasekit/gitea-token"]` in all three profiles.
This delegates protection of the supplied credential path to ReleaseKit's
existing check. The path is repository-relative and the check applies across
the captured repository even for an integration release. It does not read token
contents, scan other paths, or inspect Git history.
Retain the explicit shared-file lists and use ReleaseKit's existing input scope.
The root profile does not run every nested module's suite. File checks inspect
committed inputs and do not replace formatting worktree changes or reviewing
documentation meaning, attribution, and external links. Markdown keeps its
default treatment of outside-checkout destinations; no stricter boundary mode
is proposed.
The pinned [ReleaseKit configuration reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/23203f235cc414c4ffe0fd3941030eb0c9f9943f/docs/config.md)
owns exact scope and behavior, including nested-module boundaries and vendored
inputs. Do not expand an option's scope into a general repository prohibition.
Race testing, full vet, compilation matrices, size limits, and additional
forbidden-file paths are not selected by this roadmap. They can be considered
separately for demonstrated needs.
### Launcher and recovery
Use the root module's registered `go tool releasekit` through `exec`, keeping
the current selector interface and publication argument mapping.
Retain host normalization, workspace-off execution, the local toolchain, and
read-only module metadata. Preserve other effective Go settings while arranging
read-only selection.
The implementation plan specifies the wrapper interface and invocation details.
Go's cache supplies executable storage; the launcher creates no persistent
binary directory. `/.releasekit/` remains ignored for the credential file, with
an explanatory comment. Recovery instructions use the registered tool or the
entry point printed for the saved attempt, preserving ReleaseKit's distinction
between publication outcome and record-storage errors.
Share module selection and host-safe tool invocation between publication,
delivery, and recovery where applicable. Delivery selects the same module
profile as publication; recovery uses its recorded attempt rather than inferring
identity from a current profile. Keep the existing publication interface and
provide an explicit delivery invocation without automatically chaining the two
operations. Keep one selector mapping and shared invocation plumbing.
ReleaseKit v0.9.0 shipped host-safe consumer launcher examples and regression
coverage. The earlier simplification used v0.8.0, which already supported
registered tools. Keep host normalization locally because the reviewed
`go tool` invocation inherited foreign `GOOS`/`GOARCH` values and failed to run.
Do not switch tool-version selection from the root module to each integration
merely to match the upstream multi-module example.
### Hosted Gitea release pages
Configure this delivery selection in each module profile:
| Field | Selected value |
| --- | --- |
| `delivery.forge` | `gitea` |
| `delivery.base_url` | `https://forge.maximumdirect.net` |
| `delivery.owner` | `base-layer` |
| `delivery.repository` | `commandkit` |
| `delivery.token_file` | `.releasekit/gitea-token` |
Preserve the profiles' distinct product identities and physical module
directories. All pages belong to the same Gitea repository and are keyed by
the complete tag: `vX.Y.Z` for core, `integrations/ckconfig/vX.Y.Z` for ckconfig,
and `integrations/ckprompt/vX.Y.Z` for ckprompt. Pass the selected profile and
semantic version to `deliver`; ReleaseKit derives the complete module tag.
With no build targets, delivery is page-only. ReleaseKit uses the complete
checked-in module release note, including its licensing footer, as the hosted
body. No binary assets, receipts, or checksum manifests are added. Use current
forge URLs and the full module tag for future notes' immutable source and
licensing links. Existing published tags and notes remain unchanged.
After source publication succeeds, explicitly invoke delivery from a clean
checkout whose `HEAD` identifies that published tag's commit. For later retries,
use the original tagged source and its recorded module files. Report the source
publication outcome and hosted-delivery outcome separately, including the hosted
URL on success. A delivery failure leaves the source release complete; repair
delivery without republishing or moving its tag.
Delivery performs its own read-only credential preflight, without rerunning
candidate validation or publishing a tag. Hosted-service availability and the
Gitea token do not become source-publication gates. A nonblank
`RELEASEKIT_GITEA_TOKEN` environment value takes precedence over the configured
file. The file path resolves from the repository root for all three modules.
ReleaseKit accepts matching hosted content and reports conflicting content
without overwriting it. Resolve uncertain hosted writes before retrying the
same immutable version. The pinned
[delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/23203f235cc414c4ffe0fd3941030eb0c9f9943f/docs/consumers/delivery.md)
owns exact delivery behavior and recovery. Start with tags containing the new
delivery configuration; historical page backfilling remains a separate decision.
This local workflow needs no CI delivery job or additional secret distribution.
### Focused local tests
Replace the release-test scaffolding with a small controlled Go-command boundary
that invokes the actual launcher. Cover:
- Selector-to-profile/module routing for core, ckconfig, and ckprompt, including
consistency with the checked-in profiles' intended module and product.
- Invalid argument handling before tool invocation.
- Invocation from another directory and paths containing spaces.
- Host normalization and workspace/toolchain/read-only settings.
- Publication and delivery argument, output-stream, and child exit-status
forwarding, with the selected module profile reaching the delivery operation.
- Profile wiring for the shared Gitea destination, token path, and its selected
committed-path protection; assertions do not read the credential file.
Exercise module routing separately from exit statuses rather than multiplying
every combination. Assert behavior and meaningful selections, not helper
implementation details or exact usage prose.
The resulting local suite omits the disposable publication exercise, generic
tag inspection, assertion-helper self-tests, and custom build-call log/parser.
Upstream publication and delivery coverage owns those generic behaviors. No
replacement publication engine or exported test-helper API is needed. There is
no line-count target for the replacement tests.
## Selected choices and rationale
All four generic file checks are selected as release-time checks, in addition
to the agreed forbidden token path. Formatting and local-link validation automate
existing contributor review work. Workspace-file and filesystem-replacement
checks address accidental inclusion of development metadata that a successful
local test run can overlook. This adds specific release checks; it does not
make every optional ReleaseKit validator mandatory or prohibit development
workspaces and replacements outside the selected committed candidate inputs.
Selecting only formatting and Markdown would change less release behavior, but
would leave these distribution mistakes to manual review. The selected upstream
checks avoid a second local implementation.
Host-normalized registered-tool execution is selected because the root module
already registers ReleaseKit and Go caches the executable. Retaining a persistent
native executable would provide a recovery entry point independent of a later
Go-driver invocation, but would retain executable-storage and bootstrap test
maintenance. Recovery instead uses the registered tool or ReleaseKit's reported
entry point. The credential directory remains ignored under either approach.
The implementation plan records the existing integration dependency-version
mismatch separately from validation of the current source. This infrastructure
sprint does not publish releases or select new library dependency versions.
Historical hosted-page backfilling and CI delivery remain separate future
choices, not prerequisites for the selected local page-only workflow.

View File

@@ -1,184 +0,0 @@
# ReleaseKit v0.9.0 adoption
Status: R1 and R2 remediation, ReleaseKit v0.9.0 adoption, and the completion
assertion repair are complete. The [completed implementation record](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/c8d659dfe5ae3e834b3aeabdfd0fe29b736dc05a/docs/roadmap/implementation.md)
records stage order, tasks, and validation evidence. This document owns the
target and selected choices; it does not authorize source or hosted publication.
## Purpose and authority
Adopt ReleaseKit v0.9.0's improved recovery behavior while preserving
CommandKit's existing release-tooling architecture. Update current dependency
and maintainer documentation together, and make the validation evidence clear
about existing local defects.
The [agent policy](../policy/agents.md),
[release policy](../policy/release.md#candidate-validation),
[testing policy](../policy/testing.md#upstream-integration-testing-ownership),
and [documentation policy](../policy/documentation.md) govern the work. Current
behavior remains owned by the [release procedure](../release.md), Go declarations,
and owning package tests until implementation updates them.
## Current baseline and review evidence
Reviewed CommandKit code baseline: `4d92ee4ee6a8403b22a4172e0f04face802b9499`.
The original upgrade review used `98e7e9933353cf43f2be4a96cb0436a0bce068d2`;
its findings and validation history are retained below.
- The root [module](../../go.mod) selects
`go.maximumdirect.net/releasekit v0.8.0` as a Go tool. Its selected version
serves core, ckconfig, and ckprompt operations.
- The [shared helper](../../scripts/releasekit) already normalizes the Go host,
disables workspace discovery, selects the local toolchain, retains read-only
metadata, and invokes the full executable package path. The
[legacy entry point](../../scripts/release) delegates publication to it.
- All three profiles already select the agreed generic file checks, forbidden
token path, and page-only Gitea destination. Source publication and hosted
delivery are separate operations. The local token directory remains ignored.
- [R1 and R2](remediation.md) are complete. Commit `7eac3fe` rejects invalid
legacy selectors before invoking Go; commit `4d92ee4` restores explicit,
optional POSIX-shell invocation in launcher tests. The remediation record owns
their acceptance evidence, including passing focused tests, shell syntax
checks, and profile assertions running when no shell is available.
- Both integrations still require older published CommandKit dependencies than
their current source needs. The completed [release-tooling implementation record](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/87eb84ccb462bf54e6250c8b287ac217efe90883/docs/roadmap/implementation.md#stage-5-verify-the-final-source-and-record-validation-boundaries)
distinguishes workspace-backed tests from independent release readiness.
The upstream v0.9.0 tag identifies commit
`2180aa868ec2caa95fc8279fd59d5f0ebac41bd4`. The tag and direct Go-module
resolution were verified. The default proxy initially returned v0.8.0 for
`@latest`; select v0.9.0 explicitly rather than changing repository-wide proxy
settings to work around that observation.
Upstream [release notes](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/releases/v0.9.0.md)
describe host-safe launchers and generated recovery commands, full package-path
tool selection, preservation of wildcard and version-specific replacements in
API-only recovery, and safer manual hosted-page retries. Public Go APIs,
profiles, record formats, and the Go 1.26.0 consumer minimum remain unchanged.
The v0.8.0-to-v0.9.0 comparison also left upstream module requirements, checksums,
copyright, and license texts unchanged.
A disposable CommandKit upgrade changed only the selected ReleaseKit version in
`go.mod`. With Go 1.26.8, the root suite passed on a subsequent run, both
workspace-backed integration suites passed, and the real helper reported v0.9.0
under foreign target settings. No production publication or delivery was run.
A later disposable review at `7eac3fe` confirmed that downloading and tidying
v0.9.0 changed only the ReleaseKit requirement and its checksum entries. The
offline root suite and both workspace-backed integration suites passed with
Go 1.26.8, and the helper reported v0.9.0 under Windows/ARM64 target settings.
That snapshot excluded the then-uncommitted R2 change; it does not replace
validation of the eventual adoption commit.
The initial upgrade run failed in
[`TestCompletionCommandContext`](../../completion_context_test.go). The same
alias-order failure reproduced four times in 30 focused repetitions on the
unchanged v0.8.0 baseline. A single passing rerun therefore does not establish
stable validation. The test compares an internal alias slice in a fixed order;
commit `0acbbf4` deliberately changed completion to use unsorted lookup records
before this upgrade review.
## Targeted end state
### Tool adoption and retained architecture
The root module selects ReleaseKit v0.9.0 with authentic downloaded checksums.
Its existing tool declaration and root-owned selection remain. No integration
dependency version, library import path, Go minimum, or selected Go 1.26.8
maintainer toolchain changes as part of adoption.
Keep host normalization and full-package invocation in the existing helper:
upgrading a dependency does not update or replace local shell scripts. Keep one
module/profile mapping and the established wrapper forms. Retain the selected
profiles, token-file handling, credential-directory ignore rule, and separation
of source and hosted outcomes. No new validator, binary target, CI workflow,
publication channel, credential preflight gate, or runtime API is needed.
The new generated Go recovery commands complement the helper's existing host
handling. Recovery continues to use saved publication evidence and the upstream
record formats; CommandKit does not acquire a parallel recovery implementation.
### Release-code simplification review
The v0.9.0 review identified no additional local release implementation to remove.
The earlier simplification already delegated generic validation, publication,
delivery, and recovery to ReleaseKit. This release improves launcher examples
and generated recovery commands without adding validation options that replace
the remaining CommandKit code.
Retain the shared helper's module/profile selection and execution environment.
Host normalization must happen before Go compiles and runs ReleaseKit. Retain
the legacy publication wrapper to preserve the established maintainer interface.
The upstream multi-module launcher example adds a module map and temporary
metadata preparation for a different tool-selection model; replacing our
root-owned registered-tool helper with it would add machinery.
Retain the focused [wiring tests](../../scripts/release_test.go) for profile
selections, argument forwarding, environment, streams, status, and optional-shell
handling. ReleaseKit owns validation and recovery semantics; CommandKit's tests
continue to cover its own adapter. The adoption work therefore preserves the
release-code structure while updating the dependency and documentation, alongside
the separately planned completion assertion repair.
### Current documentation and history
Current release procedure, project-specific release policy, consumer Go-tool
selection summary, and root attribution describe v0.9.0. Upstream contract and
notice links use the corresponding immutable source revision. Record that the
host-safe launcher request has shipped upstream instead of describing it as
pending in the earlier feature roadmap.
Continue linking to upstream recovery and delivery contracts from the maintainer
procedure. Use ReleaseKit's generated recovery commands and keep local guidance
focused on CommandKit's entry points and selections.
Preserve the baseline and validation history in completed implementation and
review records. Do not mechanically replace every historical v0.8.0 reference.
Integration attribution describes its own selected published dependency graph;
update it only if that actual selection changes. No license-text replacement
is needed for the unchanged upstream texts.
### Completed remediation and remaining correctness work
R1 and R2 were delivered before the dependency upgrade, with their completion
markers and acceptance evidence in [remediation.md](remediation.md). Preserve
their selector rejection, legitimate raw helper forms, and optional-shell test
boundary during adoption; no further implementation of those findings remains.
The completion assertion compares the expected alias source/target records
without depending on their iteration order, while detecting missing, unexpected,
or duplicate records. Its routing and visible-completion assertions remain.
Production alias ordering and public contracts are unchanged. This correction
was completed as part of the maintenance scope, separate from R1/R2 remediation;
the implementation plan records the test changes and focused verification.
Ordinary releases retain their single selected-module publication suite and
separate hosted-delivery outcome. One-time upgrade diagnostics are maintenance
evidence, with no new recurring release gate. Credentials and live publication
are unnecessary for this adoption.
## Completion evidence
Adoption is demonstrated by authentic module metadata, the real helper reporting
v0.9.0 for the host, aligned current documentation and attribution, and recorded
validation of the final source. The reviewed upgrade introduced no other module
version change; verify rather than assume that result during implementation.
R1/R2 acceptance evidence is complete in the remediation record. Completion of
the remaining maintenance work also includes a stable, order-independent
completion assertion and validation that the adopted helper retains the
corrected launcher behavior. Independent integration dependency alignment
remains separate release-preparation work; this roadmap selects no future
versions or sums.
## Resolved choices
R1 and R2 were corrected in separate commits before adoption, resolving the
former OD-1 scheduling question. Their acceptance evidence remains in
[remediation.md](remediation.md).
The selected maintenance scope is the completion assertion repair, root tool
upgrade, aligned current documentation and attribution, and validation of the
resulting source. The older integration dependency-version mismatch remains
separate release-preparation work. The implementation plan defines execution
and commit sequencing.

View File

@@ -1,116 +0,0 @@
# Release tooling review findings
Status: R1 and R2 complete; no open findings. Review baseline:
`124a9fa57fb3f815bedbafe2d4819f6aa265ee7d`, covering the implementation commits
after `cb40f1d`. The findings below preserve the review evidence; completion
markers record the subsequent corrections. No implementation was changed during
the original review.
The selected architecture is substantially present: three declarative profiles,
one host-normalized registered-tool helper, separate publication and page-only
delivery, retained credential-directory ignoring, and focused wiring tests.
Both issues identified by the review have been addressed.
## R1 — P2: Reject raw helper command names as legacy module selectors
**Completed.** The legacy entry point now checks its three module selectors
before delegating to the helper, retaining the single profile/module mapping
there. Regression cases cover all three raw-command collisions and require
usage status 2 with no Go invocation. The new regression failed before the fix;
the complete focused scripts suite and shell syntax checks passed afterward
with Go 1.26.8, including valid legacy and direct raw-command forwarding.
**Location:** [scripts/release](../../scripts/release), lines 9–10, delegating to
the first-argument dispatch in [scripts/releasekit](../../scripts/releasekit),
lines 10–15. The invalid-form cases in
[scripts/release_test.go](../../scripts/release_test.go), lines 186–200, miss
these collisions.
The legacy interface is `scripts/release MODULE VERSION`, with three module
selectors. It now delegates its first argument directly into a helper that also
accepts `version`, `publication-attempts`, and `reconcile` as raw commands. These
invalid module names consequently bypass selector rejection and invoke Go.
For example, `./scripts/release version v1.2.3` reaches the tool as
`version publish v1.2.3`. With the real pinned tool it returns upstream's
"version takes no arguments" diagnostic after tool resolution. In a disposable
copy with a controlled Go boundary returning status 73, each of the three raw
command names returned 73 and invoked Go; an ordinary unknown selector returned
2 without invoking Go. Thus invalid legacy input depends on tool availability
and behavior instead of being rejected at the wrapper boundary.
**Required correction:** Make legacy module selection unambiguous before raw
command dispatch. Keep one profile/module mapping and the supported raw helper
forms; a small selector check is preferable to another routing framework.
Preserve upstream ownership of version and flag validation.
**Verification:** Add all three raw command names as invalid legacy selectors.
Each must return usage status 2 before any Go command runs. Valid legacy
publication and the helper's direct raw forms must retain their forwarding and
exit-status behavior. This restores the requested malformed-wrapper handling;
it adds no new accepted-version restriction.
## R2 — P2: Restore the tests' optional POSIX-shell execution boundary
**Completed.** Launcher execution tests now discover `sh`, invoke it explicitly
with the script path, and skip when it is unavailable. Profile assertions remain
independent of shell availability. A regression case removes the fixture
script's executable permission to verify interpreter invocation; it failed
before the correction and passes afterward. The focused scripts suite and shell
syntax checks passed with Go 1.26.8 on the available POSIX host. Running the
compiled test binary with an empty `PATH` confirmed that every launcher
execution case skips while all three profile assertions pass. Native Windows
execution was not tested and no new platform guarantee is introduced.
**Location:** [scripts/release_test.go](../../scripts/release_test.go), especially
`fixture.run` at line 63 and the launcher test setup.
The rewritten tests execute extensionless POSIX shell scripts directly through
`exec.Command`. They neither discover a shell nor skip launcher execution when
it is unavailable. The previous suite resolved `sh`, skipped shell-dependent
tests when it was missing, and passed the script as an argument to that shell.
Direct execution works on this review's Linux host, but relies on Unix script
execution semantics. Native Windows process creation does not supply a POSIX
interpreter for these files. Go's
[os/exec documentation](https://pkg.go.dev/os/exec#hdr-Overview) explicitly notes
that it does not invoke a system shell. Consequently the default repository
suite now fails on hosts without compatible script execution rather than
preserving the previous optional-shell behavior. This portability finding is
based on the changed execution path and platform implementation; a native
Windows test run was not available during review.
**Required correction:** Discover a POSIX shell for launcher execution tests,
invoke it explicitly with the script path, and skip those tests when the shell
is unavailable. Keep profile-only JSON assertions runnable without requiring a
shell. Preserve the reduced test architecture and existing argv/status checks.
No native Windows release launcher or additional supported-platform guarantee
is requested.
**Verification:** Confirm that launcher tests use the discovered interpreter
and handle its absence explicitly, while profile assertions still run. Rerun
the focused suite on the available POSIX host. Broader platform matrices are
not a new release gate; this correction removes an incidental environment
restriction introduced by the test rewrite.
## Review evidence and limits
- Go 1.26.8 offline root tests and the temporary-workspace suites for both
integrations passed. Both production scripts passed shell syntax checks.
- The real helper reported ReleaseKit v0.8.0 under foreign target settings.
- ReleaseKit checks passed for a disposable committed source snapshot using
the actual profiles: ordinary root validation, then both integration checks
with the implementation plan's temporary workspace-backed test process.
Selected file checks continued to inspect the committed declarations.
- Local documentation destinations and applicable Markdown fragments passed
inspection. The credential file remained ignored and its contents were not
read. No real publication or hosted-delivery operation was invoked.
- Library code and dependency declarations were unchanged by the sprint. The
documented integration dependency-version mismatch remains a pre-existing
independent-release-readiness limitation; the workspace-backed results do
not establish that published dependencies are aligned.
No additional architecture gaps or unrequested runtime/public-API guarantees
were identified. The selected file checks and forbidden token path match the
finalized scope. R2 concerns the default tests' execution environment rather
than a new production validator or library contract.

View File

@@ -1,890 +0,0 @@
# Output policy and invocation reporting
Status: behavioral decisions selected through OD-4. Message categories, all four
output modes, uniform treatment of help and version, message prefixes, and
OD-1's flag grammar and scope (including independent Progress control) are selected.
OD-2's unified reporting direction, the terminal failure model, and application
ownership of domain-specific error decisions are selected.
OD-3's failure presentation and retention boundaries are selected.
OD-4's delivery lifecycle, write-failure handling, and quiet-mode rendering are
selected. The consumer API signatures, presentation-result model, provider
diagnostic boundary, fallback explanations, and rendering callback boundary
below are also selected. Architectural preparation is complete:
[ADR 0014](../adr/0014-invocation-reporting.md) is accepted, and the
[architecture policy](../policy/architecture.md) is aligned. Implementation and
final validation are complete; Stages 1–20 are complete. The [implementation
plan](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/230187296fae5253cb1773f16c2f1d1a39499ed0/docs/roadmap/implementation.md) records the stage order and validation evidence.
## Purpose
Provide consistent handling of `--quiet`, `--verbose`, `--debug`, and
`--progress` across application commands and optional integrations. Reduce
repeated output classification, diagnostic presentation, and delivery bookkeeping
where CommandKit can provide a meaningful benefit.
Ordinary Go writers and formatting functions already make application output
straightforward. Design reporting conveniences around the shared output policy
and invocation lifecycle. Applications continue to own their report content,
domain behavior, and operation outcomes.
## CLI presentation and error-handling ownership
Applications decide how to respond to domain-specific errors: recover, retry,
continue silently, report a Warning, or end the invocation with a failure.
CommandKit may provide shared helpers for carrying, inspecting, and presenting
errors while leaving those decisions with the application. A Go `error` alone
does not determine a CLI message category. Warnings may describe issues with or
without an underlying Go error, and handling an error does not require emitting
a message.
CommandKit handles the presentation the application requests: message category,
visibility, formatting, timing, and delivery. For terminal application failures,
the application supplies a non-zero outcome and an operator-facing explanation.
It may also supply an optional Go error cause for the execution caller to inspect.
CommandKit preserves that cause and its existing `errors.Is`/`errors.As` behavior.
Supplying a cause alone does not select an outcome, generate an operator-facing
message, or change visibility.
Applications can explicitly present `err.Error()` as a Warning through the
ordinary message API, illustrated by `report.Warn(err.Error())`. Defer a separate
`WarnError` convenience helper pending demonstrated value; this conversion
already requires little code.
CommandKit retains responsibility for handling its own errors, including
routing, parsing, and reporting failures. Integration-owned commands similarly
adapt their upstream APIs into deliberate command outcomes and presentations.
Ordinary Go error returns from CommandKit APIs and writers remain available where
appropriate.
## Current baseline
[`Streams` and `Invocation`](../../identity.go) expose caller-selected output
and diagnostic writers. [`Outcome` and `Diagnostic`](../../outcome.go) carry
operation causes, diagnostic writers, and delivery state. Handlers can return
`Failure(err)` or attach diagnostics to an outcome for presentation after the
handler returns. `MessageDiagnostic` currently writes the supplied message with
a newline; severity does not add a label automatically.
CommandKit coordinates final diagnostic delivery and retains delivery failures
separately from the operation cause. Custom handlers can also write directly to
the supplied streams and retain responsibility for their output. The existing
outcome and channel ownership decisions are recorded in
[ADR 0005](../adr/0005-invocation-outcomes.md).
Integration adapters already translate upstream diagnostic records into
CommandKit diagnostics while preserving native rendering and source context.
Some adapters wrap multiple records in one diagnostic writer and classify the
batch by its highest severity. This matters when designing selective visibility.
There is currently no shared verbosity policy or invocation reporter API.
## Selected output policy
Distinguish substantive application output from diagnostic message categories:
- **Substantive output (Normal):** The requested report or command result.
- **Error:** Terminal presentation of an unrecoverable invocation failure,
resulting in a non-zero status code.
- **Warning:** Something went wrong or deserves attention, but the invocation
can still succeed. The application chooses whether to report such an issue.
- **Info:** Informational context and useful explanation, such as which profile
was selected or a final operation summary.
- **Progress:** Streaming updates about work underway, such as items processed.
- **Debug:** Troubleshooting detail, including intermediate decisions and
execution-path information.
Debug mode adds troubleshooting detail to verbose output:
| Output category | `--quiet` | No flag | `--verbose` | `--debug` |
| --- | --- | --- | --- | --- |
| Substantive application output | Suppressed | Shown | Shown | Shown |
| Error | Shown | Shown | Shown | Shown |
| Warning | Suppressed | Suppressed | Shown | Shown |
| Info | Suppressed | Suppressed | Shown | Shown |
| Debug | Suppressed | Suppressed | Suppressed | Shown |
| Progress, without an explicit setting | Suppressed | Suppressed | Suppressed | Shown |
Info belongs in verbose mode; debug includes verbose messages. Normal output
remains a substantive result category, separate from diagnostic severity.
The category of a message determines its visibility; stream destination alone
cannot distinguish a warning from an error.
Progress is distinct from Info and is controlled separately as specified below.
Applications and integrations are not required to generate messages in any
category. Enabling a category adds no output when there are no messages in it;
other output remains subject to its own visibility rules.
Error presentation follows the terminal failure model below. An application
may report a recovered problem as a Warning or continue without a message.
Apply the same policy to help and version. There are no command-specific
exceptions for successful output:
```text
app help --quiet # succeeds silently
app version --quiet # succeeds silently
```
Usage and operational failures still report their errors in quiet mode.
The feature must distinguish successful output suppression from failure
diagnostic delivery.
### Message prefixes and channels
Substantive output uses the result destination, conventionally stdout. Warning,
Info, Progress, Debug, and terminal explanations use the diagnostic destination,
conventionally stderr. Callers supply both destinations.
Use `WARN:`, `DEBUG:`, and `ERROR:` prefixes for simple warning, debug, and error
messages respectively. Substantive output and Info messages have no prefix.
For multiline diagnostics, prefix the opening line and preserve the structured
detail. Integration adapters coordinate native diagnostic rendering to avoid
duplicate severity labels or loss of upstream formatting.
Usage guidance belongs to the terminal usage explanation and remains visible in
quiet mode. Successful help output follows the substantive-output policy.
Initially, Progress messages are unprefixed streamed lines. The diagnostic
destination keeps them separate from substantive output such as JSON or YAML.
Terminal redraw and progress-bar behavior are outside this implementation.
### Terminal failure model
During execution, applications can produce substantive output, Progress, Info,
Debug, and Warnings. An unrecoverable failure ends the handler by returning a
failed outcome. There is no standalone `report.Error(...)` method. Detailed
error explanations accompany the returned failure and are presented as part of
that terminal step.
CommandKit finalizes reporting, presents the terminal failure explanation, and
returns the non-zero outcome to the application entry point. It accepts no
further normal output for that invocation after terminal error presentation.
The same boundary applies to core routing and parsing failures that prevent
handler execution. The application entry point retains ownership of process
exit; CommandKit ends the invocation and returns its outcome.
For example, if an export operation fails and the application decides it cannot
continue, the handler returns a non-zero outcome with an explicit explanation
such as "Unable to export the requested report." The application can choose to
include detail from a Go error in that explanation. CommandKit presents the
supplied explanation without automatically deriving text from an application
error. The selected API below provides failure constructors for this purpose.
When a failed outcome supplies no explanation, CommandKit supplies the generic
fallback defined under terminal outcomes below.
Output streamed before the failure remains visible according to its selected
mode; terminal failure does not withdraw earlier bytes. Deferred messages,
cleanup writes, and delivery failures must respect the end of normal reporting.
Their finalization and late-write behavior follow OD-4 below. A terminal
failure may have multiple explanatory records, all belonging to that failure.
### Failure explanations and presentation ownership (OD-3)
Make a non-zero outcome with an explicit message the convenient failure path.
Support structured explanations, including multiple records, through the same
presentation facility for richer application and integration reports. Neither
form requires an underlying Go error.
The application or integration explicitly identifies the context belonging to
a terminal explanation. Present that context with the failure in every mode,
including quiet. Independent warnings and other messages retain their own
visibility rules. For mixed upstream batches, adapters preserve useful native
rendering while distinguishing terminal explanatory context from independent
messages. Core does not infer those relationships from error chains or text.
Assign each submitted message one presentation path. Returning a terminal
explanation schedules its presentation once; core does not append another
message derived from an application error cause. Native renderers can supply
already-labelled content without receiving duplicate prefixes. Avoid text-based
deduplication: separate events may legitimately produce identical messages.
Constructing a failure result attaches explanatory content without emitting or
independently scheduling an Error. Returning that result hands the explanation
to CommandKit for terminal delivery through the invocation's reporting facility.
The selected API expresses this distinction for both simple and structured
explanations. For example:
| Handler action | Presentation |
| --- | --- |
| Construct a non-zero result with "Unable to export the requested report," then return it. | Construction emits nothing. CommandKit presents the message once during terminal delivery. |
| Return a failure with native diagnostic records and their source context, submitting independent warnings separately. | CommandKit presents the structured terminal explanation in every mode. Independent warnings follow their own visibility policy. |
### Retention boundary (OD-3)
CommandKit provides invocation-scoped CLI presentation. Logging and message
history collection remain application concerns. Keep terminal explanations and
any supplied cause with the returned outcome, and keep the temporary state needed
for deferred delivery, parser events awaiting mode selection, and delivery
bookkeeping. The reporting facility does not collect a transcript or retain other
emitted or suppressed messages for later inspection. No optional message-history
facility is planned for this implementation.
## Selected flag grammar and scope
### Mode selection and resets
Model quiet, ordinary, verbose, and debug as mutually exclusive presentation
modes. Each invocation starts in ordinary mode. Process flag occurrences in
invocation order; the last enabled mode wins. Repetition does not increment
verbosity, and debug is selected explicitly.
Accept presence and the existing explicit boolean syntax, including
`--quiet=true` and `--quiet=false`. A false occurrence clears its mode only if
that mode is currently selected, returning to ordinary mode. It does not restore
an earlier selection or disable a different mode. Every occurrence is converted;
invalid boolean values remain usage errors even when a later flag would replace
the selection.
| Flag sequence | Final mode |
| --- | --- |
| `--quiet --verbose` | Verbose |
| `--debug --quiet` | Quiet |
| `--verbose --verbose` | Verbose |
| `--verbose --verbose=false` | Ordinary |
| `--quiet --verbose=false` | Quiet |
| `--verbose --quiet --quiet=false` | Ordinary |
| `--quiet=false --debug` | Debug |
Keep verbosity a presentation choice: quiet preserves the requested operation's
semantics and rendering failures. Delivery failures depend on the output actually
requested; suppressing a destination write can avoid a delivery failure. Apply
the policy through CommandKit-managed reporting and streams. Output written
directly to process streams or external destinations remains application-owned.
### Progress selection
`--progress` enables Progress without enabling Info, Warning, or Debug messages.
`--progress=false` explicitly disables it. Accept the existing boolean syntax;
repeated canonical or aliased progress settings use the last value. Retain
whether a setting was explicitly supplied, separately from its boolean value.
Determine effective Progress visibility from the final presentation mode and
explicit progress setting:
1. Quiet mode always suppresses Progress.
2. Otherwise, an explicit progress setting controls visibility.
3. Without an explicit setting, debug enables Progress; ordinary and verbose
modes leave it disabled.
An explicit progress setting overrides the mode's default regardless of its
position relative to mode flags. It does not select or reset the presentation
mode. Debug includes verbose detail and defaults Progress on; verbose alone
does not enable Progress.
| Flags | Result |
| --- | --- |
| `--verbose` | Warning and Info, without Progress. |
| `--verbose --progress` | Warning, Info, and Progress. |
| `--debug` | Warning, Info, Debug, and Progress. |
| `--debug --progress=false` or `--progress=false --debug` | Debug detail without Progress. |
| `--quiet --progress` or `--progress --quiet` | Only failure diagnostics. |
| `--debug --verbose` | Verbose detail without Progress, absent an explicit progress setting. |
Passing `--progress` to an application that emits no Progress messages adds no
visible output. It does not suppress that application's substantive results or
require a progress implementation.
### Placement, availability, and aliases
Core supplies `--quiet`, `--verbose`, `--debug`, and `--progress` for application
commands, integrations, the root, canonical groups, and alias-only discovery help.
Consumers do not register them individually. Preserve the existing placement
grammar: flags follow the complete command path and may be interspersed with
operands after that path. For example:
```text
app config show --quiet
app export destination --verbose
app help config show --quiet
app version --quiet
```
Options before or between command-path components are outside this grammar.
Tokens consumed as values and tokens following `--` retain their ordinary
meaning.
Reserve the four long spellings. Applications may add `-q`, `-v`, or `-d`
through the alias API; no built-in short spellings are selected. An application
flag declaration that defines a reserved long name fails installation with a
collision error. Aliases may target these flags but may not overload canonical
spellings. Apply the existing collision and atomic installation rules, including
later registration.
Aliased and canonical occurrences participate in the same ordering. With
`-q → --quiet`, `app export --verbose -q` selects quiet mode. Document the newly
reserved canonical names as a compatibility change.
### Routing and parsing failures
Determine the mode through the normal parser before execution or successful
help/report presentation. Do not add a separate verbosity scanner. This keeps
token interpretation consistent and avoids reinterpreting values or operands.
Routing and parsing failures report their errors regardless of verbosity. For
example, `app export --quiet --unknown` still reports the unknown flag. Error
visibility follows the selected output policy independently of how the mode is
determined; avoiding a separate scanner is an implementation simplification.
## CommandKit's own diagnostics
Use the shared reporting system for CommandKit's invocation diagnostics so
parser, integration, and application messages follow the same visibility and
delivery policy. Installation errors occur before an invocation exists and
continue to be returned to the application without automatic printing.
Supported repetition and last-occurrence overrides are routine decisions.
Repeated singleton flags, including `--verbose --verbose`, may produce debug
detail; repetition alone does not warrant a warning. Repeatable flags retain
their intended accumulation behavior. Add warnings for concrete concerns an
operator can act on, such as a deprecated spelling if deprecation is introduced.
Unknown flags, invalid values, and invalid command paths remain invocation
failures with error diagnostics.
Start debug instrumentation with routing, alias resolution, verbosity selection,
and handler completion. Other useful candidates can be added where they
explain a consequential decision:
| Event | Useful detail |
| --- | --- |
| Command-alias resolution | Entered path, intermediate redirects, and final canonical path. |
| Flag-alias resolution | Entered spelling and canonical flag name. |
| Scoped alias selection | A command-scoped mapping took precedence over an application-wide mapping. |
| Verbosity selection | A later occurrence selected a different mode; for `--verbose --debug`, debug wins because it appears last. |
| Help-route selection | A canonical command or alias-only discovery context was selected. |
| Repeated singleton flag | A later occurrence replaced an earlier occurrence, without printing their values. |
| Input preparation | Canonical input validation completed and the handler is starting. |
| Handler completion | Canonical command and the handler's returned classification and status, before final delivery. |
The handler-completion event describes the handler's result. The final returned
invocation outcome also includes delivery failures and remains authoritative.
Present this event before terminal failure presentation; do not emit a completion
message afterward.
Use the final selected mode to present earlier parser decisions. Collect the
relevant events during parsing and apply visibility after mode selection:
`--verbose --debug` shows the selection explanation, while `--debug --quiet`
suppresses it. This collection of parser events does not require whole-run
output buffering.
Debug messages should explain operator-relevant decisions independently of
incidental implementation steps. Use command paths, flag names, and decision
descriptions rather than raw argument or configuration-value dumps. Upstream
libraries retain ownership of their diagnostic detail and disclosure rules.
## Selected delivery lifecycle and quiet execution (OD-4)
### Ordering and finalization
Use synchronous delivery. Present immediate messages and substantive writes in
submission order. After the handler returns:
1. Deliver eligible deferred messages in queue order, without sorting by severity.
2. Finish ordinary reporting, including any handler-completion debug message and
flushing any CommandKit-owned buffers for ordinary output. Account for failures
discovered during this delivery before preparing the terminal explanation.
3. Present any terminal failure explanation and complete its delivery, including
any CommandKit-owned buffering of that explanation.
4. Close the invocation's reporting facility and return the final outcome.
Deferred messages remain eligible when the operation fails, subject to verbosity.
Deferral controls timing; applications queue success summaries only after the
relevant operation succeeds. Ordering governs CommandKit's writes; separate
stdout and stderr destinations may be buffered or displayed differently by the
caller.
CommandKit finalizes automatically. Consumers do not need a separate flush or
finish call. Handler cleanup, including Go `defer` functions, runs before the
handler returns and participates in normal reporting. Applications complete any
reporting goroutines before returning. After finalization, writes and message
submissions return a closed-reporter error and emit nothing.
Reporter operations within an invocation are safe to call concurrently.
Serialize access to reporting state and delivery so individual messages do not
interleave. Concurrent submissions have no predetermined order; deferred messages
follow the order in which they enter the queue. Multiple writes composing one
application report still require application coordination if they must remain
together. This contract covers operations through one invocation's reporter;
coordination with writes outside that reporter remains caller-owned.
Finalize any buffers owned by CommandKit. Caller-supplied writers remain
caller-owned; CommandKit does not automatically close them or invoke arbitrary
flushing interfaces.
Visibility applies when output is emitted. A verbose or debug run may show
informational detail before subsequently failing. Progress and substantive
output may likewise be partially delivered before a failure. Previously written
bytes cannot be withdrawn. Whole-run buffering solely to make failed runs
contain only error output is outside this implementation.
Keep errors understandable in every mode, including source locations and
related explanatory context. Unrelated warnings and informational messages
remain subject to the selected mode. Apply the selected retention boundary
without accumulating a message history.
### Rendering failures, write failures, and execution flow
Distinguish who invokes a renderer and whether its destination failed:
| Failure source | Responsibility and handling |
| --- | --- |
| An encoder or renderer called directly by the application | The application handles its returned error and chooses the outcome and explanation. |
| A rendering callback invoked by CommandKit | CommandKit handles its returned error as a presentation failure and reflects it in the invocation outcome. |
| A destination rejects a write | CommandKit records the delivery failure and stops further writes to that destination. |
A rendering failure may occur before any write or while the destination remains
healthy. It does not by itself establish a failed destination. CommandKit can
explain its rendering failure through a working diagnostic destination. These
responsibilities also apply when rendering into a discard destination under
quiet mode.
Failure to deliver requested output makes an otherwise successful invocation
unsuccessful. Apply this rule to substantive output, Warning, Info, Progress,
Debug, and terminal explanations. Intentional suppression requests no delivery
and is not a delivery failure.
Standard writer operations return write errors normally. Immediate reporting
methods also expose delivery errors. CommandKit records bounded delivery-failure
information so an ignored write error cannot yield a successful invocation.
Deferred delivery failures appear in the final outcome.
Apply ADR 0005's outcome rules when presentation fails. For standard outcomes,
return operational failure with status `1`, including when the original outcome
was a usage failure. Preserve the original explanation and any supplied cause;
record the presentation failure separately in `Presentation`.
Application-defined domain outcomes retain their domain classification. Preserve
an already non-zero domain status. If the domain status was `0`, change the
suggested status to `1` so presentation failure cannot yield a successful
invocation. The application entry point retains control of process exit.
| Outcome before presentation fails | Final outcome |
| --- | --- |
| Success, status `0` | Operational failure, status `1`. |
| Usage failure, status `2` | Operational failure, status `1`. |
| Operational failure, status `1` | Operational failure, status `1`. |
| Domain outcome, non-zero status | Same domain classification and status. |
| Domain outcome, status `0` | Same domain classification, status `1`. |
These rules apply to both destination-write failures and rendering failures from
callbacks invoked by CommandKit. Reporting failures remain CommandKit's own
error-handling responsibility.
Stop writing to a destination after it fails. A substantive-output failure can
receive a terminal explanation through the diagnostic destination if it still
works. If diagnostic delivery fails, return that information to the caller.
Do not redirect diagnostics into substantive output or recursively attempt to
report the reporting failure.
A failed write does not stop application code. The handler can inspect the error
and return immediately; otherwise, CommandKit records the failure and finalizes
when the handler returns. CommandKit does not use process exit or panic to
interrupt execution. Terminal failure presentation ends normal output.
### Quiet-mode rendering and suppression
Initially, run substantive report generation normally with its managed writer
discarding output in quiet mode. This preserves report generation, validation,
and serialization failures while changing presentation. For example, a report
encoder failure still fails the invocation under `--quiet`; the handler remains
responsible for handling errors returned by an encoder it calls.
Simple suppressed messages need no formatting or destination writes. For richer
report renderers, initially render into a discard destination. Rendering may be
skipped later where doing so preserves the command's operation and failure
semantics.
Treat suppression as intentional suppression, never as failed or incomplete
delivery. Do not retain suppressed content. Delivery needs only the deferred
queue, reporting lifecycle state, and bounded delivery-failure information,
alongside the temporary parser state and returned explanations described above.
## Selected consumer reporting API
Provide one reporting facility per invocation for substantive output, immediate
messages, and end-of-run diagnostics. The consumer API is chosen for its intended
long-term design; with no current consumers, preserving existing API shapes
carries no weight in this decision.
The following declarations specify the target API and are not yet implemented.
The reporter owns presentation: output visibility, progress settings, prefixes,
ordering, and delivery bookkeeping. Its views share invocation state, allowing
already-emitted messages, deferred messages, suppression, and write failures to
be coordinated in one place. Helpers may receive the reporter or a narrow
application-defined interface. Domain libraries can continue returning
diagnostic data for the handler to submit, independently of CommandKit.
Use the reporter as the common diagnostic submission path, replacing the earlier
proposal to split submission between a reporter and `Outcome.Diagnostics`.
Terminal explanations accompany returned outcomes and are delivered through the
same facility. Caller-selected destinations remain at `App.Execute`; handlers
use `Invocation.Reporter()` in place of raw invocation streams.
### Invocation reporter and structured detail
```go
func (inv Invocation) Reporter() *Reporter
type Reporter struct {
// Private invocation state.
}
func (r *Reporter) Output() io.Writer
func (r *Reporter) RenderOutput(write func(io.Writer) error) error
func (r *Reporter) Info(message string, details ...Detail) error
func (r *Reporter) Warn(message string, details ...Detail) error
func (r *Reporter) Debug(message string, details ...Detail) error
func (r *Reporter) Progress(message string, details ...Detail) error
func (r *Reporter) AtEnd() DeferredReporter
type Detail func(io.Writer) error
```
`Output()` supports ordinary writers and encoders. `RenderOutput()` coordinates
an entire rendering operation, captures its rendering error, and prevents other
reporter operations from interleaving with it. Rendering callbacks write through
their supplied writer. Each complete categorized message, including its details,
is likewise coordinated as one reporter operation.
Rendering callbacks use their supplied writer only during the callback. They may
call rendering helpers that write through it. While a callback is running, it
must not recursively submit messages, queue deferred messages, start another
rendering operation, or write through a separate `Output()` handle belonging to
the same invocation's reporter. Make additional reporter submissions before or
after the callback. A callback must also avoid waiting for another goroutine to
submit through that reporter, since the submission may be waiting for the
callback to finish.
This boundary applies to `RenderOutput`, immediate and deferred message details,
and terminal explanation details. Ordinary concurrent reporter calls remain
supported and serialized. Keeping each rendering operation together motivates
the boundary; it does not prescribe a lock implementation or promise runtime
detection of recursive calls.
Message strings are literal; consumers use `fmt.Sprintf` when formatting is
needed. Initially omit parallel `Infof`, `Warnf`, and similar methods. CommandKit
formats the opening message with its category's prefix and a terminating newline.
Optional details follow in order with their formatting preserved. An empty
opening message produces no heading, prefix, or blank opening line, supporting
native renderers that supply a complete, labelled diagnostic:
```go
_ = report.Warn("Using the default profile")
_ = report.Warn("Some configuration settings need attention.", writeNativeWarnings)
_ = report.Warn("", writeNativeWarnings)
```
These are alternative calls; `writeNativeWarnings` represents an
application- or integration-supplied function with signature
`func(io.Writer) error`. No generic logging method or severity enumeration is
needed to submit messages. Applications need not check verbosity before reporting
or implement categories they do not use.
Immediate methods return rendering or delivery errors, and CommandKit records
the corresponding presentation failure even if the caller ignores the error.
A caller can inspect the returned error and end its handler immediately.
### Deferred messages
```go
type DeferredReporter struct {
// Private reference to the same invocation state.
}
func (r DeferredReporter) Info(message string, details ...Detail) error
func (r DeferredReporter) Warn(message string, details ...Detail) error
func (r DeferredReporter) Debug(message string, details ...Detail) error
```
For example:
```go
_ = report.AtEnd().Warn("Three optional files were unavailable.")
```
Copies of the deferred view share invocation state. Its methods return submission
errors, such as attempting to use a closed reporter. Errors encountered during
deferred rendering appear in the final invocation outcome. Progress remains an
immediate streaming operation; substantive output uses the main reporter.
Copy callback lists when accepting a submission. Callers retain ownership of
data captured by rendering callbacks and keep that data valid until presentation.
### Terminal outcomes
Outcome construction requires no invocation state and emits nothing:
```go
type Explanation struct {
Message string
Details []Detail
}
type Outcome struct {
Kind OutcomeKind
Status int
Domain string
Cause error
Explanation Explanation
Presentation PresentationResult
}
func Success() Outcome
func Failure(message string, details ...Detail) Outcome
func Usage(message string, details ...Detail) Outcome
func Domain(name string, status int) Outcome
func (o Outcome) WithExplanation(message string, details ...Detail) Outcome
func (o Outcome) WithCause(cause error) Outcome
```
`Success` returns a successful outcome with status `0`. `Failure` produces an
operational failure with status `1`; `Usage` produces a usage failure with status
`2`. `Domain` constructs an outcome with the supplied application-defined
classification and status. Subsequent presentation failures follow OD-4's rules:
domain classification is preserved, and a zero status becomes `1` if presentation
fails.
`OutcomeKind` retains the success, usage, operational, and domain classifications.
`WithExplanation` returns a modified outcome value and replaces its terminal
explanation. Explanations belong to failed outcomes; successful summaries use
Info or substantive output. Ordinary application failure, native failure
rendering, and a custom domain failure respectively can be expressed as:
```go
return commandkit.Failure("Unable to export the requested report.")
```
```go
return commandkit.Failure("", writeNativeFailure)
```
```go
return commandkit.Domain("validation", 3).
WithExplanation("The document failed validation.", writeFindings)
```
The named renderers above have signature `func(io.Writer) error` and preserve the
owning library's safe diagnostic formatting. They run during terminal delivery,
not when constructing the outcome.
Before terminal delivery, supply the explanation `Command failed.` when a
non-zero outcome has an empty `Explanation.Message` and no non-nil callback in
`Explanation.Details`. This produces `ERROR: Command failed.` in every mode,
including quiet, and becomes the explanation retained in the returned outcome.
Adding the fallback leaves classification, status, and cause unchanged;
presentation failures still follow OD-4's rules. The fallback is based on the
failed outcome and absent explanation, without deriving text from `Cause.Error()`.
An empty heading with a non-nil detail renderer is an explicitly supplied
explanation. Trust that renderer to provide its content. Do not buffer or replay
it to decide whether the explanation is sufficiently informative, and do not
add the fallback merely because it writes no bytes.
| Returned outcome | Terminal presentation |
| --- | --- |
| `Failure("")` | Generic fallback, operational classification and status `1`. |
| `Domain("validation", 3)` | Generic fallback, domain classification and status `3`. |
| `Failure("").WithCause(err)` | Generic fallback; the supplied cause remains available without automatic rendering. |
| `Failure("", writeNativeFailure)` | Native explanation without an added generic heading. |
`Cause` is optional. `WithCause` returns a modified outcome value, replacing the
cause without changing the classification, status, or explanation. For example:
```go
return commandkit.Failure("Unable to open the configuration file.").
WithCause(err)
```
CommandKit presents the supplied explanation and carries the original error
through to the execution caller, which can inspect it with expressions such as
`errors.Is(outcome.Cause, fs.ErrNotExist)`. It does not automatically present
`Cause.Error()` or use the cause to decide success, failure, or message category.
Providing a cause is independent of choosing the terminal explanation; neither
is inferred from the other. Warnings do not require supplying or retaining causes.
Outcome processing preserves supplied causes, including their existing Go error
identity, when a subsequent rendering or delivery failure occurs. Presentation
failures are recorded separately in `Presentation`. The returned outcome retains
its terminal explanation and optional cause, with other retention limited to the
presentation needs described above.
### Provider-returned diagnostic data
Providers return values and diagnostic data without needing a reporter parameter.
Retain `Acquired[T]` with separate fields for independent messages and failure
explanations:
```go
type Acquired[T any] struct {
Value T
Diagnostics []Diagnostic
Explanation Explanation
}
```
`Diagnostics` carries independent, nonterminal messages. `Diagnostic` and
`Severity` remain passive data types for those messages, with each diagnostic
carrying its `Severity` and a `Write func(io.Writer) error` renderer. Adapt their
contracts to the nonterminal reporting categories. Integrations submit the
records through the corresponding reporter methods, including the deferred view
where appropriate. Removing `Outcome.Diagnostics` removes a submission path;
provider-returned records remain available for the integration to submit.
`Explanation` carries context to attach when the integration returns a failed
outcome. Terminal Error content uses this field rather than an independently
submitted diagnostic. An upstream warning that helps explain the failure may
belong in the explanation; unrelated warnings remain in `Diagnostics` and follow
their ordinary visibility rules. Adapters preserve this distinction and useful
native formatting when translating mixed upstream batches, instead of assigning
the whole batch its highest severity.
Providers may return acquired data alongside a Go error. The owning integration
chooses the command outcome, attaches any terminal explanation, and decides which
cause to preserve. Returning diagnostic data does not itself emit messages or
select the invocation outcome. Diagnostic renderers follow the same callback
boundary as other details when invoked through the reporter.
### Presentation observations and closed reporters
```go
type PresentationResult struct {
Output DeliveryResult
Diagnostics DeliveryResult
}
type DeliveryResult struct {
State DeliveryState
RenderError error
WriteError error
}
type DeliveryState uint8
const (
DeliveryNone DeliveryState = iota
DeliverySuppressed
DeliveryComplete
DeliveryIncomplete
)
var ErrReporterClosed = errors.New("reporter is closed")
```
| State | Meaning |
| --- | --- |
| `DeliveryNone` | No content was submitted for this destination. |
| `DeliverySuppressed` | All submitted content was intentionally suppressed, and rendering succeeded. |
| `DeliveryComplete` | All requested delivery completed; other content may have been suppressed. |
| `DeliveryIncomplete` | Rendering or destination writing failed. |
Retain the first rendering failure and first destination-write failure for each
destination. This provides bounded evidence without collecting an event history.
`Outcome.Presentation` is populated by CommandKit during execution and
finalization; it is observation data, not another submission API.
The invocation supplies the reporter. Consumers neither construct nor finalize
it. After closure, message submissions and writer operations return
`ErrReporterClosed`, including in quiet mode. The initial API needs no explicit
flush method, transcript accessor, or verbosity checks. Error-conversion helpers
are deferred pending demonstrated value.
### Application example
This target-API handler uses an application-owned `exportData` operation that
returns a JSON-encodable result with a `UsedDefaults` field:
```go
func runExport(inv commandkit.Invocation) commandkit.Outcome {
report := inv.Reporter()
result, err := exportData(inv.Context)
if err != nil {
return commandkit.Failure("Unable to export the requested data.")
}
if result.UsedDefaults {
_ = report.Warn("Some optional settings used defaults.")
}
if err := report.RenderOutput(func(w io.Writer) error {
return json.NewEncoder(w).Encode(result)
}); err != nil {
return commandkit.Failure("Unable to present the export result.")
}
_ = report.AtEnd().Info("Export completed.")
return commandkit.Success()
}
```
Ignored immediate reporting errors are still recorded and can make the final
invocation unsuccessful. The renderer runs in quiet mode against the discard
destination, preserving serialization failures. The deferred summary follows
the selected verbosity policy and describes the completed export operation;
the final invocation outcome also accounts for presentation failures.
## Upstream diagnostic ownership
ConfigKit and PromptKit remain authoritative for their diagnostic semantics,
severity, source context, and disclosure rules. Integration adapters expose
those capabilities through the shared CommandKit presentation policy.
Support consistent reporting of ConfigKit errors and warnings as its adopted
public API evolves. This roadmap does not assume that a future warning API
already exists or prescribe its upstream shape. Integration-owned commands
select the outcome and submit useful native diagnostic detail for presentation.
Core presents those records under the shared policy. Applications using upstream
libraries directly choose how to respond to domain-specific errors and which
outcomes and messages to supply, as for their other operations.
An integration may receive both a useful substantive report and an operation
failure, as PromptKit smoke testing supports. Preserve the report under the
ordinary substantive-output policy, then present the terminal explanation.
Quiet mode suppresses the report while retaining the explanation. Having a
report does not imply a successful outcome, and returning a failure does not
discard substantive output already prepared for delivery before the terminal step.
Debug output follows the same upstream disclosure and safe-rendering contracts.
Enabling debug does not authorize secret disclosure or bypass owning-library
rules.
## Delivery scope
The completed feature includes the selected reporter and outcome API, ordered
mode/progress selection through normal parsing, shared core presentation, and
both optional integration migrations. Passive provider diagnostics distinguish
independent messages from terminal explanations while preserving native evidence
and rendering. Existing command behavior remains unchanged outside the selected
reporting policy.
Maintained consumers demonstrate ordinary output, immediate/deferred messages,
silent recovery, explicit warnings, failure after partial output, optional cause
transport, and structured terminal explanations. Integration consumers include
native diagnostic context and useful reports returned alongside failures.
Behavioral coverage establishes CommandKit-owned parsing, visibility, ordering,
concurrent reporter coordination, closure, presentation observations, and failure
handling. It preserves existing alias, input/state, discovery, native-report,
and upstream-adapter protections without testing upstream domain correctness.
GoDoc, CLI documentation, consumer guidance, internal documentation, and release
notes describe the implemented boundary under their canonical ownership.
The [implementation plan](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/230187296fae5253cb1773f16c2f1d1a39499ed0/docs/roadmap/implementation.md) defines the concrete migration
steps, focused validation, and completion criteria. This roadmap defines the
target behavior and selected policy choices.
## Architectural preparation completed
- [x] Review and accept [ADR 0014](../adr/0014-invocation-reporting.md) as an
extension for shared reporting, retaining ADR 0005's outcome and supplied-error
preservation decisions.
- [x] Align the [architecture policy](../policy/architecture.md) with the reporting
ownership model, including application ownership of domain-specific error
decisions and scope for useful shared error helpers.
OD-1 through OD-4 and the consumer API signatures are selected. The target end
state includes maintained, executable consumers using those APIs; the
[implementation plan](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/230187296fae5253cb1773f16c2f1d1a39499ed0/docs/roadmap/implementation.md) owns their delivery sequence.

View File

@@ -7,6 +7,7 @@ import (
"io" "io"
"os" "os"
"strconv" "strconv"
"text/template"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
) )
@@ -20,6 +21,15 @@ type reportInput struct {
Scenario string Scenario string
} }
type greetingReport struct {
Count string `json:"count"`
Tags string `json:"tags"`
Entered string `json:"entered"`
Canonical string `json:"canonical"`
}
var greetingTemplate = template.Must(commandkit.ParseTextTemplate("greeting", "Hello; count={{.Count}}; tags={{.Tags}}; entered={{printf \"%q\" .Entered}}; canonical={{printf \"%q\" .Canonical}}\n", nil))
var ( var (
errRecoveredFixture = errors.New("a local read failed and was recovered") errRecoveredFixture = errors.New("a local read failed and was recovered")
errWarningFixture = errors.New("a local warning has a cause") errWarningFixture = errors.New("a local warning has a cause")
@@ -50,10 +60,7 @@ func newApp() (*commandkit.App, error) {
if input.Tags.Set { if input.Tags.Set {
tags = fmt.Sprintf("explicit %v", input.Tags.Value) tags = fmt.Sprintf("explicit %v", input.Tags.Value)
} }
_ = reporter.RenderOutput(func(writer io.Writer) error { _ = reporter.Report(commandkit.Report{Value: greetingReport{Count: count, Tags: tags, Entered: invocation.EnteredPath(), Canonical: invocation.CanonicalPath()}, TextTemplate: greetingTemplate})
_, err := fmt.Fprintf(writer, "Hello; count=%s; tags=%s; entered=%q; canonical=%q\n", count, tags, invocation.EnteredPath(), invocation.CanonicalPath())
return err
})
return commandkit.Success() return commandkit.Success()
}, },
}) })

View File

@@ -59,13 +59,13 @@ func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outco
if !ok || payload == nil { if !ok || payload == nil {
outcome = current.renderHelp(parsed.Path) outcome = current.renderHelp(parsed.Path)
} else if parsed.HelpRequested || !payload.hasRun() { } else if parsed.HelpRequested || !payload.hasRun() {
if _, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.Operands, false); err != nil { if _, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.FlagOccurrences, parsed.Operands, false); err != nil {
outcome = classifyInputError(err) outcome = classifyInputError(err)
} else { } else {
outcome = current.renderHelp(parsed.Path) outcome = current.renderHelp(parsed.Path)
} }
} else { } else {
run, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.Operands, true) run, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.FlagOccurrences, parsed.Operands, true)
if err != nil { if err != nil {
outcome = classifyInputError(err) outcome = classifyInputError(err)
} else { } else {
@@ -107,6 +107,7 @@ func (current *execution) invocation() Invocation {
reporter: current.reporter, reporter: current.reporter,
enteredPath: current.route.enteredPath.String(), enteredPath: current.route.enteredPath.String(),
canonicalPath: current.route.canonicalPath.String(), canonicalPath: current.route.canonicalPath.String(),
renderHelp: func() Outcome { return current.renderHelp(nil) },
} }
} }

View File

@@ -475,10 +475,15 @@ func TestVersionCommandFormatsApplicationAndCommandKitIdentity(t *testing.T) {
} }
out.Reset() out.Reset()
yamlOutcome := app.Execute(context.Background(), []string{"version", "--format=yaml"}, commandkit.Streams{Out: &out, Err: &diagnostic}) yamlOutcome := app.Execute(context.Background(), []string{"version", "--format=yaml"}, commandkit.Streams{Out: &out, Err: &diagnostic})
if yamlOutcome.Kind != commandkit.OutcomeSuccess || out.String() != "name: ExampleApp\nversion: dev\nrevision: abc123\n" { if yamlOutcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), "name: ExampleApp\n") || !strings.Contains(out.String(), "version: dev\n") || !strings.Contains(out.String(), "revision: abc123\n") {
t.Fatalf("YAML version = %#v, output %q", yamlOutcome, out.String()) t.Fatalf("YAML version = %#v, output %q", yamlOutcome, out.String())
} }
out.Reset() out.Reset()
jsonOutcome := app.Execute(context.Background(), []string{"version", "--format=json"}, commandkit.Streams{Out: &out, Err: &diagnostic})
if jsonOutcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), `"revision": "abc123"`) {
t.Fatalf("JSON version = %#v, output %q", jsonOutcome, out.String())
}
out.Reset()
component := app.Execute(context.Background(), []string{"version", "--scope=commandkit"}, commandkit.Streams{Out: &out, Err: &diagnostic}) component := app.Execute(context.Background(), []string{"version", "--scope=commandkit"}, commandkit.Streams{Out: &out, Err: &diagnostic})
if component.Kind != commandkit.OutcomeSuccess || !strings.HasPrefix(out.String(), "CommandKit ") || !strings.HasSuffix(out.String(), "\n") { if component.Kind != commandkit.OutcomeSuccess || !strings.HasPrefix(out.String(), "CommandKit ") || !strings.HasSuffix(out.String(), "\n") {
t.Fatalf("CommandKit version = %#v, output %q", component, out.String()) t.Fatalf("CommandKit version = %#v, output %q", component, out.String())

View File

@@ -23,8 +23,8 @@ func stringFlag(name, short string) Flag[struct{}] {
} }
func TestFlagAliasChainsUseScopedOverridesAndCanonicalEndpoints(t *testing.T) { func TestFlagAliasChainsUseScopedOverridesAndCanonicalEndpoints(t *testing.T) {
inspect := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("format", "f")) inspect := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("style", "f"))
show := flagAliasCommand("show", stringFlag("color", "c"), stringFlag("format", "f")) show := flagAliasCommand("show", stringFlag("color", "c"), stringFlag("style", "f"))
group := Group("Tools", "tools", show) group := Group("Tools", "tools", show)
app, err := New(Identity{Name: "Example"}, app, err := New(Identity{Name: "Example"},
inspect, inspect,
@@ -35,8 +35,8 @@ func TestFlagAliasChainsUseScopedOverridesAndCanonicalEndpoints(t *testing.T) {
FlagAlias("-x", "--color"), FlagAlias("-x", "--color"),
FlagAlias("--tint", "-c"), FlagAlias("--tint", "-c"),
FlagAliasFor("Tools", "--group-help", "--help"), FlagAliasFor("Tools", "--group-help", "--help"),
FlagAliasFor("inspect", "--hue", "--format"), FlagAliasFor("inspect", "--hue", "--style"),
FlagAliasFor("inspect", "--local", "--format"), FlagAliasFor("inspect", "--local", "--style"),
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -44,11 +44,11 @@ func TestFlagAliasChainsUseScopedOverridesAndCanonicalEndpoints(t *testing.T) {
inspectAliases := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling inspectAliases := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling
for source, target := range map[string]string{ for source, target := range map[string]string{
"--colour": "--format", "--colour": "--style",
"--hue": "--format", "--hue": "--style",
"-x": "--color", "-x": "--color",
"--tint": "-c", "--tint": "-c",
"--local": "--format", "--local": "--style",
} { } {
alias, ok := inspectAliases[flagSpellingKey(capturedSpelling(source))] alias, ok := inspectAliases[flagSpellingKey(capturedSpelling(source))]
if !ok || alias.target.token() != target { if !ok || alias.target.token() != target {
@@ -126,7 +126,7 @@ func TestFlagAliasDiscoveryContextsUseGlobalReportingAliases(t *testing.T) {
} }
func TestReportingFlagsAreReservedAndAcceptApplicationShortAliases(t *testing.T) { func TestReportingFlagsAreReservedAndAcceptApplicationShortAliases(t *testing.T) {
for _, name := range []string{"quiet", "verbose", "debug", "progress"} { for _, name := range []string{"quiet", "verbose", "debug", "progress", "format"} {
command := flagAliasCommand("reserved", stringFlag(name, "")) command := flagAliasCommand("reserved", stringFlag(name, ""))
if len(command.record.issues) == 0 { if len(command.record.issues) == 0 {
t.Errorf("capturing --%s did not retain a declaration error", name) t.Errorf("capturing --%s did not retain a declaration error", name)
@@ -154,7 +154,7 @@ func TestReportingFlagsAreReservedAndAcceptApplicationShortAliases(t *testing.T)
} }
func TestReportingFlagSpellingsConflictWithAliasSources(t *testing.T) { func TestReportingFlagSpellingsConflictWithAliasSources(t *testing.T) {
for _, name := range []string{"quiet", "verbose", "debug", "progress"} { for _, name := range []string{"quiet", "verbose", "debug", "progress", "format"} {
if _, err := New(Identity{Name: "Example"}, FlagAlias("--"+name, "--help")); err == nil || !strings.Contains(err.Error(), "conflicts with a canonical spelling") { if _, err := New(Identity{Name: "Example"}, FlagAlias("--"+name, "--help")); err == nil || !strings.Contains(err.Error(), "conflicts with a canonical spelling") {
t.Errorf("alias source --%s returned %v, want a canonical spelling collision", name, err) t.Errorf("alias source --%s returned %v, want a canonical spelling collision", name, err)
} }
@@ -166,7 +166,7 @@ func TestReportingFlagSpellingsConflictWithAliasSources(t *testing.T) {
func TestRoutingSnapshotCarriesReportingMetadataOutsideConsumerBindings(t *testing.T) { func TestRoutingSnapshotCarriesReportingMetadataOutsideConsumerBindings(t *testing.T) {
app, err := New(Identity{Name: "Example"}, app, err := New(Identity{Name: "Example"},
flagAliasCommand("run", stringFlag("format", "f")), flagAliasCommand("run", stringFlag("style", "f")),
Group("tools", "", flagAliasCommand("show")), Group("tools", "", flagAliasCommand("show")),
) )
if err != nil { if err != nil {
@@ -174,7 +174,7 @@ func TestRoutingSnapshotCarriesReportingMetadataOutsideConsumerBindings(t *testi
} }
var check func(*commandmeta.Command) var check func(*commandmeta.Command)
check = func(node *commandmeta.Command) { check = func(node *commandmeta.Command) {
if len(node.CoreFlags) != 4 { if len(node.CoreFlags) != 5 {
t.Errorf("%s core reporting flags = %#v", node.Name, node.CoreFlags) t.Errorf("%s core reporting flags = %#v", node.Name, node.CoreFlags)
} }
for _, flag := range node.Flags { for _, flag := range node.Flags {
@@ -190,15 +190,15 @@ func TestRoutingSnapshotCarriesReportingMetadataOutsideConsumerBindings(t *testi
} }
func TestFlagAliasDuplicatesAndScopesAreDeterministic(t *testing.T) { func TestFlagAliasDuplicatesAndScopesAreDeterministic(t *testing.T) {
command := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("format", "f")) command := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("style", "f"))
duplicate := FlagAlias("--colour", "--color") duplicate := FlagAlias("--colour", "--color")
app, err := New(Identity{Name: "Example"}, command, flagAliasCommand("plain", stringFlag("color", "c")), duplicate, FlagAlias("--colour", "--color"), app, err := New(Identity{Name: "Example"}, command, flagAliasCommand("plain", stringFlag("color", "c")), duplicate, FlagAlias("--colour", "--color"),
FlagAliasFor("inspect", "--colour", "--format")) FlagAliasFor("inspect", "--colour", "--style"))
if err != nil { if err != nil {
t.Fatalf("identical duplicates and scoped override were rejected: %v", err) t.Fatalf("identical duplicates and scoped override were rejected: %v", err)
} }
if got := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--format" { if got := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--style" {
t.Fatalf("scoped alias target = %s, want --format", got) t.Fatalf("scoped alias target = %s, want --style", got)
} }
if got := app.flagAliases[commandpath.Path{"plain"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--color" { if got := app.flagAliases[commandpath.Path{"plain"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--color" {
t.Fatalf("effective global alias target = %s, want --color", got) t.Fatalf("effective global alias target = %s, want --color", got)
@@ -207,11 +207,11 @@ func TestFlagAliasDuplicatesAndScopesAreDeterministic(t *testing.T) {
for name, rules := range map[string][]RoutingRule{ for name, rules := range map[string][]RoutingRule{
"competing global targets": { "competing global targets": {
FlagAlias("--colour", "--color"), FlagAlias("--colour", "--color"),
FlagAlias("--colour", "--format"), FlagAlias("--colour", "--style"),
}, },
"competing scoped targets": { "competing scoped targets": {
FlagAliasFor("inspect", "--colour", "--color"), FlagAliasFor("inspect", "--colour", "--color"),
FlagAliasFor("inspect", "--colour", "--format"), FlagAliasFor("inspect", "--colour", "--style"),
}, },
"unknown scope": {FlagAliasFor("missing", "--colour", "--color")}, "unknown scope": {FlagAliasFor("missing", "--colour", "--color")},
"missing scoped destination": {FlagAliasFor("inspect", "--colour", "--missing")}, "missing scoped destination": {FlagAliasFor("inspect", "--colour", "--missing")},

View File

@@ -3,6 +3,7 @@ package commandkit
import ( import (
"errors" "errors"
"fmt" "fmt"
"sort"
) )
// ErrFlagInput identifies a missing required captured flag selected for an input. // ErrFlagInput identifies a missing required captured flag selected for an input.
@@ -13,7 +14,8 @@ var ErrFlagWiring = errors.New("flag wiring is invalid")
// FlagValues stores invocation-local converted values and presence for captured // FlagValues stores invocation-local converted values and presence for captured
// flags. It does not retain an input instance; values can be applied to // flags. It does not retain an input instance; values can be applied to
// independently initialized destinations. // independently initialized destinations. Captured occurrence flags retain
// their relative command-line order across the selected bindings.
type FlagValues[I any] struct { type FlagValues[I any] struct {
values map[*bindingIdentity]preparedValue[I] values map[*bindingIdentity]preparedValue[I]
} }
@@ -42,7 +44,7 @@ func CaptureFlags[Outer, I any](flags []Flag[I], target func(*Outer) *FlagValues
if err != nil { if err != nil {
return preparedValue[Outer]{}, err return preparedValue[Outer]{}, err
} }
return preparedValue[Outer]{ result := preparedValue[Outer]{
identity: identity, identity: identity,
supplied: prepared.supplied, supplied: prepared.supplied,
apply: func(outer *Outer) { apply: func(outer *Outer) {
@@ -55,7 +57,11 @@ func CaptureFlags[Outer, I any](flags []Flag[I], target func(*Outer) *FlagValues
} }
store.values[inner.binding.identity] = prepared store.values[inner.binding.identity] = prepared
}, },
}, nil }
for _, occurrence := range prepared.ordered {
result.ordered = append(result.ordered, preparedOccurrence[Outer]{ordinal: occurrence.ordinal})
}
return result, nil
} }
} }
captured[index] = Flag[Outer]{binding: binding} captured[index] = Flag[Outer]{binding: binding}
@@ -66,11 +72,25 @@ func CaptureFlags[Outer, I any](flags []Flag[I], target func(*Outer) *FlagValues
// ApplyTo applies selected original captured flags to an initialized // ApplyTo applies selected original captured flags to an initialized
// destination. A missing captured binding or nil destination matches // destination. A missing captured binding or nil destination matches
// ErrFlagWiring. A required selected value that was not supplied matches // ErrFlagWiring. A required selected value that was not supplied matches
// ErrFlagInput. Repeated core-owned values are copied for each application. // ErrFlagInput. Ordinary selected values are applied first, followed by selected
// occurrence values in command-line order. Repeated core-owned values are copied
// for each application.
func (values FlagValues[I]) ApplyTo(target *I, selected ...Flag[I]) error { func (values FlagValues[I]) ApplyTo(target *I, selected ...Flag[I]) error {
return values.applyTo(target, false, selected...)
}
// ApplySuppliedTo applies only explicitly supplied selected flags, preserving
// the destination's existing values for omitted flags. It checks selected
// bindings and required values like ApplyTo and preserves occurrence order.
func (values FlagValues[I]) ApplySuppliedTo(target *I, selected ...Flag[I]) error {
return values.applyTo(target, true, selected...)
}
func (values FlagValues[I]) applyTo(target *I, suppliedOnly bool, selected ...Flag[I]) error {
if target == nil { if target == nil {
return fmt.Errorf("%w: destination is nil", ErrFlagWiring) return fmt.Errorf("%w: destination is nil", ErrFlagWiring)
} }
preparedValues := make([]preparedValue[I], 0, len(selected))
for _, flag := range selected { for _, flag := range selected {
binding := flag.binding binding := flag.binding
if binding.identity == nil { if binding.identity == nil {
@@ -83,7 +103,25 @@ func (values FlagValues[I]) ApplyTo(target *I, selected ...Flag[I]) error {
if binding.meta.Required && !prepared.supplied { if binding.meta.Required && !prepared.supplied {
return fmt.Errorf("flag --%s: %w", binding.meta.Name, ErrFlagInput) return fmt.Errorf("flag --%s: %w", binding.meta.Name, ErrFlagInput)
} }
prepared.apply(target) if suppliedOnly && !prepared.supplied {
continue
}
preparedValues = append(preparedValues, prepared)
}
var ordered []preparedOccurrence[I]
for _, prepared := range preparedValues {
if prepared.apply != nil {
prepared.apply(target)
}
for _, occurrence := range prepared.ordered {
if occurrence.apply != nil {
ordered = append(ordered, occurrence)
}
}
}
sort.SliceStable(ordered, func(i, j int) bool { return *ordered[i].ordinal < *ordered[j].ordinal })
for _, occurrence := range ordered {
occurrence.apply(target)
} }
return nil return nil
} }

82
format_flag_test.go Normal file
View File

@@ -0,0 +1,82 @@
package commandkit
import (
"bytes"
"context"
"encoding/json"
"reflect"
"strings"
"testing"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
)
func TestSharedFormatFlag(t *testing.T) {
factories, runs := 0, 0
command := NewCommand(CommandSpec[struct{}]{Name: "run", NewInput: func() struct{} { factories++; return struct{}{} }, Run: func(inv Invocation, _ struct{}) Outcome {
runs++
_ = inv.Reporter().Report(Report{Value: map[string]any{"answer": 42}})
return Success()
}})
app, err := New(Identity{Name: "Example", Version: "1"}, command, FlagAlias("--fmt", "--format"))
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
args []string
expected string
}{
{[]string{"run", "--format", "text"}, "yaml"},
{[]string{"run", "--format=yaml"}, "yaml"},
{[]string{"run", "--fmt=json"}, "json"},
{[]string{"run", "--format=yaml", "--fmt", "json"}, "json"},
{[]string{"run"}, "yaml"},
} {
var out bytes.Buffer
got := app.Execute(context.Background(), tc.args, Streams{Out: &out})
if got.Status != 0 {
t.Fatalf("%v: %#v", tc.args, got)
}
var value any
if tc.expected == "json" {
dec := json.NewDecoder(&out)
dec.UseNumber()
err = dec.Decode(&value)
} else {
value, err = DecodeYAMLReport(&out)
}
if err != nil || !reflect.DeepEqual(value, map[string]any{"answer": json.Number("42")}) {
t.Fatalf("%v: %#v %v", tc.args, value, err)
}
}
beforeFactories, beforeRuns := factories, runs
for _, args := range [][]string{{"run", "--format="}, {"run", "--fmt=xml"}, {"run", "--format=bad", "--format=json"}, {"run", "--format", "--quiet"}} {
var diagnostics bytes.Buffer
got := app.Execute(context.Background(), args, Streams{Err: &diagnostics})
if got.Kind != OutcomeUsage || !strings.Contains(diagnostics.String(), "format") {
t.Fatalf("%v: %#v %q", args, got, &diagnostics)
}
}
if factories != beforeFactories || runs != beforeRuns {
t.Fatalf("invalid formats ran domain code: factories %d, runs %d", factories, runs)
}
var help bytes.Buffer
got := app.Execute(context.Background(), []string{"run", "--help"}, Streams{Out: &help})
if got.Status != 0 || !strings.Contains(help.String(), "--format") || factories != beforeFactories || runs != beforeRuns {
t.Fatalf("help: %#v, %q", got, &help)
}
}
func TestSharedFormatParseOrderAndTerminator(t *testing.T) {
app, err := New(Identity{Name: "Example"}, flagAliasCommand("run", stringFlag("message", "")), FlagAlias("--fmt", "--format"))
if err != nil {
t.Fatal(err)
}
result := parseReportingArgs(app, []string{"run", "--verbose", "--fmt", "json", "--quiet", "--message=--format", "--", "--format=yaml"})
if result.Failure != nil || result.Reporting.Format != "json" || result.Reporting.Mode != commandmeta.ReportingModeQuiet || !reflect.DeepEqual(result.Operands, []string{"--format=yaml"}) {
t.Fatalf("%#v", result)
}
if result.FlagOccurrences[1].Spelling != "--fmt" || result.FlagOccurrences[1].RawValue != "json" {
t.Fatalf("%#v", result.FlagOccurrences)
}
}

View File

@@ -52,12 +52,22 @@ type Invocation struct {
reporter *Reporter reporter *Reporter
enteredPath string enteredPath string
canonicalPath string canonicalPath string
renderHelp func() Outcome
} }
// Reporter returns the reporting facility supplied for this invocation. A // Reporter returns the reporting facility supplied for this invocation. A
// manually constructed Invocation has no reporter. // manually constructed Invocation has no reporter.
func (inv Invocation) Reporter() *Reporter { return inv.reporter } func (inv Invocation) Reporter() *Reporter { return inv.reporter }
// Help renders the selected command's help through this invocation's output.
// It returns an operational failure when the invocation has no help renderer.
func (inv Invocation) Help() Outcome {
if inv.renderHelp == nil {
return Failure("Command help is unavailable.")
}
return inv.renderHelp()
}
// EnteredPath returns the command path used to invoke the selected command, // EnteredPath returns the command path used to invoke the selected command,
// excluding the executable name, flags, and operands. It is empty for an // excluding the executable name, flags, and operands. It is empty for an
// Invocation that was not created by command execution. // Invocation that was not created by command execution.

View File

@@ -7,9 +7,9 @@ itself.
## Module ## Module
The module path is `go.maximumdirect.net/ckconfig`. Version v0.2.0 uses The module path is `go.maximumdirect.net/ckconfig`. The v0.4.0 candidate requires Go
`go.maximumdirect.net/commandkit v0.3.0` and 1.27.0 and uses `go.maximumdirect.net/commandkit v0.4.0` and
ConfigKit; it does not import PromptKit. See the `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) [consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#configkit-scopes)
for exact callback and lifetime contracts. for exact callback and lifetime contracts.
@@ -26,9 +26,9 @@ scopes, selectors, input checks, and state shared by the selected acquisitions
or comparison in a config invocation. `ckconfig.FromConfigKit` adapts a typed or comparison in a config invocation. `ckconfig.FromConfigKit` adapts a typed
ConfigKit loader. The `config` command family exposes scope discovery and the ConfigKit loader. The `config` command family exposes scope discovery and the
validate, show, sources, explain, and diff operations supported by the declared validate, show, sources, explain, and diff operations supported by the declared
scopes. Source reports include native destination, placement, unresolved scopes. Source reports include native destination, placement, destination
destination, and source version-check evidence. Comparison returns ConfigKit's fallback, version evidence, and empty-import selections. Comparison returns
native records to the YAML report path; the 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) [CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#compare-configuration-selections)
owns its operator details. owns its operator details.

View File

@@ -7,11 +7,12 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
) )
type adapterSettings struct { type adapterSettings struct {
@@ -27,7 +28,7 @@ func adapterDefinition() configkit.Definition[adapterSettings] {
validation.Warning(configkit.Keys("greeting"), "using the default greeting") validation.Warning(configkit.Keys("greeting"), "using the default greeting")
} }
}, },
Secret: func(path configkit.Path) bool { return path.String() == "/secret" }, Sensitive: func(path configkit.Path) bool { return path.String() == "/secret" },
} }
} }
@@ -66,7 +67,7 @@ func TestFromConfigKitCompareReturnsNativeChangesAndLoadsEqualSelections(t *test
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(calls) != 2 || calls[0].selection != selection || calls[1].selection != selection { if len(calls) != 2 || !reflect.DeepEqual(calls[0].selection, selection) || !reflect.DeepEqual(calls[1].selection, selection) {
t.Fatalf("loader calls = %#v", calls) t.Fatalf("loader calls = %#v", calls)
} }
if calls[0].input.Label != "before" || calls[1].input.Label != "after" || calls[0].state != state || calls[1].state != state || *state != 2 { if calls[0].input.Label != "before" || calls[1].input.Label != "after" || calls[0].state != state || calls[1].state != state || *state != 2 {
@@ -97,7 +98,7 @@ func TestFromConfigKitCompareIgnoresDefaultAndResolutionHistoryForEqualValues(t
Resolve: func(value *resolvedSettings, resolution *configkit.Resolution) error { Resolve: func(value *resolvedSettings, resolution *configkit.Resolution) error {
if value.Greeting != "resolved" { if value.Greeting != "resolved" {
value.Greeting = "resolved" value.Greeting = "resolved"
resolution.Record(configkit.Keys("greeting"), configkit.ChangeNormalization, "canonical greeting") resolution.RecordNormalization(configkit.Keys("greeting"), "canonical greeting")
} }
return nil return nil
}, },

View File

@@ -7,8 +7,8 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
) )
func TestComparisonCapabilitiesAreIndependentAndCaptured(t *testing.T) { func TestComparisonCapabilitiesAreIndependentAndCaptured(t *testing.T) {

View File

@@ -3,64 +3,14 @@ package ckconfig
import ( import (
"context" "context"
"io" "io"
"reflect"
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
) )
func TestComparisonSelectionInheritsFieldsIndependently(t *testing.T) {
leftConfig := commandkit.Supplied[string]{Value: "before.yml", Set: true}
leftOverlay := commandkit.Supplied[string]{Value: "base", Set: true}
tests := []struct {
name string
left Selection
right Selection
want Selection
}{
{
name: "omitted fields stay omitted",
},
{
name: "both fields inherit supplied state",
left: Selection{Config: leftConfig, Overlay: leftOverlay},
want: Selection{Config: leftConfig, Overlay: leftOverlay},
},
{
name: "explicit empty config overrides while overlay inherits",
left: Selection{Config: leftConfig, Overlay: leftOverlay},
right: Selection{Config: commandkit.Supplied[string]{Set: true}},
want: Selection{Config: commandkit.Supplied[string]{Set: true}, Overlay: leftOverlay},
},
{
name: "explicit overlay overrides while config inherits",
left: Selection{Config: leftConfig, Overlay: leftOverlay},
right: Selection{Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}},
want: Selection{Config: leftConfig, Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}},
},
{
name: "explicit empty overlay remains supplied",
left: Selection{Config: leftConfig, Overlay: leftOverlay},
right: Selection{Overlay: commandkit.Supplied[string]{Set: true}},
want: Selection{Config: leftConfig, Overlay: commandkit.Supplied[string]{Set: true}},
},
{
name: "both explicit right fields replace left",
left: Selection{Config: leftConfig, Overlay: leftOverlay},
right: Selection{Config: commandkit.Supplied[string]{Value: "after.yml", Set: true}, Overlay: commandkit.Supplied[string]{Value: "new", Set: true}},
want: Selection{Config: commandkit.Supplied[string]{Value: "after.yml", Set: true}, Overlay: commandkit.Supplied[string]{Value: "new", Set: true}},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := inheritComparisonSelection(test.left, test.right); got != test.want {
t.Fatalf("inherited selection = %#v, want %#v", got, test.want)
}
})
}
}
func TestComparisonHelpLimitsFlagsAndDefaultsFormatToYAML(t *testing.T) { func TestComparisonHelpLimitsFlagsAndDefaultsFormatToYAML(t *testing.T) {
scope := Scope[struct{}, struct{}]{ scope := Scope[struct{}, struct{}]{
Name: "app", Operations: []Operation{Diff, Show, Explain, Validate, Sources}, Name: "app", Operations: []Operation{Diff, Show, Explain, Validate, Sources},
@@ -83,21 +33,12 @@ func TestComparisonHelpLimitsFlagsAndDefaultsFormatToYAML(t *testing.T) {
if !strings.Contains(diffHelp, "--against-config") || !strings.Contains(diffHelp, "--against-overlay") { if !strings.Contains(diffHelp, "--against-config") || !strings.Contains(diffHelp, "--against-overlay") {
t.Fatalf("diff help omits right-side selection flags:\n%s", diffHelp) t.Fatalf("diff help omits right-side selection flags:\n%s", diffHelp)
} }
if !strings.Contains(diffHelp, "default: yaml") {
t.Fatalf("diff help does not show the YAML default:\n%s", diffHelp)
}
for _, operation := range []string{"validate", "show", "sources", "explain"} { for _, operation := range []string{"validate", "show", "sources", "explain"} {
operationHelp := help("config", operation, "--help") operationHelp := help("config", operation, "--help")
if strings.Contains(operationHelp, "--against-config") || strings.Contains(operationHelp, "--against-overlay") { if strings.Contains(operationHelp, "--against-config") || strings.Contains(operationHelp, "--against-overlay") {
t.Errorf("%s exposes comparison-only flags:\n%s", operation, operationHelp) t.Errorf("%s exposes comparison-only flags:\n%s", operation, operationHelp)
} }
if operation == "show" && !strings.Contains(operationHelp, "default: yaml") { if operation != "explain" && operation != "show" && strings.Contains(operationHelp, "--path") {
t.Errorf("show format default changed:\n%s", operationHelp)
}
if operation != "show" && !strings.Contains(operationHelp, "default: text") {
t.Errorf("%s format default changed:\n%s", operation, operationHelp)
}
if operation != "explain" && strings.Contains(operationHelp, "--path") {
t.Errorf("%s exposes the explain-only path flag:\n%s", operation, operationHelp) t.Errorf("%s exposes the explain-only path flag:\n%s", operation, operationHelp)
} }
} }
@@ -107,7 +48,7 @@ func TestComparisonHelpLimitsFlagsAndDefaultsFormatToYAML(t *testing.T) {
} }
} }
func TestComparisonPreparationAcceptsOnlyYAML(t *testing.T) { func TestComparisonPreparation(t *testing.T) {
inputCalls, stateCalls := 0, 0 inputCalls, stateCalls := 0, 0
prepared := captureSuite(Suite[struct{}, struct{}]{ prepared := captureSuite(Suite[struct{}, struct{}]{
NewInput: func() struct{} { inputCalls++; return struct{}{} }, NewInput: func() struct{} { inputCalls++; return struct{}{} },
@@ -123,30 +64,22 @@ func TestComparisonPreparationAcceptsOnlyYAML(t *testing.T) {
input := &configCommandInput[struct{}, struct{}]{ input := &configCommandInput[struct{}, struct{}]{
selection: Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}}, selection: Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}},
against: Selection{Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}}, against: Selection{Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}},
format: commandkit.Supplied[string]{Value: "yaml", Set: true},
} }
if err := prepareConfigOperation(prepared, Diff, input); err != nil { if err := prepareConfigOperation(prepared, Diff, input); err != nil {
t.Fatalf("explicit YAML format rejected: %v", err) t.Fatalf("explicit YAML format rejected: %v", err)
} }
if input.plan == nil || input.plan.format != "yaml" { if input.plan == nil {
t.Fatalf("comparison plan = %#v", input.plan) t.Fatalf("comparison plan = %#v", input.plan)
} }
if input.plan.scopeName != "app" { if input.plan.scopeName != "app" {
t.Fatalf("comparison default scope = %q, want app", input.plan.scopeName) t.Fatalf("comparison default scope = %q, want app", input.plan.scopeName)
} }
if want := (Selection{ if want := (Selection{
Config: commandkit.Supplied[string]{Value: "before.yml", Set: true},
Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}, Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true},
}); input.against != want { }); !reflect.DeepEqual(input.against, want) {
t.Fatalf("resolved right selection = %#v, want %#v", input.against, want) t.Fatalf("resolved right selection = %#v, want %#v", input.against, want)
} }
for _, format := range []string{"text", "json"} {
input := &configCommandInput[struct{}, struct{}]{format: commandkit.Supplied[string]{Value: format}}
if err := prepareConfigOperation(prepared, Diff, input); err == nil {
t.Errorf("comparison format %q was accepted", format)
}
}
if inputCalls != 2 || stateCalls != 0 { if inputCalls != 2 || stateCalls != 0 {
t.Fatalf("comparison preparation calls input/state: %d/%d", inputCalls, stateCalls) t.Fatalf("comparison preparation calls input/state: %d/%d", inputCalls, stateCalls)
} }
@@ -170,8 +103,7 @@ func TestComparisonAvailabilityPreflightPrecedesFactories(t *testing.T) {
}) })
for _, scope := range []string{"missing", "all", "plain"} { for _, scope := range []string{"missing", "all", "plain"} {
input := &configCommandInput[struct{}, struct{}]{ input := &configCommandInput[struct{}, struct{}]{
scope: commandkit.Supplied[string]{Value: scope, Set: true}, scope: commandkit.Supplied[string]{Value: scope, Set: true},
format: commandkit.Supplied[string]{Value: "yaml"},
} }
if err := prepareConfigOperation(prepared, Diff, input); err == nil { if err := prepareConfigOperation(prepared, Diff, input); err == nil {
t.Errorf("comparison scope %q was accepted", scope) t.Errorf("comparison scope %q was accepted", scope)

View File

@@ -4,12 +4,13 @@ import (
"context" "context"
"errors" "errors"
"io" "io"
"reflect"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
) )
func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T) { func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T) {
@@ -62,7 +63,7 @@ func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T)
} }
return struct{}{} return struct{}{}
}, },
Selectors: []Selector[*input]{{Binding: enabled}, {Binding: limit}, {Binding: tags}}, Selectors: []Selector[*input]{{Binding: enabled, AgainstName: "against-enabled"}, {Binding: limit, AgainstName: "against-limit"}, {Binding: tags, AgainstName: "against-tag"}},
CheckInput: func(scope string, selection Selection, value *input) error { CheckInput: func(scope string, selection Selection, value *input) error {
checkCalls++ checkCalls++
preparedInputs = append(preparedInputs, value) preparedInputs = append(preparedInputs, value)
@@ -91,7 +92,7 @@ func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T)
app := newConfigTestApp(t, Command(suite)) app := newConfigTestApp(t, Command(suite))
outcome := app.Execute(context.Background(), []string{ outcome := app.Execute(context.Background(), []string{
"config", "diff", "--config", "same.yml", "--against-config", "same.yml", "--overlay=", "config", "diff", "--config", "same.yml", "--against-config", "same.yml", "--overlay=",
"--enabled=false", "--limit=0", "--tag", "alpha", "--tag", "beta", "--enabled=false", "--limit=0", "--tag", "alpha", "--tag", "beta", "--against-enabled=false", "--against-limit=0", "--against-tag", "alpha", "--against-tag", "beta", "--against-overlay=",
}, commandkit.Streams{Out: io.Discard, Err: io.Discard}) }, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess { if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("valid comparison outcome = %#v", outcome) t.Fatalf("valid comparison outcome = %#v", outcome)
@@ -99,14 +100,14 @@ func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T)
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 { if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 {
t.Fatalf("preparation calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls) t.Fatalf("preparation calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
} }
if parseCalls != 3 { if parseCalls != 6 {
t.Fatalf("typed values were converted %d times, want once per supplied occurrence", parseCalls) t.Fatalf("typed values were converted %d times, want once per supplied occurrence", parseCalls)
} }
wantSelection := Selection{ wantSelection := Selection{
Config: commandkit.Supplied[string]{Value: "same.yml", Set: true}, Config: commandkit.Supplied[string]{Value: "same.yml", Set: true},
Overlay: commandkit.Supplied[string]{Set: true}, Overlay: commandkit.Supplied[string]{Set: true},
} }
if len(selections) != 2 || selections[0] != wantSelection || selections[1] != wantSelection { if len(selections) != 2 || !reflect.DeepEqual(selections[0], wantSelection) || !reflect.DeepEqual(selections[1], wantSelection) {
t.Fatalf("side selections = %#v, want equal %#v values", selections, wantSelection) t.Fatalf("side selections = %#v, want equal %#v values", selections, wantSelection)
} }
} }
@@ -115,7 +116,7 @@ func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) {
type input struct{ ID int } type input struct{ ID int }
type state struct{ Label string } type state struct{ Label string }
inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls := 0, 0, 0, 0, 0 inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls := 0, 0, 0, 0, 0
checked := make(map[Selection]input) var checked []input
shared := &state{Label: "shared"} shared := &state{Label: "shared"}
var request ComparisonRequest[input] var request ComparisonRequest[input]
app := newConfigTestApp(t, Command(Suite[input, *state]{ app := newConfigTestApp(t, Command(Suite[input, *state]{
@@ -135,7 +136,7 @@ func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) {
if scope != "app" { if scope != "app" {
return errors.New("unexpected scope") return errors.New("unexpected scope")
} }
checked[selection] = value checked = append(checked, value)
return nil return nil
}, },
Scopes: []Scope[input, *state]{{ Scopes: []Scope[input, *state]{{
@@ -161,11 +162,11 @@ func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) {
t.Fatalf("comparison outcome = %#v", outcome) t.Fatalf("comparison outcome = %#v", outcome)
} }
left := Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}} left := Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}}
right := Selection{Config: left.Config, Overlay: commandkit.Supplied[string]{Set: true}} right := Selection{Overlay: commandkit.Supplied[string]{Set: true}}
if request.Left.Selection != left || request.Right.Selection != right { if !reflect.DeepEqual(request.Left.Selection, left) || !reflect.DeepEqual(request.Right.Selection, right) {
t.Fatalf("provider selections = %#v", request) t.Fatalf("provider selections = %#v", request)
} }
if len(checked) != 2 || request.Left.Input != checked[left] || request.Right.Input != checked[right] { if len(checked) != 2 || request.Left.Input != checked[0] || request.Right.Input != checked[1] {
t.Fatalf("provider inputs = %#v, checked = %#v", request, checked) t.Fatalf("provider inputs = %#v, checked = %#v", request, checked)
} }
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 || compareCalls != 1 || acquireCalls != 0 { if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 || compareCalls != 1 || acquireCalls != 0 {
@@ -191,7 +192,7 @@ func TestComparisonDoesNotRetainPartialPlanAfterSideFailure(t *testing.T) {
}, },
}}, }},
}) })
commandInput := &configCommandInput[struct{}, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}} commandInput := &configCommandInput[struct{}, struct{}]{}
if err := prepareConfigOperation(prepared, Diff, commandInput); !errors.Is(err, failure) { if err := prepareConfigOperation(prepared, Diff, commandInput); !errors.Is(err, failure) {
t.Fatalf("right-side input failure = %v", err) t.Fatalf("right-side input failure = %v", err)
} }
@@ -217,7 +218,7 @@ func TestComparisonNilInputFactoryUsesZeroValue(t *testing.T) {
}, },
}}, }},
}) })
commandInput := &configCommandInput[*int, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}} commandInput := &configCommandInput[*int, struct{}]{}
if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil { if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -246,7 +247,7 @@ func TestComparisonPreservesCallerOwnedInputAliases(t *testing.T) {
}, },
}}, }},
}) })
commandInput := &configCommandInput[*int, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}} commandInput := &configCommandInput[*int, struct{}]{}
if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil { if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -7,8 +7,8 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
) )
func TestCompletionUsesInstalledScopeCapabilitiesWithoutAcquisition(t *testing.T) { func TestCompletionUsesInstalledScopeCapabilitiesWithoutAcquisition(t *testing.T) {
@@ -37,7 +37,7 @@ func TestCompletionUsesInstalledScopeCapabilitiesWithoutAcquisition(t *testing.T
}{ }{
{[]string{"__complete", "config", "diff", "--scope="}, "app\n", "other\n"}, {[]string{"__complete", "config", "diff", "--scope="}, "app\n", "other\n"},
{[]string{"__complete", "config", "validate", "--scope="}, "other\n", "all\n"}, {[]string{"__complete", "config", "validate", "--scope="}, "other\n", "all\n"},
{[]string{"__complete", "config", "diff", "--format="}, "yaml\n", "text\n"}, {[]string{"__complete", "config", "diff", "--format="}, "yaml\n", "invalid\n"},
{[]string{"__complete", "config", "validate", "--format="}, "text\n", "invalid\n"}, {[]string{"__complete", "config", "validate", "--format="}, "text\n", "invalid\n"},
{[]string{"__complete", "config", "diff", "--against-config=fixtures/"}, ":0\n", ":4\n"}, {[]string{"__complete", "config", "diff", "--against-config=fixtures/"}, ":0\n", ":4\n"},
} { } {

View File

@@ -7,8 +7,8 @@ import (
"io" "io"
"strconv" "strconv"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
) )
// FromConfigKit adapts a typed ConfigKit loader to all five native // FromConfigKit adapts a typed ConfigKit loader to all five native
@@ -26,6 +26,7 @@ func FromConfigKit[I, S, T any](
Name: name, Name: name,
Summary: summary, Summary: summary,
Operations: []Operation{Validate, Show, Sources, Explain, Diff}, Operations: []Operation{Validate, Show, Sources, Explain, Diff},
Features: ScopeFeatures{PathDiscovery: true, PathShow: true},
} }
if load == nil { if load == nil {
return WithScopeError(scope, fmt.Errorf("ConfigKit scope %q loader must not be nil", name)) return WithScopeError(scope, fmt.Errorf("ConfigKit scope %q loader must not be nil", name))
@@ -34,22 +35,25 @@ func FromConfigKit[I, S, T any](
scope.Acquire = func(ctx context.Context, request Request, input I, state S) (commandkit.Acquired[View], error) { scope.Acquire = func(ctx context.Context, request Request, input I, state S) (commandkit.Acquired[View], error) {
result, err := load(ctx, request.Selection, input, state) result, err := load(ctx, request.Selection, input, state)
acquired := commandkit.Acquired[View]{} acquired := commandkit.Acquired[View]{}
if result != nil {
acquired.Value = View{Effective: result, Sources: result, Explanation: result}
acquired.Diagnostics, acquired.Explanation = configKitDiagnostics(name, result.Sources(), result.Diagnostics())
}
if err != nil { if err != nil {
var loadError *configkit.LoadError var loadError *configkit.LoadError
if errors.As(err, &loadError) && loadError != nil { if errors.As(err, &loadError) && loadError != nil {
warnings, explanation := configKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics()) records := loadError.Diagnostics()
acquired.Diagnostics = append(acquired.Diagnostics, warnings...) messages, explanation := configKitDiagnostics(name, loadError.Sources(), records)
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, explanation) 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 result == nil {
return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name) return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
} }
metadata := result.Metadata()
records := result.Diagnostics()
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.Explanation = explanation
return acquired, nil return acquired, nil
} }
scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) { scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
@@ -99,11 +103,6 @@ func loadComparisonSide[I, S, T any](
result, err := load(ctx, request.Selection, request.Input, state) result, err := load(ctx, request.Selection, request.Input, state)
var diagnostics []commandkit.Diagnostic var diagnostics []commandkit.Diagnostic
var explanation commandkit.Explanation var explanation commandkit.Explanation
if result != nil {
warnings, terminal := configKitComparisonDiagnostics(scope, side, result.Sources(), result.Diagnostics())
diagnostics = append(diagnostics, warnings...)
explanation = appendConfigKitExplanation(explanation, terminal)
}
if err != nil { if err != nil {
var loadError *configkit.LoadError var loadError *configkit.LoadError
if errors.As(err, &loadError) && loadError != nil { if errors.As(err, &loadError) && loadError != nil {
@@ -116,6 +115,7 @@ func loadComparisonSide[I, S, T any](
if result == nil { if result == nil {
return nil, diagnostics, explanation, fmt.Errorf("ConfigKit scope %q %s loader returned a nil result", scope, side) return nil, diagnostics, explanation, fmt.Errorf("ConfigKit scope %q %s loader returned a nil result", scope, side)
} }
diagnostics, explanation = configKitComparisonDiagnostics(scope, side, result.Sources(), result.Diagnostics())
return result, diagnostics, explanation, nil return result, diagnostics, explanation, nil
} }

View File

@@ -4,8 +4,8 @@ import (
"context" "context"
"io" "io"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
) )
// Selection carries the configuration and overlay choices supplied for a // Selection carries the configuration and overlay choices supplied for a
@@ -15,6 +15,8 @@ type Selection struct {
Config commandkit.Supplied[string] Config commandkit.Supplied[string]
// Overlay is the caller-supplied overlay path, if present. // Overlay is the caller-supplied overlay path, if present.
Overlay commandkit.Supplied[string] Overlay commandkit.Supplied[string]
// Overrides contains ordered CLI override records, or nil when none were supplied.
Overrides []configkit.Override
} }
// Operation identifies a supported standard configuration operation. // Operation identifies a supported standard configuration operation.
@@ -45,7 +47,7 @@ type Request struct {
// initialized and checked typed input. The supplied-state fields of Selection // initialized and checked typed input. The supplied-state fields of Selection
// are preserved for the scope's loader. // are preserved for the scope's loader.
type ComparisonSide[I any] struct { type ComparisonSide[I any] struct {
// Selection carries the explicit config and overlay inputs for this side. // Selection carries the explicit config, overlay, and override inputs for this side.
Selection Selection Selection Selection
// Input is initialized and checked for this side before comparison. // Input is initialized and checked for this side before comparison.
Input I Input I
@@ -83,6 +85,29 @@ type View struct {
Effective EffectiveWriter Effective EffectiveWriter
Sources SourceReader Sources SourceReader
Explanation ExplanationReader Explanation ExplanationReader
// Metadata supplies native configuration path metadata when available.
Metadata *configkit.Metadata
// Findings supplies native diagnostics and their ordinary presentations.
// Nil means unavailable; a nonnil value may contain no records.
Findings *Findings
}
// Findings associates native diagnostic records with their ordinary messages.
type Findings struct {
// Records contains the native diagnostic records, which may be empty.
Records []configkit.Diagnostic
// Messages contains ordinary presentations associated with Records.
Messages []commandkit.Diagnostic
}
// ScopeFeatures describes optional modes within configuration operations.
type ScopeFeatures struct {
// PathDiscovery indicates support for metadata-backed path discovery.
PathDiscovery bool
// PathShow indicates support for displaying a selected effective path.
PathShow bool
// Overrides indicates that the loader accepts explicit configuration overrides.
Overrides bool
} }
// Scope declares the operations and providers for one named configuration. // Scope declares the operations and providers for one named configuration.
@@ -93,10 +118,12 @@ type View struct {
type Scope[I, S any] struct { type Scope[I, S any] struct {
// Name identifies the scope in CLI selection and reports. // Name identifies the scope in CLI selection and reports.
Name string Name string
// Summary describes the scope. Scope discovery reports name and operations only. // Summary describes the scope.
Summary string Summary string
// Operations selects the standard handlers exposed for this scope. // Operations selects the standard handlers exposed for this scope.
Operations []Operation Operations []Operation
// Features describes optional modes supported by this scope.
Features ScopeFeatures
// Acquire loads the selected configuration for one ordinary inspection // Acquire loads the selected configuration for one ordinary inspection
// invocation. It returns independent diagnostics and terminal context // invocation. It returns independent diagnostics and terminal context
// separately from the native value and optional operation error. // separately from the native value and optional operation error.
@@ -116,6 +143,9 @@ type Selector[I any] struct {
Binding commandkit.Flag[I] Binding commandkit.Flag[I]
// Scopes lists the scope names that receive this flag's value. // Scopes lists the scope names that receive this flag's value.
Scopes []string Scopes []string
// AgainstName optionally declares the long name of an independent
// right-side comparison flag using the same parser and setter.
AgainstName string
} }
// Suite declares the typed input, shared state, selectors, and configuration // Suite declares the typed input, shared state, selectors, and configuration

View File

@@ -6,11 +6,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"reflect"
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
) )
func TestWithScopeErrorCopiesAndRetainsCauses(t *testing.T) { func TestWithScopeErrorCopiesAndRetainsCauses(t *testing.T) {
@@ -91,10 +92,10 @@ func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if gotContext != ctx || gotSelection != selection || gotInput.Label != "typed" || gotState != stateValue || stateValue.Calls != 1 { if gotContext != ctx || !reflect.DeepEqual(gotSelection, selection) || gotInput.Label != "typed" || gotState != stateValue || stateValue.Calls != 1 {
t.Fatalf("loader arguments/context/state = %v, %#v, %#v, %p, %d", gotContext, gotSelection, gotInput, gotState, stateValue.Calls) t.Fatalf("loader arguments/context/state = %v, %#v, %#v, %p, %d", gotContext, gotSelection, gotInput, gotState, stateValue.Calls)
} }
if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result { if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result || acquired.Value.Metadata == nil || acquired.Value.Findings == nil {
t.Fatal("adapter did not expose the native result facets directly") t.Fatal("adapter did not expose the native result facets directly")
} }
@@ -150,14 +151,14 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
if !errors.As(gotErr, &typed) { if !errors.As(gotErr, &typed) {
t.Fatalf("load error type was not retained: %T", gotErr) t.Fatalf("load error type was not retained: %T", gotErr)
} }
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning { if acquired.Value.Effective != nil || acquired.Value.Metadata != nil || acquired.Value.Sources == nil || acquired.Value.Findings == nil || len(acquired.Value.Findings.Messages) != 1 || acquired.Value.Findings.Messages[0].Severity != commandkit.SeverityWarning {
t.Fatalf("failure diagnostics = %#v", acquired.Diagnostics) t.Fatalf("failure evidence = %#v", acquired.Value)
} }
if acquired.Explanation.Message != "" || len(acquired.Explanation.Details) != 1 { if acquired.Explanation.Message != "" || len(acquired.Explanation.Details) != 1 {
t.Fatalf("failure explanation = %#v", acquired.Explanation) t.Fatalf("failure explanation = %#v", acquired.Explanation)
} }
var warning, terminal bytes.Buffer var warning, terminal bytes.Buffer
if err := acquired.Diagnostics[0].Write(&warning); err != nil { if err := acquired.Value.Findings.Messages[0].Write(&warning); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := acquired.Explanation.Details[0](&terminal); err != nil { if err := acquired.Explanation.Details[0](&terminal); err != nil {
@@ -174,7 +175,7 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
} }
} }
func TestFromConfigKitPreservesValueAndDiagnosticsAlongsideError(t *testing.T) { func TestFromConfigKitDoesNotExposeResultFromFailedLoad(t *testing.T) {
result, err := adapterSettingsLoad(configkit.Mapping("partial source", map[string]any{"greeting": "Hello"})) result, err := adapterSettingsLoad(configkit.Mapping("partial source", map[string]any{"greeting": "Hello"}))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -188,11 +189,8 @@ func TestFromConfigKitPreservesValueAndDiagnosticsAlongsideError(t *testing.T) {
if gotErr != loadErr { if gotErr != loadErr {
t.Fatalf("load error identity = %v, want %v", gotErr, loadErr) t.Fatalf("load error identity = %v, want %v", gotErr, loadErr)
} }
if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result { if acquired.Value.Effective != nil || acquired.Value.Metadata != nil || acquired.Value.Sources != nil || acquired.Value.Explanation != nil || acquired.Value.Findings != nil {
t.Fatalf("acquired value facets were lost: %#v", acquired.Value) t.Fatalf("failed load exposed result facets: %#v", acquired.Value)
}
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
t.Fatalf("independent diagnostics = %#v", acquired.Diagnostics)
} }
if len(acquired.Explanation.Details) != 0 { if len(acquired.Explanation.Details) != 0 {
t.Fatalf("warning-only result acquired terminal context: %#v", acquired.Explanation) t.Fatalf("warning-only result acquired terminal context: %#v", acquired.Explanation)
@@ -274,3 +272,23 @@ func (shortWriter) Write(value []byte) (int, error) { return len(value) - 1, nil
type failingWriter struct{ err error } type failingWriter struct{ err error }
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err } func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
func TestFromConfigKitUsesFailureSnapshotInsteadOfReturnedResult(t *testing.T) {
result, err := adapterSettingsLoad(configkit.Mapping("successful", map[string]any{"greeting": "Hello"}))
if err != nil {
t.Fatal(err)
}
_, failure := configkit.Load[map[string]any](configkit.Bytes("failed", []byte("broken: [")))
var snapshot *configkit.LoadError
if !errors.As(failure, &snapshot) {
t.Fatalf("load failure: %v", failure)
}
wrapped := fmt.Errorf("loading: %w", failure)
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
return result, wrapped
})
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
if !errors.Is(gotErr, failure) || acquired.Value.Effective != nil || acquired.Value.Metadata != nil || acquired.Value.Explanation != nil || acquired.Value.Sources == nil || acquired.Value.Findings == nil || !reflect.DeepEqual(acquired.Value.Sources.Sources(), snapshot.Sources()) || !reflect.DeepEqual(acquired.Value.Findings.Records, snapshot.Diagnostics()) || len(acquired.Diagnostics) != 0 {
t.Fatalf("failed acquisition = %#v, %v", acquired, gotErr)
}
}

View File

@@ -0,0 +1,104 @@
package ckconfig
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"reflect"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func TestDetailedDiffPreservesNativeEvidenceAndAssertions(t *testing.T) {
values := []configkit.Difference{
{Path: configkit.Keys("added"), Right: configkit.ValueSnapshot{Present: true, YAML: "null\n"}},
{Path: configkit.Keys("removed"), Left: configkit.ValueSnapshot{Present: true, YAML: "false\n"}},
{Path: configkit.Keys("secret"), Left: configkit.ValueSnapshot{Present: true, Redacted: true, YAML: "'***'\n"}, Right: configkit.ValueSnapshot{Present: true, Redacted: true, YAML: "'***'\n"}, HiddenChanged: true},
{Path: configkit.Keys("list"), Left: configkit.ValueSnapshot{Present: true, YAML: "[one, two]\n"}, Right: configkit.ValueSnapshot{Present: true, YAML: "[three]\n"}},
{Path: configkit.Keys("marker"), Left: configkit.ValueSnapshot{Present: true, YAML: "'redacted'\n"}, Right: configkit.ValueSnapshot{Present: true, YAML: "'***'\n"}},
}
calls := 0
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
calls++
return commandkit.Acquired[[]configkit.Difference]{Value: values}, nil
}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
for _, format := range []string{"json", "yaml", "text"} {
var out bytes.Buffer
result := app.Execute(context.Background(), []string{"config", "diff", "--details", "--check", "--format=" + format}, commandkit.Streams{Out: &out, Err: io.Discard})
var logical any
var err error
if format == "json" {
err = json.Unmarshal(out.Bytes(), &logical)
} else {
logical, err = commandkit.DecodeYAMLReport(&out)
}
if result.Kind != commandkit.OutcomeDomain || result.Status != 3 || err != nil {
t.Fatalf("format %s = %#v %v %q", format, result, err, out.String())
}
report := logical.(map[string]any)
entries := report["differences"].([]any)
if report["scope"] != "app" || len(entries) != len(values) {
t.Fatalf("format %s report %#v", format, report)
}
added := entries[0].(map[string]any)
before, after := added["before"].(map[string]any), added["after"].(map[string]any)
if before["present"] != false || before["value"] != nil || after["present"] != true || after["value"] != nil {
t.Fatalf("null/absence: %#v", added)
}
secret := entries[2].(map[string]any)
if secret["hidden_changed"] != true || !reflect.DeepEqual(secret["before"].(map[string]any)["value"], secret["after"].(map[string]any)["value"]) {
t.Fatalf("hidden change: %#v", secret)
}
list := entries[3].(map[string]any)["after"].(map[string]any)["value"]
if !reflect.DeepEqual(list, []any{"three"}) {
t.Fatalf("atomic sequence: %#v", list)
}
marker := entries[4].(map[string]any)["after"].(map[string]any)
if marker["value"] != "***" || marker["redacted"] != false {
t.Fatalf("marker string: %#v", marker)
}
}
if calls != 3 {
t.Fatalf("provider calls=%d", calls)
}
result := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || calls != 4 {
t.Fatalf("ordinary diff: %#v calls=%d", result, calls)
}
values = nil
var out bytes.Buffer
result = app.Execute(context.Background(), []string{"config", "diff", "--check", "--details", "--format=json"}, commandkit.Streams{Out: &out, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), `"differences": []`) {
t.Fatalf("equality: %#v %q", result, out.String())
}
}
func TestDiffAssertionRetainsDomainOutcomeOnDeliveryFailures(t *testing.T) {
values := []configkit.Difference{{Path: configkit.Keys("x"), Right: configkit.ValueSnapshot{Present: true, YAML: "true\n"}}}
cause := errors.New("provider failed")
var providerErr error
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{Value: values}, providerErr
}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
result := app.Execute(context.Background(), []string{"config", "diff", "--check", "--details"}, commandkit.Streams{Out: shortWriter{}, Err: io.Discard})
if result.Kind != commandkit.OutcomeDomain || result.Status != 3 || result.Presentation.Output.State != commandkit.DeliveryIncomplete {
t.Fatalf("writer failure: %#v", result)
}
values[0].Right.YAML = "[invalid"
result = app.Execute(context.Background(), []string{"config", "diff", "--check", "--details"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeDomain || result.Status != 3 || result.Presentation.Output.RenderError == nil {
t.Fatalf("render failure: %#v", result)
}
providerErr = cause
result = app.Execute(context.Background(), []string{"config", "diff", "--check"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeOperational || !errors.Is(result.Cause, cause) {
t.Fatalf("provider failure: %#v", result)
}
}

View File

@@ -0,0 +1,136 @@
package ckconfig
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func validationDiagnostic(message string) commandkit.Diagnostic {
return commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(w io.Writer) error { _, err := io.WriteString(w, message+"\n"); return err }}
}
func TestDetailedValidationFiltersAndRetainsFailureEvidence(t *testing.T) {
cause := errors.New("second scope failed")
calls := []string{}
reader := &sourceInventory{values: []configkit.SourceInfo{{ID: "primary", Label: "main", Path: "main.yml"}, {ID: "related", Label: "included", Path: "include.yml"}, {ID: "unused", Label: "unused"}}}
records := []configkit.Diagnostic{
{Code: "chosen", Severity: configkit.SeverityWarning, Path: configkit.Keys("a/b"), Message: "selected warning", Primary: &configkit.Location{SourceID: "primary", Line: 2, Column: 3}, Related: []configkit.Location{{SourceID: "related", Line: 7, Column: 8}}},
{Code: "other", Severity: configkit.SeverityWarning, Path: configkit.Keys("other"), Message: "other warning"},
}
scopes := []Scope[struct{}, struct{}]{
{Name: "app", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls = append(calls, "app")
return commandkit.Acquired[View]{Value: View{Sources: reader, Findings: &Findings{Records: records, Messages: []commandkit.Diagnostic{validationDiagnostic("native warnings")}}}, Diagnostics: []commandkit.Diagnostic{validationDiagnostic("application warning")}}, nil
}},
{Name: "second", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls = append(calls, "second")
return commandkit.Acquired[View]{Value: View{Findings: &Findings{Records: []configkit.Diagnostic{{Code: "failed", Severity: configkit.SeverityError, Message: "failed finding"}}}}, Explanation: commandkit.Explanation{Message: "terminal failure"}}, cause
}},
{Name: "third", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls = append(calls, "third")
return commandkit.Acquired[View]{}, nil
}},
}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: scopes}))
var out, diag bytes.Buffer
result := app.Execute(context.Background(), []string{"config", "validate", "--scope=all", "--details", "--severity=warning", "--code=chosen", "--format=json", "--verbose"}, commandkit.Streams{Out: &out, Err: &diag})
decoder := json.NewDecoder(&out)
var report validationReport
if err := decoder.Decode(&report); err != nil {
t.Fatal(err)
}
if err := decoder.Decode(new(any)); err != io.EOF {
t.Fatalf("extra JSON: %v", err)
}
if !errors.Is(result.Cause, cause) || result.Kind != commandkit.OutcomeOperational || len(calls) != 2 || calls[0] != "app" || calls[1] != "second" || len(report.Scopes) != 2 || report.Scopes[0].Outcome != "valid" || report.Scopes[1].Outcome != "failed" {
t.Fatalf("result=%#v calls=%v report=%#v", result, calls, report)
}
first, second := report.Scopes[0].Details, report.Scopes[1].Details
if first == nil || first.State != "present" || len(first.Findings) != 1 || first.Findings[0].Path != "/a~1b" || len(first.Sources) != 2 || first.Sources[0].ID != "primary" || first.Sources[1].ID != "related" || second == nil || second.State != "filtered" || len(second.Findings) != 0 {
t.Fatalf("details=%#v %#v", first, second)
}
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())
}
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})
if result.Kind != commandkit.OutcomeUsage || len(calls) != before {
t.Fatalf("invalid %v: %#v", args, result)
}
}
}
func TestValidationCancellationAndDeliveryRetainReachedScopes(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
calls := 0
scopes := []Scope[struct{}, struct{}]{
{Name: "app", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{Value: View{Findings: &Findings{}}}, nil
}},
{Name: "second", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
cancel()
return commandkit.Acquired[View]{}, nil
}},
{Name: "third", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{}, nil
}},
}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: scopes}))
var out bytes.Buffer
result := app.Execute(ctx, []string{"config", "validate", "--scope=all", "--details", "--format=json"}, 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(result.Cause, context.Canceled) || calls != 2 || len(report.Scopes) != 2 || report.Scopes[0].Details.State != "empty" || report.Scopes[1].Details.State != "unavailable" || report.Scopes[1].Outcome != "canceled" {
t.Fatalf("result=%#v calls=%d report=%#v", result, calls, report)
}
failure := errors.New("load failure")
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{}, failure
}}
app = newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
result = app.Execute(context.Background(), []string{"config", "validate", "--details"}, commandkit.Streams{Out: shortWriter{}, Err: io.Discard})
if !errors.Is(result.Cause, failure) || result.Presentation.Output.State != commandkit.DeliveryIncomplete {
t.Fatalf("failure and delivery: %#v", result)
}
out.Reset()
result = app.Execute(context.Background(), []string{"config", "validate", "--details", "--quiet"}, commandkit.Streams{Out: &out, Err: io.Discard})
if !errors.Is(result.Cause, failure) || out.Len() != 0 {
t.Fatalf("quiet failure: %#v %q", result, out.String())
}
}
func TestDetailedValidationReportsNativeLoadError(t *testing.T) {
_, failure := configkit.Load[map[string]any](configkit.Bytes("invalid source", []byte("invalid: [")))
var native *configkit.LoadError
if !errors.As(failure, &native) {
t.Fatalf("fixture: %v", failure)
}
scope := FromConfigKit("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[map[string]any], error) {
return nil, failure
})
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
var out bytes.Buffer
result := app.Execute(context.Background(), []string{"config", "validate", "--details", "--format=json"}, 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(result.Cause, failure) || len(report.Scopes) != 1 || report.Scopes[0].Outcome != "failed" || report.Scopes[0].Details.State != "present" || len(report.Scopes[0].Details.Findings) != len(native.Diagnostics()) {
t.Fatalf("native failure: %#v %q", result, out.String())
}
}

View File

@@ -30,7 +30,14 @@ func runConfigDiff[I, S any](invocation commandkit.Invocation, plan *configOpera
if err := invocation.Context.Err(); err != nil { if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration comparison was canceled.", acquired.Explanation, err) return configFailure("Configuration comparison was canceled.", acquired.Explanation, err)
} }
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) { outcome := renderPreparedConfigReport(invocation, acquired.Explanation, func() (any, error) {
if plan.details {
return renderDetailedDiff(selected.scope.scope.Name, acquired.Value)
}
return renderDiffReport(acquired.Value) return renderDiffReport(acquired.Value)
}) })
if outcome.Kind == commandkit.OutcomeSuccess && plan.check && len(acquired.Value) > 0 {
return commandkit.Domain("configuration differences", 3)
}
return outcome
} }

View File

@@ -9,12 +9,12 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndInheritedSelections(t *testing.T) { func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndIndependentSelections(t *testing.T) {
type settings struct { type settings struct {
Greeting string `yaml:"greeting"` Greeting string `yaml:"greeting"`
} }
@@ -32,7 +32,7 @@ func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndInheritedSelections(t *t
if outcome.Kind != commandkit.OutcomeSuccess { if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("default YAML comparison outcome = %#v", outcome) t.Fatalf("default YAML comparison outcome = %#v", outcome)
} }
if len(selections) != 2 || !selections[0].Config.Set || selections[0].Config.Value != "before.yml" || !selections[1].Config.Set || selections[1].Config.Value != "after.yml" || selections[1].Overlay != selections[0].Overlay { if len(selections) != 2 || !selections[0].Config.Set || selections[0].Config.Value != "before.yml" || !selections[1].Config.Set || selections[1].Config.Value != "after.yml" || selections[1].Overlay.Set {
t.Fatalf("comparison selections = %#v", selections) t.Fatalf("comparison selections = %#v", selections)
} }
var document yaml.Node var document yaml.Node
@@ -60,7 +60,7 @@ func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndInheritedSelections(t *t
loadsBeforeText := len(selections) loadsBeforeText := len(selections)
output.Reset() output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--format", "text"}, commandkit.Streams{Out: &output, Err: io.Discard}) outcome = app.Execute(context.Background(), []string{"config", "diff", "--format", "text"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || len(selections) != loadsBeforeText || output.Len() != 0 { if outcome.Kind != commandkit.OutcomeSuccess || len(selections) != loadsBeforeText+2 || !strings.Contains(output.String(), "deletions:") {
t.Fatalf("text format outcome/work/output = %#v / %d / %q", outcome, len(selections)-loadsBeforeText, output.String()) t.Fatalf("text format outcome/work/output = %#v / %d / %q", outcome, len(selections)-loadsBeforeText, output.String())
} }
} }
@@ -256,7 +256,7 @@ func TestConfigDiffRepeatedInvocationsPrepareFreshInputsAndState(t *testing.T) {
if len(observed) != 2 || observed[0].left.ID == observed[0].right.ID || observed[1].left.ID == observed[1].right.ID || observed[0].left.ID == observed[1].left.ID || observed[0].right.ID == observed[1].right.ID { if len(observed) != 2 || observed[0].left.ID == observed[0].right.ID || observed[1].left.ID == observed[1].right.ID || observed[0].left.ID == observed[1].left.ID || observed[0].right.ID == observed[1].right.ID {
t.Fatalf("input instances were reused: %#v", observed) t.Fatalf("input instances were reused: %#v", observed)
} }
if observed[0].left.Label != "current" || observed[0].right.Label != "current" || observed[1].left.Label != "current" || observed[1].right.Label != "current" { if observed[0].left.Label != "current" || observed[0].right.Label != "" || observed[1].left.Label != "current" || observed[1].right.Label != "" {
t.Fatalf("comparison inputs missed captured flag values: %#v", observed) t.Fatalf("comparison inputs missed captured flag values: %#v", observed)
} }
if observed[0].state == nil || observed[1].state == nil || observed[0].state == observed[1].state || observed[0].state.ID != 1 || observed[1].state.ID != 2 { if observed[0].state == nil || observed[1].state == nil || observed[0].state == observed[1].state || observed[0].state.ID != 1 || observed[1].state.ID != 2 {
@@ -273,7 +273,7 @@ func TestConfigDiffExecutesConcealedChangesAndEmptyResults(t *testing.T) {
if selection.Config.Set && selection.Config.Value == "after" { if selection.Config.Set && selection.Config.Value == "after" {
value = "changed-secret" value = "changed-secret"
} }
return configkit.Load(configkit.Mapping("selected", map[string]any{"secret": value}), configkit.Options[settings]{Definition: configkit.Definition[settings]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }}}) return configkit.Load(configkit.Mapping("selected", map[string]any{"secret": value}), configkit.Options[settings]{Definition: configkit.Definition[settings]{Sensitive: func(path configkit.Path) bool { return path.String() == "/secret" }}})
})) }))
var output bytes.Buffer var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "before", "--against-config", "after"}, commandkit.Streams{Out: &output, Err: io.Discard}) outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "before", "--against-config", "after"}, commandkit.Streams{Out: &output, Err: io.Discard})

View File

@@ -1,26 +1,13 @@
package ckconfig package ckconfig
import ( import (
"bytes"
"fmt" "fmt"
"strings" "strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit" commandkit "go.maximumdirect.net/commandkit"
"go.yaml.in/yaml/v4" configkit "go.maximumdirect.net/configkit"
) )
func decodeSafeSnapshot(snapshot string) (*yaml.Node, error) {
var document yaml.Node
decoder := yaml.NewDecoder(strings.NewReader(snapshot))
if err := decoder.Decode(&document); err != nil {
return nil, fmt.Errorf("decode safe YAML snapshot: %w", err)
}
if document.Kind != yaml.DocumentNode || len(document.Content) == 0 {
return nil, fmt.Errorf("decode safe YAML snapshot: document has no value")
}
return document.Content[0], nil
}
func decodeJSONPointer(pointer string) ([]string, error) { func decodeJSONPointer(pointer string) ([]string, error) {
if pointer == "" { if pointer == "" {
return nil, nil return nil, nil
@@ -56,139 +43,114 @@ func decodeJSONPointer(pointer string) ([]string, error) {
return parts, nil return parts, nil
} }
// yamlValuePlacer indexes each mapping once while assembling one report. func placeReportValue(root any, path []string, value any) (any, error) {
// Content remains the ordered representation used by the YAML encoder.
type yamlValuePlacer struct {
keys map[*yaml.Node]map[string]int
}
func (placer *yamlValuePlacer) placeValue(category *yaml.Node, path []string, value *yaml.Node) (*yaml.Node, error) {
if value == nil {
return nil, fmt.Errorf("place YAML value: value node is nil")
}
if len(path) == 0 { if len(path) == 0 {
return value, nil return value, nil
} }
if category == nil || category.Kind != yaml.MappingNode { current, ok := root.(map[string]any)
return nil, fmt.Errorf("place YAML value at %q: category ancestor is not a mapping", pointerFromTokens(path[:1])) if !ok {
return nil, fmt.Errorf("report path ancestor is not a mapping")
} }
current := category
for index, token := range path { for index, token := range path {
keys := placer.mappingKeys(current)
keyIndex, exists := keys[token]
if index == len(path)-1 { if index == len(path)-1 {
if exists { current[token] = value
current.Content[keyIndex+1] = value return root, nil
} else {
keys[token] = len(current.Content)
current.Content = append(current.Content, stringKey(token), value)
}
return category, nil
} }
child, exists := current[token]
if !exists { if !exists {
child := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} next := make(map[string]any)
keys[token] = len(current.Content) current[token] = next
current.Content = append(current.Content, stringKey(token), child) current = next
current = child
continue continue
} }
next, ok := child.(map[string]any)
child := current.Content[keyIndex+1] if !ok {
if child.Kind != yaml.MappingNode { return nil, fmt.Errorf("report path ancestor %q is not a mapping", token)
return nil, fmt.Errorf("place YAML value at %q: ancestor %q is not a mapping", pointerFromTokens(path), pointerFromTokens(path[:index+1]))
} }
current = child current = next
} }
return category, nil return root, nil
} }
func (placer *yamlValuePlacer) mappingKeys(mapping *yaml.Node) map[string]int { type diffReport struct {
if keys, exists := placer.keys[mapping]; exists { Additions any `json:"additions"`
return keys Changes any `json:"changes"`
} Deletions []string `json:"deletions"`
if placer.keys == nil { }
placer.keys = make(map[*yaml.Node]map[string]int)
} type detailedDiffReport struct {
keys := make(map[string]int, len(mapping.Content)/2) Scope string `json:"scope"`
for index := 0; index+1 < len(mapping.Content); index += 2 { Differences []detailedDifference `json:"differences"`
candidate := mapping.Content[index] }
if candidate.Kind == yaml.ScalarNode && candidate.Tag == "!!str" {
if _, exists := keys[candidate.Value]; !exists { type detailedDifference struct {
keys[candidate.Value] = index Path string `json:"path"`
} Before detailedDiffSnapshot `json:"before"`
After detailedDiffSnapshot `json:"after"`
HiddenChanged bool `json:"hidden_changed"`
}
type detailedDiffSnapshot struct {
Present bool `json:"present"`
Redacted bool `json:"redacted"`
Value any `json:"value"`
}
func renderDetailedDiff(scope string, differences []configkit.Difference) (any, error) {
report := detailedDiffReport{Scope: scope, Differences: make([]detailedDifference, 0, len(differences))}
for _, difference := range differences {
before, err := detailedSnapshot(difference.Left)
if err != nil {
return nil, fmt.Errorf("difference at %q before: %w", difference.Path.String(), err)
} }
after, err := detailedSnapshot(difference.Right)
if err != nil {
return nil, fmt.Errorf("difference at %q after: %w", difference.Path.String(), err)
}
report.Differences = append(report.Differences, detailedDifference{Path: difference.Path.String(), Before: before, After: after, HiddenChanged: difference.HiddenChanged})
} }
placer.keys[mapping] = keys return report, nil
return keys
} }
func stringKey(value string) *yaml.Node { func detailedSnapshot(snapshot configkit.ValueSnapshot) (detailedDiffSnapshot, error) {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} report := detailedDiffSnapshot{Present: snapshot.Present, Redacted: snapshot.Redacted}
if snapshot.Present {
value, err := commandkit.DecodeYAMLReport(strings.NewReader(snapshot.YAML))
if err != nil {
return report, err
}
report.Value = value
}
return report, nil
} }
func pointerFromTokens(tokens []string) string { func renderDiffReport(differences []configkit.Difference) (any, error) {
if len(tokens) == 0 { report := diffReport{Additions: map[string]any{}, Changes: map[string]any{}, Deletions: []string{}}
return ""
}
var pointer strings.Builder
for _, token := range tokens {
pointer.WriteByte('/')
pointer.WriteString(strings.ReplaceAll(strings.ReplaceAll(token, "~", "~0"), "/", "~1"))
}
return pointer.String()
}
func renderDiffReport(differences []configkit.Difference) ([]byte, error) {
var placer yamlValuePlacer
additions := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
changes := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
deletions := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
for _, difference := range differences { for _, difference := range differences {
path := difference.Path.String() path := difference.Path.String()
switch { if difference.Left.Present && !difference.Right.Present {
case !difference.Left.Present && difference.Right.Present: report.Deletions = append(report.Deletions, path)
tokens, err := decodeJSONPointer(path) continue
if err != nil { }
return nil, fmt.Errorf("render addition at %q: %w", path, err) if !difference.Right.Present {
} continue
value, err := decodeSafeSnapshot(difference.Right.YAML) }
if err != nil { tokens, err := decodeJSONPointer(path)
return nil, fmt.Errorf("render addition at %q: %w", path, err) if err != nil {
} return nil, fmt.Errorf("difference at %q: %w", path, err)
additions, err = placer.placeValue(additions, tokens, value) }
if err != nil { value, err := commandkit.DecodeYAMLReport(strings.NewReader(difference.Right.YAML))
return nil, fmt.Errorf("render addition at %q: %w", path, err) if err != nil {
} return nil, fmt.Errorf("difference at %q: %w", path, err)
case difference.Left.Present && difference.Right.Present: }
tokens, err := decodeJSONPointer(path) if difference.Left.Present {
if err != nil { report.Changes, err = placeReportValue(report.Changes, tokens, value)
return nil, fmt.Errorf("render change at %q: %w", path, err) } else {
} report.Additions, err = placeReportValue(report.Additions, tokens, value)
value, err := decodeSafeSnapshot(difference.Right.YAML) }
if err != nil { if err != nil {
return nil, fmt.Errorf("render change at %q: %w", path, err) return nil, fmt.Errorf("difference at %q: %w", path, err)
}
changes, err = placer.placeValue(changes, tokens, value)
if err != nil {
return nil, fmt.Errorf("render change at %q: %w", path, err)
}
case difference.Left.Present && !difference.Right.Present:
deletions.Content = append(deletions.Content, stringKey(path))
} }
} }
return report, nil
root := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{
stringKey("additions"), additions,
stringKey("changes"), changes,
stringKey("deletions"), deletions,
}}
document := &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}}
var output bytes.Buffer
if err := encodeYAML(&output, document); err != nil {
return nil, fmt.Errorf("encode configuration diff report: %w", err)
}
return output.Bytes(), nil
} }

View File

@@ -2,11 +2,11 @@ package ckconfig
import ( import (
"bytes" "bytes"
"reflect"
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
@@ -16,11 +16,16 @@ func renderAndDecode(t *testing.T, differences []configkit.Difference) (*yaml.No
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var rendered bytes.Buffer
if err := commandkit.RenderReport(&rendered, commandkit.Report{Value: report}, commandkit.FormatYAML); err != nil {
t.Fatal(err)
}
data := rendered.Bytes()
var document yaml.Node var document yaml.Node
if err := yaml.Unmarshal(report, &document); err != nil { if err := yaml.Unmarshal(data, &document); err != nil {
t.Fatalf("decode rendered report %q: %v", report, err) t.Fatalf("decode rendered report %q: %v", report, err)
} }
return document.Content[0], report return document.Content[0], data
} }
func reportField(mapping *yaml.Node, key string) *yaml.Node { func reportField(mapping *yaml.Node, key string) *yaml.Node {
@@ -43,18 +48,12 @@ func TestRenderDiffReportClassifiesAndOrdersNativeRecords(t *testing.T) {
if root.Kind != yaml.MappingNode || len(root.Content) != 6 { if root.Kind != yaml.MappingNode || len(root.Content) != 6 {
t.Fatalf("report root = %#v", root) t.Fatalf("report root = %#v", root)
} }
if got := []string{root.Content[0].Value, root.Content[2].Value, root.Content[4].Value}; !reflect.DeepEqual(got, []string{"additions", "changes", "deletions"}) {
t.Fatalf("report category order = %#v", got)
}
additions := reportField(root, "additions") additions := reportField(root, "additions")
if additions.Kind != yaml.MappingNode || additions.Content[0].Value != "z-added" || additions.Content[2].Value != "a-added" { if value := reportField(additions, "z-added"); value == nil || value.Tag != "!!bool" || value.Value != "false" {
t.Fatalf("addition order/value = %#v", additions) t.Fatalf("boolean addition = %#v", value)
} }
if additions.Content[1].Kind != yaml.ScalarNode || additions.Content[1].Tag != "!!bool" || additions.Content[1].Value != "false" { if value := reportField(additions, "a-added"); value == nil || value.Tag != "!!str" || value.Value != "123" {
t.Fatalf("boolean addition lost its type: %#v", additions.Content[1]) t.Fatalf("string addition = %#v", value)
}
if additions.Content[3].Tag != "!!str" || additions.Content[3].Value != "123" {
t.Fatalf("numeric-looking string lost its type: %#v", additions.Content[3])
} }
changes := reportField(root, "changes") changes := reportField(root, "changes")
if changes.Kind != yaml.MappingNode || changes.Content[1].Tag != "!!int" || changes.Content[1].Value != "9007199254740993" { if changes.Kind != yaml.MappingNode || changes.Content[1].Tag != "!!int" || changes.Content[1].Value != "9007199254740993" {
@@ -68,9 +67,6 @@ func TestRenderDiffReportClassifiesAndOrdersNativeRecords(t *testing.T) {
func TestRenderDiffReportEmitsEmptyCategoriesAndRootValue(t *testing.T) { func TestRenderDiffReportEmitsEmptyCategoriesAndRootValue(t *testing.T) {
root, _ := renderAndDecode(t, nil) root, _ := renderAndDecode(t, nil)
if got := []string{root.Content[0].Value, root.Content[2].Value, root.Content[4].Value}; !reflect.DeepEqual(got, []string{"additions", "changes", "deletions"}) {
t.Fatalf("empty report keys = %#v", got)
}
if reportField(root, "additions").Kind != yaml.MappingNode || len(reportField(root, "additions").Content) != 0 || reportField(root, "changes").Kind != yaml.MappingNode || len(reportField(root, "changes").Content) != 0 || reportField(root, "deletions").Kind != yaml.SequenceNode || len(reportField(root, "deletions").Content) != 0 { if reportField(root, "additions").Kind != yaml.MappingNode || len(reportField(root, "additions").Content) != 0 || reportField(root, "changes").Kind != yaml.MappingNode || len(reportField(root, "changes").Content) != 0 || reportField(root, "deletions").Kind != yaml.SequenceNode || len(reportField(root, "deletions").Content) != 0 {
t.Fatalf("empty report categories = %#v", root) t.Fatalf("empty report categories = %#v", root)
} }
@@ -119,7 +115,7 @@ func TestRenderDiffReportPreservesNativeMasksAndMarkerLookingStrings(t *testing.
Public string `yaml:"public"` Public string `yaml:"public"`
Secret string `yaml:"secret"` Secret string `yaml:"secret"`
} }
definition := configkit.Definition[settings]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }} definition := configkit.Definition[settings]{Sensitive: func(path configkit.Path) bool { return path.String() == "/secret" }}
load := func(public, secret string) *configkit.Result[settings] { load := func(public, secret string) *configkit.Result[settings] {
result, err := configkit.Load(configkit.Mapping("configuration", map[string]any{"public": public, "secret": secret}), configkit.Options[settings]{Definition: definition}) result, err := configkit.Load(configkit.Mapping("configuration", map[string]any{"public": public, "secret": secret}), configkit.Options[settings]{Definition: definition})
if err != nil { if err != nil {

View File

@@ -2,49 +2,9 @@ package ckconfig
import ( import (
"reflect" "reflect"
"strings"
"testing" "testing"
"go.yaml.in/yaml/v4"
) )
func TestDecodeSafeSnapshotPreservesYAMLTypes(t *testing.T) {
tests := []struct {
name string
text string
kind yaml.Kind
tag string
value string
}{
{name: "null", text: "null\n", kind: yaml.ScalarNode, tag: "!!null", value: "null"},
{name: "boolean", text: "true\n", kind: yaml.ScalarNode, tag: "!!bool", value: "true"},
{name: "large integer", text: "9007199254740993\n", kind: yaml.ScalarNode, tag: "!!int", value: "9007199254740993"},
{name: "numeric string", text: "'123'\n", kind: yaml.ScalarNode, tag: "!!str", value: "123"},
{name: "boolean-like string", text: "'false'\n", kind: yaml.ScalarNode, tag: "!!str", value: "false"},
{name: "empty mapping", text: "{}\n", kind: yaml.MappingNode, tag: "!!map"},
{name: "empty sequence", text: "[]\n", kind: yaml.SequenceNode, tag: "!!seq"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
node, err := decodeSafeSnapshot(test.text)
if err != nil {
t.Fatal(err)
}
if node.Kind != test.kind || node.Tag != test.tag || node.Value != test.value {
t.Fatalf("node kind/tag/value = %v/%q/%q, want %v/%q/%q", node.Kind, node.Tag, node.Value, test.kind, test.tag, test.value)
}
})
}
nested, err := decodeSafeSnapshot("items:\n - name: example\n enabled: false\n")
if err != nil {
t.Fatal(err)
}
if nested.Kind != yaml.MappingNode || nested.Content[1].Kind != yaml.SequenceNode || nested.Content[1].Content[0].Content[3].Tag != "!!bool" {
t.Fatalf("nested collection/scalar tags were not preserved: %#v", nested)
}
}
func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) { func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) {
tests := []struct { tests := []struct {
pointer string pointer string
@@ -73,74 +33,25 @@ func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) {
} }
} }
func TestPlaceYAMLValueBuildsOrderedSemanticMappings(t *testing.T) { func TestPlaceReportValue(t *testing.T) {
var placer yamlValuePlacer var root any = map[string]any{}
category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} var err error
paths := [][]string{ root, err = placeReportValue(root, []string{"a/b", "~1"}, false)
{"parent", "0", "dot.name", "50%"},
{"parent", "second"},
{"a/b", "~1"},
{""},
}
for index, path := range paths {
value := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: string(rune('a' + index))}
updated, err := placer.placeValue(category, path, value)
if err != nil {
t.Fatalf("place %q: %v", pointerFromTokens(path), err)
}
category = updated
}
if len(category.Content) != 6 || category.Content[0].Value != "parent" || category.Content[2].Value != "a/b" || category.Content[4].Value != "" {
t.Fatalf("mapping insertion order or empty key = %#v", category.Content)
}
parent := category.Content[1]
if parent.Kind != yaml.MappingNode || len(parent.Content) != 4 || parent.Content[0].Value != "0" || parent.Content[2].Value != "second" {
t.Fatalf("nested shared ancestor/order = %#v", parent)
}
numeric := parent.Content[1]
dotted := numeric.Content[1]
if numeric.Kind != yaml.MappingNode || numeric.Content[0].Value != "dot.name" || dotted.Kind != yaml.MappingNode || dotted.Content[0].Value != "50%" {
t.Fatalf("semantic tokens were changed: numeric=%#v dotted=%#v", numeric, dotted)
}
if category.Content[1].Content[0].Tag != "!!str" || category.Content[2].Tag != "!!str" || category.Content[4].Tag != "!!str" {
t.Fatal("mapping keys are not explicitly tagged as YAML strings")
}
}
func TestPlaceYAMLValueHandlesRootAndEmptyKey(t *testing.T) {
var placer yamlValuePlacer
category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
root := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}
updated, err := placer.placeValue(category, nil, root)
if err != nil || updated != root {
t.Fatalf("root placement = %#v, %v", updated, err)
}
category = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
value := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "empty-key"}
updated, err = placer.placeValue(category, []string{""}, value)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if updated != category || len(category.Content) != 2 || category.Content[0].Value != "" || category.Content[0].Tag != "!!str" || category.Content[1] != value { root, err = placeReportValue(root, []string{""}, nil)
t.Fatalf("slash path placement = %#v", category) if err != nil {
} t.Fatal(err)
} }
want := map[string]any{"a/b": map[string]any{"~1": false}, "": nil}
func TestPlaceYAMLValueRejectsIncompatibleAncestors(t *testing.T) { if !reflect.DeepEqual(root, want) {
var placer yamlValuePlacer t.Fatalf("got %#v", root)
category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{ }
stringKey("parent"), {Kind: yaml.ScalarNode, Tag: "!!str", Value: "leaf"}, if _, err := placeReportValue(root, []string{"a/b", "~1", "child"}, 1); err == nil {
}} t.Fatal("scalar ancestor accepted")
_, err := placer.placeValue(category, []string{"parent", "child"}, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}) }
if err == nil || !strings.Contains(err.Error(), "/parent/child") || !strings.Contains(err.Error(), "/parent") { if value, err := placeReportValue(root, nil, "replacement"); err != nil || value != "replacement" {
t.Fatalf("incompatible ancestor error = %v", err) t.Fatalf("root: %#v, %v", value, err)
}
}
func TestDecodeSafeSnapshotRejectsMalformedYAML(t *testing.T) {
if _, err := decodeSafeSnapshot("value: [unterminated\n"); err == nil {
t.Fatal("malformed snapshot unexpectedly decoded")
} }
} }

View File

@@ -0,0 +1,116 @@
package ckconfig
import (
"bytes"
"context"
"encoding/json"
"io"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
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})
})
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}}))
run := func(args ...string) (commandkit.Outcome, string) {
var out bytes.Buffer
result := app.Execute(context.Background(), append([]string{"config", "explain"}, args...), commandkit.Streams{Out: &out, Err: io.Discard})
return result, out.String()
}
result, out := run()
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out, "--list") || !strings.Contains(out, "--path") || loads != 0 || inputs != 0 || states != 0 {
t.Fatalf("bare help: %#v %q, %d/%d/%d", result, out, loads, inputs, states)
}
for _, args := range [][]string{{"--list", "--path=/ab"}, {"--historical"}, {"--under=/ab"}, {"--explicit=false"}, {"--path=/ab", "--under=/ab"}, {"--list", "--under=bad"}} {
result, _ := run(args...)
if result.Kind != commandkit.OutcomeUsage || loads != 0 || inputs != 0 || states != 0 {
t.Fatalf("invalid %v: %#v, %d/%d/%d", args, result, loads, inputs, states)
}
}
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 {
t.Fatalf("escaped subtree: %#v %q", result, out)
}
seen := map[string]bool{}
for _, entry := range report.Paths {
seen[entry.Path] = true
}
if !seen["/a~1b"] || !seen["/a~1b/x"] {
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" {
t.Fatalf("token boundary: %#v %q", result, out)
}
result, out = run("--list", "--historical", "--set=/gone=null", "--format=json")
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil {
t.Fatalf("historical: %#v %q", result, out)
}
removed := false
for _, entry := range report.Paths {
if entry.Path == "/gone/x" && !entry.Present && entry.Metadata == nil {
removed = true
}
}
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 {
t.Fatalf("explicit filter: %#v %q", result, out)
}
for _, entry := range report.Paths {
if !entry.Present || entry.Metadata == nil || entry.Metadata.Explicit {
t.Fatalf("invalid filtered entry: %#v", entry)
}
}
result, out = run("--path=", "--format=json")
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out, `"path": ""`) || !strings.Contains(out, `"metadata"`) {
t.Fatalf("root explanation: %#v %q", result, out)
}
result, out = run("--path=/gone/x", "--set=/gone=null", "--format=json")
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out, `"metadata"`) || !strings.Contains(out, `"history"`) {
t.Fatalf("historical explanation: %#v %q", result, out)
}
}
func TestExplainDiscoveryCapabilityBeforeAcquisition(t *testing.T) {
calls := 0
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{}, nil
}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
result := app.Execute(context.Background(), []string{"config", "explain", "--list"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeUsage || calls != 0 {
t.Fatalf("unsupported discovery: %#v calls=%d", result, calls)
}
scope.Features.PathDiscovery = true
app = newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
result = app.Execute(context.Background(), []string{"config", "explain", "--list"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeOperational || calls != 1 {
t.Fatalf("missing metadata: %#v calls=%d", result, calls)
}
}
func TestExplainBareHelpRecordsDeliveryFailure(t *testing.T) {
inputs, states, calls := 0, 0, 0
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{}, nil
}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{NewInput: func() struct{} { inputs++; return struct{}{} }, NewState: func() struct{} { states++; return struct{}{} }, Scopes: []Scope[struct{}, struct{}]{scope}}))
result := app.Execute(context.Background(), []string{"config", "explain"}, commandkit.Streams{Out: shortWriter{}, Err: io.Discard})
if result.Presentation.Output.State != commandkit.DeliveryIncomplete || inputs != 0 || states != 0 || calls != 0 {
t.Fatalf("help delivery = %#v, input/state/acquire=%d/%d/%d", result, inputs, states, calls)
}
}

View File

@@ -1,10 +1,10 @@
// Package ckconfig, provided by go.maximumdirect.net/ckconfig, provides // Package ckconfig, provided by go.maximumdirect.net/ckconfig, provides
// CommandKit's optional ConfigKit integration. Its // CommandKit's optional ConfigKit v1.1.0 integration, requiring Go 1.27.0. Its
// canonical Command declaration installs the standard config command family // canonical Command declaration installs the standard config command family
// through the core commandkit package. Applications can install CommandKit // through the core commandkit package. Applications can install CommandKit
// RoutingRule declarations alongside it through commandkit.New or // RoutingRule declarations alongside it through commandkit.New or
// App.Register. Source reports preserve ConfigKit's destination, placement, // App.Register. Source reports preserve ConfigKit's destination, placement,
// and version-check evidence. Invocation reporting applies CommandKit's shared // version, and empty-import evidence. Invocation reporting applies CommandKit's shared
// visibility and delivery policy; provider warnings remain independent of // visibility and delivery policy; provider warnings remain independent of
// failure explanations. Importing ckconfig alone installs no commands. // failure explanations. Importing ckconfig alone installs no commands.
package ckconfig package ckconfig

View File

@@ -2,16 +2,16 @@
## ConfigKit ## ConfigKit
Selected module: `gitea.maximumdirect.net/eric/ConfigKit` at `v0.8.0`. Selected module: `go.maximumdirect.net/configkit` at `v1.1.0`.
Copyright (C) 2026 Eric Rakestraw Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: LGPL-3.0-or-later SPDX-License-Identifier: LGPL-3.0-or-later
License and notices: [LGPL v3.0](../../LICENSE) | License and notices: [LGPL v3.0](../../LICENSE) |
[LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/LICENSE). [LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5). [Source](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9).
## go-yaml v4 ## go-yaml v4
@@ -116,8 +116,9 @@ root): `docs/policy/meta.md`, `docs/policy/agents.md`,
`docs/policy/documentation.md`, `docs/policy/release.md`, and `docs/policy/documentation.md`, `docs/policy/release.md`, and
`docs/policy/oss.md`; the root `AGENTS.md` orientation after the knowledge-graph `docs/policy/oss.md`; the root `AGENTS.md` orientation after the knowledge-graph
instructions; and the Policy Reading Guide in `docs/development.md`, adapted for instructions; and the Policy Reading Guide in `docs/development.md`, adapted for
the core module. The release policy uses baseline the core module. The documentation policy uses baseline
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses baseline `beb5f191470a9daef2383981a6bfd02232490ab4`; the release policy uses
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses
`d84a38ebab26f048cafbe59a2246700c35d29843`. `d84a38ebab26f048cafbe59a2246700c35d29843`.
Copyright (C) 2026 Eric Rakestraw Copyright (C) 2026 Eric Rakestraw
@@ -128,12 +129,16 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) | [COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) | [LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) | [COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE). [LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843). [Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348). [Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4).
## go-yaml v3 ## go-yaml v3
Selected module: `gopkg.in/yaml.v3` at `v3.0.1` (transitive dependency). Selected module: `gopkg.in/yaml.v3` at `v3.0.1` (transitive dependency).
@@ -153,19 +158,6 @@ License and notices: [MIT](licenses/MIT.txt) |
## ReleaseKit ## ReleaseKit
Selected module: `gitea.maximumdirect.net/eric/ReleaseKit` at `v0.4.1` (transitive Go tool requirement; not a runtime import).
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: LGPL-3.0-or-later
License and notices: [LGPL v3.0](../../LICENSE) |
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/2d5a8110c61e31daf8698766114ae305d0e0e469/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/2d5a8110c61e31daf8698766114ae305d0e0e469).
## ReleaseKit (vanity module)
Selected module: `go.maximumdirect.net/releasekit` at `v0.9.0` (transitive Go tool requirement; not a runtime import). Selected module: `go.maximumdirect.net/releasekit` at `v0.9.0` (transitive Go tool requirement; not a runtime import).
Copyright (C) 2026 Eric Rakestraw Copyright (C) 2026 Eric Rakestraw

View File

@@ -0,0 +1,25 @@
# CommandKit ckconfig v0.3.0
## Summary
This candidate adopts `go.maximumdirect.net/configkit v1.1.0` and requires Go
1.27.0, while retaining `go.maximumdirect.net/commandkit v0.3.0`. It updates
configuration source reports to expose ConfigKit's current evidence.
## Migration
Change ConfigKit imports from `gitea.maximumdirect.net/eric/ConfigKit` to
`go.maximumdirect.net/configkit`. Values from the two paths have distinct Go
type identities. Update application loaders and provider values together with
ckconfig. Published ckprompt v0.2.0 continues to use ckconfig v0.2.0 and the old
ConfigKit identity; it does not compose with this candidate.
Source reports rename `destination_unresolved` to `destination_is_fallback` and
`version_check` to `version_evidence`, use the native `main` layer name, and add
`empty_import_selections` records (or `[]` when empty). Update report consumers.
See the [consumer migration guide](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.3.0/docs/consumers/guide.md#configkit-v11-migration)
for links to upstream configuration and API changes.
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.3.0/integrations/ckconfig/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.3.0/integrations/ckconfig/LICENSE) |
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.3.0/integrations/ckconfig/docs/oss/attribution.md)

View File

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

View File

@@ -1,7 +1,9 @@
# Simple consumer # Simple consumer
This example imports `go.maximumdirect.net/ckconfig` and This example imports `go.maximumdirect.net/ckconfig` and
`go.maximumdirect.net/commandkit`; use ckconfig v0.2.0 with core v0.3.0. `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.
Run the maintained example with: Run the maintained example with:
@@ -9,7 +11,12 @@ Run the maintained example with:
go run . config show go run . config show
go run . config show --config fixtures/before.yml --verbose go run . config show --config fixtures/before.yml --verbose
go run . config show --config fixtures/before.yml --quiet go run . config show --config fixtures/before.yml --quiet
go run . config explain --list --under /schema
go run . config show --path /greeting --set '/greeting="Hi"'
go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml
go run . config diff --set '/greeting="Hi"' --details --check
go run . config validate --config fixtures/invalid.yml --details --severity error
go run . config sources --config fixtures/invalid.yml --source-id source-1
go run . greet go run . greet
go run . greet --count=0 --tag=first --tag second go run . greet --count=0 --tag=first --tag second
``` ```
@@ -23,3 +30,8 @@ command loads the two local fixtures and reports `changes.greeting: Goodbye`.
The before fixture uses schema version 1, which ConfigKit reports as a provider 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 warning in verbose mode. Ordinary mode hides the warning while preserving the
effective YAML report; quiet mode suppresses that report. 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.

View File

@@ -0,0 +1,3 @@
schema:
version: 2
greeting: [invalid]

View File

@@ -7,9 +7,9 @@ import (
"os" "os"
"strconv" "strconv"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"go.maximumdirect.net/ckconfig" "go.maximumdirect.net/ckconfig"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
) )
type appConfiguration struct { type appConfiguration struct {
@@ -49,10 +49,15 @@ func loadAppConfiguration(_ context.Context, selection ckconfig.Selection) (*con
options.Overlay = selection.Overlay.Value options.Overlay = selection.Overlay.Value
options.Filesystem = &configkit.FilesystemContext{Root: ".", BaseDir: "."} options.Filesystem = &configkit.FilesystemContext{Root: ".", BaseDir: "."}
} }
options.Overrides = append(options.Overrides, selection.Overrides...)
return configkit.Load(source, options) return configkit.Load(source, options)
} }
func newApp() (*commandkit.App, error) { func newApp() (*commandkit.App, error) {
return newAppWithLoader(loadAppConfiguration)
}
func newAppWithLoader(load func(context.Context, ckconfig.Selection) (*configkit.Result[appConfiguration], error)) (*commandkit.App, error) {
countDefault := 1 countDefault := 1
tagDefaults := []string{"example"} tagDefaults := []string{"example"}
greet := commandkit.NewCommand(commandkit.CommandSpec[greetInput]{ greet := commandkit.NewCommand(commandkit.CommandSpec[greetInput]{
@@ -68,7 +73,7 @@ func newApp() (*commandkit.App, error) {
}, },
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome { Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
reporter := invocation.Reporter() reporter := invocation.Reporter()
configuration, err := loadAppConfiguration(invocation.Context, ckconfig.Selection{}) configuration, err := load(invocation.Context, ckconfig.Selection{})
if err != nil { if err != nil {
return commandkit.Failure("Could not load application configuration.").WithCause(err) return commandkit.Failure("Could not load application configuration.").WithCause(err)
} }
@@ -87,9 +92,13 @@ func newApp() (*commandkit.App, error) {
return commandkit.Success() return commandkit.Success()
}, },
}) })
scope := ckconfig.FromConfigKit("app", "Application configuration.", func(ctx context.Context, selection ckconfig.Selection, _, _ struct{}) (*configkit.Result[appConfiguration], error) {
return load(ctx, selection)
})
scope.Features.Overrides = true
return commandkit.New( return commandkit.New(
commandkit.Identity{Name: "simple-example", Version: "1.0"}, commandkit.Identity{Name: "simple-example", Version: "1.0"},
ckconfig.AppCommand(loadAppConfiguration), ckconfig.Command(ckconfig.Suite[struct{}, struct{}]{Scopes: []ckconfig.Scope[struct{}, struct{}]{scope}}),
greet, greet,
) )
} }

View File

@@ -3,12 +3,14 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"io" "io"
"strings" "strings"
"testing" "testing"
"go.maximumdirect.net/ckconfig" "go.maximumdirect.net/ckconfig"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
@@ -147,3 +149,55 @@ func decodeReport(data []byte) (*yaml.Node, error) {
} }
return document.Content[0], nil return document.Content[0], nil
} }
func TestSimpleInspectionAndIndependentOverrides(t *testing.T) {
outcome, output, _ := executeSimple(t, "config", "explain", "--list", "--under=/schema", "--format=json")
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output, `"path": "/schema/version"`) {
t.Fatalf("discovery = %#v %q", outcome, output)
}
outcome, output, _ = executeSimple(t, "config", "show", "--path=/greeting", "--set=/greeting=\"Hi\"")
if outcome.Kind != commandkit.OutcomeSuccess || output != "Hi\n" {
t.Fatalf("focused override = %#v %q", outcome, output)
}
outcome, output, _ = executeSimple(t, "config", "diff", "--set=/greeting=\"Hi\"", "--details", "--check", "--format=json")
if outcome.Kind != commandkit.OutcomeDomain || outcome.Status != 3 || !strings.Contains(output, `"before"`) || !strings.Contains(output, `"after"`) {
t.Fatalf("independent diff = %#v %q", outcome, output)
}
outcome, output, _ = executeSimple(t, "config", "diff", "--set=/greeting=\"Hi\"", "--against-set=/greeting=\"Hi\"", "--details", "--check", "--format=json")
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output, `"differences": []`) {
t.Fatalf("equal overrides = %#v %q", outcome, output)
}
}
func TestSimpleFailedLoadReportsEvidence(t *testing.T) {
_, failure := loadAppConfiguration(context.Background(), ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "fixtures/invalid.yml", Set: true}})
var native *configkit.LoadError
if !errors.As(failure, &native) || len(native.Sources()) == 0 {
t.Fatalf("fixture failure = %v", failure)
}
outcome, output, _ := executeSimple(t, "config", "validate", "--config=fixtures/invalid.yml", "--details", "--severity=error", "--format=json")
if outcome.Kind != commandkit.OutcomeOperational || !strings.Contains(output, `"state": "present"`) || !strings.Contains(output, `"severity": "error"`) {
t.Fatalf("detailed failure = %#v %q", outcome, output)
}
outcome, output, _ = executeSimple(t, "config", "sources", "--config=fixtures/invalid.yml", "--source-id="+native.Sources()[0].ID, "--format=json")
if outcome.Kind != commandkit.OutcomeOperational || !strings.Contains(output, `"id":`) || !strings.Contains(output, "invalid.yml") {
t.Fatalf("source failure = %#v %q", outcome, output)
}
}
func TestSimpleMetadataCommandsDoNotLoadConfiguration(t *testing.T) {
loads := 0
app, err := newAppWithLoader(func(context.Context, ckconfig.Selection) (*configkit.Result[appConfiguration], error) {
loads++
return nil, errors.New("unexpected load")
})
if err != nil {
t.Fatal(err)
}
for _, args := range [][]string{{"version"}, {"config", "scopes"}, {"config", "explain"}, {"config", "show", "--help"}} {
result := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || loads != 0 {
t.Fatalf("%v: %#v loads=%d", args, result, loads)
}
}
}

View File

@@ -3,51 +3,81 @@ package ckconfig
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"io" "strings"
"text/template"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
) )
type explanationReport struct { type explanationReport struct {
Scope string `yaml:"scope"` Scope string `json:"scope"`
Path string `yaml:"path"` Path string `json:"path"`
Effective snapshotDTO `yaml:"effective"` Effective snapshotDTO `json:"effective"`
Origin explanationOriginDTO `yaml:"origin"` Origin explanationOriginDTO `json:"origin"`
History []explanationChangeDTO `yaml:"history"` History []explanationChangeDTO `json:"history"`
SecretReference *secretReferenceDTO `yaml:"secret_reference"` SecretReference *secretReferenceDTO `json:"secret_reference"`
Metadata *explanationMetadata `json:"metadata,omitempty"`
} }
type explanationMetadata struct {
Explicit *bool `json:"explicit,omitempty"`
Null *bool `json:"null,omitempty"`
History []metadataHistoryRecord `json:"history,omitempty"`
}
type metadataHistoryRecord struct {
Path string `json:"path"`
Previous []explanationOriginDTO `json:"previous,omitempty"`
}
type pathDiscoveryReport struct {
Scope string `json:"scope"`
Paths []pathDiscoveryEntry `json:"paths"`
}
type pathDiscoveryEntry struct {
Path string `json:"path"`
Present bool `json:"present"`
Metadata *pathMetadata `json:"metadata,omitempty"`
}
type pathMetadata struct {
Explicit bool `json:"explicit"`
}
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))
type snapshotDTO struct { type snapshotDTO struct {
Present bool `yaml:"present"` Present bool `json:"present"`
Redacted bool `yaml:"redacted"` Redacted bool `json:"redacted"`
YAML string `yaml:"yaml"` YAML string `json:"yaml"`
} }
type explanationOriginDTO struct { type explanationOriginDTO struct {
Layer string `yaml:"layer"` Layer string `json:"layer"`
Locations []locationDTO `yaml:"locations"` Locations []locationDTO `json:"locations"`
Operation string `yaml:"operation"` Operation string `json:"operation"`
Inheritance []inheritanceStepDTO `yaml:"inheritance"` Inheritance []inheritanceStepDTO `json:"inheritance"`
} }
type inheritanceStepDTO struct { type inheritanceStepDTO struct {
From string `yaml:"from"` From string `json:"from"`
To string `yaml:"to"` To string `json:"to"`
Reference string `yaml:"reference"` Reference string `json:"reference"`
Locations []locationDTO `yaml:"locations"` Locations []locationDTO `json:"locations"`
} }
type explanationChangeDTO struct { type explanationChangeDTO struct {
Before snapshotDTO `yaml:"before"` Before snapshotDTO `json:"before"`
After snapshotDTO `yaml:"after"` After snapshotDTO `json:"after"`
Origin explanationOriginDTO `yaml:"origin"` Origin explanationOriginDTO `json:"origin"`
Reasons []string `yaml:"reasons"` Reasons []string `json:"reasons"`
Dependencies []string `yaml:"dependencies"` Dependencies []string `json:"dependencies"`
} }
type secretReferenceDTO struct { type secretReferenceDTO struct {
Name string `yaml:"name"` Name string `json:"name"`
} }
func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome { func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
@@ -63,15 +93,40 @@ func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *conf
} }
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
Operation: Explain, Operation: Explain,
Selection: plan.selection, Selection: selected.selection,
}, selected.input, state) }, selected.input, state)
queueConfigDiagnostics(reporter, acquired.Diagnostics) queueConfigAcquisition(reporter, acquired)
if acquireErr != nil { if acquireErr != nil {
return configFailure(fmt.Sprintf("Unable to explain configuration scope %q.", name), acquired.Explanation, acquireErr) return configFailure(fmt.Sprintf("Unable to explain configuration scope %q.", name), acquired.Explanation, acquireErr)
} }
if err := invocation.Context.Err(); err != nil { if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration explanation was canceled.", acquired.Explanation, err) return configFailure("Configuration explanation was canceled.", acquired.Explanation, err)
} }
if plan.list {
if acquired.Value.Metadata == nil {
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{}}
for _, path := range acquired.Value.Metadata.HistoricalPaths() {
if plan.under != nil && !pathWithin(path, *plan.under) {
continue
}
info, present := acquired.Value.Metadata.Lookup(path)
if !present && (!plan.historical || plan.explicit.Set) {
continue
}
if present && plan.explicit.Set && info.Explicit != plan.explicit.Value {
continue
}
entry := pathDiscoveryEntry{Path: path.String(), Present: present}
if present {
entry.Metadata = &pathMetadata{Explicit: info.Explicit}
}
report.Paths = append(report.Paths, entry)
}
_ = reporter.Report(commandkit.Report{Value: report, TextTemplate: pathDiscoveryTemplate})
return commandkit.Success()
}
if acquired.Value.Explanation == nil { if acquired.Value.Explanation == nil {
cause := fmt.Errorf("configuration scope %q did not provide an explanation reader", name) cause := fmt.Errorf("configuration scope %q did not provide an explanation reader", name)
return configFailure("Configuration scope did not provide explanation data.", acquired.Explanation, cause) return configFailure("Configuration scope did not provide explanation data.", acquired.Explanation, cause)
@@ -85,22 +140,57 @@ func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *conf
if err := invocation.Context.Err(); err != nil { if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration explanation was canceled.", acquired.Explanation, err) return configFailure("Configuration explanation was canceled.", acquired.Explanation, err)
} }
if plan.format != "yaml" { report := makeExplanationReport(name, explanation)
err := reporter.RenderOutput(func(writer io.Writer) error { if acquired.Value.Metadata != nil {
return acquired.Value.Explanation.WriteExplanation(writer, plan.path) metadata := &explanationMetadata{}
}) if info, ok := acquired.Value.Metadata.Lookup(plan.path); ok {
if err != nil && invocation.Context.Err() != nil { metadata.Explicit, metadata.Null = &info.Explicit, &info.Null
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err()) }
for _, change := range acquired.Value.Metadata.History(plan.path) {
entry := metadataHistoryRecord{Path: change.Path.String()}
for _, previous := range change.Previous {
entry.Previous = append(entry.Previous, originReport(previous))
}
metadata.History = append(metadata.History, entry)
}
if metadata.Explicit != nil || len(metadata.History) > 0 {
report.Metadata = metadata
} }
return commandkit.Success()
} }
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) { text := template.Must(commandkit.ParseTextTemplate("explanation", "{{render .}}", template.FuncMap{
var report bytes.Buffer "render": func(report explanationReport) (string, error) {
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil { var output bytes.Buffer
return nil, err 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 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 report.Bytes(), nil return strings.Split(strings.TrimPrefix(value, "/"), "/")
}) }
child, prefix := tokens(path.String()), tokens(parent.String())
if len(prefix) > len(child) {
return false
}
for index := range prefix {
if prefix[index] != child[index] {
return false
}
}
return true
} }
func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport { func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport {

View File

@@ -1,30 +1,31 @@
package ckconfig package ckconfig
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"sort" "sort"
"strings" "strings"
"text/template"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
) )
type scopeMetadata struct { type scopeMetadata struct {
Name string `yaml:"name"` Name string `json:"name"`
Operations []Operation `yaml:"operations"` Operations []Operation `json:"operations"`
Features []string `json:"features,omitempty"`
} }
type scopeInventory struct { type scopeInventory struct {
Scopes []scopeMetadata `yaml:"scopes"` Scopes []scopeMetadata `json:"scopes"`
} }
type preparedScope[I, S any] struct { type preparedScope[I, S any] struct {
scope Scope[I, S] scope Scope[I, S]
bindings []commandkit.Flag[I] bindings []commandkit.Flag[I]
againstBindings []commandkit.Flag[I]
} }
type preparedSuite[I, S any] struct { type preparedSuite[I, S any] struct {
@@ -34,6 +35,7 @@ type preparedSuite[I, S any] struct {
scopes []*preparedScope[I, S] scopes []*preparedScope[I, S]
byName map[string]*preparedScope[I, S] byName map[string]*preparedScope[I, S]
bindingsByOperation map[Operation][]commandkit.Flag[I] bindingsByOperation map[Operation][]commandkit.Flag[I]
againstBindings []commandkit.Flag[I]
scopeChoices string scopeChoices string
operations []Operation operations []Operation
inventory scopeInventory inventory scopeInventory
@@ -90,9 +92,13 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
bindingsByOperation: make(map[Operation][]commandkit.Flag[I]), bindingsByOperation: make(map[Operation][]commandkit.Flag[I]),
} }
selectors := make([]Selector[I], len(suite.Selectors)) selectors := make([]Selector[I], len(suite.Selectors))
against := make([]commandkit.Flag[I], len(suite.Selectors))
for index, selector := range suite.Selectors { for index, selector := range suite.Selectors {
selector.Scopes = append([]string(nil), selector.Scopes...) selector.Scopes = append([]string(nil), selector.Scopes...)
selectors[index] = selector selectors[index] = selector
if selector.AgainstName != "" {
against[index] = commandkit.RenameFlag(selector.Binding, selector.AgainstName)
}
} }
seenNames := make(map[string]struct{}, len(suite.Scopes)) seenNames := make(map[string]struct{}, len(suite.Scopes))
@@ -158,12 +164,18 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
for scopeIndex := range prepared.scopes { for scopeIndex := range prepared.scopes {
if len(prepared.scopes[scopeIndex].scope.Operations) > 0 { if len(prepared.scopes[scopeIndex].scope.Operations) > 0 {
prepared.scopes[scopeIndex].bindings = append(prepared.scopes[scopeIndex].bindings, selector.Binding) prepared.scopes[scopeIndex].bindings = append(prepared.scopes[scopeIndex].bindings, selector.Binding)
if selector.AgainstName != "" && containsOperation(prepared.scopes[scopeIndex].scope.Operations, Diff) {
prepared.scopes[scopeIndex].againstBindings = append(prepared.scopes[scopeIndex].againstBindings, against[index])
}
} }
} }
} else { } else {
for _, name := range uniqueScopes { for _, name := range uniqueScopes {
if scope, exists := prepared.byName[name]; exists && len(scope.scope.Operations) > 0 { if scope, exists := prepared.byName[name]; exists && len(scope.scope.Operations) > 0 {
scope.bindings = append(scope.bindings, selector.Binding) scope.bindings = append(scope.bindings, selector.Binding)
if selector.AgainstName != "" && containsOperation(scope.scope.Operations, Diff) {
scope.againstBindings = append(scope.againstBindings, against[index])
}
} }
} }
} }
@@ -176,10 +188,13 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
break break
} }
} }
for _, selector := range selectors { for index, selector := range selectors {
for _, scope := range prepared.scopes { for _, scope := range prepared.scopes {
if containsOperation(scope.scope.Operations, operation) && selectorApplies(selector.Scopes, scope.scope.Name) { if containsOperation(scope.scope.Operations, operation) && selectorApplies(selector.Scopes, scope.scope.Name) {
prepared.bindingsByOperation[operation] = append(prepared.bindingsByOperation[operation], selector.Binding) prepared.bindingsByOperation[operation] = append(prepared.bindingsByOperation[operation], selector.Binding)
if operation == Diff && selector.AgainstName != "" {
prepared.againstBindings = append(prepared.againstBindings, against[index])
}
break break
} }
} }
@@ -189,6 +204,7 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
prepared.inventory.Scopes = append(prepared.inventory.Scopes, scopeMetadata{ prepared.inventory.Scopes = append(prepared.inventory.Scopes, scopeMetadata{
Name: scope.scope.Name, Name: scope.scope.Name,
Operations: append([]Operation(nil), scope.scope.Operations...), Operations: append([]Operation(nil), scope.scope.Operations...),
Features: featureNames(scope.scope.Features),
}) })
} }
return prepared return prepared
@@ -254,62 +270,37 @@ func joinNames(names []string) string {
return strings.Join(ordered, ", ") return strings.Join(ordered, ", ")
} }
func featureNames(features ScopeFeatures) []string {
var names []string
if features.PathDiscovery {
names = append(names, "path_discovery")
}
if features.PathShow {
names = append(names, "path_show")
}
if features.Overrides {
names = append(names, "overrides")
}
return names
}
var scopeInventoryTemplate = template.Must(commandkit.ParseTextTemplate("scopes", `{{range .Scopes}}{{.Name}}: {{range $index, $operation := .Operations}}{{if $index}}, {{end}}{{$operation}}{{end}}{{if .Features}} (features: {{range $index, $feature := .Features}}{{if $index}}, {{end}}{{$feature}}{{end}}){{end}}
{{end}}`, nil))
func newConfigScopesCommand[I, S any](prepared *preparedSuite[I, S]) commandkit.Command { func newConfigScopesCommand[I, S any](prepared *preparedSuite[I, S]) commandkit.Command {
formatDefault := "text" var description strings.Builder
spec := commandkit.CommandSpec[scopeInventoryInput]{ for _, scope := range prepared.inventory.Scopes {
Name: "scopes", if len(scope.Features) > 0 {
Summary: "List configuration scopes and supported operations.", fmt.Fprintf(&description, "%s: %s\n", scope.Name, strings.Join(scope.Features, ", "))
Flags: []commandkit.Flag[scopeInventoryInput]{commandkit.ValueFlag( }
commandkit.FlagMeta{Name: "format", Usage: "Output format: text or yaml.", Completion: completionChoices("text", "yaml")}, &formatDefault, parseConfigString, }
func(input *scopeInventoryInput, value commandkit.Supplied[string]) { input.Format = value.Value }, return commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
)}, Name: "scopes", Summary: "List configuration scopes, operations, and supported features.", Description: description.String(),
CheckInput: func(input scopeInventoryInput) error { return checkConfigFormat(input.Format) }, Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
Run: func(invocation commandkit.Invocation, input scopeInventoryInput) commandkit.Outcome { _ = invocation.Reporter().Report(commandkit.Report{Value: prepared.inventory, TextTemplate: scopeInventoryTemplate})
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
var report bytes.Buffer
if input.Format == "yaml" {
if err := encodeYAML(&report, prepared.inventory); err != nil {
return err
}
} else {
_, _ = report.WriteString(formatScopeInventory(prepared.inventory))
}
_, err := writer.Write(report.Bytes())
return err
})
return commandkit.Success() return commandkit.Success()
}, },
} })
return commandkit.NewCommand(spec)
}
type scopeInventoryInput struct{ Format string }
type scopeInventoryYAML struct {
Scopes []scopeMetadata `yaml:"scopes"`
}
func formatScopeInventory(inventory scopeInventory) string {
var output strings.Builder
for _, scope := range inventory.Scopes {
output.WriteString(scope.Name)
output.WriteString(": ")
for index, operation := range scope.Operations {
if index > 0 {
output.WriteString(", ")
}
output.WriteString(string(operation))
}
output.WriteByte('\n')
}
return output.String()
}
func checkConfigFormat(format string) error {
if format != "text" && format != "yaml" {
return fmt.Errorf("unsupported configuration format %q", format)
}
return nil
} }
func parseConfigString(value string) (string, error) { return value, nil } func parseConfigString(value string) (string, error) { return value, nil }

View File

@@ -6,11 +6,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"reflect"
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
) )
func TestScopeDiscoveryIsOrderedAndDoesNotUseSuiteFactories(t *testing.T) { func TestScopeDiscoveryIsOrderedAndDoesNotUseSuiteFactories(t *testing.T) {
@@ -270,7 +271,7 @@ func TestConfigAliasesForwardSelectedTenantAndGlobalHelp(t *testing.T) {
} }
} }
func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) { func TestShowAcquiresAllViewsBeforeRenderingAggregate(t *testing.T) {
var output bytes.Buffer var output bytes.Buffer
var acquisitionOutputLengths []int var acquisitionOutputLengths []int
makeScope := func(name, native string, acquireErr error) Scope[struct{}, *int] { makeScope := func(name, native string, acquireErr error) Scope[struct{}, *int] {
@@ -306,9 +307,13 @@ func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) {
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Presentation.Output.State != commandkit.DeliveryComplete { if outcome.Kind != commandkit.OutcomeSuccess || outcome.Presentation.Output.State != commandkit.DeliveryComplete {
t.Fatalf("aggregate show outcome = %#v", outcome) t.Fatalf("aggregate show outcome = %#v", outcome)
} }
want := "---\n# scope: app\nname: app\n---\n# scope: promptkit\nname: promptkit\n" decoded, err := commandkit.DecodeYAMLReport(&output)
if output.String() != want { if err != nil {
t.Fatalf("aggregate show output = %q, want %q", output.String(), want) t.Fatal(err)
}
want := map[string]any{"app": map[string]any{"name": "app"}, "promptkit": map[string]any{"name": "promptkit"}}
if !reflect.DeepEqual(decoded, want) {
t.Fatalf("aggregate = %#v, want %#v", decoded, want)
} }
} }
@@ -441,6 +446,7 @@ func TestExplainParsesBeforeAcquisitionAndPreservesNativeSafeHistory(t *testing.
value: configkit.Explanation{ value: configkit.Explanation{
Path: configkit.Path{}, Path: configkit.Path{},
Effective: configkit.ValueSnapshot{Present: true, Redacted: true}, Effective: configkit.ValueSnapshot{Present: true, Redacted: true},
Origin: configkit.Origin{Layer: configkit.LayerMain},
History: []configkit.ValueChange{{ History: []configkit.ValueChange{{
Before: configkit.ValueSnapshot{Present: true, Redacted: true}, Before: configkit.ValueSnapshot{Present: true, Redacted: true},
After: configkit.ValueSnapshot{Present: true, Redacted: true}, After: configkit.ValueSnapshot{Present: true, Redacted: true},
@@ -460,7 +466,7 @@ func TestExplainParsesBeforeAcquisitionAndPreservesNativeSafeHistory(t *testing.
if outcome.Kind != commandkit.OutcomeSuccess || acquireCalls != 1 || reader.path.String() != "" { if outcome.Kind != commandkit.OutcomeSuccess || acquireCalls != 1 || reader.path.String() != "" {
t.Fatalf("root explanation outcome=%#v calls=%d path=%q", outcome, acquireCalls, reader.path.String()) t.Fatalf("root explanation outcome=%#v calls=%d path=%q", outcome, acquireCalls, reader.path.String())
} }
if strings.Contains(output.String(), "APP_TOKEN_VALUE") || !strings.Contains(output.String(), "redacted: true") || !strings.Contains(output.String(), "history:") || !strings.Contains(output.String(), "dependencies: []") || !strings.Contains(output.String(), "name: APP_TOKEN") { if strings.Contains(output.String(), "APP_TOKEN_VALUE") || !strings.Contains(output.String(), "redacted: true") || !strings.Contains(output.String(), "layer: main") || !strings.Contains(output.String(), "history:") || !strings.Contains(output.String(), "dependencies: []") || !strings.Contains(output.String(), "name: APP_TOKEN") {
t.Fatalf("safe explanation report = %s", output.String()) t.Fatalf("safe explanation report = %s", output.String())
} }
@@ -482,7 +488,7 @@ func TestExplainTracksNativeRenderFailureWithoutReplacingOperationCause(t *testi
}}})) }}}))
var output bytes.Buffer var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "explain", "--path", "/token"}, commandkit.Streams{Out: &output, Err: io.Discard}) outcome := app.Execute(context.Background(), []string{"config", "explain", "--path", "/token"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.RenderError, renderErr) || output.String() != "partial native explanation\n" { 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()) t.Fatalf("native explanation failure outcome/output = %#v / %q", outcome, output.String())
} }
} }
@@ -512,7 +518,7 @@ func TestReportWriteFailureIsRecordedByReporterAndPreservesCancellation(t *testi
}})) }}))
var output bytes.Buffer var output bytes.Buffer
outcome = app.Execute(ctx, []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard}) outcome = app.Execute(ctx, []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
if !errors.Is(outcome.Cause, context.Canceled) || outcome.Presentation.Output.State != commandkit.DeliveryComplete || calls != 2 || output.String() != "app: valid\n" { if !errors.Is(outcome.Cause, context.Canceled) || outcome.Presentation.Output.State != commandkit.DeliveryComplete || calls != 2 || output.String() != "app: valid\npromptkit: canceled\n" {
t.Fatalf("cancelled aggregate validation outcome=%#v calls=%d output=%q", outcome, calls, output.String()) t.Fatalf("cancelled aggregate validation outcome=%#v calls=%d output=%q", outcome, calls, output.String())
} }
} }
@@ -587,3 +593,30 @@ func newConfigTestApp(t *testing.T, command commandkit.Command) *commandkit.App
} }
return app return app
} }
func TestScopeFeaturesAndAcquisitionMessages(t *testing.T) {
calls := 0
message := func(text string) commandkit.Diagnostic {
return commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(w io.Writer) error { _, err := io.WriteString(w, text); return err }}
}
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Validate}, Features: ScopeFeatures{PathDiscovery: true, PathShow: true}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{Value: View{Findings: &Findings{Messages: []commandkit.Diagnostic{message("native warning\n")}}}, Diagnostics: []commandkit.Diagnostic{message("application warning\n")}}, nil
}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
var out, diagnostic bytes.Buffer
result := app.Execute(context.Background(), []string{"config", "scopes", "--format=json"}, commandkit.Streams{Out: &out, Err: &diagnostic})
if result.Kind != commandkit.OutcomeSuccess || calls != 0 || !strings.Contains(out.String(), "path_discovery") || !strings.Contains(out.String(), "path_show") {
t.Fatalf("discovery = %#v %q", result, out.String())
}
out.Reset()
result = app.Execute(context.Background(), []string{"config", "scopes", "--help"}, commandkit.Streams{Out: &out, Err: &diagnostic})
if result.Kind != commandkit.OutcomeSuccess || calls != 0 || !strings.Contains(out.String(), "path_discovery") {
t.Fatalf("help = %#v %q", result, out.String())
}
out.Reset()
result = app.Execute(context.Background(), []string{"config", "validate", "--verbose"}, commandkit.Streams{Out: &out, Err: &diagnostic})
if result.Kind != commandkit.OutcomeSuccess || calls != 1 || strings.Count(diagnostic.String(), "native warning") != 1 || strings.Count(diagnostic.String(), "application warning") != 1 {
t.Fatalf("diagnostics = %#v %q", result, diagnostic.String())
}
}

View File

@@ -0,0 +1,126 @@
package ckconfig
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"reflect"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func TestFocusedShowUsesSafeNativeValue(t *testing.T) {
loads := 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{"object": map[string]any{"x": "y"}, "text": "hello", "false": false, "zero": 0, "null": nil, "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{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
for _, test := range []struct {
path string
want any
}{{"/object", map[string]any{"x": "y"}}, {"/text", "hello"}, {"/false", false}, {"/zero", float64(0)}, {"/null", nil}} {
var out bytes.Buffer
result := app.Execute(context.Background(), []string{"config", "show", "--path=" + test.path, "--format=json"}, commandkit.Streams{Out: &out, Err: io.Discard})
var got any
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal(out.Bytes(), &got) != nil || !reflect.DeepEqual(got, test.want) {
t.Fatalf("%s: %#v %q", test.path, result, out.String())
}
}
var out bytes.Buffer
result := app.Execute(context.Background(), []string{"config", "show", "--path=/text"}, commandkit.Streams{Out: &out, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || out.String() != "hello\n" {
t.Fatalf("automatic text: %#v %q", result, out.String())
}
out.Reset()
result = app.Execute(context.Background(), []string{"config", "show", "--path=/false", "--format=yaml"}, commandkit.Streams{Out: &out, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || strings.TrimSpace(out.String()) != "false" {
t.Fatalf("yaml false: %#v %q", result, out.String())
}
for _, args := range [][]string{{"--path=/missing"}, {"--path=/gone/x", "--set=/gone=null"}} {
result = app.Execute(context.Background(), append([]string{"config", "show"}, args...), commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeOperational {
t.Fatalf("missing %v: %#v", args, result)
}
}
before := loads
for _, args := range [][]string{{"--path=bad"}, {"--scope=all", "--path=/text"}} {
result = app.Execute(context.Background(), append([]string{"config", "show"}, args...), commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeUsage || loads != before {
t.Fatalf("preflight %v: %#v", args, result)
}
}
}
func TestFocusedShowChecksCapabilityAndFacet(t *testing.T) {
calls := 0
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{}, nil
}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
result := app.Execute(context.Background(), []string{"config", "show", "--path=/x"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeUsage || calls != 0 {
t.Fatalf("unsupported: %#v", result)
}
scope.Features.PathShow = true
app = newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
result = app.Execute(context.Background(), []string{"config", "show", "--path=/x"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeOperational || calls != 1 {
t.Fatalf("missing facet: %#v", result)
}
}
func TestSourcesFilterAndFailureEvidence(t *testing.T) {
cause := errors.New("load failed")
reader := &sourceInventory{values: []configkit.SourceInfo{{ID: "one", Label: "first"}, {ID: "two", Label: "second"}}}
var returnedErr error
var source SourceReader = reader
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Sources}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Sources: source}}, returnedErr
}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
run := func(args ...string) (commandkit.Outcome, string, string) {
var out, diag bytes.Buffer
result := app.Execute(context.Background(), append([]string{"config", "sources"}, args...), commandkit.Streams{Out: &out, Err: &diag})
return result, out.String(), diag.String()
}
result, out, _ := run("--source-id=two", "--format=json")
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out, `"id": "two"`) || strings.Contains(out, `"id": "one"`) {
t.Fatalf("selected source: %#v %q", result, out)
}
result, _, _ = run("--source-id=missing")
if result.Kind != commandkit.OutcomeOperational {
t.Fatalf("unknown source: %#v", result)
}
returnedErr = cause
result, out, _ = run("--source-id=one", "--format=json")
if !errors.Is(result.Cause, cause) || !strings.Contains(out, `"id": "one"`) || strings.Contains(out, `"id": "two"`) {
t.Fatalf("failed selection: %#v %q", result, out)
}
result, _, diag := run("--source-id=missing")
if !errors.Is(result.Cause, cause) || !strings.Contains(diag, "not found") {
t.Fatalf("failed missing source: %#v %q", result, diag)
}
source = &sourceInventory{}
result, out, _ = run("--format=json")
if !errors.Is(result.Cause, cause) || !strings.Contains(out, `"sources": []`) {
t.Fatalf("empty evidence: %#v %q", result, out)
}
source = nil
result, out, diag = run()
if !errors.Is(result.Cause, cause) || out != "" || !strings.Contains(diag, "unavailable") {
t.Fatalf("unavailable evidence: %#v %q %q", result, out, diag)
}
source = reader
result = app.Execute(context.Background(), []string{"config", "sources"}, commandkit.Streams{Out: shortWriter{}, Err: io.Discard})
if !errors.Is(result.Cause, cause) || result.Presentation.Output.State != commandkit.DeliveryIncomplete {
t.Fatalf("delivery failure: %#v", result)
}
}

View File

@@ -1,9 +1,10 @@
module go.maximumdirect.net/ckconfig module go.maximumdirect.net/ckconfig
go 1.26.0 go 1.27.0
require ( require (
gitea.maximumdirect.net/eric/ConfigKit v0.8.0 go.maximumdirect.net/commandkit v0.4.0
go.maximumdirect.net/configkit v1.1.0
go.yaml.in/yaml/v4 v4.0.0-rc.6 go.yaml.in/yaml/v4 v4.0.0-rc.6
) )
@@ -11,5 +12,4 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/cobra v1.10.1 // indirect github.com/spf13/cobra v1.10.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
go.maximumdirect.net/commandkit v0.3.0
) )

View File

@@ -1,5 +1,3 @@
gitea.maximumdirect.net/eric/ConfigKit v0.8.0 h1:xCIhYnOHjw7rFvB+vIremPKGeROOxKTomnUEUipuQno=
gitea.maximumdirect.net/eric/ConfigKit v0.8.0/go.mod h1:C82sbyKHYMV1OwaD2GJanFrwTfcZfjlW2JsGze5b0kA=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
@@ -8,8 +6,10 @@ github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.maximumdirect.net/commandkit v0.3.0 h1:RCmg2GS8uzlLSJCIqarpn1uFDec0W3ylNNRRPT6kUFE= go.maximumdirect.net/commandkit v0.4.0 h1:bWqflDWDn98FUCgh+0e+w6LGmvWIkaeJK519H7PjD2Y=
go.maximumdirect.net/commandkit v0.3.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc= go.maximumdirect.net/commandkit v0.4.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA=
go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -0,0 +1,79 @@
package ckconfig
import (
"context"
"io"
"reflect"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func TestComparisonSidesKeepIndependentSelectionsAndDefaults(t *testing.T) {
type input struct {
Label commandkit.Supplied[string]
Tags []string
}
label := commandkit.ValueFlag(commandkit.FlagMeta{Name: "label"}, (*string)(nil), parseConfigString, func(i *input, value commandkit.Supplied[string]) { i.Label = value })
tags := commandkit.RepeatFlag(commandkit.FlagMeta{Name: "tag"}, []string(nil), parseConfigString, func(i *input, value commandkit.Supplied[[]string]) { i.Tags = value.Value })
var pair ComparisonRequest[input]
calls := 0
suite := Suite[input, struct{}]{
NewInput: func() input {
return input{Label: commandkit.Supplied[string]{Value: "factory"}, Tags: []string{"factory"}}
},
Selectors: []Selector[input]{{Binding: label, AgainstName: "against-label"}, {Binding: tags, AgainstName: "against-tag"}},
Scopes: []Scope[input, struct{}]{{Name: "app", Operations: []Operation{Diff}, Compare: func(_ context.Context, request ComparisonRequest[input], _ struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
calls++
pair = request
return commandkit.Acquired[[]configkit.Difference]{}, nil
}}},
}
app := newConfigTestApp(t, Command(suite))
tests := []struct {
args []string
left, right Selection
leftLabel, rightLabel commandkit.Supplied[string]
leftTags, rightTags []string
}{
{leftLabel: commandkit.Supplied[string]{Value: "factory"}, rightLabel: commandkit.Supplied[string]{Value: "factory"}, leftTags: []string{"factory"}, rightTags: []string{"factory"}},
{args: []string{"--config=left", "--overlay=base", "--label=", "--tag=a", "--tag=b"}, left: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}, Overlay: commandkit.Supplied[string]{Value: "base", Set: true}}, leftLabel: commandkit.Supplied[string]{Set: true}, rightLabel: commandkit.Supplied[string]{Value: "factory"}, leftTags: []string{"a", "b"}, rightTags: []string{"factory"}},
{args: []string{"--against-config=right", "--against-label=r", "--against-tag=x"}, right: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}, leftLabel: commandkit.Supplied[string]{Value: "factory"}, rightLabel: commandkit.Supplied[string]{Value: "r", Set: true}, leftTags: []string{"factory"}, rightTags: []string{"x"}},
{args: []string{"--config=left", "--against-overlay=", "--label=l", "--against-label=r", "--tag=a", "--against-tag=b"}, left: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}, right: Selection{Overlay: commandkit.Supplied[string]{Set: true}}, leftLabel: commandkit.Supplied[string]{Value: "l", Set: true}, rightLabel: commandkit.Supplied[string]{Value: "r", Set: true}, leftTags: []string{"a"}, rightTags: []string{"b"}},
}
for _, test := range tests {
result := app.Execute(context.Background(), append([]string{"config", "diff"}, test.args...), commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || !reflect.DeepEqual(pair.Left.Selection, test.left) || !reflect.DeepEqual(pair.Right.Selection, test.right) || pair.Left.Input.Label != test.leftLabel || pair.Right.Input.Label != test.rightLabel || !reflect.DeepEqual(pair.Left.Input.Tags, test.leftTags) || !reflect.DeepEqual(pair.Right.Input.Tags, test.rightTags) {
t.Fatalf("args=%v outcome=%#v pair=%#v", test.args, result, pair)
}
}
if calls != len(tests) {
t.Fatalf("provider calls = %d", calls)
}
}
func TestComparisonRequiredCounterpartsAndCollisions(t *testing.T) {
flag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "choice", Required: true}, (*string)(nil), parseConfigString, func(i *string, value commandkit.Supplied[string]) { *i = value.Value })
calls := 0
scope := Scope[string, struct{}]{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[string], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
calls++
return commandkit.Acquired[[]configkit.Difference]{}, nil
}}
suite := Suite[string, struct{}]{Selectors: []Selector[string]{{Binding: flag, AgainstName: "against-choice"}}, Scopes: []Scope[string, struct{}]{scope}}
app := newConfigTestApp(t, Command(suite))
for _, args := range [][]string{{"--choice=x"}, {"--against-choice=x"}} {
result := app.Execute(context.Background(), append([]string{"config", "diff"}, args...), commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeUsage || calls != 0 {
t.Fatalf("missing side %v: %#v", args, result)
}
}
result := app.Execute(context.Background(), []string{"config", "diff", "--choice=x", "--against-choice=y"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || calls != 1 {
t.Fatalf("complete sides: %#v", result)
}
suite.Selectors[0].AgainstName = "against-config"
if _, err := commandkit.New(commandkit.Identity{Name: "app"}, Command(suite)); err == nil {
t.Fatal("conflicting counterpart accepted")
}
}

View File

@@ -4,23 +4,33 @@ import (
"fmt" "fmt"
"strings" "strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
) )
type configCommandInput[I, S any] struct { type configCommandInput[I, S any] struct {
selection Selection selection Selection
against Selection against Selection
scope commandkit.Supplied[string] scope commandkit.Supplied[string]
format commandkit.Supplied[string] path commandkit.Supplied[string]
path commandkit.Supplied[string] list commandkit.Supplied[bool]
values commandkit.FlagValues[I] historical commandkit.Supplied[bool]
plan *configOperationPlan[I, S] under commandkit.Supplied[string]
explicit commandkit.Supplied[bool]
sourceID commandkit.Supplied[string]
details commandkit.Supplied[bool]
check commandkit.Supplied[bool]
severities []configkit.Severity
codes []string
values commandkit.FlagValues[I]
againstValues commandkit.FlagValues[I]
plan *configOperationPlan[I, S]
} }
type selectedScopeInput[I, S any] struct { type selectedScopeInput[I, S any] struct {
scope *preparedScope[I, S] scope *preparedScope[I, S]
input I input I
selection Selection
} }
type preparedComparison[I, S any] struct { type preparedComparison[I, S any] struct {
@@ -31,24 +41,25 @@ type preparedComparison[I, S any] struct {
type configOperationPlan[I, S any] struct { type configOperationPlan[I, S any] struct {
prepared *preparedSuite[I, S] prepared *preparedSuite[I, S]
operation Operation operation Operation
selection Selection
scopeName string scopeName string
format string
path configkit.Path path configkit.Path
pathSet bool
sourceID commandkit.Supplied[string]
details bool
check bool
severities []configkit.Severity
codes []string
list bool
help bool
historical bool
under *configkit.Path
explicit commandkit.Supplied[bool]
scopes []selectedScopeInput[I, S] scopes []selectedScopeInput[I, S]
comparison *preparedComparison[I, S] comparison *preparedComparison[I, S]
} }
func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operation Operation) commandkit.Command { func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operation Operation) commandkit.Command {
defaultScope := "app" defaultScope := "app"
defaultFormat := "text"
if operation == Show || operation == Diff {
defaultFormat = "yaml"
}
formats := completionChoices("text", "yaml")
if operation == Show || operation == Diff {
formats = completionChoices("yaml")
}
var scopeChoices []commandkit.CompletionChoice var scopeChoices []commandkit.CompletionChoice
allSupported := len(prepared.scopes) > 0 allSupported := len(prepared.scopes) > 0
for _, scope := range prepared.scopes { for _, scope := range prepared.scopes {
@@ -64,8 +75,6 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
flags := []commandkit.Flag[*configCommandInput[I, S]]{ flags := []commandkit.Flag[*configCommandInput[I, S]]{
commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported.", Completion: commandkit.CompleteChoices(scopeChoices...)}, &defaultScope, parseConfigString, commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported.", Completion: commandkit.CompleteChoices(scopeChoices...)}, &defaultScope, parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).scope = value }), func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).scope = value }),
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Output format.", Completion: formats}, &defaultFormat, parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).format = value }),
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString, commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
(*input).selection.Config = value (*input).selection.Config = value
@@ -77,6 +86,8 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
} }
if operation == Diff { if operation == Diff {
flags = append(flags, 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, 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]) { func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
(*input).against.Config = value (*input).against.Config = value
@@ -87,9 +98,43 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
}), }),
) )
} }
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.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]) {
(*input).codes = value.Value
}),
)
}
if supportsOverrides(prepared, operation) {
flags = append(flags, commandkit.RepeatFlag(commandkit.FlagMeta{Name: "set", Usage: "Override POINTER=JSON (pointer keys cannot contain =)."}, []configkit.Override(nil), overrideParser("--set"),
func(input **configCommandInput[I, S], value commandkit.Supplied[[]configkit.Override]) {
(*input).selection.Overrides = value.Value
}))
if operation == Diff {
flags = append(flags, commandkit.RepeatFlag(commandkit.FlagMeta{Name: "against-set", Usage: "Right-side override POINTER=JSON (pointer keys cannot contain =)."}, []configkit.Override(nil), overrideParser("--against-set"),
func(input **configCommandInput[I, S], value commandkit.Supplied[[]configkit.Override]) {
(*input).against.Overrides = value.Value
}))
}
}
if operation == Explain { if operation == Explain {
flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain.", Required: true}, (*string)(nil), parseConfigString, flags = append(flags,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value })) commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value }),
commandkit.BoolFlag(commandkit.FlagMeta{Name: "list", Usage: "List configuration paths."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).list = value }),
commandkit.BoolFlag(commandkit.FlagMeta{Name: "historical", Usage: "Include historical-only paths when listing."}, 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 }),
)
}
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.CaptureFlags(prepared.bindingsByOperation[operation], func(input **configCommandInput[I, S]) *commandkit.FlagValues[I] { flags = append(flags, commandkit.CaptureFlags(prepared.bindingsByOperation[operation], func(input **configCommandInput[I, S]) *commandkit.FlagValues[I] {
if input == nil || *input == nil { if input == nil || *input == nil {
@@ -97,12 +142,21 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
} }
return &(*input).values return &(*input).values
})...) })...)
if operation == Diff {
flags = append(flags, commandkit.CaptureFlags(prepared.againstBindings, func(input **configCommandInput[I, S]) *commandkit.FlagValues[I] {
if input == nil || *input == nil {
return nil
}
return &(*input).againstValues
})...)
}
name, summary := operationDeclaration(operation) name, summary := operationDeclaration(operation)
return commandkit.NewCommand(commandkit.CommandSpec[*configCommandInput[I, S]]{ return commandkit.NewCommand(commandkit.CommandSpec[*configCommandInput[I, S]]{
Name: name, Summary: summary, Name: name, Summary: summary,
NewInput: func() *configCommandInput[I, S] { return &configCommandInput[I, S]{} }, Description: operationDescription(operation),
Flags: flags, NewInput: func() *configCommandInput[I, S] { return &configCommandInput[I, S]{} },
Flags: flags,
CheckInput: func(input *configCommandInput[I, S]) error { CheckInput: func(input *configCommandInput[I, S]) error {
if input == nil { if input == nil {
return fmt.Errorf("configuration input must not be nil") return fmt.Errorf("configuration input must not be nil")
@@ -115,6 +169,13 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
}) })
} }
func operationDescription(operation Operation) string {
if operation == Explain {
return "Use --list to discover paths or --path POINTER to explain one path."
}
return ""
}
func operationDeclaration(operation Operation) (string, string) { func operationDeclaration(operation Operation) (string, string) {
switch operation { switch operation {
case Validate: case Validate:
@@ -133,15 +194,20 @@ func operationDeclaration(operation Operation) (string, string) {
} }
func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation Operation, input *configCommandInput[I, S]) error { func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation Operation, input *configCommandInput[I, S]) error {
format := input.format.Value if operation == Validate && !input.details.Value && (len(input.severities) > 0 || len(input.codes) > 0) {
if err := checkConfigFormat(format); err != nil { return fmt.Errorf("--severity and --code require --details")
return err
} }
if operation == Show && format != "yaml" { if operation == Explain {
return fmt.Errorf("unsupported configuration show format %q", format) if input.list.Value && input.path.Set {
} return fmt.Errorf("--list and --path cannot be combined")
if operation == Diff && format != "yaml" { }
return fmt.Errorf("unsupported configuration diff format %q", format) if !input.list.Value && (input.historical.Set || input.under.Set || input.explicit.Set) {
return 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
}
} }
scopeName := "app" scopeName := "app"
@@ -154,6 +220,9 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O
if scopeName == "all" && operation != Validate && operation != Show { if scopeName == "all" && operation != Validate && operation != Show {
return fmt.Errorf("scope all is supported only for validate and show") return 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")
}
var selected []*preparedScope[I, S] var selected []*preparedScope[I, S]
if scopeName == "all" { if scopeName == "all" {
@@ -168,37 +237,60 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O
if !containsOperation(scope.scope.Operations, operation) { if !containsOperation(scope.scope.Operations, operation) {
return fmt.Errorf("configuration scope %q does not support %s (available: %s)", scope.scope.Name, operation, joinOperations(scope.scope.Operations)) return 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)
}
if operation == Explain && input.list.Value && !scope.scope.Features.PathDiscovery {
return 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)
}
} }
var path configkit.Path var path configkit.Path
if operation == Explain { if (operation == Explain || operation == Show) && input.path.Set {
if !input.path.Set {
return fmt.Errorf("configuration explanation requires --path")
}
parsed, err := configkit.ParsePath(input.path.Value) parsed, err := configkit.ParsePath(input.path.Value)
if err != nil { if err != nil {
return fmt.Errorf("invalid configuration path: %w", err) return fmt.Errorf("invalid configuration path: %w", err)
} }
path = parsed path = parsed
} }
var under *configkit.Path
if input.under.Set {
parsed, err := configkit.ParsePath(input.under.Value)
if err != nil {
return fmt.Errorf("invalid subtree path: %w", err)
}
under = &parsed
}
plan := &configOperationPlan[I, S]{ plan := &configOperationPlan[I, S]{
prepared: prepared, prepared: prepared,
operation: operation, operation: operation,
selection: input.selection, scopeName: scopeName,
scopeName: scopeName, path: path,
format: format, pathSet: input.path.Set,
path: path, sourceID: input.sourceID,
scopes: make([]selectedScopeInput[I, S], 0, len(selected)), details: input.details.Value,
check: input.check.Value,
severities: append([]configkit.Severity(nil), input.severities...),
codes: append([]string(nil), input.codes...),
list: input.list.Value,
historical: input.historical.Value,
under: under,
explicit: input.explicit,
scopes: make([]selectedScopeInput[I, S], 0, len(selected)),
} }
if operation == Diff { if operation == Diff {
input.against = inheritComparisonSelection(input.selection, input.against)
scope := selected[0] scope := selected[0]
left, err := prepareScopeInput(prepared, input.values, scope, input.selection) 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 { if err != nil {
return fmt.Errorf("configuration scope %q left input: %w", scope.scope.Name, err) return fmt.Errorf("configuration scope %q left input: %w", scope.scope.Name, err)
} }
right, err := prepareScopeInput(prepared, input.values, scope, input.against) right, err := prepareScopeInput(prepared, input.againstValues, scope, scope.againstBindings, input.against, true)
if err != nil { if err != nil {
return fmt.Errorf("configuration scope %q right input: %w", scope.scope.Name, err) return fmt.Errorf("configuration scope %q right input: %w", scope.scope.Name, err)
} }
@@ -213,24 +305,29 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O
return nil return nil
} }
for _, scope := range selected { for _, scope := range selected {
value, err := prepareScopeInput(prepared, input.values, scope, input.selection) selection := cloneSelection(input.selection)
value, err := prepareScopeInput(prepared, input.values, scope, scope.bindings, selection, false)
if err != nil { if err != nil {
return fmt.Errorf("configuration scope %q: %w", scope.scope.Name, err) return fmt.Errorf("configuration scope %q: %w", scope.scope.Name, err)
} }
plan.scopes = append(plan.scopes, selectedScopeInput[I, S]{scope: scope, input: value}) plan.scopes = append(plan.scopes, selectedScopeInput[I, S]{scope: scope, input: value, selection: selection})
} }
input.plan = plan input.plan = plan
return nil return nil
} }
func prepareScopeInput[I, S any](prepared *preparedSuite[I, S], values commandkit.FlagValues[I], scope *preparedScope[I, S], selection Selection) (I, error) { func prepareScopeInput[I, S any](prepared *preparedSuite[I, S], values commandkit.FlagValues[I], scope *preparedScope[I, S], bindings []commandkit.Flag[I], selection Selection, suppliedOnly bool) (I, error) {
var value I var value I
if prepared.newInput != nil { if prepared.newInput != nil {
value = prepared.newInput() value = prepared.newInput()
} }
// Availability was checked before this helper, so every binding for the // Availability was checked before this helper, so every binding for the
// selected scope belongs to the operation's captured union. // selected scope belongs to the operation's captured union.
if err := values.ApplyTo(&value, scope.bindings...); err != nil { apply := values.ApplyTo
if suppliedOnly {
apply = values.ApplySuppliedTo
}
if err := apply(&value, bindings...); err != nil {
return value, err return value, err
} }
if prepared.checkInput != nil { if prepared.checkInput != nil {
@@ -241,16 +338,6 @@ func prepareScopeInput[I, S any](prepared *preparedSuite[I, S], values commandki
return value, nil return value, nil
} }
func inheritComparisonSelection(left, right Selection) Selection {
if !right.Config.Set {
right.Config = left.Config
}
if !right.Overlay.Set {
right.Overlay = left.Overlay
}
return right
}
func joinOperations(operations []Operation) string { func joinOperations(operations []Operation) string {
values := make([]string, len(operations)) values := make([]string, len(operations))
for index, operation := range operations { for index, operation := range operations {

View File

@@ -11,6 +11,9 @@ func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Op
if input == nil || input.plan == nil { if input == nil || input.plan == nil {
return commandkit.Failure("Configuration operation was not prepared.") return commandkit.Failure("Configuration operation was not prepared.")
} }
if input.plan.help {
return invocation.Help()
}
if err := invocation.Context.Err(); err != nil { if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration operation was canceled.", commandkit.Explanation{}, err) return configFailure("Configuration operation was canceled.", commandkit.Explanation{}, err)
} }
@@ -54,6 +57,13 @@ func queueConfigDiagnostics(reporter *commandkit.Reporter, diagnostics []command
} }
} }
func queueConfigAcquisition(reporter *commandkit.Reporter, acquired commandkit.Acquired[View]) {
queueConfigDiagnostics(reporter, acquired.Diagnostics)
if acquired.Value.Findings != nil {
queueConfigDiagnostics(reporter, acquired.Value.Findings.Messages)
}
}
func configFailure(message string, explanation commandkit.Explanation, cause error) commandkit.Outcome { func configFailure(message string, explanation commandkit.Explanation, cause error) commandkit.Outcome {
if explanation.Message != "" || len(explanation.Details) > 0 { if explanation.Message != "" || len(explanation.Details) > 0 {
message = explanation.Message message = explanation.Message
@@ -71,14 +81,15 @@ func configAcquisitionFailure(
acquired commandkit.Acquired[View], acquired commandkit.Acquired[View],
cause error, cause error,
) commandkit.Outcome { ) commandkit.Outcome {
queueConfigDiagnostics(reporter, acquired.Diagnostics) queueConfigAcquisition(reporter, acquired)
message := fmt.Sprintf("Unable to %s configuration scope %q.", operation, scope) message := fmt.Sprintf("Unable to %s configuration scope %q.", operation, scope)
return configFailure(message, acquired.Explanation, cause) return configFailure(message, acquired.Explanation, cause)
} }
// Preserve the preparation/delivery cancellation checkpoint for buffered reports. // Preserve the preparation/delivery cancellation checkpoint for buffered reports.
func renderPreparedConfigReport(invocation commandkit.Invocation, explanation commandkit.Explanation, prepare func() ([]byte, error)) commandkit.Outcome { func renderPreparedConfigReport(invocation commandkit.Invocation, explanation commandkit.Explanation, prepare func() (any, error)) commandkit.Outcome {
var canceled error var canceled error
var value any
renderErr := invocation.Reporter().RenderOutput(func(writer io.Writer) error { renderErr := invocation.Reporter().RenderOutput(func(writer io.Writer) error {
report, err := prepare() report, err := prepare()
if err != nil { if err != nil {
@@ -87,8 +98,8 @@ func renderPreparedConfigReport(invocation commandkit.Invocation, explanation co
if canceled = invocation.Context.Err(); canceled != nil { if canceled = invocation.Context.Err(); canceled != nil {
return nil return nil
} }
_, err = writer.Write(report) value = report
return err return nil
}) })
if canceled == nil && renderErr != nil { if canceled == nil && renderErr != nil {
canceled = invocation.Context.Err() canceled = invocation.Context.Err()
@@ -96,5 +107,8 @@ func renderPreparedConfigReport(invocation commandkit.Invocation, explanation co
if canceled != nil { if canceled != nil {
return configFailure("Configuration report preparation was canceled.", explanation, canceled) return configFailure("Configuration report preparation was canceled.", explanation, canceled)
} }
if renderErr == nil {
_ = invocation.Reporter().Report(commandkit.Report{Value: value})
}
return commandkit.Success() return commandkit.Success()
} }

View File

@@ -0,0 +1,65 @@
package ckconfig
import (
"fmt"
"strings"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func supportsOverrides[I, S any](prepared *preparedSuite[I, S], operation Operation) bool {
for _, scope := range prepared.scopes {
if scope.scope.Features.Overrides && containsOperation(scope.scope.Operations, operation) {
return true
}
}
return false
}
func overrideParser(flag string) func(string) (configkit.Override, error) {
return func(raw string) (configkit.Override, error) {
pointer, jsonText, ok := strings.Cut(raw, "=")
if !ok {
return configkit.Override{}, fmt.Errorf("override requires POINTER=JSON")
}
path, err := configkit.ParsePath(pointer)
if err != nil {
return configkit.Override{}, fmt.Errorf("invalid override path: %w", err)
}
value, err := commandkit.ParseCLIJSON(jsonText)
if err != nil {
return configkit.Override{}, fmt.Errorf("invalid override JSON: %w", err)
}
return configkit.Override{Path: path, Value: value, Flag: flag}, nil
}
}
func cloneSelection(selection Selection) Selection {
if selection.Overrides != nil {
selection.Overrides = append([]configkit.Override(nil), selection.Overrides...)
for index := range selection.Overrides {
selection.Overrides[index].Value = cloneCLIValue(selection.Overrides[index].Value)
}
}
return selection
}
func cloneCLIValue(value any) any {
switch value := value.(type) {
case []any:
copy := make([]any, len(value))
for index, element := range value {
copy[index] = cloneCLIValue(element)
}
return copy
case map[string]any:
copy := make(map[string]any, len(value))
for key, element := range value {
copy[key] = cloneCLIValue(element)
}
return copy
default:
return value
}
}

View File

@@ -0,0 +1,94 @@
package ckconfig
import (
"bytes"
"context"
"errors"
"io"
"reflect"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func TestOverrideAssignmentsAreCopiedPerScopeAndPreservedInOrder(t *testing.T) {
var received []Selection
makeScope := func(name string) Scope[struct{}, struct{}] {
return Scope[struct{}, struct{}]{Name: name, Operations: []Operation{Validate}, Features: ScopeFeatures{Overrides: true}, Acquire: func(_ context.Context, req Request, _, _ struct{}) (commandkit.Acquired[View], error) {
received = append(received, cloneSelection(req.Selection))
if name == "app" {
req.Selection.Overrides[0].Value.(map[string]any)["numbers"].([]any)[0] = int64(999)
}
return commandkit.Acquired[View]{}, nil
}}
}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{makeScope("app"), makeScope("other")}}))
result := app.Execute(context.Background(), []string{"config", "validate", "--scope=all", "--set=/nested={\"numbers\":[1,2.5],\"v\":\"a=b\"}", "--set=/nested=null", "--set=/false=false", "--set=/zero=0", "--set=/empty=\"\""}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || len(received) != 2 || !reflect.DeepEqual(received[0].Overrides, received[1].Overrides) || len(received[0].Overrides) != 5 {
t.Fatalf("result=%#v selections=%#v", result, received)
}
first := received[1].Overrides
object, ok := first[0].Value.(map[string]any)
if !ok || !reflect.DeepEqual(object["numbers"], []any{int64(1), float64(2.5)}) || object["v"] != "a=b" || first[0].Flag != "--set" || first[1].Path.String() != "/nested" || first[1].Value != nil || first[2].Value != false || first[3].Value != int64(0) || first[4].Value != "" {
t.Fatalf("records=%#v", first)
}
}
func TestOverridePreflightAndComparisonSides(t *testing.T) {
calls, factories := 0, 0
var pair ComparisonRequest[struct{}]
supported := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Validate, Diff}, Features: ScopeFeatures{Overrides: true}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{}, nil
}, Compare: func(_ context.Context, value ComparisonRequest[struct{}], _ struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
calls++
pair = value
return commandkit.Acquired[[]configkit.Difference]{}, nil
}}
unsupported := Scope[struct{}, struct{}]{Name: "other", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{}, nil
}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{NewInput: func() struct{} { factories++; return struct{}{} }, Scopes: []Scope[struct{}, struct{}]{supported, unsupported}}))
for _, args := range [][]string{{"config", "validate", "--scope=other", "--set=/x=1"}, {"config", "validate", "--scope=all", "--set=/x=1"}, {"config", "validate", "--set=/bad"}, {"config", "validate", "--set=/bad={"}, {"config", "validate", "--set=bad=1"}} {
result := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeUsage || calls != 0 || factories != 0 {
t.Fatalf("preflight %v: %#v, calls=%d factories=%d", args, result, calls, factories)
}
}
result := app.Execute(context.Background(), []string{"config", "diff", "--set=/x=1", "--against-set=/x=2", "--against-set=/y=[]"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || len(pair.Left.Selection.Overrides) != 1 || len(pair.Right.Selection.Overrides) != 2 || pair.Left.Selection.Overrides[0].Flag != "--set" || pair.Right.Selection.Overrides[0].Flag != "--against-set" || pair.Left.Selection.Overrides[0].Value != int64(1) || pair.Right.Selection.Overrides[0].Value != int64(2) {
t.Fatalf("pair=%#v outcome=%#v", pair, result)
}
result = app.Execute(context.Background(), []string{"config", "diff", "--set=/x=1"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || pair.Right.Selection.Overrides != nil {
t.Fatalf("omitted right = %#v", pair.Right.Selection)
}
without := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{unsupported}}))
if result := without.Execute(context.Background(), []string{"config", "validate", "--set=/x=1"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); result.Kind != commandkit.OutcomeUsage {
t.Fatalf("unavailable flag: %#v", result)
}
}
func TestOverrideNativeLoadErrorAndProvenance(t *testing.T) {
type settings struct {
Name string `yaml:"name"`
}
scope := FromConfigKit("app", "", func(_ context.Context, selection Selection, _, _ struct{}) (*configkit.Result[settings], error) {
return configkit.Load(configkit.Mapping("input", map[string]any{"name": "before"}), configkit.Options[settings]{Overrides: selection.Overrides})
})
scope.Features.Overrides = true
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
var out bytes.Buffer
result := app.Execute(context.Background(), []string{"config", "explain", "--path=/name", "--set=/name=\"after\""}, commandkit.Streams{Out: &out, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), "--set") {
t.Fatalf("provenance: %#v %q", result, out.String())
}
result = app.Execute(context.Background(), []string{"config", "validate", "--set==1"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
var loadErr *configkit.LoadError
if result.Kind != commandkit.OutcomeOperational || !errors.As(result.Cause, &loadErr) {
t.Fatalf("native invalid root override: %#v", result)
}
}

View File

@@ -18,10 +18,10 @@ func TestBufferedReportCancellationBeforeDelivery(t *testing.T) {
app := newConfigTestApp(t, commandkit.NewCommand(commandkit.CommandSpec[struct{}]{ app := newConfigTestApp(t, commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "report", Name: "report",
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome { Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
return renderPreparedConfigReport(invocation, commandkit.Explanation{Message: "selected context"}, func() ([]byte, error) { return renderPreparedConfigReport(invocation, commandkit.Explanation{Message: "selected context"}, func() (any, error) {
prepared = true prepared = true
cancel() cancel()
return []byte("prepared report"), nil return "prepared report", nil
}) })
}, },
})) }))
@@ -54,8 +54,8 @@ func TestBufferedReportDoesNotRejectCancellationAfterDeliveryStarts(t *testing.T
app := newConfigTestApp(t, commandkit.NewCommand(commandkit.CommandSpec[struct{}]{ app := newConfigTestApp(t, commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "report", Name: "report",
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome { Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
return renderPreparedConfigReport(invocation, commandkit.Explanation{}, func() ([]byte, error) { return renderPreparedConfigReport(invocation, commandkit.Explanation{}, func() (any, error) {
return []byte("report"), nil return "report", nil
}) })
}, },
})) }))

View File

@@ -0,0 +1,152 @@
package ckconfig
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"reflect"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func TestShowSharedFormatsAndScopeShape(t *testing.T) {
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Effective: effectiveValue("'false'\n")}}, nil
},
}}}))
for _, tc := range []struct {
name string
args []string
want string
}{
{"automatic", []string{"config", "show"}, "false\n"},
{"text", []string{"config", "show", "--format=text"}, "false\n"},
{"yaml", []string{"config", "show", "--format=yaml"}, "\"false\"\n"},
{"json", []string{"config", "show", "--format=json"}, "\"false\"\n"},
} {
t.Run(tc.name, func(t *testing.T) {
var out bytes.Buffer
result := app.Execute(t.Context(), tc.args, commandkit.Streams{Out: &out, Err: io.Discard})
if tc.name == "yaml" {
value, err := commandkit.DecodeYAMLReport(&out)
if err != nil || value != "false" || result.Status != 0 {
t.Fatalf("%#v value=%#v err=%v", result, value, err)
}
return
}
if result.Status != 0 || out.String() != tc.want {
t.Fatalf("%#v output=%q", result, out.String())
}
})
}
var out bytes.Buffer
result := app.Execute(t.Context(), []string{"config", "show", "--scope=all", "--format=json"}, commandkit.Streams{Out: &out, Err: io.Discard})
var decoded map[string]any
if err := json.Unmarshal(out.Bytes(), &decoded); err != nil || result.Status != 0 || !reflect.DeepEqual(decoded, map[string]any{"app": "false"}) {
t.Fatalf("%#v output=%q decoded=%#v err=%v", result, out.String(), decoded, err)
}
}
func TestShowConversionFailureIsPresentationEvidence(t *testing.T) {
for _, quiet := range []bool{false, true} {
t.Run(map[bool]string{false: "ordinary", true: "quiet"}[quiet], func(t *testing.T) {
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Effective: effectiveValue("x: [invalid\n")}}, nil
}}}}))
args := []string{"config", "show"}
if quiet {
args = append(args, "--quiet")
}
var out bytes.Buffer
result := app.Execute(t.Context(), args, commandkit.Streams{Out: &out, Err: io.Discard})
if result.Kind != commandkit.OutcomeOperational || result.Presentation.Output.RenderError == nil || out.Len() != 0 {
t.Fatalf("%#v output=%q", result, out.String())
}
})
}
}
func TestValidationSubmitsOneReportAfterAcquisitionStops(t *testing.T) {
failure := errors.New("invalid configuration")
calls := 0
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{
{Name: "app", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{}, nil
}},
{Name: "middle", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{}, failure
}},
{Name: "third", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{}, nil
}},
}}))
var out bytes.Buffer
result := app.Execute(t.Context(), []string{"config", "validate", "--scope=all", "--format=json"}, commandkit.Streams{Out: &out, Err: io.Discard})
var report struct {
Scopes []struct{ Scope, Outcome string }
}
dec := json.NewDecoder(&out)
if err := dec.Decode(&report); err != nil {
t.Fatal(err)
}
if err := dec.Decode(new(any)); err != io.EOF {
t.Fatalf("additional JSON: %v", err)
}
if !errors.Is(result.Cause, failure) || calls != 2 || len(report.Scopes) != 2 || report.Scopes[0].Outcome != "valid" || report.Scopes[1].Outcome != "failed" {
t.Fatalf("result=%#v calls=%d report=%#v", result, calls, report)
}
}
func TestValidationDeliveryFailureDoesNotStopAcquisition(t *testing.T) {
writeErr := errors.New("write failed")
calls := 0
acquire := func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{}, nil
}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Validate}, Acquire: acquire}, {Name: "second", Operations: []Operation{Validate}, Acquire: acquire}}}))
result := app.Execute(t.Context(), []string{"config", "validate", "--scope=all"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
if calls != 2 || !errors.Is(result.Presentation.Output.WriteError, writeErr) || result.Status == 0 {
t.Fatalf("result=%#v calls=%d", result, calls)
}
}
func TestConfigurationReportsSupportSharedFormats(t *testing.T) {
type settings struct {
Greeting string `yaml:"greeting"`
}
app := newConfigTestApp(t, AppCommand(func(context.Context, Selection) (*configkit.Result[settings], error) {
return configkit.Load(configkit.Mapping("fixture", map[string]any{"greeting": "hello"}), configkit.Options[settings]{})
}))
for _, args := range [][]string{{"scopes"}, {"show"}, {"sources"}, {"explain", "--path=/greeting"}, {"diff"}, {"validate"}} {
for _, format := range []string{"text", "yaml", "json"} {
t.Run(args[0]+"/"+format, func(t *testing.T) {
argv := append([]string{"config"}, args...)
argv = append(argv, "--format="+format)
var out bytes.Buffer
result := app.Execute(t.Context(), argv, commandkit.Streams{Out: &out, Err: io.Discard})
if result.Status != 0 {
t.Fatalf("%#v output=%q", result, out.String())
}
if format == "json" {
dec := json.NewDecoder(&out)
var value any
if err := dec.Decode(&value); err != nil {
t.Fatal(err)
}
if err := dec.Decode(&value); err != io.EOF {
t.Fatalf("extra JSON: %v", err)
}
}
})
}
}
}

View File

@@ -4,8 +4,7 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"io" "io"
"strconv" "strings"
"unicode"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
) )
@@ -23,41 +22,53 @@ func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOpera
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err) return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
} }
if selected.scope.scope.Acquire == nil { if selected.scope.scope.Acquire == nil {
message := fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name) return commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name))
return commandkit.Failure(message)
} }
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{Operation: Show, Selection: selected.selection}, selected.input, state)
Operation: Show,
Selection: plan.selection,
}, selected.input, state)
if acquireErr != nil { if acquireErr != nil {
return configAcquisitionFailure(reporter, "show", selected.scope.scope.Name, acquired, acquireErr) return configAcquisitionFailure(reporter, "show", selected.scope.scope.Name, acquired, acquireErr)
} }
queueConfigDiagnostics(reporter, acquired.Diagnostics) queueConfigAcquisition(reporter, acquired)
if err := invocation.Context.Err(); err != nil { if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration show was canceled.", acquired.Explanation, err) return configFailure("Configuration show was canceled.", acquired.Explanation, err)
} }
if plan.pathSet {
if acquired.Value.Explanation == nil {
return configFailure("Configuration scope did not provide explanation data.", acquired.Explanation, fmt.Errorf("configuration scope %q declared path show without an explanation reader", selected.scope.scope.Name))
}
explanation, found := acquired.Value.Explanation.Explain(plan.path)
if !found || !explanation.Effective.Present {
return configFailure("The requested effective configuration path is not available.", acquired.Explanation, fmt.Errorf("configuration path %q is not effective in scope %q", plan.path.String(), selected.scope.scope.Name))
}
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration show was canceled.", acquired.Explanation, err)
}
var value any
err := reporter.RenderOutput(func(io.Writer) error {
var err error
value, err = commandkit.DecodeYAMLReport(strings.NewReader(explanation.Effective.YAML))
return err
})
if err == nil {
if canceled := invocation.Context.Err(); canceled != nil {
return configFailure("Configuration show was canceled.", acquired.Explanation, canceled)
}
_ = reporter.Report(commandkit.Report{Value: value})
}
return commandkit.Success()
}
if acquired.Value.Effective == nil { if acquired.Value.Effective == nil {
message := fmt.Sprintf("Configuration scope %q did not provide an effective configuration writer.", selected.scope.scope.Name) return commandkit.Failure(fmt.Sprintf("Configuration scope %q did not provide an effective configuration writer.", selected.scope.scope.Name))
return commandkit.Failure(message)
} }
documents = append(documents, effectiveDocument{name: selected.scope.scope.Name, view: acquired.Value}) documents = append(documents, effectiveDocument{name: selected.scope.scope.Name, view: acquired.Value})
} }
if err := invocation.Context.Err(); err != nil { if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err) return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
} }
if len(documents) == 1 && plan.scopeName != "all" { var value any
err := reporter.RenderOutput(func(writer io.Writer) error { err := reporter.RenderOutput(func(io.Writer) error {
return documents[0].view.Effective.WriteEffective(writer) values := make(map[string]any, len(documents))
}) for _, document := range documents {
if err != nil && invocation.Context.Err() != nil {
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err())
}
return commandkit.Success()
}
err := reporter.RenderOutput(func(writer io.Writer) error {
for index, document := range documents {
if err := invocation.Context.Err(); err != nil { if err := invocation.Context.Err(); err != nil {
return err return err
} }
@@ -65,112 +76,28 @@ func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOpera
if err := document.view.Effective.WriteEffective(&native); err != nil { if err := document.view.Effective.WriteEffective(&native); err != nil {
return err return err
} }
if err := invocation.Context.Err(); err != nil { decoded, err := commandkit.DecodeYAMLReport(&native)
return err if err != nil {
}
framed, hasDirective := frameEffectiveYAML(document.name, native.Bytes())
if index > 0 && hasDirective {
if _, err := io.WriteString(writer, "...\n"); err != nil {
return err
}
}
if index < len(documents)-1 && len(framed) > 0 && framed[len(framed)-1] != '\n' {
framed = append(framed, '\n')
}
if _, err := writer.Write(framed); err != nil {
return err return err
} }
values[document.name] = decoded
}
if plan.scopeName == "all" {
value = values
} else {
value = values[documents[0].name]
} }
return nil return nil
}) })
if err != nil && invocation.Context.Err() != nil { if err != nil {
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err()) if canceled := invocation.Context.Err(); canceled != nil {
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, canceled)
}
return commandkit.Success()
} }
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
}
_ = reporter.Report(commandkit.Report{Value: value})
return commandkit.Success() return commandkit.Success()
} }
type yamlPreamble struct {
markerStart int
markerEnd int
markerFound bool
inlineMarker bool
preambleEnd int
hasDirective bool
}
func analyzeYAMLPreamble(native []byte) yamlPreamble {
var analysis yamlPreamble
for start := 0; start < len(native); {
end := len(native)
if newline := bytes.IndexByte(native[start:], '\n'); newline >= 0 {
end = start + newline + 1
}
lineEnd := end
if lineEnd > start && native[lineEnd-1] == '\n' {
lineEnd--
}
trimmed := bytes.TrimSpace(native[start:lineEnd])
if isYAMLDocumentStart(trimmed) {
content := bytes.TrimSpace(trimmed[3:])
analysis.markerStart = start
analysis.markerEnd = end
analysis.markerFound = true
analysis.inlineMarker = len(content) > 0 && content[0] != '#'
return analysis
}
if bytes.HasPrefix(trimmed, []byte("%YAML ")) || bytes.HasPrefix(trimmed, []byte("%TAG ")) {
analysis.hasDirective = true
}
if len(trimmed) == 0 || trimmed[0] == '#' || bytes.HasPrefix(trimmed, []byte("%YAML ")) || bytes.HasPrefix(trimmed, []byte("%TAG ")) {
analysis.preambleEnd = end
} else {
return analysis
}
start = end
}
return analysis
}
func frameEffectiveYAML(scope string, native []byte) ([]byte, bool) {
analysis := analyzeYAMLPreamble(native)
label := scope
for _, character := range scope {
if unicode.IsControl(character) {
label = strconv.Quote(scope)
break
}
}
comment := []byte("# scope: " + label + "\n")
if analysis.markerFound {
out := make([]byte, 0, len(native)+len(comment))
if analysis.inlineMarker {
out = append(out, native[:analysis.markerStart]...)
out = append(out, comment...)
out = append(out, native[analysis.markerStart:]...)
return out, analysis.hasDirective
}
out = append(out, native[:analysis.markerEnd]...)
if analysis.markerEnd > analysis.markerStart && native[analysis.markerEnd-1] != '\n' {
out = append(out, '\n')
}
out = append(out, comment...)
out = append(out, native[analysis.markerEnd:]...)
return out, analysis.hasDirective
}
result := make([]byte, 0, len(native)+len(comment)+5)
if analysis.hasDirective {
result = append(result, native[:analysis.preambleEnd]...)
}
result = append(result, []byte("---\n")...)
result = append(result, comment...)
if analysis.hasDirective {
result = append(result, native[analysis.preambleEnd:]...)
} else {
result = append(result, native...)
}
return result, analysis.hasDirective
}
func isYAMLDocumentStart(line []byte) bool {
return bytes.HasPrefix(line, []byte("---")) && (len(line) == 3 || line[3] == ' ' || line[3] == '\t')
}

View File

@@ -1,95 +0,0 @@
package ckconfig
import (
"bytes"
"io"
"reflect"
"strings"
"testing"
"go.yaml.in/yaml/v4"
)
func TestEffectiveYAMLFramingPreservesOneNativeDocument(t *testing.T) {
for _, native := range []string{
"--- {setting: value}\n", "---\t{setting: value}\n", "--- value\n",
"--- |\n first\n second\n", "--- # authored\nsetting: value\n",
"setting: value\n", "%YAML 1.1\n--- {setting: value}\n",
} {
t.Run(native, func(t *testing.T) {
var want any
if err := yaml.Unmarshal([]byte(native), &want); err != nil {
t.Fatal(err)
}
framed, _ := frameEffectiveYAML("app", []byte(native))
withoutLabel := bytes.Replace(framed, []byte("# scope: app\n"), nil, 1)
if !strings.HasPrefix(native, "---") && !strings.HasPrefix(native, "%") {
withoutLabel = bytes.TrimPrefix(withoutLabel, []byte("---\n"))
}
if !bytes.Equal(withoutLabel, []byte(native)) {
t.Fatalf("native content changed: %q", framed)
}
if !bytes.Contains(framed, []byte("# scope: app\n")) {
t.Fatalf("missing label: %q", framed)
}
decoder := yaml.NewDecoder(bytes.NewReader(framed))
var got any
if err := decoder.Decode(&got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got=%#v want=%#v", got, want)
}
if err := decoder.Decode(new(any)); err != io.EOF {
t.Fatalf("extra document: %v", err)
}
})
}
}
func TestEffectiveYAMLFramingPreservesBoundariesAndDirectives(t *testing.T) {
for _, test := range []struct {
name string
scope string
native string
directive bool
label string
}{
{name: "no trailing newline", scope: "app", native: "value: plain", label: "# scope: app\n"},
{name: "CRLF", scope: "app", native: "# authored\r\n---\r\nvalue: yes\r\n", label: "# scope: app\n"},
{name: "directive preamble", scope: "app", native: "%YAML 1.1\n# authored\n---\nvalue: yes", directive: true, label: "# scope: app\n"},
{name: "control character label", scope: "line\nbreak", native: "value: yes", label: "# scope: \"line\\nbreak\"\n"},
} {
t.Run(test.name, func(t *testing.T) {
var want any
if err := yaml.Unmarshal([]byte(test.native), &want); err != nil {
t.Fatal(err)
}
framed, hasDirective := frameEffectiveYAML(test.scope, []byte(test.native))
if hasDirective != test.directive {
t.Fatalf("hasDirective = %t, want %t", hasDirective, test.directive)
}
withoutLabel := bytes.Replace(framed, []byte(test.label), nil, 1)
if !strings.HasPrefix(test.native, "---") && !strings.HasPrefix(test.native, "%") {
withoutLabel = bytes.TrimPrefix(withoutLabel, []byte("---\n"))
}
if !bytes.Equal(withoutLabel, []byte(test.native)) {
t.Fatalf("native content changed: got %q want %q", withoutLabel, test.native)
}
if !bytes.Contains(framed, []byte(test.label)) {
t.Fatalf("missing scope label %q in %q", test.label, framed)
}
decoder := yaml.NewDecoder(bytes.NewReader(framed))
var first any
if err := decoder.Decode(&first); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(first, want) {
t.Fatalf("decoded value = %#v, want %#v", first, want)
}
if err := decoder.Decode(new(any)); err != io.EOF {
t.Fatalf("unexpected extra YAML document: %v", err)
}
})
}
}

View File

@@ -1,52 +1,57 @@
package ckconfig package ckconfig
import ( import (
"bytes"
"fmt" "fmt"
"io" "io"
"strconv" "text/template"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
"go.yaml.in/yaml/v4" configkit "go.maximumdirect.net/configkit"
) )
type sourceReport struct { type sourceReport struct {
Scope string `yaml:"scope"` Scope string `json:"scope"`
Sources []sourceDTO `yaml:"sources"` Sources []sourceDTO `json:"sources"`
} }
type sourceDTO struct { type sourceDTO struct {
ID string `yaml:"id"` ID string `json:"id"`
Label string `yaml:"label"` Label string `json:"label"`
Path string `yaml:"path"` Path string `json:"path"`
Destination string `yaml:"destination"` Destination string `json:"destination"`
Placement string `yaml:"placement"` Placement string `json:"placement"`
DestinationUnresolved bool `yaml:"destination_unresolved"` DestinationIsFallback bool `json:"destination_is_fallback"`
Layer string `yaml:"layer"` Layer string `json:"layer"`
Kind string `yaml:"kind"` Kind string `json:"kind"`
UnderlayIndex int `yaml:"underlay_index"` UnderlayIndex int `json:"underlay_index"`
ImportedFrom *locationDTO `yaml:"imported_from"` ImportedFrom *locationDTO `json:"imported_from"`
VersionCheck *sourceVersionCheckDTO `yaml:"version_check"` VersionEvidence *sourceVersionEvidenceDTO `json:"version_evidence"`
EmptyImportSelections []emptyImportSelectionDTO `json:"empty_import_selections"`
}
type emptyImportSelectionDTO struct {
Path string `json:"path"`
Location locationDTO `json:"location"`
Selector string `json:"selector"`
} }
type locationDTO struct { type locationDTO struct {
SourceID string `yaml:"source_id"` SourceID string `json:"source_id"`
Line int `yaml:"line"` Line int `json:"line"`
Column int `yaml:"column"` Column int `json:"column"`
} }
type sourceVersionCheckDTO struct { type sourceVersionEvidenceDTO struct {
Path string `yaml:"path"` Path string `json:"path"`
Version versionDTO `yaml:"version"` Version versionDTO `json:"version"`
Location *locationDTO `yaml:"location"` Location *locationDTO `json:"location"`
Redacted bool `yaml:"redacted"` Redacted bool `json:"redacted"`
} }
type versionDTO struct { type versionDTO struct {
Present bool `yaml:"present"` Present bool `json:"present"`
Null bool `yaml:"null"` Null bool `json:"null"`
Text string `yaml:"text"` Text string `json:"text"`
} }
func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome { func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
@@ -62,16 +67,18 @@ func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOp
} }
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
Operation: Sources, Operation: Sources,
Selection: plan.selection, Selection: selected.selection,
}, selected.input, state) }, selected.input, state)
queueConfigDiagnostics(reporter, acquired.Diagnostics) queueConfigAcquisition(reporter, acquired)
if acquireErr != nil { if acquireErr == nil && invocation.Context.Err() != nil {
return configFailure(fmt.Sprintf("Unable to list sources for configuration scope %q.", name), acquired.Explanation, acquireErr) err := invocation.Context.Err()
}
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration source listing was canceled.", acquired.Explanation, err) return configFailure("Configuration source listing was canceled.", acquired.Explanation, err)
} }
if acquired.Value.Sources == nil { if acquired.Value.Sources == nil {
if acquireErr != nil {
explanation := sourceSelectionExplanation(acquired.Explanation, "Source inventory is unavailable.")
return configFailure(fmt.Sprintf("Unable to list sources for configuration scope %q.", name), explanation, acquireErr)
}
cause := fmt.Errorf("configuration scope %q did not provide a source reader", name) cause := fmt.Errorf("configuration scope %q did not provide a source reader", name)
return configFailure("Configuration scope did not provide source information.", acquired.Explanation, cause) return configFailure("Configuration scope did not provide source information.", acquired.Explanation, cause)
} }
@@ -79,6 +86,9 @@ func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOp
sources := acquired.Value.Sources.Sources() sources := acquired.Value.Sources.Sources()
document := sourceReport{Scope: name, Sources: make([]sourceDTO, 0, len(sources))} document := sourceReport{Scope: name, Sources: make([]sourceDTO, 0, len(sources))}
for _, source := range sources { for _, source := range sources {
if plan.sourceID.Set && source.ID != plan.sourceID.Value {
continue
}
placement := "mapping" placement := "mapping"
if source.PlacementMode == configkit.PlacementModeSequenceItem { if source.PlacementMode == configkit.PlacementModeSequenceItem {
placement = "sequence_item" placement = "sequence_item"
@@ -89,17 +99,18 @@ func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOp
Path: source.Path, Path: source.Path,
Destination: source.Destination.String(), Destination: source.Destination.String(),
Placement: placement, Placement: placement,
DestinationUnresolved: source.DestinationUnresolved, DestinationIsFallback: source.DestinationIsFallback,
Layer: string(source.Layer), Layer: string(source.Layer),
Kind: string(source.Kind), Kind: string(source.Kind),
UnderlayIndex: source.UnderlayIndex, UnderlayIndex: source.UnderlayIndex,
EmptyImportSelections: make([]emptyImportSelectionDTO, 0, len(source.EmptyImportSelections)),
} }
if source.ImportedFrom != nil { if source.ImportedFrom != nil {
item.ImportedFrom = &locationDTO{SourceID: source.ImportedFrom.SourceID, Line: source.ImportedFrom.Line, Column: source.ImportedFrom.Column} item.ImportedFrom = &locationDTO{SourceID: source.ImportedFrom.SourceID, Line: source.ImportedFrom.Line, Column: source.ImportedFrom.Column}
} }
if source.VersionCheck != nil { if source.VersionEvidence != nil {
check := source.VersionCheck check := source.VersionEvidence
item.VersionCheck = &sourceVersionCheckDTO{ item.VersionEvidence = &sourceVersionEvidenceDTO{
Path: check.Path.String(), Path: check.Path.String(),
Version: versionDTO{ Version: versionDTO{
Present: check.Version.Present, Present: check.Version.Present,
@@ -109,73 +120,73 @@ func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOp
Redacted: check.Redacted, Redacted: check.Redacted,
} }
if check.Location != nil { if check.Location != nil {
item.VersionCheck.Location = &locationDTO{SourceID: check.Location.SourceID, Line: check.Location.Line, Column: check.Location.Column} item.VersionEvidence.Location = &locationDTO{SourceID: check.Location.SourceID, Line: check.Location.Line, Column: check.Location.Column}
} }
} }
for _, selection := range source.EmptyImportSelections {
item.EmptyImportSelections = append(item.EmptyImportSelections, emptyImportSelectionDTO{Path: selection.Path.String(), Location: locationDTO{SourceID: selection.Location.SourceID, Line: selection.Location.Line, Column: selection.Location.Column}, Selector: string(selection.Selector)})
}
document.Sources = append(document.Sources, item) document.Sources = append(document.Sources, item)
if plan.sourceID.Set {
break
}
} }
if err := invocation.Context.Err(); err != nil { if plan.sourceID.Set && len(document.Sources) == 0 {
message := fmt.Sprintf("Source ID %q was not found in configuration scope %q.", plan.sourceID.Value, name)
if acquireErr != nil {
return configFailure(fmt.Sprintf("Unable to list sources for configuration scope %q.", name), sourceSelectionExplanation(acquired.Explanation, message), acquireErr)
}
return configFailure(message, acquired.Explanation, fmt.Errorf("source ID %q was not found", plan.sourceID.Value))
}
if acquireErr == nil && invocation.Context.Err() != nil {
err := invocation.Context.Err()
return configFailure("Configuration source listing was canceled.", acquired.Explanation, err) return configFailure("Configuration source listing was canceled.", acquired.Explanation, err)
} }
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) { _ = reporter.Report(commandkit.Report{Value: document, TextTemplate: sourceTextTemplate})
var report bytes.Buffer if acquireErr != nil {
if plan.format == "yaml" { return configFailure(fmt.Sprintf("Unable to list sources for configuration scope %q.", name), acquired.Explanation, acquireErr)
if err := encodeYAML(&report, document); err != nil { }
return nil, err return commandkit.Success()
} }
} else {
writeSourceReportText(&report, document) func sourceSelectionExplanation(explanation commandkit.Explanation, message string) commandkit.Explanation {
} explanation.Details = append(append([]commandkit.Detail(nil), explanation.Details...), func(writer io.Writer) error {
return report.Bytes(), nil _, err := fmt.Fprintln(writer, message)
return err
}) })
return explanation
} }
func writeSourceReportText(writer *bytes.Buffer, document sourceReport) { var sourceTextTemplate = template.Must(commandkit.ParseTextTemplate("sources", `Scope: {{printf "%q" .Scope}}
_, _ = fmt.Fprintf(writer, "Scope: %s\n", strconv.Quote(document.Scope)) {{if .Sources}}Sources:
if len(document.Sources) == 0 { {{range .Sources}} - id: {{printf "%q" .ID}}
_, _ = writer.WriteString("Sources: []\n") label: {{printf "%q" .Label}}
return path: {{printf "%q" .Path}}
} destination: {{printf "%q" .Destination}}
_, _ = writer.WriteString("Sources:\n") placement: {{printf "%q" .Placement}}
for _, source := range document.Sources { destination_is_fallback: {{.DestinationIsFallback}}
_, _ = fmt.Fprintf(writer, " - id: %s\n", strconv.Quote(source.ID)) layer: {{printf "%q" .Layer}}
_, _ = fmt.Fprintf(writer, " label: %s\n", strconv.Quote(source.Label)) kind: {{printf "%q" .Kind}}
_, _ = fmt.Fprintf(writer, " path: %s\n", strconv.Quote(source.Path)) underlay_index: {{.UnderlayIndex}}
_, _ = fmt.Fprintf(writer, " destination: %s\n", strconv.Quote(source.Destination)) {{if .ImportedFrom}} imported_from: {source_id: {{printf "%q" .ImportedFrom.SourceID}}, line: {{.ImportedFrom.Line}}, column: {{.ImportedFrom.Column}}}
_, _ = fmt.Fprintf(writer, " placement: %s\n", strconv.Quote(source.Placement)) {{else}} imported_from: null
_, _ = fmt.Fprintf(writer, " destination_unresolved: %t\n", source.DestinationUnresolved) {{end}}{{if .VersionEvidence}} version_evidence:
_, _ = fmt.Fprintf(writer, " layer: %s\n", strconv.Quote(source.Layer)) path: {{printf "%q" .VersionEvidence.Path}}
_, _ = fmt.Fprintf(writer, " kind: %s\n", strconv.Quote(source.Kind)) version:
_, _ = fmt.Fprintf(writer, " underlay_index: %d\n", source.UnderlayIndex) present: {{.VersionEvidence.Version.Present}}
if source.ImportedFrom == nil { null: {{.VersionEvidence.Version.Null}}
_, _ = writer.WriteString(" imported_from: null\n") text: {{printf "%q" .VersionEvidence.Version.Text}}
} else { {{if .VersionEvidence.Location}} location: {source_id: {{printf "%q" .VersionEvidence.Location.SourceID}}, line: {{.VersionEvidence.Location.Line}}, column: {{.VersionEvidence.Location.Column}}}
_, _ = fmt.Fprintf(writer, " imported_from: {source_id: %s, line: %d, column: %d}\n", {{else}} location: null
strconv.Quote(source.ImportedFrom.SourceID), source.ImportedFrom.Line, source.ImportedFrom.Column) {{end}} redacted: {{.VersionEvidence.Redacted}}
} {{else}} version_evidence: null
if source.VersionCheck == nil { {{end}}{{if .EmptyImportSelections}} empty_import_selections:
_, _ = writer.WriteString(" version_check: null\n") {{range .EmptyImportSelections}} - path: {{printf "%q" .Path}}
continue location: {source_id: {{printf "%q" .Location.SourceID}}, line: {{.Location.Line}}, column: {{.Location.Column}}}
} selector: {{printf "%q" .Selector}}
check := source.VersionCheck {{end}}{{else}} empty_import_selections: []
_, _ = fmt.Fprintf(writer, " version_check:\n path: %s\n", strconv.Quote(check.Path)) {{end}}{{end}}{{else}}Sources: []
_, _ = fmt.Fprintf(writer, " version:\n present: %t\n null: %t\n text: %s\n", {{end}}`, nil))
check.Version.Present, check.Version.Null, strconv.Quote(check.Version.Text))
if check.Location == nil {
_, _ = writer.WriteString(" location: null\n")
} else {
_, _ = fmt.Fprintf(writer, " location: {source_id: %s, line: %d, column: %d}\n",
strconv.Quote(check.Location.SourceID), check.Location.Line, check.Location.Column)
}
_, _ = fmt.Fprintf(writer, " redacted: %t\n", check.Redacted)
}
}
func encodeYAML(writer io.Writer, document any) error {
encoder := yaml.NewEncoder(writer)
encoder.SetIndent(2)
return encoder.Encode(document)
}
func locationReports(locations []configkit.Location) []locationDTO { func locationReports(locations []configkit.Location) []locationDTO {
report := make([]locationDTO, 0, len(locations)) report := make([]locationDTO, 0, len(locations))

View File

@@ -8,8 +8,8 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
@@ -34,13 +34,17 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
reader := &sourceInventory{values: []configkit.SourceInfo{ reader := &sourceInventory{values: []configkit.SourceInfo{
{ {
ID: "root", Label: "root source", Path: "root.yml", ID: "root", Label: "root source", Path: "root.yml",
PlacementMode: configkit.PlacementModeMapping, PlacementMode: configkit.PlacementModeMapping, Layer: configkit.LayerMain,
EmptyImportSelections: []configkit.EmptyImportSelection{
{Path: configkit.Keys("imports", "first"), Location: configkit.Location{SourceID: "root.yml", Line: 3, Column: 4}, Selector: configkit.ImportSelectorGlob},
{Path: configkit.Keys("imports", "second"), Location: configkit.Location{}, Selector: configkit.ImportSelectorPath},
},
}, },
{ {
ID: "item", Label: "sequence item", Path: "item.yml", ID: "item", Label: "sequence item", Path: "item.yml",
Destination: servicePath, PlacementMode: configkit.PlacementModeSequenceItem, Destination: servicePath, PlacementMode: configkit.PlacementModeSequenceItem,
DestinationUnresolved: true, DestinationIsFallback: true,
VersionCheck: &configkit.SourceVersionCheck{ VersionEvidence: &configkit.SourceVersionEvidence{
Path: versionPath, Path: versionPath,
Version: configkit.Version{Present: true, Text: "v2"}, Version: configkit.Version{Present: true, Text: "v2"},
Location: &configkit.Location{SourceID: "item.yml", Line: 4, Column: 2}, Location: &configkit.Location{SourceID: "item.yml", Line: 4, Column: 2},
@@ -49,7 +53,7 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
{ {
ID: "null", Label: "null version", Path: "null.yml", ID: "null", Label: "null version", Path: "null.yml",
Destination: otherPath, PlacementMode: configkit.PlacementModeMapping, Destination: otherPath, PlacementMode: configkit.PlacementModeMapping,
VersionCheck: &configkit.SourceVersionCheck{ VersionEvidence: &configkit.SourceVersionEvidence{
Path: nullVersionPath, Path: nullVersionPath,
Version: configkit.Version{Present: true, Null: true}, Version: configkit.Version{Present: true, Null: true},
Redacted: true, Redacted: true,
@@ -73,8 +77,18 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
ID string `yaml:"id"` ID string `yaml:"id"`
Destination string `yaml:"destination"` Destination string `yaml:"destination"`
Placement string `yaml:"placement"` Placement string `yaml:"placement"`
DestinationUnresolved bool `yaml:"destination_unresolved"` Layer string `yaml:"layer"`
VersionCheck *struct { EmptyImportSelections []struct {
Path string `yaml:"path"`
Location struct {
SourceID string `yaml:"source_id"`
Line int `yaml:"line"`
Column int `yaml:"column"`
} `yaml:"location"`
Selector string `yaml:"selector"`
} `yaml:"empty_import_selections"`
DestinationIsFallback bool `yaml:"destination_is_fallback"`
VersionEvidence *struct {
Path string `yaml:"path"` Path string `yaml:"path"`
Version struct { Version struct {
Present bool `yaml:"present"` Present bool `yaml:"present"`
@@ -87,7 +101,7 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
Column int `yaml:"column"` Column int `yaml:"column"`
} `yaml:"location"` } `yaml:"location"`
Redacted bool `yaml:"redacted"` Redacted bool `yaml:"redacted"`
} `yaml:"version_check"` } `yaml:"version_evidence"`
} `yaml:"sources"` } `yaml:"sources"`
} }
if err := yaml.Unmarshal(yamlOutput.Bytes(), &report); err != nil { if err := yaml.Unmarshal(yamlOutput.Bytes(), &report); err != nil {
@@ -99,21 +113,28 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
if got := []string{report.Sources[0].ID, report.Sources[1].ID, report.Sources[2].ID}; !reflect.DeepEqual(got, []string{"root", "item", "null"}) { if got := []string{report.Sources[0].ID, report.Sources[1].ID, report.Sources[2].ID}; !reflect.DeepEqual(got, []string{"root", "item", "null"}) {
t.Fatalf("source order = %v", got) t.Fatalf("source order = %v", got)
} }
if root := report.Sources[0]; root.Destination != "" || root.Placement != "mapping" || root.DestinationUnresolved || root.VersionCheck != nil { if root := report.Sources[0]; root.Destination != "" || root.Placement != "mapping" || root.DestinationIsFallback || root.VersionEvidence != nil || root.Layer != "main" {
t.Errorf("root source projection = %#v", root) t.Errorf("root source projection = %#v", root)
} }
selections := report.Sources[0].EmptyImportSelections
if len(selections) != 2 || selections[0].Path != "/imports/first" || selections[0].Location.SourceID != "root.yml" || selections[0].Location.Line != 3 || selections[0].Location.Column != 4 || selections[0].Selector != "glob" || selections[1].Path != "/imports/second" || selections[1].Selector != "path" || report.Sources[1].EmptyImportSelections == nil || len(report.Sources[1].EmptyImportSelections) != 0 {
t.Fatalf("import selections: %#v", report.Sources)
}
if strings.Contains(yamlOutput.String(), "destination_unresolved:") || strings.Contains(yamlOutput.String(), "version_check:") {
t.Fatal("legacy YAML keys present")
}
item := report.Sources[1] item := report.Sources[1]
if item.Destination != "/services" || item.Placement != "sequence_item" || !item.DestinationUnresolved { if item.Destination != "/services" || item.Placement != "sequence_item" || !item.DestinationIsFallback {
t.Errorf("sequence item projection = %#v", item) t.Errorf("sequence item projection = %#v", item)
} }
if check := item.VersionCheck; check == nil || check.Path != "/version" || !check.Version.Present || check.Version.Null || check.Version.Text != "v2" || check.Location == nil || check.Location.SourceID != "item.yml" || check.Location.Line != 4 || check.Location.Column != 2 || check.Redacted { if check := item.VersionEvidence; check == nil || check.Path != "/version" || !check.Version.Present || check.Version.Null || check.Version.Text != "v2" || check.Location == nil || check.Location.SourceID != "item.yml" || check.Location.Line != 4 || check.Location.Column != 2 || check.Redacted {
t.Errorf("text version evidence projection = %#v", check) t.Errorf("text version evidence projection = %#v", check)
} }
nullSource := report.Sources[2] nullSource := report.Sources[2]
if nullSource.Destination != "/other" || nullSource.Placement != "mapping" || nullSource.DestinationUnresolved { if nullSource.Destination != "/other" || nullSource.Placement != "mapping" || nullSource.DestinationIsFallback {
t.Errorf("mapping projection = %#v", nullSource) t.Errorf("mapping projection = %#v", nullSource)
} }
if check := nullSource.VersionCheck; check == nil || check.Path != "/metadata/version" || !check.Version.Present || !check.Version.Null || check.Version.Text != "" || check.Location != nil || !check.Redacted { if check := nullSource.VersionEvidence; check == nil || check.Path != "/metadata/version" || !check.Version.Present || !check.Version.Null || check.Version.Text != "" || check.Location != nil || !check.Redacted {
t.Errorf("null/redacted version evidence projection = %#v", check) t.Errorf("null/redacted version evidence projection = %#v", check)
} }
@@ -122,9 +143,15 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
if outcome.Kind != commandkit.OutcomeSuccess || reader.calls != 2 { if outcome.Kind != commandkit.OutcomeSuccess || reader.calls != 2 {
t.Fatalf("text source report outcome=%#v calls=%d", outcome, reader.calls) t.Fatalf("text source report outcome=%#v calls=%d", outcome, reader.calls)
} }
if strings.Contains(textOutput.String(), "destination_unresolved:") || strings.Contains(textOutput.String(), "version_check:") {
t.Fatal("legacy text keys present")
}
if strings.Index(textOutput.String(), `path: "/imports/first"`) > strings.Index(textOutput.String(), `path: "/imports/second"`) {
t.Fatal("import selection order changed")
}
for _, fragment := range []string{ for _, fragment := range []string{
`id: "root"`, `destination: ""`, `placement: "mapping"`, "destination_unresolved: false", "version_check: null", `id: "root"`, `destination: ""`, `placement: "mapping"`, `layer: "main"`, "destination_is_fallback: false", "version_evidence: null", `path: "/imports/first"`, `location: {source_id: "root.yml", line: 3, column: 4}`, `selector: "glob"`, `path: "/imports/second"`, `location: {source_id: "", line: 0, column: 0}`, `selector: "path"`, "empty_import_selections: []",
`destination: "/services"`, `placement: "sequence_item"`, "destination_unresolved: true", `path: "/version"`, `text: "v2"`, `destination: "/services"`, `placement: "sequence_item"`, "destination_is_fallback: true", `path: "/version"`, `text: "v2"`,
`destination: "/other"`, `path: "/metadata/version"`, "null: true", `text: ""`, "location: null", "redacted: true", `destination: "/other"`, `path: "/metadata/version"`, "null: true", `text: ""`, "location: null", "redacted: true",
} { } {
if !strings.Contains(textOutput.String(), fragment) { if !strings.Contains(textOutput.String(), fragment) {

View File

@@ -1,83 +1,156 @@
package ckconfig package ckconfig
import ( import (
"bytes" "errors"
"fmt" "fmt"
"io" "slices"
"strings" "text/template"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
"go.yaml.in/yaml/v4" configkit "go.maximumdirect.net/configkit"
) )
type validationReport struct {
Scopes []validationScope `json:"scopes"`
}
type validationScope struct {
Scope string `json:"scope"`
Outcome string `json:"outcome"`
Details *validationDetails `json:"details,omitempty"`
}
type validationDetails struct {
State string `json:"state"`
Findings []validationFinding `json:"findings"`
Sources []validationSource `json:"sources"`
}
type validationFinding struct {
Code string `json:"code"`
Severity string `json:"severity"`
Path string `json:"path"`
Message string `json:"message"`
Primary *locationDTO `json:"primary"`
Related []locationDTO `json:"related"`
}
type validationSource struct {
ID string `json:"id"`
Label string `json:"label"`
Path string `json:"path"`
}
var validationTextTemplate = template.Must(commandkit.ParseTextTemplate("validation", "{{range .Scopes}}{{.Scope}}: {{.Outcome}}\n{{end}}", nil))
var validationDetailsTemplate = template.Must(commandkit.ParseTextTemplate("validation-details", `{{range .Scopes}}{{.Scope}}: {{.Outcome}} ({{.Details.State}})
{{range .Details.Findings}} {{.Severity}} {{.Code}} {{printf "%q" .Path}}: {{.Message}}
{{if .Primary}} {{.Primary.SourceID}}:{{.Primary.Line}}:{{.Primary.Column}}
{{end}}{{range .Related}} {{.SourceID}}:{{.Line}}:{{.Column}}
{{end}}{{end}}{{range .Details.Sources}} source {{printf "%q" .ID}}: {{.Label}} {{.Path}}
{{end}}{{end}}`, nil))
func parseValidationSeverity(raw string) (configkit.Severity, error) {
switch configkit.Severity(raw) {
case configkit.SeverityError, configkit.SeverityWarning:
return configkit.Severity(raw), nil
default:
return "", fmt.Errorf("unknown validation severity %q", raw)
}
}
func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome { func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
reporter := invocation.Reporter() reporter := invocation.Reporter()
writtenRecord := false report := validationReport{Scopes: make([]validationScope, 0, len(plan.scopes))}
outcome := commandkit.Success()
for _, selected := range plan.scopes { for _, selected := range plan.scopes {
if err := invocation.Context.Err(); err != nil { if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration validation was canceled.", commandkit.Explanation{}, err) outcome = configFailure("Configuration validation was canceled.", commandkit.Explanation{}, err)
break
} }
name := selected.scope.scope.Name
if selected.scope.scope.Acquire == nil { if selected.scope.scope.Acquire == nil {
message := fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name) entry := validationScope{Scope: name, Outcome: "failed"}
return commandkit.Failure(message) if plan.details {
} entry.Details = projectValidationDetails(View{}, plan.severities, plan.codes)
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ }
Operation: Validate, report.Scopes = append(report.Scopes, entry)
Selection: plan.selection, outcome = commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", name))
}, selected.input, state) break
if acquireErr != nil {
return configAcquisitionFailure(reporter, "validate", selected.scope.scope.Name, acquired, acquireErr)
} }
acquired, err := selected.scope.scope.Acquire(invocation.Context, Request{Operation: Validate, Selection: selected.selection}, selected.input, state)
queueConfigDiagnostics(reporter, acquired.Diagnostics) queueConfigDiagnostics(reporter, acquired.Diagnostics)
if err := invocation.Context.Err(); err != nil { if !plan.details && acquired.Value.Findings != nil {
return configFailure("Configuration validation was canceled.", acquired.Explanation, err) queueConfigDiagnostics(reporter, acquired.Value.Findings.Messages)
} }
entry := validationScope{Scope: name, Outcome: "valid"}
err := reporter.RenderOutput(func(writer io.Writer) error { if plan.details {
record, err := validationSuccessRecord(selected.scope.scope.Name, plan.format) entry.Details = projectValidationDetails(acquired.Value, plan.severities, plan.codes)
if err != nil { }
return err if canceled := invocation.Context.Err(); canceled != nil {
entry.Outcome = "canceled"
if err != nil && !errors.Is(err, canceled) {
err = errors.Join(err, canceled)
} else if err == nil {
err = canceled
} }
if plan.format == "yaml" && !writtenRecord { } else if err != nil {
if _, err := io.WriteString(writer, "scopes:\n"); err != nil { entry.Outcome = "failed"
return err }
} report.Scopes = append(report.Scopes, entry)
}
_, err = writer.Write(record)
return err
})
if err != nil { if err != nil {
if ctxErr := invocation.Context.Err(); ctxErr != nil { message := fmt.Sprintf("Unable to validate configuration scope %q.", name)
return configFailure("Configuration validation was canceled.", acquired.Explanation, ctxErr) if entry.Outcome == "canceled" {
message = "Configuration validation was canceled."
} }
return commandkit.Success() outcome = configFailure(message, acquired.Explanation, err)
break
} }
writtenRecord = true
} }
return commandkit.Success() text := validationTextTemplate
if plan.details {
text = validationDetailsTemplate
}
_ = reporter.Report(commandkit.Report{Value: report, TextTemplate: text})
return outcome
} }
func validationSuccessRecord(scope, format string) ([]byte, error) { func projectValidationDetails(view View, severities []configkit.Severity, codes []string) *validationDetails {
if format == "text" { details := &validationDetails{State: "unavailable", Findings: []validationFinding{}, Sources: []validationSource{}}
return []byte(scope + ": valid\n"), nil if view.Findings == nil {
return details
} }
encoded, err := yaml.Marshal(struct { if len(view.Findings.Records) == 0 {
Name string `yaml:"name"` details.State = "empty"
Valid bool `yaml:"valid"` return details
}{Name: scope, Valid: true})
if err != nil {
return nil, err
} }
lines := strings.Split(strings.TrimSuffix(string(encoded), "\n"), "\n") details.State = "filtered"
var record bytes.Buffer references := make(map[string]bool)
for index, line := range lines { for _, record := range view.Findings.Records {
if index == 0 { if len(severities) > 0 && !slices.Contains(severities, record.Severity) {
record.WriteString(" - ") continue
} else {
record.WriteString(" ")
} }
record.WriteString(line) if len(codes) > 0 && !slices.Contains(codes, string(record.Code)) {
record.WriteByte('\n') continue
}
finding := validationFinding{Code: string(record.Code), Severity: string(record.Severity), Path: record.Path.String(), Message: record.Message, Related: locationReports(record.Related)}
if record.Primary != nil {
finding.Primary = &locationDTO{SourceID: record.Primary.SourceID, Line: record.Primary.Line, Column: record.Primary.Column}
references[record.Primary.SourceID] = true
}
for _, location := range record.Related {
references[location.SourceID] = true
}
details.Findings = append(details.Findings, finding)
} }
return record.Bytes(), nil if len(details.Findings) > 0 {
details.State = "present"
}
if view.Sources != nil {
for _, source := range view.Sources.Sources() {
if references[source.ID] {
details.Sources = append(details.Sources, validationSource{ID: source.ID, Label: source.Label, Path: source.Path})
}
}
}
return details
} }

View File

@@ -7,11 +7,13 @@ Importing it does not install either command family.
## Module ## Module
The module path is `go.maximumdirect.net/ckprompt`. Version v0.2.0 uses The module path is `go.maximumdirect.net/ckprompt`. This source uses Go 1.27.0,
`go.maximumdirect.net/commandkit v0.3.0`, `go.maximumdirect.net/ckconfig v0.2.0`, `go.maximumdirect.net/commandkit v0.4.0`, `go.maximumdirect.net/ckconfig v0.3.0`,
and PromptKit v0.16.0. See the `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-and-definitions) [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. for callback, input, state, and acquisition contracts.
The current source requires the aligned local workspace until a new ckconfig
release supplies its APIs.
## Example ## Example
@@ -19,11 +21,12 @@ Add `ckprompt.ConfigScope(loadEngine)` to the scopes passed to
`ckconfig.Command` when the application wants PromptKit engine configuration `ckconfig.Command` when the application wants PromptKit engine configuration
and native comparison in the `config` family. Install and native comparison in the `config` family. Install
`ckprompt.Command(ckprompt.FromEngine(loadEngine))` separately for catalogs, `ckprompt.Command(ckprompt.FromEngine(loadEngine))` separately for catalogs,
named inspections, and requirements discovery. `FromEngine` supplies eight named inspections, and requirements discovery. `FromEngine` supplies nine
PromptKit-backed callbacks: three inspections and five catalogs. Applications PromptKit-backed callbacks: four inspections and five catalogs. Applications
may narrow, wrap, or replace them before capture or provide custom callbacks may narrow, wrap, or replace them before capture or provide custom callbacks
with `ckprompt.Suite`. Add concrete request validation and smoke testing with `ckprompt.Suite`. Add concrete request validation, prepared-request preview,
explicitly with `WithRequestValidation` and `WithSmokeTesting`. Inspection, and smoke testing explicitly with `WithRequestValidation`,
`WithRequestPreparation`, and `WithSmokeTesting`. Inspection,
requirements, and catalog operations do not execute prompts or contact model requirements, and catalog operations do not execute prompts or contact model
providers; application loaders and callbacks retain their own effects. Smoke providers; application loaders and callbacks retain their own effects. Smoke
testing invokes the selected native PromptKit operation; the testing invokes the selected native PromptKit operation; the

Some files were not shown because too many files have changed in this diff Show More