docsite: fail the build when a command has no docs
Reads main.go's dispatch switch as an AST, not its usage text — gating against the prose would only prove the docs match a second copy of the docs. Flags stay out of scope: rastrillo can scan them because its CLI is one small package, and CARLOS's root package would produce hundreds. The scanner has its own test, because a scanner that silently finds nothing turns the gate into one that always passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 files changed,
+155
−0
internal/docsite/cli_test.go+72 −0internal/docsite/cliscan.go+83 −0
diff --git a/internal/docsite/cli_test.go b/internal/docsite/cli_test.go| new file mode 100644 |
| index 000000000..758682c7b |
| --- /dev/null |
| +++ b/internal/docsite/cli_test.go |
| @@ -0,0 +1,72 @@ |
| +package docsite |
| + |
| +import ( |
| + "os" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// mainFile is the CLI's dispatch, relative to this package. |
| +const mainFile = "../../main.go" |
| + |
| +// cliPage is the page that must document all of it. |
| +const cliPage = "cli" |
| + |
| +// TestTopLevelCommandsReadsTheDispatch checks the scanner before the |
| +// gate that depends on it. A scanner that silently returns nothing |
| +// turns the gate below into a test that always passes. |
| +func TestTopLevelCommandsReadsTheDispatch(t *testing.T) { |
| + cmds, err := TopLevelCommands(mainFile) |
| + if err != nil { |
| + t.Fatalf("scan: %v", err) |
| + } |
| + if len(cmds) < 20 { |
| + t.Fatalf("found %d commands (%v) — the scanner is broken, not the docs", len(cmds), cmds) |
| + } |
| + for _, want := range []string{"ship", "promote", "deploy", "domains", "logs", "fleets"} { |
| + if !contains(cmds, want) { |
| + t.Errorf("scanner missed the %q command", want) |
| + } |
| + } |
| + for _, unwanted := range []string{"-h", "--help", ""} { |
| + if contains(cmds, unwanted) { |
| + t.Errorf("scanner kept %q, which is not a command", unwanted) |
| + } |
| + } |
| +} |
| + |
| +func contains(xs []string, s string) bool { |
| + for _, x := range xs { |
| + if x == s { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// TestCLICommandsAreDocumented is the gate. It reads the dispatch |
| +// switch rather than the usage text on purpose: usage text is prose |
| +// someone can forget to update, so gating against it would only prove |
| +// the docs match a second copy of the docs. The switch is the CLI. |
| +func TestCLICommandsAreDocumented(t *testing.T) { |
| + if _, err := os.Stat(root + "/nav.json"); os.IsNotExist(err) { |
| + t.Skip("no corpus yet — Task 8 creates docs/site/nav.json") |
| + } |
| + site, err := Load(root) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + page, ok := site.BySlug[cliPage] |
| + if !ok { |
| + t.Fatalf("no %s page — every command has to be documented somewhere", cliPage) |
| + } |
| + cmds, err := TopLevelCommands(mainFile) |
| + if err != nil { |
| + t.Fatalf("scan: %v", err) |
| + } |
| + for _, cmd := range cmds { |
| + if !strings.Contains(page.Body, "carlos "+cmd) { |
| + t.Errorf("%s.md does not document %q (looked for %q)", cliPage, cmd, "carlos "+cmd) |
| + } |
| + } |
| +} |
diff --git a/internal/docsite/cliscan.go b/internal/docsite/cliscan.go| new file mode 100644 |
| index 000000000..9d6130a86 |
| --- /dev/null |
| +++ b/internal/docsite/cliscan.go |
| @@ -0,0 +1,83 @@ |
| +package docsite |
| + |
| +import ( |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "sort" |
| + "strconv" |
| +) |
| + |
| +// TopLevelCommands returns every subcommand main.go dispatches on. |
| +// |
| +// It reads the switch statement rather than the usage text because the |
| +// switch is the CLI and the usage text is prose about it. A gate |
| +// against the prose would prove only that the docs agree with a second |
| +// copy of the docs, which is exactly the drift worth catching. |
| +// |
| +// Flags are deliberately not scanned. rastrillo's equivalent gate does |
| +// scan them, but its CLI is one small package; CARLOS declares flags |
| +// across the whole root package and the list would run to hundreds, |
| +// most of them things no reader ever types. |
| +func TopLevelCommands(mainFile string) ([]string, error) { |
| + fset := token.NewFileSet() |
| + file, err := parser.ParseFile(fset, mainFile, nil, parser.SkipObjectResolution) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + seen := map[string]bool{} |
| + var out []string |
| + ast.Inspect(file, func(n ast.Node) bool { |
| + sw, ok := n.(*ast.SwitchStmt) |
| + if !ok { |
| + return true |
| + } |
| + for _, stmt := range sw.Body.List { |
| + clause, ok := stmt.(*ast.CaseClause) |
| + if !ok { |
| + continue |
| + } |
| + for _, expr := range clause.List { |
| + s, ok := stringLit(expr) |
| + if !ok || !isCommandWord(s) { |
| + continue |
| + } |
| + if !seen[s] { |
| + seen[s] = true |
| + out = append(out, s) |
| + } |
| + } |
| + } |
| + return true |
| + }) |
| + sort.Strings(out) |
| + return out, nil |
| +} |
| + |
| +// isCommandWord filters case arms down to real commands: flag aliases |
| +// like "-h" are documented as part of the command they belong to, and a |
| +// case arm is only a command if it looks like one. |
| +func isCommandWord(s string) bool { |
| + if s == "" { |
| + return false |
| + } |
| + for _, r := range s { |
| + if (r < 'a' || r > 'z') && r != '-' { |
| + return false |
| + } |
| + } |
| + return s[0] != '-' |
| +} |
| + |
| +func stringLit(e ast.Expr) (string, bool) { |
| + lit, ok := e.(*ast.BasicLit) |
| + if !ok || lit.Kind != token.STRING { |
| + return "", false |
| + } |
| + s, err := strconv.Unquote(lit.Value) |
| + if err != nil { |
| + return "", false |
| + } |
| + return s, true |
| +} |