From 346bcaeddf9634c3d63e96164d6e96a6ca3b7e54 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 12 Oct 2026 22:18:50 +0000 Subject: [PATCH] Cover validation command lifecycle and grammar --- docs/roadmap/implementation.md | 15 +- .../ckprompt/validation_command_test.go | 226 ++++++++++++++++++ 2 files changed, 240 insertions(+), 1 deletion(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index cbc3f6e..ba69fdc 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # 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 @@ -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, 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 **Primary files:** validation command tests and small real-engine fixtures. diff --git a/integrations/ckprompt/validation_command_test.go b/integrations/ckprompt/validation_command_test.go index 9bf4aa9..ec94ce0 100644 --- a/integrations/ckprompt/validation_command_test.go +++ b/integrations/ckprompt/validation_command_test.go @@ -3,8 +3,10 @@ package ckprompt import ( "bytes" "context" + "errors" "io" "reflect" + "strings" "testing" commandkit "gitea.maximumdirect.net/eric/CommandKit" @@ -88,3 +90,227 @@ func TestValidationCommandComposesWithRequirementsShow(t *testing.T) { 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) + } +}