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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user