diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 2e6b8c8..88633dc 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement ConfigKit adoption and configuration comparison -Status: implementation in progress; Stages 1–9 are complete. +Status: implementation in progress; Stages 1–10 are complete. ## Scope and authority @@ -506,9 +506,18 @@ different selections or loader inputs; PromptKit remains an independent capabili supplied to both sides can make their effective defaults equal. Include `--limit=0` when campaign inspection triggers the existing input check. -**Completion evidence:** meaningful simple and composed diff reports, cache -separation for different selections, unchanged representative PromptKit/domain -commands, and a usage failure for unsupported `--scope promptkit` comparison. +**Completion evidence — complete:** the composed example now loads its two local +files through ConfigKit, applies explicitly supplied Campaign as a native +`default_campaign` override, and caches successful Results by complete supplied +selection and Campaign presence/value. Tests confirm the ordinary fixture diff +reports `changes.default_campaign: autumn`, a shared `--campaign spring` override +produces equal effective values, distinct file and supplied-campaign keys do not +reuse the wrong result, equal keys may reuse, nil state bypasses caching, and +failed loads are not retained. Representative engine validate/show/sources/ +explain, named prompt/profile inspection, and session restore commands continue +to succeed without model calls. The PromptKit config scope remains unsupported +by `config diff`; that selection returns a usage error. README commands and cache +identity explanation are updated. `go test -count=1 ./...` passes in ckp. ## Stage 11: Reconcile operator, API, and consumer documentation diff --git a/integrations/ckprompt/examples/composed/README.md b/integrations/ckprompt/examples/composed/README.md index 040f6c4..71262d3 100644 --- a/integrations/ckprompt/examples/composed/README.md +++ b/integrations/ckprompt/examples/composed/README.md @@ -6,6 +6,8 @@ definition inspection, and one application-owned command: ```sh go run . config scopes go run . config validate --scope all --campaign spring --limit=0 +go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml +go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring --limit=0 go run . promptkit show prompt briefing --campaign spring --format yaml go run . promptkit show profile spring --campaign spring go run . session restore session-42 --campaign spring @@ -20,3 +22,12 @@ cache independently for its invocation, using the same application and engine loader functions. See the [consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md) and the suites' GoDoc for initialization timing and ownership. + +The application-scope comparison loads the local `before.yml` and `after.yml` +fixtures and reports `changes.default_campaign: autumn`. The second invocation +supplies `spring` to both sides, so the effective defaults compare equal; the +application input check also requires `--limit=0` when `--campaign` is supplied. +The example's shared run cache keys successful results by the complete supplied +configuration/overlay selection and the supplied campaign, including presence. +The campaign override is applied through ConfigKit before loading. These are +choices for this example's loader and do not define a CommandKit cache policy. diff --git a/integrations/ckprompt/examples/composed/fixtures/after.yml b/integrations/ckprompt/examples/composed/fixtures/after.yml new file mode 100644 index 0000000..456b35c --- /dev/null +++ b/integrations/ckprompt/examples/composed/fixtures/after.yml @@ -0,0 +1,12 @@ +default_campaign: autumn +campaigns: + spring: + prompt_id: briefing + prompt_version: "1" + profile_id: spring + model: spring-model + autumn: + prompt_id: briefing + prompt_version: "1" + profile_id: autumn + model: autumn-model diff --git a/integrations/ckprompt/examples/composed/fixtures/before.yml b/integrations/ckprompt/examples/composed/fixtures/before.yml new file mode 100644 index 0000000..2bd5742 --- /dev/null +++ b/integrations/ckprompt/examples/composed/fixtures/before.yml @@ -0,0 +1,12 @@ +default_campaign: spring +campaigns: + spring: + prompt_id: briefing + prompt_version: "1" + profile_id: spring + model: spring-model + autumn: + prompt_id: briefing + prompt_version: "1" + profile_id: autumn + model: autumn-model diff --git a/integrations/ckprompt/examples/composed/main.go b/integrations/ckprompt/examples/composed/main.go index 0483579..71a2e01 100644 --- a/integrations/ckprompt/examples/composed/main.go +++ b/integrations/ckprompt/examples/composed/main.go @@ -31,10 +31,15 @@ type inspectionInput struct { Limit commandkit.Supplied[int] } +type applicationCacheKey struct { + selection ckconfig.Selection + campaign commandkit.Supplied[string] +} + // runCache belongs to one invocation and is shared only where the application // chooses to pass it to its loaders. type runCache struct { - application *configkit.Result[appConfiguration] + applications map[applicationCacheKey]*configkit.Result[appConfiguration] } type restoreInput struct { @@ -53,22 +58,44 @@ func campaignSelector() commandkit.Flag[*inspectionInput] { func(input **inspectionInput, value commandkit.Supplied[string]) { (*input).Campaign = value }) } -func loadApplicationConfig(ctx context.Context, _ ckconfig.Selection, _ *inspectionInput, cache *runCache) (*configkit.Result[appConfiguration], error) { +func loadApplicationConfig(ctx context.Context, selection ckconfig.Selection, input *inspectionInput, cache *runCache) (*configkit.Result[appConfiguration], error) { if err := ctx.Err(); err != nil { return nil, err } - if cache != nil && cache.application != nil { - return cache.application, nil + campaignInput := commandkit.Supplied[string]{} + if input != nil { + campaignInput = input.Campaign } - result, err := configkit.Load[appConfiguration](configkit.Mapping("composed application settings", map[string]any{ + key := applicationCacheKey{selection: selection, campaign: campaignInput} + if cache != nil { + if cached, ok := cache.applications[key]; ok { + return cached, nil + } + } + source := configkit.Mapping("composed application settings", map[string]any{ "default_campaign": "spring", "campaigns": map[string]any{ "spring": map[string]any{"prompt_id": "briefing", "prompt_version": "1", "profile_id": "spring", "model": "spring-model"}, "autumn": map[string]any{"prompt_id": "briefing", "prompt_version": "1", "profile_id": "autumn", "model": "autumn-model"}, }, - })) - if err == nil && cache != nil { - cache.application = result + }) + if selection.Config.Set { + source = configkit.File(selection.Config.Value) + } + options := configkit.Options[appConfiguration]{} + if selection.Overlay.Set { + options.Overlay = selection.Overlay.Value + options.Filesystem = &configkit.FilesystemContext{Root: ".", BaseDir: "."} + } + if campaignInput.Set { + options.Overrides = []configkit.Override{{Path: configkit.Keys("default_campaign"), Value: campaignInput.Value, Flag: "--campaign"}} + } + result, err := configkit.Load(source, options) + if err == nil && result != nil && cache != nil { + if cache.applications == nil { + cache.applications = make(map[applicationCacheKey]*configkit.Result[appConfiguration]) + } + cache.applications[key] = result } return result, err } diff --git a/integrations/ckprompt/examples/composed/main_test.go b/integrations/ckprompt/examples/composed/main_test.go new file mode 100644 index 0000000..f2417da --- /dev/null +++ b/integrations/ckprompt/examples/composed/main_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "bytes" + "context" + "io" + "testing" + + commandkit "gitea.maximumdirect.net/eric/CommandKit" + "gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig" + configkit "gitea.maximumdirect.net/eric/ConfigKit" + "go.yaml.in/yaml/v4" +) + +func TestComposedApplicationDiffUsesFixturesAndCampaignOverride(t *testing.T) { + app, err := newApp() + if err != nil { + t.Fatal(err) + } + args := []string{"config", "diff", "--scope", "app", "--config", "fixtures/before.yml", "--against-config", "fixtures/after.yml"} + var output bytes.Buffer + outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess { + t.Fatalf("fixture diff outcome = %#v", outcome) + } + value := composedReportValue(t, output.Bytes(), "changes", "default_campaign") + if value == nil || value.Value != "autumn" { + t.Fatalf("fixture diff default_campaign = %#v; report %q", value, output.String()) + } + + output.Reset() + args = append(args, "--campaign", "spring", "--limit=0") + outcome = app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess { + t.Fatalf("overridden fixture diff outcome = %#v", outcome) + } + for _, category := range []string{"additions", "changes", "deletions"} { + if node := composedReportValue(t, output.Bytes(), category); len(node.Content) != 0 { + t.Fatalf("campaign override left %s: %q", category, output.String()) + } + } +} + +func TestApplicationCacheSeparatesSelectionsCampaignValuesAndPresence(t *testing.T) { + cache := &runCache{} + beforeSelection := ckconfigSelection("fixtures/before.yml") + afterSelection := ckconfigSelection("fixtures/after.yml") + load := func(selection ckconfig.Selection, input *inspectionInput) *configkit.Result[appConfiguration] { + t.Helper() + result, err := loadApplicationConfig(context.Background(), selection, input, cache) + if err != nil { + t.Fatal(err) + } + return result + } + + before := load(beforeSelection, nil) + after := load(afterSelection, nil) + if before.Config.DefaultCampaign != "spring" || after.Config.DefaultCampaign != "autumn" || before == after { + t.Fatalf("file selections shared or returned wrong values: before=%#v after=%#v", before.Config, after.Config) + } + defaultAfter := load(afterSelection, nil) + suppliedSpring := load(afterSelection, &inspectionInput{Campaign: commandkit.Supplied[string]{Value: "spring", Set: true}}) + suppliedAutumn := load(afterSelection, &inspectionInput{Campaign: commandkit.Supplied[string]{Value: "autumn", Set: true}}) + suppliedEmpty := load(afterSelection, &inspectionInput{Campaign: commandkit.Supplied[string]{Set: true}}) + if defaultAfter != after || suppliedSpring.Config.DefaultCampaign != "spring" || suppliedAutumn.Config.DefaultCampaign != "autumn" || suppliedEmpty.Config.DefaultCampaign != "" { + t.Fatalf("campaign loading values = default:%q spring:%q autumn:%q empty:%q", defaultAfter.Config.DefaultCampaign, suppliedSpring.Config.DefaultCampaign, suppliedAutumn.Config.DefaultCampaign, suppliedEmpty.Config.DefaultCampaign) + } + if suppliedSpring == suppliedAutumn || suppliedAutumn == defaultAfter || suppliedEmpty == defaultAfter || len(cache.applications) != 5 { + t.Fatalf("cache did not distinguish selection/input presence: entries=%d", len(cache.applications)) + } + + uncachedOne, err := loadApplicationConfig(context.Background(), afterSelection, nil, nil) + if err != nil { + t.Fatal(err) + } + uncachedTwo, err := loadApplicationConfig(context.Background(), afterSelection, nil, nil) + if err != nil { + t.Fatal(err) + } + if uncachedOne == uncachedTwo { + t.Fatal("nil cache reused an application result") + } +} + +func TestApplicationCacheDoesNotRetainFailedLoads(t *testing.T) { + cache := &runCache{} + selection := ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "fixtures/missing.yml", Set: true}} + for range 2 { + if _, err := loadApplicationConfig(context.Background(), selection, nil, cache); err == nil { + t.Fatal("missing configuration unexpectedly loaded") + } + } + if len(cache.applications) != 0 { + t.Fatalf("failed result was cached: %#v", cache.applications) + } +} + +func TestComposedPromptKitAndDomainCommandsRemainAvailable(t *testing.T) { + app, err := newApp() + if err != nil { + t.Fatal(err) + } + commands := [][]string{ + {"config", "validate", "--scope", "all", "--campaign", "spring", "--limit=0"}, + {"config", "show", "--scope", "promptkit", "--campaign", "spring"}, + {"config", "sources", "--scope", "promptkit", "--campaign", "spring"}, + {"config", "explain", "--scope", "promptkit", "--path", "/profiles/spring/model", "--campaign", "spring"}, + {"promptkit", "show", "prompt", "briefing", "--campaign", "spring", "--format", "yaml"}, + {"promptkit", "show", "profile", "spring", "--campaign", "spring"}, + {"session", "restore", "session-42", "--campaign", "spring"}, + } + for _, args := range commands { + outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess { + t.Errorf("command %q outcome = %#v", args, outcome) + } + } +} + +func TestPromptKitScopeDoesNotSupportConfigurationDiff(t *testing.T) { + app, err := newApp() + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "diff", "--scope", "promptkit"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeUsage || output.Len() != 0 { + t.Fatalf("PromptKit diff outcome/output = %#v / %q", outcome, output.String()) + } +} + +func composedReportValue(t *testing.T, data []byte, category string, fields ...string) *yaml.Node { + t.Helper() + var document yaml.Node + if err := yaml.Unmarshal(data, &document); err != nil { + t.Fatalf("decode composed report %q: %v", data, err) + } + node := composedField(document.Content[0], category) + for _, field := range fields { + node = composedField(node, field) + } + return node +} + +func composedField(mapping *yaml.Node, name string) *yaml.Node { + if mapping == nil || mapping.Kind != yaml.MappingNode { + return nil + } + for index := 0; index+1 < len(mapping.Content); index += 2 { + if mapping.Content[index].Value == name { + return mapping.Content[index+1] + } + } + return nil +} + +func ckconfigSelection(path string) ckconfig.Selection { + return ckconfig.Selection{Config: commandkit.Supplied[string]{Value: path, Set: true}} +}