Add ConfigKit comparison acquisition

This commit is contained in:
2026-10-11 21:47:16 +00:00
parent d2434b9580
commit 3542422b2f
4 changed files with 408 additions and 14 deletions

View File

@@ -1,10 +1,11 @@
# Implement ConfigKit adoption and configuration comparison
Status: implementation plan; no implementation stages are complete.
Status: implementation in progress; Stages 1–5 are complete.
## Scope and authority
Follow the stages in order, starting with Stage 1. Each stage is sized for one
Follow the stages in order, starting with the first stage without completion
evidence. Each stage is sized for one
Luna implementation assignment. The [feature roadmap](configkit.md) owns the
selected end state, operator behavior, target API, and scope limits. This document
owns the steps to reach that state. Read [development guidance](../development.md)
@@ -257,14 +258,19 @@ This is the selected internal coordination for the adapter, not a new policy
for application-supplied pair callbacks. Tests should protect diagnostic retention
and required lifecycle boundaries, not an incidental number of context polls.
**Completion evidence:** direct adapter tests with real ConfigKit loads for
public changes, base-versus-overlay loading, equal effective values,
defaults/resolution, and paired-safe concealed changes. Test right failure
retaining left warnings, wrapped load-error identity, nil results, and
cancellation between loads. Exercise a native
comparison error with a non-nil uninitialized result and retain available
diagnostics. Do not build a global fake comparator just to force an unreachable
branch; handler-level provider errors can carry diagnostics from both sides.
**Completion evidence — complete:** `FromConfigKit` now declares all five
operations and wires its typed comparison callback to sequentially load both
selected sides with the same state, then return `configkit.Diff` records. The
adapter keeps successful and `LoadError` diagnostics in left-to-right order,
labels comparison diagnostics by side, checks cancellation at the selected
boundaries, and wraps load/native comparison failures with their causes intact.
Direct tests use real ConfigKit loads for public changes, base-versus-overlay
loading, equal values after defaults and resolution, and safe concealed-change
records. They also cover both sides' diagnostics when the right load fails,
wrapped `LoadError` identity, nil results, cancellation between loads, and a
native Diff error from a non-nil uninitialized result while retaining available
diagnostics. Ordinary `Acquire` diagnostics and error identity remain covered
and unchanged. `go test -count=1 ./...` passes in ckconfig and ckp.
## Stage 6: Implement safe YAML snapshot and semantic-path placement helpers

View File

