mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
cfda4463e8
* feat(cmdhelp): tests + golden contract + drift validator (TASK-938)
The verification layer that turns cmdhelp v0.1 from "implementation"
into "stable contract." Three categories of tests, all running in
`go test ./...`:
1. Schema validation (cmdhelp.schema.json as CI gate)
- internal/cmdhelp/schema_test.go — synthetic tree's emitted JSON
validates after static walk, after dynamic resolution, and after
a no-workspace fallback.
- cmd/pad/cmdhelp_real_test.go — the REAL pad cobra tree's emitted
JSON validates against the published schema. Future regressions
caught: types outside the closed vocabulary, non-numeric exit_code
keys, flag names violating propertyNames, malformed cmdhelp_version.
2. Drift-prevention contract (spec §6 / §11 Q5)
- internal/cmdhelp/example_validation.go — ValidateExamples walks
every example's `cmd` string, tokenizes with shellSplit, resolves
non-flag tokens against the live cobra tree, and asserts every
--flag exists on the resolved command (or any ancestor for
persistent / inherited flags). Negate-flag form (`--no-cache`)
is recognized via the negation rule from spec §5.3.
- shellSplit handles double/single quotes, backslash escape, and
stops at unquoted pipeline boundaries (|, ;, &, >, <) so the
validator only checks the first command in a pipeline.
- ValidateBoolArity asserts no bool flag appears in valued form
(--flag=value) anywhere in its examples (spec §5.3).
- cmd/pad/cmdhelp_real_test.go runs both validators against the
real pad tree as CI gates.
- Negative tests in internal/cmdhelp/example_validation_test.go
prove the validator catches: typo'd flag (--priorty), unknown
command path, valued-form bool flag.
3. Capabilities form equivalence (spec §8)
- cmd/pad/cmdhelp_real_test.go — both forms (help --capabilities
and --cmdhelp-capabilities fallback) produce byte-identical
output. Side-effect-free guarantee verified by passing garbage
args alongside the fallback flag.
Refactors enabling the tests:
- cmd/pad/main.go: extract newRootCmd() so tests can build the real
cobra tree without running it. main() body shrinks to two lines.
- cmd/pad/main.go: extract handleCmdhelpCapabilitiesFallback() so the
fallback's side-effect-free contract is directly assertable instead
of requiring a subprocess.
Parser improvements driven by real-pad-tree drift findings:
- parseExamplesFromLong: strip same-line `# comment` annotations so
`pad foo --bar # one item's attachments` doesn't pollute Examples.
stripCommentIndex is quote-aware (# inside "..." or '...' is literal).
- main.go (github cmd): the Long had annotations on example lines
separated only by spaces (no `#`), which was malformed input. Fixed
to use `#` separators — caught by the drift validator on first run.
New deps:
- github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 — Go JSON Schema
validator supporting draft 2020-12 (matches the cmdhelp schema's $schema).
New helpers in internal/cmdhelp/:
- FindAndCompileSchema(startDir) walks up to locate
schema/cmdhelp.schema.json and returns a compiled schema. Reusable
by any consumer that wants to validate cmdhelp documents.
End-to-end on real binary:
- pad help --format json → 100 commands, schema-valid.
- All examples in pad's emitted output resolve against the live tree
(zero drift findings).
- pad help --capabilities byte-identical to pad --cmdhelp-capabilities.
- Adding a typo'd flag in any cobra Long block in cmd/pad MUST break
TestRealPadTree_ExampleDriftValidator. Verified by the negative
TestValidateExamples_DetectsTypoFlag.
make check clean. All 53 cmdhelp + cmd/pad tests pass.
Parent: PLAN-930.
* fix(cmdhelp): pass full token stream to cobra.Find per Codex review (round 1)
Codex round 1 caught: ValidateExamples stopped collecting the command
path at the first flag, so an example like
pad --workspace foo item create task --priority high
resolved to root, not `item create`. That meant `--priority` was
checked against root's flag set (where it doesn't exist) — false
positive — AND the validator silently missed any command-path drift
after a leading root flag.
Cobra's own Find walks the full token stream and uses each command's
flag definitions to skip flag/value pairs while matching subcommand
names. Pass tokens[1:] directly to root.Find — let cobra handle the
interleaving correctly.
New test:
- TestValidateExamples_FlagBeforeSubcommandResolvesToCorrectTarget —
flag-before-subcommand resolves to the leaf and accepts leaf flags.
276 lines
9.0 KiB
Go
276 lines
9.0 KiB
Go
package cmdhelp
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func TestShellSplit_BasicCases(t *testing.T) {
|
|
cases := map[string][]string{
|
|
`pad item create task "Fix OAuth"`: {"pad", "item", "create", "task", "Fix OAuth"},
|
|
`pad item create idea 'one two'`: {"pad", "item", "create", "idea", "one two"},
|
|
`pad foo --bar=baz qux`: {"pad", "foo", "--bar=baz", "qux"},
|
|
`pad x \"escaped\"`: {"pad", "x", `"escaped"`},
|
|
`pad item create`: {"pad", "item", "create"},
|
|
}
|
|
for in, want := range cases {
|
|
got, err := shellSplit(in)
|
|
if err != nil {
|
|
t.Errorf("shellSplit(%q) error: %v", in, err)
|
|
continue
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Errorf("shellSplit(%q) = %v (len %d), want %v (len %d)", in, got, len(got), want, len(want))
|
|
continue
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Errorf("shellSplit(%q)[%d] = %q, want %q", in, i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShellSplit_UnterminatedQuotesError(t *testing.T) {
|
|
if _, err := shellSplit(`pad "unterminated`); err == nil {
|
|
t.Errorf("expected error for unterminated double quote")
|
|
}
|
|
if _, err := shellSplit(`pad 'unterminated`); err == nil {
|
|
t.Errorf("expected error for unterminated single quote")
|
|
}
|
|
}
|
|
|
|
func TestValidateExamples_Clean(t *testing.T) {
|
|
// Build a tree where the example references real commands and flags.
|
|
root := &cobra.Command{Use: "padtest"}
|
|
root.PersistentFlags().String("workspace", "", "workspace override")
|
|
|
|
create := &cobra.Command{
|
|
Use: "create <coll>",
|
|
Short: "create item",
|
|
Example: ` padtest item create task --priority high`,
|
|
}
|
|
create.Flags().String("priority", "", "priority")
|
|
|
|
item := &cobra.Command{Use: "item", Short: "item group"}
|
|
item.AddCommand(create)
|
|
root.AddCommand(item)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
findings := ValidateExamples(doc, root)
|
|
if len(findings) != 0 {
|
|
t.Errorf("expected no findings, got: %v", findings)
|
|
}
|
|
}
|
|
|
|
func TestValidateExamples_DetectsTypoFlag(t *testing.T) {
|
|
// THIS IS THE DRIFT-PREVENTION CONTRACT TEST (spec §6 / §11 Q5).
|
|
// A typo in an example flag — `--priorty` instead of `--priority`
|
|
// — MUST be detected by ValidateExamples and reported with the
|
|
// offending command + example index.
|
|
root := &cobra.Command{Use: "padtest"}
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` padtest leaf --priorty high`, // typo
|
|
}
|
|
leaf.Flags().String("priority", "", "priority")
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
findings := ValidateExamples(doc, root)
|
|
|
|
if len(findings) == 0 {
|
|
t.Fatalf("expected at least one finding for --priorty typo, got none")
|
|
}
|
|
want := []string{"leaf", "example[0]", "priorty"}
|
|
for _, w := range want {
|
|
if !strings.Contains(findings[0], w) {
|
|
t.Errorf("finding should mention %q for triage; got: %q", w, findings[0])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateExamples_DetectsUnknownCommand(t *testing.T) {
|
|
// Example references a command path that doesn't exist on the tree.
|
|
// Common when a command gets renamed/deleted but the example block
|
|
// in Long stays stale.
|
|
root := &cobra.Command{Use: "padtest"}
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` padtest does-not-exist --foo bar`,
|
|
}
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
findings := ValidateExamples(doc, root)
|
|
if len(findings) == 0 {
|
|
t.Errorf("expected finding for unknown command 'does-not-exist'")
|
|
}
|
|
}
|
|
|
|
func TestValidateExamples_AcceptsNegateFlag(t *testing.T) {
|
|
// `--no-cache` form must be accepted when the underlying flag is
|
|
// `cache` (negation per spec §5.3).
|
|
root := &cobra.Command{Use: "padtest"}
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` padtest leaf --no-cache`,
|
|
}
|
|
leaf.Flags().Bool("cache", true, "use cache")
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
findings := ValidateExamples(doc, root)
|
|
if len(findings) != 0 {
|
|
t.Errorf("--no-cache should resolve via negate-flag rule, got findings: %v", findings)
|
|
}
|
|
}
|
|
|
|
func TestValidateExamples_FlagBeforeSubcommandResolvesToCorrectTarget(t *testing.T) {
|
|
// Cobra accepts flags interleaved with the command path — e.g.
|
|
// `pad --workspace foo item create task --priority high`.
|
|
// The validator MUST resolve to `item create` (not root) so leaf
|
|
// flags like --priority are checked against the leaf, not just
|
|
// against root's flag set. (Caught by Codex round 1.)
|
|
root := &cobra.Command{Use: "padtest"}
|
|
root.PersistentFlags().String("workspace", "", "workspace override")
|
|
|
|
create := &cobra.Command{
|
|
Use: "create <coll>",
|
|
Short: "create",
|
|
Example: ` padtest --workspace foo item create task --priority high`,
|
|
}
|
|
create.Flags().String("priority", "", "priority")
|
|
|
|
item := &cobra.Command{Use: "item", Short: "item group"}
|
|
item.AddCommand(create)
|
|
root.AddCommand(item)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
if findings := ValidateExamples(doc, root); len(findings) != 0 {
|
|
t.Errorf("flag-before-subcommand example should resolve cleanly; got: %v", findings)
|
|
}
|
|
}
|
|
|
|
func TestValidateExamples_AcceptsPersistentFlagFromAncestor(t *testing.T) {
|
|
// `--workspace` is a persistent root flag; it must be accepted on
|
|
// any subcommand example.
|
|
root := &cobra.Command{Use: "padtest"}
|
|
root.PersistentFlags().String("workspace", "", "workspace override")
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` padtest leaf --workspace foo`,
|
|
}
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
if findings := ValidateExamples(doc, root); len(findings) != 0 {
|
|
t.Errorf("persistent root flag should resolve on subcommand, got: %v", findings)
|
|
}
|
|
}
|
|
|
|
func TestValidateExamples_SkipsNonBinaryExamples(t *testing.T) {
|
|
// Documentation snippets that aren't pad invocations (e.g.
|
|
// `cat ~/foo.json | jq` to show output) are skipped, not flagged.
|
|
// The drift contract is for pad-invocation drift specifically;
|
|
// non-pad lines are docs prose.
|
|
root := &cobra.Command{Use: "padtest"}
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` cat ~/.padtest/foo.json | jq
|
|
padtest leaf --real-flag`,
|
|
}
|
|
leaf.Flags().Bool("real-flag", false, "real flag")
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
findings := ValidateExamples(doc, root)
|
|
if len(findings) != 0 {
|
|
t.Errorf("non-pad example should be skipped silently; got: %v", findings)
|
|
}
|
|
}
|
|
|
|
func TestValidateExamples_PipelineStopsAtFirstCommand(t *testing.T) {
|
|
// `padtest leaf --foo | jq -r .x` — the validator only checks the
|
|
// first command in the pipeline. The `-r .x` after the pipe
|
|
// belongs to jq, not padtest, and would erroneously flag if we
|
|
// kept tokenizing past `|`.
|
|
root := &cobra.Command{Use: "padtest"}
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` padtest leaf --foo | jq -r .x`,
|
|
}
|
|
leaf.Flags().Bool("foo", false, "foo flag")
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
if findings := ValidateExamples(doc, root); len(findings) != 0 {
|
|
t.Errorf("pipeline should be honored; got: %v", findings)
|
|
}
|
|
}
|
|
|
|
func TestParseExamplesFromLong_StripsSameLineComments(t *testing.T) {
|
|
// Pad's existing convention: trailing `# annotation` on an example
|
|
// line. Should be stripped before recording the example.
|
|
long := `Examples:
|
|
pad attachment list --item TASK-5 # one item's attachments
|
|
pad attachment list --category image # filter by category`
|
|
|
|
got := parseExamplesFromLong(long)
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 examples, got %d: %+v", len(got), got)
|
|
}
|
|
if strings.Contains(got[0].Cmd, "#") {
|
|
t.Errorf("trailing comment leaked into example[0]: %q", got[0].Cmd)
|
|
}
|
|
if got[0].Cmd != "pad attachment list --item TASK-5" {
|
|
t.Errorf("example[0] = %q, want stripped form", got[0].Cmd)
|
|
}
|
|
}
|
|
|
|
func TestValidateBoolArity_DetectsValuedBool(t *testing.T) {
|
|
// Spec §5.3: bool flags MUST be presence-only. An example using
|
|
// `--cache=true` violates the contract.
|
|
root := &cobra.Command{Use: "padtest"}
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` padtest leaf --cache=true`,
|
|
}
|
|
leaf.Flags().Bool("cache", false, "cache")
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
findings := ValidateBoolArity(doc)
|
|
if len(findings) == 0 {
|
|
t.Errorf("expected violation for --cache=true on bool flag")
|
|
}
|
|
if len(findings) > 0 && !strings.Contains(findings[0], "cache") {
|
|
t.Errorf("finding should mention the offending flag: %q", findings[0])
|
|
}
|
|
}
|
|
|
|
func TestValidateBoolArity_AcceptsPresenceForm(t *testing.T) {
|
|
root := &cobra.Command{Use: "padtest"}
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` padtest leaf --cache`,
|
|
}
|
|
leaf.Flags().Bool("cache", false, "cache")
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
if findings := ValidateBoolArity(doc); len(findings) != 0 {
|
|
t.Errorf("--cache (presence form) should be accepted, got: %v", findings)
|
|
}
|
|
}
|