Render configuration explanations from acquired reports
This commit is contained in:
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user