Cover validation command lifecycle and grammar
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# PromptKit request validation implementation plan
|
# PromptKit request validation implementation plan
|
||||||
|
|
||||||
Status: Stages 1–4 complete; implementation is ongoing.
|
Status: Stages 1–5 complete; implementation is ongoing.
|
||||||
|
|
||||||
## Authority, scope, and baseline
|
## Authority, scope, and baseline
|
||||||
|
|
||||||
@@ -311,6 +311,19 @@ YAML output into a fresh value; avoid output-prefix or key-order assertions.
|
|||||||
**Complete when:** the new capability obeys the selected standard grammar,
|
**Complete when:** the new capability obeys the selected standard grammar,
|
||||||
availability, capture, and invocation-lifetime contracts.
|
availability, capture, and invocation-lifetime contracts.
|
||||||
|
|
||||||
|
**Completed:** 2026-10-12. Added validation-only availability, namespace
|
||||||
|
collision and root-only placement coverage; construction/install/help/version
|
||||||
|
callback timing; eager callback and selector capture; exact standard request,
|
||||||
|
selector, empty-presence, repeated-assignment, first-`=` file, and duplicate
|
||||||
|
last-wins behavior; repeated-invocation isolation; usage preflight; input-check
|
||||||
|
failure ordering; and nil-pointer zero-value factories. A missing required
|
||||||
|
captured selector is a usage outcome before `Suite.CheckInput`, state creation,
|
||||||
|
or provider work. Under the existing `CaptureFlags`/`prepareSuiteInput` flow,
|
||||||
|
`Suite.NewInput` runs before `FlagValues.ApplyTo` reports that missing selector.
|
||||||
|
Focused validation command tests and
|
||||||
|
`GOTOOLCHAIN=local go test -count=1 ./...` passed from
|
||||||
|
`integrations/ckprompt`.
|
||||||
|
|
||||||
## Stage 6: Cover native command outcomes and report delivery
|
## Stage 6: Cover native command outcomes and report delivery
|
||||||
|
|
||||||
**Primary files:** validation command tests and small real-engine fixtures.
|
**Primary files:** validation command tests and small real-engine fixtures.
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package ckprompt
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||||
@@ -88,3 +90,227 @@ func TestValidationCommandComposesWithRequirementsShow(t *testing.T) {
|
|||||||
t.Fatalf("validation confirmation = %#v", report)
|
t.Fatalf("validation confirmation = %#v", report)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidationCommandIsAvailableIndependentlyAndOwnsItsFamily(t *testing.T) {
|
||||||
|
validationOnly := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ValidateRequest: func(context.Context, ValidationRequest, struct{}, struct{}) (commandkit.Acquired[struct{}], error) {
|
||||||
|
return commandkit.Acquired[struct{}]{}, nil
|
||||||
|
}}))
|
||||||
|
var help bytes.Buffer
|
||||||
|
outcome := validationOnly.Execute(context.Background(), []string{"help", "promptkit", "requirements"}, commandkit.Streams{Out: &help, Err: io.Discard})
|
||||||
|
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(help.String(), "validate") || strings.Contains(help.String(), "show") {
|
||||||
|
t.Fatalf("validation-only availability = (%#v, %q)", outcome, help.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
declaration := Command(Suite[struct{}, struct{}]{ValidateRequest: func(context.Context, ValidationRequest, struct{}, struct{}) (commandkit.Acquired[struct{}], error) {
|
||||||
|
return commandkit.Acquired[struct{}]{}, nil
|
||||||
|
}})
|
||||||
|
other := commandkit.Group("promptkit", "An application command tree.", commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "child", Summary: "A child."}))
|
||||||
|
app := newPromptTestApp(t, other)
|
||||||
|
if err := app.Register(declaration); err == nil {
|
||||||
|
t.Fatal("validation-only command installed over an existing PromptKit namespace")
|
||||||
|
}
|
||||||
|
if err := newPromptTestApp(t, declaration).Register(other); err == nil {
|
||||||
|
t.Fatal("later application command installed over validation-only PromptKit namespace")
|
||||||
|
}
|
||||||
|
if _, err := commandkit.New(commandkit.Identity{Name: "nested"}, commandkit.Group("outer", "Outer group.", declaration)); err == nil {
|
||||||
|
t.Fatal("validation-only root family was accepted beneath another group")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidationCommandConstructionAndCaptureLifecycle(t *testing.T) {
|
||||||
|
type input struct{ campaign commandkit.Supplied[string] }
|
||||||
|
type state struct{}
|
||||||
|
newInputCalls, newStateCalls, checkCalls := 0, 0, 0
|
||||||
|
buildCalls, loadCalls := 0, 0
|
||||||
|
engine := adapterEngine(t)
|
||||||
|
oldSelector := commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Required: true}, (*string)(nil), parseDefinitionString,
|
||||||
|
func(target *input, value commandkit.Supplied[string]) { target.campaign = value })
|
||||||
|
newSelector := commandkit.ValueFlag(commandkit.FlagMeta{Name: "replacement"}, (*string)(nil), parseDefinitionString,
|
||||||
|
func(*input, commandkit.Supplied[string]) {})
|
||||||
|
suite := WithRequestValidation(Suite[input, state]{
|
||||||
|
NewInput: func() input { newInputCalls++; return input{} },
|
||||||
|
NewState: func() state { newStateCalls++; return state{} },
|
||||||
|
Selectors: []commandkit.Flag[input]{oldSelector},
|
||||||
|
CheckInput: func(_ ckconfig.Selection, value input) error {
|
||||||
|
checkCalls++
|
||||||
|
if !value.campaign.Set || value.campaign.Value != "spring" {
|
||||||
|
return errors.New("captured campaign selector was not applied")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, func(context.Context, ckconfig.Selection, input, state) (*promptkit.Engine, error) {
|
||||||
|
loadCalls++
|
||||||
|
return engine, nil
|
||||||
|
}, func(_ context.Context, request ValidationRequest, _ input, _ state) (promptkit.RunRequest, error) {
|
||||||
|
buildCalls++
|
||||||
|
return request.Request, nil
|
||||||
|
})
|
||||||
|
declaration := Command(suite)
|
||||||
|
if newInputCalls != 0 || newStateCalls != 0 || checkCalls != 0 || buildCalls != 0 || loadCalls != 0 {
|
||||||
|
t.Fatal("command construction invoked application or adapter callbacks")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command captured the original callback and binding metadata.
|
||||||
|
suite.ValidateRequest = func(context.Context, ValidationRequest, input, state) (commandkit.Acquired[struct{}], error) {
|
||||||
|
return commandkit.Acquired[struct{}]{}, errors.New("replacement callback was captured")
|
||||||
|
}
|
||||||
|
suite.Selectors[0] = newSelector
|
||||||
|
app := newPromptTestApp(t, declaration)
|
||||||
|
for _, args := range [][]string{{"help", "promptkit", "requirements", "validate"}, {"version"}} {
|
||||||
|
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||||
|
t.Fatalf("construction lifecycle command %v = %#v", args, outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if newInputCalls != 0 || newStateCalls != 0 || checkCalls != 0 || buildCalls != 0 || loadCalls != 0 {
|
||||||
|
t.Fatal("installation, help, or version invoked application or adapter callbacks")
|
||||||
|
}
|
||||||
|
|
||||||
|
var output bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "empty", "--profile", "default", "--campaign", "spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||||
|
if outcome.Kind != commandkit.OutcomeSuccess || newInputCalls != 1 || newStateCalls != 1 || checkCalls != 1 || buildCalls != 1 || loadCalls != 1 {
|
||||||
|
t.Fatalf("captured command = %#v, callback counts input/state/check/build/load=%d/%d/%d/%d/%d", outcome, newInputCalls, newStateCalls, checkCalls, buildCalls, loadCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidationCommandPreservesPresenceAndRepeatedAssignmentsPerInvocation(t *testing.T) {
|
||||||
|
type input struct{ campaign commandkit.Supplied[string] }
|
||||||
|
type state struct{ sequence int }
|
||||||
|
campaignFlag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign"}, (*string)(nil), parseDefinitionString,
|
||||||
|
func(target *input, value commandkit.Supplied[string]) { target.campaign = value })
|
||||||
|
var requests []ValidationRequest
|
||||||
|
var selectedInputs []input
|
||||||
|
newInputCalls, checkCalls, newStateCalls := 0, 0, 0
|
||||||
|
suite := Suite[input, state]{
|
||||||
|
NewInput: func() input { newInputCalls++; return input{} },
|
||||||
|
NewState: func() state { newStateCalls++; return state{sequence: newStateCalls} },
|
||||||
|
Selectors: []commandkit.Flag[input]{campaignFlag},
|
||||||
|
CheckInput: func(ckconfig.Selection, input) error { checkCalls++; return nil },
|
||||||
|
ValidateRequest: func(_ context.Context, request ValidationRequest, value input, _ state) (commandkit.Acquired[struct{}], error) {
|
||||||
|
requests = append(requests, request)
|
||||||
|
selectedInputs = append(selectedInputs, value)
|
||||||
|
return commandkit.Acquired[struct{}]{}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
app := newPromptTestApp(t, Command(suite))
|
||||||
|
|
||||||
|
firstArgs := []string{
|
||||||
|
"promptkit", "requirements", "validate", "briefing", "--config", "", "--overlay", "",
|
||||||
|
"--version", "", "--profile", "", "--session-id", "", "--campaign", "spring",
|
||||||
|
"--var", "=", "--var", "phrase= two words, three ", "--var", "duplicate=first", "--var", "duplicate=last=value",
|
||||||
|
"--input", "=", "--input", "file= path, with commas=and=equals ", "--input", "duplicate=first.txt", "--input", "duplicate=last=path.txt",
|
||||||
|
}
|
||||||
|
outcome := app.Execute(context.Background(), firstArgs, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if outcome.Kind != commandkit.OutcomeSuccess || len(requests) != 1 {
|
||||||
|
t.Fatalf("first repeated assignment invocation = %#v, requests=%d", outcome, len(requests))
|
||||||
|
}
|
||||||
|
first := requests[0]
|
||||||
|
if first.Selection.Config.Value != "" || !first.Selection.Config.Set || first.Selection.Overlay.Value != "" || !first.Selection.Overlay.Set {
|
||||||
|
t.Errorf("explicit empty selection = %#v", first.Selection)
|
||||||
|
}
|
||||||
|
if first.Request.PromptVersion != "" || first.Request.ProfileID != "" || first.Request.SessionID != "" {
|
||||||
|
t.Errorf("empty scalar values = %#v", first.Request)
|
||||||
|
}
|
||||||
|
wantVars := map[string]string{"": "", "phrase": " two words, three ", "duplicate": "last=value"}
|
||||||
|
if !reflect.DeepEqual(first.Request.Vars, wantVars) {
|
||||||
|
t.Errorf("request vars = %#v, want %#v", first.Request.Vars, wantVars)
|
||||||
|
}
|
||||||
|
wantInputs := map[string]promptkit.ArtifactRef{
|
||||||
|
"": promptkit.File(""),
|
||||||
|
"file": promptkit.File(" path, with commas=and=equals "),
|
||||||
|
"duplicate": promptkit.File("last=path.txt"),
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(first.Request.Inputs, wantInputs) {
|
||||||
|
t.Errorf("request inputs = %#v, want %#v", first.Request.Inputs, wantInputs)
|
||||||
|
}
|
||||||
|
if !selectedInputs[0].campaign.Set || selectedInputs[0].campaign.Value != "spring" {
|
||||||
|
t.Errorf("first selector input = %#v", selectedInputs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome = app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if outcome.Kind != commandkit.OutcomeSuccess || len(requests) != 2 {
|
||||||
|
t.Fatalf("second invocation = %#v, requests=%d", outcome, len(requests))
|
||||||
|
}
|
||||||
|
second := requests[1]
|
||||||
|
if second.Selection.Config.Set || second.Selection.Overlay.Set || second.Request.PromptVersion != "" || second.Request.ProfileID != "" || second.Request.SessionID != "" || len(second.Request.Vars) != 0 || len(second.Request.Inputs) != 0 {
|
||||||
|
t.Errorf("first invocation values leaked into second request: %#v", second)
|
||||||
|
}
|
||||||
|
if selectedInputs[1].campaign.Set || selectedInputs[1].campaign.Value != "" {
|
||||||
|
t.Errorf("first selector leaked into second input: %#v", selectedInputs[1])
|
||||||
|
}
|
||||||
|
if newInputCalls != 2 || checkCalls != 2 || newStateCalls != 2 {
|
||||||
|
t.Errorf("per-invocation counts input/check/state = %d/%d/%d", newInputCalls, checkCalls, newStateCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidationCommandUsageAndInputFailuresStopProviderWork(t *testing.T) {
|
||||||
|
type input struct{ campaign commandkit.Supplied[string] }
|
||||||
|
selector := commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Required: true}, (*string)(nil), parseDefinitionString,
|
||||||
|
func(target *input, value commandkit.Supplied[string]) { target.campaign = value })
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{name: "missing operand", args: []string{"promptkit", "requirements", "validate"}},
|
||||||
|
{name: "extra operand", args: []string{"promptkit", "requirements", "validate", "briefing", "extra", "--campaign", "spring"}},
|
||||||
|
{name: "unsupported format", args: []string{"promptkit", "requirements", "validate", "briefing", "--campaign", "spring", "--format", "text"}},
|
||||||
|
{name: "unknown flag", args: []string{"promptkit", "requirements", "validate", "briefing", "--campaign", "spring", "--unknown", "value"}},
|
||||||
|
{name: "malformed variable before valid assignment", args: []string{"promptkit", "requirements", "validate", "briefing", "--campaign", "spring", "--var", "broken", "--var", "mode=full"}},
|
||||||
|
{name: "malformed input before valid assignment", args: []string{"promptkit", "requirements", "validate", "briefing", "--campaign", "spring", "--input", "broken", "--input", "notes=file.txt"}},
|
||||||
|
{name: "missing required selector", args: []string{"promptkit", "requirements", "validate", "briefing"}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
newInputCalls, checkCalls, newStateCalls, providerCalls := 0, 0, 0, 0
|
||||||
|
suite := Suite[input, struct{}]{
|
||||||
|
NewInput: func() input { newInputCalls++; return input{} },
|
||||||
|
NewState: func() struct{} { newStateCalls++; return struct{}{} },
|
||||||
|
Selectors: []commandkit.Flag[input]{selector},
|
||||||
|
CheckInput: func(ckconfig.Selection, input) error { checkCalls++; return nil },
|
||||||
|
ValidateRequest: func(context.Context, ValidationRequest, input, struct{}) (commandkit.Acquired[struct{}], error) {
|
||||||
|
providerCalls++
|
||||||
|
return commandkit.Acquired[struct{}]{}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
outcome := newPromptTestApp(t, Command(suite)).Execute(context.Background(), test.args, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
wantInputCalls := 0
|
||||||
|
if test.name == "missing required selector" {
|
||||||
|
// prepareSuiteInput initializes the caller input before core validates
|
||||||
|
// captured required bindings; CheckInput and provider work still stop.
|
||||||
|
wantInputCalls = 1
|
||||||
|
}
|
||||||
|
if outcome.Kind != commandkit.OutcomeUsage || outcome.Status != 2 || newInputCalls != wantInputCalls || checkCalls != 0 || newStateCalls != 0 || providerCalls != 0 {
|
||||||
|
t.Fatalf("preflight failure = %#v, input/check/state/provider=%d/%d/%d/%d", outcome, newInputCalls, checkCalls, newStateCalls, providerCalls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
newStateCalls, providerCalls := 0, 0
|
||||||
|
suite := Suite[input, struct{}]{
|
||||||
|
NewInput: func() input { return input{} },
|
||||||
|
NewState: func() struct{} { newStateCalls++; return struct{}{} },
|
||||||
|
Selectors: []commandkit.Flag[input]{selector},
|
||||||
|
CheckInput: func(ckconfig.Selection, input) error { return errors.New("input rejected") },
|
||||||
|
ValidateRequest: func(context.Context, ValidationRequest, input, struct{}) (commandkit.Acquired[struct{}], error) {
|
||||||
|
providerCalls++
|
||||||
|
return commandkit.Acquired[struct{}]{}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
outcome := newPromptTestApp(t, Command(suite)).Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing", "--campaign", "spring"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if outcome.Kind != commandkit.OutcomeUsage || newStateCalls != 0 || providerCalls != 0 {
|
||||||
|
t.Fatalf("input check failure reached state/provider: %#v state=%d provider=%d", outcome, newStateCalls, providerCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidationCommandSuppliesZeroValueFactoriesIncludingNilPointers(t *testing.T) {
|
||||||
|
var gotInput *int
|
||||||
|
var gotState *int
|
||||||
|
suite := Suite[*int, *int]{ValidateRequest: func(_ context.Context, _ ValidationRequest, input, state *int) (commandkit.Acquired[struct{}], error) {
|
||||||
|
gotInput, gotState = input, state
|
||||||
|
return commandkit.Acquired[struct{}]{}, nil
|
||||||
|
}}
|
||||||
|
app := newPromptTestApp(t, Command(suite))
|
||||||
|
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if outcome.Kind != commandkit.OutcomeSuccess || gotInput != nil || gotState != nil {
|
||||||
|
t.Fatalf("omitted factories = outcome %#v, input=%v state=%v", outcome, gotInput, gotState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user