Files
summercms/internal/docsite/check_commands.go
Jakub Zych 5d4c1e3046 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
2026-09-30 21:40:46 +02:00

191 lines
5.2 KiB
Go

package docsite
import (
"errors"
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
)
// Commands is the set of command names docs pages may show. The caller
// collects them from the real command constructors; the checker holds no
// hard-coded list.
type Commands struct {
// Tool lists the summer CLI command names.
Tool []string
// App lists the command names every application binary gets from the
// framework (migrations, queue, serve, admin and the rest).
App []string
}
// shellLangs are the fence languages whose lines are read as commands.
var shellLangs = []string{"sh", "shell", "bash", "console"}
// commandToken finds `summer <name>` and `./bin/<app> <name>` at the start
// of a shell command (after an optional "$ " prompt).
var commandToken = regexp.MustCompile(`^(?:\$\s+)?(summer|\./bin/[A-Za-z0-9._-]+)\s+(\S+)`)
// commandSeparators split one shell line into its commands.
var commandSeparators = regexp.MustCompile(`&&|\|\||;|\|`)
// checkCommands verifies every summer and ./bin/<app> command name in shell
// fences and code spans of the pages. Application names may also come from
// bonfire.Command literals in docs/examples and examples.
func (s *site) checkCommands(docs []parsedDoc) ([]Problem, error) {
if s.opts.Commands == nil {
return []Problem{{File: s.rel(s.opts.Src), Rule: "command", Message: "no command set supplied"}}, nil
}
tool := map[string]bool{}
for _, n := range s.opts.Commands.Tool {
tool[n] = true
}
app := map[string]bool{}
for _, n := range s.opts.Commands.App {
app[n] = true
}
for _, dir := range []string{filepath.Join(s.opts.Src, "examples"), filepath.Join(s.opts.Root, "examples")} {
names, err := exampleCommandNames(dir)
if err != nil {
return nil, err
}
for _, n := range names {
app[n] = true
}
}
var problems []Problem
check := func(d parsedDoc, line int, text string) {
for _, cmd := range commandSeparators.Split(text, -1) {
m := commandToken.FindStringSubmatch(strings.TrimSpace(cmd))
if m == nil || strings.HasPrefix(m[2], "-") {
continue
}
name := m[2]
known := tool[name]
if m[1] != "summer" {
known = app[name]
}
if !known {
problems = append(problems, Problem{File: d.file, Line: line, Rule: "command",
Message: fmt.Sprintf("%q is not a summer or application command", name)})
}
}
}
for _, d := range docs {
if d.page == nil {
continue
}
for _, span := range codeSpans(d.doc, d.body, d.line) {
check(d, span.line, span.text)
}
lines := strings.Split(string(d.body), "\n")
for _, f := range scanFences(lines) {
lang, _, _ := strings.Cut(f.info, " ")
if !slices.Contains(shellLangs, lang) {
continue
}
end := f.close
if end < 0 {
end = len(lines)
}
for i := f.open + 1; i < end; i++ {
check(d, d.line+i, lines[i])
}
}
}
return problems, nil
}
// exampleCommandNames returns the string-literal Name of every
// bonfire.Command composite literal (including the elided elements of a
// []bonfire.Command literal) in the non-test Go files under dir. It parses
// the files; the tool never imports example code.
func exampleCommandNames(dir string) ([]string, error) {
if _, err := os.Stat(dir); errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
var names []string
fset := token.NewFileSet()
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
name := d.Name()
if d.IsDir() {
if p != dir && (strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") || name == "testdata" || name == "node_modules" || name == "vendor") {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
return nil
}
f, err := parser.ParseFile(fset, p, nil, parser.SkipObjectResolution)
if err != nil {
return fmt.Errorf("docsite: parse %s: %w", p, err)
}
ast.Inspect(f, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
switch {
case isBonfireCommand(lit.Type):
names = appendCommandName(names, lit)
case isBonfireCommandSlice(lit.Type):
for _, e := range lit.Elts {
if el, ok := e.(*ast.CompositeLit); ok && el.Type == nil {
names = appendCommandName(names, el)
}
}
}
return true
})
return nil
})
if err != nil {
return nil, fmt.Errorf("docsite: scan %s: %w", dir, err)
}
return names, nil
}
func isBonfireCommand(expr ast.Expr) bool {
sel, ok := expr.(*ast.SelectorExpr)
if !ok {
return false
}
pkg, ok := sel.X.(*ast.Ident)
return ok && pkg.Name == "bonfire" && sel.Sel.Name == "Command"
}
func isBonfireCommandSlice(expr ast.Expr) bool {
arr, ok := expr.(*ast.ArrayType)
return ok && isBonfireCommand(arr.Elt)
}
func appendCommandName(names []string, lit *ast.CompositeLit) []string {
for _, e := range lit.Elts {
kv, ok := e.(*ast.KeyValueExpr)
if !ok {
continue
}
key, ok := kv.Key.(*ast.Ident)
if !ok || key.Name != "Name" {
continue
}
if bl, ok := kv.Value.(*ast.BasicLit); ok && bl.Kind == token.STRING {
if v, err := strconv.Unquote(bl.Value); err == nil {
names = append(names, v)
}
}
}
return names
}