diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 990fc0a..5333918 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement ConfigKit adoption and configuration comparison -Status: implementation in progress; Stages 1–6 are complete. +Status: implementation in progress; Stages 1–7 are complete. ## Scope and authority @@ -355,11 +355,16 @@ Stage 6's helpers, ready for delivery in Stage 8. handling rather than adding a general provider schema-validation framework or new restrictions on legitimate native records. -**Completion evidence:** semantic YAML checks for all three categories and the -empty document; deletion versus null and mapping-to-empty deletions. Cover -atomic sequences, structural replacements, native masks, literal -marker-looking public values, and concealed-only changes. Protect ordered root -keys and types, without freezing incidental emitter spelling or mapping styles. +**Completion evidence — complete:** `renderDiffReport` now classifies native +records into ordered additions, changes, and deletions and encodes the complete +three-category YAML document before returning bytes. Semantic node checks cover +all categories, category order, the empty document, root values, explicit null +versus deletion, mapping-to-empty deletions, atomic sequences, and structural +values. Real ConfigKit results cover native redaction, a public string matching +the upstream redaction marker, and a concealed-only change; the report retains +safe values without exposing secret contents or native mask metadata. Malformed +present snapshots return an error without report bytes. `go test -count=1 ./...` +passes in ckconfig. ## Stage 8: Execute comparison through existing outcomes and delivery diff --git a/integrations/ckconfig/diff_report.go b/integrations/ckconfig/diff_report.go index fc67670..506546b 100644 --- a/integrations/ckconfig/diff_report.go +++ b/integrations/ckconfig/diff_report.go @@ -1,9 +1,11 @@ package ckconfig import ( + "bytes" "fmt" "strings" + configkit "gitea.maximumdirect.net/eric/ConfigKit" "go.yaml.in/yaml/v4" ) @@ -118,3 +120,55 @@ func pointerFromTokens(tokens []string) string { } return pointer.String() } + +func renderDiffReport(differences []configkit.Difference) ([]byte, error) { + additions := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + changes := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + deletions := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + + for _, difference := range differences { + path := difference.Path.String() + switch { + case !difference.Left.Present && difference.Right.Present: + tokens, err := decodeJSONPointer(path) + if err != nil { + return nil, fmt.Errorf("render addition at %q: %w", path, err) + } + value, err := decodeSafeSnapshot(difference.Right.YAML) + if err != nil { + return nil, fmt.Errorf("render addition at %q: %w", path, err) + } + additions, err = placeYAMLValue(additions, tokens, value) + if err != nil { + return nil, fmt.Errorf("render addition at %q: %w", path, err) + } + case difference.Left.Present && difference.Right.Present: + tokens, err := decodeJSONPointer(path) + if err != nil { + return nil, fmt.Errorf("render change at %q: %w", path, err) + } + value, err := decodeSafeSnapshot(difference.Right.YAML) + if err != nil { + return nil, fmt.Errorf("render change at %q: %w", path, err) + } + changes, err = placeYAMLValue(changes, tokens, value) + if err != nil { + return nil, fmt.Errorf("render change at %q: %w", path, err) + } + case difference.Left.Present && !difference.Right.Present: + deletions.Content = append(deletions.Content, stringKey(path)) + } + } + + root := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{ + stringKey("additions"), additions, + stringKey("changes"), changes, + stringKey("deletions"), deletions, + }} + document := &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}} + var output bytes.Buffer + if err := encodeYAML(&output, document); err != nil { + return nil, fmt.Errorf("encode configuration diff report: %w", err) + } + return output.Bytes(), nil +} diff --git a/integrations/ckconfig/diff_report_render_test.go b/integrations/ckconfig/diff_report_render_test.go new file mode 100644 index 0000000..93a07c3 --- /dev/null +++ b/integrations/ckconfig/diff_report_render_test.go @@ -0,0 +1,173 @@ +package ckconfig + +import ( + "bytes" + "reflect" + "strings" + "testing" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" + "go.yaml.in/yaml/v4" +) + +func renderAndDecode(t *testing.T, differences []configkit.Difference) (*yaml.Node, []byte) { + t.Helper() + report, err := renderDiffReport(differences) + if err != nil { + t.Fatal(err) + } + var document yaml.Node + if err := yaml.Unmarshal(report, &document); err != nil { + t.Fatalf("decode rendered report %q: %v", report, err) + } + return document.Content[0], report +} + +func reportField(mapping *yaml.Node, key string) *yaml.Node { + for index := 0; index+1 < len(mapping.Content); index += 2 { + if mapping.Content[index].Value == key { + return mapping.Content[index+1] + } + } + return nil +} + +func TestRenderDiffReportClassifiesAndOrdersNativeRecords(t *testing.T) { + differences := []configkit.Difference{ + {Path: configkit.Keys("z-added"), Right: configkit.ValueSnapshot{Present: true, YAML: "false\n"}}, + {Path: configkit.Keys("count"), Left: configkit.ValueSnapshot{Present: true, YAML: "1\n"}, Right: configkit.ValueSnapshot{Present: true, YAML: "9007199254740993\n"}}, + {Path: configkit.Keys("removed"), Left: configkit.ValueSnapshot{Present: true, YAML: "unused"}}, + {Path: configkit.Keys("a-added"), Right: configkit.ValueSnapshot{Present: true, YAML: "'123'\n"}}, + } + root, _ := renderAndDecode(t, differences) + if root.Kind != yaml.MappingNode || len(root.Content) != 6 { + t.Fatalf("report root = %#v", root) + } + if got := []string{root.Content[0].Value, root.Content[2].Value, root.Content[4].Value}; !reflect.DeepEqual(got, []string{"additions", "changes", "deletions"}) { + t.Fatalf("report category order = %#v", got) + } + additions := reportField(root, "additions") + if additions.Kind != yaml.MappingNode || additions.Content[0].Value != "z-added" || additions.Content[2].Value != "a-added" { + t.Fatalf("addition order/value = %#v", additions) + } + if additions.Content[1].Kind != yaml.ScalarNode || additions.Content[1].Tag != "!!bool" || additions.Content[1].Value != "false" { + t.Fatalf("boolean addition lost its type: %#v", additions.Content[1]) + } + if additions.Content[3].Tag != "!!str" || additions.Content[3].Value != "123" { + t.Fatalf("numeric-looking string lost its type: %#v", additions.Content[3]) + } + changes := reportField(root, "changes") + if changes.Kind != yaml.MappingNode || changes.Content[1].Tag != "!!int" || changes.Content[1].Value != "9007199254740993" { + t.Fatalf("change value/type = %#v", changes) + } + deletions := reportField(root, "deletions") + if deletions.Kind != yaml.SequenceNode || len(deletions.Content) != 1 || deletions.Content[0].Tag != "!!str" || deletions.Content[0].Value != "/removed" { + t.Fatalf("deletion records = %#v", deletions) + } +} + +func TestRenderDiffReportEmitsEmptyCategoriesAndRootValue(t *testing.T) { + root, _ := renderAndDecode(t, nil) + if got := []string{root.Content[0].Value, root.Content[2].Value, root.Content[4].Value}; !reflect.DeepEqual(got, []string{"additions", "changes", "deletions"}) { + t.Fatalf("empty report keys = %#v", got) + } + if reportField(root, "additions").Kind != yaml.MappingNode || len(reportField(root, "additions").Content) != 0 || reportField(root, "changes").Kind != yaml.MappingNode || len(reportField(root, "changes").Content) != 0 || reportField(root, "deletions").Kind != yaml.SequenceNode || len(reportField(root, "deletions").Content) != 0 { + t.Fatalf("empty report categories = %#v", root) + } + + root, _ = renderAndDecode(t, []configkit.Difference{{Right: configkit.ValueSnapshot{Present: true, YAML: "null\n"}}}) + additions := reportField(root, "additions") + if additions.Tag != "!!null" || additions.Value != "null" { + t.Fatalf("whole-root addition was wrapped: %#v", additions) + } +} + +func TestRenderDiffReportDistinguishesDeletionFromNullAndEmptyMappings(t *testing.T) { + differences := []configkit.Difference{ + {Path: configkit.Keys("nullable"), Right: configkit.ValueSnapshot{Present: true, YAML: "null\n"}}, + {Path: configkit.Keys("settings", "first"), Left: configkit.ValueSnapshot{Present: true, YAML: "intentionally not parsed"}}, + {Path: configkit.Keys("settings", "second"), Left: configkit.ValueSnapshot{Present: true, YAML: "also not parsed"}}, + } + root, _ := renderAndDecode(t, differences) + if got := reportField(root, "additions").Content; len(got) != 2 || got[0].Value != "nullable" || got[1].Tag != "!!null" { + t.Fatalf("explicit null addition = %#v", got) + } + deletions := reportField(root, "deletions") + if len(deletions.Content) != 2 || deletions.Content[0].Value != "/settings/first" || deletions.Content[1].Value != "/settings/second" { + t.Fatalf("mapping-to-empty deletion records = %#v", deletions) + } +} + +func TestRenderDiffReportKeepsAtomicSequencesAndStructuralValues(t *testing.T) { + differences := []configkit.Difference{ + {Path: configkit.Keys("items"), Left: configkit.ValueSnapshot{Present: true}, Right: configkit.ValueSnapshot{Present: true, YAML: "- one\n- two\n"}}, + {Path: configkit.Keys("settings"), Left: configkit.ValueSnapshot{Present: true}, Right: configkit.ValueSnapshot{Present: true, YAML: "nested:\n enabled: true\n"}}, + } + root, _ := renderAndDecode(t, differences) + changes := reportField(root, "changes") + if changes.Content[1].Kind != yaml.SequenceNode || len(changes.Content[1].Content) != 2 { + t.Fatalf("sequence change was expanded or altered: %#v", changes.Content[1]) + } + settings := reportField(changes, "settings") + if settings.Kind != yaml.MappingNode || reportField(settings, "nested").Kind != yaml.MappingNode || reportField(reportField(settings, "nested"), "enabled").Tag != "!!bool" { + t.Fatalf("structural replacement was altered: %#v", settings) + } +} + +func TestRenderDiffReportPreservesNativeMasksAndMarkerLookingStrings(t *testing.T) { + type settings struct { + Public string `yaml:"public"` + Secret string `yaml:"secret"` + } + definition := configkit.Definition[settings]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }} + load := func(public, secret string) *configkit.Result[settings] { + result, err := configkit.Load(configkit.Mapping("configuration", map[string]any{"public": public, "secret": secret}), configkit.Options[settings]{Definition: definition}) + if err != nil { + t.Fatal(err) + } + return result + } + left := load("visible", "old-secret") + right := load("[REDACTED]", "new-secret") + differences, err := configkit.Diff(left, right) + if err != nil { + t.Fatal(err) + } + if len(differences) != 2 || differences[0].Path.String() != "/public" || differences[1].Path.String() != "/secret" || !differences[1].HiddenChanged || !differences[1].Right.Redacted { + t.Fatalf("native changes/mask metadata = %#v", differences) + } + root, report := renderAndDecode(t, differences) + changes := reportField(root, "changes") + for _, key := range []string{"public", "secret"} { + value := reportField(changes, key) + if value == nil || value.Tag != "!!str" || value.Value != "[REDACTED]" { + t.Fatalf("safe value for %q = %#v", key, value) + } + } + if bytes.Contains(report, []byte("HiddenChanged")) || bytes.Contains(report, []byte("Redacted")) || strings.Contains(string(report), "old-secret") || strings.Contains(string(report), "new-secret") { + t.Fatalf("report exposed native metadata or secret contents: %q", report) + } + + concealedOnly := load("[REDACTED]", "later-secret") + concealedDifferences, err := configkit.Diff(right, concealedOnly) + if err != nil { + t.Fatal(err) + } + if len(concealedDifferences) != 1 || concealedDifferences[0].Path.String() != "/secret" || !concealedDifferences[0].HiddenChanged { + t.Fatalf("concealed-only native records = %#v", concealedDifferences) + } + concealedRoot, _ := renderAndDecode(t, concealedDifferences) + if value := reportField(reportField(concealedRoot, "changes"), "secret"); value == nil || value.Tag != "!!str" || value.Value != "[REDACTED]" { + t.Fatalf("concealed-only safe value = %#v", value) + } +} + +func TestRenderDiffReportReportsMalformedPresentSnapshot(t *testing.T) { + report, err := renderDiffReport([]configkit.Difference{{ + Path: configkit.Keys("setting"), + Right: configkit.ValueSnapshot{Present: true, YAML: "setting: [unterminated\n"}, + }}) + if err == nil || report != nil || !strings.Contains(err.Error(), "/setting") { + t.Fatalf("malformed snapshot report/error = %q / %v", report, err) + } +}