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

@@ -5,7 +5,15 @@ import (
"errors"
"git.golem15.com/golem15/summercms/internal/docsite"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/flare"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lighthouse/centrifugo"
"git.golem15.com/golem15/summercms/modules/surf"
)
func docsBuildCommand() bonfire.Command {
@@ -72,7 +80,7 @@ func docsSyncCommand() bonfire.Command {
}
func docsOptions(in bonfire.Input) docsite.Options {
var opts docsite.Options
opts := docsite.Options{Commands: docsCommands()}
opts.Root, _ = in.Flag("root")
opts.Src, _ = in.Flag("src")
opts.Out, _ = in.Flag("out")
@@ -80,6 +88,34 @@ func docsOptions(in bonfire.Input) docsite.Options {
return opts
}
// docsCommands collects the command names docs pages may show: the summer
// tool's own commands, and every command the generated application main
// registers (TestDocsCommandsMirrorGeneratedMain keeps this list in step
// with internal/build) plus the realtime and push commands applications
// append. The constructors only capture the app, so an empty config is
// enough; nothing runs.
func docsCommands() *docsite.Commands {
var tool []string
for _, c := range toolCommands() {
tool = append(tool, c.Name)
}
app := backpack.New(&compass.Config{})
var appCmds []bonfire.Command
appCmds = append(appCmds, lagoon.RuntimeCommands(app, nil)...)
appCmds = append(appCmds, lagoon.KeyGenerateCommand())
appCmds = append(appCmds, conga.RuntimeCommands(app, nil)...)
appCmds = append(appCmds, surf.ServeCommand(app, nil))
appCmds = append(appCmds, surf.RouteListCommand(app, nil))
appCmds = append(appCmds, cabana.RuntimeCommands(app)...)
appCmds = append(appCmds, centrifugo.Commands(app)...)
appCmds = append(appCmds, flare.Commands(app)...)
names := make([]string, 0, len(appCmds))
for _, c := range appCmds {
names = append(names, c.Name)
}
return &docsite.Commands{Tool: tool, App: names}
}
// reportDocsProblems prints one line per problem and a summary, and returns
// a short error so the binary exits 1.
func reportDocsProblems(out bonfire.Output, cmd string, problems []docsite.Problem) error {

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

View File

@@ -89,7 +89,7 @@ func TestToolDoesNotImportExamplePlugins(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if strings.Contains(path, "examples/hello") || strings.Contains(path, "golem15/fonoteka") {
if strings.Contains(path, "examples/hello") || strings.Contains(path, "docs/examples") || strings.Contains(path, "golem15/fonoteka") {
t.Fatalf("%s imports %s", name, path)
}
}