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

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

View File

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

View File

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

View File

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

View File

@@ -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)",
})
}

View File

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

View File

@@ -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"))

View File

@@ -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