Add configuration comparison capability contract
This commit is contained in:
@@ -124,10 +124,20 @@ ordinary acquisition for a comparison-only scope.
|
||||
when applicable to a comparison-capable scope. Rely on existing core binding
|
||||
validation for collisions with generated flags.
|
||||
|
||||
**Completion evidence:** focused installation/discovery checks for comparison-only,
|
||||
mixed, unsupported, and metadata-only scopes; missing callback and retained nil
|
||||
loader issues; captured metadata surviving source edits. Execution is wired in
|
||||
Stages 3, 4, and 8; do not add temporary handlers for intermediate completeness.
|
||||
**Completion evidence — complete:** `Diff`, `ComparisonSide[I]`,
|
||||
`ComparisonRequest[I]`, and the optional `Scope.Compare` callback are declared in
|
||||
ckconfig. Capability validation now requires `Acquire` only for ordinary
|
||||
operations and `Compare` only for `Diff`; retained adapter issues still reach
|
||||
installation after capability narrowing. Operation normalization and discovery
|
||||
preserve the four existing operation order and append `diff`, generating the
|
||||
family command only when a scope declares comparison. Focused checks cover
|
||||
comparison-only, mixed, unsupported, and metadata-only scopes; missing provider
|
||||
wiring; a narrowed ConfigKit adapter retaining its nil-loader issue; and captured
|
||||
capability metadata and provider references surviving edits to the source
|
||||
declaration. Construction, installation, discovery, and unsupported-scope
|
||||
preflight leave factories and providers uninvoked. The ckconfig and dependent
|
||||
ckp suites pass. Input flags and successful comparison execution remain for
|
||||
their later owners.
|
||||
|
||||
## Stage 3: Add comparison flags, inheritance, and availability preflight
|
||||
|
||||
|
||||
105
integrations/ckconfig/comparison_contract_test.go
Normal file
105
integrations/ckconfig/comparison_contract_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestComparisonCapabilitiesAreIndependentAndCaptured(t *testing.T) {
|
||||
type input struct{ Value string }
|
||||
var inputCalls, stateCalls, acquireCalls, compareCalls int
|
||||
compare := func(context.Context, ComparisonRequest[input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
compareCalls++
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
}
|
||||
operations := []Operation{Diff}
|
||||
suite := Suite[input, struct{}]{
|
||||
NewInput: func() input { inputCalls++; return input{} },
|
||||
NewState: func() struct{} { stateCalls++; return struct{}{} },
|
||||
Scopes: []Scope[input, struct{}]{
|
||||
{Name: "app", Operations: operations, Compare: compare},
|
||||
{Name: "promptkit", Operations: []Operation{Diff, Show, Validate}, Acquire: func(context.Context, Request, input, struct{}) (commandkit.Acquired[View], error) {
|
||||
acquireCalls++
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
}, Compare: compare},
|
||||
{Name: "alpha"},
|
||||
{Name: "plain", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, input, struct{}) (commandkit.Acquired[View], error) {
|
||||
acquireCalls++
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
}},
|
||||
},
|
||||
}
|
||||
captured := captureSuite(suite)
|
||||
declaration := Command(suite)
|
||||
|
||||
// Capability metadata and callback references are copied during capture.
|
||||
suite.Scopes[0].Operations[0] = Show
|
||||
suite.Scopes[0].Compare = nil
|
||||
|
||||
app := newConfigTestApp(t, declaration)
|
||||
var output strings.Builder
|
||||
outcome := app.Execute(context.Background(), []string{"config", "scopes"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("scope discovery outcome = %#v", outcome)
|
||||
}
|
||||
if got, want := output.String(), "app: diff\npromptkit: validate, show, diff\nalpha: \nplain: validate\n"; got != want {
|
||||
t.Fatalf("scope discovery = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// A family-wide diff command is available, but unsupported scopes fail before
|
||||
// suite factories or either provider runs.
|
||||
outcome = app.Execute(context.Background(), []string{"config", "diff", "--scope", "plain"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeUsage {
|
||||
t.Fatalf("unsupported scope outcome = %#v", outcome)
|
||||
}
|
||||
if inputCalls != 0 || stateCalls != 0 || acquireCalls != 0 || compareCalls != 0 {
|
||||
t.Fatalf("discovery or preflight invoked suite work: input=%d state=%d acquire=%d compare=%d", inputCalls, stateCalls, acquireCalls, compareCalls)
|
||||
}
|
||||
if captured.byName["app"].scope.Compare == nil {
|
||||
t.Fatal("captured comparison callback was cleared by a source edit")
|
||||
}
|
||||
if _, err := captured.byName["app"].scope.Compare(context.Background(), ComparisonRequest[input]{}, struct{}{}); err != nil || compareCalls != 1 {
|
||||
t.Fatalf("captured comparison callback result=%v calls=%d", err, compareCalls)
|
||||
}
|
||||
|
||||
withoutComparison := newConfigTestApp(t, Command(Suite[input, struct{}]{Scopes: []Scope[input, struct{}]{{
|
||||
Name: "plain", Operations: []Operation{Validate},
|
||||
Acquire: func(context.Context, Request, input, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
},
|
||||
}}}))
|
||||
outcome = withoutComparison.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeUsage {
|
||||
t.Fatalf("family without comparison exposed diff: %#v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparisonCallbackWiringAndRetainedAdapterIssues(t *testing.T) {
|
||||
newApp := func(scope Scope[struct{}, struct{}]) error {
|
||||
_, err := commandkit.New(commandkit.Identity{Name: "example"}, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := newApp(Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Diff}}); err == nil {
|
||||
t.Fatalf("missing comparison callback error = %v", err)
|
||||
}
|
||||
if err := newApp(Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Validate}}); err == nil {
|
||||
t.Fatalf("missing ordinary acquisition callback error = %v", err)
|
||||
}
|
||||
|
||||
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "Application configuration", nil)
|
||||
scope.Operations = []Operation{Diff}
|
||||
scope.Compare = func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
}
|
||||
retained := scope.issues[0]
|
||||
if err := newApp(scope); err == nil || !errors.Is(err, retained) {
|
||||
t.Fatalf("narrowed adapter lost its retained loader issue: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ const (
|
||||
Sources Operation = "sources"
|
||||
// Explain reports the history and provenance of a selected path.
|
||||
Explain Operation = "explain"
|
||||
// Diff compares two selections of the same configuration scope.
|
||||
Diff Operation = "diff"
|
||||
)
|
||||
|
||||
// Request describes one configuration operation and its selected inputs.
|
||||
@@ -39,6 +41,24 @@ type Request struct {
|
||||
Selection Selection
|
||||
}
|
||||
|
||||
// ComparisonSide groups one configuration selection with its independently
|
||||
// prepared typed input.
|
||||
type ComparisonSide[I any] struct {
|
||||
// Selection carries the explicit config and overlay inputs for this side.
|
||||
Selection Selection
|
||||
// Input is initialized and checked for this side before comparison.
|
||||
Input I
|
||||
}
|
||||
|
||||
// ComparisonRequest carries the earlier and later selections supplied to a
|
||||
// scope's comparison callback.
|
||||
type ComparisonRequest[I any] struct {
|
||||
// Left is the earlier configuration selection.
|
||||
Left ComparisonSide[I]
|
||||
// Right is the later configuration selection.
|
||||
Right ComparisonSide[I]
|
||||
}
|
||||
|
||||
// EffectiveWriter writes a configuration's native effective representation.
|
||||
type EffectiveWriter interface {
|
||||
WriteEffective(io.Writer) error
|
||||
@@ -63,9 +83,11 @@ type View struct {
|
||||
Explanation ExplanationReader
|
||||
}
|
||||
|
||||
// Scope declares the operations and acquisition callback for one named
|
||||
// configuration. Construction issues are retained privately until family
|
||||
// assembly attaches them to its command declaration.
|
||||
// Scope declares the operations and providers for one named configuration.
|
||||
// Ordinary inspection operations use Acquire; Diff uses Compare and does not
|
||||
// require Acquire unless an ordinary operation is also declared. Construction
|
||||
// issues are retained privately until family assembly attaches them to its
|
||||
// command declaration.
|
||||
type Scope[I, S any] struct {
|
||||
// Name identifies the scope in CLI selection and reports.
|
||||
Name string
|
||||
@@ -73,8 +95,12 @@ type Scope[I, S any] struct {
|
||||
Summary string
|
||||
// Operations selects the standard handlers exposed for this scope.
|
||||
Operations []Operation
|
||||
// Acquire loads the selected configuration for one inspection invocation.
|
||||
// Acquire loads the selected configuration for one ordinary inspection
|
||||
// invocation.
|
||||
Acquire func(context.Context, Request, I, S) (commandkit.Acquired[View], error)
|
||||
// Compare compares two selections for this scope using one shared state.
|
||||
// It is required when Diff is declared in Operations.
|
||||
Compare func(context.Context, ComparisonRequest[I], S) (commandkit.Acquired[[]configkit.Difference], error)
|
||||
|
||||
issues []error
|
||||
}
|
||||
|
||||
@@ -112,9 +112,20 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
|
||||
for _, issue := range scope.issues {
|
||||
prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q: %w", scope.Name, issue))
|
||||
}
|
||||
if len(scope.Operations) > 0 && scope.Acquire == nil {
|
||||
requiresAcquire, requiresCompare := false, false
|
||||
for _, operation := range scope.Operations {
|
||||
if operation == Diff {
|
||||
requiresCompare = true
|
||||
} else {
|
||||
requiresAcquire = true
|
||||
}
|
||||
}
|
||||
if requiresAcquire && scope.Acquire == nil {
|
||||
prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q declares operations without an acquisition callback", scope.Name))
|
||||
}
|
||||
if requiresCompare && scope.Compare == nil {
|
||||
prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q declares diff without a comparison callback", scope.Name))
|
||||
}
|
||||
prepared.scopes = append(prepared.scopes, &preparedScope[I, S]{scope: scope})
|
||||
}
|
||||
sort.SliceStable(prepared.scopes, func(i, j int) bool {
|
||||
@@ -156,7 +167,7 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
|
||||
}
|
||||
}
|
||||
|
||||
for _, operation := range []Operation{Validate, Show, Sources, Explain} {
|
||||
for _, operation := range []Operation{Validate, Show, Sources, Explain, Diff} {
|
||||
for _, scope := range prepared.scopes {
|
||||
if containsOperation(scope.scope.Operations, operation) {
|
||||
prepared.operations = append(prepared.operations, operation)
|
||||
@@ -185,14 +196,14 @@ func normalizeOperations(operations []Operation, issues *[]error, scope string)
|
||||
seen := make(map[Operation]struct{}, len(operations))
|
||||
for _, operation := range operations {
|
||||
switch operation {
|
||||
case Validate, Show, Sources, Explain:
|
||||
case Validate, Show, Sources, Explain, Diff:
|
||||
default:
|
||||
*issues = append(*issues, fmt.Errorf("configuration scope %q declares unknown operation %q", scope, operation))
|
||||
}
|
||||
seen[operation] = struct{}{}
|
||||
}
|
||||
ordered := make([]Operation, 0, len(seen))
|
||||
for _, operation := range []Operation{Validate, Show, Sources, Explain} {
|
||||
for _, operation := range []Operation{Validate, Show, Sources, Explain, Diff} {
|
||||
if _, exists := seen[operation]; exists {
|
||||
ordered = append(ordered, operation)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user