carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev b3786a36df63bda956758cdea5f60a7378ef859b parent 70c91f4
2 files changed, +155 −0
  • internal/docsite/cli_test.go +72 −0
  • internal/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
+}