Add configuration path explanations

This commit is contained in:
2026-10-07 14:17:46 +00:00
parent 25b412ea93
commit c0be9ffadc
8 changed files with 437 additions and 9 deletions

View File

@@ -6,8 +6,8 @@ and shared command infrastructure across related applications.
The current Go library provides an instance-owned command registry, typed flag The current Go library provides an instance-owned command registry, typed flag
and operand bindings, Cobra-backed routing, invocation outcomes, generated help, and operand bindings, Cobra-backed routing, invocation outcomes, generated help,
application or CommandKit version reporting, configuration scope discovery, and application or CommandKit version reporting, configuration scope discovery, and
ConfigKit-backed validation, native effective YAML display, and source inventory ConfigKit-backed validation, native effective YAML display, source inventory,
reports. Explanation reports and PromptKit commands remain planned. and path explanation reports. PromptKit commands remain planned.
## Documentation ## Documentation

View File

@@ -111,6 +111,7 @@ type configInput[C any] struct {
Selection Selection Selection Selection
Scope string Scope string
Format string Format string
Path Supplied[string]
SelectorContext C SelectorContext C
selectors []selectorState[C] selectors []selectorState[C]
} }
@@ -131,8 +132,8 @@ func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error {
return fmt.Errorf("command path %q is already registered", "config") return fmt.Errorf("command path %q is already registered", "config")
} }
commands := make([]Command, 0, 4) commands := make([]Command, 0, 5)
installed := make([]ConfigOperation, 0, 3) installed := make([]ConfigOperation, 0, 4)
if configOperationDeclared(normalized.Scopes, ConfigValidate) { if configOperationDeclared(normalized.Scopes, ConfigValidate) {
validate, err := newConfigValidateCommand(normalized) validate, err := newConfigValidateCommand(normalized)
if err != nil { if err != nil {
@@ -157,6 +158,14 @@ func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error {
commands = append(commands, sources) commands = append(commands, sources)
installed = append(installed, ConfigSources) installed = append(installed, ConfigSources)
} }
if configOperationDeclared(normalized.Scopes, ConfigExplain) {
explain, err := newConfigExplainCommand(normalized)
if err != nil {
return err
}
commands = append(commands, explain)
installed = append(installed, ConfigExplain)
}
command, err := newConfigScopesCommandWithScopes(normalized.Scopes, installed) command, err := newConfigScopesCommandWithScopes(normalized.Scopes, installed)
if err != nil { if err != nil {
return err return err

View File

@@ -47,7 +47,7 @@ func TestConfigScopeDiscoveryOrdersAndDeduplicatesCapabilities(t *testing.T) {
if outcome.Kind != OutcomeSuccess { if outcome.Kind != OutcomeSuccess {
t.Fatalf("text inventory outcome = %#v", outcome) t.Fatalf("text inventory outcome = %#v", outcome)
} }
wantText := "app: validate, show\npromptkit: sources\nmetadata:\nzeta: show\n" wantText := "app: validate, show\npromptkit: sources\nmetadata:\nzeta: show, explain\n"
if textOutput.String() != wantText { if textOutput.String() != wantText {
t.Fatalf("text inventory = %q, want %q", textOutput.String(), wantText) t.Fatalf("text inventory = %q, want %q", textOutput.String(), wantText)
} }

View File

@@ -27,6 +27,153 @@ type capturedSourceReader struct {
calls int calls int
} }
func TestConfigExplainPreservesConfigKitProvenanceAndRedaction(t *testing.T) {
type entry struct {
Enabled bool `yaml:"enabled"`
Credential configkit.Secret `yaml:"credential"`
}
type configuration struct {
Entries map[string]entry `yaml:"entries"`
Odd map[string]string `yaml:"odd"`
Value string `yaml:"value"`
Dependency string `yaml:"dependency"`
Secret configkit.Secret `yaml:"secret"`
}
const secretValue = "EXPLAIN_TEST_SECRET_VALUE"
t.Setenv("EXPLAIN_TEST_SECRET", secretValue)
input := []byte("entries:\n parent:\n enabled: false\n credential: EXPLAIN_TEST_SECRET\n child:\n extends: parent\nodd:\n \"key/name~\": escaped\nvalue: original\ndependency: source\nsecret: EXPLAIN_TEST_SECRET\n")
result, err := configkit.Load[configuration](configkit.Bytes("explanation.yml", input), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
Secret: func(path configkit.Path) bool { return path.String() == "/entries/parent/credential" },
Resolve: func(value *configuration, resolution *configkit.Resolution) error {
resolution.Record(configkit.Keys("value"), configkit.ChangeNormalization, "upper case", configkit.Keys("dependency"))
value.Value = strings.ToUpper(value.Value)
return nil
},
}})
if err != nil {
t.Fatal(err)
}
loads, contexts := 0, 0
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) {
loads++
return result, nil
})
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{NewContext: func() struct{} { contexts++; return struct{}{} }, Scopes: []commandkit.ConfigScope[struct{}]{scope}}); err != nil {
t.Fatal(err)
}
runYAML := func(pointer string) (map[string]any, commandkit.Outcome, string) {
var output bytes.Buffer
argv := []string{"config", "explain", "--path", pointer, "--format", "yaml"}
if pointer == "" {
argv = []string{"config", "explain", "--path=", "--format", "yaml"}
}
outcome := app.Execute(context.Background(), argv, commandkit.Streams{Out: &output, Err: io.Discard})
var report map[string]any
if outcome.Kind == commandkit.OutcomeSuccess {
if err := yaml.Unmarshal(output.Bytes(), &report); err != nil {
t.Fatalf("decode explanation YAML: %v; output=%q", err, output.String())
}
}
return report, outcome, output.String()
}
path := "/entries/child/enabled"
var native bytes.Buffer
if err := result.WriteExplanation(&native, configkit.Keys("entries", "child", "enabled")); err != nil {
t.Fatal(err)
}
var textOutput bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "explain", "--path", path}, commandkit.Streams{Out: &textOutput, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || textOutput.String() != native.String() {
t.Fatalf("native text outcome=%#v output=%q want=%q", outcome, textOutput.String(), native.String())
}
var inherited map[string]any
inherited, outcome, _ = runYAML(path)
if outcome.Kind != commandkit.OutcomeSuccess || inherited["path"] != path {
t.Fatalf("inherited explanation outcome=%#v report=%#v", outcome, inherited)
}
effective := inherited["effective"].(map[string]any)
if effective["present"] != true || effective["yaml"] != "false\n" {
t.Fatalf("inherited effective snapshot = %#v", effective)
}
origin := inherited["origin"].(map[string]any)
steps := origin["inheritance"].([]any)
step := steps[0].(map[string]any)
if step["from"] != "/entries/parent/enabled" || step["to"] != path || step["reference"] != "/entries/child/extends" {
t.Fatalf("inheritance paths = %#v", step)
}
escaped, outcome, _ := runYAML("/odd/key~1name~0")
if outcome.Kind != commandkit.OutcomeSuccess || escaped["path"] != "/odd/key~1name~0" || escaped["effective"].(map[string]any)["yaml"] != "escaped\n" {
t.Fatalf("escaped path outcome=%#v report=%#v", outcome, escaped)
}
historical, outcome, _ := runYAML("/entries/child/extends")
if outcome.Kind != commandkit.OutcomeSuccess || historical["effective"].(map[string]any)["present"] != false || len(historical["history"].([]any)) == 0 {
t.Fatalf("historical path outcome=%#v report=%#v", outcome, historical)
}
resolved, outcome, _ := runYAML("/value")
if outcome.Kind != commandkit.OutcomeSuccess || resolved["effective"].(map[string]any)["yaml"] != "ORIGINAL\n" {
t.Fatalf("resolved snapshot outcome=%#v report=%#v", outcome, resolved)
}
changes := resolved["history"].([]any)
last := changes[len(changes)-1].(map[string]any)
if last["reasons"].([]any)[0] != "upper case" || last["dependencies"].([]any)[0] != "/dependency" {
t.Fatalf("resolution change = %#v", last)
}
secretReport, outcome, secretText := runYAML("/entries/child/credential")
secretEffective := secretReport["effective"].(map[string]any)
if outcome.Kind != commandkit.OutcomeSuccess || secretEffective["redacted"] != true || strings.Contains(secretText, secretValue) {
t.Fatalf("secret report outcome=%#v report=%#v", outcome, secretReport)
}
for _, change := range secretReport["history"].([]any) {
entry := change.(map[string]any)
for _, key := range []string{"before", "after"} {
snapshot := entry[key].(map[string]any)
if snapshot["present"] == true && snapshot["redacted"] != true {
t.Fatalf("secret history was not redacted: %#v", entry)
}
}
}
directSecret, outcome, directSecretText := runYAML("/secret")
secretRef, refPresent := directSecret["secret_reference"].(map[string]any)
if !refPresent {
t.Fatalf("secret reference was absent: %#v", directSecret)
}
if outcome.Kind != commandkit.OutcomeSuccess || secretRef["name"] != "EXPLAIN_TEST_SECRET" || strings.Contains(directSecretText, secretValue) {
t.Fatalf("direct secret reference outcome=%#v report=%#v", outcome, directSecret)
}
root, outcome, _ := runYAML("")
if outcome.Kind != commandkit.OutcomeSuccess || root["path"] != "" {
t.Fatalf("root path outcome=%#v report=%#v", outcome, root)
}
loadsBeforeInvalid, contextsBeforeInvalid := loads, contexts
outcome = app.Execute(context.Background(), []string{"config", "explain"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || loads != loadsBeforeInvalid || contexts != contextsBeforeInvalid {
t.Fatalf("missing path outcome=%#v loads=%d contexts=%d", outcome, loads, contexts)
}
outcome = app.Execute(context.Background(), []string{"config", "explain", "--path", "/bad~2path"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || outcome.Status != 2 || loads != loadsBeforeInvalid || contexts != contextsBeforeInvalid {
t.Fatalf("malformed path outcome=%#v loads=%d contexts=%d", outcome, loads, contexts)
}
outcome = app.Execute(context.Background(), []string{"config", "explain", "--path", "/does-not-exist"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 {
t.Fatalf("unknown path outcome = %#v", outcome)
}
partial := &configKitShowPartialWriter{failure: io.ErrClosedPipe}
outcome = app.Execute(context.Background(), []string{"config", "explain", "--path", path}, commandkit.Streams{Out: partial, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.DeliveryError, io.ErrClosedPipe) {
t.Fatalf("explanation writer failure = %#v", outcome)
}
}
func (reader *capturedSourceReader) Sources() []configkit.SourceInfo { func (reader *capturedSourceReader) Sources() []configkit.SourceInfo {
reader.calls++ reader.calls++
return reader.entries return reader.entries

View File

@@ -173,6 +173,53 @@ including empty fields, zero coordinates, and synthetic sources without paths.
An empty inventory is `sources: []`. Text output begins with the scope heading An empty inventory is `sources: []`. Text output begins with the scope heading
and then prints the same source fields, quoting labels and paths. and then prints the same source fields, quoting labels and paths.
### Explain a configuration path
`config explain --path POINTER` explains the effective and historical value at a
JSON Pointer path. `--path` is required; an explicitly empty value selects the
root. The default scope is `app`; `--format yaml` selects structured output, and
text is the default. `--scope all` is unsupported.
```text
example config explain --path /server/addr
example config explain --path /profiles/team~1summary --format yaml
example config explain --path= --format yaml
```
Pointer tokens use JSON Pointer escaping: `~1` represents `/` and `~0`
represents `~`. A malformed pointer is a usage error with status `2` and is
rejected before acquisition. A well-formed path that is unknown in the selected
configuration is an operational error with status `1`. Text output comes from
the scope's native explanation writer.
YAML output uses these root fields:
```yaml
scope: app
path: /server/addr
effective:
present: true
redacted: false
yaml: "localhost:8080\n"
origin:
layer: base
locations:
- source_id: source-1
line: 4
column: 9
operation: ""
inheritance: []
history: []
secret_reference: null
```
Snapshot `yaml` values remain strings, including null and empty fragments.
Origin and history retain source locations, operation names, inheritance paths,
ordered reasons and dependencies. Secret references contain only the owning
environment-variable name; effective and historical secret fragments remain
redacted. Empty location, inheritance, history, reason, and dependency lists are
serialized as `[]`.
## Outcome status ## Outcome status
CommandKit usage failures use status `2`; operational failures use status `1`. CommandKit usage failures use status `2`; operational failures use status `1`.

View File

@@ -5,8 +5,9 @@ decisions, implementation plans, identity and outcome primitives, typed command
declarations, an instance-owned registry, a Cobra-backed routing and parsing declarations, an instance-owned registry, a Cobra-backed routing and parsing
adapter, public `App.Execute` coordination, and generated help/version commands. adapter, public `App.Execute` coordination, and generated help/version commands.
Configuration suite registration, scope discovery, ConfigKit integration, Configuration suite registration, scope discovery, ConfigKit integration,
validation, native effective YAML display, and source inventory reports are validation, native effective YAML display, source inventory, and path
implemented; other configuration and PromptKit operations remain pending. The explanation reports are implemented; other configuration and PromptKit
operations remain pending. The
[README](../README.md) provides project orientation; the [README](../README.md) provides project orientation; the
[documentation policy](policy/documentation.md#canonical-ownership) assigns [documentation policy](policy/documentation.md#canonical-ownership) assigns
canonical documentation owners. canonical documentation owners.

View File

@@ -1,6 +1,6 @@
# Initial CommandKit implementation plan # Initial CommandKit implementation plan
Status: implementation underway; Stages 1-10 complete and Stages 11-14 pending. Status: implementation underway; Stages 1-11 complete and Stages 12-14 pending.
Date: 2026-10-07. Date: 2026-10-07.
@@ -676,7 +676,7 @@ describe the report.
## Stage 11: implement path explanation reports ## Stage 11: implement path explanation reports
**Status:** pending. **Prerequisite:** Stage 10. **Status:** complete. **Prerequisite:** Stage 10.
1. Implement single-scope `config explain --path POINTER`. Presence is required; 1. Implement single-scope `config explain --path POINTER`. Presence is required;
explicitly empty is the root. Parse with public `configkit.ParsePath` before explicitly empty is the root. Parse with public `configkit.ParsePath` before
@@ -704,6 +704,16 @@ inherited values; malformed versus unknown outcomes; redacted before/after and
reference-name preservation; YAML snapshot strings and inheritance pointers; reference-name preservation; YAML snapshot strings and inheritance pointers;
native text equivalence; writer failure. Update explanation CLI documentation. native text equivalence; writer failure. Update explanation CLI documentation.
**Completion evidence:** installed single-scope `config explain` with required
JSON Pointer `--path`, text as the native writer output, and explicit YAML DTOs
for snapshots, origins, history, inheritance, and secret references. Malformed
pointers return usage before context creation or acquisition; unknown paths are
operational failures checked before native writing. Checks cover root and escaped
paths, inherited and historical values, ordered resolution dependencies, secret
redaction and name-only references, exact native text, missing/invalid/unknown
paths, and writer failure. `go test ./...` passes; README, development orientation,
and CLI reference describe the feature.
## Stage 12: integrate the PromptKit configuration scope ## Stage 12: integrate the PromptKit configuration scope
**Status:** pending. **Prerequisite:** Stage 11. **Status:** pending. **Prerequisite:** Stage 11.

214
explain.go Normal file
View File

@@ -0,0 +1,214 @@
package commandkit
import (
"bytes"
"errors"
"fmt"
"io"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func newConfigExplainCommand[C any](suite ConfigSuite[C]) (Command, error) {
flags := configInputFlags(suite, "text")
flags = append(flags, ValueFlag[configInput[C]](FlagMeta{
Name: "path",
Usage: "Configuration path as a JSON Pointer; an empty value selects the root.",
Required: true,
}, nil, parseString, func(input *configInput[C], value Supplied[string]) {
input.Path = value
}))
spec := CommandSpec[configInput[C]]{
Name: string(ConfigExplain),
Summary: "Explain the effective and historical value at a configuration path.",
Flags: flags,
CheckInput: func(input configInput[C]) error {
if _, err := preflightConfig(suite, input, ConfigExplain); err != nil {
return err
}
if !input.Path.Set {
return fmt.Errorf("--path is required")
}
if _, err := configkit.ParsePath(input.Path.Value); err != nil {
return fmt.Errorf("invalid configuration path %q: %w", input.Path.Value, err)
}
return nil
},
Run: func(invocation Invocation, input configInput[C]) Outcome {
return runConfigExplanation(invocation, suite, input)
},
}
return NewCommand(spec)
}
type explanationReport struct {
Scope string `yaml:"scope"`
Path string `yaml:"path"`
Effective snapshotDTO `yaml:"effective"`
Origin explanationOriginDTO `yaml:"origin"`
History []explanationChangeDTO `yaml:"history"`
SecretReference *secretReferenceDTO `yaml:"secret_reference"`
}
type snapshotDTO struct {
Present bool `yaml:"present"`
Redacted bool `yaml:"redacted"`
YAML string `yaml:"yaml"`
}
type explanationOriginDTO struct {
Layer string `yaml:"layer"`
Locations []locationDTO `yaml:"locations"`
Operation string `yaml:"operation"`
Inheritance []inheritanceStepDTO `yaml:"inheritance"`
}
type inheritanceStepDTO struct {
From string `yaml:"from"`
To string `yaml:"to"`
Reference string `yaml:"reference"`
Locations []locationDTO `yaml:"locations"`
}
type explanationChangeDTO struct {
Before snapshotDTO `yaml:"before"`
After snapshotDTO `yaml:"after"`
Origin explanationOriginDTO `yaml:"origin"`
Reasons []string `yaml:"reasons"`
Dependencies []string `yaml:"dependencies"`
}
type secretReferenceDTO struct {
Name string `yaml:"name"`
}
func runConfigExplanation[C any](invocation Invocation, suite ConfigSuite[C], input configInput[C]) Outcome {
scopeNames, err := selectConfigScopes(suite, input, ConfigExplain)
if err != nil {
return Failure(err)
}
if !input.Path.Set {
return Failure(fmt.Errorf("--path is required"))
}
path, err := configkit.ParsePath(input.Path.Value)
if err != nil {
return Usage(fmt.Errorf("invalid configuration path %q: %w", input.Path.Value, err))
}
name := scopeNames[0]
contexts := contextsForScopes(suite, input, scopeNames)
scope, ok := findConfigScope(suite.Scopes, name)
if !ok || scope.Acquire == nil {
return Failure(fmt.Errorf("configuration scope %q has no acquisition callback", name))
}
acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigExplain, Selection: input.Selection}, contexts[name])
diagnostics := append([]Diagnostic(nil), acquired.Diagnostics...)
if acquireErr != nil {
return explanationFailure(acquireErr, diagnostics, false)
}
if acquired.Value.Explanation == nil {
return explanationFailure(fmt.Errorf("configuration scope %q did not provide an explanation reader", name), diagnostics, false)
}
explanation, found := acquired.Value.Explanation.Explain(path)
if !found {
return explanationFailure(fmt.Errorf("configuration path %q is not known in scope %q", path.String(), name), diagnostics, false)
}
if input.Format != "yaml" {
return writeNativeExplanation(invocation.Streams.Out, acquired.Value.Explanation, path, diagnostics)
}
var report bytes.Buffer
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
return explanationFailure(err, diagnostics, false)
}
if err := writeReport(invocation.Streams.Out, report.Bytes()); err != nil {
outcome := explanationFailure(err, diagnostics, true)
outcome.DeliveryError = err
return outcome
}
outcome := Success()
outcome.Diagnostics = diagnostics
return outcome
}
func writeNativeExplanation(writer io.Writer, reader ExplanationReader, path configkit.Path, diagnostics []Diagnostic) Outcome {
checked := &checkedWriter{writer: writer}
renderErr := reader.WriteExplanation(checked, path)
if checked.failure != nil {
if renderErr == nil {
renderErr = checked.failure
} else {
renderErr = errors.Join(renderErr, checked.failure)
}
}
if renderErr != nil {
outcome := explanationFailure(renderErr, diagnostics, true)
outcome.DeliveryError = checked.failure
return outcome
}
outcome := Success()
outcome.Diagnostics = diagnostics
return outcome
}
func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport {
report := explanationReport{
Scope: scope,
Path: explanation.Path.String(),
Effective: snapshotReport(explanation.Effective),
Origin: originReport(explanation.Origin),
History: make([]explanationChangeDTO, 0, len(explanation.History)),
}
for _, change := range explanation.History {
entry := explanationChangeDTO{
Before: snapshotReport(change.Before),
After: snapshotReport(change.After),
Origin: originReport(change.Origin),
Reasons: append([]string{}, change.Reasons...),
Dependencies: make([]string, 0, len(change.Dependencies)),
}
for _, dependency := range change.Dependencies {
entry.Dependencies = append(entry.Dependencies, dependency.String())
}
report.History = append(report.History, entry)
}
if explanation.SecretReference != nil {
report.SecretReference = &secretReferenceDTO{Name: explanation.SecretReference.Name}
}
return report
}
func snapshotReport(snapshot configkit.ValueSnapshot) snapshotDTO {
return snapshotDTO{Present: snapshot.Present, Redacted: snapshot.Redacted, YAML: snapshot.YAML}
}
func originReport(origin configkit.Origin) explanationOriginDTO {
report := explanationOriginDTO{
Layer: string(origin.Layer),
Locations: locationReports(origin.Locations),
Operation: string(origin.Operation),
Inheritance: make([]inheritanceStepDTO, 0, len(origin.Inheritance)),
}
for _, step := range origin.Inheritance {
report.Inheritance = append(report.Inheritance, inheritanceStepDTO{
From: step.From.String(),
To: step.To.String(),
Reference: step.Reference.String(),
Locations: locationReports(step.Locations),
})
}
return report
}
func locationReports(locations []configkit.Location) []locationDTO {
report := make([]locationDTO, 0, len(locations))
for _, location := range locations {
report = append(report, locationDTO{SourceID: location.SourceID, Line: location.Line, Column: location.Column})
}
return report
}
func explanationFailure(cause error, diagnostics []Diagnostic, reportStarted bool) Outcome {
outcome := Failure(cause)
outcome.Diagnostics = diagnostics
outcome.ReportIncomplete = reportStarted
return outcome
}