diff --git a/docs/cli.md b/docs/cli.md index b87acd8..dd075d8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -306,6 +306,17 @@ runtime context or acquire configuration. It reports the intersection between declared scope capabilities and installed handlers, so an operation is listed only when the corresponding command is available. +Scopes can opt in to transient overrides. For an opted-in scope, acquisition +commands accept repeatable `--set POINTER=JSON`; comparison also accepts +`--against-set POINTER=JSON` for the right side. Each assignment is passed to +the loader in order, with `--set` or `--against-set` as its source label. +The pointer and value are split at the first `=`, so pointer keys containing +`=` cannot be addressed by these flags; JSON strings can contain `=`. Values +use JSON types, including null, false, zero, and empty containers. ConfigKit +owns override path validation and conflict handling. A selected scope must opt +in before receiving overrides; for `--scope all`, every selected scope must +support them. + Applications can add `ckprompt.ConfigScope(loadEngine)` to the ConfigKit suite to expose PromptKit engine configuration through these handlers. The scope declares validate, show, sources, explain, and native diff. It does not install diff --git a/docs/consumers/guide.md b/docs/consumers/guide.md index 6b0750d..4cfee1b 100644 --- a/docs/consumers/guide.md +++ b/docs/consumers/guide.md @@ -255,6 +255,10 @@ owns operator syntax and report details. `Scope.Features` declares optional mode `View.Metadata` and `View.Findings` carry native path metadata and diagnostic records when available. `Findings.Messages` contains presentations associated with those records, while `Acquired.Diagnostics` carries independent messages. +Set `Scope.Features.Overrides` only when the loader passes +`Selection.Overrides` to ConfigKit as part of its normal load. The integration +parses CLI records but cannot add them to an already-loaded result. The +[CLI reference](../cli.md#configuration-scopes) describes the operator syntax. ## PromptKit configuration, definitions, and requirements diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index a9853a1..c7d56a0 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Shared reporting and PromptKit v0.20.0 implementation plan -Status: implementation in progress; Stages 1–15 complete. +Status: implementation in progress; Stages 1–16 complete. ## Scope, authority, and execution @@ -571,6 +571,11 @@ model. Explicit-empty custom collections remain application-defined. ## Stage 16: Opt-in transient override acquisition +**Complete.** Opted-in loaders receive ordered, independently copied native +override records with safe flag labels. The root, ckconfig, and ckprompt +workspace suites passed with Go 1.27.0, including scope preflight, comparison +separation, native provenance, and load-error propagation. + Add `Selection.Overrides []configkit.Override`. Preserve ordered records; nil means no CLI records. A loader opting in via `scope.Features.Overrides = true` reads those records during its ordinary load and supplies them to ConfigKit. diff --git a/integrations/ckconfig/comparison_adapter_test.go b/integrations/ckconfig/comparison_adapter_test.go index dc73400..ca3e9dd 100644 --- a/integrations/ckconfig/comparison_adapter_test.go +++ b/integrations/ckconfig/comparison_adapter_test.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "strings" "testing" @@ -66,7 +67,7 @@ func TestFromConfigKitCompareReturnsNativeChangesAndLoadsEqualSelections(t *test if err != nil { t.Fatal(err) } - if len(calls) != 2 || calls[0].selection != selection || calls[1].selection != selection { + if len(calls) != 2 || !reflect.DeepEqual(calls[0].selection, selection) || !reflect.DeepEqual(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 { diff --git a/integrations/ckconfig/comparison_input_test.go b/integrations/ckconfig/comparison_input_test.go index 3fa595b..eb6c35e 100644 --- a/integrations/ckconfig/comparison_input_test.go +++ b/integrations/ckconfig/comparison_input_test.go @@ -3,6 +3,7 @@ package ckconfig import ( "context" "io" + "reflect" "strings" "testing" @@ -75,7 +76,7 @@ func TestComparisonPreparation(t *testing.T) { } if want := (Selection{ Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}, - }); input.against != want { + }); !reflect.DeepEqual(input.against, want) { t.Fatalf("resolved right selection = %#v, want %#v", input.against, want) } diff --git a/integrations/ckconfig/comparison_preparation_test.go b/integrations/ckconfig/comparison_preparation_test.go index 7f77147..d8873b9 100644 --- a/integrations/ckconfig/comparison_preparation_test.go +++ b/integrations/ckconfig/comparison_preparation_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "reflect" "strconv" "strings" "testing" @@ -106,7 +107,7 @@ func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T) Config: commandkit.Supplied[string]{Value: "same.yml", Set: true}, Overlay: commandkit.Supplied[string]{Set: true}, } - if len(selections) != 2 || selections[0] != wantSelection || selections[1] != wantSelection { + if len(selections) != 2 || !reflect.DeepEqual(selections[0], wantSelection) || !reflect.DeepEqual(selections[1], wantSelection) { t.Fatalf("side selections = %#v, want equal %#v values", selections, wantSelection) } } @@ -115,7 +116,7 @@ func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) { type input struct{ ID int } type state struct{ Label string } inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls := 0, 0, 0, 0, 0 - checked := make(map[Selection]input) + var checked []input shared := &state{Label: "shared"} var request ComparisonRequest[input] app := newConfigTestApp(t, Command(Suite[input, *state]{ @@ -135,7 +136,7 @@ func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) { if scope != "app" { return errors.New("unexpected scope") } - checked[selection] = value + checked = append(checked, value) return nil }, Scopes: []Scope[input, *state]{{ @@ -162,10 +163,10 @@ func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) { } left := Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}} right := Selection{Overlay: commandkit.Supplied[string]{Set: true}} - if request.Left.Selection != left || request.Right.Selection != right { + if !reflect.DeepEqual(request.Left.Selection, left) || !reflect.DeepEqual(request.Right.Selection, right) { t.Fatalf("provider selections = %#v", request) } - if len(checked) != 2 || request.Left.Input != checked[left] || request.Right.Input != checked[right] { + if len(checked) != 2 || request.Left.Input != checked[0] || request.Right.Input != checked[1] { t.Fatalf("provider inputs = %#v, checked = %#v", request, checked) } if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 || compareCalls != 1 || acquireCalls != 0 { diff --git a/integrations/ckconfig/contracts.go b/integrations/ckconfig/contracts.go index 59300f4..57cb9c3 100644 --- a/integrations/ckconfig/contracts.go +++ b/integrations/ckconfig/contracts.go @@ -15,6 +15,8 @@ type Selection struct { Config commandkit.Supplied[string] // Overlay is the caller-supplied overlay path, if present. Overlay commandkit.Supplied[string] + // Overrides contains ordered CLI override records, or nil when none were supplied. + Overrides []configkit.Override } // Operation identifies a supported standard configuration operation. diff --git a/integrations/ckconfig/contracts_adapter_test.go b/integrations/ckconfig/contracts_adapter_test.go index 1851766..2b12f2f 100644 --- a/integrations/ckconfig/contracts_adapter_test.go +++ b/integrations/ckconfig/contracts_adapter_test.go @@ -92,7 +92,7 @@ func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) { if err != nil { t.Fatal(err) } - if gotContext != ctx || gotSelection != selection || gotInput.Label != "typed" || gotState != stateValue || stateValue.Calls != 1 { + if gotContext != ctx || !reflect.DeepEqual(gotSelection, selection) || gotInput.Label != "typed" || gotState != stateValue || stateValue.Calls != 1 { t.Fatalf("loader arguments/context/state = %v, %#v, %#v, %p, %d", gotContext, gotSelection, gotInput, gotState, stateValue.Calls) } if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result || acquired.Value.Metadata == nil || acquired.Value.Findings == nil { diff --git a/integrations/ckconfig/explain.go b/integrations/ckconfig/explain.go index 4576b76..48fd19b 100644 --- a/integrations/ckconfig/explain.go +++ b/integrations/ckconfig/explain.go @@ -63,7 +63,7 @@ func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *conf } acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ Operation: Explain, - Selection: plan.selection, + Selection: selected.selection, }, selected.input, state) queueConfigAcquisition(reporter, acquired) if acquireErr != nil { diff --git a/integrations/ckconfig/independent_comparison_test.go b/integrations/ckconfig/independent_comparison_test.go index 6581e61..1e188f7 100644 --- a/integrations/ckconfig/independent_comparison_test.go +++ b/integrations/ckconfig/independent_comparison_test.go @@ -44,7 +44,7 @@ func TestComparisonSidesKeepIndependentSelectionsAndDefaults(t *testing.T) { } for _, test := range tests { result := app.Execute(context.Background(), append([]string{"config", "diff"}, test.args...), commandkit.Streams{Out: io.Discard, Err: io.Discard}) - if result.Kind != commandkit.OutcomeSuccess || pair.Left.Selection != test.left || pair.Right.Selection != test.right || pair.Left.Input.Label != test.leftLabel || pair.Right.Input.Label != test.rightLabel || !reflect.DeepEqual(pair.Left.Input.Tags, test.leftTags) || !reflect.DeepEqual(pair.Right.Input.Tags, test.rightTags) { + if result.Kind != commandkit.OutcomeSuccess || !reflect.DeepEqual(pair.Left.Selection, test.left) || !reflect.DeepEqual(pair.Right.Selection, test.right) || pair.Left.Input.Label != test.leftLabel || pair.Right.Input.Label != test.rightLabel || !reflect.DeepEqual(pair.Left.Input.Tags, test.leftTags) || !reflect.DeepEqual(pair.Right.Input.Tags, test.rightTags) { t.Fatalf("args=%v outcome=%#v pair=%#v", test.args, result, pair) } } diff --git a/integrations/ckconfig/input.go b/integrations/ckconfig/input.go index 964b8b5..a5a18d5 100644 --- a/integrations/ckconfig/input.go +++ b/integrations/ckconfig/input.go @@ -19,8 +19,9 @@ type configCommandInput[I, S any] struct { } type selectedScopeInput[I, S any] struct { - scope *preparedScope[I, S] - input I + scope *preparedScope[I, S] + input I + selection Selection } type preparedComparison[I, S any] struct { @@ -31,7 +32,6 @@ type preparedComparison[I, S any] struct { type configOperationPlan[I, S any] struct { prepared *preparedSuite[I, S] operation Operation - selection Selection scopeName string path configkit.Path scopes []selectedScopeInput[I, S] @@ -76,6 +76,18 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio }), ) } + if supportsOverrides(prepared, operation) { + flags = append(flags, commandkit.RepeatFlag(commandkit.FlagMeta{Name: "set", Usage: "Override POINTER=JSON (pointer keys cannot contain =)."}, []configkit.Override(nil), overrideParser("--set"), + func(input **configCommandInput[I, S], value commandkit.Supplied[[]configkit.Override]) { + (*input).selection.Overrides = value.Value + })) + if operation == Diff { + flags = append(flags, commandkit.RepeatFlag(commandkit.FlagMeta{Name: "against-set", Usage: "Right-side override POINTER=JSON (pointer keys cannot contain =)."}, []configkit.Override(nil), overrideParser("--against-set"), + func(input **configCommandInput[I, S], value commandkit.Supplied[[]configkit.Override]) { + (*input).against.Overrides = value.Value + })) + } + } if operation == Explain { flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain.", Required: true}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value })) @@ -155,6 +167,9 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O if !containsOperation(scope.scope.Operations, operation) { return fmt.Errorf("configuration scope %q does not support %s (available: %s)", scope.scope.Name, operation, joinOperations(scope.scope.Operations)) } + if (len(input.selection.Overrides) > 0 || len(input.against.Overrides) > 0) && !scope.scope.Features.Overrides { + return fmt.Errorf("configuration scope %q does not support overrides", scope.scope.Name) + } } var path configkit.Path @@ -172,13 +187,14 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O plan := &configOperationPlan[I, S]{ prepared: prepared, operation: operation, - selection: input.selection, scopeName: scopeName, path: path, scopes: make([]selectedScopeInput[I, S], 0, len(selected)), } if operation == Diff { scope := selected[0] + input.selection = cloneSelection(input.selection) + input.against = cloneSelection(input.against) left, err := prepareScopeInput(prepared, input.values, scope, scope.bindings, input.selection, true) if err != nil { return fmt.Errorf("configuration scope %q left input: %w", scope.scope.Name, err) @@ -198,11 +214,12 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O return nil } for _, scope := range selected { - value, err := prepareScopeInput(prepared, input.values, scope, scope.bindings, input.selection, false) + selection := cloneSelection(input.selection) + value, err := prepareScopeInput(prepared, input.values, scope, scope.bindings, selection, false) if err != nil { return fmt.Errorf("configuration scope %q: %w", scope.scope.Name, err) } - plan.scopes = append(plan.scopes, selectedScopeInput[I, S]{scope: scope, input: value}) + plan.scopes = append(plan.scopes, selectedScopeInput[I, S]{scope: scope, input: value, selection: selection}) } input.plan = plan return nil diff --git a/integrations/ckconfig/overrides.go b/integrations/ckconfig/overrides.go new file mode 100644 index 0000000..d0c13c5 --- /dev/null +++ b/integrations/ckconfig/overrides.go @@ -0,0 +1,65 @@ +package ckconfig + +import ( + "fmt" + "strings" + + commandkit "go.maximumdirect.net/commandkit" + configkit "go.maximumdirect.net/configkit" +) + +func supportsOverrides[I, S any](prepared *preparedSuite[I, S], operation Operation) bool { + for _, scope := range prepared.scopes { + if scope.scope.Features.Overrides && containsOperation(scope.scope.Operations, operation) { + return true + } + } + return false +} + +func overrideParser(flag string) func(string) (configkit.Override, error) { + return func(raw string) (configkit.Override, error) { + pointer, jsonText, ok := strings.Cut(raw, "=") + if !ok { + return configkit.Override{}, fmt.Errorf("override requires POINTER=JSON") + } + path, err := configkit.ParsePath(pointer) + if err != nil { + return configkit.Override{}, fmt.Errorf("invalid override path: %w", err) + } + value, err := commandkit.ParseCLIJSON(jsonText) + if err != nil { + return configkit.Override{}, fmt.Errorf("invalid override JSON: %w", err) + } + return configkit.Override{Path: path, Value: value, Flag: flag}, nil + } +} + +func cloneSelection(selection Selection) Selection { + if selection.Overrides != nil { + selection.Overrides = append([]configkit.Override(nil), selection.Overrides...) + for index := range selection.Overrides { + selection.Overrides[index].Value = cloneCLIValue(selection.Overrides[index].Value) + } + } + return selection +} + +func cloneCLIValue(value any) any { + switch value := value.(type) { + case []any: + copy := make([]any, len(value)) + for index, element := range value { + copy[index] = cloneCLIValue(element) + } + return copy + case map[string]any: + copy := make(map[string]any, len(value)) + for key, element := range value { + copy[key] = cloneCLIValue(element) + } + return copy + default: + return value + } +} diff --git a/integrations/ckconfig/overrides_test.go b/integrations/ckconfig/overrides_test.go new file mode 100644 index 0000000..936b722 --- /dev/null +++ b/integrations/ckconfig/overrides_test.go @@ -0,0 +1,94 @@ +package ckconfig + +import ( + "bytes" + "context" + "errors" + "io" + "reflect" + "strings" + "testing" + + commandkit "go.maximumdirect.net/commandkit" + configkit "go.maximumdirect.net/configkit" +) + +func TestOverrideAssignmentsAreCopiedPerScopeAndPreservedInOrder(t *testing.T) { + var received []Selection + makeScope := func(name string) Scope[struct{}, struct{}] { + return Scope[struct{}, struct{}]{Name: name, Operations: []Operation{Validate}, Features: ScopeFeatures{Overrides: true}, Acquire: func(_ context.Context, req Request, _, _ struct{}) (commandkit.Acquired[View], error) { + received = append(received, cloneSelection(req.Selection)) + if name == "app" { + req.Selection.Overrides[0].Value.(map[string]any)["numbers"].([]any)[0] = int64(999) + } + return commandkit.Acquired[View]{}, nil + }} + } + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{makeScope("app"), makeScope("other")}})) + result := app.Execute(context.Background(), []string{"config", "validate", "--scope=all", "--set=/nested={\"numbers\":[1,2.5],\"v\":\"a=b\"}", "--set=/nested=null", "--set=/false=false", "--set=/zero=0", "--set=/empty=\"\""}, commandkit.Streams{Out: io.Discard, Err: io.Discard}) + if result.Kind != commandkit.OutcomeSuccess || len(received) != 2 || !reflect.DeepEqual(received[0].Overrides, received[1].Overrides) || len(received[0].Overrides) != 5 { + t.Fatalf("result=%#v selections=%#v", result, received) + } + first := received[1].Overrides + object, ok := first[0].Value.(map[string]any) + if !ok || !reflect.DeepEqual(object["numbers"], []any{int64(1), float64(2.5)}) || object["v"] != "a=b" || first[0].Flag != "--set" || first[1].Path.String() != "/nested" || first[1].Value != nil || first[2].Value != false || first[3].Value != int64(0) || first[4].Value != "" { + t.Fatalf("records=%#v", first) + } +} + +func TestOverridePreflightAndComparisonSides(t *testing.T) { + calls, factories := 0, 0 + var pair ComparisonRequest[struct{}] + supported := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Validate, Diff}, Features: ScopeFeatures{Overrides: true}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + calls++ + return commandkit.Acquired[View]{}, nil + }, Compare: func(_ context.Context, value ComparisonRequest[struct{}], _ struct{}) (commandkit.Acquired[[]configkit.Difference], error) { + calls++ + pair = value + return commandkit.Acquired[[]configkit.Difference]{}, nil + }} + unsupported := Scope[struct{}, struct{}]{Name: "other", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + calls++ + return commandkit.Acquired[View]{}, nil + }} + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{NewInput: func() struct{} { factories++; return struct{}{} }, Scopes: []Scope[struct{}, struct{}]{supported, unsupported}})) + for _, args := range [][]string{{"config", "validate", "--scope=other", "--set=/x=1"}, {"config", "validate", "--scope=all", "--set=/x=1"}, {"config", "validate", "--set=/bad"}, {"config", "validate", "--set=/bad={"}, {"config", "validate", "--set=bad=1"}} { + result := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard}) + if result.Kind != commandkit.OutcomeUsage || calls != 0 || factories != 0 { + t.Fatalf("preflight %v: %#v, calls=%d factories=%d", args, result, calls, factories) + } + } + result := app.Execute(context.Background(), []string{"config", "diff", "--set=/x=1", "--against-set=/x=2", "--against-set=/y=[]"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}) + if result.Kind != commandkit.OutcomeSuccess || len(pair.Left.Selection.Overrides) != 1 || len(pair.Right.Selection.Overrides) != 2 || pair.Left.Selection.Overrides[0].Flag != "--set" || pair.Right.Selection.Overrides[0].Flag != "--against-set" || pair.Left.Selection.Overrides[0].Value != int64(1) || pair.Right.Selection.Overrides[0].Value != int64(2) { + t.Fatalf("pair=%#v outcome=%#v", pair, result) + } + result = app.Execute(context.Background(), []string{"config", "diff", "--set=/x=1"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}) + if result.Kind != commandkit.OutcomeSuccess || pair.Right.Selection.Overrides != nil { + t.Fatalf("omitted right = %#v", pair.Right.Selection) + } + without := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{unsupported}})) + if result := without.Execute(context.Background(), []string{"config", "validate", "--set=/x=1"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); result.Kind != commandkit.OutcomeUsage { + t.Fatalf("unavailable flag: %#v", result) + } +} + +func TestOverrideNativeLoadErrorAndProvenance(t *testing.T) { + type settings struct { + Name string `yaml:"name"` + } + scope := FromConfigKit("app", "", func(_ context.Context, selection Selection, _, _ struct{}) (*configkit.Result[settings], error) { + return configkit.Load(configkit.Mapping("input", map[string]any{"name": "before"}), configkit.Options[settings]{Overrides: selection.Overrides}) + }) + scope.Features.Overrides = true + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}})) + var out bytes.Buffer + result := app.Execute(context.Background(), []string{"config", "explain", "--path=/name", "--set=/name=\"after\""}, commandkit.Streams{Out: &out, Err: io.Discard}) + if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), "--set") { + t.Fatalf("provenance: %#v %q", result, out.String()) + } + result = app.Execute(context.Background(), []string{"config", "validate", "--set==1"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}) + var loadErr *configkit.LoadError + if result.Kind != commandkit.OutcomeOperational || !errors.As(result.Cause, &loadErr) { + t.Fatalf("native invalid root override: %#v", result) + } +} diff --git a/integrations/ckconfig/show.go b/integrations/ckconfig/show.go index c0317e6..cfa84ca 100644 --- a/integrations/ckconfig/show.go +++ b/integrations/ckconfig/show.go @@ -23,7 +23,7 @@ func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOpera if selected.scope.scope.Acquire == nil { return commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name)) } - acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{Operation: Show, Selection: plan.selection}, selected.input, state) + acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{Operation: Show, Selection: selected.selection}, selected.input, state) if acquireErr != nil { return configAcquisitionFailure(reporter, "show", selected.scope.scope.Name, acquired, acquireErr) } diff --git a/integrations/ckconfig/sources.go b/integrations/ckconfig/sources.go index c0b95f4..468bc1d 100644 --- a/integrations/ckconfig/sources.go +++ b/integrations/ckconfig/sources.go @@ -66,7 +66,7 @@ func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOp } acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ Operation: Sources, - Selection: plan.selection, + Selection: selected.selection, }, selected.input, state) queueConfigAcquisition(reporter, acquired) if acquireErr != nil { diff --git a/integrations/ckconfig/validate.go b/integrations/ckconfig/validate.go index 69a471e..d27de86 100644 --- a/integrations/ckconfig/validate.go +++ b/integrations/ckconfig/validate.go @@ -33,7 +33,7 @@ func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *confi outcome = commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", name)) break } - acquired, err := selected.scope.scope.Acquire(invocation.Context, Request{Operation: Validate, Selection: plan.selection}, selected.input, state) + acquired, err := selected.scope.scope.Acquire(invocation.Context, Request{Operation: Validate, Selection: selected.selection}, selected.input, state) if err != nil { status := "failed" if invocation.Context.Err() != nil { diff --git a/integrations/ckprompt/adapters_test.go b/integrations/ckprompt/adapters_test.go index 5231402..732f1e2 100644 --- a/integrations/ckprompt/adapters_test.go +++ b/integrations/ckprompt/adapters_test.go @@ -150,14 +150,14 @@ func TestWithSmokeTestingForwardsAuthoritativeNativeRequestAndRetainsReport(t *t suite := WithSmokeTesting(Suite[input, state]{}, func(ctx context.Context, selection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) { loaderCalls++ - if ctx.Value(contextKey{}) != "smoke invocation" || selection != wantSelection || gotInput != wantInput || gotState != wantState { + if ctx.Value(contextKey{}) != "smoke invocation" || !reflect.DeepEqual(selection, wantSelection) || gotInput != wantInput || gotState != wantState { t.Errorf("loader args = ctx=%v selection=%#v input=%#v state=%#v", ctx.Value(contextKey{}), selection, gotInput, gotState) } return engine, nil }, func(ctx context.Context, request SmokeRequest, gotInput input, gotState state) (promptkit.SmokeTestRequest, error) { builderCalls++ - if ctx.Value(contextKey{}) != "smoke invocation" || request.Selection != wantSelection || request.Request.Kind != promptkit.SmokeTestAuthentication || gotInput != wantInput || gotState != wantState { + if ctx.Value(contextKey{}) != "smoke invocation" || !reflect.DeepEqual(request.Selection, wantSelection) || request.Request.Kind != promptkit.SmokeTestAuthentication || gotInput != wantInput || gotState != wantState { t.Errorf("builder args = ctx=%v request=%#v input=%#v state=%#v", ctx.Value(contextKey{}), request, gotInput, gotState) } return built, nil @@ -431,7 +431,7 @@ func TestConfigScopeCompareUsesNativeDiffAndLoadsBothEqualSelections(t *testing. if !reflect.DeepEqual(got.Value, want) { t.Fatalf("adapter differences = %#v, want native PromptKit result %#v", got.Value, want) } - if len(calls) != 2 || calls[0].selection != selection || calls[1].selection != selection || calls[0].input.label != "before" || calls[1].input.label != "after" || calls[0].state != state || calls[1].state != state { + if len(calls) != 2 || !reflect.DeepEqual(calls[0].selection, selection) || !reflect.DeepEqual(calls[1].selection, selection) || calls[0].input.label != "before" || calls[1].input.label != "after" || calls[0].state != state || calls[1].state != state { t.Fatalf("loader calls = %#v", calls) } } @@ -659,7 +659,7 @@ func TestFromEngineListsNativeCatalogsWithInvocationValues(t *testing.T) { loaderCalls := 0 suite := FromEngine(func(ctx context.Context, selection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) { loaderCalls++ - if ctx.Value(contextKey{}) != "catalog invocation" || selection != wantSelection || gotInput != wantInput || gotState != wantState { + if ctx.Value(contextKey{}) != "catalog invocation" || !reflect.DeepEqual(selection, wantSelection) || gotInput != wantInput || gotState != wantState { t.Errorf("loader args = ctx=%v selection=%#v input=%#v state=%#v", ctx.Value(contextKey{}), selection, gotInput, gotState) } return engine, nil diff --git a/integrations/ckprompt/examples/composed/main.go b/integrations/ckprompt/examples/composed/main.go index fd820ef..fb3b1b8 100644 --- a/integrations/ckprompt/examples/composed/main.go +++ b/integrations/ckprompt/examples/composed/main.go @@ -32,8 +32,9 @@ type inspectionInput struct { } type applicationCacheKey struct { - selection ckconfig.Selection - campaign commandkit.Supplied[string] + config commandkit.Supplied[string] + overlay commandkit.Supplied[string] + campaign commandkit.Supplied[string] } // runCache belongs to one invocation and is shared only where the application @@ -80,7 +81,7 @@ func loadApplicationConfig(ctx context.Context, selection ckconfig.Selection, in if input != nil { campaignInput = input.Campaign } - key := applicationCacheKey{selection: selection, campaign: campaignInput} + key := applicationCacheKey{config: selection.Config, overlay: selection.Overlay, campaign: campaignInput} if cache != nil { if cached, ok := cache.applications[key]; ok { return cached, nil diff --git a/integrations/ckprompt/examples/composed/main_test.go b/integrations/ckprompt/examples/composed/main_test.go index 752ce56..963572d 100644 --- a/integrations/ckprompt/examples/composed/main_test.go +++ b/integrations/ckprompt/examples/composed/main_test.go @@ -266,7 +266,7 @@ func TestBuildValidationRequestDefaultsOnlyAbsentVariables(t *testing.T) { if !reflect.DeepEqual(got.Inputs, inputs) || !reflect.DeepEqual(seed.Request.Vars, explicit) { t.Fatalf("seed bindings changed: built=%#v seed=%#v", got.Inputs, explicit) } - key := applicationCacheKey{selection: seed.Selection, campaign: input.Campaign} + key := applicationCacheKey{config: seed.Selection.Config, overlay: seed.Selection.Overlay, campaign: input.Campaign} cached := cache.applications[key] if cached == nil { t.Fatal("builder did not load application configuration into invocation state") diff --git a/integrations/ckprompt/smoke_lifecycle_test.go b/integrations/ckprompt/smoke_lifecycle_test.go index c05e33f..50c7963 100644 --- a/integrations/ckprompt/smoke_lifecycle_test.go +++ b/integrations/ckprompt/smoke_lifecycle_test.go @@ -49,7 +49,7 @@ func TestSmokeTestCommandRetainsSingletonValuesAndResetsInvocations(t *testing.T Overlay: commandkit.Supplied[string]{Value: "final-overlay.yml", Set: true}, } got := requests[0] - if got.selection != wantSelection || got.input.campaign != "final" || got.request.BackendID != "final-backend" || got.request.PromptID != "final-prompt" || got.request.ProfileID != "final-profile" { + if !reflect.DeepEqual(got.selection, wantSelection) || got.input.campaign != "final" || got.request.BackendID != "final-backend" || got.request.PromptID != "final-prompt" || got.request.ProfileID != "final-profile" { t.Fatalf("last supplied values = %#v", got) } if got.request.Execution == nil || got.request.Execution.Model != "final-model" || got.request.Execution.Temperature == nil || *got.request.Execution.Temperature != 0 || got.request.TransientRetries == nil || *got.request.TransientRetries != 0 { diff --git a/integrations/ckprompt/validation_adapters_test.go b/integrations/ckprompt/validation_adapters_test.go index 7fbc12e..8743449 100644 --- a/integrations/ckprompt/validation_adapters_test.go +++ b/integrations/ckprompt/validation_adapters_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "reflect" "strings" "testing" "testing/fstest" @@ -61,7 +62,7 @@ func TestWithRequestValidationPreservesSuiteAndValidatesSeedWithoutBuilder(t *te loaderCalls := 0 suite = WithRequestValidation(suite, func(gotContext context.Context, gotSelection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) { loaderCalls++ - if gotContext != ctx || gotContext.Value(contextKey{}) != "invocation" || gotSelection != selection || gotInput.value != "input" || gotState.value != "state" { + if gotContext != ctx || gotContext.Value(contextKey{}) != "invocation" || !reflect.DeepEqual(gotSelection, selection) || gotInput.value != "input" || gotState.value != "state" { t.Errorf("loader args = (%v, %#v, %#v, %#v)", gotContext, gotSelection, gotInput, gotState) } return engine, nil @@ -138,7 +139,7 @@ func TestWithRequestValidationDelegatesNativeOutcomesWithoutModelCalls(t *testin engine := validationTestEngine(t, adapterPrompt, promptkit.WithLLMClient(client)) selection := ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "selected.yml", Set: true}} suite := WithRequestValidation(Suite[struct{}, struct{}]{}, func(_ context.Context, gotSelection ckconfig.Selection, _, _ struct{}) (*promptkit.Engine, error) { - if gotSelection != selection { + if !reflect.DeepEqual(gotSelection, selection) { t.Errorf("loader selection = %#v, want %#v", gotSelection, selection) } return engine, nil @@ -190,13 +191,13 @@ func TestWithRequestValidationBuilderOutputReachesNativeValidation(t *testing.T) builderCalls, loaderCalls := 0, 0 suite := WithRequestValidation(Suite[input, state]{}, func(gotContext context.Context, gotSelection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) { loaderCalls++ - if gotContext != ctx || gotContext.Value(contextKey{}) != "validation invocation" || gotSelection != selection || gotInput != wantInput || gotState != wantState { + if gotContext != ctx || gotContext.Value(contextKey{}) != "validation invocation" || !reflect.DeepEqual(gotSelection, selection) || gotInput != wantInput || gotState != wantState { t.Errorf("loader args = (%v, %#v, %#v, %#v)", gotContext, gotSelection, gotInput, gotState) } return engine, nil }, func(gotContext context.Context, request ValidationRequest, gotInput input, gotState state) (promptkit.RunRequest, error) { builderCalls++ - if gotContext != ctx || gotContext.Value(contextKey{}) != "validation invocation" || request.Selection != selection || request.Request.PromptID != seed.PromptID || request.Request.Vars["topic"] != "seed topic" || gotInput != wantInput || gotState != wantState { + if gotContext != ctx || gotContext.Value(contextKey{}) != "validation invocation" || !reflect.DeepEqual(request.Selection, selection) || request.Request.PromptID != seed.PromptID || request.Request.Vars["topic"] != "seed topic" || gotInput != wantInput || gotState != wantState { t.Errorf("builder args = (%v, %#v, %#v, %#v)", gotContext, request, gotInput, gotState) } built := request.Request diff --git a/integrations/ckprompt/validation_command_test.go b/integrations/ckprompt/validation_command_test.go index 8567162..16f1192 100644 --- a/integrations/ckprompt/validation_command_test.go +++ b/integrations/ckprompt/validation_command_test.go @@ -34,7 +34,7 @@ func TestValidationCommandComposesWithRequirementsShow(t *testing.T) { NewState: func() state { return state{sequence: 1} }, Selectors: []commandkit.Flag[input]{campaignFlag}, CheckInput: func(selection ckconfig.Selection, value input) error { - if selection != wantSelection || !reflect.DeepEqual(value, wantInput) { + if !reflect.DeepEqual(selection, wantSelection) || !reflect.DeepEqual(value, wantInput) { t.Errorf("checked input = (%#v, %#v)", selection, value) } return nil @@ -70,7 +70,7 @@ func TestValidationCommandComposesWithRequirementsShow(t *testing.T) { if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 || validateCalls != 1 { t.Fatalf("validation outcome = (%#v, %q), calls=%d", outcome, output.String(), validateCalls) } - if received.Selection != wantSelection || received.Request.PromptID != "briefing" || received.Request.ProfileID != "editorial" || received.Request.SessionID != "session-1" { + if !reflect.DeepEqual(received.Selection, wantSelection) || received.Request.PromptID != "briefing" || received.Request.ProfileID != "editorial" || received.Request.SessionID != "session-1" { t.Errorf("validation request = %#v", received) } if received.Request.Vars["topic"] != "roadmap" || len(received.Request.Vars) != 1 {