Files
pad/internal/cmdhelp/example_validation_test.go
T
xarmian cfda4463e8 feat(cmdhelp): tests + golden contract + drift validator (TASK-938) (#332)
* 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.
2026-05-01 07:17:58 -04:00

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)
}
}