diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 264f476..8745b86 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # PromptKit request validation implementation plan -Status: Stages 1–6 complete; implementation is ongoing. +Status: Stages 1–7 complete; implementation is ongoing. ## Authority, scope, and baseline @@ -411,6 +411,17 @@ making the intentionally failing example a failed completion check. **Complete when:** the maintained consumer demonstrates the accepted feature, typed selection, shared state, and application-owned defaults without execution. +**Completed:** 2026-10-12. Composed `WithRequestValidation` with the existing +engine loader, campaign selector, input/state factories, and invocation cache. +The typed builder loads the selected campaign through that cache and defaults +only absent `topic` and `mode` variables, preserving explicit empty values and +operator bindings. Added offline topic and notes fixtures and extended the +consumer README with both successful file-backed commands and the intentional +missing-topic failure. Composed tests passed with +`GOTOOLCHAIN=local go test -count=1 .`; both successful README commands emitted +the expected two-field YAML confirmation, and the missing-topic command exited +with status 1 for the native required-artifact error. + ## Stage 8: Update canonical documentation for implemented behavior **Primary files:** GoDoc, `docs/cli.md`, `docs/consumers/guide.md`, README, diff --git a/integrations/ckprompt/examples/composed/README.md b/integrations/ckprompt/examples/composed/README.md index 4e35a82..6ca3520 100644 --- a/integrations/ckprompt/examples/composed/README.md +++ b/integrations/ckprompt/examples/composed/README.md @@ -16,6 +16,9 @@ go run . promptkit show prompt briefing --campaign spring --format yaml go run . promptkit show profile spring --campaign spring go run . promptkit requirements show briefing --campaign spring go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml +go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt +go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt --var mode=full --input notes=fixtures/notes.txt --format yaml +go run . promptkit requirements validate briefing --campaign spring go run . session restore session-42 --campaign spring ``` @@ -48,3 +51,13 @@ completes discovery. Both commands return successful reports; the result does not execute the prompt or establish input, credential, or execution readiness. See the [CLI reference](../../../../docs/cli.md#promptkit-requirements) for the command and report contract. + +The validation commands exercise concrete PromptKit request preparation. +The first uses the builder's campaign-specific default topic variable and the +operator's topic file. The second also selects the notes branch and supplies its +file. Both succeed and report only `prompt_id` and `valid: true`; they do not +execute the prompt. The final command intentionally fails because the prompt's +required `topic` artifact is not supplied. The builder defaults variables from +the selected campaign but leaves artifact bindings to the operator. The +`requirements show` commands remain discovery reports: partial discovery can +succeed even when a concrete request would fail validation. diff --git a/integrations/ckprompt/examples/composed/fixtures/notes.txt b/integrations/ckprompt/examples/composed/fixtures/notes.txt new file mode 100644 index 0000000..4a5e2c8 --- /dev/null +++ b/integrations/ckprompt/examples/composed/fixtures/notes.txt @@ -0,0 +1 @@ +Keep the briefing concise and focus on practical next steps. diff --git a/integrations/ckprompt/examples/composed/fixtures/topic.txt b/integrations/ckprompt/examples/composed/fixtures/topic.txt new file mode 100644 index 0000000..4c1c8cf --- /dev/null +++ b/integrations/ckprompt/examples/composed/fixtures/topic.txt @@ -0,0 +1 @@ +Spring planning priorities diff --git a/integrations/ckprompt/examples/composed/main.go b/integrations/ckprompt/examples/composed/main.go index 95e8dd7..245a279 100644 --- a/integrations/ckprompt/examples/composed/main.go +++ b/integrations/ckprompt/examples/composed/main.go @@ -141,6 +141,31 @@ func selectedCampaign(configuration appConfiguration, supplied commandkit.Suppli return name, chosen, nil } +func buildValidationRequest(ctx context.Context, seed ckprompt.ValidationRequest, input *inspectionInput, cache *runCache) (promptkit.RunRequest, error) { + application, err := loadApplicationConfig(ctx, seed.Selection, input, cache) + if err != nil { + return promptkit.RunRequest{}, err + } + name, _, err := selectedCampaign(application.Config, input.Campaign) + if err != nil { + return promptkit.RunRequest{}, err + } + + request := seed.Request + vars := make(map[string]string, len(request.Vars)+2) + for key, value := range request.Vars { + vars[key] = value + } + if _, supplied := vars["topic"]; !supplied { + vars["topic"] = name + " campaign planning" + } + if _, supplied := vars["mode"]; !supplied { + vars["mode"] = "" + } + request.Vars = vars + return request, nil +} + func newApp() (*commandkit.App, error) { limitDefault := 0 limitFlag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "limit", Usage: "Override the application item limit."}, &limitDefault, strconv.Atoi, @@ -170,6 +195,7 @@ func newApp() (*commandkit.App, error) { definitions.NewInput = inputFactory definitions.NewState = stateFactory definitions.Selectors = []commandkit.Flag[*inspectionInput]{campaignSelector()} + definitions = ckprompt.WithRequestValidation(definitions, loadPromptEngine, buildValidationRequest) definitionCommand := ckprompt.Command(definitions) store := map[string]session{ diff --git a/integrations/ckprompt/examples/composed/main_test.go b/integrations/ckprompt/examples/composed/main_test.go index db89761..d63dc88 100644 --- a/integrations/ckprompt/examples/composed/main_test.go +++ b/integrations/ckprompt/examples/composed/main_test.go @@ -4,11 +4,14 @@ import ( "bytes" "context" "io" + "reflect" "testing" commandkit "gitea.maximumdirect.net/eric/CommandKit" "gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig" + "gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt" configkit "gitea.maximumdirect.net/eric/ConfigKit" + promptkit "gitea.maximumdirect.net/eric/promptkit" "go.yaml.in/yaml/v4" ) @@ -211,6 +214,106 @@ func TestComposedRequirementsDiscoveryUsesSelectedCampaign(t *testing.T) { } } +func TestComposedRequestValidationUsesFileBindings(t *testing.T) { + app, err := newApp() + if err != nil { + t.Fatal(err) + } + + for _, test := range []struct { + name string + args []string + }{ + { + name: "default mode", + args: []string{"promptkit", "requirements", "validate", "briefing", "--campaign", "spring", "--input", "topic=fixtures/topic.txt"}, + }, + { + name: "conditional notes", + args: []string{"promptkit", "requirements", "validate", "briefing", "--campaign", "spring", "--input", "topic=fixtures/topic.txt", "--var", "mode=full", "--input", "notes=fixtures/notes.txt", "--format", "yaml"}, + }, + } { + t.Run(test.name, func(t *testing.T) { + var output bytes.Buffer + outcome := app.Execute(context.Background(), test.args, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 { + t.Fatalf("request validation = %#v, report %q", outcome, output.String()) + } + var report map[string]any + if err := yaml.Unmarshal(output.Bytes(), &report); err != nil { + t.Fatalf("decode validation confirmation %q: %v", output.String(), err) + } + if len(report) != 2 || report["prompt_id"] != "briefing" || report["valid"] != true { + t.Fatalf("validation confirmation = %#v", report) + } + }) + } +} + +func TestComposedRequestValidationRequiresTopicArtifact(t *testing.T) { + app, err := newApp() + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing", "--campaign", "spring"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.Cause == nil || output.Len() != 0 { + t.Fatalf("missing topic artifact outcome = %#v, report %q", outcome, output.String()) + } +} + +func TestBuildValidationRequestDefaultsOnlyAbsentVariables(t *testing.T) { + input := &inspectionInput{Campaign: commandkit.Supplied[string]{Value: "autumn", Set: true}} + cache := &runCache{} + inputs := map[string]promptkit.ArtifactRef{"topic": promptkit.File("fixtures/topic.txt")} + explicit := map[string]string{"topic": "", "mode": "", "audience": ""} + seed := ckprompt.ValidationRequest{Request: promptkit.RunRequest{ + PromptID: "briefing", + PromptVersion: "2", + ProfileID: "custom-profile", + SessionID: "session-9", + Vars: explicit, + Inputs: inputs, + }} + + got, err := buildValidationRequest(context.Background(), seed, input, cache) + if err != nil { + t.Fatal(err) + } + if got.PromptID != seed.Request.PromptID || got.PromptVersion != seed.Request.PromptVersion || got.ProfileID != seed.Request.ProfileID || got.SessionID != seed.Request.SessionID { + t.Fatalf("request identity/context changed: %#v", got) + } + if got.Vars["topic"] != "" || got.Vars["mode"] != "" || got.Vars["audience"] != "" { + t.Fatalf("explicit empty values were replaced: %#v", got.Vars) + } + 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} + cached := cache.applications[key] + if cached == nil { + t.Fatal("builder did not load application configuration into invocation state") + } + if _, err := loadPromptEngine(context.Background(), seed.Selection, input, cache); err != nil { + t.Fatal(err) + } + if cache.applications[key] != cached { + t.Fatal("engine loader did not reuse the builder's application configuration") + } + + missing := ckprompt.ValidationRequest{Request: promptkit.RunRequest{PromptID: "briefing", Vars: map[string]string{"audience": "internal"}}} + defaulted, err := buildValidationRequest(context.Background(), missing, input, cache) + if err != nil { + t.Fatal(err) + } + if defaulted.Vars["topic"] != "autumn campaign planning" || defaulted.Vars["mode"] != "" || defaulted.Vars["audience"] != "internal" { + t.Fatalf("missing variables were not defaulted while retaining supplied values: %#v", defaulted.Vars) + } + if _, ok := missing.Request.Vars["topic"]; ok { + t.Fatalf("builder modified the seed variable map: %#v", missing.Request.Vars) + } +} + func TestPromptKitScopeDiffAndCatalogsUseSelectedCampaign(t *testing.T) { app, err := newApp() if err != nil {