From c0be9ffadcd037805905e48b38383b50043b0459 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 7 Oct 2026 14:17:46 +0000 Subject: [PATCH] Add configuration path explanations --- README.md | 4 +- config.go | 13 +- config_test.go | 2 +- configkit_test.go | 147 ++++++++++++++++++++++ docs/cli.md | 47 ++++++++ docs/development.md | 5 +- docs/roadmap/implementation.md | 14 ++- explain.go | 214 +++++++++++++++++++++++++++++++++ 8 files changed, 437 insertions(+), 9 deletions(-) create mode 100644 explain.go diff --git a/README.md b/README.md index 8f0dbb1..b9db8a2 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ and shared command infrastructure across related applications. The current Go library provides an instance-owned command registry, typed flag and operand bindings, Cobra-backed routing, invocation outcomes, generated help, application or CommandKit version reporting, configuration scope discovery, and -ConfigKit-backed validation, native effective YAML display, and source inventory -reports. Explanation reports and PromptKit commands remain planned. +ConfigKit-backed validation, native effective YAML display, source inventory, +and path explanation reports. PromptKit commands remain planned. ## Documentation diff --git a/config.go b/config.go index b31e337..e4e63fa 100644 --- a/config.go +++ b/config.go @@ -111,6 +111,7 @@ type configInput[C any] struct { Selection Selection Scope string Format string + Path Supplied[string] SelectorContext 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") } - commands := make([]Command, 0, 4) - installed := make([]ConfigOperation, 0, 3) + commands := make([]Command, 0, 5) + installed := make([]ConfigOperation, 0, 4) if configOperationDeclared(normalized.Scopes, ConfigValidate) { validate, err := newConfigValidateCommand(normalized) if err != nil { @@ -157,6 +158,14 @@ func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error { commands = append(commands, sources) 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) if err != nil { return err diff --git a/config_test.go b/config_test.go index 57d221f..8d44e5b 100644 --- a/config_test.go +++ b/config_test.go @@ -47,7 +47,7 @@ func TestConfigScopeDiscoveryOrdersAndDeduplicatesCapabilities(t *testing.T) { if outcome.Kind != OutcomeSuccess { 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 { t.Fatalf("text inventory = %q, want %q", textOutput.String(), wantText) } diff --git a/configkit_test.go b/configkit_test.go index 5f620ed..1b9acbb 100644 --- a/configkit_test.go +++ b/configkit_test.go @@ -27,6 +27,153 @@ type capturedSourceReader struct { 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 { reader.calls++ return reader.entries diff --git a/docs/cli.md b/docs/cli.md index 2d0f3be..d0b8a4d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 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 CommandKit usage failures use status `2`; operational failures use status `1`. diff --git a/docs/development.md b/docs/development.md index 0b6f3c2..8349ac6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -5,8 +5,9 @@ decisions, implementation plans, identity and outcome primitives, typed command declarations, an instance-owned registry, a Cobra-backed routing and parsing adapter, public `App.Execute` coordination, and generated help/version commands. Configuration suite registration, scope discovery, ConfigKit integration, -validation, native effective YAML display, and source inventory reports are -implemented; other configuration and PromptKit operations remain pending. The +validation, native effective YAML display, source inventory, and path +explanation reports are implemented; other configuration and PromptKit +operations remain pending. The [README](../README.md) provides project orientation; the [documentation policy](policy/documentation.md#canonical-ownership) assigns canonical documentation owners. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index ade15f2..cd41cf2 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # 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. @@ -676,7 +676,7 @@ describe the report. ## 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; 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; 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 **Status:** pending. **Prerequisite:** Stage 11. diff --git a/explain.go b/explain.go new file mode 100644 index 0000000..a44690d --- /dev/null +++ b/explain.go @@ -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 +}