Add opt-in configuration overrides

This commit is contained in:
2026-11-07 04:39:34 +00:00
parent 85ae55bf42
commit aeb70e5873
22 changed files with 238 additions and 35 deletions

View File

@@ -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 declared scope capabilities and installed handlers, so an operation is listed
only when the corresponding command is available. 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 Applications can add `ckprompt.ConfigScope(loadEngine)` to the ConfigKit suite
to expose PromptKit engine configuration through these handlers. The scope to expose PromptKit engine configuration through these handlers. The scope
declares validate, show, sources, explain, and native diff. It does not install declares validate, show, sources, explain, and native diff. It does not install

View File

@@ -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 `View.Metadata` and `View.Findings` carry native path metadata and diagnostic
records when available. `Findings.Messages` contains presentations associated records when available. `Findings.Messages` contains presentations associated
with those records, while `Acquired.Diagnostics` carries independent messages. 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 ## PromptKit configuration, definitions, and requirements

View File

@@ -1,6 +1,6 @@
# Shared reporting and PromptKit v0.20.0 implementation plan # 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 ## Scope, authority, and execution
@@ -571,6 +571,11 @@ model. Explicit-empty custom collections remain application-defined.
## Stage 16: Opt-in transient override acquisition ## 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 Add `Selection.Overrides []configkit.Override`. Preserve ordered records; nil
means no CLI records. A loader opting in via `scope.Features.Overrides = true` 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. reads those records during its ordinary load and supplies them to ConfigKit.

View File