@@ -0,0 +1,327 @@
package ckconfig
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type adapterSettings struct {
Greeting string `yaml:"greeting"`
Secret string `yaml:"secret"`
}
func adapterDefinition() configkit.Definition[adapterSettings] {
return configkit.Definition[adapterSettings]{
Defaults: func() adapterSettings { return adapterSettings{Greeting: "Hello"} },
Validate: func(value *adapterSettings, validation *configkit.Validation) {
if value.Greeting == "Hello" {
validation.Warning(configkit.Keys("greeting"), "using the default greeting")
}
},
Secret: func(path configkit.Path) bool { return path.String() == "/secret" },
}
}
func adapterSettingsLoad(source configkit.Source) (*configkit.Result[adapterSettings], error) {
return configkit.Load(source, configkit.Options[adapterSettings]{Definition: adapterDefinition()})
}
func comparisonSide(selection Selection, greeting string) ComparisonSide[struct{ Label string }] {
return ComparisonSide[struct{ Label string }]{
Selection: selection,
Input: struct{ Label string }{Label: greeting},
}
}
func TestFromConfigKitCompareReturnsNativeChangesAndLoadsEqualSelections(t *testing.T) {
selection := Selection{Config: commandkit.Supplied[string]{Value: "same.yml", Set: true}}
var calls []struct {
selection Selection
input struct{ Label string }
state *int
}
scope := FromConfigKit[struct{ Label string }, *int, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, input struct{ Label string }, state *int) (*configkit.Result[adapterSettings], error) {
calls = append(calls, struct {
selection Selection
input struct{ Label string }
state *int
}{selected, input, state})
*state++
return adapterSettingsLoad(configkit.Mapping(input.Label, map[string]any{"greeting": input.Label}))
})
state := new(int)
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{ Label string }]{
Left: comparisonSide(selection, "before"),
Right: comparisonSide(selection, "after"),
}, state)
if err != nil {
t.Fatal(err)
}
if len(calls) != 2 || calls[0].selection != selection || calls[1].selection != selection {
t.Fatalf("loader calls = %#v", calls)
}
if calls[0].input.Label != "before" || calls[1].input.Label != "after" || calls[0].state != state || calls[1].state != state || *state != 2 {
t.Fatalf("loader inputs/state = %#v, %#v, state=%d", calls[0], calls[1], *state)
}
if len(acquired.Value) != 1 || acquired.Value[0].Path.String() != "/greeting" || acquired.Value[0].Left.YAML != "before\n" || acquired.Value[0].Right.YAML != "after\n" {
t.Fatalf("native differences = %#v", acquired.Value)
}
equal, err := scope.Compare(context.Background(), ComparisonRequest[struct{ Label string }]{
Left: comparisonSide(selection, "same"),
Right: comparisonSide(selection, "same"),
}, state)
if err != nil {
t.Fatal(err)
}
if len(equal.Value) != 0 || len(calls) != 4 {
t.Fatalf("equal comparison records/calls = %#v / %d", equal.Value, len(calls))
}
}
func TestFromConfigKitCompareIgnoresDefaultAndResolutionHistoryForEqualValues(t *testing.T) {
type resolvedSettings struct {
Greeting string `yaml:"greeting"`
}
definition := configkit.Definition[resolvedSettings]{
Defaults: func() resolvedSettings { return resolvedSettings{Greeting: "resolved"} },
Resolve: func(value *resolvedSettings, resolution *configkit.Resolution) error {
if value.Greeting != "resolved" {
value.Greeting = "resolved"
resolution.Record(configkit.Keys("greeting"), configkit.ChangeNormalization, "canonical greeting")
}
return nil
},
}
left, err := configkit.Load(configkit.NoSource(), configkit.Options[resolvedSettings]{Definition: definition})
if err != nil {
t.Fatal(err)
}
right, err := configkit.Load(configkit.Mapping("right-source", map[string]any{"greeting": "source value"}), configkit.Options[resolvedSettings]{Definition: definition})
if err != nil {
t.Fatal(err)
}
scope := FromConfigKit[struct{}, struct{}, resolvedSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[resolvedSettings], error) {
if selected.Config.Value == "left" {
return left, nil
}
return right, nil
})
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if err != nil {
t.Fatal(err)
}
if left.Config != right.Config || left.Config.Greeting != "resolved" || len(acquired.Value) != 0 {
t.Fatalf("equal resolved values yielded differences: left=%#v right=%#v records=%#v", left.Config, right.Config, acquired.Value)
}
}
func TestFromConfigKitCompareUsesBaseAndOverlaySelections(t *testing.T) {
dir := t.TempDir()
basePath := filepath.Join(dir, "base.yml")
overlayPath := filepath.Join(dir, "overlays", "testing.yml")
if err := os.WriteFile(basePath, []byte("greeting: Hello\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Dir(overlayPath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(overlayPath, []byte("greeting: Goodbye\n"), 0o600); err != nil {
t.Fatal(err)
}
loader := func(_ context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
options := configkit.LayeredOptions[adapterSettings]{Definition: adapterDefinition()}
if selection.Overlay.Set {
options.Overlay = selection.Overlay.Value
options.OverlayFilesystem = &configkit.FilesystemContext{Root: dir, BaseDir: dir}
}
return configkit.LoadLayered(configkit.Input{Source: configkit.File(selection.Config.Value)}, options)
}
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", loader)
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: basePath, Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{
Config: commandkit.Supplied[string]{Value: basePath, Set: true},
Overlay: commandkit.Supplied[string]{Value: "testing", Set: true},
}},
}, struct{}{})
if err != nil {
t.Fatal(err)
}
if len(acquired.Value) != 1 || acquired.Value[0].Path.String() != "/greeting" || acquired.Value[0].Left.YAML != "Hello\n" || acquired.Value[0].Right.YAML != "Goodbye\n" {
t.Fatalf("base/overlay differences = %#v", acquired.Value)
}
}
func TestFromConfigKitCompareRetainsSideDiagnosticsAndLoadError(t *testing.T) {
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"greeting": "Hello"}))
if err != nil {
t.Fatal(err)
}
_, loadErr := configkit.Load(configkit.Mapping("right-source", map[string]any{"unknown": true}), configkit.Options[adapterSettings]{Definition: adapterDefinition()})
if loadErr == nil {
t.Fatal("invalid right source unexpectedly loaded")
}
wrapper := fmt.Errorf("application loader context: %w", loadErr)
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
if selected.Config.Value == "left" {
return left, nil
}
return nil, wrapper
})
acquired, gotErr := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if !errors.Is(gotErr, loadErr) {
t.Fatalf("load error identity not preserved: %v", gotErr)
}
var typed *configkit.LoadError
if !errors.As(gotErr, &typed) || typed != loadErr {
t.Fatalf("load error type not preserved: %#v", typed)
}
if len(acquired.Diagnostics) != 2 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning || acquired.Diagnostics[1].Severity != commandkit.SeverityError {
t.Fatalf("retained diagnostics = %#v", acquired.Diagnostics)
}
var leftText, rightText bytes.Buffer
if err := acquired.Diagnostics[0].Write(&leftText); err != nil {
t.Fatal(err)
}
if err := acquired.Diagnostics[1].Write(&rightText); err != nil {
t.Fatal(err)
}
if !strings.Contains(leftText.String(), `Configuration scope "app" left:`) || !strings.Contains(leftText.String(), `"left-source"`) {
t.Fatalf("left diagnostic lost side/source identity: %q", leftText.String())
}
if !strings.Contains(rightText.String(), `Configuration scope "app" right:`) || !strings.Contains(rightText.String(), `"right-source"`) {
t.Fatalf("right diagnostic lost side/source identity: %q", rightText.String())
}
}
func TestFromConfigKitCompareStopsAfterLeftLoadFailure(t *testing.T) {
_, loadErr := configkit.Load(configkit.Mapping("left-source", map[string]any{"unknown": true}), configkit.Options[adapterSettings]{Definition: adapterDefinition()})
if loadErr == nil {
t.Fatal("invalid left source unexpectedly loaded")
}
rightCalled := false
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
if selected.Config.Value == "left" {
return nil, loadErr
}
rightCalled = true
return adapterSettingsLoad(configkit.Mapping("right-source", map[string]any{"greeting": "Goodbye"}))
})
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if !errors.Is(err, loadErr) || rightCalled {
t.Fatalf("left failure/right load = %v / %v", err, rightCalled)
}
var typed *configkit.LoadError
if !errors.As(err, &typed) || typed != loadErr || len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityError {
t.Fatalf("left failure identity/diagnostics = %v / %#v", err, acquired.Diagnostics)
}
}
func TestFromConfigKitCompareCancellationStopsBeforeRightLoad(t *testing.T) {
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"greeting": "Hello"}))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
rightCalled := false
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
if selection.Config.Value == "left" {
cancel()
return left, nil
}
rightCalled = true
return adapterSettingsLoad(configkit.Mapping("right-source", map[string]any{"greeting": "Goodbye"}))
})
acquired, err := scope.Compare(ctx, ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if !errors.Is(err, context.Canceled) || rightCalled {
t.Fatalf("cancellation/right load = %v / %v", err, rightCalled)
}
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
t.Fatalf("left diagnostics lost on cancellation: %#v", acquired.Diagnostics)
}
}
func TestFromConfigKitCompareRejectsNilResultAndRetainsNativeDiffFailureDiagnostics(t *testing.T) {
t.Run("nil result", func(t *testing.T) {
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
return nil, nil
})
_, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{}, struct{}{})
if err == nil || !strings.Contains(err.Error(), `"app" left`) || !strings.Contains(err.Error(), "nil result") {
t.Fatalf("nil left result error = %v", err)
}
})
t.Run("native diff error", func(t *testing.T) {
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"greeting": "Hello"}))
if err != nil {
t.Fatal(err)
}
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
if selection.Config.Value == "left" {
return left, nil
}
return &configkit.Result[adapterSettings]{}, nil
})
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if err == nil || !strings.Contains(err.Error(), "native diff") {
t.Fatalf("native diff error = %v", err)
}
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
t.Fatalf("native diff failure lost diagnostics: %#v", acquired.Diagnostics)
}
})
}
func TestFromConfigKitComparePreservesConcealedNativeDifference(t *testing.T) {
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"secret": "old"}))
if err != nil {
t.Fatal(err)
}
right, err := adapterSettingsLoad(configkit.Mapping("right-source", map[string]any{"secret": "new"}))
if err != nil {
t.Fatal(err)
}
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
if selected.Config.Value == "left" {
return left, nil
}
return right, nil
})
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if err != nil {
t.Fatal(err)
}
if len(acquired.Value) != 1 || !acquired.Value[0].HiddenChanged || !acquired.Value[0].Left.Redacted || !acquired.Value[0].Right.Redacted || acquired.Value[0].Left.YAML != acquired.Value[0].Right.YAML {
t.Fatalf("concealed native difference was altered: %#v", acquired.Value)
}
}

