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
|
when applicable to a comparison-capable scope. Rely on existing core binding
|
||||||
validation for collisions with generated flags.
|
validation for collisions with generated flags.
|
||||||
|
|
||||||
**Completion evidence:** focused installation/discovery checks for comparison-only,
|
**Completion evidence — complete:** `Diff`, `ComparisonSide[I]`,
|
||||||
mixed, unsupported, and metadata-only scopes; missing callback and retained nil
|
`ComparisonRequest[I]`, and the optional `Scope.Compare` callback are declared in
|
||||||
loader issues; captured metadata surviving source edits. Execution is wired in
|
ckconfig. Capability validation now requires `Acquire` only for ordinary
|
||||||
Stages 3, 4, and 8; do not add temporary handlers for intermediate completeness.
|
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
|
## 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"
|
Sources Operation = "sources"
|
||||||
// Explain reports the history and provenance of a selected path.
|
// Explain reports the history and provenance of a selected path.
|
||||||
Explain Operation = "explain"
|
Explain Operation = "explain"
|
||||||
|
// Diff compares two selections of the same configuration scope.
|
||||||
|
Diff Operation = "diff"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Request describes one configuration operation and its selected inputs.
|
// Request describes one configuration operation and its selected inputs.
|
||||||
@@ -39,6 +41,24 @@ type Request struct {
|
|||||||
Selection Selection
|
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.
|
// EffectiveWriter writes a configuration's native effective representation.
|
||||||
type EffectiveWriter interface {
|
type EffectiveWriter interface {
|
||||||
WriteEffective(io.Writer) error
|
WriteEffective(io.Writer) error
|
||||||
@@ -63,9 +83,11 @@ type View struct {
|
|||||||
Explanation ExplanationReader
|
Explanation ExplanationReader
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scope declares the operations and acquisition callback for one named
|
// Scope declares the operations and providers for one named configuration.
|
||||||
// configuration. Construction issues are retained privately until family
|
// Ordinary inspection operations use Acquire; Diff uses Compare and does not
|
||||||
// assembly attaches them to its command declaration.
|
// 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 {
|
type Scope[I, S any] struct {
|
||||||
// Name identifies the scope in CLI selection and reports.
|
// Name identifies the scope in CLI selection and reports.
|
||||||
Name string
|
Name string
|
||||||
@@ -73,8 +95,12 @@ type Scope[I, S any] struct {
|
|||||||
Summary string
|
Summary string
|
||||||
// Operations selects the standard handlers exposed for this scope.
|
// Operations selects the standard handlers exposed for this scope.
|
||||||
Operations []Operation
|
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)
|
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
|
issues []error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,9 +112,20 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
|
|||||||
for _, issue := range scope.issues {
|
for _, issue := range scope.issues {
|
||||||
prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q: %w", scope.Name, issue))
|
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))
|
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})
|
prepared.scopes = append(prepared.scopes, &preparedScope[I, S]{scope: scope})
|
||||||
}
|
}
|
||||||
sort.SliceStable(prepared.scopes, func(i, j int) bool {
|
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 {
|
for _, scope := range prepared.scopes {
|
||||||
if containsOperation(scope.scope.Operations, operation) {
|
if containsOperation(scope.scope.Operations, operation) {
|
||||||
prepared.operations = append(prepared.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))
|
seen := make(map[Operation]struct{}, len(operations))
|
||||||
for _, operation := range operations {
|
for _, operation := range operations {
|
||||||
switch operation {
|
switch operation {
|
||||||
case Validate, Show, Sources, Explain:
|
case Validate, Show, Sources, Explain, Diff:
|
||||||
default:
|
default:
|
||||||
*issues = append(*issues, fmt.Errorf("configuration scope %q declares unknown operation %q", scope, operation))
|
*issues = append(*issues, fmt.Errorf("configuration scope %q declares unknown operation %q", scope, operation))
|
||||||
}
|
}
|
||||||
seen[operation] = struct{}{}
|
seen[operation] = struct{}{}
|
||||||
}
|
}
|
||||||
ordered := make([]Operation, 0, len(seen))
|
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 {
|
if _, exists := seen[operation]; exists {
|
||||||
ordered = append(ordered, operation)
|
ordered = append(ordered, operation)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user