From 2f16228a8db15028e8b3457146e1cb6a883b5352 Mon Sep 17 00:00:00 2001 From: tytv2 Date: Mon, 6 Jul 2026 22:33:47 +0700 Subject: [PATCH] test(cmd): enforce cross-product CLI conventions Add a repo-wide conventions test that walks the assembled command tree and gates new commands so product teams stay consistent without reviewer memory: - destructive commands (delete/stop/reboot) must define --dry-run and --force; - command verbs must be canonical (denylist rejects remove/fetch/ls/add/etc.); - every command has a Short description. All current commands already conform; the test is a gate for future ones. --- go/cmd/conventions_test.go | 115 +++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 go/cmd/conventions_test.go diff --git a/go/cmd/conventions_test.go b/go/cmd/conventions_test.go new file mode 100644 index 0000000..e0f3506 --- /dev/null +++ b/go/cmd/conventions_test.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// These tests enforce cross-product CLI conventions at build time so new product +// commands stay consistent without relying on reviewers to remember the rules. +// They walk the assembled command tree from rootCmd. + +// verbToken returns the action word of a command name: the segment before the +// first hyphen. For "create-nodegroup" -> "create", for "create" -> "create", +// for "cluster-active" (a wait condition) -> "cluster". +func verbToken(use string) string { + name := strings.Fields(use)[0] // Use may be "name "; take the name + if i := strings.Index(name, "-"); i >= 0 { + return name[:i] + } + return name +} + +// leafCommands returns every runnable (non-group) command under root, excluding +// cobra's built-ins (help, completion). +func leafCommands() []*cobra.Command { + var out []*cobra.Command + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + for _, sub := range c.Commands() { + if sub.Name() == "help" || sub.Name() == "completion" { + continue + } + if sub.Runnable() && len(sub.Commands()) == 0 { + out = append(out, sub) + } + walk(sub) + } + } + walk(rootCmd) + return out +} + +// deniedVerbs are synonyms that must not creep in — keep the vocabulary uniform +// (use the canonical verb on the right instead). +var deniedVerbs = map[string]string{ + "remove": "delete", + "rm": "delete", + "del": "delete", + "destroy": "delete", + "ls": "list", + "fetch": "get", + "describe": "get", + "show": "get", + "add": "create", + "new": "create", + "modify": "update", + "edit": "update", + "set": "update", // note: `configure set` is exempt below (config file, not an API resource) +} + +// destructiveVerbs identify commands that mutate/lose state and therefore must +// offer --dry-run and --force. +var destructiveVerbs = map[string]bool{ + "delete": true, + "stop": true, + "reboot": true, +} + +// configureExempt: the `grn configure` subcommands manage the local credential +// file (not API resources), so they use their own verbs (set/get/list). +func isConfigureSub(c *cobra.Command) bool { + for p := c.Parent(); p != nil; p = p.Parent() { + if p.Name() == "configure" { + return true + } + } + return false +} + +func TestNoDeniedVerbs(t *testing.T) { + for _, c := range leafCommands() { + if isConfigureSub(c) { + continue + } + v := verbToken(c.Use) + if canonical, denied := deniedVerbs[v]; denied { + t.Errorf("command %q uses non-canonical verb %q; use %q instead", + c.CommandPath(), v, canonical) + } + } +} + +func TestDestructiveCommandsHaveDryRunAndForce(t *testing.T) { + for _, c := range leafCommands() { + if !destructiveVerbs[verbToken(c.Use)] { + continue + } + if c.Flags().Lookup("dry-run") == nil { + t.Errorf("destructive command %q must define a --dry-run flag", c.CommandPath()) + } + if c.Flags().Lookup("force") == nil { + t.Errorf("destructive command %q must define a --force flag", c.CommandPath()) + } + } +} + +func TestEveryCommandHasShortHelp(t *testing.T) { + for _, c := range leafCommands() { + if strings.TrimSpace(c.Short) == "" { + t.Errorf("command %q has no Short description", c.CommandPath()) + } + } +}