Compare commits
154 Commits
integratio
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 1613ff95ca | |||
| 68288ed684 | |||
| 56e3b013f9 | |||
| 96c96bb793 | |||
| d914295ced | |||
| a54ff03aef | |||
| 7aff296c86 | |||
| 3271c18b77 | |||
| 7dd30f7df3 | |||
| 5e5f61a306 | |||
| 6727d22ce6 | |||
| f23af30234 | |||
| ec0e58efff | |||
| 69257066e2 | |||
| 88ac6e76e4 | |||
| 5c96053255 | |||
| 49f2d09ccd | |||
| af7cc1da3e | |||
| ee4e226914 | |||
| c648d1de33 | |||
| 69fe35c480 | |||
| 68825de200 | |||
| 0435a632b3 | |||
| aac0e11ae6 | |||
| e46d28a924 | |||
| 1cba65fffe | |||
| d1102e12c5 | |||
| e950f61c7f | |||
| c4b5bcdbc2 | |||
| b66a00da26 | |||
| a62f1f056b | |||
| 0a81be84f6 | |||
| 83386217db | |||
| 0d3f7299cc | |||
| 02cd49d6b6 | |||
| 51a147a6e5 | |||
| f2822f055c | |||
| 27500dbc6c | |||
| 3b75ae7d2e | |||
| 52f1d99994 | |||
| ac113291fc | |||
| 8dbb25aba9 | |||
| 7c7d1df13f | |||
| 4346b840db | |||
| 552b91c0a2 | |||
| 1fc6409f95 | |||
| c82235100e | |||
| b66d265032 | |||
| a806a9a62e | |||
| 118b49ee55 | |||
| d492c77471 | |||
| 264b82bd57 | |||
| d131999c7c | |||
| c7f898e56e | |||
| 485a27a662 | |||
| 9e0f2d28cd | |||
| 2a93b45419 | |||
| 9d4c963c53 | |||
| 1798dcde2e | |||
| f00fe1909d | |||
| db732e3a1d | |||
| 4a91f29bcd | |||
| efb5f77417 | |||
| 8fe8b67b29 | |||
| 2ea26afcc5 | |||
| 1e4fb73d15 | |||
| d8da761a21 | |||
| 7b2dc2f2c9 | |||
| 7bb7326ed1 | |||
| d72c9a788f | |||
| 3223b5cb82 | |||
| d7a85f5f35 | |||
| 07e1634e79 | |||
| c61e125649 | |||
| 3a7e2d4ce7 | |||
| f5b2d1f80c | |||
| f83866e44a | |||
| 2f43f78f76 | |||
| 668c609062 | |||
| bd115c8bee | |||
| f66502cb23 | |||
| 7416bf6fdc | |||
| d14fa366f5 | |||
| 1289868ed0 | |||
| 42af1da142 | |||
| 0b70f37267 | |||
| 1986c44f49 | |||
| 65b454b2ab | |||
| b38130548e | |||
| aeb70e5873 | |||
| 85ae55bf42 | |||
| da120c2dd4 | |||
| 0183022a28 | |||
| 644e6c365b | |||
| 8e5e64c6e9 | |||
| c3d5f551c3 | |||
| 6378ceb3c5 | |||
| 5ff0dd4ea2 | |||
| 6c35da3ab6 | |||
| 7159f52f4d | |||
| d748056652 | |||
| 924692b541 | |||
| 3a6c66f550 | |||
| 190bfbd77b | |||
| 7693482a8b | |||
| 105bb741cc | |||
| cb7e05284c | |||
| 022055dc26 | |||
| e908213318 | |||
| 5d90d59996 | |||
| 76c00e32d0 | |||
| 9b85c7cf60 | |||
| ee3cb21ebc | |||
| 93c16bd57e | |||
| ca454624cc | |||
| 90d9706f6d | |||
| f494088124 | |||
| 5010f9a102 | |||
| 3b6614605a | |||
| e7b00efc07 | |||
| 647a9d5372 | |||
| c52034f3fe | |||
| 408383db60 | |||
| e82101434f | |||
| ffe568b6f0 | |||
| 756954f056 | |||
| b6a8f1f2cc | |||
| d15926a878 | |||
| 7f2fdce8e7 | |||
| 491f0b49f4 | |||
| 61bcd80171 | |||
| 9076cb2c9c | |||
| f63af12097 | |||
| 111f963c67 | |||
| 66a33756c3 | |||
| ff9f840edd | |||
| f6f41ee6aa | |||
| df33e8aefc | |||
| 6830b96821 | |||
| 42af12cb0e | |||
| 0b4e47c420 | |||
| ad16bfc521 | |||
| 540e2359d6 | |||
| e28ba63b59 | |||
| 7f327758d8 | |||
| 1e3879e880 | |||
| 33cc36ff3b | |||
| 983c22be77 | |||
| f933f056d7 | |||
| cf50b135f2 | |||
| ee0debe4fb | |||
| 4f851371da | |||
| 69cc6429ff | |||
| eeae10fa05 |
34
README.md
34
README.md
@@ -1,32 +1,26 @@
|
|||||||
# CommandKit
|
# CommandKit
|
||||||
|
|
||||||
CommandKit is a Go library for typed command declarations, shared CLI behavior,
|
CommandKit helps Go applications build consistent command-line interfaces. It
|
||||||
and application-owned execution. The core `commandkit` module is usable by
|
provides typed flags and arguments, help, output formatting, and error reporting.
|
||||||
itself. ConfigKit and PromptKit support lives in separately versioned optional
|
Optional integrations add commands for inspecting configuration and working with
|
||||||
modules, so applications choose which upstream libraries they add.
|
PromptKit.
|
||||||
|
|
||||||
The core captures reusable command declarations, validates installation batches
|
CommandKit is in alpha. Start with the [consumer guide](docs/consumers/guide.md#core-commands)
|
||||||
before changing an application's registry, converts typed inputs before
|
to define a command, or run the [core example](examples/core/README.md).
|
||||||
constructing them, and returns outcomes without exiting the process. Its
|
Applications supply their own command handlers and decide when to exit;
|
||||||
[consumer guide](docs/consumers/guide.md) covers declaration, invocation
|
CommandKit returns the result of each invocation.
|
||||||
reporting, and migration. Each handler receives one invocation reporter for
|
|
||||||
substantive output, categorized messages, and terminal explanations; `Execute`
|
|
||||||
returns delivery observations with the outcome. The [CLI reference](docs/cli.md)
|
|
||||||
defines the shared visibility policy.
|
|
||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
| Module | Package | Provides |
|
| Module | Package | Provides |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `go.maximumdirect.net/commandkit` | `commandkit` | Typed commands, bindings, execution reporting and outcomes, help, and version. |
|
| `go.maximumdirect.net/commandkit` | `commandkit` | Commands, typed inputs, shared output and error reporting, help, and version. |
|
||||||
| `go.maximumdirect.net/ckconfig` | `ckconfig` | ConfigKit-backed `config` scopes, including configuration source evidence. |
|
| `go.maximumdirect.net/ckconfig` | `ckconfig` | Commands to view, validate, explain, and compare ConfigKit configuration. |
|
||||||
| `go.maximumdirect.net/ckprompt` | `ckprompt` | PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, concrete request validation, and native smoke tests. |
|
| `go.maximumdirect.net/ckprompt` | `ckprompt` | Commands to list and inspect PromptKit resources, discover required inputs, validate and preview requests, and run smoke tests. |
|
||||||
|
|
||||||
Importing an integration does not install commands. The published ckconfig and
|
Importing an integration does not install commands. Core and the integrations
|
||||||
ckprompt v0.2.0 modules use core v0.3.0. The local
|
are versioned separately; see the [consumer guide](docs/consumers/guide.md#module-selection)
|
||||||
ckconfig v0.3.0 candidate uses ConfigKit v1.1.0 and does not compose with
|
for published versions, compatibility, and installation choices.
|
||||||
ckprompt v0.2.0; the published v0.2.0 pair remains matched. See the
|
|
||||||
[consumer guide](docs/consumers/guide.md#module-selection) for migration details.
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
|
|||||||
145
binding.go
145
binding.go
@@ -38,9 +38,58 @@ 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.
|
||||||
|
// The name follows the declaration rules in [FlagMeta].
|
||||||
|
func RenameFlag[I any](flag Flag[I], name string) Flag[I] {
|
||||||
|
binding := flag.binding
|
||||||
|
binding.meta.Name = name
|
||||||
|
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.
|
||||||
|
//
|
||||||
|
// A BoolFlag named debug receives the shared reporting flag's input. Its
|
||||||
|
// default and requiredness apply to application input, not reporting mode;
|
||||||
|
// explicit false satisfies requiredness. Its optional short spelling and
|
||||||
|
// completion metadata follow ordinary flag rules. The final input value can
|
||||||
|
// differ from [Invocation.ReportingMode] when other mode flags are supplied.
|
||||||
func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[bool])) Flag[I] {
|
func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[bool])) Flag[I] {
|
||||||
binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, defaultText: fmt.Sprint(defaultValue), boolean: true}
|
binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, defaultText: fmt.Sprint(defaultValue), boolean: true}
|
||||||
if set == nil {
|
if set == nil {
|
||||||
@@ -189,19 +238,14 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepareInput parses all supplied bindings into typed closures before it
|
func prepareInputWithOccurrences[I any](spec CommandSpec[I], flagValues, flagSpellings [][]string, occurrences []commandmeta.FlagOccurrence, operands []string, enforceRequired bool) (preparedInput[I], error) {
|
||||||
// creates an input value. When enforceRequired is false, missing required flags
|
|
||||||
// and operands are allowed so metadata help can validate supplied syntax.
|
|
||||||
func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) {
|
|
||||||
return prepareInputWithSpellings(spec, flagValues, nil, operands, enforceRequired)
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareInputWithSpellings[I any](spec CommandSpec[I], flagValues, flagSpellings [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) {
|
|
||||||
if len(flagValues) != len(spec.Flags) {
|
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,25 +256,42 @@ 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
|
||||||
value, err := binding.prepare(flagValues[i])
|
value, err := binding.prepare(flagValues[i])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var occurrenceErr *flagOccurrenceError
|
return preparedInput[I]{}, attributeFlagConversionError(binding.meta.Name, flagSpellings[i], err)
|
||||||
if errors.As(err, &occurrenceErr) {
|
|
||||||
spelling := "--" + binding.meta.Name
|
|
||||||
if occurrenceErr.index >= 0 && occurrenceErr.index < len(flagSpellings[i]) && flagSpellings[i][occurrenceErr.index] != "" {
|
|
||||||
spelling = flagSpellings[i][occurrenceErr.index]
|
|
||||||
}
|
|
||||||
return preparedInput[I]{}, &flagConversionError{
|
|
||||||
binding: binding.meta.Name, spelling: spelling,
|
|
||||||
occurrence: occurrenceErr.index + 1, cause: occurrenceErr.cause,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err)
|
|
||||||
}
|
}
|
||||||
preparedFlags[i] = value
|
preparedFlags[i] = value
|
||||||
|
for index, occurrence := range value.ordered {
|
||||||
|
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 {
|
||||||
|
if occurrences == nil {
|
||||||
|
ordered = append(ordered, occurrence)
|
||||||
|
} else {
|
||||||
|
if ordered == nil {
|
||||||
|
ordered = make([]preparedOccurrence[I], len(occurrences))
|
||||||
|
}
|
||||||
|
ordered[*occurrence.ordinal] = occurrence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if enforceRequired && binding.meta.Required && !binding.deferredRequired && !value.supplied && missingRequired == "" {
|
if enforceRequired && binding.meta.Required && !binding.deferredRequired && !value.supplied && missingRequired == "" {
|
||||||
missingRequired = binding.meta.Name
|
missingRequired = binding.meta.Name
|
||||||
}
|
}
|
||||||
@@ -238,22 +299,50 @@ func prepareInputWithSpellings[I any](spec CommandSpec[I], flagValues, flagSpell
|
|||||||
if missingRequired != "" {
|
if missingRequired != "" {
|
||||||
return preparedInput[I]{}, fmt.Errorf("required flag --%s was not supplied", missingRequired)
|
return preparedInput[I]{}, fmt.Errorf("required flag --%s was not supplied", missingRequired)
|
||||||
}
|
}
|
||||||
argumentValues, err := allocateOperands(operands, spec.Arguments, enforceRequired)
|
arguments, err := prepareOperands(operands, spec.Arguments, enforceRequired)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return preparedInput[I]{}, err
|
return preparedInput[I]{}, err
|
||||||
}
|
}
|
||||||
preparedValues := append([]preparedValue[I](nil), preparedFlags...)
|
preparedValues := append([]preparedValue[I](nil), preparedFlags...)
|
||||||
for i, argument := range spec.Arguments {
|
// Parsed occurrences use dense parser positions; without a parser log the
|
||||||
binding := argument.binding
|
// fallback ordinals were assigned in the same order as the append above.
|
||||||
value, err := binding.prepare(argumentValues[i])
|
for _, occurrence := range ordered {
|
||||||
if err != nil {
|
if occurrence.apply != nil {
|
||||||
return preparedInput[I]{}, fmt.Errorf("argument %q: %w", binding.meta.Name, err)
|
preparedValues = append(preparedValues, preparedValue[I]{apply: occurrence.apply})
|
||||||
}
|
}
|
||||||
preparedValues = append(preparedValues, value)
|
|
||||||
}
|
}
|
||||||
|
preparedValues = append(preparedValues, arguments...)
|
||||||
return preparedInput[I]{newInput: spec.NewInput, values: preparedValues}, nil
|
return preparedInput[I]{newInput: spec.NewInput, values: preparedValues}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func attributeFlagConversionError(name string, spellings []string, err error) error {
|
||||||
|
var occurrenceErr *flagOccurrenceError
|
||||||
|
if errors.As(err, &occurrenceErr) {
|
||||||
|
spelling := "--" + name
|
||||||
|
if occurrenceErr.index >= 0 && occurrenceErr.index < len(spellings) && spellings[occurrenceErr.index] != "" {
|
||||||
|
spelling = spellings[occurrenceErr.index]
|
||||||
|
}
|
||||||
|
return &flagConversionError{binding: name, spelling: spelling, occurrence: occurrenceErr.index + 1, cause: occurrenceErr.cause}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("flag --%s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([]preparedValue[I], error) {
|
||||||
|
values, err := allocateOperands(operands, definitions, enforceRequired)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
prepared := make([]preparedValue[I], 0, len(definitions))
|
||||||
|
for i, argument := range definitions {
|
||||||
|
value, err := argument.binding.prepare(values[i])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("argument %q: %w", argument.binding.meta.Name, err)
|
||||||
|
}
|
||||||
|
prepared = append(prepared, value)
|
||||||
|
}
|
||||||
|
return prepared, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newBindingIdentity() *bindingIdentity { return &bindingIdentity{marker: 1} }
|
func newBindingIdentity() *bindingIdentity { return &bindingIdentity{marker: 1} }
|
||||||
|
|
||||||
func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) {
|
func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defaultMessage = "changed after declaration"
|
defaultMessage = "changed after declaration"
|
||||||
prepared, err := prepareInput(spec, [][]string{nil, nil, nil}, nil, true)
|
prepared, err := prepareInputWithOccurrences(spec, [][]string{nil, nil, nil}, nil, nil, nil, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
|
|||||||
t.Fatalf("NewInput runtime value = %q, want caller value", input.runtime)
|
t.Fatalf("NewInput runtime value = %q, want caller value", input.runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
prepared, err = prepareInput(spec, [][]string{{"", "last"}, {"0"}, {"true", "false"}}, nil, true)
|
prepared, err = prepareInputWithOccurrences(spec, [][]string{{"", "last"}, {"0"}, {"true", "false"}}, nil, nil, nil, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
|
|||||||
t.Fatalf("enabled = %#v, want explicit false", input.enabled)
|
t.Fatalf("enabled = %#v, want explicit false", input.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
prepared, err = prepareInput(spec, [][]string{{""}, nil, nil}, nil, true)
|
prepared, err = prepareInputWithOccurrences(spec, [][]string{{""}, nil, nil}, nil, nil, nil, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
|
|||||||
if input.message.Value != "" || !input.message.Set {
|
if input.message.Value != "" || !input.message.Set {
|
||||||
t.Fatalf("message = %#v, want explicitly supplied empty string", input.message)
|
t.Fatalf("message = %#v, want explicitly supplied empty string", input.message)
|
||||||
}
|
}
|
||||||
prepared, err = prepareInput(spec, [][]string{nil, nil, nil}, nil, true)
|
prepared, err = prepareInputWithOccurrences(spec, [][]string{nil, nil, nil}, nil, nil, nil, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,7 @@ func TestRepeatArgumentAllocationReservesLaterMinimums(t *testing.T) {
|
|||||||
if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
|
if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
prepared, err := prepareInput(spec, nil, []string{"a", "b", "id-1", "x", "y"}, true)
|
prepared, err := prepareInputWithOccurrences(spec, nil, nil, nil, []string{"a", "b", "id-1", "x", "y"}, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -237,10 +237,10 @@ func TestIncompletePreparationSkipsMissingRequirementsAndInputFactory(t *testing
|
|||||||
if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
|
if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := prepareInput(spec, [][]string{nil}, nil, true); err == nil {
|
if _, err := prepareInputWithOccurrences(spec, [][]string{nil}, nil, nil, nil, true); err == nil {
|
||||||
t.Fatal("strict preparation accepted missing requirements")
|
t.Fatal("strict preparation accepted missing requirements")
|
||||||
}
|
}
|
||||||
if _, err := prepareInput(spec, [][]string{nil}, nil, false); err != nil {
|
if _, err := prepareInputWithOccurrences(spec, [][]string{nil}, nil, nil, nil, false); err != nil {
|
||||||
t.Fatalf("help preparation rejected omitted execution inputs: %v", err)
|
t.Fatalf("help preparation rejected omitted execution inputs: %v", err)
|
||||||
}
|
}
|
||||||
if factoryCalls != 0 {
|
if factoryCalls != 0 {
|
||||||
|
|||||||
58
builtins.go
58
builtins.go
@@ -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,36 +17,35 @@ 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",
|
||||||
Summary: "Show help for commands.",
|
Summary: "Show help for commands.",
|
||||||
Description: "Describe registered commands and their inputs.",
|
Description: "Show available commands, flags, and arguments.",
|
||||||
Arguments: []Argument[helpInput]{RepeatArgument(ArgumentMeta{Name: "COMMAND", Usage: "registered command path"}, 0, -1,
|
Arguments: []Argument[helpInput]{RepeatArgument(ArgumentMeta{Name: "COMMAND", Usage: "Command path to describe."}, 0, -1,
|
||||||
func(value string) (string, error) { return value, nil },
|
func(value string) (string, error) { return value, nil },
|
||||||
func(input *helpInput, value Supplied[[]string]) { input.Targets = value })},
|
func(input *helpInput, value Supplied[[]string]) { input.Targets = value })},
|
||||||
}, func(current commandExecution, input helpInput) Outcome {
|
}, func(current commandExecution, input helpInput) Outcome {
|
||||||
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: "Show the name, version, and revision without loading application configuration.",
|
||||||
Flags: []Flag[versionInput]{
|
Flags: []Flag[versionInput]{
|
||||||
ValueFlag(FlagMeta{Name: "scope", Usage: "version subject", Completion: CompleteChoices(CompletionChoice{Value: "app"}, CompletionChoice{Value: "commandkit"})}, &scopeDefault, parseVersionScope, func(input *versionInput, value Supplied[string]) { input.Scope = value.Value }),
|
ValueFlag(FlagMeta{Name: "scope", Usage: "Show the app or CommandKit version.", Completion: CompleteChoices(CompletionChoice{Value: "app"}, CompletionChoice{Value: "commandkit"})}, &scopeDefault, parseVersionScope, func(input *versionInput, value Supplied[string]) { input.Scope = value.Value }),
|
||||||
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
76
cli_json.go
Normal 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
44
cli_json_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
93
command.go
93
command.go
@@ -90,10 +90,15 @@ type Supplied[T any] struct {
|
|||||||
|
|
||||||
// FlagMeta describes a command flag. Required applies when execution prepares
|
// FlagMeta describes a command flag. Required applies when execution prepares
|
||||||
// the selected command; help reports the metadata without requiring the value.
|
// the selected command; help reports the metadata without requiring the value.
|
||||||
|
// Invalid declarations are reported when installed.
|
||||||
type FlagMeta struct {
|
type FlagMeta struct {
|
||||||
// Name is the flag name without leading dashes.
|
// Name is a nonempty long name without leading dashes. It consists of
|
||||||
|
// Unicode lowercase letters or digits in segments separated by single
|
||||||
|
// hyphens; digit-leading and numeric-only names are valid. The names help,
|
||||||
|
// quiet, verbose, debug, progress and format are reserved, except that a
|
||||||
|
// BoolFlag named debug attaches to the shared reporting flag.
|
||||||
Name string
|
Name string
|
||||||
// Short is an optional one-byte spelling.
|
// Short is an optional one-byte spelling other than '-' or the reserved 'h'.
|
||||||
Short string
|
Short string
|
||||||
// Usage briefly describes the flag.
|
// Usage briefly describes the flag.
|
||||||
Usage string
|
Usage string
|
||||||
@@ -127,6 +132,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 +198,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 +247,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
|
||||||
}
|
}
|
||||||
@@ -345,6 +357,7 @@ func Alias(path, target string) RoutingRule {
|
|||||||
// wherever its canonical target flag is available and contributes nothing on
|
// wherever its canonical target flag is available and contributes nothing on
|
||||||
// commands without that flag. Identical mappings are accepted; different
|
// commands without that flag. Identical mappings are accepted; different
|
||||||
// targets for the same source spelling in this scope are installation errors.
|
// targets for the same source spelling in this scope are installation errors.
|
||||||
|
// Long and short spelling forms follow the grammar described by [FlagMeta].
|
||||||
func FlagAlias(spelling, target string) RoutingRule {
|
func FlagAlias(spelling, target string) RoutingRule {
|
||||||
record := &routingRuleRecord{
|
record := &routingRuleRecord{
|
||||||
kind: globalFlagAliasRule,
|
kind: globalFlagAliasRule,
|
||||||
@@ -361,6 +374,7 @@ func FlagAlias(spelling, target string) RoutingRule {
|
|||||||
// available in the complete installation. Scoped mappings take precedence over
|
// available in the complete installation. Scoped mappings take precedence over
|
||||||
// application-wide mappings with the same source spelling. Identical mappings
|
// application-wide mappings with the same source spelling. Identical mappings
|
||||||
// are accepted; competing targets in the same scope are installation errors.
|
// are accepted; competing targets in the same scope are installation errors.
|
||||||
|
// Long and short spelling forms follow the grammar described by [FlagMeta].
|
||||||
func FlagAliasFor(commandPath, spelling, target string) RoutingRule {
|
func FlagAliasFor(commandPath, spelling, target string) RoutingRule {
|
||||||
record := &routingRuleRecord{
|
record := &routingRuleRecord{
|
||||||
kind: scopedFlagAliasRule,
|
kind: scopedFlagAliasRule,
|
||||||
@@ -430,39 +444,46 @@ func validateCommandDeclaration(command Command) error {
|
|||||||
|
|
||||||
func validateCommandPlacement(command Command) error {
|
func validateCommandPlacement(command Command) error {
|
||||||
var issues []error
|
var issues []error
|
||||||
collectDeclarationIssues(command, nil, true, &issues)
|
// Keep the grown slice header across recursive calls so siblings reuse capacity.
|
||||||
|
var path []string
|
||||||
|
var collectDeclarationIssues func(Command, bool)
|
||||||
|
collectDeclarationIssues = func(command Command, root bool) {
|
||||||
|
if command.record == nil {
|
||||||
|
issues = append(issues, errors.New("invalid zero command declaration"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record := command.record
|
||||||
|
length := len(path)
|
||||||
|
path = append(path, record.name)
|
||||||
|
defer func() { path = path[:length] }()
|
||||||
|
var label string
|
||||||
|
if len(record.issues) > 0 || (record.rootOnly && !root) {
|
||||||
|
label = commandpath.Path(path).String()
|
||||||
|
}
|
||||||
|
for _, issue := range record.issues {
|
||||||
|
if label != "" {
|
||||||
|
issues = append(issues, fmt.Errorf("command %q: %w", label, issue))
|
||||||
|
} else {
|
||||||
|
issues = append(issues, issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if record.rootOnly && !root {
|
||||||
|
issues = append(issues, fmt.Errorf("root-only command %q is nested beneath another command", label))
|
||||||
|
}
|
||||||
|
if record.invalid || record.absent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, child := range record.children {
|
||||||
|
if child.record == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
collectDeclarationIssues(child, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collectDeclarationIssues(command, true)
|
||||||
return errors.Join(issues...)
|
return errors.Join(issues...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectDeclarationIssues(command Command, parents []string, root bool, issues *[]error) {
|
|
||||||
if command.record == nil {
|
|
||||||
*issues = append(*issues, errors.New("invalid zero command declaration"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
record := command.record
|
|
||||||
path := append(append([]string(nil), parents...), record.name)
|
|
||||||
label := commandpath.FromTokens(path).String()
|
|
||||||
for _, issue := range record.issues {
|
|
||||||
if label != "" {
|
|
||||||
*issues = append(*issues, fmt.Errorf("command %q: %w", label, issue))
|
|
||||||
} else {
|
|
||||||
*issues = append(*issues, issue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if record.rootOnly && !root {
|
|
||||||
*issues = append(*issues, fmt.Errorf("root-only command %q is nested beneath another command", label))
|
|
||||||
}
|
|
||||||
if record.invalid || record.absent {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, child := range record.children {
|
|
||||||
if child.record == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
collectDeclarationIssues(child, path, false, issues)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateCommandName(name string) error {
|
func validateCommandName(name string) error {
|
||||||
return commandpath.ValidateComponent(name)
|
return commandpath.ValidateComponent(name)
|
||||||
}
|
}
|
||||||
@@ -478,7 +499,7 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
|
|||||||
if binding.meta.Name == "help" {
|
if binding.meta.Name == "help" {
|
||||||
return fmt.Errorf("flag name %q is reserved for help", binding.meta.Name)
|
return fmt.Errorf("flag name %q is reserved for help", binding.meta.Name)
|
||||||
}
|
}
|
||||||
if commandmeta.IsReportingFlag(binding.meta.Name) {
|
if commandmeta.IsReportingFlag(binding.meta.Name) && !(binding.meta.Name == "debug" && binding.boolean) {
|
||||||
return fmt.Errorf("flag name %q is reserved for reporting", binding.meta.Name)
|
return fmt.Errorf("flag name %q is reserved for reporting", binding.meta.Name)
|
||||||
}
|
}
|
||||||
if _, exists := flagNames[binding.meta.Name]; exists {
|
if _, exists := flagNames[binding.meta.Name]; exists {
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ type completionFlagState struct {
|
|||||||
func completionFlags(context completionContext) []commandmeta.Flag {
|
func completionFlags(context completionContext) []commandmeta.Flag {
|
||||||
flags := make([]commandmeta.Flag, 0, len(context.command.Flags)+len(context.command.CoreFlags)+1)
|
flags := make([]commandmeta.Flag, 0, len(context.command.Flags)+len(context.command.CoreFlags)+1)
|
||||||
flags = append(flags, commandmeta.HelpFlag())
|
flags = append(flags, commandmeta.HelpFlag())
|
||||||
flags = append(flags, context.command.CoreFlags...)
|
flags = append(flags, commandmeta.EffectiveFlags(context.command.Flags, context.command.CoreFlags)...)
|
||||||
flags = append(flags, context.command.Flags...)
|
|
||||||
return flags
|
return flags
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,44 +52,26 @@ func walkCompletionFlags(tokens []string, lookup commandmeta.FlagLookup) complet
|
|||||||
state.ended = true
|
state.ended = true
|
||||||
case commandmeta.OperandToken:
|
case commandmeta.OperandToken:
|
||||||
state.operands = append(state.operands, token)
|
state.operands = append(state.operands, token)
|
||||||
case commandmeta.LongFlagToken:
|
case commandmeta.LongFlagToken, commandmeta.ShortFlagToken:
|
||||||
spelling, _, attached := commandmeta.SplitLongFlag(token)
|
for offset := 1; ; {
|
||||||
flag, ok := lookup.Find(spelling)
|
fact := commandmeta.ConsumeFlag(lookup, token, offset)
|
||||||
if !ok {
|
if !fact.Found {
|
||||||
state.invalid = true
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
state.supplied[flag.Name] = true
|
|
||||||
if !flag.Boolean && !attached {
|
|
||||||
if index+1 == len(tokens) {
|
|
||||||
state.pending = &flag
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
case commandmeta.ShortFlagToken:
|
|
||||||
for offset := 1; offset < len(token); offset++ {
|
|
||||||
flag, ok := lookup.Find("-" + token[offset:offset+1])
|
|
||||||
if !ok {
|
|
||||||
state.invalid = true
|
state.invalid = true
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
state.supplied[flag.Name] = true
|
state.supplied[fact.Flag.Name] = true
|
||||||
suffix := token[offset+1:]
|
if fact.NeedsNext {
|
||||||
if flag.Boolean {
|
|
||||||
if strings.HasPrefix(suffix, "=") {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if suffix == "" {
|
|
||||||
if index+1 == len(tokens) {
|
if index+1 == len(tokens) {
|
||||||
|
flag := fact.Flag
|
||||||
state.pending = &flag
|
state.pending = &flag
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
index++
|
index++
|
||||||
}
|
}
|
||||||
break
|
if fact.NextOffset == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset = fact.NextOffset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,10 +113,10 @@ func completionOptionTarget(current string, flags []commandmeta.Flag, lookup com
|
|||||||
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)}
|
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)}
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(current, "--") {
|
if strings.HasPrefix(current, "--") {
|
||||||
spelling, value, attached := commandmeta.SplitLongFlag(current)
|
fact := commandmeta.ConsumeFlag(lookup, current, 1)
|
||||||
if attached {
|
if fact.Attached {
|
||||||
if flag, ok := lookup.Find(spelling); ok {
|
if fact.Found {
|
||||||
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: spelling + "=", valuePrefix: value}
|
return completionTarget{kind: completionTargetValue, hint: fact.Flag.Completion, prefix: fact.Spelling + "=", valuePrefix: fact.Value}
|
||||||
}
|
}
|
||||||
return completionTarget{}
|
return completionTarget{}
|
||||||
}
|
}
|
||||||
@@ -145,25 +126,26 @@ func completionOptionTarget(current string, flags []commandmeta.Flag, lookup com
|
|||||||
for name, value := range supplied {
|
for name, value := range supplied {
|
||||||
used[name] = value
|
used[name] = value
|
||||||
}
|
}
|
||||||
for offset := 1; offset < len(current); offset++ {
|
for offset := 1; offset < len(current); {
|
||||||
flag, ok := lookup.Find("-" + current[offset:offset+1])
|
fact := commandmeta.ConsumeFlag(lookup, current, offset)
|
||||||
if !ok {
|
if !fact.Found {
|
||||||
return completionTarget{}
|
return completionTarget{}
|
||||||
}
|
}
|
||||||
used[flag.Name] = true
|
used[fact.Flag.Name] = true
|
||||||
suffix := current[offset+1:]
|
if fact.Flag.Boolean && fact.Attached {
|
||||||
if flag.Boolean {
|
return completionTarget{kind: completionTargetValue, hint: fact.Flag.Completion, prefix: current[:offset+2], valuePrefix: fact.Value}
|
||||||
if strings.HasPrefix(suffix, "=") {
|
}
|
||||||
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: current[:offset+2], valuePrefix: suffix[1:]}
|
if !fact.Flag.Boolean {
|
||||||
|
prefix := current[:offset+1]
|
||||||
|
if strings.HasPrefix(current[offset+1:], "=") {
|
||||||
|
prefix += "="
|
||||||
}
|
}
|
||||||
continue
|
return completionTarget{kind: completionTargetValue, hint: fact.Flag.Completion, prefix: prefix, valuePrefix: fact.Value}
|
||||||
}
|
}
|
||||||
prefix := current[:offset+1]
|
if fact.NextOffset == 0 {
|
||||||
if strings.HasPrefix(suffix, "=") {
|
break
|
||||||
prefix += "="
|
|
||||||
suffix = suffix[1:]
|
|
||||||
}
|
}
|
||||||
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: prefix, valuePrefix: suffix}
|
offset = fact.NextOffset
|
||||||
}
|
}
|
||||||
var choices []commandmeta.CompletionChoice
|
var choices []commandmeta.CompletionChoice
|
||||||
for _, flag := range flags {
|
for _, flag := range flags {
|
||||||
|
|||||||
@@ -70,6 +70,34 @@ func TestCompletionFlagAndValuePositions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSharedDebugCompletionHints(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
hint CompletionHint
|
||||||
|
want commandmeta.CompletionKind
|
||||||
|
}{
|
||||||
|
{"none", NoCompletion(), commandmeta.CompletionNone},
|
||||||
|
{"choices", CompleteChoices(CompletionChoice{Value: "true"}), commandmeta.CompletionChoices},
|
||||||
|
{"files", CompleteFiles(), commandmeta.CompletionFiles},
|
||||||
|
{"directories", CompleteDirectories(), commandmeta.CompletionDirectories},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{Name: "run", Flags: []Flag[struct{}]{BoolFlag(FlagMeta{Name: "debug", Short: "d", Completion: tc.hint}, false, func(*struct{}, Supplied[bool]) {})}, Run: func(Invocation, struct{}) Outcome { return Success() }})
|
||||||
|
app, err := New(Identity{Name: "example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshot := app.routeSnapshot()
|
||||||
|
for _, current := range []string{"--debug=", "-d="} {
|
||||||
|
target := completionTargetFor(snapshot, resolveCompletionContext(snapshot, []string{"run"}, current))
|
||||||
|
if target.kind != completionTargetValue || target.hint.Kind != tc.want {
|
||||||
|
t.Fatalf("%s: %#v", current, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCompletionOperandAllocation(t *testing.T) {
|
func TestCompletionOperandAllocation(t *testing.T) {
|
||||||
arguments := []commandmeta.Argument{
|
arguments := []commandmeta.Argument{
|
||||||
{Name: "optional", Min: 0, Max: 1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionFiles}},
|
{Name: "optional", Min: 0, Max: 1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionFiles}},
|
||||||
|
|||||||
@@ -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},
|
||||||
} {
|
} {
|
||||||
@@ -119,3 +120,51 @@ func TestCompletionQueryDiscovery(t *testing.T) {
|
|||||||
t.Fatalf("discovery outcome=%#v reply=%q", outcome, output.String())
|
t.Fatalf("discovery outcome=%#v reply=%q", outcome, output.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSharedDebugHelpAndCompletion(t *testing.T) {
|
||||||
|
var factories, setters, checks, handlers int
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "run",
|
||||||
|
NewInput: func() struct{} { factories++; return struct{}{} },
|
||||||
|
Flags: []Flag[struct{}]{BoolFlag(FlagMeta{Name: "debug", Short: "d", Usage: "application tracing", Required: true, Completion: CompleteChoices(CompletionChoice{Value: "true"}, CompletionChoice{Value: "trace"})}, true, func(*struct{}, Supplied[bool]) { setters++ })},
|
||||||
|
CheckInput: func(struct{}) error { checks++; return nil },
|
||||||
|
Run: func(Invocation, struct{}) Outcome { handlers++; return Success() },
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "example"}, command, Completion("example"), Alias("shortcut", "run"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{{"run", "--help"}, {"help", "run"}, {"shortcut", "--help"}, {"help", "shortcut"}, {"run", "--unknown"}, {"shortcut", "--unknown"}} {
|
||||||
|
var output, diagnostic bytes.Buffer
|
||||||
|
result := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostic})
|
||||||
|
text := output.String() + diagnostic.String()
|
||||||
|
if strings.Count(text, "--debug") != 1 || !strings.Contains(text, "-d") || !strings.Contains(text, "application tracing") || !strings.Contains(text, "application input defaults to true") || !strings.Contains(text, "required") || !strings.Contains(text, "default: false") {
|
||||||
|
t.Fatalf("%v: outcome=%#v text=%q", args, result, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
count int
|
||||||
|
}{
|
||||||
|
{[]string{"__complete", "run", "--deb"}, "--debug", 1},
|
||||||
|
{[]string{"__complete", "run", "-"}, "\n-d\t", 1},
|
||||||
|
{[]string{"__complete", "run", "--debug=tr"}, "trace", 1},
|
||||||
|
{[]string{"__complete", "run", "-d=tr"}, "trace", 1},
|
||||||
|
} {
|
||||||
|
var output bytes.Buffer
|
||||||
|
result := app.Execute(context.Background(), tc.args, Streams{Out: &output, Err: io.Discard})
|
||||||
|
if result.Kind != OutcomeSuccess || strings.Count(output.String(), tc.want) != tc.count {
|
||||||
|
t.Fatalf("%v: %#v %q", tc.args, result, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if factories != 0 || setters != 0 || checks != 0 || handlers != 0 {
|
||||||
|
t.Fatalf("callbacks: %d %d %d %d", factories, setters, checks, handlers)
|
||||||
|
}
|
||||||
|
if result := app.Execute(context.Background(), []string{"run", "--debug=invalid", "--help"}, Streams{Out: io.Discard, Err: io.Discard}); result.Kind != OutcomeUsage {
|
||||||
|
t.Fatalf("help skipped boolean validation: %#v", result)
|
||||||
|
}
|
||||||
|
if result := app.Execute(context.Background(), []string{"run", "--debug=trace"}, Streams{Out: io.Discard, Err: io.Discard}); result.Kind != OutcomeUsage {
|
||||||
|
t.Fatalf("suggestion bypassed boolean parsing: %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ func TestCompletionConsumerInShells(t *testing.T) {
|
|||||||
directory bool
|
directory bool
|
||||||
}{
|
}{
|
||||||
{"canonical command", "probe ru", []string{"run"}, false},
|
{"canonical command", "probe ru", []string{"run"}, false},
|
||||||
{"entered alias", "probe shortcut --format=ya", []string{"shortcut", "--format=yaml"}, false},
|
{"entered alias", "probe shortcut --choice=ya", []string{"shortcut", "--choice=yaml"}, false},
|
||||||
{"choice", "probe run --format=te", []string{"run", "--format=text"}, false},
|
{"choice", "probe run --choice=te", []string{"run", "--choice=text"}, false},
|
||||||
{"no file fallback", `probe run --format=./partial\ f`, []string{"run", "--format=./partial f"}, false},
|
{"no file fallback", `probe run --choice=./partial\ f`, []string{"run", "--choice=./partial f"}, false},
|
||||||
{"separated file", `probe run --config ./partial\ f`, []string{"run", "--config", "./partial file"}, false},
|
{"separated file", `probe run --config ./partial\ f`, []string{"run", "--config", "./partial file"}, false},
|
||||||
{"attached file", `probe run --config=./partial\ f`, []string{"run", "--config=./partial file"}, false},
|
{"attached file", `probe run --config=./partial\ f`, []string{"run", "--config=./partial file"}, false},
|
||||||
{"short attached file", `probe run -c./partial\ f`, []string{"run", "-c./partial file"}, false},
|
{"short attached file", `probe run -c./partial\ f`, []string{"run", "-c./partial file"}, false},
|
||||||
@@ -42,7 +42,7 @@ func TestCompletionConsumerInShells(t *testing.T) {
|
|||||||
{"separated directory", "probe run --directory ./partial", []string{"run", "--directory", "./partial dir"}, true},
|
{"separated directory", "probe run --directory ./partial", []string{"run", "--directory", "./partial dir"}, true},
|
||||||
{"attached directory", "probe run --directory=./partial", []string{"run", "--directory=./partial dir"}, true},
|
{"attached directory", "probe run --directory=./partial", []string{"run", "--directory=./partial dir"}, true},
|
||||||
{"empty operand", "probe run ", []string{"run", "alpha"}, false},
|
{"empty operand", "probe run ", []string{"run", "alpha"}, false},
|
||||||
{"leading dash", "probe run --for", []string{"run", "--format"}, false},
|
{"leading dash", "probe run --cho", []string{"run", "--choice"}, false},
|
||||||
}
|
}
|
||||||
for _, shell := range []string{"bash", "zsh", "fish"} {
|
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||||
generator := exec.Command(app, "completion", shell)
|
generator := exec.Command(app, "completion", shell)
|
||||||
@@ -82,7 +82,7 @@ func TestCompletionConsumerInShells(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
t.Run(shell+"/descriptions", func(t *testing.T) {
|
t.Run(shell+"/descriptions", func(t *testing.T) {
|
||||||
terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, "probe run --format=")
|
terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, "probe run --choice=")
|
||||||
terminal.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_LOG="+filepath.Join(dir, "log"), "PROBE_DIR="+dir, "PROBE_LIST=1")
|
terminal.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_LOG="+filepath.Join(dir, "log"), "PROBE_DIR="+dir, "PROBE_LIST=1")
|
||||||
output, err := terminal.CombinedOutput()
|
output, err := terminal.CombinedOutput()
|
||||||
if err != nil || !strings.Contains(string(output), "Readable text") || !strings.Contains(string(output), "YAML output") {
|
if err != nil || !strings.Contains(string(output), "Readable text") || !strings.Contains(string(output), "YAML output") {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,3 +79,40 @@ func TestCompletionChoicesDoNotRestrictExecution(t *testing.T) {
|
|||||||
t.Fatalf("outcome=%#v input=%#v", outcome, received)
|
t.Fatalf("outcome=%#v input=%#v", outcome, received)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompletionConsumesFlagGrammarAndKeepsPendingValues(t *testing.T) {
|
||||||
|
flags := []commandmeta.Flag{{Name: "verbose", Short: "v", Boolean: true}, {Name: "choice", Short: "c", Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionChoices}}}
|
||||||
|
lookup := commandmeta.NewFlagLookup(flags, []commandmeta.FlagAlias{{Source: "-x", Target: "--choice"}})
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
tokens []string
|
||||||
|
pending, operand string
|
||||||
|
invalid bool
|
||||||
|
}{
|
||||||
|
{name: "cluster attached empty", tokens: []string{"-vc="}, operand: ""},
|
||||||
|
{name: "option-looking value", tokens: []string{"-vx", "--other"}, operand: ""},
|
||||||
|
{name: "pending", tokens: []string{"-vx"}, pending: "choice"},
|
||||||
|
{name: "unknown cluster", tokens: []string{"-vz"}, invalid: true},
|
||||||
|
{name: "terminator", tokens: []string{"--", "--choice"}, operand: "--choice"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
state := walkCompletionFlags(tc.tokens, lookup)
|
||||||
|
if state.invalid != tc.invalid {
|
||||||
|
t.Fatalf("state=%#v", state)
|
||||||
|
}
|
||||||
|
if tc.pending != "" && (state.pending == nil || state.pending.Name != tc.pending) {
|
||||||
|
t.Fatalf("pending=%#v", state.pending)
|
||||||
|
}
|
||||||
|
if tc.pending == "" && state.pending != nil {
|
||||||
|
t.Fatalf("unexpected pending=%#v", state.pending)
|
||||||
|
}
|
||||||
|
if tc.operand != "" && (len(state.operands) != 1 || state.operands[0] != tc.operand) {
|
||||||
|
t.Fatalf("operands=%v", state.operands)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
target := completionOptionTarget("-vc=", flags, lookup, nil)
|
||||||
|
if target.kind != completionTargetValue || target.prefix != "-vc=" || target.valuePrefix != "" {
|
||||||
|
t.Fatalf("partial target = %#v", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
16
doc.go
16
doc.go
@@ -1,8 +1,10 @@
|
|||||||
// Package commandkit provides the core instance-owned CLI dispatcher, typed
|
// Package commandkit helps Go applications build consistent command-line
|
||||||
// command declarations, application-owned command and flag aliases, input
|
// interfaces with typed flags and arguments, help, and shared output formatting.
|
||||||
// bindings, invocation-scoped reporting, and outcomes with separate operation
|
// Define commands with NewCommand and install them with New or App.Register.
|
||||||
// causes and presentation observations. New and App.Register accept canonical
|
// Commands and command or flag aliases can be installed together.
|
||||||
// Command declarations and RoutingRule declarations together. Optional
|
//
|
||||||
// integration modules provide ConfigKit and PromptKit-specific commands;
|
// App.Execute runs a command and returns an Outcome; the application decides
|
||||||
// importing those modules does not install their commands.
|
// when to exit. Handlers use Invocation.Reporter for output and messages.
|
||||||
|
// Optional ckconfig and ckprompt modules add inspection and testing commands;
|
||||||
|
// applications install those commands explicitly.
|
||||||
package commandkit
|
package commandkit
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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).
|
|
||||||
68
docs/adr/0002-public-facade-and-cli-backend.md
Normal file
68
docs/adr/0002-public-facade-and-cli-backend.md
Normal 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.
|
||||||
61
docs/adr/0003-configuration-scopes.md
Normal file
61
docs/adr/0003-configuration-scopes.md
Normal 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.
|
||||||
@@ -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.
|
|
||||||
71
docs/adr/0004-optional-integration-modules.md
Normal file
71
docs/adr/0004-optional-integration-modules.md
Normal 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.
|
||||||
@@ -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.
|
|
||||||
65
docs/adr/0005-application-alias-routing.md
Normal file
65
docs/adr/0005-application-alias-routing.md
Normal 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.
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
80
docs/adr/0006-invocation-reporting.md
Normal file
80
docs/adr/0006-invocation-reporting.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# ADR 0006: Share invocation reporting while preserving application outcomes
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
The structured-report formatting extension is implemented; its adoption record
|
||||||
|
is preserved in [Git history](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/2f43f78f76439f5d11ef313cd379bec036543504/docs/roadmap/promptkit-v0.20.0.md#shared-core-report-output-prerequisite).
|
||||||
|
The future-tense implementation and roadmap references in the decision and
|
||||||
|
consequences below are historical. Current contracts are owned by the
|
||||||
|
[reporting GoDoc](../../report.go) and [CLI reference](../cli.md#reporting).
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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).
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
14
docs/adr/README.md
Normal 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 |
|
||||||
1010
docs/cli.md
1010
docs/cli.md
File diff suppressed because it is too large
Load Diff
@@ -1,39 +1,50 @@
|
|||||||
# Consumer guide
|
# Consumer guide
|
||||||
|
|
||||||
CommandKit is distributed as three Go modules. Start with the core and add an
|
Start with core to build commands with typed inputs and shared output
|
||||||
integration module only when the application uses that library. Core and
|
formatting. Add an integration when your application uses ConfigKit or
|
||||||
compatible integration command factories return canonical `commandkit.Command` values.
|
PromptKit. The [core command example](#core-commands) below shows the basic
|
||||||
Applications may install those alongside application-owned `RoutingRule`
|
API; the [maintained examples](../../README.md#examples) show complete
|
||||||
declarations through `commandkit.New` or `App.Register`; routing rules are
|
applications.
|
||||||
installed at the application root, not as group children.
|
|
||||||
|
|
||||||
## Module selection
|
## Module selection
|
||||||
|
|
||||||
| Import | Use when |
|
| Import | Use when |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `go.maximumdirect.net/commandkit` | The application needs typed command declarations and common CLI behavior. |
|
| `go.maximumdirect.net/commandkit` | You need commands, typed inputs, help, and common output handling. |
|
||||||
| `go.maximumdirect.net/ckconfig` | The application wants ConfigKit-backed configuration scopes and reports. |
|
| `go.maximumdirect.net/ckconfig` | You want operators to view, validate, explain, and compare ConfigKit configuration. |
|
||||||
| `go.maximumdirect.net/ckprompt` | The application wants PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, request validation, or native smoke testing. |
|
| `go.maximumdirect.net/ckprompt` | You want operators to inspect PromptKit resources, check and preview requests, or run smoke tests. |
|
||||||
|
|
||||||
The published matched releases are core v0.3.0, ckconfig v0.2.0, and ckprompt v0.2.0.
|
The following lists the current release set and earlier module combinations:
|
||||||
The local ckconfig v0.3.0 candidate uses ConfigKit v1.1.0 and does not compose
|
|
||||||
with ckprompt v0.2.0, which retains ckconfig v0.2.0 and ConfigKit v0.8.0.
|
|
||||||
Update application imports and dependency versions together; old and new core
|
|
||||||
paths have distinct Go type identities. Install core with
|
|
||||||
`go get go.maximumdirect.net/commandkit@v0.3.0` and either integration with
|
|
||||||
`go get go.maximumdirect.net/ckconfig@v0.2.0` or
|
|
||||||
`go get go.maximumdirect.net/ckprompt@v0.2.0`.
|
|
||||||
|
|
||||||
The two integrations are independent optional imports. `ckprompt` uses the
|
| Selection | Core | ckconfig | ckprompt | Upstream libraries and status |
|
||||||
public scope and error contracts from `ckconfig`; importing it does not add the
|
| --- | --- | --- | --- | --- |
|
||||||
`config` family. An application that wants both families passes both returned
|
| Published core | v0.5.0 | — | — | Usable independently. |
|
||||||
declarations to core. The [maintained examples](../../README.md#examples) show
|
| ConfigKit integration | v0.5.0 | v0.5.0 | — | ConfigKit v1.1.0. |
|
||||||
core-only, ConfigKit-only, and composed applications.
|
| PromptKit integration | v0.5.0 | v0.5.0 | v0.5.0 | ConfigKit v1.1.0 and PromptKit v0.20.0. |
|
||||||
|
| Earlier matched integration set | v0.3.0 | v0.2.0 | v0.2.0 | ConfigKit v0.8.0 and PromptKit v0.16.0, using their earlier import paths. |
|
||||||
|
|
||||||
|
Install the published core with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get go.maximumdirect.net/commandkit@v0.5.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Select either integration with `go get go.maximumdirect.net/ckconfig@v0.5.0`
|
||||||
|
or `go get go.maximumdirect.net/ckprompt@v0.5.0`. The integrations select core
|
||||||
|
v0.5.0, and ckprompt selects ckconfig v0.5.0, which includes `AdaptDiagnostics`.
|
||||||
|
They build independently against these released dependencies; a local workspace
|
||||||
|
is optional for development. Published ckconfig v0.3.0 also uses ConfigKit v1.1.0
|
||||||
|
and does not compose with ckprompt v0.2.0's earlier ConfigKit dependency.
|
||||||
|
|
||||||
|
Importing a module does not install its commands. Pass commands to
|
||||||
|
`commandkit.New` or `App.Register` explicitly. ckprompt depends on ckconfig's
|
||||||
|
public APIs, but you can install its `promptkit` commands without installing
|
||||||
|
the `config` family.
|
||||||
|
|
||||||
## Go compatibility
|
## Go compatibility
|
||||||
|
|
||||||
Core and ckprompt declare a consumer minimum of Go 1.26.0; the ckconfig
|
Core declares a consumer minimum of Go 1.26.0; both current integration
|
||||||
v0.3.0 candidate requires Go 1.27.0. The root module also
|
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
|
||||||
@@ -51,7 +62,6 @@ default from an explicitly supplied empty, false, or zero value.
|
|||||||
package example
|
package example
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
commandkit "go.maximumdirect.net/commandkit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
@@ -75,8 +85,12 @@ func Greeting() commandkit.Command {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
|
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
|
||||||
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil {
|
greetings := []string{}
|
||||||
return commandkit.Failure("Could not write the greeting.").WithCause(err)
|
for i := 0; i < input.Count.Value; i++ {
|
||||||
|
greetings = append(greetings, "Hello!")
|
||||||
|
}
|
||||||
|
if err := invocation.Reporter().Report(commandkit.Report{Value: greetings}); err != nil {
|
||||||
|
return commandkit.Failure("Could not write the greetings.").WithCause(err)
|
||||||
}
|
}
|
||||||
return commandkit.Success()
|
return commandkit.Success()
|
||||||
},
|
},
|
||||||
@@ -84,6 +98,10 @@ func Greeting() commandkit.Command {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Install `Greeting()` with `commandkit.New(identity, Greeting())`. By default,
|
||||||
|
`greet` prints `Hello!`; `greet --count=2` prints it twice. Add `--format
|
||||||
|
json` to receive a JSON list. A count of zero produces an empty list.
|
||||||
|
|
||||||
The application supplies `context.Context`, argv without the executable name,
|
The application supplies `context.Context`, argv without the executable name,
|
||||||
and output streams to `App.Execute`; it decides how the returned status maps to
|
and output streams to `App.Execute`; it decides how the returned status maps to
|
||||||
process exit. `New` installs generated help/version and caller declarations as
|
process exit. `New` installs generated help/version and caller declarations as
|
||||||
@@ -108,7 +126,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"},
|
||||||
@@ -128,12 +146,49 @@ installation alongside aliases and integration commands.
|
|||||||
|
|
||||||
## Invocation reporting
|
## Invocation reporting
|
||||||
|
|
||||||
`Invocation.Reporter()` supplies the reporter for the current handler. Use
|
Use `Invocation.Reporter().Report` to print a value in the selected output
|
||||||
`Output()` or `RenderOutput()` for substantive results; use `Warn`, `Info`,
|
format. Pass ordinary Go values and, optionally, a `text/template` for text
|
||||||
`Progress`, and `Debug` for categorized messages. Queue a summary with
|
output. Use `Warn`, `Info`, `Progress`, and `Debug` for categorized messages.
|
||||||
`AtEnd().Info` when it belongs after the handler returns. The
|
Queue a summary with `AtEnd().Info` when it belongs after the handler returns.
|
||||||
[CLI reference](../cli.md#reporting) owns flag grammar, visibility, prefixes,
|
Deferred submissions are accepted until finalization begins; later submissions
|
||||||
and status behavior.
|
return `ErrReporterClosed`. The [CLI reference](../cli.md#reporting) owns flag
|
||||||
|
grammar, visibility, prefixes, and status behavior.
|
||||||
|
|
||||||
|
An application can attach a boolean input to the shared `--debug` option and
|
||||||
|
inspect the reporting selection separately. For example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type input struct { debug commandkit.Supplied[bool] }
|
||||||
|
|
||||||
|
command := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||||
|
Name: "run",
|
||||||
|
Flags: []commandkit.Flag[input]{
|
||||||
|
commandkit.BoolFlag(commandkit.FlagMeta{Name: "debug", Short: "d"}, false,
|
||||||
|
func(in *input, value commandkit.Supplied[bool]) { in.debug = value }),
|
||||||
|
},
|
||||||
|
Run: func(inv commandkit.Invocation, in input) commandkit.Outcome {
|
||||||
|
captureArtifacts := in.debug.Value
|
||||||
|
quiet := inv.ReportingMode() == commandkit.ReportingModeQuiet
|
||||||
|
_, _ = captureArtifacts, quiet // Apply application policy here.
|
||||||
|
return commandkit.Success()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
_ = command
|
||||||
|
```
|
||||||
|
|
||||||
|
With `run --debug --quiet`, the input is true and explicitly supplied, while
|
||||||
|
the selected reporting mode is quiet. Application defaults and requiredness
|
||||||
|
affect input, not mode selection; explicit false counts as supplied. Captured
|
||||||
|
bindings retain the usual selection and application behavior. Applications own
|
||||||
|
any artifact creation and other effects. The mode reports selection rather than
|
||||||
|
successful delivery; see [Invocation.ReportingMode](../../identity.go).
|
||||||
|
|
||||||
|
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
|
||||||
@@ -143,31 +198,29 @@ write to their supplied writer only while the callback runs; they must not
|
|||||||
submit another message or start another rendering operation through the same
|
submit another message or start another rendering operation through the same
|
||||||
reporter.
|
reporter.
|
||||||
|
|
||||||
|
For example, a handler can report a count as a small mapping:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
|
if err := invocation.Reporter().Report(commandkit.Report{
|
||||||
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil {
|
Value: map[string]int{"count": input.Count.Value},
|
||||||
return commandkit.Failure("Could not write the greeting.").WithCause(err)
|
}); err != nil {
|
||||||
}
|
return commandkit.Failure("Could not write the count.").WithCause(err)
|
||||||
return commandkit.Success()
|
|
||||||
}
|
}
|
||||||
|
return commandkit.Success()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This uses YAML automatically and supports `--format json` without a separate
|
||||||
|
encoder. Use `Output()` or `RenderOutput()` instead when you deliberately need
|
||||||
|
raw output; `--format` does not reformat those bytes.
|
||||||
|
|
||||||
After `App.Execute`, inspect the returned `Outcome`. Its `Cause` retains the
|
After `App.Execute`, inspect the returned `Outcome`. Its `Cause` retains the
|
||||||
operation error; `Presentation.Output` and `Presentation.Diagnostics` report
|
operation error; `Presentation.Output` and `Presentation.Diagnostics` report
|
||||||
delivery state and the first rendering or write errors for their destinations.
|
delivery state and the first rendering or write errors for their destinations.
|
||||||
CommandKit does not retain a message transcript. Reporter operations for one
|
Joined operation errors support `errors.Is` and `errors.As` where the
|
||||||
invocation are safe concurrently, but concurrent submission order is not
|
underlying errors do. CommandKit does not retain a message transcript.
|
||||||
specified. The caller owns the writers passed to `Execute`; CommandKit does not
|
Reporter operations for one invocation are safe concurrently, but concurrent
|
||||||
close them.
|
submission order is not specified. The caller owns the writers passed to
|
||||||
|
`Execute`; CommandKit does not close them.
|
||||||
### 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
|
||||||
|
|
||||||
@@ -228,21 +281,39 @@ values.
|
|||||||
|
|
||||||
Use `ckconfig.Command(ckconfig.Suite[I, S]{...})` when the application needs
|
Use `ckconfig.Command(ckconfig.Suite[I, S]{...})` when the application needs
|
||||||
multiple named scopes, selector flags, scope-aware input checks, or shared
|
multiple named scopes, selector flags, scope-aware input checks, or shared
|
||||||
acquisition state or a custom comparison provider. A declared `Diff` capability
|
acquisition state or a custom comparison provider. A declared `Diff`
|
||||||
uses `Scope.Compare`; `ckconfig.FromConfigKit` and `AppCommand` provide the
|
capability uses `Scope.Compare`; `ckconfig.FromConfigKit` and `AppCommand`
|
||||||
ConfigKit-backed implementation. Each comparison side gets its own initialized
|
provide the ConfigKit-backed implementation. `Selector.AgainstName` opts a
|
||||||
and checked input, while state is shared by the invocation. Factory results
|
selector into an independent right-side flag. Each comparison side gets its
|
||||||
remain caller-owned, so separate initialization calls do not guarantee distinct
|
own initialized and checked input, with only its supplied selectors applied;
|
||||||
objects. See
|
state is shared by the invocation. Factory results remain caller-owned, so
|
||||||
|
separate initialization calls do not guarantee distinct objects. See
|
||||||
[ckconfig.Suite's GoDoc](../../integrations/ckconfig/contracts.go) for factory
|
[ckconfig.Suite's GoDoc](../../integrations/ckconfig/contracts.go) for factory
|
||||||
timing, omitted-factory zero values (including nil pointers), and ownership.
|
timing, omitted-factory zero values (including nil pointers), and ownership.
|
||||||
The application owns the state type and what it caches. `ckconfig.FromConfigKit`
|
The application owns the state type and what it caches.
|
||||||
adapts a typed ConfigKit loader without calling it during construction.
|
`ckconfig.FromConfigKit` adapts a typed ConfigKit loader without calling it
|
||||||
|
during construction. The bundled comparison adapters call the loader for the
|
||||||
|
left side before the right, retain left diagnostics if right loading fails,
|
||||||
|
and collect diagnostics from both loads before native comparison. Custom
|
||||||
|
comparison callbacks own their loading and diagnostics; loader calls need not
|
||||||
|
reread sources if the application caches them.
|
||||||
|
|
||||||
The `config` family provides scope discovery, validation, 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.
|
||||||
|
Custom explanation providers implement `Explain`; CommandKit renders the
|
||||||
|
acquired evidence. See `EffectiveWriter` and `ExplanationReader` in the
|
||||||
|
[ckconfig GoDoc](../../integrations/ckconfig/contracts.go) for the provider boundary.
|
||||||
|
`ckconfig.AdaptDiagnostics` captures native diagnostic presentation evidence
|
||||||
|
for applications providing their own ConfigKit-backed CLI integration.
|
||||||
|
Set `Scope.Features.Overrides` only when the loader passes
|
||||||
|
`Selection.Overrides` to ConfigKit as part of its normal load. The integration
|
||||||
|
parses CLI records but cannot add them to an already-loaded result. The
|
||||||
|
[CLI reference](../cli.md#configuration-scopes) describes the operator syntax.
|
||||||
|
|
||||||
## PromptKit configuration, definitions, and requirements
|
## PromptKit configuration, definitions, and requirements
|
||||||
|
|
||||||
@@ -250,20 +321,27 @@ 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,
|
### Install resource commands and optional checks
|
||||||
backend, namespace, and collection catalogs; named prompt and profile
|
|
||||||
inspection; and native requirements discovery. Concrete request validation and
|
`ckprompt.FromEngine(loadEngine)` supplies nine callbacks: prompt, profile,
|
||||||
smoke testing are separate capabilities, added explicitly with
|
backend, namespace, and collection catalogs; named prompt, profile, and backend
|
||||||
`ckprompt.WithRequestValidation` and `ckprompt.WithSmokeTesting`. The suite has
|
inspection; and native requirements discovery. Concrete request validation,
|
||||||
ten independently optional callbacks in total. `ckprompt.Command(suite)` creates
|
prepared-request preview, and smoke testing are separate capabilities, added
|
||||||
only operations whose callbacks are present; discovery, validation, and smoke
|
with `ckprompt.WithRequestValidation`, `ckprompt.WithRequestPreparation`, and
|
||||||
testing can be installed independently. A composed suite can enable both
|
`ckprompt.WithSmokeTesting`. The suite has twelve independently optional
|
||||||
additional operations:
|
callbacks in total. `ckprompt.Command(suite)` creates only operations whose
|
||||||
|
callbacks are present, with canonical flags. `ckprompt.Declarations(suite)`
|
||||||
|
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
|
||||||
@@ -282,6 +360,8 @@ suite := ckprompt.WithRequestValidation(
|
|||||||
validationCommand := ckprompt.Command(suite)
|
validationCommand := ckprompt.Command(suite)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Request builders and loading
|
||||||
|
|
||||||
If the loader is nil, the returned suite retains a wiring issue for installation
|
If the loader is nil, the returned suite retains a wiring issue for installation
|
||||||
to report, even if capabilities are narrowed later. A builder runs before the
|
to report, even if capabilities are narrowed later. A builder runs before the
|
||||||
engine loader and receives the seed request, selection, typed input, shared
|
engine loader and receives the seed request, selection, typed input, shared
|
||||||
@@ -293,7 +373,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:
|
||||||
|
|
||||||
@@ -301,11 +381,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
|
||||||
@@ -318,6 +399,26 @@ 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.
|
||||||
|
|
||||||
|
### Enable namespace flags
|
||||||
|
|
||||||
|
`ckprompt.WithNamespaces` enables standard resource namespace selection from
|
||||||
|
application-supplied defaults. Loaders used with this option should leave view
|
||||||
|
selection to the adapter. Without it, a loader can supply its own engine view.
|
||||||
|
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
|
||||||
@@ -338,6 +439,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
|
||||||
@@ -350,24 +473,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 for ckprompt v0.2.0](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).
|
||||||
|
|
||||||
## ConfigKit v1.1 migration
|
|
||||||
|
|
||||||
The ckconfig v0.3.0 candidate uses `go.maximumdirect.net/configkit v1.1.0`
|
|
||||||
and Go 1.27.0. Consumers must migrate ConfigKit imports and values passed to
|
|
||||||
ckconfig: the old and new package paths have distinct type identities. Source
|
|
||||||
reports rename `destination_unresolved` to `destination_is_fallback` and
|
|
||||||
`version_check` to `version_evidence`, and add `empty_import_selections`.
|
|
||||||
|
|
||||||
The upstream [v0.9 migration notes](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/docs/releases/v0.9.0.md),
|
|
||||||
[v0.10 notes](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/docs/releases/v0.10.0.md),
|
|
||||||
[v1.0 migration notes](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/docs/releases/v1.0.0.md),
|
|
||||||
and [v1.1 notes](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/docs/releases/v1.1.0.md)
|
|
||||||
cover source-root `configkit` controls becoming `config`, import requiredness
|
|
||||||
and defaults, collection registration and operations, removed APIs, and CLI
|
|
||||||
assignment order.
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ 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 unfinished work and
|
[ADR 0005](adr/0005-application-alias-routing.md). Keep unfinished work and
|
||||||
capability proposals in [future work](roadmap/future.md).
|
capability proposals in [future work](roadmap/future.md).
|
||||||
|
|
||||||
## Initial orientation
|
## Initial orientation
|
||||||
@@ -24,7 +24,7 @@ capability proposals in [future work](roadmap/future.md).
|
|||||||
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,14 +54,15 @@ capability proposals in [future work](roadmap/future.md).
|
|||||||
| 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, 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 |
|
||||||
| Unfinished work and capability proposals | [Future work](roadmap/future.md) |
|
| Unfinished work and capability proposals | [Future work](roadmap/future.md) |
|
||||||
|
| Completed documentation refresh | [Retired refresh record](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/5c960532552037d38119941389e558f78bf73aad/docs/roadmap/documentation-refresh.md) |
|
||||||
| Completed planning and implementation history | [Completed dependency upgrades, PromptKit validation, discovery, smoke testing, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/a6278a6d9f940948ec7745955d745523aaff0734/docs/roadmap), [earlier ConfigKit and PromptKit adoption and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap), [earlier retired records](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap), and [completed module implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/bb2528532b78d30e52f5082e491a5e1fd9b0fb99/docs/roadmap) in Git history |
|
| Completed planning and implementation history | [Completed dependency upgrades, PromptKit validation, discovery, smoke testing, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/a6278a6d9f940948ec7745955d745523aaff0734/docs/roadmap), [earlier ConfigKit and PromptKit adoption and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap), [earlier retired records](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap), and [completed module implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/bb2528532b78d30e52f5082e491a5e1fd9b0fb99/docs/roadmap) in Git history |
|
||||||
| Release tooling | [Maintainer release procedure](release.md) |
|
| Release tooling | [Maintainer release procedure](release.md) |
|
||||||
| Performance maintenance | [Internal overview](internal/overview.md), existing benchmarks, and [possible further efficiency work](roadmap/future.md#possible-further-efficiency-work) |
|
| Performance maintenance | [Internal overview](internal/overview.md), existing benchmarks, and [possible further efficiency work](roadmap/future.md#possible-further-efficiency-work) |
|
||||||
@@ -72,13 +73,20 @@ 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 [ConfigKit adoption, reporting, release-tooling, and remediation records](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap),
|
including [shared debug binding and reporting-mode implementation](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/56e3b013f9f83c9c9494f11602e802ce5ed8814e/docs/roadmap),
|
||||||
|
[audit remediation and its declaration-path follow-up](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/aac0e11ae67c78a35745ed9a25688aa51c15e793/docs/roadmap),
|
||||||
|
[shared reporting and PromptKit v0.20.0 implementation](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/2f43f78f76439f5d11ef313cd379bec036543504/docs/roadmap),
|
||||||
|
[PromptKit v0.18.0 adoption and implementation](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/540e2359d66d604a563df0be1c9fc961588f5b9c/docs/roadmap),
|
||||||
|
[ConfigKit adoption, reporting, release-tooling, and remediation records](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap),
|
||||||
[application-owned alias planning, implementation, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/f42aefe02d84e154855ac922173e6f367180929f/docs/roadmap)
|
[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
|
||||||
[future work record](roadmap/future.md) preserves unfinished work and unselected
|
[future work record](roadmap/future.md) preserves unfinished work and unselected
|
||||||
proposals separately from current contracts.
|
proposals separately from current contracts.
|
||||||
|
|
||||||
|
The [ckprompt release errata](../integrations/ckprompt/docs/releases/errata.md)
|
||||||
|
corrects navigation in a historical release note.
|
||||||
|
|
||||||
## Documentation validation
|
## Documentation validation
|
||||||
|
|
||||||
For current prose work, inspect relevant diffs, check local links and fragment
|
For current prose work, inspect relevant diffs, check local links and fragment
|
||||||
@@ -100,13 +108,29 @@ Sibling-checkout references in retired planning records are historical review
|
|||||||
evidence, not build dependencies. Implemented contracts belong in CommandKit
|
evidence, not build dependencies. Implemented contracts belong in CommandKit
|
||||||
under their canonical owners.
|
under their canonical owners.
|
||||||
|
|
||||||
The root module does not traverse nested modules. Preserve the ignored `go.work`
|
The root module does not traverse nested modules. With Go 1.27.0 on `PATH`,
|
||||||
and `go.work.sum`; use `GOWORK=off GOTOOLCHAIN=local` for dependency preparation,
|
create the local workspace from the repository root if `go.work` is absent:
|
||||||
module suites, and examples during the ckconfig migration. Local ckprompt uses
|
|
||||||
the old ConfigKit identity and cannot compose with migrated local ckconfig. Its
|
```sh
|
||||||
independent suite and composed example use published ckconfig v0.2.0; restore
|
go work init . ./integrations/ckconfig ./integrations/ckprompt
|
||||||
full local workspace guidance after the coordinated ckprompt migration.
|
```
|
||||||
Run `go test ./...` separately from each module directory.
|
|
||||||
|
If a workspace already exists, ensure it includes these three modules. Preserve
|
||||||
|
the ignored `go.work` and `go.work.sum`; do not commit them. Use
|
||||||
|
`GOWORK=off GOTOOLCHAIN=local` for dependency preparation
|
||||||
|
and independent module suites. Both integrations select released core v0.5.0;
|
||||||
|
ckprompt also selects released ckconfig v0.5.0. Their declared dependencies
|
||||||
|
support independent builds. The aligned workspace exercises the local sources
|
||||||
|
together.
|
||||||
|
Run `GOWORK=off GOTOOLCHAIN=local go test ./...` separately from each module
|
||||||
|
directory. From the repository root, a workspace containing all three modules can
|
||||||
|
also run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GOTOOLCHAIN=local go test ./...
|
||||||
|
GOTOOLCHAIN=local go test ./integrations/ckconfig/...
|
||||||
|
GOTOOLCHAIN=local go test ./integrations/ckprompt/...
|
||||||
|
```
|
||||||
|
|
||||||
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
|
||||||
@@ -126,30 +150,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
|
||||||
GOWORK=off GOTOOLCHAIN=local 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 && GOWORK=off GOTOOLCHAIN=local go run . config show)
|
(cd integrations/ckconfig/examples/simple && GOTOOLCHAIN=local go run . config show)
|
||||||
(cd integrations/ckconfig/examples/simple && GOWORK=off GOTOOLCHAIN=local 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 && GOWORK=off GOTOOLCHAIN=local 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 && GOWORK=off GOTOOLCHAIN=local 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 && GOWORK=off GOTOOLCHAIN=local 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 && GOWORK=off GOTOOLCHAIN=local 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 && GOWORK=off GOTOOLCHAIN=local 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 && GOWORK=off GOTOOLCHAIN=local 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 && GOWORK=off GOTOOLCHAIN=local 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 && GOWORK=off GOTOOLCHAIN=local go run . promptkit list collections)
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit list collections)
|
||||||
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local 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 && GOWORK=off GOTOOLCHAIN=local 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 && GOWORK=off GOTOOLCHAIN=local 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 && GOWORK=off GOTOOLCHAIN=local 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 && GOWORK=off GOTOOLCHAIN=local 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 && GOWORK=off GOTOOLCHAIN=local 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 && GOWORK=off GOTOOLCHAIN=local 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 && GOWORK=off GOTOOLCHAIN=local 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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -38,6 +38,12 @@ collected parser events through that selection. The selected mode therefore
|
|||||||
applies to command execution, help, and version; routing and parsing failures
|
applies to command execution, help, and version; routing and parsing failures
|
||||||
remain visible in every mode.
|
remain visible in every mode.
|
||||||
|
|
||||||
|
An effective flag view combines a shared application debug binding with the
|
||||||
|
core declaration for parsing, help, and completion, while preserving the
|
||||||
|
application binding's position. The same occurrence stream selects reporting
|
||||||
|
mode independently of the prepared application value. The invocation's mode
|
||||||
|
accessor reads the reporter's selection under its existing state mutex.
|
||||||
|
|
||||||
The reporter owns invocation-scoped visibility, message formatting, managed
|
The reporter owns invocation-scoped visibility, message formatting, managed
|
||||||
report rendering, delivery observations, and finalization. It presents queued
|
report rendering, delivery observations, and finalization. It presents queued
|
||||||
messages after the handler returns, then a failed outcome's explanation, and
|
messages after the handler returns, then a failed outcome's explanation, and
|
||||||
@@ -76,6 +82,8 @@ rendering. A session owns the mutable backend tree for one invocation; parsing
|
|||||||
and presentation reuse that session. The adapter returns raw values and neutral
|
and presentation reuse that session. The adapter returns raw values and neutral
|
||||||
syntax failures to core. It does not own the registry, typed input preparation,
|
syntax failures to core. It does not own the registry, typed input preparation,
|
||||||
integration declarations, provider acquisition, or native report rendering.
|
integration declarations, provider acquisition, or native report rendering.
|
||||||
|
Parser options carry flag-alias selection; invocation context and streams stay
|
||||||
|
with core, while backend output destinations discard and help uses a local buffer.
|
||||||
Cobra and pflag imports remain inside this package.
|
Cobra and pflag imports remain inside this package.
|
||||||
|
|
||||||
## Optional modules
|
## Optional modules
|
||||||
@@ -90,8 +98,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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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. The [CLI reference](../cli.md#reporting) describes the
|
||||||
|
implemented structured-report behavior.
|
||||||
|
|
||||||
|
For that shared report handoff, core owns format selection, serialization,
|
||||||
|
template execution, and delivery. Applications and integrations select logical
|
||||||
|
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#output-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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.6
|
**Base Policy Version:** 1.6
|
||||||
|
|
||||||
**Last Updated**: 2026-10-11
|
**Last Updated**: 2026-11-08
|
||||||
|
|
||||||
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
||||||
|
|
||||||
@@ -96,6 +96,22 @@ Additional compatibility promises and deliberate exceptions belong in the
|
|||||||
adopted policy or canonical contract documentation. Release notes explain
|
adopted policy or canonical contract documentation. Release notes explain
|
||||||
changes; they do not redefine current compatibility contracts.
|
changes; they do not redefine current compatibility contracts.
|
||||||
|
|
||||||
|
### CommandKit downstream dependency alignment
|
||||||
|
|
||||||
|
Each new core or integration release requires dependency updates and new releases
|
||||||
|
of all downstream CommandKit-owned integration modules, following direct and
|
||||||
|
transitive dependencies. Each affected integration advances at least its minor
|
||||||
|
version to incorporate the dependency updates, even when the initiating release
|
||||||
|
is a patch. Use a major release where the compatibility rules above require it.
|
||||||
|
Versions remain independent and need not match across modules.
|
||||||
|
|
||||||
|
A module with no downstream CommandKit integrations may release independently;
|
||||||
|
its release does not require new versions of its dependencies. This policy does
|
||||||
|
not require releases of external consuming applications. The
|
||||||
|
[runbook](../release.md#scope-and-release-order) owns dependency-order sequencing.
|
||||||
|
Downstream releases are required follow-up work, not additional completion gates
|
||||||
|
for an already published source release.
|
||||||
|
|
||||||
## Consumer Minimum And Release Toolchain
|
## Consumer Minimum And Release Toolchain
|
||||||
|
|
||||||
Distinguish the minimum toolchain supported for consumers from the toolchain
|
Distinguish the minimum toolchain supported for consumers from the toolchain
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -9,11 +9,28 @@ Gitea release page; there are no binaries,
|
|||||||
checksums, or other delivery assets. CI, module proxy availability, and consumer
|
checksums, or other delivery assets. CI, module proxy availability, and consumer
|
||||||
installation are not completion gates.
|
installation are not completion gates.
|
||||||
|
|
||||||
Publish in dependency order: core, then `ckconfig`, then `ckprompt`. The
|
Plan the initiating release and all downstream integration releases required by
|
||||||
integration modules require the preceding CommandKit releases, so prepare and
|
the [dependency-alignment policy](policy/release.md#commandkit-downstream-dependency-alignment).
|
||||||
publish each module only after its CommandKit dependencies are available. A root
|
That policy sets the minimum version bump for affected integrations; select
|
||||||
release runs only the root module's default suite; it need not run suites for
|
each module's version independently according to its compatibility impact.
|
||||||
independently released integrations.
|
|
||||||
|
| Initiating release | Required downstream releases, in order |
|
||||||
|
| --- | --- |
|
||||||
|
| Core | `ckconfig`, then `ckprompt` |
|
||||||
|
| `ckconfig` | `ckprompt` |
|
||||||
|
| `ckprompt` | None with the current dependency graph |
|
||||||
|
|
||||||
|
As integrations are added, follow their actual dependencies to include every
|
||||||
|
affected downstream integration. Publish dependencies before their consumers.
|
||||||
|
Prepare each integration after its updated CommandKit dependencies are published,
|
||||||
|
incorporating all of those versions in one candidate. For a core update, release
|
||||||
|
`ckprompt` once after both core and `ckconfig`; it does not need separate releases
|
||||||
|
for each dependency bump. Unaffected dependencies do not need new releases.
|
||||||
|
|
||||||
|
Track any remaining downstream releases as pending coordination work. Each
|
||||||
|
successful source-tag push still completes that module's release; a downstream
|
||||||
|
failure does not undo it or authorize replacing its tag. A root release runs
|
||||||
|
only the root module's default suite; each downstream release runs its own suite.
|
||||||
|
|
||||||
Use branch `main`, remote `origin`, and expected effective push endpoint
|
Use branch `main`, remote `origin`, and expected effective push endpoint
|
||||||
`ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git`. The selected
|
`ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git`. The selected
|
||||||
@@ -35,8 +52,8 @@ module metadata.
|
|||||||
|
|
||||||
## Release toolchain
|
## Release toolchain
|
||||||
|
|
||||||
The selected release toolchain is **Go 1.27.0**. Core and ckprompt declare a
|
The selected release toolchain is **Go 1.27.0**. Core declares a
|
||||||
consumer minimum of **Go 1.26.0**; ckconfig requires **Go 1.27.0**. Maintainers
|
consumer minimum of **Go 1.26.0**; both integrations require **Go 1.27.0**. Maintainers
|
||||||
use Go 1.27.0 for dependency preparation, candidate validation, and ReleaseKit
|
use Go 1.27.0 for dependency preparation, candidate validation, and ReleaseKit
|
||||||
execution. The
|
execution. The
|
||||||
[consumer guide](consumers/guide.md#go-compatibility) and
|
[consumer guide](consumers/guide.md#go-compatibility) and
|
||||||
@@ -98,12 +115,38 @@ standard offline suite and ReleaseKit’s candidate/note and publication guards.
|
|||||||
## Prepare and publish one candidate
|
## Prepare and publish one candidate
|
||||||
|
|
||||||
Prepare dependencies in the selected module before the candidate is finalized.
|
Prepare dependencies in the selected module before the candidate is finalized.
|
||||||
Do this after the preceding CommandKit module has been published and before the
|
Do this after any CommandKit dependency releases required by the coordinated
|
||||||
selected candidate suite is run. For `core`, run the command at the repository
|
update have been published and before the selected candidate suite is run.
|
||||||
root; for integrations, run it from that module directory. Commit any resulting
|
For `core`, run the command at the repository root; for integrations, run it
|
||||||
`go.mod` or `go.sum` changes with the release candidate. The sum files must
|
from that module directory.
|
||||||
reflect dependencies actually downloaded for that module; do not fabricate
|
|
||||||
future sums.
|
Before downloading dependencies, update each integration to the newly published
|
||||||
|
CommandKit versions. `go mod download` downloads the versions already selected;
|
||||||
|
it does not upgrade requirements. Replace the version placeholders below with
|
||||||
|
the actual published versions, which may differ between modules. When a
|
||||||
|
dependency is not being released in this update, use its current selected
|
||||||
|
release; a `ckconfig`-initiated update does not require a new core release:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# After publishing core, prepare ckconfig from its module directory.
|
||||||
|
GOWORK=off GOTOOLCHAIN=local go get go.maximumdirect.net/commandkit@vCORE_VERSION
|
||||||
|
|
||||||
|
# After publishing ckconfig, prepare ckprompt from its module directory.
|
||||||
|
GOWORK=off GOTOOLCHAIN=local go get \
|
||||||
|
go.maximumdirect.net/commandkit@vCORE_VERSION \
|
||||||
|
go.maximumdirect.net/ckconfig@vCKCONFIG_VERSION
|
||||||
|
```
|
||||||
|
|
||||||
|
Run only the command for the integration being prepared. ckprompt needs a
|
||||||
|
ckconfig release containing `AdaptDiagnostics`; v0.5.0 contains it, while v0.4.0
|
||||||
|
does not. Validate the integration with the workspace disabled against its
|
||||||
|
updated requirements, using the selected module's release validation below.
|
||||||
|
An aligned workspace can hide an outdated dependency.
|
||||||
|
|
||||||
|
Review and commit the resulting `go.mod` and `go.sum` changes with the release
|
||||||
|
candidate, and make its release note agree with the selected versions. The sum
|
||||||
|
files must reflect dependencies actually downloaded for that module; do not
|
||||||
|
fabricate future sums.
|
||||||
|
|
||||||
Then commit the selected module's release note and all intended candidate
|
Then commit the selected module's release note and all intended candidate
|
||||||
changes on `main`, and publish the candidate commit to `origin`. Set one
|
changes on `main`, and publish the candidate commit to `origin`. Set one
|
||||||
|
|||||||
32
docs/releases/v0.4.0.md
Normal file
32
docs/releases/v0.4.0.md
Normal 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)
|
||||||
43
docs/releases/v0.5.0.md
Normal file
43
docs/releases/v0.5.0.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# CommandKit v0.5.0
|
||||||
|
|
||||||
|
CommandKit v0.5.0 fixes lost deferred messages and preserves YAML value types
|
||||||
|
more accurately. It also reduces repeated work when installing commands and
|
||||||
|
showing alias help, and refreshes documentation and help text.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Deferred messages accepted before finalization are retained for final reporting;
|
||||||
|
they can no longer be silently lost when a handler finishes. Later submissions return
|
||||||
|
`ErrReporterClosed`.
|
||||||
|
- Finished invocations release their direct references to output and diagnostic
|
||||||
|
writers while keeping delivery results available. Applications still own the
|
||||||
|
writers.
|
||||||
|
- `DecodeYAMLReport` preserves numeric-looking strings as strings. YAML report
|
||||||
|
output also preserves explicitly tagged numbers beyond the YAML resolver's
|
||||||
|
ordinary numeric range.
|
||||||
|
- Command installation, alias help, and typed input handling avoid repeated
|
||||||
|
work. Parsing and completion share flag handling while retaining their
|
||||||
|
different treatment of errors and unfinished input.
|
||||||
|
- The reporting example now works when its optional scenario is omitted, and
|
||||||
|
the interactive completion fixture no longer conflicts with `--format`.
|
||||||
|
`FlagMeta` documents existing naming and reserved-spelling rules.
|
||||||
|
- READMEs, CLI instructions, examples, and generated help use clearer language
|
||||||
|
and explain common tasks before implementation details.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
The public core Go API, CLI grammar, and Go 1.26.0 consumer minimum are unchanged.
|
||||||
|
The maintainer release toolchain remains Go 1.27.0.
|
||||||
|
|
||||||
|
Applications using background producers should coordinate deferred submissions
|
||||||
|
before the handler returns and handle `ErrReporterClosed` once finalization
|
||||||
|
begins. See the [invocation reporting guide](../consumers/guide.md#invocation-reporting).
|
||||||
|
|
||||||
|
Callers supplying YAML with numbers beyond the resolver's ordinary range should
|
||||||
|
use explicit `!!int` or `!!float` tags when numeric meaning is intended. Untagged
|
||||||
|
values resolved as strings now retain that type. See
|
||||||
|
[`DecodeYAMLReport`](../../report_value.go) for the conversion contract.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.5.0/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.5.0/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.5.0/docs/oss/attribution.md)
|
||||||
25
docs/releases/v0.6.0.md
Normal file
25
docs/releases/v0.6.0.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# CommandKit v0.6.0
|
||||||
|
|
||||||
|
CommandKit v0.6.0 lets applications bind the shared debug option to typed
|
||||||
|
input and inspect the selected reporting mode during and after an invocation.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- A `BoolFlag` named `debug` attaches to the existing reporting option. Its
|
||||||
|
application default, requiredness, short spelling, and completion hints are
|
||||||
|
retained; help describes the combined option.
|
||||||
|
- `Invocation.ReportingMode` returns the resolved visibility selection, which
|
||||||
|
can differ from the bound debug value when other mode flags are supplied.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
Other reporting flag names remain reserved. Applications choosing a required
|
||||||
|
debug binding require an explicit value; an explicit false satisfies it. A
|
||||||
|
true application default does not enable debug reporting. See the
|
||||||
|
[consumer guide](../consumers/guide.md#invocation-reporting) and
|
||||||
|
[CLI reference](../cli.md#reporting). The core consumer minimum remains Go
|
||||||
|
1.26.0 and the maintainer release toolchain remains Go 1.27.0.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.6.0/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.6.0/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.6.0/docs/oss/attribution.md)
|
||||||
@@ -1,13 +1,24 @@
|
|||||||
# Future work
|
# Future work
|
||||||
|
|
||||||
Status: pending release and integration work, plus future capability proposals.
|
Status: optional shell validation and future capability proposals.
|
||||||
No delivery dates are selected here.
|
No delivery dates are selected here.
|
||||||
|
|
||||||
## Purpose and authority
|
## Purpose and authority
|
||||||
|
|
||||||
Completed roadmaps, implementation plans, and review findings have been retired.
|
Earlier completed roadmaps, implementation plans, and review findings have been retired.
|
||||||
This document preserves unfinished work and unselected ideas under the
|
This document preserves unfinished work and unselected ideas under the
|
||||||
[release policy](../policy/release.md#release-documentation-and-preparation).
|
[release policy](../policy/release.md#release-documentation-and-preparation).
|
||||||
|
The completed shared debug binding and reporting-mode implementation is recorded
|
||||||
|
in its [retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/56e3b013f9f83c9c9494f11602e802ce5ed8814e/docs/roadmap).
|
||||||
|
The completed [documentation refresh](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/5c960532552037d38119941389e558f78bf73aad/docs/roadmap/documentation-refresh.md) remains available in Git history.
|
||||||
|
The completed audits, consolidated remediation, implementation plan, and
|
||||||
|
declaration-path follow-up remain available in the
|
||||||
|
[audit remediation retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/aac0e11ae67c78a35745ed9a25688aa51c15e793/docs/roadmap).
|
||||||
|
The completed PromptKit v0.18.0 adoption roadmap and implementation plan remain
|
||||||
|
available in the
|
||||||
|
[PromptKit retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/540e2359d66d604a563df0be1c9fc961588f5b9c/docs/roadmap).
|
||||||
|
The completed PromptKit v0.20.0 and shared reporting implementation is recorded
|
||||||
|
in its [retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/2f43f78f76439f5d11ef313cd379bec036543504/docs/roadmap).
|
||||||
The completed ConfigKit adoption, reporting, dependency alignment, release-tooling,
|
The completed ConfigKit adoption, reporting, dependency alignment, release-tooling,
|
||||||
and remediation records remain available at the
|
and remediation records remain available at the
|
||||||
[retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap).
|
[retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap).
|
||||||
@@ -19,90 +30,62 @@ govern any future implementation. The [CLI reference](../cli.md), public GoDoc,
|
|||||||
and [consumer guide](../consumers/guide.md) own implemented behavior. Retaining
|
and [consumer guide](../consumers/guide.md) own implemented behavior. Retaining
|
||||||
an idea here does not select its syntax, API, priority, or delivery date.
|
an idea here does not select its syntax, API, priority, or delivery date.
|
||||||
|
|
||||||
## Pending release and integration work
|
## Optional verification
|
||||||
|
|
||||||
### ckconfig release and hosted delivery
|
### Optional shell completion validation
|
||||||
|
|
||||||
The ckconfig v0.3.0 candidate adopts ConfigKit v1.1.0 and Go 1.27.0; its
|
Zsh and Fish interactive completion checks remain unverified because those
|
||||||
implementation and audits are complete. Its
|
executables were unavailable during remediation. Script generation for Bash,
|
||||||
[candidate release note](../../integrations/ckconfig/docs/releases/v0.3.0.md)
|
Zsh and Fish and the Bash interactive checks passed. Run the existing opt-in
|
||||||
is prepared, but source publication and hosted delivery remain pending. Complete
|
backend and consumer checks when those prerequisites are available, following
|
||||||
those operations when requested through the [release procedure](../release.md),
|
the [developer guide](../development.md#shell-completion-validation).
|
||||||
using the existing fresh-version guards and reporting source publication and
|
|
||||||
hosted Gitea delivery separately. Publishing this roadmap cleanup does not
|
|
||||||
publish a module version tag or deliver a release page.
|
|
||||||
|
|
||||||
### ckprompt dependency migration and workspace alignment
|
|
||||||
|
|
||||||
Review the upstream PromptKit vanity release and select compatible ConfigKit
|
|
||||||
and PromptKit dependencies before migrating ckprompt's adapters and maintained
|
|
||||||
composed consumer. The new ckconfig uses vanity ConfigKit types; ckprompt v0.2.0
|
|
||||||
still uses published ckconfig v0.2.0 and old-path ConfigKit v0.8.0. Their local
|
|
||||||
sources do not currently compose in the three-module workspace. See the
|
|
||||||
[consumer guide](../consumers/guide.md#module-selection) for the current module
|
|
||||||
relationship.
|
|
||||||
|
|
||||||
The follow-up includes migrating affected imports and public type boundaries,
|
|
||||||
selecting real released dependency versions and the required Go minimum,
|
|
||||||
updating notices and migration guidance, and validating both ckprompt's
|
|
||||||
independent graph and the aligned composed consumer. Restore three-module
|
|
||||||
workspace guidance when those dependencies align, updating the ignored workspace
|
|
||||||
Go version as needed. Until then, preserve local workspace files and validate
|
|
||||||
with workspace discovery off as described in the [development guide](../development.md).
|
|
||||||
No unreviewed PromptKit version or new compatibility bridge is selected here.
|
|
||||||
|
|
||||||
## Future capability targets
|
## Future capability targets
|
||||||
|
|
||||||
### Comparison extensions
|
### Comparison extensions
|
||||||
|
|
||||||
Independent per-side domain selectors and an optional assertion mode remain
|
General collection-clearing support is deferred pending a concrete consumer need.
|
||||||
unselected extensions to the delivered ConfigKit comparison. Consider them only
|
Revisit standardized syntax and binding support for explicitly replacing a
|
||||||
for a concrete consumer need and a deliberate API/behavior decision under the
|
nonempty application-default selector collection with an empty collection.
|
||||||
relevant ADRs.
|
This is separate from clearing configuration lists through transient
|
||||||
|
overrides and does not reinstate comparison inheritance. No clearing grammar
|
||||||
|
or public helper API is selected.
|
||||||
|
|
||||||
### Configuration inspection extensions
|
### Configuration inspection extensions
|
||||||
|
|
||||||
The [completed ConfigKit v1.1.0 adoption review](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap/configkit-v1.1.0.md#selected-scope-decisions)
|
Persistent configuration editing remains deferred.
|
||||||
retained the following work after dependency and source-report adoption.
|
|
||||||
Metadata inspection is the recommended next functional sprint; the other
|
|
||||||
features have no selected sequence or delivery date. These proposals do not
|
|
||||||
establish command grammar, report schemas, or new provider requirements.
|
|
||||||
ConfigKit's public APIs own the evidence and its semantics.
|
|
||||||
|
|
||||||
- **Optional field metadata, path discovery, and history (OD-3).** Expose native
|
|
||||||
`Metadata.Lookup`, `IsExplicit`, `HistoricalPaths`, and `History` through an
|
|
||||||
optional provider capability. Design field and history reports together,
|
|
||||||
keeping existing providers usable. Distinguish effective paths from removed
|
|
||||||
historical paths: a failed `Lookup` must not suppress available `History`.
|
|
||||||
Validate that distinction and faithful projection of returned field/change
|
|
||||||
records without recreating upstream provenance logic. Historical enumeration
|
|
||||||
is not an exhaustive inventory of possible schema fields. This gives the
|
|
||||||
earlier field-origin inventory proposal a concrete, upstream-backed scope;
|
|
||||||
command names, selectors, and report shapes remain to be designed.
|
|
||||||
- **Failed-load source inventory (OD-4).** Present acquired source evidence from
|
|
||||||
`LoadError.Sources()` while retaining the failed outcome and diagnostics.
|
|
||||||
Design how an adapter returns its source facet on error and how result output
|
|
||||||
precedes terminal failure presentation. Cover acquisition and report-delivery
|
|
||||||
failures together; current diagnostic context remains available meanwhile.
|
|
||||||
- **Detailed comparison (OD-5).** Consider an additional form projecting native
|
|
||||||
`Difference` paths, both snapshots, presence/redaction fields, and
|
|
||||||
`HiddenChanged`. The current comparison retains the reduced
|
|
||||||
additions/changes/deletions report. Decide later whether a detailed form
|
|
||||||
complements it or replaces it through a deliberate compatibility change.
|
|
||||||
- **Structured diagnostics (OD-6).** Consider machine-readable native codes,
|
|
||||||
paths, severities, and locations alongside CommandKit's shared reporting
|
|
||||||
design. Define result/diagnostic channels, warning and terminal-failure
|
|
||||||
interaction, and delivery outcomes. A ckconfig-specific report remains an
|
|
||||||
alternative, with its cross-family alignment considered explicitly.
|
|
||||||
- **Source-ID filtering.** Consider selecting a record already available in the
|
|
||||||
source inventory. Native `Source(id)` lookup is a convenience; this proposal
|
|
||||||
need not introduce another command or require a lookup method from every
|
|
||||||
custom source provider.
|
|
||||||
|
|
||||||
Discovery, import defaults, collection identities, and field-level resolution
|
Discovery, import defaults, collection identities, and field-level resolution
|
||||||
recording remain available through application loaders. Standard inspection of
|
recording remain available through application loaders. Discovery tracing,
|
||||||
resource contexts or secrets-directory bootstrap has no immediate proposal;
|
resource-context inspection, and secrets-directory bootstrap commands remain
|
||||||
those capabilities primarily serve application loading and startup.
|
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
|
||||||
|
|
||||||
|
General-purpose helpers for application-internal PromptKit use remain outside
|
||||||
|
CommandKit's ownership boundary. The following CLI extensions are deferred.
|
||||||
|
|
||||||
|
- **Output-contract override flags.** Deferred from the current adoption. A
|
||||||
|
possible future operator task is validating or previewing one prompt with a
|
||||||
|
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.
|
||||||
|
|
||||||
### Alias completion and discovery
|
### Alias completion and discovery
|
||||||
|
|
||||||
@@ -120,6 +103,9 @@ steps. No delivery date or final API is selected here.
|
|||||||
|
|
||||||
### Reporting extensions
|
### Reporting extensions
|
||||||
|
|
||||||
|
XML is outside current consideration; Markdown and other renderers remain
|
||||||
|
unselected.
|
||||||
|
|
||||||
Error-conversion helpers such as `WarnError` remain deferred pending demonstrated
|
Error-conversion helpers such as `WarnError` remain deferred pending demonstrated
|
||||||
value; applications can already use `report.Warn(err.Error())`. Terminal redraw
|
value; applications can already use `report.Warn(err.Error())`. Terminal redraw
|
||||||
and progress-bar presentation were outside the completed reporting scope and
|
and progress-bar presentation were outside the completed reporting scope and
|
||||||
@@ -144,12 +130,12 @@ candidate checks do not become general prohibitions on development workspaces.
|
|||||||
These capabilities remain outside the delivered scope and require a concrete
|
These capabilities remain outside the delivered scope and require a concrete
|
||||||
operator or consumer need and an appropriate ownership/behavior decision:
|
operator or consumer need and an appropriate ownership/behavior decision:
|
||||||
|
|
||||||
- JSON reports and output-file management or reloadable export.
|
- 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
|
- Shell completion extensions, including alias discovery described above and
|
||||||
dynamic discovery of configured or remote values.
|
dynamic discovery of configured or remote values.
|
||||||
- Configuration mutation and generic value assignment. Field metadata and
|
- Persistent configuration mutation. Transient CLI overrides and field metadata
|
||||||
provenance inspection are scoped under
|
inspection are available through the standard configuration suite.
|
||||||
[configuration inspection extensions](#configuration-inspection-extensions).
|
|
||||||
- Credential diagnostics beyond PromptKit's native smoke checks and additional
|
- Credential diagnostics beyond PromptKit's native smoke checks and additional
|
||||||
live provider probes.
|
live provider probes.
|
||||||
- Additional component identity registration and domain artifact catalogs.
|
- Additional component identity registration and domain artifact catalogs.
|
||||||
@@ -158,8 +144,10 @@ operator or consumer need and an appropriate ownership/behavior decision:
|
|||||||
- Backend selection or replacement, a plugin framework, general dependency
|
- Backend selection or replacement, a plugin framework, general dependency
|
||||||
injection, and concurrent-dispatch guarantees.
|
injection, and concurrent-dispatch guarantees.
|
||||||
|
|
||||||
Execution, rendering, serving, and session operations remain application-owned.
|
Execution, serving, and session operations remain application-owned.
|
||||||
Shared vocabulary is guidance under [ADR 0006](../adr/0006-command-grammar.md).
|
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
|
Backend replacement remains possible behind the accepted facade, subject to the
|
||||||
published API/CLI contracts and deliberate compatibility decisions.
|
published API/CLI contracts and deliberate compatibility decisions.
|
||||||
|
|
||||||
@@ -175,9 +163,6 @@ records are historical evidence, not performance guarantees or release gates.
|
|||||||
|
|
||||||
## Consumer adoption
|
## Consumer adoption
|
||||||
|
|
||||||
Consumer migrations remain separate from library delivery. Applications can
|
CommandKit is in alpha with no consumers. No application adoption work or rollout
|
||||||
replace duplicated standard handlers and routing, then update their CLI docs
|
order is selected. The [consumer guide](../consumers/guide.md) and maintained
|
||||||
and subprocess consumers for intentional changes while retaining domain commands.
|
examples provide integration guidance for future applications.
|
||||||
No migration order or claim about individual applications' adoption is recorded
|
|
||||||
here; the [consumer guide](../consumers/guide.md) and maintained examples provide
|
|
||||||
integration guidance.
|
|
||||||
|
|||||||
@@ -1,38 +1,70 @@
|
|||||||
# Core example
|
# Core example
|
||||||
|
|
||||||
Run the example from the repository root:
|
This example shows typed flags, command shortcuts, shared reporting, and shell
|
||||||
|
completion. It needs no external service. See the [consumer guide](../../docs/consumers/guide.md#go-compatibility)
|
||||||
|
for Go requirements; maintainers use the toolchain in the
|
||||||
|
[developer guide](../../docs/development.md#documentation-validation).
|
||||||
|
|
||||||
|
Run from the repository root, with that Go toolchain on `PATH`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
export GOWORK=off GOTOOLCHAIN=local
|
||||||
|
go run ./examples/core workspace greet --count=2
|
||||||
|
```
|
||||||
|
|
||||||
|
The result shows the selected count and whether it was supplied. It also shows
|
||||||
|
the entered command path and the original command path, which differ when you
|
||||||
|
use a shortcut.
|
||||||
|
|
||||||
|
## Try shortcuts and explicit values
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# The hello shortcut runs workspace greet; --times means --count.
|
||||||
go run ./examples/core hello --times=0 --tag=first
|
go run ./examples/core hello --times=0 --tag=first
|
||||||
|
# ws is a shortcut for the workspace group.
|
||||||
go run ./examples/core ws greet --count=2
|
go run ./examples/core ws greet --count=2
|
||||||
|
# Inspect the group or use the example's global help alias.
|
||||||
go run ./examples/core ws --help
|
go run ./examples/core ws --help
|
||||||
go run ./examples/core hello --hlep
|
go run ./examples/core hello --hlep
|
||||||
|
```
|
||||||
|
|
||||||
|
An explicit zero remains a supplied value. The handler prints both
|
||||||
|
`Invocation.EnteredPath()` and `Invocation.CanonicalPath()` so you can see how
|
||||||
|
shortcuts resolve.
|
||||||
|
|
||||||
|
## Try reporting
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./examples/core report
|
||||||
|
go run ./examples/core report --verbose
|
||||||
|
go run ./examples/core report --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
The default scenario succeeds and writes its result. Verbose mode also shows
|
||||||
|
warnings and information; quiet mode hides the result. The
|
||||||
|
[CLI reference](../../docs/cli.md#reporting) defines the shared visibility rules.
|
||||||
|
|
||||||
|
## Try shell completion
|
||||||
|
|
||||||
|
```sh
|
||||||
go build -o core-example ./examples/core
|
go build -o core-example ./examples/core
|
||||||
./core-example completion bash
|
./core-example completion bash
|
||||||
```
|
```
|
||||||
|
|
||||||
The example installs a canonical `workspace greet` command together with a
|
This prints the Bash completion script. Initialize `bash-completion`, then load
|
||||||
command shortcut, a group shortcut, a scoped flag alias, and a global help
|
the script as described in the [CLI reference](../../docs/cli.md#shell-completion).
|
||||||
alias. Its declaration slice mixes the canonical group and routing rules as
|
Use the executable basename `core-example` and try completing
|
||||||
`[]commandkit.Declaration`. The handler prints `Invocation.EnteredPath()` and
|
`core-example report d` or `core-example ws gr`. Other supported shells are
|
||||||
`Invocation.CanonicalPath()` so the alternate route and its canonical target
|
listed in the reference.
|
||||||
are visible in the output.
|
|
||||||
|
|
||||||
The example also installs `Completion("core-example")` and gives the `report`
|
## How it is wired
|
||||||
scenario argument static choices. Initialize `bash-completion` first if using
|
|
||||||
Bash. Build it with the basename shown above, load
|
|
||||||
the script for your shell using the [CLI reference](../../docs/cli.md#shell-completion),
|
|
||||||
and try completing `core-example report d` or `core-example ws gr`.
|
|
||||||
|
|
||||||
Compatible integration factories also return `commandkit.Command` and can be
|
The example installs `workspace greet` alongside command and flag aliases in a
|
||||||
passed directly to `New` beside `commandkit.Alias` or flag-alias declarations.
|
`[]commandkit.Declaration`. It also installs `Completion("core-example")` and
|
||||||
The matched ckconfig and ckprompt releases use the new core identity. For a
|
provides static choices for the reporting scenario argument. The
|
||||||
command-only `[]commandkit.Command`, convert its elements into a
|
|
||||||
`[]commandkit.Declaration` before appending routing rules; Go cannot expand the
|
|
||||||
command slice as `...commandkit.Declaration`. The
|
|
||||||
[consumer guide](../../docs/consumers/guide.md#application-owned-aliases)
|
[consumer guide](../../docs/consumers/guide.md#application-owned-aliases)
|
||||||
shows both forms, and the [composed consumer](../../integrations/ckprompt/examples/composed)
|
explains declaration composition; the [composed example](../../integrations/ckprompt/examples/composed)
|
||||||
is the maintained integration example.
|
adds integration commands.
|
||||||
|
|
||||||
These aliases require core v0.3.0. Repository contributors can run its test with
|
Run `GOWORK=off GOTOOLCHAIN=local go test ./examples/core` from the repository
|
||||||
`go test ./examples/core` from the root.
|
root to check this example.
|
||||||
|
|||||||
@@ -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()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -67,7 +74,11 @@ func newApp() (*commandkit.App, error) {
|
|||||||
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "scenario", Usage: "demo, recover, warn, warn-error, fail, or partial.", Completion: commandkit.CompleteChoices(
|
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "scenario", Usage: "demo, recover, warn, warn-error, fail, or partial.", Completion: commandkit.CompleteChoices(
|
||||||
commandkit.CompletionChoice{Value: "demo"}, commandkit.CompletionChoice{Value: "recover"}, commandkit.CompletionChoice{Value: "warn"},
|
commandkit.CompletionChoice{Value: "demo"}, commandkit.CompletionChoice{Value: "recover"}, commandkit.CompletionChoice{Value: "warn"},
|
||||||
commandkit.CompletionChoice{Value: "warn-error"}, commandkit.CompletionChoice{Value: "fail"}, commandkit.CompletionChoice{Value: "partial"})}, false, func(value string) (string, error) { return value, nil },
|
commandkit.CompletionChoice{Value: "warn-error"}, commandkit.CompletionChoice{Value: "fail"}, commandkit.CompletionChoice{Value: "partial"})}, false, func(value string) (string, error) { return value, nil },
|
||||||
func(input *reportInput, value commandkit.Supplied[string]) { input.Scenario = value.Value }),
|
func(input *reportInput, value commandkit.Supplied[string]) {
|
||||||
|
if value.Set {
|
||||||
|
input.Scenario = value.Value
|
||||||
|
}
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
CheckInput: func(input reportInput) error {
|
CheckInput: func(input reportInput) error {
|
||||||
switch input.Scenario {
|
switch input.Scenario {
|
||||||
|
|||||||
16
execute.go
16
execute.go
@@ -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 {
|
||||||
@@ -80,10 +80,8 @@ func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outco
|
|||||||
type execution struct {
|
type execution struct {
|
||||||
app *App
|
app *App
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
streams Streams
|
|
||||||
reporter *Reporter
|
reporter *Reporter
|
||||||
debugEvents []string
|
debugEvents []string
|
||||||
options commandmeta.ParseOptions
|
|
||||||
snapshot *routingSnapshot
|
snapshot *routingSnapshot
|
||||||
session *internalcobra.Session
|
session *internalcobra.Session
|
||||||
route routeResult
|
route routeResult
|
||||||
@@ -107,6 +105,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) },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,13 +114,10 @@ func (current commandExecution) renderHelpTargets(targets []string) Outcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newExecution(app *App, ctx context.Context, streams Streams) *execution {
|
func newExecution(app *App, ctx context.Context, streams Streams) *execution {
|
||||||
streams = normalizeStreams(streams)
|
|
||||||
current := &execution{
|
current := &execution{
|
||||||
app: app,
|
app: app,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
streams: streams,
|
|
||||||
reporter: newReporter(streams),
|
reporter: newReporter(streams),
|
||||||
options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
|
|
||||||
}
|
}
|
||||||
if app != nil {
|
if app != nil {
|
||||||
current.snapshot = app.routeSnapshot()
|
current.snapshot = app.routeSnapshot()
|
||||||
@@ -146,7 +142,7 @@ func (current *execution) prepareSession() {
|
|||||||
if current.snapshot == nil {
|
if current.snapshot == nil {
|
||||||
current.snapshot = current.app.routeSnapshot()
|
current.snapshot = current.app.routeSnapshot()
|
||||||
}
|
}
|
||||||
options := current.options
|
options := commandmeta.ParseOptions{}
|
||||||
if current.snapshot != nil {
|
if current.snapshot != nil {
|
||||||
switch current.route.kind {
|
switch current.route.kind {
|
||||||
case routeCanonical:
|
case routeCanonical:
|
||||||
@@ -200,11 +196,11 @@ func (current *execution) renderHelpTargets(targets []string) Outcome {
|
|||||||
// Explicit help targets are paths, not command invocations with operands.
|
// Explicit help targets are paths, not command invocations with operands.
|
||||||
// Discovery routes have no canonical path and retain only trailing options.
|
// Discovery routes have no canonical path and retain only trailing options.
|
||||||
if route.kind == routeUnknown || len(route.args) != len(route.canonicalPath) {
|
if route.kind == routeUnknown || len(route.args) != len(route.canonicalPath) {
|
||||||
session := internalcobra.Prepare(snapshot.metadata, current.options)
|
session := internalcobra.Prepare(snapshot.metadata, commandmeta.ParseOptions{})
|
||||||
view := commandmeta.HelpView{EnteredPath: targets, CanonicalPath: targets}
|
view := commandmeta.HelpView{EnteredPath: targets, CanonicalPath: targets}
|
||||||
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
|
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
|
||||||
}
|
}
|
||||||
options := current.options
|
options := commandmeta.ParseOptions{}
|
||||||
switch route.kind {
|
switch route.kind {
|
||||||
case routeCanonical:
|
case routeCanonical:
|
||||||
options.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()
|
options.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
103
flag_aliases.go
103
flag_aliases.go
@@ -66,6 +66,7 @@ func (spelling capturedFlagSpelling) token() string {
|
|||||||
|
|
||||||
func compileFlagAliases(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
|
func compileFlagAliases(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
|
||||||
Resolve(commandpath.Path) commandpath.Path
|
Resolve(commandpath.Path) commandpath.Path
|
||||||
|
ProperSourcePrefixes() []commandpath.Path
|
||||||
}) (map[string]resolvedFlagAliases, map[string]resolvedFlagAliases, error) {
|
}) (map[string]resolvedFlagAliases, map[string]resolvedFlagAliases, error) {
|
||||||
canonicalNodes := indexCanonicalNodes(roots)
|
canonicalNodes := indexCanonicalNodes(roots)
|
||||||
global := make(map[string]flagAliasMapping)
|
global := make(map[string]flagAliasMapping)
|
||||||
@@ -170,6 +171,7 @@ func canonicalFlagSpellings(command Command) map[string]capturedFlagSpelling {
|
|||||||
|
|
||||||
func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
|
func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
|
||||||
Resolve(commandpath.Path) commandpath.Path
|
Resolve(commandpath.Path) commandpath.Path
|
||||||
|
ProperSourcePrefixes() []commandpath.Path
|
||||||
}, canonicalNodes map[string]*canonicalNode) map[string]flagAliasContext {
|
}, canonicalNodes map[string]*canonicalNode) map[string]flagAliasContext {
|
||||||
commandSources := make(map[string]struct{})
|
commandSources := make(map[string]struct{})
|
||||||
prefixes := make(map[string]commandpath.Path)
|
prefixes := make(map[string]commandpath.Path)
|
||||||
@@ -178,26 +180,20 @@ func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode,
|
|||||||
commandSources[rule.record.sourcePath.Key()] = struct{}{}
|
commandSources[rule.record.sourcePath.Key()] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, rule := range rules {
|
for _, prefix := range aliases.ProperSourcePrefixes() {
|
||||||
if rule.record.kind != commandAliasRule {
|
key := prefix.Key()
|
||||||
|
if canonicalNodes[key] != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for length := 1; length < len(rule.record.sourcePath); length++ {
|
if _, exactAlias := commandSources[key]; exactAlias {
|
||||||
prefix := rule.record.sourcePath[:length].Clone()
|
continue
|
||||||
key := prefix.Key()
|
|
||||||
if canonicalNodes[key] != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, exactAlias := commandSources[key]; exactAlias {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resolved := aliases.Resolve(prefix)
|
|
||||||
trace := traceCanonicalPath(roots, resolved)
|
|
||||||
if trace.exact != nil || (trace.operandOwner != nil && acceptsOperandPrefix(trace.operandOwner.command, len(resolved)-len(trace.operandOwner.path))) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
prefixes[key] = prefix
|
|
||||||
}
|
}
|
||||||
|
resolved := aliases.Resolve(prefix)
|
||||||
|
trace := traceCanonicalPath(roots, resolved)
|
||||||
|
if trace.exact != nil || (trace.operandOwner != nil && acceptsOperandPrefix(trace.operandOwner.command, len(resolved)-len(trace.operandOwner.path))) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefixes[key] = prefix
|
||||||
}
|
}
|
||||||
contexts := make(map[string]flagAliasContext, len(prefixes))
|
contexts := make(map[string]flagAliasContext, len(prefixes))
|
||||||
for key, path := range prefixes {
|
for key, path := range prefixes {
|
||||||
@@ -230,14 +226,54 @@ func resolveFlagAliases(context flagAliasContext, mappings map[string]flagAliasM
|
|||||||
sources = append(sources, source)
|
sources = append(sources, source)
|
||||||
}
|
}
|
||||||
sort.Strings(sources)
|
sort.Strings(sources)
|
||||||
|
type terminal struct {
|
||||||
|
spelling capturedFlagSpelling
|
||||||
|
active bool
|
||||||
|
}
|
||||||
|
resolved := make(map[string]terminal)
|
||||||
|
visiting := make(map[string]int)
|
||||||
|
var stack []capturedFlagSpelling
|
||||||
|
var resolve func(capturedFlagSpelling) (terminal, error)
|
||||||
|
resolve = func(current capturedFlagSpelling) (terminal, error) {
|
||||||
|
key := flagSpellingKey(current)
|
||||||
|
if endpoint, exists := context.canonical[key]; exists {
|
||||||
|
return terminal{spelling: endpoint, active: true}, nil
|
||||||
|
}
|
||||||
|
if result, exists := resolved[key]; exists {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if start, exists := visiting[key]; exists {
|
||||||
|
cycle := append(append([]capturedFlagSpelling(nil), stack[start:]...), current)
|
||||||
|
spellings := make([]string, 0, len(cycle))
|
||||||
|
for _, spelling := range cycle {
|
||||||
|
spellings = append(spellings, spelling.token())
|
||||||
|
}
|
||||||
|
return terminal{}, fmt.Errorf("flag alias cycle: %s", strings.Join(spellings, " → "))
|
||||||
|
}
|
||||||
|
mapping, exists := mappings[key]
|
||||||
|
if !exists {
|
||||||
|
resolved[key] = terminal{}
|
||||||
|
return terminal{}, nil
|
||||||
|
}
|
||||||
|
visiting[key] = len(stack)
|
||||||
|
stack = append(stack, current)
|
||||||
|
result, err := resolve(mapping.target)
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
delete(visiting, key)
|
||||||
|
if err != nil {
|
||||||
|
return terminal{}, err
|
||||||
|
}
|
||||||
|
resolved[key] = result
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
for _, sourceKey := range sources {
|
for _, sourceKey := range sources {
|
||||||
mapping := mappings[sourceKey]
|
mapping := mappings[sourceKey]
|
||||||
source := spellingFromKey(sourceKey)
|
source := spellingFromKey(sourceKey)
|
||||||
target, active, err := resolveFlagTarget(source, mapping.target, mappings, context.canonical)
|
terminal, err := resolve(mapping.target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolvedFlagAliases{}, fmt.Errorf("%s: %w", flagContextDescription(context), err)
|
return resolvedFlagAliases{}, fmt.Errorf("%s: %w", flagContextDescription(context), err)
|
||||||
}
|
}
|
||||||
if !active {
|
if !terminal.active {
|
||||||
if mapping.scoped {
|
if mapping.scoped {
|
||||||
return resolvedFlagAliases{}, fmt.Errorf("scoped flag alias %s has no canonical destination in %s", source.token(), flagContextDescription(context))
|
return resolvedFlagAliases{}, fmt.Errorf("scoped flag alias %s has no canonical destination in %s", source.token(), flagContextDescription(context))
|
||||||
}
|
}
|
||||||
@@ -246,38 +282,11 @@ func resolveFlagAliases(context flagAliasContext, mappings map[string]flagAliasM
|
|||||||
if _, collision := context.canonical[sourceKey]; collision {
|
if _, collision := context.canonical[sourceKey]; collision {
|
||||||
return resolvedFlagAliases{}, fmt.Errorf("flag alias %s conflicts with a canonical spelling in %s", source.token(), flagContextDescription(context))
|
return resolvedFlagAliases{}, fmt.Errorf("flag alias %s conflicts with a canonical spelling in %s", source.token(), flagContextDescription(context))
|
||||||
}
|
}
|
||||||
result.bySpelling[sourceKey] = resolvedFlagAlias{source: source, target: target}
|
result.bySpelling[sourceKey] = resolvedFlagAlias{source: source, target: terminal.spelling}
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveFlagTarget(source, target capturedFlagSpelling, mappings map[string]flagAliasMapping, canonical map[string]capturedFlagSpelling) (capturedFlagSpelling, bool, error) {
|
|
||||||
visited := map[string]int{flagSpellingKey(source): 0}
|
|
||||||
chain := []capturedFlagSpelling{source}
|
|
||||||
current := target
|
|
||||||
for {
|
|
||||||
key := flagSpellingKey(current)
|
|
||||||
if endpoint, exists := canonical[key]; exists {
|
|
||||||
return endpoint, true, nil
|
|
||||||
}
|
|
||||||
if start, exists := visited[key]; exists {
|
|
||||||
cycle := append(append([]capturedFlagSpelling(nil), chain[start:]...), current)
|
|
||||||
spellings := make([]string, 0, len(cycle))
|
|
||||||
for _, spelling := range cycle {
|
|
||||||
spellings = append(spellings, spelling.token())
|
|
||||||
}
|
|
||||||
return capturedFlagSpelling{}, false, fmt.Errorf("flag alias cycle: %s", strings.Join(spellings, " → "))
|
|
||||||
}
|
|
||||||
visited[key] = len(chain)
|
|
||||||
chain = append(chain, current)
|
|
||||||
next, exists := mappings[key]
|
|
||||||
if !exists {
|
|
||||||
return capturedFlagSpelling{}, false, nil
|
|
||||||
}
|
|
||||||
current = next.target
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func spellingFromKey(key string) capturedFlagSpelling {
|
func spellingFromKey(key string) capturedFlagSpelling {
|
||||||
if strings.HasPrefix(key, "short:") {
|
if strings.HasPrefix(key, "short:") {
|
||||||
return capturedFlagSpelling{name: strings.TrimPrefix(key, "short:"), short: true}
|
return capturedFlagSpelling{name: strings.TrimPrefix(key, "short:"), short: true}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package commandkit
|
package commandkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -23,8 +24,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 +36,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 +45,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 +127,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 +155,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 +167,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 +175,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 +191,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 +208,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")},
|
||||||
@@ -295,3 +296,45 @@ func capturedSpelling(token string) capturedFlagSpelling {
|
|||||||
issues := []error(nil)
|
issues := []error(nil)
|
||||||
return captureFlagSpelling("test", token, &issues)
|
return captureFlagSpelling("test", token, &issues)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFlagAliasSharedSuffixAndUnavailableDestination(t *testing.T) {
|
||||||
|
app, err := New(Identity{Name: "Example"}, flagAliasCommand("active", stringFlag("color", "")), flagAliasCommand("inactive"),
|
||||||
|
FlagAlias("--first", "--common"), FlagAlias("--second", "--common"), FlagAlias("--common", "--color"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"first", "second", "common"} {
|
||||||
|
key := flagSpellingKey(capturedFlagSpelling{name: name})
|
||||||
|
active := app.flagAliases[commandpath.Path{"active"}.Key()].bySpelling
|
||||||
|
if alias, ok := active[key]; !ok || alias.target.token() != "--color" {
|
||||||
|
t.Fatalf("active %s = %#v", name, alias)
|
||||||
|
}
|
||||||
|
if _, ok := app.flagAliases[commandpath.Path{"inactive"}.Key()].bySpelling[key]; ok {
|
||||||
|
t.Fatalf("inactive %s was installed", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedDebugDeclarationValidation(t *testing.T) {
|
||||||
|
valid := BoolFlag(FlagMeta{Name: "debug", Short: "d"}, true, func(*struct{}, Supplied[bool]) {})
|
||||||
|
if _, err := New(Identity{Name: "Example"}, flagAliasCommand("run", valid)); err != nil {
|
||||||
|
t.Fatalf("valid debug: %v", err)
|
||||||
|
}
|
||||||
|
for _, flags := range [][]Flag[struct{}]{
|
||||||
|
{valid, valid},
|
||||||
|
{BoolFlag[struct{}](FlagMeta{Name: "debug"}, false, nil)},
|
||||||
|
{BoolFlag(FlagMeta{Name: "debug", Short: "h"}, false, func(*struct{}, Supplied[bool]) {})},
|
||||||
|
{ValueFlag(FlagMeta{Name: "debug"}, (*bool)(nil), strconv.ParseBool, func(*struct{}, Supplied[bool]) {})},
|
||||||
|
{OccurrenceFlag(FlagMeta{Name: "debug"}, strconv.ParseBool, func(*struct{}, Supplied[bool]) {})},
|
||||||
|
} {
|
||||||
|
if _, err := New(Identity{Name: "Example"}, flagAliasCommand("bad", flags...)); err == nil {
|
||||||
|
t.Fatalf("invalid binding accepted: %#v", flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{"quiet", "verbose", "progress", "format"} {
|
||||||
|
flag := BoolFlag(FlagMeta{Name: name}, false, func(*struct{}, Supplied[bool]) {})
|
||||||
|
if _, err := New(Identity{Name: "Example"}, flagAliasCommand("bad", flag)); err == nil {
|
||||||
|
t.Errorf("reserved boolean --%s was accepted", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
100
flag_values.go
100
flag_values.go
@@ -1,6 +1,7 @@
|
|||||||
package commandkit
|
package commandkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"container/heap"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
@@ -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]
|
||||||
}
|
}
|
||||||
@@ -22,6 +24,8 @@ type FlagValues[I any] struct {
|
|||||||
// each adapted flag stores its prepared value in target, associated with the
|
// each adapted flag stores its prepared value in target, associated with the
|
||||||
// original inner binding identity. Copies of an original handle remain
|
// original inner binding identity. Copies of an original handle remain
|
||||||
// selectable; an independently constructed lookalike does not.
|
// selectable; an independently constructed lookalike does not.
|
||||||
|
// A captured debug BoolFlag shares reporting input as described by [BoolFlag];
|
||||||
|
// its requiredness is checked when selected for application.
|
||||||
func CaptureFlags[Outer, I any](flags []Flag[I], target func(*Outer) *FlagValues[I]) []Flag[Outer] {
|
func CaptureFlags[Outer, I any](flags []Flag[I], target func(*Outer) *FlagValues[I]) []Flag[Outer] {
|
||||||
captured := make([]Flag[Outer], len(flags))
|
captured := make([]Flag[Outer], len(flags))
|
||||||
for index, original := range flags {
|
for index, original := range flags {
|
||||||
@@ -42,7 +46,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 +59,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 +74,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,11 +105,79 @@ 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 occurrenceHeap[I]
|
||||||
|
for index, prepared := range preparedValues {
|
||||||
|
if prepared.apply != nil {
|
||||||
|
prepared.apply(target)
|
||||||
|
}
|
||||||
|
stream := occurrenceStream[I]{events: prepared.ordered, order: index}
|
||||||
|
stream.skipUnapplied()
|
||||||
|
if len(stream.events) > 0 {
|
||||||
|
ordered = append(ordered, stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// One selected stream already has the required order.
|
||||||
|
if len(ordered) == 1 {
|
||||||
|
for _, event := range ordered[0].events {
|
||||||
|
if event.apply != nil {
|
||||||
|
event.apply(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
heap.Init(&ordered)
|
||||||
|
for len(ordered) > 0 {
|
||||||
|
stream := &ordered[0]
|
||||||
|
stream.events[0].apply(target)
|
||||||
|
stream.events = stream.events[1:]
|
||||||
|
stream.skipUnapplied()
|
||||||
|
if len(stream.events) == 0 {
|
||||||
|
heap.Pop(&ordered)
|
||||||
|
} else {
|
||||||
|
heap.Fix(&ordered, 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each binding supplies a monotone stream. The selection index breaks ordinal
|
||||||
|
// ties in the same order as concatenation followed by a stable sort.
|
||||||
|
type occurrenceStream[I any] struct {
|
||||||
|
events []preparedOccurrence[I]
|
||||||
|
order int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (stream *occurrenceStream[I]) skipUnapplied() {
|
||||||
|
for len(stream.events) > 0 && stream.events[0].apply == nil {
|
||||||
|
stream.events = stream.events[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type occurrenceHeap[I any] []occurrenceStream[I]
|
||||||
|
|
||||||
|
func (h occurrenceHeap[I]) Len() int { return len(h) }
|
||||||
|
func (h occurrenceHeap[I]) Less(i, j int) bool {
|
||||||
|
left, right := *h[i].events[0].ordinal, *h[j].events[0].ordinal
|
||||||
|
if left == right {
|
||||||
|
return h[i].order < h[j].order
|
||||||
|
}
|
||||||
|
return left < right
|
||||||
|
}
|
||||||
|
func (h occurrenceHeap[I]) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
|
||||||
|
func (h *occurrenceHeap[I]) Push(value any) { *h = append(*h, value.(occurrenceStream[I])) }
|
||||||
|
func (h *occurrenceHeap[I]) Pop() any {
|
||||||
|
last := len(*h) - 1
|
||||||
|
value := (*h)[last]
|
||||||
|
(*h)[last] = occurrenceStream[I]{}
|
||||||
|
*h = (*h)[:last]
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func classifyInputError(err error) Outcome {
|
func classifyInputError(err error) Outcome {
|
||||||
if errors.Is(err, ErrFlagWiring) {
|
if errors.Is(err, ErrFlagWiring) {
|
||||||
return Failure(err.Error()).WithCause(err)
|
return Failure(err.Error()).WithCause(err)
|
||||||
|
|||||||
82
format_flag_test.go
Normal file
82
format_flag_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,11 +36,8 @@ func routeHelpAliases(snapshot *routingSnapshot, route routeResult) []commandmet
|
|||||||
}
|
}
|
||||||
bySource := make(map[string]commandmeta.CommandAlias)
|
bySource := make(map[string]commandmeta.CommandAlias)
|
||||||
for _, prefix := range snapshot.aliases.ResolutionPaths(route.enteredPath) {
|
for _, prefix := range snapshot.aliases.ResolutionPaths(route.enteredPath) {
|
||||||
for _, alias := range snapshot.commandAliases {
|
for _, alias := range snapshot.aliases.Descendants(prefix) {
|
||||||
source := commandpath.FromTokens(alias.Source)
|
source := alias.Source
|
||||||
if len(source) <= len(prefix) || !source.HasPrefix(prefix) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
suffix := source[len(prefix):]
|
suffix := source[len(prefix):]
|
||||||
entered := append(route.enteredPath.Clone(), suffix...)
|
entered := append(route.enteredPath.Clone(), suffix...)
|
||||||
target := snapshot.aliases.Resolve(entered)
|
target := snapshot.aliases.Resolve(entered)
|
||||||
|
|||||||
25
identity.go
25
identity.go
@@ -52,12 +52,37 @@ 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 }
|
||||||
|
|
||||||
|
// ReportingMode returns the selected reporting mode, independently of delivery
|
||||||
|
// success. The selection is stable during the handler and remains available
|
||||||
|
// after finalization. A zero Invocation returns ReportingModeOrdinary. Reads
|
||||||
|
// are safe alongside concurrent reporting; reporting callbacks must not reenter
|
||||||
|
// the same reporter.
|
||||||
|
func (inv Invocation) ReportingMode() ReportingMode {
|
||||||
|
if inv.reporter == nil || inv.reporter.state == nil {
|
||||||
|
return ReportingModeOrdinary
|
||||||
|
}
|
||||||
|
state := inv.reporter.state
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
return state.mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|||||||
@@ -1,36 +1,30 @@
|
|||||||
# CommandKit ckconfig
|
# CommandKit ckconfig
|
||||||
|
|
||||||
`ckconfig` is the optional ConfigKit integration for CommandKit. It adapts
|
`ckconfig` adds configuration inspection commands to Go applications that use
|
||||||
ConfigKit loaders and scopes into reusable `commandkit.Command` declarations;
|
CommandKit and ConfigKit. Operators can view settings, validate configuration,
|
||||||
installing the integration is explicit and does not modify an application by
|
find where values came from, and compare two configurations.
|
||||||
itself.
|
|
||||||
|
The application chooses which commands to install. Importing the package alone
|
||||||
|
does not install commands.
|
||||||
|
|
||||||
## Module
|
## Module
|
||||||
|
|
||||||
The module path is `go.maximumdirect.net/ckconfig`. The prepared v0.3.0
|
The module path is `go.maximumdirect.net/ckconfig`. It does not import PromptKit.
|
||||||
candidate requires Go 1.27.0 and uses `go.maximumdirect.net/commandkit v0.3.0` and
|
See the [consumer guide](https://forge.maximumdirect.net/base-layer/commandkit/src/branch/main/docs/consumers/guide.md#module-selection)
|
||||||
`go.maximumdirect.net/configkit v1.1.0`; it does not import PromptKit. See the
|
for compatible releases and Go requirements.
|
||||||
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#configkit-scopes)
|
|
||||||
for exact callback and lifetime contracts.
|
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
For one application configuration scope, install the convenience declaration:
|
To inspect one application configuration, pass your loader to `AppCommand`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
app, err := commandkit.New(identity, ckconfig.AppCommand(loadAppConfig))
|
app, err := commandkit.New(identity, ckconfig.AppCommand(loadAppConfig))
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `ckconfig.Command(ckconfig.Suite[Input, State]{...})` to define named
|
For multiple named configurations or application-specific flags, use
|
||||||
scopes, selectors, input checks, and state shared by the selected acquisitions
|
`ckconfig.Command` with a `ckconfig.Suite`. The
|
||||||
or comparison in a config invocation. `ckconfig.FromConfigKit` adapts a typed
|
[integration guide](https://forge.maximumdirect.net/base-layer/commandkit/src/branch/main/docs/consumers/guide.md#configkit-scopes)
|
||||||
ConfigKit loader. The `config` command family exposes scope discovery and the
|
explains loader setup, inputs, and shared state.
|
||||||
validate, show, sources, explain, and diff operations supported by the declared
|
|
||||||
scopes. Source reports include native destination, placement, destination
|
|
||||||
fallback, version evidence, and empty-import selections. Comparison returns
|
|
||||||
ConfigKit's native records to the YAML report path; the
|
|
||||||
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#compare-configuration-selections)
|
|
||||||
owns its operator details.
|
|
||||||
|
|
||||||
The maintained [simple example](examples/simple) shows a complete consumer.
|
The maintained [simple example](examples/simple) shows a complete consumer.
|
||||||
The [CommandKit CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#configuration-scopes)
|
The [CommandKit CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#configuration-scopes)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ckconfig
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -10,57 +11,6 @@ import (
|
|||||||
configkit "go.maximumdirect.net/configkit"
|
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)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -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,32 @@ 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 {
|
||||||
|
if err != nil {
|
||||||
|
return acquired, err
|
||||||
|
}
|
||||||
return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
|
return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
|
||||||
}
|
}
|
||||||
|
records := result.Diagnostics()
|
||||||
|
messages, explanation := configKitDiagnostics(name, result.Sources(), records)
|
||||||
|
acquired.Value = View{Sources: result, Findings: &Findings{Records: records, Messages: messages}}
|
||||||
|
acquired.Explanation = explanation
|
||||||
|
if err != nil {
|
||||||
|
return acquired, err
|
||||||
|
}
|
||||||
|
metadata := result.Metadata()
|
||||||
|
acquired.Value.Effective, acquired.Value.Explanation, acquired.Value.Metadata = result, result, &metadata
|
||||||
return acquired, nil
|
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 +110,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,18 +122,22 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
||||||
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+":\n", sources, records)
|
return AdaptDiagnostics("Configuration scope "+strconv.Quote(scope)+":\n", sources, records)
|
||||||
}
|
}
|
||||||
|
|
||||||
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
||||||
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+" "+side+":\n", sources, records)
|
return AdaptDiagnostics("Configuration scope "+strconv.Quote(scope)+" "+side+":\n", sources, records)
|
||||||
}
|
}
|
||||||
|
|
||||||
func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
// AdaptDiagnostics captures ConfigKit diagnostics as CLI presentation evidence.
|
||||||
|
// It does not print, acquire configuration, determine outcomes, or schedule messages.
|
||||||
|
// The heading is used as supplied when the captured evidence is rendered.
|
||||||
|
func AdaptDiagnostics(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
||||||
if len(records) == 0 {
|
if len(records) == 0 {
|
||||||
return nil, commandkit.Explanation{}
|
return nil, commandkit.Explanation{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -61,7 +63,9 @@ type ComparisonRequest[I any] struct {
|
|||||||
Right ComparisonSide[I]
|
Right ComparisonSide[I]
|
||||||
}
|
}
|
||||||
|
|
||||||
// EffectiveWriter writes a configuration's native effective representation.
|
// EffectiveWriter writes one safe YAML document for conversion by
|
||||||
|
// commandkit.DecodeYAMLReport. Its formatting, comments, and framing are not
|
||||||
|
// passed through to the resulting report.
|
||||||
type EffectiveWriter interface {
|
type EffectiveWriter interface {
|
||||||
WriteEffective(io.Writer) error
|
WriteEffective(io.Writer) error
|
||||||
}
|
}
|
||||||
@@ -71,18 +75,39 @@ type SourceReader interface {
|
|||||||
Sources() []configkit.SourceInfo
|
Sources() []configkit.SourceInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExplanationReader reads and writes a configuration's native path explanation.
|
// ExplanationReader acquires a configuration's native path explanation.
|
||||||
type ExplanationReader interface {
|
type ExplanationReader interface {
|
||||||
Explain(configkit.Path) (configkit.Explanation, bool)
|
Explain(configkit.Path) (configkit.Explanation, bool)
|
||||||
WriteExplanation(io.Writer, configkit.Path) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// View exposes native configuration facets without reconstructing their
|
// View exposes native configuration evidence used to build reports.
|
||||||
// reports.
|
|
||||||
type View struct {
|
type View struct {
|
||||||
Effective EffectiveWriter
|
Effective EffectiveWriter
|
||||||
Sources SourceReader
|
Sources SourceReader
|
||||||
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
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,13 +117,6 @@ func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
|
|||||||
if _, ok := acquired.Value.Explanation.Explain(path); !ok {
|
if _, ok := acquired.Value.Explanation.Explain(path); !ok {
|
||||||
t.Fatal("native explanation facet did not resolve /name")
|
t.Fatal("native explanation facet did not resolve /name")
|
||||||
}
|
}
|
||||||
var explanation bytes.Buffer
|
|
||||||
if err := acquired.Value.Explanation.WriteExplanation(&explanation, path); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if explanation.Len() == 0 {
|
|
||||||
t.Fatal("native explanation writer produced no output")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
||||||
@@ -139,8 +133,12 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
|||||||
if loadErr == nil {
|
if loadErr == nil {
|
||||||
t.Fatal("fixture did not produce a ConfigKit load error")
|
t.Fatal("fixture did not produce a ConfigKit load error")
|
||||||
}
|
}
|
||||||
|
partial, err := configkit.Load[struct{}](configkit.Mapping("unrelated result", map[string]any{}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[struct{}], error) {
|
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[struct{}], error) {
|
||||||
return nil, loadErr
|
return partial, loadErr
|
||||||
})
|
})
|
||||||
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||||
if gotErr != loadErr {
|
if gotErr != loadErr {
|
||||||
@@ -150,14 +148,17 @@ 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)
|
||||||
}
|
}
|
||||||
|
if !reflect.DeepEqual(acquired.Value.Sources.Sources(), typed.Sources()) || !reflect.DeepEqual(acquired.Value.Findings.Records, typed.Diagnostics()) {
|
||||||
|
t.Fatal("LoadError snapshot was replaced or duplicated")
|
||||||
|
}
|
||||||
var warning, terminal bytes.Buffer
|
var warning, terminal bytes.Buffer
|
||||||
if err := acquired.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,46 +175,83 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFromConfigKitPreservesValueAndDiagnosticsAlongsideError(t *testing.T) {
|
func TestFromConfigKitRetainsResultEvidenceWithApplicationFailure(t *testing.T) {
|
||||||
result, err := adapterSettingsLoad(configkit.Mapping("partial source", map[string]any{"greeting": "Hello"}))
|
result, err := adapterSettingsLoad(configkit.Mapping("partial source", map[string]any{"greeting": "Hello"}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
loadErr := errors.New("loader returned a value with an error")
|
for _, cause := range []error{errors.New("post-load check failed"), context.Canceled} {
|
||||||
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
|
t.Run(cause.Error(), func(t *testing.T) {
|
||||||
return result, loadErr
|
scope := FromConfigKit("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
|
||||||
})
|
return result, cause
|
||||||
|
})
|
||||||
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||||
if gotErr != loadErr {
|
if gotErr != cause || acquired.Value.Effective != nil || acquired.Value.Metadata != nil || acquired.Value.Explanation != nil || acquired.Value.Sources == nil || acquired.Value.Findings == nil {
|
||||||
t.Fatalf("load error identity = %v, want %v", gotErr, loadErr)
|
t.Fatalf("failed acquisition = %#v, %v", acquired, gotErr)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(acquired.Value.Sources.Sources(), result.Sources()) || !reflect.DeepEqual(acquired.Value.Findings.Records, result.Diagnostics()) || len(acquired.Value.Findings.Messages) != 1 || len(acquired.Explanation.Details) != 0 {
|
||||||
|
t.Fatalf("lost or duplicated result evidence: %#v", acquired)
|
||||||
|
}
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
|
||||||
|
for _, args := range [][]string{{"config", "sources", "--format=json"}, {"config", "validate", "--details", "--format=json"}, {"config", "validate", "--verbose"}} {
|
||||||
|
var out, diag bytes.Buffer
|
||||||
|
got := app.Execute(context.Background(), args, commandkit.Streams{Out: &out, Err: &diag})
|
||||||
|
if !errors.Is(got.Cause, cause) {
|
||||||
|
t.Fatalf("lost cause: %#v", got)
|
||||||
|
}
|
||||||
|
if args[1] == "sources" {
|
||||||
|
if !strings.Contains(out.String(), "partial source") {
|
||||||
|
t.Fatalf("lost sources: %s", out.String())
|
||||||
|
}
|
||||||
|
} else if strings.Contains(strings.Join(args, " "), "--details") {
|
||||||
|
if !strings.Contains(out.String(), result.Diagnostics()[0].Message) || strings.Contains(diag.String(), result.Diagnostics()[0].Message) {
|
||||||
|
t.Fatalf("detailed evidence: output=%s diag=%s", out.String(), diag.String())
|
||||||
|
}
|
||||||
|
} else if !strings.Contains(diag.String(), result.Diagnostics()[0].Message) {
|
||||||
|
t.Fatalf("lost ordinary warning: %s", diag.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeErr := errors.New("output failed")
|
||||||
|
got := app.Execute(context.Background(), []string{"config", "validate", "--details"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
|
||||||
|
if !errors.Is(got.Cause, cause) || !errors.Is(got.Presentation.Output.WriteError, writeErr) {
|
||||||
|
t.Fatalf("lost operation/presentation cause: %#v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result {
|
empty, err := configkit.Load[map[string]any](configkit.Mapping("empty", map[string]any{}))
|
||||||
t.Fatalf("acquired value facets were lost: %#v", acquired.Value)
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
|
for _, result := range []*configkit.Result[map[string]any]{nil, empty} {
|
||||||
t.Fatalf("independent diagnostics = %#v", acquired.Diagnostics)
|
cause := errors.New("application failure")
|
||||||
}
|
scope := FromConfigKit("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[map[string]any], error) {
|
||||||
if len(acquired.Explanation.Details) != 0 {
|
return result, cause
|
||||||
t.Fatalf("warning-only result acquired terminal context: %#v", acquired.Explanation)
|
})
|
||||||
|
got, err := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||||
|
if err != cause || (got.Value.Findings == nil) != (result == nil) {
|
||||||
|
t.Fatalf("unavailable/empty evidence: %#v %v", got, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigKitDiagnosticsMapSeverityAndPropagateWriterFailures(t *testing.T) {
|
func TestConfigKitDiagnosticsMapSeverityAndPropagateWriterFailures(t *testing.T) {
|
||||||
warning, noExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}})
|
warning, noExplanation := AdaptDiagnostics("scope:\n", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}})
|
||||||
if len(warning) != 1 || warning[0].Severity != commandkit.SeverityWarning {
|
if len(warning) != 1 || warning[0].Severity != commandkit.SeverityWarning {
|
||||||
t.Fatalf("warning diagnostics = %#v", warning)
|
t.Fatalf("warning diagnostics = %#v", warning)
|
||||||
}
|
}
|
||||||
if noExplanation.Message != "" || len(noExplanation.Details) != 0 {
|
if noExplanation.Message != "" || len(noExplanation.Details) != 0 {
|
||||||
t.Fatalf("warning-only explanation = %#v", noExplanation)
|
t.Fatalf("warning-only explanation = %#v", noExplanation)
|
||||||
}
|
}
|
||||||
withWarning, withExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{
|
withWarning, withExplanation := AdaptDiagnostics("scope:\n", nil, []configkit.Diagnostic{
|
||||||
{Severity: configkit.SeverityWarning, Message: "safe warning"},
|
{Severity: configkit.SeverityWarning, Message: "safe warning"},
|
||||||
{Severity: configkit.SeverityError, Message: "safe error"},
|
{Severity: configkit.SeverityError, Message: "safe error"},
|
||||||
})
|
})
|
||||||
if len(withWarning) != 1 || withWarning[0].Severity != commandkit.SeverityWarning || len(withExplanation.Details) != 1 {
|
if len(withWarning) != 1 || withWarning[0].Severity != commandkit.SeverityWarning || len(withExplanation.Details) != 1 {
|
||||||
t.Fatalf("mixed diagnostics = %#v, explanation = %#v", withWarning, withExplanation)
|
t.Fatalf("mixed diagnostics = %#v, explanation = %#v", withWarning, withExplanation)
|
||||||
}
|
}
|
||||||
|
if empty, explanation := AdaptDiagnostics("unused", nil, nil); len(empty) != 0 || len(explanation.Details) != 0 {
|
||||||
|
t.Fatalf("empty evidence = %#v, %#v", empty, explanation)
|
||||||
|
}
|
||||||
|
|
||||||
if err := withWarning[0].Write(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
|
if err := withWarning[0].Write(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
|
||||||
t.Fatalf("short writer error = %v, want %v", err, io.ErrShortWrite)
|
t.Fatalf("short writer error = %v, want %v", err, io.ErrShortWrite)
|
||||||
@@ -245,10 +283,11 @@ func TestConfigKitTerminalContextRetainsRelatedLocationsAndSnapshots(t *testing.
|
|||||||
Primary: primary,
|
Primary: primary,
|
||||||
Related: related,
|
Related: related,
|
||||||
}}
|
}}
|
||||||
_, explanation := configKitDiagnostics("app", sources, records)
|
_, explanation := AdaptDiagnostics("Configuration scope \"app\":\n", sources, records)
|
||||||
primary.SourceID = "mutated-primary"
|
primary.SourceID = "mutated-primary"
|
||||||
related[0].SourceID = "mutated-related"
|
related[0].SourceID = "mutated-related"
|
||||||
sources[0].Path = "mutated.yml"
|
sources[0].Path = "mutated.yml"
|
||||||
|
records[0].Message = "mutated message"
|
||||||
|
|
||||||
if len(explanation.Details) != 1 {
|
if len(explanation.Details) != 1 {
|
||||||
t.Fatalf("explanation = %#v", explanation)
|
t.Fatalf("explanation = %#v", explanation)
|
||||||
@@ -262,7 +301,7 @@ func TestConfigKitTerminalContextRetainsRelatedLocationsAndSnapshots(t *testing.
|
|||||||
t.Errorf("terminal context %q omits %q", output.String(), fragment)
|
t.Errorf("terminal context %q omits %q", output.String(), fragment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(output.String(), "mutated-") || strings.Contains(output.String(), "mutated.yml") {
|
if strings.Contains(output.String(), "mutated-") || strings.Contains(output.String(), "mutated.yml") || strings.Contains(output.String(), "mutated message") {
|
||||||
t.Fatalf("terminal renderer retained caller mutations: %q", output.String())
|
t.Fatalf("terminal renderer retained caller mutations: %q", output.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,3 +313,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
104
integrations/ckconfig/detailed_diff_test.go
Normal file
104
integrations/ckconfig/detailed_diff_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
174
integrations/ckconfig/detailed_validation_test.go
Normal file
174
integrations/ckconfig/detailed_validation_test.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
flags []string
|
||||||
|
first, second []string
|
||||||
|
}{
|
||||||
|
{"any within filters", []string{"--severity=warning", "--severity=error", "--code=chosen", "--code=other", "--code=failed"}, []string{"selected warning", "other warning"}, []string{"failed finding"}},
|
||||||
|
{"duplicates and unknown", []string{"--severity=warning", "--severity=warning", "--code=chosen", "--code=chosen", "--code=future-code"}, []string{"selected warning"}, nil},
|
||||||
|
{"both filters", []string{"--severity=error", "--code=chosen", "--code=failed"}, nil, []string{"failed finding"}},
|
||||||
|
{"no matching code", []string{"--code=future-code"}, nil, nil},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
args := append([]string{"config", "validate", "--scope=all", "--details", "--format=json"}, tc.flags...)
|
||||||
|
got := app.Execute(context.Background(), args, commandkit.Streams{Out: &out, Err: io.Discard})
|
||||||
|
var report validationReport
|
||||||
|
if err := json.Unmarshal(out.Bytes(), &report); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !errors.Is(got.Cause, cause) || len(report.Scopes) != 2 {
|
||||||
|
t.Fatalf("outcome=%#v report=%#v", got, report)
|
||||||
|
}
|
||||||
|
for index, want := range [][]string{tc.first, tc.second} {
|
||||||
|
details := report.Scopes[index].Details
|
||||||
|
if len(details.Findings) != len(want) {
|
||||||
|
t.Fatalf("findings=%#v want=%v", details.Findings, want)
|
||||||
|
}
|
||||||
|
for i, message := range want {
|
||||||
|
if details.Findings[i].Message != message {
|
||||||
|
t.Fatalf("finding order=%#v want=%v", details.Findings, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(want) == 0 && details.State != "filtered" {
|
||||||
|
t.Fatalf("state=%q", details.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
before := len(calls)
|
||||||
|
for _, args := range [][]string{{"--severity=warning"}, {"--code=x"}, {"--details", "--severity=unknown"}} {
|
||||||
|
result = app.Execute(context.Background(), append([]string{"config", "validate"}, args...), commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"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 {
|
||||||
|
|||||||
@@ -1,194 +1,131 @@
|
|||||||
package ckconfig
|
package ckconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "go.maximumdirect.net/configkit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
"go.yaml.in/yaml/v4"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func decodeSafeSnapshot(snapshot string) (*yaml.Node, error) {
|
func decodeJSONPointer(path configkit.Path) []string {
|
||||||
var document yaml.Node
|
pointer := path.String()
|
||||||
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) {
|
|
||||||
if pointer == "" {
|
if pointer == "" {
|
||||||
return nil, nil
|
return nil
|
||||||
}
|
}
|
||||||
if pointer[0] != '/' {
|
|
||||||
return nil, fmt.Errorf("decode JSON pointer %q: path must start with /", pointer)
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(pointer[1:], "/")
|
parts := strings.Split(pointer[1:], "/")
|
||||||
|
decoder := strings.NewReplacer("~1", "/", "~0", "~")
|
||||||
for index, part := range parts {
|
for index, part := range parts {
|
||||||
var decoded strings.Builder
|
parts[index] = decoder.Replace(part)
|
||||||
decoded.Grow(len(part))
|
|
||||||
for offset := 0; offset < len(part); offset++ {
|
|
||||||
if part[offset] != '~' {
|
|
||||||
decoded.WriteByte(part[offset])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if offset+1 >= len(part) {
|
|
||||||
return nil, fmt.Errorf("decode JSON pointer %q: incomplete escape in token %d", pointer, index)
|
|
||||||
}
|
|
||||||
offset++
|
|
||||||
switch part[offset] {
|
|
||||||
case '0':
|
|
||||||
decoded.WriteByte('~')
|
|
||||||
case '1':
|
|
||||||
decoded.WriteByte('/')
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("decode JSON pointer %q: invalid escape in token %d", pointer, index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parts[index] = decoded.String()
|
|
||||||
}
|
}
|
||||||
return parts, nil
|
return parts
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 := decodeJSONPointer(difference.Path)
|
||||||
return nil, fmt.Errorf("render addition at %q: %w", path, err)
|
value, err := commandkit.DecodeYAMLReport(strings.NewReader(difference.Right.YAML))
|
||||||
}
|
if err != nil {
|
||||||
additions, err = placer.placeValue(additions, tokens, value)
|
return nil, fmt.Errorf("difference at %q: %w", path, err)
|
||||||
if err != nil {
|
}
|
||||||
return nil, fmt.Errorf("render addition at %q: %w", path, err)
|
if difference.Left.Present {
|
||||||
}
|
report.Changes, err = placeReportValue(report.Changes, tokens, value)
|
||||||
case difference.Left.Present && difference.Right.Present:
|
} else {
|
||||||
tokens, err := decodeJSONPointer(path)
|
report.Additions, err = placeReportValue(report.Additions, tokens, value)
|
||||||
if err != nil {
|
}
|
||||||
return nil, fmt.Errorf("render change at %q: %w", path, err)
|
if err != nil {
|
||||||
}
|
return nil, fmt.Errorf("difference at %q: %w", path, err)
|
||||||
value, err := decodeSafeSnapshot(difference.Right.YAML)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("render change 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package ckconfig
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"reflect"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "go.maximumdirect.net/configkit"
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,49 +2,11 @@ package ckconfig
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.yaml.in/yaml/v4"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
@@ -56,91 +18,39 @@ func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) {
|
|||||||
{pointer: "/0/../a.b/50%", want: []string{"0", "..", "a.b", "50%"}},
|
{pointer: "/0/../a.b/50%", want: []string{"0", "..", "a.b", "50%"}},
|
||||||
{pointer: "/a~1b/c~0d", want: []string{"a/b", "c~d"}},
|
{pointer: "/a~1b/c~0d", want: []string{"a/b", "c~d"}},
|
||||||
{pointer: "/~01", want: []string{"~1"}},
|
{pointer: "/~01", want: []string{"~1"}},
|
||||||
|
{pointer: "/café", want: []string{"café"}},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
got, err := decodeJSONPointer(test.pointer)
|
path, err := configkit.ParsePath(test.pointer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("decode %q: %v", test.pointer, err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
got := decodeJSONPointer(path)
|
||||||
if !reflect.DeepEqual(got, test.want) {
|
if !reflect.DeepEqual(got, test.want) {
|
||||||
t.Errorf("decode %q = %#v, want %#v", test.pointer, got, test.want)
|
t.Errorf("decode %q = %#v, want %#v", test.pointer, got, test.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, pointer := range []string{"missing-leading-slash", "/bad~", "/bad~2escape"} {
|
|
||||||
if _, err := decodeJSONPointer(pointer); err == nil {
|
|
||||||
t.Errorf("decode %q unexpectedly succeeded", pointer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func 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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
156
integrations/ckconfig/discovery_test.go
Normal file
156
integrations/ckconfig/discovery_test.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package ckconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"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}, "a~b": 4, "ab": 2, "abacus": 3, "gone": map[string]any{"x": 1}}), configkit.Options[map[string]any]{Overrides: selection.Overrides})
|
||||||
|
})
|
||||||
|
scope.Features.Overrides = true
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{NewInput: func() struct{} { inputs++; return struct{}{} }, NewState: func() struct{} { states++; return struct{}{} }, Scopes: []Scope[struct{}, struct{}]{scope}}))
|
||||||
|
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
|
||||||
|
decode := func(output string) error {
|
||||||
|
report = pathDiscoveryReport{}
|
||||||
|
return json.Unmarshal([]byte(output), &report)
|
||||||
|
}
|
||||||
|
if result.Kind != commandkit.OutcomeSuccess || decode(out) != nil || len(report.Paths) != 2 {
|
||||||
|
t.Fatalf("escaped subtree: %#v %q", result, out)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
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 || decode(out) != nil || len(report.Paths) != 1 || report.Paths[0].Path != "/ab" {
|
||||||
|
t.Fatalf("token boundary: %#v %q", result, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
prefix string
|
||||||
|
paths []string
|
||||||
|
}{
|
||||||
|
{"/a~1b/x", []string{"/a~1b/x"}},
|
||||||
|
{"/a~0b", []string{"/a~0b"}},
|
||||||
|
{"/a", nil},
|
||||||
|
{"/a~1b/x/unknown", nil},
|
||||||
|
{"/ab/" + strings.Repeat("too-deep/", 20) + "unknown", nil},
|
||||||
|
} {
|
||||||
|
result, out = run("--list", "--under="+tc.prefix, "--format=json")
|
||||||
|
if result.Status != 0 || decode(out) != nil {
|
||||||
|
t.Fatalf("prefix %q: %#v %s", tc.prefix, result, out)
|
||||||
|
}
|
||||||
|
var paths []string
|
||||||
|
for _, entry := range report.Paths {
|
||||||
|
paths = append(paths, entry.Path)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(paths, tc.paths) {
|
||||||
|
t.Fatalf("prefix %q: paths=%v want=%v", tc.prefix, paths, tc.paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, baseline := run("--list", "--format=json")
|
||||||
|
if result.Status != 0 {
|
||||||
|
t.Fatalf("unfiltered discovery=%#v", result)
|
||||||
|
}
|
||||||
|
var all pathDiscoveryReport
|
||||||
|
if err := json.Unmarshal([]byte(baseline), &all); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, out = run("--list", "--under=", "--format=json")
|
||||||
|
if result.Status != 0 || decode(out) != nil || !reflect.DeepEqual(report, all) {
|
||||||
|
t.Fatalf("root subtree changed discovery: %#v %s", result, out)
|
||||||
|
}
|
||||||
|
result, out = run("--list", "--historical", "--set=/gone=null", "--format=json")
|
||||||
|
if result.Kind != commandkit.OutcomeSuccess || decode(out) != 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", "--under=/gone", "--historical", "--explicit=false", "--set=/gone=null", "--format=json")
|
||||||
|
if result.Kind != commandkit.OutcomeSuccess || decode(out) != nil {
|
||||||
|
t.Fatalf("explicit filter: %#v %q", result, out)
|
||||||
|
}
|
||||||
|
for _, entry := range report.Paths {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
// Package ckconfig, provided by go.maximumdirect.net/ckconfig, provides
|
// Package ckconfig adds commands to view, validate, explain, and compare
|
||||||
// CommandKit's optional ConfigKit v1.1.0 integration, requiring Go 1.27.0. Its
|
// ConfigKit configuration in CommandKit applications.
|
||||||
// canonical Command declaration installs the standard config command family
|
//
|
||||||
// through the core commandkit package. Applications can install CommandKit
|
// Use AppCommand for one application configuration loader, or Command with a
|
||||||
// RoutingRule declarations alongside it through commandkit.New or
|
// Suite for multiple named configurations and application-specific flags.
|
||||||
// App.Register. Source reports preserve ConfigKit's destination, placement,
|
// Install the returned command with commandkit.New or App.Register.
|
||||||
// version, and empty-import evidence. Invocation reporting applies CommandKit's shared
|
// Importing this package alone installs no commands.
|
||||||
// visibility and delivery policy; provider warnings remain independent of
|
//
|
||||||
// failure explanations. Importing ckconfig alone installs no commands.
|
// ConfigKit owns configuration loading and interpretation. CommandKit formats
|
||||||
|
// reports and messages using its shared reporting policy.
|
||||||
package ckconfig
|
package ckconfig
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
24
integrations/ckconfig/docs/releases/v0.4.0.md
Normal file
24
integrations/ckconfig/docs/releases/v0.4.0.md
Normal 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)
|
||||||
39
integrations/ckconfig/docs/releases/v0.5.0.md
Normal file
39
integrations/ckconfig/docs/releases/v0.5.0.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# CommandKit ckconfig v0.5.0
|
||||||
|
|
||||||
|
CommandKit ckconfig v0.5.0 improves configuration explanations, preserves
|
||||||
|
available inspection evidence after loader failures, and uses CommandKit core
|
||||||
|
v0.5.0 for reporting and input handling. ConfigKit remains at v1.1.0, and the
|
||||||
|
consumer minimum remains Go 1.27.0.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Configuration explanations render the same acquired evidence in text, YAML,
|
||||||
|
and JSON, including available field metadata, history, and source labels.
|
||||||
|
- Failed loads retain available ConfigKit result evidence for inspection.
|
||||||
|
Cancellation during aggregate configuration display remains an operation
|
||||||
|
failure rather than being reported as a rendering failure.
|
||||||
|
- The new `AdaptDiagnostics` helper captures native ConfigKit diagnostics for
|
||||||
|
CLI presentation. Both standard integrations use the same adapter.
|
||||||
|
- Validation and path discovery avoid rebuilding filter sets and subtree
|
||||||
|
prefixes repeatedly. Scope setup and operation preparation are simpler,
|
||||||
|
preserving independent comparison inputs and existing CLI behavior.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
Select `go.maximumdirect.net/ckconfig v0.5.0` with CommandKit core v0.5.0.
|
||||||
|
Custom `ExplanationReader` implementations need only `Explain`; the interface
|
||||||
|
no longer requires `WriteExplanation`. CommandKit renders the acquired evidence
|
||||||
|
instead of invoking a provider's text writer. Applications that customized that
|
||||||
|
writer should review their text output.
|
||||||
|
|
||||||
|
Effective configuration writers supply one safe YAML document. Conversion
|
||||||
|
preserves resolved scalar types rather than formatting or comments. Core
|
||||||
|
v0.5.0 preserves numeric-looking strings and explicit numeric tags; review
|
||||||
|
callers that relied on automatic conversion of strings into numbers.
|
||||||
|
|
||||||
|
See the [configuration CLI reference](../../../../docs/cli.md#configuration-scopes)
|
||||||
|
and [consumer guide](../../../../docs/consumers/guide.md#configkit-scopes).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.5.0/integrations/ckconfig/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.5.0/integrations/ckconfig/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.5.0/integrations/ckconfig/docs/oss/attribution.md)
|
||||||
@@ -1,27 +1,90 @@
|
|||||||
# Simple consumer
|
# ConfigKit example
|
||||||
|
|
||||||
This example imports `go.maximumdirect.net/ckconfig` and
|
This example adds configuration inspection and a custom `greet` command to a
|
||||||
`go.maximumdirect.net/commandkit`; use the ckconfig v0.3.0 candidate with core
|
small application. It uses local fixtures and in-memory defaults, with no
|
||||||
v0.3.0, ConfigKit v1.1.0, and Go 1.27.0. Set `GOWORK=off GOTOOLCHAIN=local`
|
external services. See [module selection](../../../../docs/consumers/guide.md#module-selection)
|
||||||
for the commands below.
|
for dependencies and Go requirements. Use Go 1.27.0 on `PATH` for this checkout.
|
||||||
|
|
||||||
Run the maintained example with:
|
From the repository root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
cd integrations/ckconfig/examples/simple
|
||||||
|
export GOWORK=off GOTOOLCHAIN=local
|
||||||
go run . config show
|
go run . config show
|
||||||
go run . config show --config fixtures/before.yml --verbose
|
```
|
||||||
go run . config show --config fixtures/before.yml --quiet
|
|
||||||
|
The output includes the default greeting, `Hello`, and schema version 2.
|
||||||
|
This example can run against its declared released dependencies with the
|
||||||
|
workspace disabled.
|
||||||
|
|
||||||
|
## Inspect and override a value
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Discover configuration paths under schema.
|
||||||
|
go run . config explain --list --under /schema
|
||||||
|
# Change the greeting for this invocation and show just that value.
|
||||||
|
go run . config show --path /greeting --set '/greeting="Hi"'
|
||||||
|
```
|
||||||
|
|
||||||
|
The second command prints `Hi`. It does not edit a file.
|
||||||
|
|
||||||
|
## Compare configurations
|
||||||
|
|
||||||
|
```sh
|
||||||
go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml
|
go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
The report includes `changes.greeting: Goodbye`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Expected status 3: the overridden greeting differs from the ordinary default.
|
||||||
|
go run . config diff --set '/greeting="Hi"' --details --check
|
||||||
|
```
|
||||||
|
|
||||||
|
This compares the overridden left side with ordinary configuration on the
|
||||||
|
right. `go run` prints `exit status 3` and itself exits with status 1; a built
|
||||||
|
executable returns the application's status 3 directly.
|
||||||
|
|
||||||
|
## Inspect warnings and failed loads
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Schema version 1 produces a warning, visible in verbose mode.
|
||||||
|
go run . config show --config fixtures/before.yml --verbose
|
||||||
|
# Quiet mode hides the configuration report and warning.
|
||||||
|
go run . config show --config fixtures/before.yml --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
Ordinary mode hides the warning but still displays configuration.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Intentional failure: show detailed validation findings for the invalid fixture.
|
||||||
|
go run . config validate --config fixtures/invalid.yml --details --severity error
|
||||||
|
# Intentional failure: still show the source recorded before loading failed.
|
||||||
|
go run . config sources --config fixtures/invalid.yml --source-id source-1
|
||||||
|
```
|
||||||
|
|
||||||
|
Both commands retain the load failure. `source-1` belongs to this fixture's
|
||||||
|
load; other configurations can have different source IDs. See the
|
||||||
|
[CLI reference](../../../../docs/cli.md#configuration-scopes) for report and status rules.
|
||||||
|
|
||||||
|
## Try a custom command
|
||||||
|
|
||||||
|
```sh
|
||||||
go run . greet
|
go run . greet
|
||||||
go run . greet --count=0 --tag=first --tag second
|
go run . greet --count=0 --tag=first --tag second
|
||||||
```
|
```
|
||||||
|
|
||||||
The `config` family comes from one typed ConfigKit scope. The custom `greet`
|
The handler reports its greeting, count, and tags. It distinguishes an omitted
|
||||||
command uses CommandKit's typed bindings and shows the difference between an
|
count from an explicitly supplied zero and preserves repeated tag order.
|
||||||
omitted `--count` and an explicitly supplied zero, plus ordered repeated tags.
|
|
||||||
With no `--config`, application settings use the in-memory default. A supplied
|
## How it is wired
|
||||||
configuration and overlay are passed to ConfigKit unchanged. The comparison
|
|
||||||
command loads the two local fixtures and reports `changes.greeting: Goodbye`.
|
One ConfigKit scope supplies the standard `config` commands. Without `--config`,
|
||||||
The before fixture uses schema version 1, which ConfigKit reports as a provider
|
its loader uses an in-memory default. Supplied configuration and overlay values
|
||||||
warning in verbose mode. Ordinary mode hides the warning while preserving the
|
are passed to ConfigKit unchanged. The scope enables temporary overrides and
|
||||||
effective YAML report; quiet mode suppresses that report.
|
passes them into its ordinary ConfigKit load. The
|
||||||
|
[consumer guide](../../../../docs/consumers/guide.md#configkit-scopes) explains
|
||||||
|
loader and scope setup.
|
||||||
|
|
||||||
|
Run `GOWORK=off GOTOOLCHAIN=local go test ./...` from this example directory to
|
||||||
|
check it against its declared dependencies.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
schema:
|
||||||
|
version: 2
|
||||||
|
greeting: [invalid]
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,53 +1,106 @@
|
|||||||
package ckconfig
|
package ckconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
commandkit "go.maximumdirect.net/commandkit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "go.maximumdirect.net/configkit"
|
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"`
|
||||||
|
Sources []validationSource `json:"sources,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))
|
||||||
|
|
||||||
|
var explanationTemplate = template.Must(commandkit.ParseTextTemplate("explanation", `Scope: {{.Scope}}
|
||||||
|
Path: {{printf "%q" .Path}}
|
||||||
|
Effective: {{template "snapshot" .Effective}}
|
||||||
|
Origin: {{template "origin" .Origin}}
|
||||||
|
History:
|
||||||
|
{{range .History}} Before: {{template "snapshot" .Before}}
|
||||||
|
After: {{template "snapshot" .After}}
|
||||||
|
Origin: {{template "origin" .Origin}}
|
||||||
|
Reasons: {{range .Reasons}}{{printf "%q " .}}{{end}}
|
||||||
|
Dependencies: {{range .Dependencies}}{{printf "%q " .}}{{end}}
|
||||||
|
{{end}}{{with .SecretReference}}Secret reference: {{.Name}}
|
||||||
|
{{end}}{{with .Metadata}}{{if .Explicit}}Explicit: {{.Explicit}}
|
||||||
|
Null: {{.Null}}
|
||||||
|
{{end}}{{range .History}}Metadata history: {{printf "%q" .Path}}
|
||||||
|
{{range .Previous}} Previous origin: {{template "origin" .}}
|
||||||
|
{{end}}{{end}}{{end}}{{with .Sources}}Sources:
|
||||||
|
{{range .}} {{printf "%q" .ID}}: label={{printf "%q" .Label}} path={{printf "%q" .Path}}
|
||||||
|
{{end}}{{end}}{{define "snapshot"}}present={{.Present}} redacted={{.Redacted}} yaml={{printf "%q" .YAML}}{{end}}
|
||||||
|
{{- define "locations"}}{{range .}} {{printf "%q" .SourceID}}:{{.Line}}:{{.Column}}{{end}}{{end}}
|
||||||
|
{{- define "origin"}}layer={{printf "%q" .Layer}} operation={{printf "%q" .Operation}} locations:{{template "locations" .Locations}}
|
||||||
|
{{range .Inheritance}} Inheritance: from={{printf "%q" .From}} to={{printf "%q" .To}} reference={{printf "%q" .Reference}} locations:{{template "locations" .Locations}}
|
||||||
|
{{end}}{{end}}`, nil))
|
||||||
|
|
||||||
type snapshotDTO struct {
|
type snapshotDTO struct {
|
||||||
Present bool `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 +116,45 @@ 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{}}
|
||||||
|
var prefix []string
|
||||||
|
if plan.under != nil {
|
||||||
|
prefix = pointerTokens(plan.under.String())
|
||||||
|
}
|
||||||
|
for _, path := range acquired.Value.Metadata.HistoricalPaths() {
|
||||||
|
pointer := path.String()
|
||||||
|
if plan.under != nil && !pathWithin(pointer, prefix) {
|
||||||
|
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: pointer, 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 +168,53 @@ 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) {
|
if acquired.Value.Sources != nil {
|
||||||
var report bytes.Buffer
|
for _, source := range acquired.Value.Sources.Sources() {
|
||||||
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
|
report.Sources = append(report.Sources, validationSource{ID: source.ID, Label: source.Label, Path: source.Path})
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
return report.Bytes(), nil
|
}
|
||||||
})
|
renderErr := reporter.Report(commandkit.Report{Value: report, TextTemplate: explanationTemplate})
|
||||||
|
if renderErr != nil && invocation.Context.Err() != nil {
|
||||||
|
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err())
|
||||||
|
}
|
||||||
|
return commandkit.Success()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointerTokens(value string) []string {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return strings.Split(strings.TrimPrefix(value, "/"), "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathWithin(path string, prefix []string) bool {
|
||||||
|
child := pointerTokens(path)
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -1,30 +1,31 @@
|
|||||||
package ckconfig
|
package ckconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
commandkit "go.maximumdirect.net/commandkit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "go.maximumdirect.net/configkit"
|
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
|
||||||
@@ -89,14 +91,16 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
|
|||||||
byName: make(map[string]*preparedScope[I, S], len(suite.Scopes)),
|
byName: make(map[string]*preparedScope[I, S], len(suite.Scopes)),
|
||||||
bindingsByOperation: make(map[Operation][]commandkit.Flag[I]),
|
bindingsByOperation: make(map[Operation][]commandkit.Flag[I]),
|
||||||
}
|
}
|
||||||
selectors := make([]Selector[I], len(suite.Selectors))
|
seenNames := captureConfigScopes(prepared, suite.Scopes)
|
||||||
for index, selector := range suite.Selectors {
|
selectors, against := captureConfigSelectors(prepared, suite.Selectors, seenNames)
|
||||||
selector.Scopes = append([]string(nil), selector.Scopes...)
|
captureOperationBindings(prepared, selectors, against)
|
||||||
selectors[index] = selector
|
captureScopeInventory(prepared)
|
||||||
}
|
return prepared
|
||||||
|
}
|
||||||
|
|
||||||
seenNames := make(map[string]struct{}, len(suite.Scopes))
|
func captureConfigScopes[I, S any](prepared *preparedSuite[I, S], scopes []Scope[I, S]) map[string]struct{} {
|
||||||
for _, original := range suite.Scopes {
|
seenNames := make(map[string]struct{}, len(scopes))
|
||||||
|
for _, original := range scopes {
|
||||||
scope := original
|
scope := original
|
||||||
scope.Operations = normalizeOperations(scope.Operations, &prepared.issues, scope.Name)
|
scope.Operations = normalizeOperations(scope.Operations, &prepared.issues, scope.Name)
|
||||||
scope.issues = append([]error(nil), original.issues...)
|
scope.issues = append([]error(nil), original.issues...)
|
||||||
@@ -140,6 +144,20 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
|
|||||||
}
|
}
|
||||||
prepared.scopeChoices = joinNames(names)
|
prepared.scopeChoices = joinNames(names)
|
||||||
|
|
||||||
|
return seenNames
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureConfigSelectors[I, S any](prepared *preparedSuite[I, S], originals []Selector[I], seenNames map[string]struct{}) ([]Selector[I], []commandkit.Flag[I]) {
|
||||||
|
selectors := make([]Selector[I], len(originals))
|
||||||
|
against := make([]commandkit.Flag[I], len(originals))
|
||||||
|
for index, selector := range originals {
|
||||||
|
selector.Scopes = append([]string(nil), selector.Scopes...)
|
||||||
|
selectors[index] = selector
|
||||||
|
if selector.AgainstName != "" {
|
||||||
|
against[index] = commandkit.RenameFlag(selector.Binding, selector.AgainstName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for index, selector := range selectors {
|
for index, selector := range selectors {
|
||||||
seen := make(map[string]struct{}, len(selector.Scopes))
|
seen := make(map[string]struct{}, len(selector.Scopes))
|
||||||
uniqueScopes := make([]string, 0, len(selector.Scopes))
|
uniqueScopes := make([]string, 0, len(selector.Scopes))
|
||||||
@@ -158,17 +176,27 @@ 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])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return selectors, against
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureOperationBindings[I, S any](prepared *preparedSuite[I, S], selectors []Selector[I], against []commandkit.Flag[I]) {
|
||||||
for _, operation := range []Operation{Validate, Show, Sources, Explain, Diff} {
|
for _, operation := range []Operation{Validate, Show, Sources, Explain, Diff} {
|
||||||
for _, scope := range prepared.scopes {
|
for _, scope := range prepared.scopes {
|
||||||
if containsOperation(scope.scope.Operations, operation) {
|
if containsOperation(scope.scope.Operations, operation) {
|
||||||
@@ -176,22 +204,28 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureScopeInventory[I, S any](prepared *preparedSuite[I, S]) {
|
||||||
for _, scope := range prepared.scopes {
|
for _, scope := range prepared.scopes {
|
||||||
prepared.inventory.Scopes = append(prepared.inventory.Scopes, scopeMetadata{
|
prepared.inventory.Scopes = append(prepared.inventory.Scopes, scopeMetadata{
|
||||||
Name: scope.scope.Name,
|
Name: scope.scope.Name,
|
||||||
Operations: append([]Operation(nil), scope.scope.Operations...),
|
Operations: append([]Operation(nil), scope.scope.Operations...),
|
||||||
|
Features: featureNames(scope.scope.Features),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return prepared
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeOperations(operations []Operation, issues *[]error, scope string) []Operation {
|
func normalizeOperations(operations []Operation, issues *[]error, scope string) []Operation {
|
||||||
@@ -254,62 +288,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 }
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ package ckconfig
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -270,7 +273,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 +309,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,18 +480,55 @@ func TestExplainParsesBeforeAcquisitionAndPreservesNativeSafeHistory(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExplainTracksNativeRenderFailureWithoutReplacingOperationCause(t *testing.T) {
|
func TestExplainUsesAcquiredEvidenceAcrossFormats(t *testing.T) {
|
||||||
renderErr := errors.New("native explanation renderer failed")
|
result, err := configkit.Load(configkit.Mapping("original source", map[string]any{"token": "before"}), configkit.Options[map[string]any]{Overrides: []configkit.Override{{Path: configkit.Keys("token"), Value: "after", Flag: "--fixture-set"}}})
|
||||||
reader := &failingNativeExplanation{err: renderErr}
|
if err != nil {
|
||||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
t.Fatal(err)
|
||||||
Name: "app", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
}
|
||||||
return commandkit.Acquired[View]{Value: View{Explanation: reader}}, nil
|
path := configkit.Keys("token")
|
||||||
},
|
explanation, found := result.Explain(path)
|
||||||
}}}))
|
if !found {
|
||||||
var output bytes.Buffer
|
t.Fatal("missing fixture explanation")
|
||||||
outcome := app.Execute(context.Background(), []string{"config", "explain", "--path", "/token"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
}
|
||||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.RenderError, renderErr) || output.String() != "partial native explanation\n" {
|
metadata := result.Metadata()
|
||||||
t.Fatalf("native explanation failure outcome/output = %#v / %q", outcome, output.String())
|
reader := &nativeExplanation{value: explanation, found: true}
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "tenant", Operations: []Operation{Explain, Show}, Features: ScopeFeatures{PathShow: true}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
return commandkit.Acquired[View]{Value: View{Explanation: reader, Metadata: &metadata, Sources: result}}, nil
|
||||||
|
}}}}))
|
||||||
|
var focused bytes.Buffer
|
||||||
|
show := app.Execute(context.Background(), []string{"config", "show", "--scope=tenant", "--path=/token", "--format=json"}, commandkit.Streams{Out: &focused, Err: io.Discard})
|
||||||
|
if show.Kind != commandkit.OutcomeSuccess || strings.TrimSpace(focused.String()) != `"after"` {
|
||||||
|
t.Fatalf("focused show = %#v %q", show, focused.String())
|
||||||
|
}
|
||||||
|
for _, format := range []string{"text", "yaml", "json"} {
|
||||||
|
t.Run(format, func(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"config", "explain", "--scope=tenant", "--path=/token", "--format=" + format}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||||
|
if outcome.Status != 0 {
|
||||||
|
t.Fatalf("outcome=%#v output=%s", outcome, output.String())
|
||||||
|
}
|
||||||
|
for _, evidence := range []string{"tenant", "/token", "before", "after", "original source", "--fixture-set"} {
|
||||||
|
if !strings.Contains(output.String(), evidence) {
|
||||||
|
t.Errorf("missing %q: %s", evidence, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if format == "json" {
|
||||||
|
var report explanationReport
|
||||||
|
if err := json.Unmarshal(output.Bytes(), &report); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if report.Metadata == nil || report.Metadata.Explicit == nil || !*report.Metadata.Explicit || !slices.ContainsFunc(report.Metadata.History, func(record metadataHistoryRecord) bool { return len(record.Previous) > 0 }) {
|
||||||
|
t.Fatalf("missing metadata: %#v", report.Metadata)
|
||||||
|
}
|
||||||
|
} else if format == "text" && (!strings.Contains(output.String(), "Previous origin:") || !strings.Contains(output.String(), "Explicit: true")) {
|
||||||
|
t.Fatalf("missing text metadata: %s", output.String())
|
||||||
|
}
|
||||||
|
writeErr := errors.New("output failed")
|
||||||
|
outcome = app.Execute(context.Background(), []string{"config", "explain", "--scope=tenant", "--path=/token", "--format=" + format}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
|
||||||
|
if outcome.Status != 1 || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) {
|
||||||
|
t.Fatalf("delivery failure = %#v", outcome)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,7 +557,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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -547,30 +591,11 @@ type nativeExplanation struct {
|
|||||||
found bool
|
found bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type failingNativeExplanation struct{ err error }
|
|
||||||
|
|
||||||
func (reader *failingNativeExplanation) Explain(configkit.Path) (configkit.Explanation, bool) {
|
|
||||||
return configkit.Explanation{}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (reader *failingNativeExplanation) WriteExplanation(writer io.Writer, _ configkit.Path) error {
|
|
||||||
if _, err := io.WriteString(writer, "partial native explanation\n"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return reader.err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (reader *nativeExplanation) Explain(path configkit.Path) (configkit.Explanation, bool) {
|
func (reader *nativeExplanation) Explain(path configkit.Path) (configkit.Explanation, bool) {
|
||||||
reader.path = path
|
reader.path = path
|
||||||
return reader.value, reader.found
|
return reader.value, reader.found
|
||||||
}
|
}
|
||||||
|
|
||||||
func (reader *nativeExplanation) WriteExplanation(writer io.Writer, path configkit.Path) error {
|
|
||||||
reader.path = path
|
|
||||||
_, err := fmt.Fprintf(writer, "Explanation for %q\n", path.String())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
type failingOutput struct{ err error }
|
type failingOutput struct{ err error }
|
||||||
|
|
||||||
func (writer failingOutput) Write(value []byte) (int, error) {
|
func (writer failingOutput) Write(value []byte) (int, error) {
|
||||||
@@ -588,3 +613,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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
126
integrations/ckconfig/focused_reports_test.go
Normal file
126
integrations/ckconfig/focused_reports_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ module go.maximumdirect.net/ckconfig
|
|||||||
go 1.27.0
|
go 1.27.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
go.maximumdirect.net/commandkit v0.3.0
|
go.maximumdirect.net/commandkit v0.5.0
|
||||||
go.maximumdirect.net/configkit v1.1.0
|
go.maximumdirect.net/configkit v1.1.0
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
|||||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
go.maximumdirect.net/commandkit v0.3.0 h1:RCmg2GS8uzlLSJCIqarpn1uFDec0W3ylNNRRPT6kUFE=
|
go.maximumdirect.net/commandkit v0.4.0 h1:bWqflDWDn98FUCgh+0e+w6LGmvWIkaeJK519H7PjD2Y=
|
||||||
go.maximumdirect.net/commandkit v0.3.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
|
go.maximumdirect.net/commandkit v0.4.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
|
||||||
|
go.maximumdirect.net/commandkit v0.5.0 h1:Ln+bCp+FwCg4tZD3NaMKQXUDB3mPusgwsnRZmb0+3cM=
|
||||||
|
go.maximumdirect.net/commandkit v0.5.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
|
||||||
go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA=
|
go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA=
|
||||||
go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
|
go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
|
|||||||
79
integrations/ckconfig/independent_comparison_test.go
Normal file
79
integrations/ckconfig/independent_comparison_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,18 +9,28 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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 {
|
||||||
@@ -62,34 +73,57 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
|
|||||||
scopeChoices = append(scopeChoices, commandkit.CompletionChoice{Value: "all"})
|
scopeChoices = append(scopeChoices, commandkit.CompletionChoice{Value: "all"})
|
||||||
}
|
}
|
||||||
flags := []commandkit.Flag[*configCommandInput[I, S]]{
|
flags := []commandkit.Flag[*configCommandInput[I, S]]{
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported.", Completion: commandkit.CompleteChoices(scopeChoices...)}, &defaultScope, parseConfigString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Select a named configuration, or all where supported.", Completion: commandkit.CompleteChoices(scopeChoices...)}, &defaultScope, parseConfigString,
|
||||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).scope = value }),
|
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).scope = value }),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Output format.", Completion: formats}, &defaultFormat, parseConfigString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Select the configuration source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
||||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).format = value }),
|
|
||||||
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
|
||||||
}),
|
}),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Overlay source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Select the configuration overlay.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
||||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||||
(*input).selection.Overlay = value
|
(*input).selection.Overlay = value
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
if operation == Diff {
|
if operation == Diff {
|
||||||
|
flags = append(flags, comparisonOperationFlags[I, S]()...)
|
||||||
|
}
|
||||||
|
if operation == Validate {
|
||||||
flags = append(flags,
|
flags = append(flags,
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-config", Usage: "Configuration source for the right side.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
commandkit.BoolFlag(commandkit.FlagMeta{Name: "details", Usage: "Show detailed validation findings."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).details = value }),
|
||||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
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).against.Config = value
|
(*input).severities = value.Value
|
||||||
}),
|
}),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-overlay", Usage: "Overlay source for the right side.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "code", Usage: "Filter detailed findings by exact diagnostic code."}, []string(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[[]string]) {
|
||||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
(*input).codes = value.Value
|
||||||
(*input).against.Overlay = 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 paths that exist only in configuration history."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).historical = value }),
|
||||||
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "under", Usage: "List paths under a JSON Pointer."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).under = value }),
|
||||||
|
commandkit.BoolFlag(commandkit.FlagMeta{Name: "explicit", Usage: "Filter current paths by whether ConfigKit marks them explicit."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).explicit = value }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if operation == Show {
|
||||||
|
flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer to one effective value."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value }))
|
||||||
|
}
|
||||||
|
if operation == Sources {
|
||||||
|
flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "source-id", Usage: "Show one source ID from this configuration load."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).sourceID = value }))
|
||||||
}
|
}
|
||||||
flags = append(flags, commandkit.CaptureFlags(prepared.bindingsByOperation[operation], func(input **configCommandInput[I, S]) *commandkit.FlagValues[I] {
|
flags = append(flags, commandkit.CaptureFlags(prepared.bindingsByOperation[operation], func(input **configCommandInput[I, S]) *commandkit.FlagValues[I] {
|
||||||
if input == nil || *input == nil {
|
if input == nil || *input == nil {
|
||||||
@@ -97,12 +131,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 +158,28 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func comparisonOperationFlags[I, S any]() []commandkit.Flag[*configCommandInput[I, S]] {
|
||||||
|
return []commandkit.Flag[*configCommandInput[I, S]]{
|
||||||
|
commandkit.BoolFlag(commandkit.FlagMeta{Name: "details", Usage: "Show before and after values with presence and redaction details."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).details = value }),
|
||||||
|
commandkit.BoolFlag(commandkit.FlagMeta{Name: "check", Usage: "Return status 3 when differences are found."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).check = value }),
|
||||||
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-config", Usage: "Configuration source for the right side.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
||||||
|
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||||
|
(*input).against.Config = value
|
||||||
|
}),
|
||||||
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-overlay", Usage: "Overlay source for the right side.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
||||||
|
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||||
|
(*input).against.Overlay = value
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationDescription(operation Operation) string {
|
||||||
|
if operation == Explain {
|
||||||
|
return "Use --list to discover paths or --path POINTER to explain one path."
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func operationDeclaration(operation Operation) (string, string) {
|
func operationDeclaration(operation Operation) (string, string) {
|
||||||
switch operation {
|
switch operation {
|
||||||
case Validate:
|
case Validate:
|
||||||
@@ -126,33 +191,44 @@ func operationDeclaration(operation Operation) (string, string) {
|
|||||||
case Explain:
|
case Explain:
|
||||||
return "explain", "Explain a configuration path."
|
return "explain", "Explain a configuration path."
|
||||||
case Diff:
|
case Diff:
|
||||||
return "diff", "Compare two configuration selections."
|
return "diff", "Compare two configurations."
|
||||||
default:
|
default:
|
||||||
return string(operation), "Inspect configuration."
|
return string(operation), "Inspect configuration."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation Operation, input *configCommandInput[I, S]) error {
|
func checkConfigOperationOptions[I, S any](operation Operation, input *configCommandInput[I, S]) (bool, error) {
|
||||||
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 false, 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 false, 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 false, fmt.Errorf("--historical, --under, and --explicit require --list")
|
||||||
|
}
|
||||||
|
if !input.list.Value && !input.path.Set {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectConfigScopes[I, S any](prepared *preparedSuite[I, S], operation Operation, input *configCommandInput[I, S]) (string, []*preparedScope[I, S], error) {
|
||||||
scopeName := "app"
|
scopeName := "app"
|
||||||
if input.scope.Set {
|
if input.scope.Set {
|
||||||
scopeName = input.scope.Value
|
scopeName = input.scope.Value
|
||||||
}
|
}
|
||||||
if scopeName == "" {
|
if scopeName == "" {
|
||||||
return fmt.Errorf("configuration scope must not be empty")
|
return "", nil, fmt.Errorf("configuration scope must not be empty")
|
||||||
}
|
}
|
||||||
if scopeName == "all" && operation != Validate && operation != Show {
|
if scopeName == "all" && operation != Validate && operation != Show {
|
||||||
return fmt.Errorf("scope all is supported only for validate and show")
|
return "", nil, fmt.Errorf("scope all is supported only for validate and show")
|
||||||
|
}
|
||||||
|
if scopeName == "all" && operation == Show && input.path.Set {
|
||||||
|
return "", nil, fmt.Errorf("path-selected show requires one scope")
|
||||||
}
|
}
|
||||||
|
|
||||||
var selected []*preparedScope[I, S]
|
var selected []*preparedScope[I, S]
|
||||||
@@ -161,76 +237,127 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O
|
|||||||
} else if scope, exists := prepared.byName[scopeName]; exists {
|
} else if scope, exists := prepared.byName[scopeName]; exists {
|
||||||
selected = []*preparedScope[I, S]{scope}
|
selected = []*preparedScope[I, S]{scope}
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("unknown configuration scope %q (available: %s)", scopeName, prepared.scopeChoices)
|
return "", nil, fmt.Errorf("unknown configuration scope %q (available: %s)", scopeName, prepared.scopeChoices)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, scope := range selected {
|
for _, scope := range selected {
|
||||||
if !containsOperation(scope.scope.Operations, operation) {
|
if !containsOperation(scope.scope.Operations, operation) {
|
||||||
return fmt.Errorf("configuration scope %q does not support %s (available: %s)", scope.scope.Name, operation, joinOperations(scope.scope.Operations))
|
return "", nil, fmt.Errorf("configuration scope %q does not support %s (available: %s)", scope.scope.Name, operation, joinOperations(scope.scope.Operations))
|
||||||
|
}
|
||||||
|
if (len(input.selection.Overrides) > 0 || len(input.against.Overrides) > 0) && !scope.scope.Features.Overrides {
|
||||||
|
return "", nil, fmt.Errorf("configuration scope %q does not support overrides", scope.scope.Name)
|
||||||
|
}
|
||||||
|
if operation == Explain && input.list.Value && !scope.scope.Features.PathDiscovery {
|
||||||
|
return "", nil, fmt.Errorf("configuration scope %q does not support path discovery", scope.scope.Name)
|
||||||
|
}
|
||||||
|
if operation == Show && input.path.Set && !scope.scope.Features.PathShow {
|
||||||
|
return "", nil, fmt.Errorf("configuration scope %q does not support path-selected show", scope.scope.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return scopeName, selected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareComparisonInputs[I, S any](prepared *preparedSuite[I, S], input *configCommandInput[I, S], scope *preparedScope[I, S], plan *configOperationPlan[I, S]) error {
|
||||||
|
input.selection = cloneSelection(input.selection)
|
||||||
|
input.against = cloneSelection(input.against)
|
||||||
|
left, err := prepareScopeInput(prepared, input.values, scope, scope.bindings, input.selection, true)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("configuration scope %q left input: %w", scope.scope.Name, err)
|
||||||
|
}
|
||||||
|
right, err := prepareScopeInput(prepared, input.againstValues, scope, scope.againstBindings, input.against, true)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("configuration scope %q right input: %w", scope.scope.Name, err)
|
||||||
|
}
|
||||||
|
plan.comparison = &preparedComparison[I, S]{
|
||||||
|
scope: scope,
|
||||||
|
request: ComparisonRequest[I]{
|
||||||
|
Left: ComparisonSide[I]{Selection: input.selection, Input: left},
|
||||||
|
Right: ComparisonSide[I]{Selection: input.against, Input: right},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation Operation, input *configCommandInput[I, S]) error {
|
||||||
|
help, err := checkConfigOperationOptions(operation, input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if help {
|
||||||
|
input.plan = &configOperationPlan[I, S]{prepared: prepared, operation: operation, help: true}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
scopeName, selected, err := selectConfigScopes(prepared, operation, input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
var path configkit.Path
|
var path configkit.Path
|
||||||
if operation == Explain {
|
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)
|
if err := prepareComparisonInputs(prepared, input, selected[0], plan); err != nil {
|
||||||
scope := selected[0]
|
return err
|
||||||
left, err := prepareScopeInput(prepared, input.values, scope, input.selection)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("configuration scope %q left input: %w", scope.scope.Name, err)
|
|
||||||
}
|
|
||||||
right, err := prepareScopeInput(prepared, input.values, scope, input.against)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("configuration scope %q right input: %w", scope.scope.Name, err)
|
|
||||||
}
|
|
||||||
plan.comparison = &preparedComparison[I, S]{
|
|
||||||
scope: scope,
|
|
||||||
request: ComparisonRequest[I]{
|
|
||||||
Left: ComparisonSide[I]{Selection: input.selection, Input: left},
|
|
||||||
Right: ComparisonSide[I]{Selection: input.against, Input: right},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
input.plan = plan
|
input.plan = plan
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
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 +368,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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
65
integrations/ckconfig/overrides.go
Normal file
65
integrations/ckconfig/overrides.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
94
integrations/ckconfig/overrides_test.go
Normal file
94
integrations/ckconfig/overrides_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package ckconfig
|
package ckconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
@@ -18,10 +19,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 +55,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
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@@ -67,3 +68,39 @@ func TestBufferedReportDoesNotRejectCancellationAfterDeliveryStarts(t *testing.T
|
|||||||
t.Fatalf("post-delivery cancellation changed the operation outcome: %#v", got)
|
t.Fatalf("post-delivery cancellation changed the operation outcome: %#v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type effectiveWriterFunc func(io.Writer) error
|
||||||
|
|
||||||
|
func (write effectiveWriterFunc) WriteEffective(w io.Writer) error { return write(w) }
|
||||||
|
|
||||||
|
func TestShowCancellationDuringBufferedPreparation(t *testing.T) {
|
||||||
|
renderErr := errors.New("native rendering failed")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
renderErr error
|
||||||
|
}{{name: "cancellation"}, {name: "cancellation and rendering failure", renderErr: renderErr}} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
acquired, secondWrites := 0, 0
|
||||||
|
scope := func(name string, writer EffectiveWriter) Scope[struct{}, struct{}] {
|
||||||
|
return Scope[struct{}, struct{}]{Name: name, Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
acquired++
|
||||||
|
return commandkit.Acquired[View]{Value: View{Effective: writer}}, nil
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{
|
||||||
|
scope("app", effectiveWriterFunc(func(w io.Writer) error { _, _ = io.WriteString(w, "name: app\n"); cancel(); return tc.renderErr })),
|
||||||
|
scope("promptkit", effectiveWriterFunc(func(w io.Writer) error { secondWrites++; _, err := io.WriteString(w, "name: promptkit\n"); return err })),
|
||||||
|
}}))
|
||||||
|
var output bytes.Buffer
|
||||||
|
got := app.Execute(ctx, []string{"config", "show", "--scope=all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||||
|
if acquired != 2 || secondWrites != 0 || output.Len() != 0 || !errors.Is(got.Cause, context.Canceled) || got.Presentation.Output.WriteError != nil {
|
||||||
|
t.Fatalf("acquired=%d second=%d output=%q outcome=%#v", acquired, secondWrites, output.String(), got)
|
||||||
|
}
|
||||||
|
if tc.renderErr == nil && got.Presentation.Output.RenderError != nil || tc.renderErr != nil && !errors.Is(got.Presentation.Output.RenderError, tc.renderErr) {
|
||||||
|
t.Fatalf("render evidence = %#v", got.Presentation.Output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
152
integrations/ckconfig/shared_report_test.go
Normal file
152
integrations/ckconfig/shared_report_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,154 +22,87 @@ 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 {
|
var canceled error
|
||||||
return documents[0].view.Effective.WriteEffective(writer)
|
err := reporter.RenderOutput(func(io.Writer) error {
|
||||||
})
|
values := make(map[string]any, len(documents))
|
||||||
if err != nil && invocation.Context.Err() != nil {
|
for _, document := range documents {
|
||||||
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
|
canceled = err
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
var native bytes.Buffer
|
var native bytes.Buffer
|
||||||
if err := document.view.Effective.WriteEffective(&native); err != nil {
|
if err := document.view.Effective.WriteEffective(&native); err != nil {
|
||||||
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 canceled != nil {
|
||||||
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err())
|
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, canceled)
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
if canceled := invocation.Context.Err(); canceled != nil {
|
||||||
|
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, canceled)
|
||||||
|
}
|
||||||
|
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')
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +1,57 @@
|
|||||||
package ckconfig
|
package ckconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strconv"
|
"text/template"
|
||||||
|
|
||||||
commandkit "go.maximumdirect.net/commandkit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "go.maximumdirect.net/configkit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
"go.yaml.in/yaml/v4"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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"`
|
||||||
DestinationIsFallback bool `yaml:"destination_is_fallback"`
|
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"`
|
||||||
VersionEvidence *sourceVersionEvidenceDTO `yaml:"version_evidence"`
|
VersionEvidence *sourceVersionEvidenceDTO `json:"version_evidence"`
|
||||||
EmptyImportSelections []emptyImportSelectionDTO `yaml:"empty_import_selections"`
|
EmptyImportSelections []emptyImportSelectionDTO `json:"empty_import_selections"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type emptyImportSelectionDTO struct {
|
type emptyImportSelectionDTO struct {
|
||||||
Path string `yaml:"path"`
|
Path string `json:"path"`
|
||||||
Location locationDTO `yaml:"location"`
|
Location locationDTO `json:"location"`
|
||||||
Selector string `yaml:"selector"`
|
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 sourceVersionEvidenceDTO 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 {
|
||||||
@@ -69,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)
|
||||||
}
|
}
|
||||||
@@ -86,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"
|
||||||
@@ -124,77 +127,66 @@ func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOp
|
|||||||
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)})
|
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_is_fallback: %t\n", source.DestinationIsFallback)
|
{{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.VersionEvidence == nil {
|
{{end}}{{if .EmptyImportSelections}} empty_import_selections:
|
||||||
_, _ = writer.WriteString(" version_evidence: null\n")
|
{{range .EmptyImportSelections}} - path: {{printf "%q" .Path}}
|
||||||
} else {
|
location: {source_id: {{printf "%q" .Location.SourceID}}, line: {{.Location.Line}}, column: {{.Location.Column}}}
|
||||||
check := source.VersionEvidence
|
selector: {{printf "%q" .Selector}}
|
||||||
_, _ = fmt.Fprintf(writer, " version_evidence:\n path: %s\n", strconv.Quote(check.Path))
|
{{end}}{{else}} empty_import_selections: []
|
||||||
_, _ = fmt.Fprintf(writer, " version:\n present: %t\n null: %t\n text: %s\n",
|
{{end}}{{end}}{{else}}Sources: []
|
||||||
check.Version.Present, check.Version.Null, strconv.Quote(check.Version.Text))
|
{{end}}`, nil))
|
||||||
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)
|
|
||||||
}
|
|
||||||
if len(source.EmptyImportSelections) == 0 {
|
|
||||||
_, _ = writer.WriteString(" empty_import_selections: []\n")
|
|
||||||
} else {
|
|
||||||
_, _ = writer.WriteString(" empty_import_selections:\n")
|
|
||||||
for _, selection := range source.EmptyImportSelections {
|
|
||||||
_, _ = fmt.Fprintf(writer, " - path: %s\n location: {source_id: %s, line: %d, column: %d}\n selector: %s\n", strconv.Quote(selection.Path), strconv.Quote(selection.Location.SourceID), selection.Location.Line, selection.Location.Column, strconv.Quote(selection.Selector))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
||||||
|
|||||||
@@ -74,12 +74,20 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
|
|||||||
var report struct {
|
var report struct {
|
||||||
Scope string `yaml:"scope"`
|
Scope string `yaml:"scope"`
|
||||||
Sources []struct {
|
Sources []struct {
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
Destination string `yaml:"destination"`
|
Destination string `yaml:"destination"`
|
||||||
Placement string `yaml:"placement"`
|
Placement string `yaml:"placement"`
|
||||||
Layer string `yaml:"layer"`
|
Layer string `yaml:"layer"`
|
||||||
EmptyImportSelections []emptyImportSelectionDTO `yaml:"empty_import_selections"`
|
EmptyImportSelections []struct {
|
||||||
DestinationIsFallback bool `yaml:"destination_is_fallback"`
|
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 {
|
VersionEvidence *struct {
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
Version struct {
|
Version struct {
|
||||||
@@ -108,11 +116,8 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
|
|||||||
if root := report.Sources[0]; root.Destination != "" || root.Placement != "mapping" || root.DestinationIsFallback || root.VersionEvidence != nil || root.Layer != "main" {
|
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)
|
||||||
}
|
}
|
||||||
wantSelections := []emptyImportSelectionDTO{
|
selections := report.Sources[0].EmptyImportSelections
|
||||||
{Path: "/imports/first", Location: locationDTO{SourceID: "root.yml", Line: 3, Column: 4}, Selector: "glob"},
|
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 {
|
||||||
{Path: "/imports/second", Location: locationDTO{}, Selector: "path"},
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(report.Sources[0].EmptyImportSelections, wantSelections) || report.Sources[1].EmptyImportSelections == nil || len(report.Sources[1].EmptyImportSelections) != 0 {
|
|
||||||
t.Fatalf("import selections: %#v", report.Sources)
|
t.Fatalf("import selections: %#v", report.Sources)
|
||||||
}
|
}
|
||||||
if strings.Contains(yamlOutput.String(), "destination_unresolved:") || strings.Contains(yamlOutput.String(), "version_check:") {
|
if strings.Contains(yamlOutput.String(), "destination_unresolved:") || strings.Contains(yamlOutput.String(), "version_check:") {
|
||||||
|
|||||||
@@ -1,83 +1,163 @@
|
|||||||
package ckconfig
|
package ckconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"text/template"
|
||||||
"strings"
|
|
||||||
|
|
||||||
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()
|
||||||
|
severities := make(map[configkit.Severity]bool, len(plan.severities))
|
||||||
|
for _, severity := range plan.severities {
|
||||||
|
severities[severity] = true
|
||||||
|
}
|
||||||
|
codes := make(map[string]bool, len(plan.codes))
|
||||||
|
for _, code := range plan.codes {
|
||||||
|
codes[code] = true
|
||||||
|
}
|
||||||
for _, selected := range plan.scopes {
|
for _, selected := range plan.scopes {
|
||||||
if err := invocation.Context.Err(); err != nil {
|
if err := invocation.Context.Err(); err != nil {
|
||||||
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{}, severities, 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, severities, 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 map[configkit.Severity]bool, codes map[string]bool) *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 && !severities[record.Severity] {
|
||||||
record.WriteString(" - ")
|
continue
|
||||||
} else {
|
|
||||||
record.WriteString(" ")
|
|
||||||
}
|
}
|
||||||
record.WriteString(line)
|
if len(codes) > 0 && !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
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user