@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
"testing" "testing"
@@ -66,7 +67,7 @@ func TestFromConfigKitCompareReturnsNativeChangesAndLoadsEqualSelections(t *test
if err != nil { if err != nil {
t.Fatal(err) 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) 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 { if calls[0].input.Label != "before" || calls[1].input.Label != "after" || calls[0].state != state || calls[1].state != state || *state != 2 {

View File

@@ -3,6 +3,7 @@ package ckconfig
import ( import (
"context" "context"
"io" "io"
"reflect"
"strings" "strings"
"testing" "testing"
@@ -75,7 +76,7 @@ func TestComparisonPreparation(t *testing.T) {
} }
if want := (Selection{ if want := (Selection{
Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}, 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) t.Fatalf("resolved right selection = %#v, want %#v", input.against, want)
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"io" "io"
"reflect"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@@ -106,7 +107,7 @@ func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T)
Config: commandkit.Supplied[string]{Value: "same.yml", Set: true}, Config: commandkit.Supplied[string]{Value: "same.yml", Set: true},
Overlay: commandkit.Supplied[string]{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) 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 input struct{ ID int }
type state struct{ Label string } type state struct{ Label string }
inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls := 0, 0, 0, 0, 0 inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls := 0, 0, 0, 0, 0
checked := make(map[Selection]input) var checked []input
shared := &state{Label: "shared"} shared := &state{Label: "shared"}
var request ComparisonRequest[input] var request ComparisonRequest[input]
app := newConfigTestApp(t, Command(Suite[input, *state]{ app := newConfigTestApp(t, Command(Suite[input, *state]{
@@ -135,7 +136,7 @@ func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) {
if scope != "app" { if scope != "app" {
return errors.New("unexpected scope") return errors.New("unexpected scope")
} }
checked[selection] = value checked = append(checked, value)
return nil return nil
}, },
Scopes: []Scope[input, *state]{{ Scopes: []Scope[input, *state]{{
@@ -162,10 +163,10 @@ func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) {
} }
left := Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}} left := Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}}
right := Selection{Overlay: commandkit.Supplied[string]{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) 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) t.Fatalf("provider inputs = %#v, checked = %#v", request, checked)
} }
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 || compareCalls != 1 || acquireCalls != 0 { if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 || compareCalls != 1 || acquireCalls != 0 {

View File

@@ -15,6 +15,8 @@ type Selection struct {
Config commandkit.Supplied[string] Config commandkit.Supplied[string]
// Overlay is the caller-supplied overlay path, if present. // Overlay is the caller-supplied overlay path, if present.
Overlay commandkit.Supplied[string] 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. // Operation identifies a supported standard configuration operation.

View File

@@ -92,7 +92,7 @@ func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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) 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 { if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result || acquired.Value.Metadata == nil || acquired.Value.Findings == nil {

View File

@@ -63,7 +63,7 @@ func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *conf
} }
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
Operation: Explain, Operation: Explain,
Selection: plan.selection, Selection: selected.selection,
}, selected.input, state) }, selected.input, state)
queueConfigAcquisition(reporter, acquired) queueConfigAcquisition(reporter, acquired)
if acquireErr != nil { if acquireErr != nil {

View File

@@ -44,7 +44,7 @@ func TestComparisonSidesKeepIndependentSelectionsAndDefaults(t *testing.T) {
} }
for _, test := range tests { for _, test := range tests {
result := app.Execute(context.Background(), append([]string{"config", "diff"}, test.args...), commandkit.Streams{Out: io.Discard, Err: io.Discard}) 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) t.Fatalf("args=%v outcome=%#v pair=%#v", test.args, result, pair)
} }
} }

View File

@@ -21,6 +21,7 @@ type configCommandInput[I, S any] struct {
type selectedScopeInput[I, S any] struct { type selectedScopeInput[I, S any] struct {
scope *preparedScope[I, S] scope *preparedScope[I, S]
input I input I
selection Selection
} }
type preparedComparison[I, S any] struct { type preparedComparison[I, S any] struct {
@@ -31,7 +32,6 @@ type preparedComparison[I, S any] struct {
type configOperationPlan[I, S any] struct { type configOperationPlan[I, S any] struct {
prepared *preparedSuite[I, S] prepared *preparedSuite[I, S]
operation Operation operation Operation
selection Selection
scopeName string scopeName string
path configkit.Path path configkit.Path
scopes []selectedScopeInput[I, S] 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 { if operation == Explain {
flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain.", Required: true}, (*string)(nil), parseConfigString, 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 })) 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) { 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)) 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 var path configkit.Path
@@ -172,13 +187,14 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O
plan := &configOperationPlan[I, S]{ plan := &configOperationPlan[I, S]{
prepared: prepared, prepared: prepared,
operation: operation, operation: operation,
selection: input.selection,
scopeName: scopeName, scopeName: scopeName,
path: path, path: path,
scopes: make([]selectedScopeInput[I, S], 0, len(selected)), scopes: make([]selectedScopeInput[I, S], 0, len(selected)),
} }
if operation == Diff { if operation == Diff {
scope := selected[0] 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) left, err := prepareScopeInput(prepared, input.values, scope, scope.bindings, input.selection, true)
if err != nil { if err != nil {
return fmt.Errorf("configuration scope %q left input: %w", scope.scope.Name, err) 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 return nil
} }
for _, scope := range selected { 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 { if err != nil {
return fmt.Errorf("configuration scope %q: %w", scope.scope.Name, err) 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 input.plan = plan
return nil return nil

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -23,7 +23,7 @@ func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOpera
if selected.scope.scope.Acquire == nil { if selected.scope.scope.Acquire == nil {
return commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name)) 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 { if acquireErr != nil {
return configAcquisitionFailure(reporter, "show", selected.scope.scope.Name, acquired, acquireErr) return configAcquisitionFailure(reporter, "show", selected.scope.scope.Name, acquired, acquireErr)
} }

View File

@@ -66,7 +66,7 @@ func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOp
} }
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
Operation: Sources, Operation: Sources,
Selection: plan.selection, Selection: selected.selection,
}, selected.input, state) }, selected.input, state)
queueConfigAcquisition(reporter, acquired) queueConfigAcquisition(reporter, acquired)
if acquireErr != nil { if acquireErr != nil {

View File

@@ -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)) outcome = commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", name))
break 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 { if err != nil {
status := "failed" status := "failed"
if invocation.Context.Err() != nil { if invocation.Context.Err() != nil {

View File

@@ -150,14 +150,14 @@ func TestWithSmokeTestingForwardsAuthoritativeNativeRequestAndRetainsReport(t *t
suite := WithSmokeTesting(Suite[input, state]{}, suite := WithSmokeTesting(Suite[input, state]{},
func(ctx context.Context, selection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) { func(ctx context.Context, selection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) {
loaderCalls++ 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) t.Errorf("loader args = ctx=%v selection=%#v input=%#v state=%#v", ctx.Value(contextKey{}), selection, gotInput, gotState)
} }
return engine, nil return engine, nil
}, },
func(ctx context.Context, request SmokeRequest, gotInput input, gotState state) (promptkit.SmokeTestRequest, error) { func(ctx context.Context, request SmokeRequest, gotInput input, gotState state) (promptkit.SmokeTestRequest, error) {
builderCalls++ 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) t.Errorf("builder args = ctx=%v request=%#v input=%#v state=%#v", ctx.Value(contextKey{}), request, gotInput, gotState)
} }
return built, nil return built, nil
@@ -431,7 +431,7 @@ func TestConfigScopeCompareUsesNativeDiffAndLoadsBothEqualSelections(t *testing.
if !reflect.DeepEqual(got.Value, want) { if !reflect.DeepEqual(got.Value, want) {
t.Fatalf("adapter differences = %#v, want native PromptKit result %#v", 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) t.Fatalf("loader calls = %#v", calls)
} }
} }
@@ -659,7 +659,7 @@ func TestFromEngineListsNativeCatalogsWithInvocationValues(t *testing.T) {
loaderCalls := 0 loaderCalls := 0
suite := FromEngine(func(ctx context.Context, selection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) { suite := FromEngine(func(ctx context.Context, selection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) {
loaderCalls++ 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) t.Errorf("loader args = ctx=%v selection=%#v input=%#v state=%#v", ctx.Value(contextKey{}), selection, gotInput, gotState)
} }
return engine, nil return engine, nil

View File

@@ -32,7 +32,8 @@ type inspectionInput struct {
} }
type applicationCacheKey struct { type applicationCacheKey struct {
selection ckconfig.Selection config commandkit.Supplied[string]
overlay commandkit.Supplied[string]
campaign commandkit.Supplied[string] campaign commandkit.Supplied[string]
} }
@@ -80,7 +81,7 @@ func loadApplicationConfig(ctx context.Context, selection ckconfig.Selection, in
if input != nil { if input != nil {
campaignInput = input.Campaign campaignInput = input.Campaign
} }
key := applicationCacheKey{selection: selection, campaign: campaignInput} key := applicationCacheKey{config: selection.Config, overlay: selection.Overlay, campaign: campaignInput}
if cache != nil { if cache != nil {
if cached, ok := cache.applications[key]; ok { if cached, ok := cache.applications[key]; ok {
return cached, nil return cached, nil

View File

@@ -266,7 +266,7 @@ func TestBuildValidationRequestDefaultsOnlyAbsentVariables(t *testing.T) {
if !reflect.DeepEqual(got.Inputs, inputs) || !reflect.DeepEqual(seed.Request.Vars, explicit) { if !reflect.DeepEqual(got.Inputs, inputs) || !reflect.DeepEqual(seed.Request.Vars, explicit) {
t.Fatalf("seed bindings changed: built=%#v seed=%#v", got.Inputs, 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] cached := cache.applications[key]
if cached == nil { if cached == nil {
t.Fatal("builder did not load application configuration into invocation state") t.Fatal("builder did not load application configuration into invocation state")

View File

@@ -49,7 +49,7 @@ func TestSmokeTestCommandRetainsSingletonValuesAndResetsInvocations(t *testing.T
Overlay: commandkit.Supplied[string]{Value: "final-overlay.yml", Set: true}, Overlay: commandkit.Supplied[string]{Value: "final-overlay.yml", Set: true},
} }
got := requests[0] 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) 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 { 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 {

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"io" "io"
"reflect"
"strings" "strings"
"testing" "testing"
"testing/fstest" "testing/fstest"
@@ -61,7 +62,7 @@ func TestWithRequestValidationPreservesSuiteAndValidatesSeedWithoutBuilder(t *te
loaderCalls := 0 loaderCalls := 0
suite = WithRequestValidation(suite, func(gotContext context.Context, gotSelection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) { suite = WithRequestValidation(suite, func(gotContext context.Context, gotSelection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) {
loaderCalls++ 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) t.Errorf("loader args = (%v, %#v, %#v, %#v)", gotContext, gotSelection, gotInput, gotState)
} }
return engine, nil return engine, nil
@@ -138,7 +139,7 @@ func TestWithRequestValidationDelegatesNativeOutcomesWithoutModelCalls(t *testin
engine := validationTestEngine(t, adapterPrompt, promptkit.WithLLMClient(client)) engine := validationTestEngine(t, adapterPrompt, promptkit.WithLLMClient(client))
selection := ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "selected.yml", Set: true}} 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) { 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) t.Errorf("loader selection = %#v, want %#v", gotSelection, selection)
} }
return engine, nil return engine, nil
@@ -190,13 +191,13 @@ func TestWithRequestValidationBuilderOutputReachesNativeValidation(t *testing.T)
builderCalls, loaderCalls := 0, 0 builderCalls, loaderCalls := 0, 0
suite := WithRequestValidation(Suite[input, state]{}, func(gotContext context.Context, gotSelection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) { suite := WithRequestValidation(Suite[input, state]{}, func(gotContext context.Context, gotSelection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) {
loaderCalls++ 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) t.Errorf("loader args = (%v, %#v, %#v, %#v)", gotContext, gotSelection, gotInput, gotState)
} }
return engine, nil return engine, nil
}, func(gotContext context.Context, request ValidationRequest, gotInput input, gotState state) (promptkit.RunRequest, error) { }, func(gotContext context.Context, request ValidationRequest, gotInput input, gotState state) (promptkit.RunRequest, error) {
builderCalls++ 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) t.Errorf("builder args = (%v, %#v, %#v, %#v)", gotContext, request, gotInput, gotState)
} }
built := request.Request built := request.Request

View File

@@ -34,7 +34,7 @@ func TestValidationCommandComposesWithRequirementsShow(t *testing.T) {
NewState: func() state { return state{sequence: 1} }, NewState: func() state { return state{sequence: 1} },
Selectors: []commandkit.Flag[input]{campaignFlag}, Selectors: []commandkit.Flag[input]{campaignFlag},
CheckInput: func(selection ckconfig.Selection, value input) error { 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) t.Errorf("checked input = (%#v, %#v)", selection, value)
} }
return nil return nil
@@ -70,7 +70,7 @@ func TestValidationCommandComposesWithRequirementsShow(t *testing.T) {
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 || validateCalls != 1 { if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 || validateCalls != 1 {
t.Fatalf("validation outcome = (%#v, %q), calls=%d", outcome, output.String(), validateCalls) 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) t.Errorf("validation request = %#v", received)
} }
if received.Request.Vars["topic"] != "roadmap" || len(received.Request.Vars) != 1 { if received.Request.Vars["topic"] != "roadmap" || len(received.Request.Vars) != 1 {