Add PromptKit requirements validation command
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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 }
|
||||
|
||||
131
integrations/ckprompt/validation_command.go
Normal file
131
integrations/ckprompt/validation_command.go
Normal file
@@ -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)
|
||||
}
|
||||
90
integrations/ckprompt/validation_command_test.go
Normal file
90
integrations/ckprompt/validation_command_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user