- 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>
136 lines
3.5 KiB
Go
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
|
|
}
|