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`
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 includes
the scope's native explanation and available field metadata. Structured output
includes metadata when supplied by the scope.
configuration is an operational error with status `1`. Text, YAML, and JSON
render the same acquired explanation, available field metadata and metadata history, and source labels. Text uses CommandKit's
explanation template; the handler does not call the provider's text writer.
Structured output uses these root fields:

View File

@@ -1,6 +1,6 @@
# 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
audit of `f5b2d1f80c4720b762f3301c29d5050710c210d0`; R4 is a correctness
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
**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),
`runConfigExplanation`, particularly lines 160–169.

View File

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

View File

@@ -3,10 +3,12 @@ package ckconfig
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"reflect"
"slices"
"strings"
"testing"
@@ -478,18 +480,50 @@ func TestExplainParsesBeforeAcquisitionAndPreservesNativeSafeHistory(t *testing.
}
}
func TestExplainTracksNativeRenderFailureWithoutReplacingOperationCause(t *testing.T) {
renderErr := errors.New("native explanation renderer failed")
reader := &failingNativeExplanation{err: renderErr}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
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
},
}}}))
var output bytes.Buffer
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 {
t.Fatalf("native explanation failure outcome/output = %#v / %q", outcome, output.String())
func TestExplainUsesAcquiredEvidenceAcrossFormats(t *testing.T) {
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"}}})
if err != nil {
t.Fatal(err)
}
path := configkit.Keys("token")
explanation, found := result.Explain(path)
if !found {
t.Fatal("missing fixture explanation")
}
metadata := result.Metadata()
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
}
type failingNativeExplanation struct{ err error }
type failingNativeExplanation struct {
err error
value configkit.Explanation
calls int
}
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 {
reader.calls++
if _, err := io.WriteString(writer, "partial native explanation\n"); err != nil {
return err
}