View File

@@ -11,7 +11,7 @@ import (
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// FromConfigKit adapts a typed ConfigKit loader to all four native
// FromConfigKit adapts a typed ConfigKit loader to all five native
// configuration operations. The loader receives the operation's explicit
// Selection, initialized input, and shared invocation state, and runs only when
// an installed operation selects this scope. Construction and help do not call
@@ -24,7 +24,7 @@ func FromConfigKit[I, S, T any](
scope := Scope[I, S]{
Name: name,
Summary: summary,
Operations: []Operation{Validate, Show, Sources, Explain},
Operations: []Operation{Validate, Show, Sources, Explain, Diff},
}
if load == nil {
return WithScopeError(scope, fmt.Errorf("ConfigKit scope %q loader must not be nil", name))
@@ -49,10 +49,71 @@ func FromConfigKit[I, S, T any](
Diagnostics: configKitDiagnostics(name, sources, result.Diagnostics()),
}, nil
}
scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
if err := ctx.Err(); err != nil {
return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("ConfigKit scope %q comparison before left load: %w", name, err)
}
left, leftDiagnostics, err := loadComparisonSide(ctx, name, "left", load, request.Left, state)
diagnostics := append([]commandkit.Diagnostic(nil), leftDiagnostics...)
if err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err
}
if err := ctx.Err(); err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q comparison after left load: %w", name, err)
}
right, rightDiagnostics, err := loadComparisonSide(ctx, name, "right", load, request.Right, state)
diagnostics = append(diagnostics, rightDiagnostics...)
if err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err
}
if err := ctx.Err(); err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q comparison before native diff: %w", name, err)
}
differences, err := configkit.Diff(left, right)
if err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q native diff: %w", name, err)
}
return commandkit.Acquired[[]configkit.Difference]{Value: differences, Diagnostics: diagnostics}, nil
}
return scope
}
func loadComparisonSide[I, S, T any](
ctx context.Context,
scope, side string,
load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
request ComparisonSide[I],
state S,
) (*configkit.Result[T], []commandkit.Diagnostic, error) {
result, err := load(ctx, request.Selection, request.Input, state)
if err != nil {
var loadError *configkit.LoadError
var diagnostics []commandkit.Diagnostic
if errors.As(err, &loadError) && loadError != nil {
diagnostics = configKitComparisonDiagnostics(scope, side, loadError.Sources(), loadError.Diagnostics())
}
return nil, diagnostics, fmt.Errorf("ConfigKit scope %q %s load: %w", scope, side, err)
}
if result == nil {
return nil, nil, fmt.Errorf("ConfigKit scope %q %s loader returned a nil result", scope, side)
}
return result, configKitComparisonDiagnostics(scope, side, result.Sources(), result.Diagnostics()), nil
}
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+":\n", sources, records)
}
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+" "+side+":\n", sources, records)
}
func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
if len(records) == 0 {
return nil
}
@@ -72,7 +133,7 @@ func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records
return []commandkit.Diagnostic{{
Severity: severity,
Write: func(writer io.Writer) error {
if err := writeDiagnosticText(writer, "Configuration scope "+strconv.Quote(scope)+":\n"); err != nil {
if err := writeDiagnosticText(writer, heading); err != nil {
return err
}
if len(sources) > 0 {

View File

@@ -80,7 +80,7 @@ func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
st.Calls++
return result, nil
})
if len(scope.Operations) != 4 || scope.Operations[0] != Validate || scope.Operations[1] != Show || scope.Operations[2] != Sources || scope.Operations[3] != Explain {
if len(scope.Operations) != 5 || scope.Operations[0] != Validate || scope.Operations[1] != Show || scope.Operations[2] != Sources || scope.Operations[3] != Explain || scope.Operations[4] != Diff {
t.Fatalf("operations = %#v", scope.Operations)
}
if gotContext != nil {