Render configuration explanations from acquired reports

This commit is contained in:
2026-11-07 12:01:53 +00:00
parent 3a7e2d4ce7
commit c61e125649
4 changed files with 92 additions and 30 deletions

View File

@@ -462,9 +462,9 @@ when present, its explicitness. Path discovery does not include values or histor
Pointer tokens use JSON Pointer escaping: `~1` represents `/` and `~0` Pointer tokens use JSON Pointer escaping: `~1` represents `/` and `~0`
represents `~`. A malformed pointer is a usage error with status `2` and is represents `~`. A malformed pointer is a usage error with status `2` and is
rejected before acquisition. A well-formed path that is unknown in the selected rejected before acquisition. A well-formed path that is unknown in the selected
configuration is an operational error with status `1`. Text output includes configuration is an operational error with status `1`. Text, YAML, and JSON
the scope's native explanation and available field metadata. Structured output render the same acquired explanation, available field metadata and metadata history, and source labels. Text uses CommandKit's
includes metadata when supplied by the scope. explanation template; the handler does not call the provider's text writer.
Structured output uses these root fields: Structured output uses these root fields:

View File

@@ -1,6 +1,6 @@
# Shared reporting and PromptKit adoption remediation # Shared reporting and PromptKit adoption remediation
Status: seven open findings. R1–R3 are from the architectural review of Status: six open findings; R1 complete. R1–R3 are from the architectural review of
`f83866e44a8a9066db52cbd2aa5cccde0d69a839`. R4–R7 are from the algorithmic `f83866e44a8a9066db52cbd2aa5cccde0d69a839`. R4–R7 are from the algorithmic
audit of `f5b2d1f80c4720b762f3301c29d5050710c210d0`; R4 is a correctness audit of `f5b2d1f80c4720b762f3301c29d5050710c210d0`; R4 is a correctness
issue and R5–R7 are efficiency opportunities for consideration. issue and R5–R7 are efficiency opportunities for consideration.
@@ -33,6 +33,11 @@ These findings do not request stronger domain validation or new guarantees.
## R1 — P2: Explain still uses a separate provider text renderer ## R1 — P2: Explain still uses a separate provider text renderer
**Complete.** A reusable template now renders the acquired projection, including
scope, metadata history, origins, and a shared source legend. No provider text
writer is called by the handler. Cross-format evidence and delivery-failure
regressions and the independent ckconfig suite pass with Go 1.27.0.
**Location:** [explain.go](../../integrations/ckconfig/explain.go), **Location:** [explain.go](../../integrations/ckconfig/explain.go),
`runConfigExplanation`, particularly lines 160–169. `runConfigExplanation`, particularly lines 160–169.

View File

@@ -1,7 +1,6 @@
package ckconfig package ckconfig
import ( import (
"bytes"
"fmt" "fmt"
"strings" "strings"
"text/template" "text/template"
@@ -18,6 +17,7 @@ type explanationReport struct {
History []explanationChangeDTO `json:"history"` History []explanationChangeDTO `json:"history"`
SecretReference *secretReferenceDTO `json:"secret_reference"` SecretReference *secretReferenceDTO `json:"secret_reference"`
Metadata *explanationMetadata `json:"metadata,omitempty"` Metadata *explanationMetadata `json:"metadata,omitempty"`
Sources []validationSource `json:"sources,omitempty"`
} }
type explanationMetadata struct { type explanationMetadata struct {
@@ -48,6 +48,29 @@ type pathMetadata struct {
var pathDiscoveryTemplate = template.Must(commandkit.ParseTextTemplate("paths", "{{range .Paths}}{{printf \"%q\" .Path}} {{if .Present}}{{if .Metadata.Explicit}}explicit{{else}}non-explicit{{end}}{{else}}historical{{end}}\n{{end}}", nil)) var 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 `json:"present"` Present bool `json:"present"`
Redacted bool `json:"redacted"` Redacted bool `json:"redacted"`
@@ -157,17 +180,12 @@ func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *conf
report.Metadata = metadata report.Metadata = metadata
} }
} }
text := template.Must(commandkit.ParseTextTemplate("explanation", "{{render .}}", template.FuncMap{ if acquired.Value.Sources != nil {
"render": func(report explanationReport) (string, error) { for _, source := range acquired.Value.Sources.Sources() {
var output bytes.Buffer report.Sources = append(report.Sources, validationSource{ID: source.ID, Label: source.Label, Path: source.Path})
err := acquired.Value.Explanation.WriteExplanation(&output, plan.path) }
if err == nil && report.Metadata != nil && report.Metadata.Explicit != nil { }
_, err = fmt.Fprintf(&output, "Explicit: %t\nNull: %t\n", *report.Metadata.Explicit, *report.Metadata.Null) renderErr := reporter.Report(commandkit.Report{Value: report, TextTemplate: explanationTemplate})
}
return output.String(), err
},
}))
renderErr := reporter.Report(commandkit.Report{Value: report, TextTemplate: text})
if renderErr != nil && invocation.Context.Err() != nil { if renderErr != nil && invocation.Context.Err() != nil {
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err()) return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err())
} }

