Files
go/internal/docs/check.go
T
endandClaude Sonnet 5.5 694c43051e
ci / check (push) Successful in 59s
ci / check (pull_request) Successful in 58s
fix: harden docs checker heading lines, definitions and docs symlinks
- N1: never call Lines() on inline nodes; locate empty ATX headings after the
  end of all preceding blocks (no panic, correct line after `***`)
- N2: take reference definition lines from ast.LinkReferenceDefinition and
  drop refCtx/defRe
- N3: reject docs/adr and docs/plans that resolve outside the repository

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 15:03:26 -07:00

136 lines
3.5 KiB
Go

// Package docs creates and checks the ADR and plan records described in docs/docs-format.md.
package docs
import (
"bytes"
"embed"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"unicode/utf8"
)
//go:embed templates/*.md
var templates embed.FS
var dirs = []struct{ kind, dir string }{{"adr", "docs/adr"}, {"plan", "docs/plans"}}
// Problem is one violation of the format. Line is 1 for file-level problems.
type Problem struct {
Path string
Line int
Msg string
}
func (p Problem) String() string { return fmt.Sprintf("%s:%d: %s", p.Path, p.Line, p.Msg) }
// Result is the outcome of Check: the number of records read and the sorted problems.
type Result struct {
Records int
Problems []Problem
}
type record struct {
path, kind, stem, date string
status string
lists map[string][]item
fm *fm
}
type checker struct {
root, realRoot string
problems []Problem
}
func (c *checker) add(path string, line int, format string, args ...any) {
c.problems = append(c.problems, Problem{path, line, fmt.Sprintf(format, args...)})
}
// Check validates every record under root. The error is for I/O failures only.
func Check(root string) (Result, error) {
real, err := filepath.EvalSymlinks(root)
if err != nil {
return Result{}, err
}
c := &checker{root: root, realRoot: real}
var recs []*record
for _, d := range dirs {
if st, err := os.Lstat(filepath.Join(root, d.dir)); os.IsNotExist(err) {
continue
} else if err == nil && !st.IsDir() {
c.add(d.dir, 1, "must be a real directory (symlinks are not allowed)")
continue
} else if r, err := filepath.EvalSymlinks(filepath.Join(root, d.dir)); err == nil && !within(real, r) {
c.add(d.dir, 1, "must be inside the repository (a symlinked parent directory is not allowed)")
continue
}
entries, err := os.ReadDir(filepath.Join(root, d.dir))
if err != nil {
return Result{}, err
}
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, ".") || name == "README.md" {
continue
}
rel := d.dir + "/" + name
if !e.Type().IsRegular() {
c.add(rel, 1, "must be a regular file (subdirectories, symlinks and other entries are not allowed)")
continue
}
stem, at, msg := parseName(name)
if msg != "" {
c.add(rel, 1, "%s", msg)
continue
}
r := &record{path: rel, kind: d.kind, stem: stem, date: at.Format("2006-01-02"), lists: map[string][]item{}}
if err := c.record(r); err != nil {
return Result{}, err
}
recs = append(recs, r)
}
}
c.graph(recs)
slices.SortFunc(c.problems, func(a, b Problem) int {
return strings.Compare(fmt.Sprintf("%s\x00%09d\x00%s", a.Path, a.Line, a.Msg), fmt.Sprintf("%s\x00%09d\x00%s", b.Path, b.Line, b.Msg))
})
return Result{len(recs), c.problems}, nil
}
func (c *checker) record(r *record) error {
data, err := os.ReadFile(filepath.Join(c.root, filepath.FromSlash(r.path)))
if err != nil {
return err
}
if bytes.HasPrefix(data, []byte("\xef\xbb\xbf")) {
c.add(r.path, 1, "file must not start with a byte order mark")
return nil
}
if !utf8.Valid(data) {
c.add(r.path, 1, "file must be valid UTF-8")
return nil
}
src := strings.ReplaceAll(string(data), "\r\n", "\n")
front, body, bodyLine, msg := splitFrontmatter(src)
if msg != "" {
c.add(r.path, 1, "%s", msg)
return nil
}
m, ok := c.parseFrontmatter(r, front)
if !ok {
return nil
}
r.fm = m
var title string
var titleOK bool
if r.kind == "adr" {
title, titleOK = c.adr(r, m)
} else {
c.plan(r, m)
}
c.body(r, body, bodyLine, title, titleOK)
return nil
}