From 7fc5423688ce558cbd54b7c75b7d20d07ac5489c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 12 Oct 2026 22:13:16 +0000 Subject: [PATCH] Add PromptKit request validation adapter --- docs/roadmap/implementation.md | 12 ++- integrations/ckprompt/adapters.go | 72 +++++++++++-- integrations/ckprompt/validation.go | 16 +++ .../ckprompt/validation_adapters_test.go | 100 ++++++++++++++++++ 4 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 integrations/ckprompt/validation.go create mode 100644 integrations/ckprompt/validation_adapters_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 60dfb7f..56cd00f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # PromptKit request validation implementation plan -Status: Stage 1 complete; implementation is ongoing. +Status: Stages 1 and 2 complete; implementation is ongoing. ## Authority, scope, and baseline @@ -153,6 +153,16 @@ capabilities are cleared. Run the focused tests and ckprompt suite. **Complete when:** the public API and adapter work without command dispatch, with explicit capability opt-in and the selected wiring/error behavior. +**Completed:** 2026-10-12. Added `ValidationRequest`, the optional suite callback, +and `WithRequestValidation`. The callback forwards a nil-builder seed through +PromptKit validation, preserves original selection and invocation objects, and +retains configuration diagnostics. Adapter tests cover suite composition, +successful nil-builder validation, builder failure before loading, independent +retention of nil-loader issues, and installation reporting after capabilities +are cleared. Focused adapter tests and +`GOTOOLCHAIN=local go test -count=1 ./...` passed from +`integrations/ckprompt`. + ## Stage 3: Verify native delegation, request construction, and cancellation **Primary files:** a focused `integrations/ckprompt/validation_adapters_test.go` diff --git a/integrations/ckprompt/adapters.go b/integrations/ckprompt/adapters.go index 0265969..788201f 100644 --- a/integrations/ckprompt/adapters.go +++ b/integrations/ckprompt/adapters.go @@ -43,8 +43,8 @@ type RequirementsRequest struct { } // Suite describes application-owned input, shared state, and optional catalog, -// named inspection, and requirements inspection capabilities. Its private -// issues retain adapter construction failures. +// named inspection, requirements inspection, and concrete request validation +// capabilities. Its private issues retain adapter construction failures. // Factory results and callback effects remain caller-owned: Command does not // deep-copy arbitrary objects or repair aliases returned by factories. // A reached operation prepares one input, applies its captured selectors, and @@ -52,18 +52,18 @@ type RequirementsRequest struct { // Factories are not invoked during construction, installation, help, or version, // and need not run after failure. type Suite[I, S any] struct { - // NewInput is called once when definition, requirements, or catalog input + // NewInput is called once when definition, requirements, validation, or catalog input // preparation is reached, before applying selectors and calling CheckInput. // If nil, input is the zero value of I, including nil for pointer types. NewInput func() I - // NewState is called once when an inspection or catalog callback is reached, + // NewState is called once when an inspection, validation, or catalog callback is reached, // after input checks pass. If nil, state is the zero value of S, including // nil for pointer types. NewState func() S // Selectors declares the original typed flags accepted by definition, - // requirements, and catalog commands. + // requirements, validation, and catalog commands. Selectors []commandkit.Flag[I] - // CheckInput validates selected definition, requirements, or catalog input + // CheckInput validates selected definition, requirements, validation, or catalog input // before state creation. CheckInput func(ckconfig.Selection, I) error // InspectPrompt inspects the selected prompt after input and state preparation. @@ -74,6 +74,10 @@ type Suite[I, S any] struct { // state preparation. It is optional; a nil callback omits the requirements // command without disabling other capabilities. InspectRequirements func(context.Context, RequirementsRequest, I, S) (commandkit.Acquired[*promptkit.RequirementInspection], error) + // ValidateRequest validates a concrete PromptKit request after input and state + // preparation. It is optional; a nil callback omits request validation without + // disabling other capabilities. + ValidateRequest func(context.Context, ValidationRequest, I, S) (commandkit.Acquired[struct{}], error) // ListPrompts lists prompt identities after input and state preparation. // It is optional; a nil callback does not install a prompt catalog command. ListPrompts func(context.Context, ckconfig.Selection, I, S) (commandkit.Acquired[[]promptkit.PromptIdentity], error) @@ -84,6 +88,62 @@ type Suite[I, S any] struct { issues []error } +// WithRequestValidation attaches PromptKit-backed concrete request validation +// to suite. The loader receives the request's original selection; the builder, +// when present, runs first and its returned request is passed to PromptKit +// unchanged. A nil builder forwards the seed request. PromptKit's native +// validation errors and the engine loader's configuration diagnostics are +// returned through the acquisition. A nil loader is retained as a suite wiring +// issue and does not install a validation callback. Caller-owned callback +// references and objects remain caller-owned. +func WithRequestValidation[I, S any]( + suite Suite[I, S], + load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error), + build func(context.Context, ValidationRequest, I, S) (promptkit.RunRequest, error), +) Suite[I, S] { + if load == nil { + issues := append([]error(nil), suite.issues...) + suite.issues = append(issues, errors.New("PromptKit engine loader is nil")) + return suite + } + + suite.ValidateRequest = func(ctx context.Context, request ValidationRequest, input I, state S) (commandkit.Acquired[struct{}], error) { + if err := promptKitContextError(ctx); err != nil { + return commandkit.Acquired[struct{}]{}, fmt.Errorf("PromptKit request validation before building: %w", err) + } + + concreteRequest := request.Request + if build != nil { + built, err := build(ctx, request, input, state) + if err != nil { + return commandkit.Acquired[struct{}]{}, err + } + concreteRequest = built + } + + if err := promptKitContextError(ctx); err != nil { + return commandkit.Acquired[struct{}]{}, fmt.Errorf("PromptKit request validation after building: %w", err) + } + + engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) + if err != nil { + return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, err + } + if err := promptKitContextError(ctx); err != nil { + return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit request validation before native validation: %w", err) + } + + if err := engine.ValidateRequest(ctx, concreteRequest); err != nil { + return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, err + } + if err := promptKitContextError(ctx); err != nil { + return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit request validation after native validation: %w", err) + } + return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, nil + } + return suite +} + type engineLoader[I, S any] func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error) // ConfigScope adapts PromptKit's captured configuration inspection to the diff --git a/integrations/ckprompt/validation.go b/integrations/ckprompt/validation.go new file mode 100644 index 0000000..50d7c0d --- /dev/null +++ b/integrations/ckprompt/validation.go @@ -0,0 +1,16 @@ +package ckprompt + +import ( + "gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig" + promptkit "gitea.maximumdirect.net/eric/promptkit" +) + +// ValidationRequest carries the configuration selection and concrete +// PromptKit request to validate. +type ValidationRequest struct { + // Selection identifies the configuration sources used to load the engine. + Selection ckconfig.Selection + // Request is the seed request, or the request returned by a validation + // builder when one is configured. + Request promptkit.RunRequest +} diff --git a/integrations/ckprompt/validation_adapters_test.go b/integrations/ckprompt/validation_adapters_test.go new file mode 100644 index 0000000..ba693f8 --- /dev/null +++ b/integrations/ckprompt/validation_adapters_test.go @@ -0,0 +1,100 @@ +package ckprompt + +import ( + "context" + "errors" + "strings" + "testing" + + commandkit "gitea.maximumdirect.net/eric/CommandKit" + "gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig" + promptkit "gitea.maximumdirect.net/eric/promptkit" +) + +func TestWithRequestValidationPreservesSuiteAndValidatesSeedWithoutBuilder(t *testing.T) { + type contextKey struct{} + type input struct{ value string } + type state struct{ value string } + + ctx := context.WithValue(context.Background(), contextKey{}, "invocation") + engine := adapterEngine(t) + selection := ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "config.yml", Set: true}} + inspect := func(context.Context, RequirementsRequest, input, state) (commandkit.Acquired[*promptkit.RequirementInspection], error) { + return commandkit.Acquired[*promptkit.RequirementInspection]{}, nil + } + newInput := func() input { return input{value: "factory"} } + suite := Suite[input, state]{InspectRequirements: inspect, NewInput: newInput} + 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" { + t.Errorf("loader args = (%v, %#v, %#v, %#v)", gotContext, gotSelection, gotInput, gotState) + } + return engine, nil + }, nil) + if suite.InspectRequirements == nil || suite.NewInput == nil || suite.ValidateRequest == nil { + t.Fatalf("composed suite lost a member or omitted validation: %#v", suite) + } + + acquired, err := suite.ValidateRequest(ctx, ValidationRequest{ + Selection: selection, + Request: promptkit.RunRequest{PromptID: "empty", ProfileID: "default"}, + }, input{value: "input"}, state{value: "state"}) + if err != nil { + t.Fatalf("validate seed request: %v", err) + } + if acquired.Value != (struct{}{}) || len(acquired.Diagnostics) != 0 || loaderCalls != 1 { + t.Fatalf("acquisition = %#v, loader calls = %d", acquired, loaderCalls) + } +} + +func TestWithRequestValidationBuilderFailurePrecedesLoading(t *testing.T) { + type input struct{ value string } + type state struct{ value string } + buildErr := errors.New("cannot build request") + loadCalls, buildCalls := 0, 0 + suite := WithRequestValidation( + Suite[input, state]{}, + func(context.Context, ckconfig.Selection, input, state) (*promptkit.Engine, error) { + loadCalls++ + return nil, errors.New("loader should not run") + }, + func(ctx context.Context, request ValidationRequest, gotInput input, gotState state) (promptkit.RunRequest, error) { + buildCalls++ + if ctx == nil || request.Request.PromptID != "seed" || gotInput.value != "input" || gotState.value != "state" { + t.Errorf("builder args = (%v, %#v, %#v, %#v)", ctx, request, gotInput, gotState) + } + return promptkit.RunRequest{}, buildErr + }, + ) + + _, err := suite.ValidateRequest(context.Background(), ValidationRequest{Request: promptkit.RunRequest{PromptID: "seed"}}, input{value: "input"}, state{value: "state"}) + if !errors.Is(err, buildErr) || buildCalls != 1 || loadCalls != 0 { + t.Fatalf("validation = (%v), builder calls = %d, loader calls = %d", err, buildCalls, loadCalls) + } +} + +func TestWithRequestValidationRetainsNilLoaderIssueIndependently(t *testing.T) { + firstIssue := errors.New("existing adapter issue") + backing := make([]error, 1, 3) + backing[0] = firstIssue + existingCallback := func(context.Context, ValidationRequest, struct{}, struct{}) (commandkit.Acquired[struct{}], error) { + return commandkit.Acquired[struct{}]{}, nil + } + suite := Suite[struct{}, struct{}]{ValidateRequest: existingCallback, issues: backing[:1]} + composed := WithRequestValidation(suite, nil, nil) + if composed.ValidateRequest == nil || len(composed.issues) != 2 { + t.Fatalf("nil loader composition = %#v", composed) + } + if _, err := composed.ValidateRequest(context.Background(), ValidationRequest{}, struct{}{}, struct{}{}); err != nil { + t.Fatalf("nil loader replaced the existing validation callback: %v", err) + } + if !errors.Is(composed.issues[0], firstIssue) || composed.issues[1] == nil || backing[:cap(backing)][1] != nil { + t.Fatalf("issues = %#v, original backing = %#v", composed.issues, backing[:cap(backing)]) + } + + composed.ValidateRequest = nil + if err := newPromptTestApp(t).Register(Command(composed)); err == nil || !strings.Contains(err.Error(), "PromptKit engine loader is nil") { + t.Fatalf("installation did not report retained issue after capabilities were cleared: %v", err) + } +}