From 686a7ba0ab74a5b2c55ff452d685e5b072a56523 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 12 Oct 2026 22:16:44 +0000 Subject: [PATCH] Add PromptKit requirements validation command --- docs/roadmap/implementation.md | 12 +- integrations/ckprompt/command.go | 44 ++++-- integrations/ckprompt/validation_command.go | 131 ++++++++++++++++++ .../ckprompt/validation_command_test.go | 90 ++++++++++++ 4 files changed, 264 insertions(+), 13 deletions(-) create mode 100644 integrations/ckprompt/validation_command.go create mode 100644 integrations/ckprompt/validation_command_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9e9c190..cbc3f6e 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # PromptKit request validation implementation plan -Status: Stages 1–3 complete; implementation is ongoing. +Status: Stages 1–4 complete; implementation is ongoing. ## Authority, scope, and baseline @@ -265,6 +265,16 @@ Run focused tests and the ckprompt suite. **Complete when:** the new command invokes the selected provider and produces the agreed YAML confirmation using existing core mechanics. +**Completed:** 2026-10-12. Added `requirements validate` with typed selector +preparation, presence-aware configuration selection, PromptKit request flags, +repeatable variable and file bindings, and YAML confirmation output. `Command` +now composes supplied show and validate declarations in one requirements group +and retains issue reporting for absent capabilities. The assembled command test +verifies callback values, first-`=` file paths, semantic YAML output, and both +operations in help. Focused command tests and +`GOTOOLCHAIN=local go test -count=1 ./...` passed from +`integrations/ckprompt`. + ## Stage 5: Cover command grammar, capability availability, and input lifetimes **Primary files:** a focused `integrations/ckprompt/validation_command_test.go` diff --git a/integrations/ckprompt/command.go b/integrations/ckprompt/command.go index 7bc9f99..e3aae4d 100644 --- a/integrations/ckprompt/command.go +++ b/integrations/ckprompt/command.go @@ -44,20 +44,25 @@ type variableAssignment struct { value string } -// Command captures the optional PromptKit catalog, named inspection, and -// requirements inspection callbacks and adds only supplied operations. An -// absent suite claims no namespace; attached construction issues remain -// reportable. It adds no configuration family; callers compose ConfigScope -// independently. Construction, installation, help, and version do not run -// suite factories or providers. Command captures suite callbacks and selector -// metadata; callback-referenced objects and effects remain caller-owned. Suite -// documents invocation input and state initialization, zero values, and caller -// ownership of returned objects. +type inputAssignment struct { + name string + path string +} + +// Command captures the optional PromptKit catalog, named inspection, +// requirements inspection, and concrete request validation callbacks and adds +// only supplied operations. An absent suite claims no namespace; attached +// construction issues remain reportable. It adds no configuration family; +// callers compose ConfigScope independently. Construction, installation, help, +// and version do not run suite factories or providers. Command captures suite +// callbacks and selector metadata; callback-referenced objects and effects +// remain caller-owned. Suite documents invocation input and state initialization, +// zero values, and caller ownership of returned objects. // Catalog commands accept config/overlay selection and suite selectors, and // render YAML with `yaml` as the only format and default. Named-show formats are // unchanged. func Command[I, S any](suite Suite[I, S]) commandkit.Command { - if suite.InspectPrompt == nil && suite.InspectProfile == nil && suite.InspectRequirements == nil && suite.ListPrompts == nil && suite.ListProfiles == nil { + if suite.InspectPrompt == nil && suite.InspectProfile == nil && suite.InspectRequirements == nil && suite.ValidateRequest == nil && suite.ListPrompts == nil && suite.ListProfiles == nil { command := commandkit.NoCommand() for _, issue := range suite.issues { command = commandkit.WithError(command, issue) @@ -87,10 +92,17 @@ func Command[I, S any](suite Suite[I, S]) commandkit.Command { if len(listCommands) != 0 { rootCommands = append(rootCommands, commandkit.Group("list", "List PromptKit catalogs.", listCommands...)) } + requirementsCommands := make([]commandkit.Command, 0, 2) if suite.InspectRequirements != nil { - rootCommands = append(rootCommands, commandkit.Group("requirements", "Inspect prompt requirements.", newRequirementsCommand(suite))) + requirementsCommands = append(requirementsCommands, newRequirementsCommand(suite)) } - command := commandkit.RootGroup("promptkit", "Inspect and list PromptKit definitions and requirements.", rootCommands...) + if suite.ValidateRequest != nil { + requirementsCommands = append(requirementsCommands, newValidationCommand(suite)) + } + if len(requirementsCommands) != 0 { + rootCommands = append(rootCommands, commandkit.Group("requirements", "Inspect and validate prompt requirements.", requirementsCommands...)) + } + command := commandkit.RootGroup("promptkit", "Inspect, validate, and list PromptKit definitions and requirements.", rootCommands...) for _, issue := range suite.issues { command = commandkit.WithError(command, issue) } @@ -721,4 +733,12 @@ func parseVariableAssignment(raw string) (variableAssignment, error) { return variableAssignment{key: key, value: value}, nil } +func parseInputAssignment(raw string) (inputAssignment, error) { + name, path, found := strings.Cut(raw, "=") + if !found { + return inputAssignment{}, fmt.Errorf("input assignment %q must contain =", raw) + } + return inputAssignment{name: name, path: path}, nil +} + func stringDefault(value string) *string { return &value } diff --git a/integrations/ckprompt/validation_command.go b/integrations/ckprompt/validation_command.go new file mode 100644 index 0000000..df3d14d --- /dev/null +++ b/integrations/ckprompt/validation_command.go @@ -0,0 +1,131 @@ +package ckprompt + +import ( + "errors" + + commandkit "gitea.maximumdirect.net/eric/CommandKit" + promptkit "gitea.maximumdirect.net/eric/promptkit" +) + +type validationCommandInput[I any] struct { + promptID string + request ValidationRequest + format string + captured commandkit.FlagValues[I] + selected I +} + +type requestValidationReport struct { + PromptID string `yaml:"prompt_id"` + Valid bool `yaml:"valid"` +} + +func newValidationCommand[I, S any](suite Suite[I, S]) commandkit.Command { + flags := commandkit.CaptureFlags(suite.Selectors, func(input **validationCommandInput[I]) *commandkit.FlagValues[I] { + if input == nil || *input == nil { + return nil + } + return &(*input).captured + }) + flags = append(flags, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString, + func(input **validationCommandInput[I], value commandkit.Supplied[string]) { + (*input).request.Selection.Config = value + }), + commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString, + func(input **validationCommandInput[I], value commandkit.Supplied[string]) { + (*input).request.Selection.Overlay = value + }), + commandkit.ValueFlag(commandkit.FlagMeta{Name: "version", Usage: "Prompt version to validate."}, stringDefault(""), parseDefinitionString, + func(input **validationCommandInput[I], value commandkit.Supplied[string]) { + (*input).request.Request.PromptVersion = value.Value + }), + commandkit.ValueFlag(commandkit.FlagMeta{Name: "profile", Usage: "Profile identity for validation."}, stringDefault(""), parseDefinitionString, + func(input **validationCommandInput[I], value commandkit.Supplied[string]) { + (*input).request.Request.ProfileID = value.Value + }), + commandkit.ValueFlag(commandkit.FlagMeta{Name: "session-id", Usage: "Session context for validation."}, stringDefault(""), parseDefinitionString, + func(input **validationCommandInput[I], value commandkit.Supplied[string]) { + (*input).request.Request.SessionID = value.Value + }), + commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Confirmation format: yaml."}, stringDefault("yaml"), parseDefinitionString, + func(input **validationCommandInput[I], value commandkit.Supplied[string]) { + (*input).format = value.Value + }), + commandkit.RepeatFlag(commandkit.FlagMeta{Name: "var", Usage: "Caller variable assignment KEY=VALUE."}, []variableAssignment(nil), parseVariableAssignment, + func(input **validationCommandInput[I], value commandkit.Supplied[[]variableAssignment]) { + variables := make(map[string]string, len(value.Value)) + for _, assignment := range value.Value { + variables[assignment.key] = assignment.value + } + (*input).request.Request.Vars = variables + }), + commandkit.RepeatFlag(commandkit.FlagMeta{Name: "input", Usage: "Prompt input file assignment NAME=PATH."}, []inputAssignment(nil), parseInputAssignment, + func(input **validationCommandInput[I], value commandkit.Supplied[[]inputAssignment]) { + inputs := make(map[string]promptkit.ArtifactRef, len(value.Value)) + for _, assignment := range value.Value { + inputs[assignment.name] = promptkit.File(assignment.path) + } + (*input).request.Request.Inputs = inputs + }), + ) + + return commandkit.NewCommand(commandkit.CommandSpec[*validationCommandInput[I]]{ + Name: "validate", + Summary: "Validate a concrete PromptKit request without executing it.", + NewInput: func() *validationCommandInput[I] { + return &validationCommandInput[I]{format: "yaml"} + }, + Flags: flags, + Arguments: []commandkit.Argument[*validationCommandInput[I]]{ + commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "id", Usage: "Prompt ID."}, true, parseDefinitionString, + func(input **validationCommandInput[I], value commandkit.Supplied[string]) { + (*input).promptID = value.Value + (*input).request.Request.PromptID = value.Value + }), + }, + CheckInput: func(input *validationCommandInput[I]) error { + if input == nil { + return errors.New("PromptKit validation input must not be nil") + } + if err := checkRequirementsFormat(input.format); err != nil { + return err + } + selected, err := prepareSuiteInput(suite, input.request.Selection, input.captured) + if err != nil { + return err + } + input.selected = selected + return nil + }, + Run: func(invocation commandkit.Invocation, input *validationCommandInput[I]) commandkit.Outcome { + return runRequestValidation(invocation, suite, input) + }, + }) +} + +func runRequestValidation[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *validationCommandInput[I]) commandkit.Outcome { + if err := promptKitContextError(invocation.Context); err != nil { + return definitionFailure(err, nil) + } + var state S + if suite.NewState != nil { + state = suite.NewState() + } + if err := promptKitContextError(invocation.Context); err != nil { + return definitionFailure(err, nil) + } + + acquired, err := suite.ValidateRequest(invocation.Context, input.request, input.selected, state) + if err != nil { + return definitionFailure(err, acquired.Diagnostics) + } + if err := promptKitContextError(invocation.Context); err != nil { + return definitionFailure(err, acquired.Diagnostics) + } + output, err := encodePromptYAMLBytes(requestValidationReport{PromptID: input.promptID, Valid: true}) + if err != nil { + return definitionFailure(err, acquired.Diagnostics) + } + return writePromptReport(invocation, output, acquired.Diagnostics) +} diff --git a/integrations/ckprompt/validation_command_test.go b/integrations/ckprompt/validation_command_test.go new file mode 100644 index 0000000..9bf4aa9 --- /dev/null +++ b/integrations/ckprompt/validation_command_test.go @@ -0,0 +1,90 @@ +package ckprompt + +import ( + "bytes" + "context" + "io" + "reflect" + "testing" + + commandkit "gitea.maximumdirect.net/eric/CommandKit" + "gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig" + promptkit "gitea.maximumdirect.net/eric/promptkit" + "go.yaml.in/yaml/v4" +) + +func TestValidationCommandComposesWithRequirementsShow(t *testing.T) { + type input struct{ campaign commandkit.Supplied[string] } + type state struct{ sequence int } + campaignFlag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Usage: "Campaign selector."}, (*string)(nil), parseDefinitionString, + func(target *input, value commandkit.Supplied[string]) { target.campaign = value }) + wantSelection := ckconfig.Selection{ + Config: commandkit.Supplied[string]{Value: "config.yml", Set: true}, + Overlay: commandkit.Supplied[string]{Value: "overlay.yml", Set: true}, + } + wantInput := input{campaign: commandkit.Supplied[string]{Value: "spring", Set: true}} + var received ValidationRequest + var receivedInput input + validateCalls := 0 + suite := Suite[input, state]{ + NewInput: func() input { return input{} }, + 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) { + t.Errorf("checked input = (%#v, %#v)", selection, value) + } + return nil + }, + InspectRequirements: func(context.Context, RequirementsRequest, input, state) (commandkit.Acquired[*promptkit.RequirementInspection], error) { + return commandkit.Acquired[*promptkit.RequirementInspection]{Value: &promptkit.RequirementInspection{PromptID: "briefing"}}, nil + }, + ValidateRequest: func(_ context.Context, request ValidationRequest, value input, gotState state) (commandkit.Acquired[struct{}], error) { + validateCalls++ + received = request + receivedInput = value + if gotState.sequence != 1 { + t.Errorf("state = %#v", gotState) + } + return commandkit.Acquired[struct{}]{}, nil + }, + } + app := newPromptTestApp(t, Command(suite)) + + var help bytes.Buffer + outcome := app.Execute(context.Background(), []string{"help", "promptkit", "requirements"}, commandkit.Streams{Out: &help, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || !bytes.Contains(help.Bytes(), []byte("show")) || !bytes.Contains(help.Bytes(), []byte("validate")) { + t.Fatalf("requirements help = (%#v, %q)", outcome, help.String()) + } + + var output bytes.Buffer + outcome = app.Execute(context.Background(), []string{ + "promptkit", "requirements", "validate", "briefing", + "--config", "config.yml", "--overlay", "overlay.yml", "--campaign", "spring", + "--version", "v2", "--profile", "editorial", "--session-id", "session-1", + "--var", "topic=roadmap", "--input", "notes=fixtures/notes=final.txt", "--format", "yaml", + }, commandkit.Streams{Out: &output, Err: io.Discard}) + 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.PromptVersion != "v2" || 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 { + t.Errorf("request vars = %#v", received.Request.Vars) + } + if ref, ok := received.Request.Inputs["notes"]; !ok || ref != promptkit.File("fixtures/notes=final.txt") || len(received.Request.Inputs) != 1 { + t.Errorf("request inputs = %#v", received.Request.Inputs) + } + if !reflect.DeepEqual(receivedInput, wantInput) { + t.Errorf("provider input = %#v, want %#v", receivedInput, wantInput) + } + + var report map[string]any + if err := yaml.Unmarshal(output.Bytes(), &report); err != nil { + t.Fatalf("decode validation report %q: %v", output.String(), err) + } + if len(report) != 2 || report["prompt_id"] != "briefing" || report["valid"] != true { + t.Fatalf("validation confirmation = %#v", report) + } +}