From 5d4c1e3046589b52f47af63783cdc32e6502182c Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 30 Sep 2026 21:40:46 +0200 Subject: [PATCH] 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/ 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 --- cmd/summer/docs.go | 38 +++++- cmd/summer/docs_test.go | 85 ++++++++++++- cmd/summer/main_test.go | 2 +- internal/docsite/check_commands.go | 190 ++++++++++++++++++++++++++++ internal/docsite/check_forbidden.go | 66 ++++++++++ internal/docsite/check_links.go | 111 ++++++++++++++++ internal/docsite/check_policy.go | 105 +++++++++++++++ internal/docsite/checks_test.go | 146 ++++++++++++++++++++- internal/docsite/docsite.go | 19 ++- internal/docsite/docsite_test.go | 17 ++- internal/docsite/load.go | 1 + scripts/check-phase11.1.sh | 42 ++++++ 12 files changed, 808 insertions(+), 14 deletions(-) create mode 100644 internal/docsite/check_commands.go create mode 100644 internal/docsite/check_forbidden.go create mode 100644 internal/docsite/check_links.go create mode 100644 internal/docsite/check_policy.go diff --git a/cmd/summer/docs.go b/cmd/summer/docs.go index a7e945e..6599a69 100644 --- a/cmd/summer/docs.go +++ b/cmd/summer/docs.go @@ -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 { diff --git a/cmd/summer/docs_test.go b/cmd/summer/docs_test.go index 956478a..edd9d42 100644 --- a/cmd/summer/docs_test.go +++ b/cmd/summer/docs_test.go @@ -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) + } + } +} diff --git a/cmd/summer/main_test.go b/cmd/summer/main_test.go index 966b1ae..2dcfba5 100644 --- a/cmd/summer/main_test.go +++ b/cmd/summer/main_test.go @@ -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) } } diff --git a/internal/docsite/check_commands.go b/internal/docsite/check_commands.go new file mode 100644 index 0000000..dbf3428 --- /dev/null +++ b/internal/docsite/check_commands.go @@ -0,0 +1,190 @@ +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 ` and `./bin/ ` 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/ 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 +} diff --git a/internal/docsite/check_forbidden.go b/internal/docsite/check_forbidden.go new file mode 100644 index 0000000..0e0a2d3 --- /dev/null +++ b/internal/docsite/check_forbidden.go @@ -0,0 +1,66 @@ +package docsite + +import ( + "fmt" + "os" + "path" + "regexp" + "slices" + "strings" +) + +// forbiddenName matches the consuming-application spellings framework docs +// must never contain (D-11), the accented variant included. +var forbiddenName = regexp.MustCompile(`(?i)fonoteka|p[lł][yý]tarium`) + +const forbiddenMessage = "consuming-application name in output" + +// forbiddenLines returns the 1-based line numbers of text that name a +// consuming application. The match itself is never reported. +func forbiddenLines(text []byte) []int { + var lines []int + for i, line := range strings.Split(string(text), "\n") { + if forbiddenName.MatchString(line) { + lines = append(lines, i+1) + } + } + return lines +} + +// checkForbiddenSources scans the full source file of every page (the +// frontmatter and a README's summary line included). +func (s *site) checkForbiddenSources() ([]Problem, error) { + var problems []Problem + for _, p := range s.pages { + raw, err := os.ReadFile(p.abs) + if err != nil { + return nil, fmt.Errorf("docsite: read %s: %w", p.Source, err) + } + for _, line := range forbiddenLines(raw) { + problems = append(problems, Problem{File: p.Source, Line: line, Rule: "forbidden", Message: forbiddenMessage}) + } + } + return problems, nil +} + +// forbiddenOutputExts are the text outputs scanned after rendering. +var forbiddenOutputExts = []string{".html", ".md", ".txt", ".json", ".js", ".css"} + +// checkForbiddenOutputs scans every rendered text output, which covers +// text pulled in through src= snippet copies and templates. +func (s *site) checkForbiddenOutputs() []Problem { + names := make([]string, 0, len(s.outputs)) + for name := range s.outputs { + if slices.Contains(forbiddenOutputExts, path.Ext(name)) { + names = append(names, name) + } + } + slices.Sort(names) + var problems []Problem + for _, name := range names { + for _, line := range forbiddenLines(s.outputs[name]) { + problems = append(problems, Problem{File: name, Line: line, Rule: "forbidden", Message: forbiddenMessage}) + } + } + return problems +} diff --git a/internal/docsite/check_links.go b/internal/docsite/check_links.go new file mode 100644 index 0000000..291bc95 --- /dev/null +++ b/internal/docsite/check_links.go @@ -0,0 +1,111 @@ +package docsite + +import ( + "fmt" + "strings" + + gast "github.com/yuin/goldmark/ast" +) + +// checkLinks verifies every link and image destination in the pages +// (guides and ingested READMEs): an anchor must be a heading ID of its +// page, a relative .md link must resolve to a page of the site and its +// fragment to a heading of that page. Heading IDs come from the same parse +// and slug algorithm the renderer uses, so the site and the checker agree. +// External http(s) and mailto links are allowed and never fetched. +func (s *site) checkLinks(docs []parsedDoc) []Problem { + ids := map[string]map[string]bool{} + for _, d := range docs { + if d.page == nil { + continue + } + set := map[string]bool{} + for _, h := range pageHeadings(d.doc, d.body) { + set[h.ID] = true + } + ids[d.page.Source] = set + } + var problems []Problem + for _, d := range docs { + if d.page == nil { + continue + } + _ = gast.Walk(d.doc, func(n gast.Node, entering bool) (gast.WalkStatus, error) { + if !entering { + return gast.WalkContinue, nil + } + var dest string + switch l := n.(type) { + case *gast.Link: + dest = string(l.Destination) + case *gast.Image: + dest = string(l.Destination) + default: + return gast.WalkContinue, nil + } + if msg := s.checkLink(d.page, dest, ids); msg != "" { + problems = append(problems, Problem{File: d.file, Line: lineOf(d.body, nodeOffset(n), d.line), Rule: "link", Message: msg}) + } + return gast.WalkContinue, nil + }) + } + return problems +} + +// checkLink returns the problem message for one destination, or "". +func (s *site) checkLink(from *Page, dest string, ids map[string]map[string]bool) string { + switch { + case dest == "": + return "empty link destination does not resolve" + case strings.HasPrefix(dest, "http://"), strings.HasPrefix(dest, "https://"), strings.HasPrefix(dest, "mailto:"): + return "" + case strings.HasPrefix(dest, "#"): + frag := dest[1:] + if !ids[from.Source][frag] { + return fmt.Sprintf("#%s not found in %s", frag, from.Source) + } + return "" + case hasScheme(dest), strings.HasPrefix(dest, "/"): + return fmt.Sprintf("%s does not resolve", dest) + } + target, frag, _ := strings.Cut(dest, "#") + if strings.HasSuffix(target, ".md") { + p, _, ok := s.resolveLink(from, dest) + if !ok { + return fmt.Sprintf("%s does not resolve", dest) + } + if frag != "" && !ids[p.Source][frag] { + return fmt.Sprintf("#%s not found in %s", frag, p.Source) + } + return "" + } + // Any other relative path (a source file, .planning/, examples/) works + // on the git host but breaks on the site. Module READMEs are read on + // the git host first, so only guide pages are held to this. + if from.Module == "" { + return fmt.Sprintf("%s does not resolve", dest) + } + return "" +} + +// nodeOffset returns the source offset of an inline node: its first text +// descendant, else the first line of its enclosing block. +func nodeOffset(n gast.Node) int { + off := -1 + _ = gast.Walk(n, func(c gast.Node, entering bool) (gast.WalkStatus, error) { + if t, ok := c.(*gast.Text); entering && ok { + off = t.Segment.Start + return gast.WalkStop, nil + } + return gast.WalkContinue, nil + }) + if off >= 0 { + return off + } + for p := n; p != nil; p = p.Parent() { + if p.Type() == gast.TypeBlock && p.Lines().Len() > 0 { + return p.Lines().At(0).Start + } + } + return -1 +} diff --git a/internal/docsite/check_policy.go b/internal/docsite/check_policy.go new file mode 100644 index 0000000..8e24325 --- /dev/null +++ b/internal/docsite/check_policy.go @@ -0,0 +1,105 @@ +package docsite + +import ( + "fmt" + "regexp" + "slices" + "strings" + + gast "github.com/yuin/goldmark/ast" +) + +// calloutTypes are the only `> [!TYPE]` callouts the theme renders. +var calloutTypes = []string{"NOTE", "TIP", "WARNING"} + +// calloutMarker matches the first line of a callout blockquote. +var calloutMarker = regexp.MustCompile(`^\s{0,3}>\s?\[!([A-Za-z]+)\]\s*$`) + +// checkPolicy enforces the page content rules: +// - every go fence in a docs/ page carries src= (module READMEs are +// rendered as written, D-18); +// - callouts are NOTE, TIP or WARNING; +// - docs/ headings are plain ASCII text without links or code spans. +func (s *site) checkPolicy(docs []parsedDoc) []Problem { + var problems []Problem + for _, d := range docs { + if d.page == nil { + continue + } + guide := d.page.Module == "" + lines := strings.Split(string(d.body), "\n") + fences := scanFences(lines) + inFence := make([]bool, len(lines)) + for _, f := range fences { + end := f.close + if end < 0 { + end = len(lines) - 1 + } + for i := f.open; i <= end; i++ { + inFence[i] = true + } + fields := strings.Fields(f.info) + if guide && len(fields) > 0 && fields[0] == "go" && + !slices.ContainsFunc(fields[1:], func(f string) bool { return strings.HasPrefix(f, "src=") }) { + problems = append(problems, Problem{File: d.file, Line: d.line + f.open, Rule: "snippet", + Message: "go code block has no src= reference"}) + } + } + for i, line := range lines { + if inFence[i] { + continue + } + if m := calloutMarker.FindStringSubmatch(line); m != nil && !slices.Contains(calloutTypes, m[1]) { + problems = append(problems, Problem{File: d.file, Line: d.line + i, Rule: "callout", + Message: fmt.Sprintf("unknown type %s (use NOTE, TIP or WARNING)", m[1])}) + } + } + if guide { + problems = append(problems, headingProblems(d)...) + } + } + return problems +} + +const headingMessage = "headings must be plain ASCII text without links or code" + +// headingProblems reports headings with a link, image, code span or raw +// HTML, or with non-ASCII text: their IDs would not be stable across the +// site, Gitea and GitHub. +func headingProblems(d parsedDoc) []Problem { + var problems []Problem + _ = gast.Walk(d.doc, func(n gast.Node, entering bool) (gast.WalkStatus, error) { + h, ok := n.(*gast.Heading) + if !entering || !ok { + return gast.WalkContinue, nil + } + bad := false + _ = gast.Walk(h, func(c gast.Node, entering bool) (gast.WalkStatus, error) { + if !entering { + return gast.WalkContinue, nil + } + switch t := c.(type) { + case *gast.Link, *gast.Image, *gast.CodeSpan, *gast.RawHTML, *gast.AutoLink: + bad = true + return gast.WalkStop, nil + case *gast.Text: + for _, b := range t.Segment.Value(d.body) { + if b >= 0x80 { + bad = true + return gast.WalkStop, nil + } + } + } + return gast.WalkContinue, nil + }) + if bad { + off := -1 + if h.Lines().Len() > 0 { + off = h.Lines().At(0).Start + } + problems = append(problems, Problem{File: d.file, Line: lineOf(d.body, off, d.line), Rule: "heading", Message: headingMessage}) + } + return gast.WalkSkipChildren, nil + }) + return problems +} diff --git a/internal/docsite/checks_test.go b/internal/docsite/checks_test.go index 3a404bc..5e6fd76 100644 --- a/internal/docsite/checks_test.go +++ b/internal/docsite/checks_test.go @@ -90,7 +90,7 @@ func TestIdentifierChecker(t *testing.T) { "", }, "\n") root := identFixture(t, passing, "## Usage\n\nCall `fixture.New()`.\n") - problems, err := Check(Options{Root: root}) + problems, err := Check(Options{Root: root, Commands: fixtureCommands}) if err != nil { t.Fatal(err) } @@ -101,7 +101,7 @@ func TestIdentifierChecker(t *testing.T) { failing := passing + "Then `fixture.Missing` and `fixture.Bus.Nope`.\n\n`fixture.Hidden` lives in testdata.\n" root = identFixture(t, failing, "## Usage\n\nCall `fixture.Gone()`.\n") writeFile(t, root, "README.md", "# Root\n\nSee `x.Y`.\n\n`sub.Nothing`\n") - problems, err = Check(Options{Root: root}) + problems, err = Check(Options{Root: root, Commands: fixtureCommands}) if err != nil { t.Fatal(err) } @@ -132,3 +132,145 @@ func TestIdentifierIndexDuplicateName(t *testing.T) { t.Fatalf("problems = %q, want [%q]", got, want) } } + +// checkFixture writes a fixture tree around one index body and one +// fixture README section and returns its problem lines. +func checkFixture(t *testing.T, cmds *Commands, files map[string]string) []string { + t.Helper() + tree := map[string]string{ + "docs/site.yaml": fixtureSite, + "docs/setup/start.md": page("Start", "setup", 10, "## First steps\n\nText.\n"), + "modules/fixture/fixture.go": "package fixture\n", + "modules/fixture/README.md": "# fixture\n\nFixture does one thing.\n\n## Usage\n\nText.\n", + } + for k, v := range files { + tree[k] = v + } + root := writeTree(t, tree) + problems, err := Check(Options{Root: root, Commands: cmds}) + if err != nil { + t.Fatal(err) + } + return problemLines(problems) +} + +func assertProblems(t *testing.T, got, want []string) { + t.Helper() + if !slices.Equal(got, want) { + t.Fatalf("problems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +func TestLinkChecker(t *testing.T) { + good := "## Alpha\n\nSee [alpha](#alpha), [start](setup/start.md#first-steps), " + + "[usage](../modules/fixture/README.md#usage), [web](https://example.com) and [mail](mailto:a@example.com).\n\n" + + "![logo](https://example.com/logo.png)\n" + assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{ + "docs/index.md": page("Acme docs", "index", 0, good), + "modules/fixture/README.md": "# fixture\n\nFixture does one thing.\n\n## Usage\n\n" + + "See [start](../../docs/setup/start.md) and [source](fixture.go).\n", + }), nil) + + bad := good + "\n[a](#nope) [b](setup/missing.md) [c](setup/start.md#nope)\n\n" + + "[d](../modules/fixture/fixture.go) [e](/abs.html)\n" + assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{ + "docs/index.md": page("Acme docs", "index", 0, bad), + "modules/fixture/README.md": "# fixture\n\nFixture does one thing.\n\n## Usage\n\n" + + "See [other](../other/README.md) and [anchor](#missing).\n", + }), []string{ + "docs/index.md:15: link: #nope not found in docs/index.md", + "docs/index.md:15: link: setup/missing.md does not resolve", + "docs/index.md:15: link: #nope not found in docs/setup/start.md", + "docs/index.md:17: link: ../modules/fixture/fixture.go does not resolve", + "docs/index.md:17: link: /abs.html does not resolve", + "modules/fixture/README.md:7: link: ../other/README.md does not resolve", + "modules/fixture/README.md:7: link: #missing not found in modules/fixture/README.md", + }) +} + +func TestCommandChecker(t *testing.T) { + example := "package main\n\nimport \"example.com/bonfire\"\n\n" + + "var one = bonfire.Command{Name: \"acme:greet\"}\n\n" + + "var many = []bonfire.Command{{Name: \"acme:list\"}, {Name: \"acme:sync\"}}\n" + good := "Run `summer docs:build` or `$ summer make:plugin acme.blog`.\n\n" + + "```sh\n$ summer docs:build --out site\nsummer --help\ncd app && summer make:plugin acme.blog\n" + + "./bin/acme serve --addr :8080\n./bin/acme acme:greet blog\n./bin/acme acme:sync\n```\n\n" + + "```text\nsummer not:checked\n```\n\n`summer.yaml` and `go install ./cmd/summer` are not commands.\n" + files := map[string]string{ + "docs/index.md": page("Acme docs", "index", 0, good), + "docs/examples/greet/main.go": example, + "examples/app/plugins/p/plugin.go": example, + } + assertProblems(t, checkFixture(t, fixtureCommands, files), nil) + + files["docs/index.md"] = page("Acme docs", "index", 0, good+ + "\nThen `summer no:such`.\n\n```bash\n./bin/acme docs:build\nsummer migrate\n```\n") + files["modules/fixture/README.md"] = "# fixture\n\nFixture does one thing.\n\n## Usage\n\n```sh\n./bin/acme fixture:run\n```\n" + assertProblems(t, checkFixture(t, fixtureCommands, files), []string{ + `docs/index.md:26: command: "no:such" is not a summer or application command`, + `docs/index.md:29: command: "docs:build" is not a summer or application command`, + `docs/index.md:30: command: "migrate" is not a summer or application command`, + `modules/fixture/README.md:8: command: "fixture:run" is not a summer or application command`, + }) + + assertProblems(t, checkFixture(t, nil, map[string]string{ + "docs/index.md": page("Acme docs", "index", 0, "Text.\n"), + }), []string{"docs: command: no command set supplied"}) +} + +func TestForbiddenChecker(t *testing.T) { + // The forbidden words are built at run time so no test source names a + // consuming application. + name := "Fono" + "teka" + accented := "P" + "Ł" + "Ý" + "tarium" + assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{ + "docs/index.md": page("Acme docs", "index", 0, "The host application.\n"), + }), nil) + + got := checkFixture(t, fixtureCommands, map[string]string{ + "docs/index.md": page("Acme docs", "index", 0, "The "+name+" app.\n"), + "docs/setup/start.md": page("Start", "setup", 10, "## First steps\n\nSee "+accented+".\n"), + }) + assertProblems(t, got, []string{ + "docs/index.md:9: forbidden: consuming-application name in output", + "docs/setup/start.md:11: forbidden: consuming-application name in output", + }) + + site := strings.Replace(fixtureSite, "description: Acme docs.", "description: Docs for "+strings.ToLower(name)+".", 1) + got = checkFixture(t, fixtureCommands, map[string]string{ + "docs/site.yaml": site, + "docs/index.md": page("Acme docs", "index", 0, "Text.\n"), + }) + if !slices.Contains(got, "llms.txt:3: forbidden: consuming-application name in output") { + t.Fatalf("output check missed llms.txt: %q", got) + } + for _, line := range got { + if !strings.HasSuffix(line, ": forbidden: consuming-application name in output") || + strings.Contains(strings.ToLower(line), strings.ToLower(name)) { + t.Fatalf("unexpected problem line %q", line) + } + } +} + +func TestFencePolicy(t *testing.T) { + readme := "# fixture\n\nFixture does one thing.\n\n## Usage of `fixture`\n\n```go\nfixture.Run()\n```\n\n> [!TIP]\n> Fine.\n" + good := "## Plain heading\n\n> [!NOTE]\n> A note.\n\n> [!WARNING]\n> Careful.\n\n```text\nplain\n```\n\n" + + "```yaml\nkey: value\n```\n\n```md\n> [!DANGER]\n```\n" + assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{ + "docs/index.md": page("Acme docs", "index", 0, good), + "modules/fixture/README.md": readme, + }), nil) + + bad := good + "\n```go\nfmt.Println()\n```\n\n> [!DANGER]\n> Boom.\n\n## Use `fixture`\n\n## Zażółć\n\n## See [start](setup/start.md)\n" + assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{ + "docs/index.md": page("Acme docs", "index", 0, bad), + "modules/fixture/README.md": readme + "\n> [!CAUTION]\n> No.\n", + }), []string{ + "docs/index.md:29: snippet: go code block has no src= reference", + "docs/index.md:33: callout: unknown type DANGER (use NOTE, TIP or WARNING)", + "docs/index.md:36: heading: headings must be plain ASCII text without links or code", + "docs/index.md:38: heading: headings must be plain ASCII text without links or code", + "docs/index.md:40: heading: headings must be plain ASCII text without links or code", + "modules/fixture/README.md:14: callout: unknown type CAUTION (use NOTE, TIP or WARNING)", + }) +} diff --git a/internal/docsite/docsite.go b/internal/docsite/docsite.go index 95f81f9..3f7b659 100644 --- a/internal/docsite/docsite.go +++ b/internal/docsite/docsite.go @@ -34,6 +34,9 @@ type Options struct { Out string // BaseURL overrides site.yaml base_url when non-empty. BaseURL string + // Commands is the set of summer and application command names pages + // may show. Check and Build report a problem when it is nil. + Commands *Commands } // Problem is one finding in the docs source, printed as @@ -230,7 +233,9 @@ func writeOutputs(out string, files map[string][]byte) error { } // checkContent runs the accuracy checkers over every page and the root -// README.md: module identifiers named in code spans. +// README.md: module identifiers, links and anchors, command names, +// consuming-application names in the sources, and the fence, callout and +// heading policy. func (s *site) checkContent() ([]Problem, error) { docs, err := s.parseDocs() if err != nil { @@ -241,6 +246,18 @@ func (s *site) checkContent() ([]Problem, error) { return nil, err } problems = append(problems, s.checkIdentifiers(idx, docs)...) + problems = append(problems, s.checkLinks(docs)...) + cp, err := s.checkCommands(docs) + if err != nil { + return nil, err + } + problems = append(problems, cp...) + fp, err := s.checkForbiddenSources() + if err != nil { + return nil, err + } + problems = append(problems, fp...) + problems = append(problems, s.checkPolicy(docs)...) return problems, nil } diff --git a/internal/docsite/docsite_test.go b/internal/docsite/docsite_test.go index 674843d..1cdd11e 100644 --- a/internal/docsite/docsite_test.go +++ b/internal/docsite/docsite_test.go @@ -34,6 +34,9 @@ func problemLines(problems []Problem) []string { return out } +// fixtureCommands is the command set the fixture trees are checked with. +var fixtureCommands = &Commands{Tool: []string{"docs:build", "make:plugin"}, App: []string{"serve", "migrate"}} + const fixtureSite = `title: Acme description: Acme docs. sections: @@ -110,7 +113,7 @@ func TestReadmeIngestion(t *testing.T) { t.Fatal(err) } out := filepath.Join(t.TempDir(), "site") - res, problems, err := Build(Options{Root: root, Out: out}) + res, problems, err := Build(Options{Root: root, Commands: fixtureCommands, Out: out}) if err != nil || len(problems) > 0 { t.Fatalf("Build: %v %q", err, problemLines(problems)) } @@ -302,7 +305,7 @@ func TestSnippetConfinement(t *testing.T) { if err := os.Symlink(outside, filepath.Join(root, "escape.txt")); err != nil { t.Fatal(err) } - problems, err := Check(Options{Root: root}) + problems, err := Check(Options{Root: root, Commands: fixtureCommands}) if err != nil { t.Fatal(err) } @@ -316,7 +319,7 @@ func TestSnippetConfinement(t *testing.T) { t.Errorf("src=%s: no problem %q...%q in\n%s", tc.src, prefix, tc.want, strings.Join(got, "\n")) } } - if _, _, err := Build(Options{Root: root, Out: filepath.Join(t.TempDir(), "site")}); err != nil { + if _, _, err := Build(Options{Root: root, Commands: fixtureCommands, Out: filepath.Join(t.TempDir(), "site")}); err != nil { t.Fatal(err) } } @@ -327,7 +330,7 @@ func TestSyncRewritesDrift(t *testing.T) { root := snippetTree(t, map[string]string{"docs/setup/start.md": doc}) path := filepath.Join(root, "docs/setup/start.md") - problems, err := Check(Options{Root: root}) + problems, err := Check(Options{Root: root, Commands: fixtureCommands}) if err != nil { t.Fatal(err) } @@ -336,7 +339,7 @@ func TestSyncRewritesDrift(t *testing.T) { t.Fatalf("problems = %q, want [%q]", got, want) } out := filepath.Join(t.TempDir(), "site") - if _, problems, err := Build(Options{Root: root, Out: out}); err != nil || len(problems) != 1 { + if _, problems, err := Build(Options{Root: root, Commands: fixtureCommands, Out: out}); err != nil || len(problems) != 1 { t.Fatalf("Build with drift: %v %v", err, problems) } if _, err := os.Stat(out); err == nil { @@ -362,10 +365,10 @@ func TestSyncRewritesDrift(t *testing.T) { if res, _, err := Sync(Options{Root: root}); err != nil || res != (SyncResult{}) { t.Fatalf("second Sync = %+v, %v; want up to date", res, err) } - if problems, err := Check(Options{Root: root}); err != nil || len(problems) > 0 { + if problems, err := Check(Options{Root: root, Commands: fixtureCommands}); err != nil || len(problems) > 0 { t.Fatalf("Check after Sync: %v %q", err, problemLines(problems)) } - if _, problems, err := Build(Options{Root: root, Out: out}); err != nil || len(problems) > 0 { + if _, problems, err := Build(Options{Root: root, Commands: fixtureCommands, Out: out}); err != nil || len(problems) > 0 { t.Fatalf("Build after Sync: %v %v", err, problems) } md, err := os.ReadFile(filepath.Join(out, "setup/start.md")) diff --git a/internal/docsite/load.go b/internal/docsite/load.go index fb01a24..2a85e44 100644 --- a/internal/docsite/load.go +++ b/internal/docsite/load.go @@ -195,6 +195,7 @@ func assemble(opts Options) (*site, []Problem, error) { return nil, nil, err } problems = append(problems, rp...) + problems = append(problems, s.checkForbiddenOutputs()...) } sortProblems(problems) return s, problems, nil diff --git a/scripts/check-phase11.1.sh b/scripts/check-phase11.1.sh index 3e518d1..85afa93 100755 --- a/scripts/check-phase11.1.sh +++ b/scripts/check-phase11.1.sh @@ -29,6 +29,7 @@ usage: check-phase11.1.sh --deps check-phase11.1.sh --docs check-phase11.1.sh --forbidden + check-phase11.1.sh --claude check-phase11.1.sh --self-test check-phase11.1.sh --go check-phase11.1.sh --all @@ -118,6 +119,24 @@ run_forbidden() { echo "phase11.1 forbidden passed" } +# The D-13 / DOCS-08 rules CLAUDE.md's Documentation section must carry. +CLAUDE_RULES=( + 'also updates the affected pages under `docs/` in the same change' + '`go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check` check identifiers' + 'Every identifier named in a README or a docs page must exist in the package.' + 'Config keys named in README or docs pages are not checked automatically yet' +) + +run_claude() { + local section rule + section="$(awk '/^## Documentation$/{on=1; next} /^## /{on=0} on' "$ROOT/CLAUDE.md")" + [ -n "$section" ] || refuse "CLAUDE.md has no Documentation section" + for rule in "${CLAUDE_RULES[@]}"; do + grep -Fq -- "$rule" <<<"$section" || refuse "CLAUDE.md Documentation section is missing: $rule" + done + echo "phase11.1 claude passed" +} + run_go() { (cd "$ROOT" && go vet ./...) (cd "$ROOT" && go test ./...) @@ -176,6 +195,27 @@ run_self_test() { expect_refusal 'frontmatter typo' 'frontmatter: unknown field' "$summer" "$scratch" restore "$pristine" "$scratch" docs/setup/installation.md + printf '\nSee [nowhere](#no-such-anchor).\n' >>"$scratch/docs/index.md" + expect_refusal 'broken anchor' 'link: #no-such-anchor not found in' "$summer" "$scratch" + restore "$pristine" "$scratch" docs/index.md + + printf '\nRun `summer no:such`.\n' >>"$scratch/docs/index.md" + expect_refusal 'unknown command' '"no:such" is not a summer or application command' "$summer" "$scratch" + restore "$pristine" "$scratch" docs/index.md + + # Built from two halves so this script's plant is not itself a hit. + printf '\nThe %s%s application.\n' 'fono' 'teka' >>"$scratch/docs/index.md" + expect_refusal 'forbidden name' 'forbidden: consuming-application name in output' "$summer" "$scratch" + restore "$pristine" "$scratch" docs/index.md + + printf '\n```go\nx := 1\n```\n' >>"$scratch/docs/index.md" + expect_refusal 'go fence without src=' 'snippet: go code block has no src= reference' "$summer" "$scratch" + restore "$pristine" "$scratch" docs/index.md + + printf '\n> [!DANGER]\n> Planted.\n' >>"$scratch/docs/index.md" + expect_refusal 'unknown callout' 'callout: unknown type DANGER (use NOTE, TIP or WARNING)' "$summer" "$scratch" + restore "$pristine" "$scratch" docs/index.md + echo "phase11.1 self-test passed" } @@ -184,6 +224,7 @@ case "${1:-}" in --deps) run_deps ;; --docs) run_docs ;; --forbidden) run_forbidden ;; +--claude) run_claude ;; --self-test) run_self_test ;; --go) run_go ;; --all) @@ -192,6 +233,7 @@ case "${1:-}" in run_self_test run_docs run_forbidden + run_claude run_go echo "phase11.1 all passed" ;;