Files
bookhoard/internal/docs/links_test.go
T
John O'Keefe 913174cdf8 docs(gates): widen relative-link checker to all docs, repair the rot it finds
TestDocsRelativeLinks walked only docs/developer; widening it to all of
docs/ immediately surfaced the same class of rot the gate was built
for, outside its old reach:

- docs/index.md was the old monolith's landing page: linked the deleted
  api-reference.md, 10 directory-style area links, and escaped the docs
  tree to README/AGENTS/.env/compose. Rewritten as a portal whose every
  link resolves, with one representative endpoint doc per API area and
  pointers to the new writing-ui-docs.md standard.
- directory-style links in user stubs (devices/, ../operations/)
- case-mismatched links (Development.md) in contributing.md
- monolith-era pointer (API_REFERENCE.md, TROUBLESHOOTING.md) and
  escaped AGENTS.md links in contributing/development.md
- docs/development/calibre-opf-implementation.md double-up escapes, a
  link to the removed repo-root plan file (absorbed here), and a
  source-code hyperlink the docs site can't serve (now inline code)
- operations/operations.md linked three never-written guide pages as
  aspirational links - they stay plain text until pages exist

HANDS-OFF (project owner, 2026-11-02): docs/user/filters/** and
docs/user/searching-and-filtering.md are exempt as link sources so no
future checker failure can force edits to the filter documentation.
Files linking INTO them remain checked.
2026-11-02 08:21:47 -05:00

106 lines
3.1 KiB
Go

package docs
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// TestDocsRelativeLinks keeps every doc tree internally navigable: every
// relative markdown link must point at a file that exists. The api-reference
// overview once shipped 20+ directory-style links (authentication/ etc.)
// that 404'd — this gate makes that class of rot fail make test.
//
// Rules:
// - http(s):, mailto:, and pure #anchors are skipped
// - every other target must end in .md and resolve (relative to the
// linking file) to an existing file inside the docs tree
// - directory-style targets ("area/") are the exact bug this test exists
// for and always fail
//
// Hands-off sources (project owner instruction, 2026-11-02: DO NOT EDIT THE
// FILTER DOCS): docs/user/filters/** and docs/user/searching-and-filtering.md
// are not scanned as link sources, so no future gate failure can force an
// edit there. Files linking INTO them are still checked — resolution needs
// file existence only, never edits to their content.
var docsLinkRe = regexp.MustCompile(`\[[^\]]*\]\(([^)\s]+)\)`)
var handsOffFilterDocs = []string{
"user/filters/",
"user/searching-and-filtering.md",
}
func TestDocsRelativeLinks(t *testing.T) {
treeRoot, err := filepath.Abs("../../docs")
if err != nil {
t.Fatalf("abs docs root: %v", err)
}
err = filepath.WalkDir(treeRoot, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") {
return err
}
rel, err := filepath.Rel(treeRoot, path)
if err != nil {
return err
}
relSlash := filepath.ToSlash(rel)
for _, off := range handsOffFilterDocs {
if strings.HasPrefix(relSlash, off) {
return nil
}
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
absPath, err := filepath.Abs(path)
if err != nil {
return err
}
for _, m := range docsLinkRe.FindAllStringSubmatch(string(raw), -1) {
target := m[1]
if strings.HasPrefix(target, "http://") ||
strings.HasPrefix(target, "https://") ||
strings.HasPrefix(target, "mailto:") {
continue
}
if strings.HasPrefix(target, "#") {
continue // in-page anchor
}
// strip a trailing fragment for file resolution
fileTarget := target
if i := strings.Index(fileTarget, "#"); i >= 0 {
fileTarget = fileTarget[:i]
}
if fileTarget == "" {
continue // pure-anchor form already handled; empty = odd, skip
}
if !strings.HasSuffix(fileTarget, ".md") {
t.Errorf("%s: link target %q is not a .md file — directory-style links 404 on the docs site; link the file (e.g. %q)",
rel, target, strings.TrimSuffix(fileTarget, "/")+".md")
continue
}
resolved := filepath.Clean(filepath.Join(filepath.Dir(absPath), fileTarget))
if !strings.HasPrefix(resolved, treeRoot+string(filepath.Separator)) {
t.Errorf("%s: link target %q escapes the docs tree", rel, target)
continue
}
if _, err := os.Stat(resolved); err != nil {
t.Errorf("%s: link target %q does not resolve to an existing file (%s)", rel, target, resolved)
}
}
return nil
})
if err != nil {
t.Fatalf("walk docs: %v", err)
}
}