View File

@@ -3,10 +3,12 @@ package ckconfig
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"reflect" "reflect"
"slices"
"strings" "strings"
"testing" "testing"
@@ -478,18 +480,50 @@ 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.Len() != 0 { metadata := result.Metadata()
t.Fatalf("native explanation failure outcome/output = %#v / %q", outcome, output.String()) reader := &failingNativeExplanation{value: explanation, err: errors.New("provider writer must not run")}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "tenant", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Explanation: reader, Metadata: &metadata, Sources: result}}, nil
}}}}))
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 || reader.calls != 0 {
t.Fatalf("outcome=%#v writer calls=%d output=%s", outcome, reader.calls, output.String())
}
for _, evidence := range []string{"tenant", "/token", "before", "after", "original source", "--fixture-set"} {
if !strings.Contains(output.String(), evidence) {
t.Errorf("missing %q: %s", evidence, output.String())
}
}
if format == "json" {
var report explanationReport
if err := json.Unmarshal(output.Bytes(), &report); err != nil {
t.Fatal(err)
}
if report.Metadata == nil || report.Metadata.Explicit == nil || !*report.Metadata.Explicit || !slices.ContainsFunc(report.Metadata.History, func(record metadataHistoryRecord) bool { return len(record.Previous) > 0 }) {
t.Fatalf("missing metadata: %#v", report.Metadata)
}
} else if format == "text" && (!strings.Contains(output.String(), "Previous origin:") || !strings.Contains(output.String(), "Explicit: true")) {
t.Fatalf("missing text metadata: %s", output.String())
}
writeErr := errors.New("output failed")
outcome = app.Execute(context.Background(), []string{"config", "explain", "--scope=tenant", "--path=/token", "--format=" + format}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
if outcome.Status != 1 || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) {
t.Fatalf("delivery failure = %#v", outcome)
}
})
} }
} }
@@ -552,13 +586,18 @@ type nativeExplanation struct {
found bool found bool
} }
type failingNativeExplanation struct{ err error } type failingNativeExplanation struct {
err error
value configkit.Explanation
calls int
}
func (reader *failingNativeExplanation) Explain(configkit.Path) (configkit.Explanation, bool) { func (reader *failingNativeExplanation) Explain(configkit.Path) (configkit.Explanation, bool) {
return configkit.Explanation{}, true return reader.value, true
} }
func (reader *failingNativeExplanation) WriteExplanation(writer io.Writer, _ configkit.Path) error { func (reader *failingNativeExplanation) WriteExplanation(writer io.Writer, _ configkit.Path) error {
reader.calls++
if _, err := io.WriteString(writer, "partial native explanation\n"); err != nil { if _, err := io.WriteString(writer, "partial native explanation\n"); err != nil {
return err return err
} }