feat(11.1-02): check links, commands, forbidden names and fence policy

- relative links and anchors resolve against the renderer's heading IDs
- summer and ./bin/<app> command names come from the real command
  constructors through docsite.Options.Commands; a nil set is a problem
- consuming-application names fail in page sources and built outputs
- go fences in docs/ pages need src=, callouts are NOTE, TIP or WARNING,
  docs/ headings are plain ASCII
- gate gains --claude and self-test plants for each new rule
This commit is contained in:
Jakub Zych
2026-09-30 21:40:46 +02:00
parent f4605292b5
commit 5d4c1e3046
12 changed files with 808 additions and 14 deletions

View File

@@ -3,11 +3,15 @@ package main
import (
"bufio"
"bytes"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"testing"
@@ -19,7 +23,7 @@ const repoRoot = "../.."
// TestDocsTree fails with every problem line in the real docs tree.
func TestDocsTree(t *testing.T) {
problems, err := docsite.Check(docsite.Options{Root: repoRoot})
problems, err := docsite.Check(docsite.Options{Root: repoRoot, Commands: docsCommands()})
if err != nil {
t.Fatal(err)
}
@@ -115,7 +119,7 @@ func TestEveryModuleInSidebar(t *testing.T) {
// .md siblings, llms.txt and llms-full.txt all list the same pages in the
// same reading order.
func TestDocsAIOutputsInSync(t *testing.T) {
pages, problems, err := docsite.Pages(docsite.Options{Root: repoRoot})
pages, problems, err := docsite.Pages(docsite.Options{Root: repoRoot, Commands: docsCommands()})
if err != nil || len(problems) > 0 {
t.Fatalf("Pages: %v %v", err, problems)
}
@@ -223,3 +227,80 @@ func first(lines []string) string {
}
return lines[0]
}
func TestDocsCommandNames(t *testing.T) {
cmds := docsCommands()
for _, want := range []string{"docs:build", "make:plugin", "migrate:status"} {
if !slices.Contains(cmds.Tool, want) {
t.Errorf("Tool is missing %s: %v", want, cmds.Tool)
}
}
for _, want := range []string{"key:generate", "route:list", "admin:create", "queue:clear", "websockets:health"} {
if !slices.Contains(cmds.App, want) {
t.Errorf("App is missing %s: %v", want, cmds.App)
}
}
}
// generatedConstructor matches a command constructor the generated app main
// appends to its command list.
var generatedConstructor = regexp.MustCompile(`(?:commands :=|append\(commands,)\s*([a-z]+)\.([A-Z][A-Za-z0-9]*)\(app\b`)
// TestDocsCommandsMirrorGeneratedMain keeps docsCommands in step with the
// application main internal/build generates: every command constructor
// written there must also be called in docs.go.
func TestDocsCommandsMirrorGeneratedMain(t *testing.T) {
fset := token.NewFileSet()
buildFile, err := parser.ParseFile(fset, filepath.Join(repoRoot, "internal", "build", "build.go"), nil, 0)
if err != nil {
t.Fatal(err)
}
var generated []string
ast.Inspect(buildFile, func(n ast.Node) bool {
lit, ok := n.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
v, err := strconv.Unquote(lit.Value)
if err != nil {
return true
}
for _, m := range generatedConstructor.FindAllStringSubmatch(v, -1) {
generated = append(generated, m[1]+"."+m[2])
}
return true
})
if len(generated) < 5 {
t.Fatalf("found %d constructors in internal/build/build.go (%v), want at least 5", len(generated), generated)
}
docsFile, err := parser.ParseFile(fset, "docs.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var called []string
ast.Inspect(docsFile, func(n ast.Node) bool {
fn, ok := n.(*ast.FuncDecl)
if !ok || fn.Name.Name != "docsCommands" {
return true
}
ast.Inspect(fn, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
if pkg, ok := sel.X.(*ast.Ident); ok {
called = append(called, pkg.Name+"."+sel.Sel.Name)
}
}
return true
})
return false
})
for _, c := range generated {
if !slices.Contains(called, c) {
t.Errorf("the generated main calls %s but docsCommands does not", c)
}
}
}