feat: add docs checker and record templates

docs new creates ADRs and plans from embedded templates; docs check reports
path:line: message problems per docs/docs-format.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
endandClaude Sonnet 5.5 committed 2026-10-23 14:46:56 -07:00
1 parent b0428256bd
commit 750b7449b0
15 files changed
+1856

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
// Command docs creates and checks ADR and plan records. See docs/docs-format.md.
package main
import (
"flag"
"fmt"
"io"
"os"
"time"
"go.auvem.com/corestack/internal/docs"
)
const usage = `usage:
docs new adr|plan "Title"
docs check [--root DIR]
`
func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, time.Now())) }
// run returns the exit status: 0 success, 1 problems or failure, 2 usage error.
func run(args []string, stdout, stderr io.Writer, now time.Time) int {
if len(args) == 0 {
fmt.Fprint(stderr, usage)
return 2
}
switch args[0] {
case "new":
if len(args) != 3 || (args[1] != "adr" && args[1] != "plan") {
fmt.Fprint(stderr, usage)
return 2
}
path, err := docs.New(".", args[1], args[2], now)
if err != nil {
fmt.Fprintf(stderr, "docs: %v\n", err)
return 1
}
fmt.Fprintln(stdout, path)
return 0
case "check":
fs := flag.NewFlagSet("docs check", flag.ContinueOnError)
fs.SetOutput(stderr)
root := fs.String("root", ".", "repository root")
if err := fs.Parse(args[1:]); err != nil || fs.NArg() != 0 {
if fs.NArg() != 0 {
fmt.Fprint(stderr, usage)
}
return 2
}
res, err := docs.Check(*root)
if err != nil {
fmt.Fprintf(stderr, "docs: %v\n", err)
return 1
}
for _, p := range res.Problems {
fmt.Fprintln(stdout, p)
}
if len(res.Problems) > 0 {
return 1
}
fmt.Fprintf(stdout, "ok: %d records checked\n", res.Records)
return 0
}
fmt.Fprint(stderr, usage)
return 2
}
+80
View File
@@ -0,0 +1,80 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
var now = time.Date(2026, 10, 23, 20, 45, 12, 345_000_000, time.UTC)
func do(t *testing.T, args ...string) (code int, stdout, stderr string) {
t.Helper()
var out, errw bytes.Buffer
code = run(args, &out, &errw, now)
return code, out.String(), errw.String()
}
func TestCLI(t *testing.T) {
root := t.TempDir()
t.Chdir(root)
if code, out, _ := do(t, "check"); code != 0 || out != "ok: 0 records checked\n" {
t.Fatalf("empty check: %d %q", code, out)
}
code, out, _ := do(t, "new", "adr", "Use X")
if code != 0 || out != "docs/adr/20261023T204512345Z-use-x.md\n" {
t.Fatalf("new: %d %q", code, out)
}
code, out, _ = do(t, "check")
var want strings.Builder
for _, line := range []string{"6", "8", "20", "24", "28", "32"} { // numeric, not lexical, order
want.WriteString("docs/adr/20261023T204512345Z-use-x.md:" + line + ": unfinished placeholder {{docs:todo}}\n")
}
if code != 1 || out != want.String() {
t.Fatalf("check with markers: %d %q", code, out)
}
// --root checks another directory; the working directory is not used.
t.Chdir(t.TempDir())
if code, _, _ := do(t, "check", "--root", root); code != 1 {
t.Errorf("--root: %d", code)
}
if code, out, _ := do(t, "check"); code != 0 || !strings.HasPrefix(out, "ok: ") {
t.Errorf("cwd root: %d %q", code, out)
}
}
func TestCLIExitCodes(t *testing.T) {
t.Chdir(t.TempDir())
must := func(err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
must(os.MkdirAll("docs/adr", 0o755))
must(os.WriteFile(filepath.Join("docs", "adr", "bad.md"), nil, 0o644))
for _, tt := range []struct {
args []string
code int
}{
{nil, 2},
{[]string{"bogus"}, 2},
{[]string{"new"}, 2},
{[]string{"new", "adr"}, 2},
{[]string{"new", "note", "T"}, 2},
{[]string{"new", "adr", "T", "extra"}, 2},
{[]string{"new", "adr", "!!!"}, 1},
{[]string{"check", "--bogus"}, 2},
{[]string{"check", "extra"}, 2},
{[]string{"check", "--root", "missing-dir"}, 1},
{[]string{"check"}, 1}, // bad.md is not a valid name
} {
if code, _, stderr := do(t, tt.args...); code != tt.code {
t.Errorf("docs %v = %d, want %d (stderr %q)", tt.args, code, tt.code, stderr)
}
}
}
+45
View File
@@ -0,0 +1,45 @@
package docs
import "time"
var (
adrStatuses = []string{"proposed", "accepted", "rejected", "deprecated", "superseded"}
adrImpls = []string{"not-started", "in-progress", "implemented", "deferred", "not-applicable"}
adrRefs = []refSpec{
{"supersedes", kAdr}, {"superseded_by", kAdr}, {"related", kAdr | kPlan},
{"tracking", kPlan | kURL}, {"implemented_by", kURL | kFile},
}
)
// adr checks the ADR frontmatter and returns its title for the body check.
func (c *checker) adr(r *record, m *fm) (string, bool) {
m.keys("title", "date", "status", "implementation", "summary", "tags", "supersedes", "superseded_by", "related", "tracking", "implemented_by")
title, titleOK := m.text("title")
if d, line, ok := m.scalar("date"); ok {
if _, err := time.Parse(time.DateOnly, d); err != nil || len(d) != len(time.DateOnly) {
c.add(r.path, line, "date must be a valid YYYY-MM-DD date")
} else if d != r.date {
c.add(r.path, line, "date %s does not match the filename date %s", d, r.date)
}
}
status, statusOK := m.enum("status", adrStatuses...)
impl, implOK := m.enum("implementation", adrImpls...)
m.text("summary")
m.tags()
m.refs(adrRefs...)
r.status = status
if statusOK {
if n := len(r.lists["superseded_by"]); status == "superseded" && n == 0 {
m.problemf("status", "status superseded requires superseded_by")
} else if status != "superseded" && n > 0 {
m.problemf("superseded_by", "superseded_by requires status superseded")
}
if status == "rejected" && implOK && impl != "not-applicable" {
m.problemf("implementation", "status rejected requires implementation not-applicable")
}
}
if implOK && impl == "implemented" && len(r.lists["implemented_by"]) == 0 {
m.problemf("implementation", "implementation implemented requires implemented_by")
}
return title, titleOK
}
+285
View File
@@ -0,0 +1,285 @@
package docs
import (
"bytes"
"regexp"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
)
var (
adrSections = []string{"Context and Problem Statement", "Considered Options", "Decision Outcome"}
planSections = []string{"Scope", "Steps", "Validation"}
)
type heading struct {
level int
text string
line int
at int // index among the document's top-level nodes
}
type doc struct {
c *checker
r *record
src []byte
startLine int
kids []ast.Node
heads []heading
}
func (d *doc) lineAt(off int) int {
return d.startLine + bytes.Count(d.src[:min(off, len(d.src))], []byte("\n"))
}
// lineOf is the line of the first text under n, or of the nearest block that has lines.
func (d *doc) lineOf(n ast.Node) int {
var off = -1
_ = ast.Walk(n, func(c ast.Node, enter bool) (ast.WalkStatus, error) {
if t, ok := c.(*ast.Text); ok && enter {
off = t.Segment.Start
return ast.WalkStop, nil
}
return ast.WalkContinue, nil
})
for p := n; off < 0 && p != nil; p = p.Parent() {
if p.Type() == ast.TypeBlock && p.Lines().Len() > 0 {
off = p.Lines().At(0).Start
}
}
if off < 0 {
return d.startLine
}
return d.lineAt(off)
}
func (c *checker) body(r *record, src string, startLine int, title string, titleOK bool) {
b := []byte(src)
ctx := parser.NewContext()
root := goldmark.DefaultParser().Parse(text.NewReader(b), parser.WithContext(ctx))
d := &doc{c: c, r: r, src: b, startLine: startLine}
for n := root.FirstChild(); n != nil; n = n.NextSibling() {
if h, ok := n.(*ast.Heading); ok {
line := d.startLine
if h.Lines().Len() > 0 {
line = d.lineAt(h.Lines().At(0).Start)
}
d.heads = append(d.heads, heading{h.Level, strings.TrimSpace(string(h.Lines().Value(b))), line, len(d.kids)})
}
d.kids = append(d.kids, n)
}
d.placeholders(root)
d.links(root, ctx)
d.title(title, titleOK)
if r.kind == "adr" {
for _, name := range adrSections {
d.section(name)
}
d.decisionOutcome()
} else {
for _, name := range planSections {
d.section(name)
}
}
}
// title checks the single leading H1.
func (d *doc) title(title string, titleOK bool) {
h1s := 0
for _, h := range d.heads {
if h.level == 1 {
if h1s++; h1s > 1 {
d.c.add(d.r.path, h.line, "more than one H1")
}
}
}
switch first := d.heads; {
case len(first) == 0 || first[0].level != 1:
d.c.add(d.r.path, d.startLine, "the first heading must be an H1")
case d.r.kind == "plan" && first[0].text == "":
d.c.add(d.r.path, first[0].line, "the H1 (plan title) must not be empty")
case d.r.kind == "adr" && titleOK && first[0].text != title:
d.c.add(d.r.path, first[0].line, "H1 %q must equal title %q", first[0].text, title)
}
}
// extent returns the top-level nodes after heading h up to the next heading of level <= upTo.
func (d *doc) extent(h heading, upTo int) []ast.Node {
end := len(d.kids)
for _, o := range d.heads {
if o.at > h.at && o.level <= upTo {
end = o.at
break
}
}
return d.kids[h.at+1 : end]
}
// section finds the one H2 called name and checks that it has prose.
func (d *doc) section(name string) (heading, bool) {
var found []heading
for _, h := range d.heads {
if h.level == 2 && h.text == name {
found = append(found, h)
}
}
switch {
case len(found) == 0:
d.c.add(d.r.path, 1, "missing required section %q", name)
case len(found) > 1:
d.c.add(d.r.path, found[1].line, "section %q appears more than once", name)
case !d.hasProse(d.extent(found[0], 2)):
d.c.add(d.r.path, found[0].line, "section %q has no prose", name)
return found[0], true
default:
return found[0], true
}
return heading{}, false
}
func (d *doc) decisionOutcome() {
for _, h := range d.heads {
if h.level == 2 && h.text == "Consequences" {
d.c.add(d.r.path, h.line, "Consequences must be a ### subsection of Decision Outcome, not a ## section")
}
}
var outcome []heading
for _, h := range d.heads {
if h.level == 2 && h.text == "Decision Outcome" {
outcome = append(outcome, h)
}
}
if len(outcome) != 1 {
return
}
h := outcome[0]
nodes := d.extent(h, 2)
// Prose before the first subsection is the decision itself.
pre := nodes
for i, n := range nodes {
if _, ok := n.(*ast.Heading); ok {
pre = nodes[:i]
break
}
}
if !d.hasProse(pre) {
d.c.add(d.r.path, h.line, "Decision Outcome needs prose before its first subsection")
}
var cons []heading
for _, s := range d.heads {
if s.level == 3 && s.text == "Consequences" && s.at > h.at && s.at < h.at+len(nodes)+1 {
cons = append(cons, s)
}
}
switch {
case len(cons) == 0:
d.c.add(d.r.path, h.line, "Decision Outcome must contain a ### Consequences subsection")
case len(cons) > 1:
d.c.add(d.r.path, cons[1].line, "### Consequences appears more than once")
case !d.hasProse(d.extent(cons[0], 3)):
d.c.add(d.r.path, cons[0].line, "### Consequences has no prose")
}
}
// hasProse reports whether nodes hold a paragraph with text outside code, images and raw HTML.
func (d *doc) hasProse(nodes []ast.Node) bool {
for _, n := range nodes {
found := false
_ = ast.Walk(n, func(c ast.Node, enter bool) (ast.WalkStatus, error) {
switch c.(type) {
case *ast.Paragraph, *ast.TextBlock:
if enter && strings.TrimSpace(d.inlineText(c)) != "" {
found = true
return ast.WalkStop, nil
}
return ast.WalkSkipChildren, nil
}
return ast.WalkContinue, nil
})
if found {
return true
}
}
return false
}
func (d *doc) inlineText(n ast.Node) string {
var s strings.Builder
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
switch t := c.(type) {
case *ast.Text:
s.Write(t.Segment.Value(d.src))
case *ast.String:
s.Write(t.Value)
case *ast.CodeSpan, *ast.RawHTML, *ast.Image:
default:
s.WriteString(d.inlineText(c))
}
}
return s.String()
}
// placeholders reports every marker outside code.
func (d *doc) placeholders(root ast.Node) {
var code [][2]int
_ = ast.Walk(root, func(n ast.Node, enter bool) (ast.WalkStatus, error) {
switch n.(type) {
case *ast.FencedCodeBlock, *ast.CodeBlock:
for i := range n.Lines().Len() {
seg := n.Lines().At(i)
code = append(code, [2]int{seg.Start, seg.Stop})
}
case *ast.CodeSpan:
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
if t, ok := c.(*ast.Text); ok {
code = append(code, [2]int{t.Segment.Start, t.Segment.Stop})
}
}
}
return ast.WalkContinue, nil
})
for off := 0; ; {
i := strings.Index(string(d.src[off:]), marker)
if i < 0 {
return
}
at := off + i
off = at + len(marker)
inCode := false
for _, r := range code {
inCode = inCode || at >= r[0] && at < r[1]
}
if !inCode {
d.c.add(d.r.path, d.lineAt(at), "unfinished placeholder %s", marker)
}
}
}
// links checks every link, image and autolink destination and every reference definition.
func (d *doc) links(root ast.Node, ctx parser.Context) {
_ = ast.Walk(root, func(n ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
return ast.WalkContinue, nil
}
switch t := n.(type) {
case *ast.Link:
d.c.checkLink(d.r, d.lineOf(n), string(t.Destination))
case *ast.Image:
d.c.checkLink(d.r, d.lineOf(n), string(t.Destination))
case *ast.AutoLink:
d.c.checkLink(d.r, d.lineOf(n), string(t.URL(d.src)))
}
return ast.WalkContinue, nil
})
for _, ref := range ctx.References() {
line := d.startLine
if m := regexp.MustCompile(`(?im)^ {0,3}\[` + regexp.QuoteMeta(string(ref.Label())) + `\]:`).FindIndex(d.src); m != nil {
line = d.lineAt(m[0])
}
d.c.checkLink(d.r, line, string(ref.Destination()))
}
}
+128
View File
@@ -0,0 +1,128 @@
// 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 {
entries, err := os.ReadDir(filepath.Join(root, d.dir))
if os.IsNotExist(err) {
continue
} else 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
}
+354
View File
@@ -0,0 +1,354 @@
package docs
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
const (
aID = "20261023T204512345Z-use-queue"
aPath = "docs/adr/" + aID + ".md"
bPath = "docs/adr/20261024T000000000Z-second.md"
pPath = "docs/plans/20261023T210000000Z-adopt-queue.md"
// Line numbers in the expectations below refer to these documents.
adrBase = `---
title: Use Queue
date: 2026-10-23
status: proposed
implementation: not-started
summary: Run jobs on a queue.
tags:
- jobs
supersedes: []
superseded_by: []
related: []
tracking: []
implemented_by: []
---
# Use Queue
## Context and Problem Statement
Jobs block requests.
## Considered Options
* Queue
* Inline
## Decision Outcome
Chosen option: Queue.
### Consequences
* One more service.
`
// bBase is a second ADR (dated 2026-10-24) that may supersede or be superseded.
bBase = `---
title: Second
date: 2026-10-24
status: accepted
implementation: not-started
summary: Second.
tags:
- jobs
supersedes: []
superseded_by: []
related: []
tracking: []
implemented_by: []
---
# Second
## Context and Problem Statement
Why.
## Considered Options
* A
## Decision Outcome
Chosen option: A.
### Consequences
* Some.
`
planBase = `---
status: draft
tracking: []
adrs: []
completion: []
---
# Adopt Queue
## Scope
Move the job.
## Steps
1. Do it.
## Validation
It runs.
`
)
// sub replaces old with new in s once; a stale expectation fails loudly.
func sub(s string, pairs ...string) string {
for i := 0; i < len(pairs); i += 2 {
if !strings.Contains(s, pairs[i]) {
panic("fixture does not contain " + pairs[i])
}
s = strings.Replace(s, pairs[i], pairs[i+1], 1)
}
return s
}
func tree(t *testing.T, files, links map[string]string) string {
t.Helper()
root, outside := t.TempDir(), t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "f.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
for p, content := range files {
full := filepath.Join(root, p)
if strings.HasSuffix(p, "/") {
must(t, os.MkdirAll(full, 0o755))
continue
}
must(t, os.MkdirAll(filepath.Dir(full), 0o755))
must(t, os.WriteFile(full, []byte(content), 0o644))
}
for p, target := range links {
must(t, os.MkdirAll(filepath.Dir(filepath.Join(root, p)), 0o755))
must(t, os.Symlink(strings.Replace(target, "@outside", outside, 1), filepath.Join(root, p)))
}
return root
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
var wantRe = regexp.MustCompile(`^(.*?:\d+): (.*)$`)
// expect requires got and want to pair up one to one. A want is "path:line: message substring"; the path may be a suffix.
func expect(t *testing.T, got []Problem, want []string) {
t.Helper()
left := append([]Problem(nil), got...)
for _, w := range want {
m := wantRe.FindStringSubmatch(w)
found := false
for i, p := range left {
if strings.HasSuffix(fmt.Sprintf("%s:%d", p.Path, p.Line), m[1]) && strings.Contains(p.Msg, m[2]) {
left = append(left[:i], left[i+1:]...)
found = true
break
}
}
if !found {
t.Errorf("missing problem %q in %v", w, got)
}
}
for _, p := range left {
t.Errorf("unexpected problem %s", p)
}
}
var support = map[string]string{
bPath: bBase,
pPath: planBase,
"docs/spec.txt": "spec",
"docs/sp ace.md": "space",
}
// withSupport adds the shared targets to a case's files.
func withSupport(files map[string]string) map[string]string {
all := map[string]string{}
for k, v := range support {
all[k] = v
}
for k, v := range files {
all[k] = v
}
return all
}
func TestCheck(t *testing.T) {
long := strings.Repeat("a", 81)
tests := []struct {
name string
files map[string]string
links map[string]string
want []string
}{
// 1. Files and directories
{name: "dot entries and README are skipped", files: map[string]string{"docs/adr/.DS_Store": "x", "docs/adr/README.md": "x", "docs/plans/README.md": "x"}},
{name: "subdirectory", files: map[string]string{"docs/adr/sub/": ""}, want: []string{"docs/adr/sub:1: regular file"}},
{name: "non-md file", files: map[string]string{"docs/plans/notes.txt": "x"}, want: []string{"docs/plans/notes.txt:1: name must be"}},
{name: "template-like name", files: map[string]string{"docs/adr/template.md": "x"}, want: []string{"docs/adr/template.md:1: name must be"}},
{name: "symlinked record", files: map[string]string{"docs/spec.txt": "x"}, links: map[string]string{"docs/adr/20261023T204512345Z-s.md": "@outside/f.md"}, want: []string{"20261023T204512345Z-s.md:1: regular file"}},
{name: "CRLF accepted", files: map[string]string{aPath: strings.ReplaceAll(adrBase, "\n", "\r\n")}},
{name: "BOM", files: map[string]string{aPath: "\xef\xbb\xbf" + adrBase}, want: []string{aPath + ":1: byte order mark"}},
{name: "invalid UTF-8", files: map[string]string{aPath: adrBase + "\xff\n"}, want: []string{aPath + ":1: UTF-8"}},
// 2. Names and IDs
{name: "invalid timestamp", files: map[string]string{"docs/adr/20261323T204512345Z-x.md": adrBase}, want: []string{"20261323T204512345Z-x.md:1: invalid UTC timestamp"}},
{name: "slug double hyphen", files: map[string]string{"docs/adr/20261023T204512345Z-a--b.md": adrBase}, want: []string{"a--b.md:1: slug must be"}},
{name: "slug uppercase", files: map[string]string{"docs/adr/20261023T204512345Z-Ab.md": adrBase}, want: []string{"Ab.md:1: slug must be"}},
{name: "slug trailing hyphen", files: map[string]string{"docs/adr/20261023T204512345Z-ab-.md": adrBase}, want: []string{"ab-.md:1: slug must be"}},
{name: "slug 80 characters", files: map[string]string{"docs/adr/20261023T204512345Z-" + long[:80] + ".md": adrBase}},
{name: "slug 81 characters", files: map[string]string{"docs/adr/20261023T204512345Z-" + long + ".md": adrBase}, want: []string{".md:1: longer than 80"}},
{name: "duplicate ID across directories", files: map[string]string{"docs/adr/20261023T210000000Z-adopt-queue.md": adrBase, pPath: planBase},
want: []string{"docs/adr/20261023T210000000Z-adopt-queue.md:1: duplicate ID", pPath + ":1: duplicate ID"}},
// 3. Frontmatter
{name: "no frontmatter", files: map[string]string{aPath: "# Use Queue\n"}, want: []string{aPath + ":1: --- frontmatter"}},
{name: "unterminated frontmatter", files: map[string]string{aPath: "---\ntitle: x\n"}, want: []string{aPath + ":1: not closed"}},
{name: "empty frontmatter", files: map[string]string{aPath: "---\n---\n# x\n"}, want: []string{aPath + ":2: empty"}},
{name: "frontmatter is a list", files: map[string]string{aPath: "---\n- a\n---\n"}, want: []string{aPath + ":2: mapping"}},
{name: "invalid YAML", files: map[string]string{aPath: sub(adrBase, "title: Use Queue", "title: [")}, want: []string{aPath + ":3: invalid YAML"}},
{name: "multiple YAML documents", files: map[string]string{aPath: sub(adrBase, "title: Use Queue\n", "title: Use Queue\n...\nx: y\n")}, want: []string{aPath + ":2: single YAML document"}},
{name: "anchor and alias", files: map[string]string{aPath: sub(adrBase, "supersedes: []\nsuperseded_by: []", "supersedes: &a []\nsuperseded_by: *a")}, want: []string{aPath + ":9: anchors", aPath + ":10: aliases"}},
{name: "explicit tag", files: map[string]string{aPath: sub(adrBase, "summary: Run", "summary: !!str Run")}, want: []string{aPath + ":6: explicit"}},
{name: "unknown key", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by: []\nextra: x")}, want: []string{aPath + ":14: unknown key"}},
{name: "missing key", files: map[string]string{aPath: sub(adrBase, "related: []\n", "")}, want: []string{aPath + ":1: missing key \"related\""}},
{name: "duplicate key", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by: []\nsummary: again")}, want: []string{aPath + ":14: duplicate key"}},
{name: "null tilde", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: ~")}, want: []string{aPath + ":6: must not be null"}},
{name: "null empty", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary:")}, want: []string{aPath + ":6: must not be null"}},
{name: "null word", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: Null")}, want: []string{aPath + ":6: must not be null"}},
{name: "scalars are literal text", files: map[string]string{aPath: sub(adrBase, "title: Use Queue", "title: 2026", "# Use Queue", "# 2026", "date: 2026-10-23", `date: "2026-10-23"`, "summary: Run jobs on a queue.", `summary: "null"`)}},
{name: "list item is null", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - ~")}, want: []string{aPath + ":12: must not be null"}},
// 3.1 ADR keys
{name: "empty title", files: map[string]string{aPath: sub(adrBase, "title: Use Queue", `title: ""`)}, want: []string{aPath + ":2: title must be a non-empty single line"}},
{name: "multi-line summary", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: |\n a\n b")}, want: []string{aPath + ":6: summary must be a non-empty single line"}},
{name: "title is a list", files: map[string]string{aPath: sub(adrBase, "title: Use Queue", "title: [a]")}, want: []string{aPath + ":2: title must be a string"}},
{name: "invalid date", files: map[string]string{aPath: sub(adrBase, "date: 2026-10-23", "date: 2026-13-40")}, want: []string{aPath + ":3: valid YYYY-MM-DD"}},
{name: "date differs from filename", files: map[string]string{aPath: sub(adrBase, "date: 2026-10-23", "date: 2026-10-24")}, want: []string{aPath + ":3: does not match"}},
{name: "invalid status", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: done")}, want: []string{aPath + ":4: status must be one of"}},
{name: "invalid implementation", files: map[string]string{aPath: sub(adrBase, "implementation: not-started", "implementation: done")}, want: []string{aPath + ":5: implementation must be one of"}},
{name: "empty tags", files: map[string]string{aPath: sub(adrBase, "tags:\n - jobs", "tags: []")}, want: []string{aPath + ":7: tags must not be empty"}},
{name: "tags is a string", files: map[string]string{aPath: sub(adrBase, "tags:\n - jobs", "tags: jobs")}, want: []string{aPath + ":7: tags must be a list"}},
{name: "bad tag", files: map[string]string{aPath: sub(adrBase, " - jobs", " - Bad_Tag")}, want: []string{aPath + ":8: tag"}},
{name: "duplicate tag", files: map[string]string{aPath: sub(adrBase, " - jobs", " - jobs\n - jobs")}, want: []string{aPath + ":9: duplicate tag"}},
// 3.2 Plan keys
{name: "plan has unknown title key", files: map[string]string{pPath: sub(planBase, "status: draft", "status: draft\ntitle: x")}, want: []string{pPath + ":3: unknown key"}},
{name: "plan invalid status", files: map[string]string{pPath: sub(planBase, "status: draft", "status: done")}, want: []string{pPath + ":2: status must be one of"}},
// 3.3 References
{name: "related accepts ADR and plan", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - /"+bPath+"\n - /"+pPath)}},
{name: "ADR tracking accepts URL and plan", files: map[string]string{aPath: sub(adrBase, "tracking: []", "tracking:\n - https://example.com/issues/1\n - /"+pPath)}},
{name: "ADR tracking rejects ADR path", files: map[string]string{aPath: sub(adrBase, "tracking: []", "tracking:\n - /"+bPath)}, want: []string{aPath + ":13: tracking"}},
{name: "plan tracking rejects path", files: map[string]string{pPath: sub(planBase, "tracking: []", "tracking:\n - /"+aPath)}, want: []string{pPath + ":4: tracking"}},
{name: "plan tracking accepts URL", files: map[string]string{pPath: sub(planBase, "tracking: []", "tracking:\n - https://example.com/issues/1")}},
{name: "plan adrs accepts ADR", files: map[string]string{aPath: adrBase, pPath: sub(planBase, "adrs: []", "adrs:\n - /"+aPath)}},
{name: "plan adrs rejects plan", files: map[string]string{pPath: sub(planBase, "adrs: []", "adrs:\n - /"+pPath)}, want: []string{pPath + ":5: adrs"}},
{name: "implemented_by accepts URL and file", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - https://example.com/pr/1\n - /docs/spec.txt")}},
{name: "implemented_by missing file", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - /docs/missing.txt")}, want: []string{aPath + ":14: does not exist"}},
{name: "reference to missing ADR", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - /docs/adr/20261023T000000000Z-gone.md")}, want: []string{aPath + ":12: does not exist"}},
{name: "duplicate reference", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - /"+bPath+"\n - /"+bPath)}, want: []string{aPath + ":13: duplicate reference"}},
{name: "self reference", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - /"+aPath)}, want: []string{aPath + ":12: itself"}},
{name: "bare issue number", files: map[string]string{aPath: sub(adrBase, "tracking: []", "tracking:\n - \"#123\"")}, want: []string{aPath + ":13: not durable"}},
{name: "owner repo issue number", files: map[string]string{aPath: sub(adrBase, "tracking: []", "tracking:\n - example/repo#123")}, want: []string{aPath + ":13: not durable"}},
{name: "this PR placeholder", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - This PR")}, want: []string{aPath + ":14: not durable"}},
{name: "fragment in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - /docs/spec.txt#x")}, want: []string{aPath + ":14: fragments"}},
{name: "credentials in frontmatter URL", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - https://u:p@example.com/x")}, want: []string{aPath + ":14: credentials"}},
{name: "relative path in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - ../docs/spec.txt")}, want: []string{aPath + ":14: relative"}},
// 4. Status
{name: "superseded needs superseded_by", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: superseded")}, want: []string{aPath + ":4: requires superseded_by"}},
{name: "superseded_by needs superseded", files: map[string]string{aPath: sub(adrBase, "superseded_by: []", "superseded_by:\n - /"+bPath), bPath: sub(bBase, "supersedes: []", "supersedes:\n - /"+aPath)}, want: []string{aPath + ":10: requires status superseded"}},
{name: "rejected needs not-applicable", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: rejected")}, want: []string{aPath + ":5: not-applicable"}},
{name: "rejected not-applicable", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: rejected", "implementation: not-started", "implementation: not-applicable")}},
{name: "implemented needs implemented_by", files: map[string]string{aPath: sub(adrBase, "implementation: not-started", "implementation: implemented")}, want: []string{aPath + ":5: requires implemented_by"}},
{name: "implemented with implemented_by", files: map[string]string{aPath: sub(adrBase, "implementation: not-started", "implementation: implemented", "implemented_by: []", "implemented_by:\n - https://example.com/pr/1")}},
{name: "active plan needs tracking", files: map[string]string{pPath: sub(planBase, "status: draft", "status: active")}, want: []string{pPath + ":2: requires tracking"}},
{name: "active plan valid", files: map[string]string{pPath: sub(planBase, "status: draft", "status: active", "tracking: []", "tracking:\n - https://example.com/issues/1")}},
{name: "active plan with completion", files: map[string]string{pPath: sub(planBase, "status: draft", "status: active", "tracking: []", "tracking:\n - https://example.com/issues/1", "completion: []", "completion:\n - https://example.com/pr/1")}, want: []string{pPath + ":6: completion must be empty"}},
{name: "draft plan with completion", files: map[string]string{pPath: sub(planBase, "completion: []", "completion:\n - https://example.com/pr/1")}, want: []string{pPath + ":5: completion must be empty"}},
{name: "completed plan needs completion", files: map[string]string{pPath: sub(planBase, "status: draft", "status: completed")}, want: []string{pPath + ":2: requires completion"}},
{name: "cancelled plan needs completion", files: map[string]string{pPath: sub(planBase, "status: draft", "status: cancelled")}, want: []string{pPath + ":2: requires completion"}},
{name: "completed plan valid", files: map[string]string{pPath: sub(planBase, "status: draft", "status: completed", "completion: []", "completion:\n - https://example.com/pr/1")}},
// 5. Supersession
{name: "supersession valid", files: map[string]string{
aPath: sub(adrBase, "status: proposed", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+bPath),
bPath: sub(bBase, "supersedes: []", "supersedes:\n - /"+aPath)}},
{name: "supersedes not reciprocated", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: accepted", "supersedes: []", "supersedes:\n - /"+bPath)}, want: []string{aPath + ":10: must list this record in superseded_by"}},
{name: "superseded_by not reciprocated", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+bPath)}, want: []string{aPath + ":11: must list this record in supersedes"}},
{name: "superseder is proposed", files: map[string]string{
aPath: sub(adrBase, "supersedes: []", "supersedes:\n - /"+bPath),
bPath: sub(bBase, "status: accepted", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+aPath)}, want: []string{aPath + ":9: must be accepted"}},
{name: "superseder is rejected", files: map[string]string{
aPath: sub(adrBase, "status: proposed", "status: rejected", "implementation: not-started", "implementation: not-applicable", "supersedes: []", "supersedes:\n - /"+bPath),
bPath: sub(bBase, "status: accepted", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+aPath)}, want: []string{aPath + ":9: must be accepted"}},
{name: "supersession cycle", files: map[string]string{
aPath: sub(adrBase, "status: proposed", "status: superseded", "supersedes: []", "supersedes:\n - /"+bPath, "superseded_by: []", "superseded_by:\n - /"+bPath),
bPath: sub(bBase, "status: accepted", "status: superseded", "supersedes: []", "supersedes:\n - /"+aPath, "superseded_by: []", "superseded_by:\n - /"+aPath)}, want: []string{bPath + ":10: cycle"}},
// 6. Body
{name: "no H1", files: map[string]string{aPath: sub(adrBase, "# Use Queue\n\n", "")}, want: []string{aPath + ":15: first heading must be an H1"}},
{name: "H1 differs from title", files: map[string]string{aPath: sub(adrBase, "# Use Queue", "# Other")}, want: []string{aPath + ":16: must equal title"}},
{name: "two H1s", files: map[string]string{aPath: adrBase + "\n# Again\n"}, want: []string{aPath + ":35: more than one H1"}},
{name: "missing required section", files: map[string]string{aPath: sub(adrBase, "## Considered Options\n\n* Queue\n* Inline\n\n", "")}, want: []string{aPath + ":1: missing required section \"Considered Options\""}},
{name: "repeated section", files: map[string]string{aPath: adrBase + "\n## Considered Options\n\nMore.\n"}, want: []string{aPath + ":35: appears more than once"}},
{name: "heading in code does not count", files: map[string]string{aPath: sub(adrBase, "## Considered Options", "```\n## Considered Options\n```")}, want: []string{aPath + ":1: missing required section \"Considered Options\""}},
{name: "section with only a comment", files: map[string]string{aPath: sub(adrBase, "* Queue\n* Inline", "<!-- none -->")}, want: []string{aPath + ":22: has no prose"}},
{name: "section with only code", files: map[string]string{aPath: sub(adrBase, "* Queue\n* Inline", "```\nQueue\n```")}, want: []string{aPath + ":22: has no prose"}},
{name: "section with only inline code", files: map[string]string{aPath: sub(adrBase, "* Queue\n* Inline", "`Queue`")}, want: []string{aPath + ":22: has no prose"}},
{name: "subsection prose counts for the section", files: map[string]string{aPath: sub(adrBase, "Jobs block requests.", "### Detail\n\nJobs block requests.")}},
{name: "setext, closing hashes and blockquote", files: map[string]string{aPath: sub(adrBase, "## Context and Problem Statement", "## Context and Problem Statement ##", "## Considered Options\n\n* Queue\n* Inline", "Considered Options\n------\n\n> Queue or inline.")}},
{name: "extra sections allowed", files: map[string]string{aPath: adrBase + "\n## More Information\n\nSee above.\n"}},
{name: "Consequences as H2", files: map[string]string{aPath: sub(adrBase, "### Consequences", "## Consequences")},
want: []string{aPath + ":31: not a ## section", aPath + ":27: must contain a ### Consequences"}},
{name: "missing Consequences", files: map[string]string{aPath: sub(adrBase, "### Consequences\n\n* One more service.\n", "")}, want: []string{aPath + ":27: must contain a ### Consequences"}},
{name: "repeated Consequences", files: map[string]string{aPath: adrBase + "\n### Consequences\n\nAgain.\n"}, want: []string{aPath + ":35: appears more than once"}},
{name: "Consequences without prose", files: map[string]string{aPath: sub(adrBase, "* One more service.", "<!-- later -->")}, want: []string{aPath + ":31: has no prose"}},
{name: "Decision Outcome without own prose", files: map[string]string{aPath: sub(adrBase, "Chosen option: Queue.\n\n", "")}, want: []string{aPath + ":27: needs prose before"}},
{name: "plan missing section", files: map[string]string{pPath: sub(planBase, "## Steps\n\n1. Do it.\n\n", "")}, want: []string{pPath + ":1: missing required section \"Steps\""}},
{name: "plan empty H1", files: map[string]string{pPath: sub(planBase, "# Adopt Queue", "#")}, want: []string{pPath + ":7: H1"}},
{name: "plan Outcome is optional", files: map[string]string{pPath: planBase + "\n## Outcome\n\nDone.\n"}},
// 6.3 Placeholders
{name: "marker in body", files: map[string]string{aPath: sub(adrBase, "Jobs block requests.", "{{docs:todo}}")}, want: []string{aPath + ":20: unfinished placeholder"}},
{name: "marker in comment", files: map[string]string{aPath: adrBase + "\n<!-- {{docs:todo}} -->\n"}, want: []string{aPath + ":35: unfinished placeholder"}},
{name: "marker in code", files: map[string]string{aPath: adrBase + "\nUse `{{docs:todo}}` as the marker.\n\n```\n{{docs:todo}}\n```\n"}},
{name: "TODO and TBD are fine", files: map[string]string{aPath: adrBase + "\nTODO and TBD.\n"}},
{name: "marker in frontmatter string", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", `summary: "{{docs:todo}}"`)}, want: []string{aPath + ":6: unfinished placeholder"}},
{name: "marker as tag reports only the marker", files: map[string]string{aPath: sub(adrBase, " - jobs", ` - "{{docs:todo}}"`)}, want: []string{aPath + ":8: unfinished placeholder"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res, err := Check(tree(t, withSupport(tt.files), tt.links))
must(t, err)
expect(t, res.Problems, tt.want)
})
}
}
func TestCheckEmptyRoot(t *testing.T) {
res, err := Check(t.TempDir())
must(t, err)
if res.Records != 0 || len(res.Problems) != 0 {
t.Fatalf("got %+v", res)
}
}
+229
View File
@@ -0,0 +1,229 @@
package docs
import (
"errors"
"io"
"regexp"
"slices"
"strconv"
"strings"
"go.yaml.in/yaml/v3"
)
const marker = "{{docs:todo}}"
var yamlLineRe = regexp.MustCompile(`line (\d+)`)
type field struct{ key, val *yaml.Node }
type item struct {
v string
line int
}
// fm reads the typed fields of one record's frontmatter mapping.
type fm struct {
c *checker
r *record
f map[string]field
}
// splitFrontmatter returns the frontmatter text, the body and the file line the body starts on.
func splitFrontmatter(src string) (front, body string, bodyLine int, msg string) {
lines := strings.Split(src, "\n")
if lines[0] != "---" {
return "", "", 0, "file must start with a --- frontmatter line"
}
for i := 1; i < len(lines); i++ {
if lines[i] == "---" {
return strings.Join(lines[1:i], "\n"), strings.Join(lines[i+1:], "\n"), i + 2, ""
}
}
return "", "", 0, "frontmatter is not closed by a --- line"
}
// parseFrontmatter decodes the mapping and rejects anything but plain keys and values.
func (c *checker) parseFrontmatter(r *record, front string) (*fm, bool) {
report := func(line int, format string, args ...any) { c.add(r.path, line, format, args...) }
dec := yaml.NewDecoder(strings.NewReader(front))
var doc, extra yaml.Node
if err := dec.Decode(&doc); err != nil || len(doc.Content) == 0 {
if err == nil || errors.Is(err, io.EOF) {
report(2, "frontmatter is empty")
} else {
line := 2
if m := yamlLineRe.FindStringSubmatch(err.Error()); m != nil {
n, _ := strconv.Atoi(m[1])
line += n - 1
}
report(line, "invalid YAML: %v", strings.TrimPrefix(err.Error(), "yaml: "))
}
return nil, false
}
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
report(2, "frontmatter must be a single YAML document")
return nil, false
}
root := doc.Content[0]
if root.Kind != yaml.MappingNode {
report(root.Line+1, "frontmatter must be a mapping")
return nil, false
}
ok := true
var walk func(n *yaml.Node)
walk = func(n *yaml.Node) {
switch {
case n.Kind == yaml.AliasNode:
report(n.Line+1, "YAML aliases are not allowed")
ok = false
case n.Anchor != "":
report(n.Line+1, "YAML anchors are not allowed")
ok = false
case n.Style&yaml.TaggedStyle != 0:
report(n.Line+1, "explicit YAML tags are not allowed")
ok = false
}
for _, ch := range n.Content {
walk(ch)
}
}
walk(root)
m := &fm{c: c, r: r, f: map[string]field{}}
for i := 0; i+1 < len(root.Content); i += 2 {
k, v := root.Content[i], root.Content[i+1]
if k.Kind != yaml.ScalarNode {
report(k.Line+1, "keys must be plain strings")
ok = false
} else if _, dup := m.f[k.Value]; dup {
report(k.Line+1, "duplicate key %q", k.Value)
ok = false
} else {
m.f[k.Value] = field{k, v}
}
markers(v, report)
}
return m, ok
}
// markers reports an unfinished placeholder in any string value or list item.
func markers(n *yaml.Node, report func(int, string, ...any)) {
if n.Kind == yaml.ScalarNode && strings.Contains(n.Value, marker) {
report(n.Line+1, "unfinished placeholder %s", marker)
}
for _, ch := range n.Content {
markers(ch, report)
}
}
// keys reports missing and unknown keys.
func (m *fm) keys(want ...string) {
for _, k := range want {
if _, ok := m.f[k]; !ok {
m.c.add(m.r.path, 1, "missing key %q", k)
}
}
for k, f := range m.f {
if !slices.Contains(want, k) {
m.c.add(m.r.path, f.key.Line+1, "unknown key %q", k)
}
}
}
func (m *fm) line(key string) int {
if f, ok := m.f[key]; ok {
return f.key.Line + 1
}
return 1
}
func (m *fm) isNull(key string, n *yaml.Node) bool {
if n.Kind == yaml.ScalarNode && n.Tag == "!!null" {
m.c.add(m.r.path, n.Line+1, "%s must not be null", key)
return true
}
return false
}
// scalar returns the literal text of a non-null scalar value.
func (m *fm) scalar(key string) (string, int, bool) {
f, ok := m.f[key]
if !ok || m.isNull(key, f.val) {
return "", 0, false
}
if f.val.Kind != yaml.ScalarNode {
m.c.add(m.r.path, f.key.Line+1, "%s must be a string", key)
return "", 0, false
}
return f.val.Value, f.val.Line + 1, true
}
// text is a non-empty single-line string.
func (m *fm) text(key string) (string, bool) {
v, line, ok := m.scalar(key)
if ok && (strings.TrimSpace(v) == "" || strings.ContainsAny(v, "\r\n")) {
m.c.add(m.r.path, line, "%s must be a non-empty single line", key)
return v, false
}
return v, ok
}
func (m *fm) enum(key string, allowed ...string) (string, bool) {
v, line, ok := m.scalar(key)
if ok && !slices.Contains(allowed, v) {
m.c.add(m.r.path, line, "%s must be one of %s", key, strings.Join(allowed, ", "))
return v, false
}
return v, ok
}
// list returns the items of a sequence of non-null scalars.
func (m *fm) list(key string) ([]item, bool) {
f, ok := m.f[key]
if !ok || m.isNull(key, f.val) {
return nil, false
}
if f.val.Kind != yaml.SequenceNode {
m.c.add(m.r.path, f.key.Line+1, "%s must be a list", key)
return nil, false
}
var items []item
for _, n := range f.val.Content {
if m.isNull(key, n) {
continue
}
if n.Kind != yaml.ScalarNode {
m.c.add(m.r.path, n.Line+1, "%s items must be strings", key)
continue
}
items = append(items, item{n.Value, n.Line + 1})
}
m.r.lists[key] = items
return items, true
}
// tags checks the ADR tags list.
func (m *fm) tags() {
items, ok := m.list("tags")
if !ok {
return
}
if len(items) == 0 {
m.c.add(m.r.path, m.line("tags"), "tags must not be empty")
}
seen := map[string]bool{}
for _, it := range items {
switch {
case it.v == marker:
case !slugRe.MatchString(it.v):
m.c.add(m.r.path, it.line, "tag %q must be lowercase a-z0-9 words joined by single hyphens", it.v)
case seen[it.v]:
m.c.add(m.r.path, it.line, "duplicate tag %q", it.v)
}
seen[it.v] = true
}
}
func (m *fm) problemf(key, format string, args ...any) {
m.c.add(m.r.path, m.line(key), format, args...)
}
+71
View File
@@ -0,0 +1,71 @@
package docs
import (
"slices"
"strings"
)
// graph checks cross-record rules: unique IDs and supersession.
func (c *checker) graph(recs []*record) {
byStem := map[string][]*record{}
byPath := map[string]*record{}
for _, r := range recs {
byStem[r.stem] = append(byStem[r.stem], r)
byPath["/"+r.path] = r
}
for _, group := range byStem {
for _, r := range group {
for _, o := range group {
if o != r {
c.add(r.path, 1, "duplicate ID %s, also used by %s", r.stem, o.path)
}
}
}
}
adrs := recs[:0:0]
for _, r := range recs {
if r.kind == "adr" && r.fm != nil {
adrs = append(adrs, r)
}
}
for _, r := range adrs {
if len(r.lists["supersedes"]) > 0 && !slices.Contains([]string{"accepted", "superseded", "deprecated"}, r.status) {
c.add(r.path, r.fm.line("supersedes"), "a record that supersedes another must be accepted, superseded or deprecated, not %s", r.status)
}
for _, e := range [][2]string{{"supersedes", "superseded_by"}, {"superseded_by", "supersedes"}} {
for _, it := range r.lists[e[0]] {
if o := byPath[it.v]; o != nil && o.kind == "adr" && !slices.ContainsFunc(o.lists[e[1]], func(x item) bool { return x.v == "/"+r.path }) {
c.add(r.path, it.line, "%s lists %s, which must list this record in %s", e[0], it.v, e[1])
}
}
}
}
// Cycles over supersedes edges: report once, on the first record (in path order) that closes one.
const (
visiting = 1
done = 2
)
state := map[*record]int{}
var visit func(r *record)
visit = func(r *record) {
state[r] = visiting
for _, it := range r.lists["supersedes"] {
o := byPath[it.v]
if o == nil || o.kind != "adr" {
continue
}
switch state[o] {
case visiting:
c.add(r.path, it.line, "supersession cycle through %s", strings.TrimPrefix(it.v, "/"))
case 0:
visit(o)
}
}
state[r] = done
}
for _, r := range adrs {
if state[r] == 0 {
visit(r)
}
}
}
+126
View File
@@ -0,0 +1,126 @@
package docs
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
const (
timestampLayout = "20060102T150405.000Z"
maxSlug = 80
)
var (
nameRe = regexp.MustCompile(`^(\d{8}T\d{9}Z)-(.+)\.md$`)
slugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
)
// parseName validates a record filename and returns its stem, UTC timestamp
// and a problem message (empty when valid).
func parseName(name string) (stem string, at time.Time, msg string) {
m := nameRe.FindStringSubmatch(name)
if m == nil {
return "", at, "name must be YYYYMMDDTHHMMSSmmmZ-slug.md"
}
at, err := time.Parse(timestampLayout, m[1][:15]+"."+m[1][15:])
if err != nil {
return "", at, "name has an invalid UTC timestamp"
}
switch {
case !slugRe.MatchString(m[2]):
return "", at, "slug must be lowercase a-z0-9 words joined by single hyphens"
case len(m[2]) > maxSlug:
return "", at, fmt.Sprintf("slug is longer than %d characters", maxSlug)
}
return strings.TrimSuffix(name, ".md"), at, ""
}
// slugify lowercases title and joins its ASCII letter and digit runs with hyphens.
func slugify(title string) string {
var b strings.Builder
for _, r := range strings.ToLower(title) {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
} else if b.Len() > 0 && !strings.HasSuffix(b.String(), "-") {
b.WriteByte('-')
}
}
s := b.String()
return strings.Trim(s[:min(len(s), maxSlug)], "-")
}
// New creates a record of the given kind ("adr" or "plan") under root and
// returns its root-relative slash path. The file is created exclusively; the
// timestamp advances by 1 ms on a collision.
func New(root, kind, title string, now time.Time) (string, error) {
title = strings.TrimSpace(title)
if title == "" || strings.ContainsAny(title, "\r\n") {
return "", errors.New("title must be non-empty and a single line")
}
slug := slugify(title)
if slug == "" {
return "", errors.New("title needs at least one ASCII letter or digit for the file name")
}
var dir string
for _, d := range dirs {
if d.kind == kind {
dir = d.dir
}
}
tmpl, err := templates.ReadFile("templates/" + kind + ".md")
if err != nil {
return "", fmt.Errorf("unknown record type %q", kind)
}
quoted, err := json.Marshal(title) // JSON strings are valid YAML double-quoted scalars
if err != nil {
return "", err
}
used, err := stems(root)
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
return "", err
}
at := now.UTC().Truncate(time.Millisecond)
for ; ; at = at.Add(time.Millisecond) {
stem := strings.Replace(at.Format(timestampLayout), ".", "", 1) + "-" + slug
if used[stem] {
continue
}
rel := dir + "/" + stem + ".md"
f, err := os.OpenFile(filepath.Join(root, filepath.FromSlash(rel)), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return "", err
}
content := strings.NewReplacer("@TITLE_YAML@", string(quoted), "@TITLE@", title, "@DATE@", at.Format(time.DateOnly)).Replace(string(tmpl))
_, werr := f.WriteString(content)
return rel, errors.Join(werr, f.Close())
}
}
// stems returns the IDs of every record-named file in both directories.
func stems(root string) (map[string]bool, error) {
used := map[string]bool{}
for _, d := range dirs {
entries, err := os.ReadDir(filepath.Join(root, d.dir))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
for _, e := range entries {
if stem, _, msg := parseName(e.Name()); msg == "" {
used[stem] = true
}
}
}
return used, nil
}
+117
View File
@@ -0,0 +1,117 @@
package docs
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestLinkDestinations covers the section 7 rules through one body link.
func TestLinkDestinations(t *testing.T) {
tests := []struct{ dest, want string }{ // want "" means valid
{"/docs/spec.txt", ""},
{"/docs/spec.txt#frag", ""},
{"/docs/sp%20ace.md", ""},
{"#anchor", ""},
{"mailto:a@example.com", ""},
{"https://example.com/a?b=1#c", ""},
{"foo.md", "relative"},
{"./foo.md", "relative"},
{"../docs/spec.txt", "relative"},
{"/docs/../docs/spec.txt", `".."`},
{"/docs/%2e%2e/docs/spec.txt", `".."`},
{`/docs\spec.txt`, "backslash"},
{`C:\spec.txt`, "backslash"},
{"//example.com/x", "protocol-relative"},
{"/docs/spec.txt?x=1", "queries"},
{"/docs/%zz", "percent-encoding"},
{"/docs/missing.txt", "does not exist"},
{"/Users/me/spec.txt", "does not exist"},
{"/docs", "not a regular file"},
{"/docs/out/f.md", "outside the repository"},
{"file:///etc/passwd", "scheme"},
{"ftp://example.com/x", "scheme"},
{"javascript:alert(1)", "scheme"},
{"https://u:p@example.com/x", "credentials"},
{"https:///nohost", "host"},
{"<>", "empty"},
}
for _, tt := range tests {
t.Run(tt.dest, func(t *testing.T) {
dest := tt.dest
if strings.ContainsAny(dest, " ") {
dest = "<" + dest + ">"
}
doc := adrBase + "\nSee [x](" + dest + ").\n"
res, err := Check(tree(t, withSupport(map[string]string{aPath: doc}), map[string]string{"docs/out": "@outside"}))
must(t, err)
var want []string
if tt.want != "" {
want = []string{aPath + ":35: " + tt.want}
}
expect(t, res.Problems, want)
})
}
}
// TestLinkForms covers which Markdown constructs are link destinations.
func TestLinkForms(t *testing.T) {
tests := []struct {
name, body string
want []string
}{
{"image", "![x](foo.md)", []string{":35: relative"}},
{"autolink", "<ftp://example.com/x>", []string{":35: scheme"}},
{"reference link and definition", "[x][r]\n\n[r]: foo.md", []string{":35: relative", ":37: relative"}},
{"unused definition", "[r]: foo.md", []string{":35: relative"}},
{"link in a list", "* [x](foo.md)", []string{":35: relative"}},
{"bare URL and raw HTML are not checked", "http://u:p@example.com and @user #123 abc1234\n\n<a href=\"foo.md\">x</a>", nil},
{"link in code is not a link", "`[x](foo.md)`\n\n```\n[x](foo.md)\n```", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res, err := Check(tree(t, withSupport(map[string]string{aPath: adrBase + "\n" + tt.body + "\n"}), nil))
must(t, err)
var want []string
for _, w := range tt.want {
want = append(want, aPath+w)
}
expect(t, res.Problems, want)
})
}
}
// TestSpecExamples keeps the minimal examples in docs/docs-format.md valid.
func TestSpecExamples(t *testing.T) {
spec, err := os.ReadFile("../../docs/docs-format.md")
must(t, err)
root := t.TempDir()
parts := strings.Split(string(spec[strings.Index(string(spec), "## 9."):]), "```markdown\n")
if len(parts) != 3 {
t.Fatalf("expected two examples, got %d", len(parts)-1)
}
// The path of each example is the backticked name on the line before its fence.
heads := strings.Split(string(spec[strings.Index(string(spec), "## 9."):]), "\n")
var paths []string
for _, l := range heads {
if strings.HasPrefix(l, "`docs/") {
paths = append(paths, strings.Trim(l, "`:"))
}
}
if len(paths) != 2 {
t.Fatalf("expected two example paths, got %v", paths)
}
for i, part := range parts[1:] {
body, _, _ := strings.Cut(part, "```")
full := filepath.Join(root, paths[i])
must(t, os.MkdirAll(filepath.Dir(full), 0o755))
must(t, os.WriteFile(full, []byte(body), 0o644))
}
res, err := Check(root)
must(t, err)
expect(t, res.Problems, nil)
if res.Records != 2 {
t.Fatalf("records = %d", res.Records)
}
}
+104
View File
@@ -0,0 +1,104 @@
package docs
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
var now = time.Date(2026, 10, 23, 20, 45, 12, 345_000_000, time.UTC)
// TestNewTemplates checks that fresh records fail only on their markers and
// become valid once those are replaced.
func TestNewTemplates(t *testing.T) {
for _, tt := range []struct {
kind, path string
markers int
}{
{"adr", "docs/adr/20261023T204512345Z-use-x-y.md", 6},
{"plan", "docs/plans/20261023T204512345Z-use-x-y.md", 3},
} {
t.Run(tt.kind, func(t *testing.T) {
root := t.TempDir()
path, err := New(root, tt.kind, `Use "X" & Y`, now)
must(t, err)
if path != tt.path {
t.Fatalf("path = %s", path)
}
res, err := Check(root)
must(t, err)
if len(res.Problems) != tt.markers {
t.Fatalf("got %v, want %d marker problems", res.Problems, tt.markers)
}
for _, p := range res.Problems {
if !strings.Contains(p.Msg, marker) {
t.Errorf("not a marker problem: %s", p)
}
}
data, err := os.ReadFile(filepath.Join(root, path))
must(t, err)
must(t, os.WriteFile(filepath.Join(root, path), []byte(strings.ReplaceAll(string(data), marker, "x")), 0o644))
res, err = Check(root)
must(t, err)
expect(t, res.Problems, nil)
})
}
}
func TestNewCollisions(t *testing.T) {
root := t.TempDir()
first, err := New(root, "adr", "Same", now)
must(t, err)
second, err := New(root, "adr", "Same", now)
must(t, err)
// The same stem in the other directory also advances the timestamp.
third, err := New(root, "plan", "Same", now)
must(t, err)
want := []string{
"docs/adr/20261023T204512345Z-same.md",
"docs/adr/20261023T204512346Z-same.md",
"docs/plans/20261023T204512347Z-same.md",
}
for i, got := range []string{first, second, third} {
if got != want[i] {
t.Errorf("file %d = %s, want %s", i, got, want[i])
}
}
// Advancing across midnight keeps the date equal to the filename.
root = t.TempDir()
_, err = New(root, "adr", "Edge", time.Date(2026, 10, 23, 23, 59, 59, 999_000_000, time.UTC))
must(t, err)
got, err := New(root, "adr", "Edge", time.Date(2026, 10, 23, 23, 59, 59, 999_000_000, time.UTC))
must(t, err)
if got != "docs/adr/20261024T000000000Z-edge.md" {
t.Fatalf("got %s", got)
}
res, err := Check(root)
must(t, err)
for _, p := range res.Problems {
if !strings.Contains(p.Msg, marker) {
t.Errorf("unexpected %s", p)
}
}
}
func TestNewRejectsBadTitles(t *testing.T) {
for _, title := range []string{"", " ", "a\nb", "!!!"} {
if path, err := New(t.TempDir(), "adr", title, now); err == nil {
t.Errorf("title %q created %s", title, path)
}
}
}
func TestSlugify(t *testing.T) {
for in, want := range map[string]string{"Use X, Y & Z!": "use-x-y-z", " --Hello-- ": "hello", "Ünï": "n"} {
if got := slugify(in); got != want {
t.Errorf("slugify(%q) = %q, want %q", in, got, want)
}
}
if got := slugify(strings.Repeat("ab ", 40)); len(got) != maxSlug || strings.HasSuffix(got, "-") {
t.Errorf("long slug = %q", got)
}
}
+23
View File
@@ -0,0 +1,23 @@
package docs
var planRefs = []refSpec{{"tracking", kURL}, {"adrs", kAdr}, {"completion", kURL | kFile}}
func (c *checker) plan(r *record, m *fm) {
m.keys("status", "tracking", "adrs", "completion")
status, ok := m.enum("status", "draft", "active", "completed", "cancelled")
m.refs(planRefs...)
r.status = status
if !ok {
return
}
closed := status == "completed" || status == "cancelled"
if status == "active" && len(r.lists["tracking"]) == 0 {
m.problemf("status", "status active requires tracking")
}
if closed && len(r.lists["completion"]) == 0 {
m.problemf("status", "status %s requires completion", status)
}
if !closed && len(r.lists["completion"]) > 0 {
m.problemf("completion", "completion must be empty while status is %s", status)
}
}
+176
View File
@@ -0,0 +1,176 @@
package docs
import (
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
)
// kinds is the set of reference forms a frontmatter key accepts.
type kinds uint8
const (
kAdr kinds = 1 << iota
kPlan
kURL
kFile
)
type refSpec struct {
key string
kinds kinds
}
var (
schemeRe = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9+.-]*):`)
durableRe = regexp.MustCompile(`^([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)?#\d+$`)
idPattern = `\d{8}T\d{9}Z-[a-z0-9]+(?:-[a-z0-9]+)*`
adrPathRe = regexp.MustCompile(`^/docs/adr/` + idPattern + `\.md$`)
planPathRe = regexp.MustCompile(`^/docs/plans/` + idPattern + `\.md$`)
)
type ref struct {
path string // decoded repo-root path; empty for URLs and anchors
remote bool
}
// parseRef checks the syntax of a reference. body allows #anchors, mailto: and #fragments.
func parseRef(v string, body bool) (ref, string) {
if v == "" {
return ref{}, "empty reference"
}
if strings.Contains(v, `\`) {
return ref{}, "backslashes are not allowed"
}
if body && v[0] == '#' {
return ref{}, ""
}
if m := schemeRe.FindStringSubmatch(v); m != nil {
switch strings.ToLower(m[1]) {
case "http", "https":
u, err := url.Parse(v)
switch {
case err != nil || u.Hostname() == "":
return ref{}, "URL must have a host"
case u.User != nil:
return ref{}, "URL must not contain credentials"
}
return ref{remote: true}, ""
case "mailto":
if body {
return ref{}, ""
}
}
return ref{}, fmt.Sprintf("scheme %q is not allowed", m[1])
}
if v[0] != '/' {
return ref{}, "relative paths are not allowed; use a repo-root path starting with /"
}
if strings.HasPrefix(v, "//") {
return ref{}, "protocol-relative references are not allowed"
}
p, frag, hasFrag := strings.Cut(v, "#")
if hasFrag && !body {
return ref{}, "fragments are not allowed here (" + frag + ")"
}
if strings.Contains(p, "?") {
return ref{}, "queries are not allowed"
}
dec, err := url.PathUnescape(p)
if err != nil {
return ref{}, "invalid percent-encoding"
}
if slices.Contains(strings.Split(dec, "/"), "..") || strings.Contains(dec, `\`) {
return ref{}, `".." segments and backslashes are not allowed`
}
return ref{path: dec}, ""
}
// exists reports why a repo-root path is not a regular file inside the repository.
func (c *checker) exists(p string) string {
real, err := filepath.EvalSymlinks(filepath.Join(c.root, filepath.FromSlash(p)))
if err != nil {
return "target " + p + " does not exist"
}
if rel, err := filepath.Rel(c.realRoot, real); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "target " + p + " resolves outside the repository"
}
if st, err := os.Stat(real); err != nil || !st.Mode().IsRegular() {
return "target " + p + " is not a regular file"
}
return ""
}
// checkLink validates a body link destination.
func (c *checker) checkLink(r *record, line int, dest string) {
ref, msg := parseRef(dest, true)
if msg == "" && ref.path != "" {
msg = c.exists(ref.path)
}
if msg != "" {
c.add(r.path, line, "link %q: %s", dest, msg)
}
}
// refs validates the reference lists of a record against their specs.
func (m *fm) refs(specs ...refSpec) {
self := "/" + m.r.path
for _, spec := range specs {
items, ok := m.list(spec.key)
if !ok {
continue
}
seen := map[string]bool{}
for _, it := range items {
if it.v == marker {
continue
}
if msg := m.checkRef(spec, it.v, self); msg != "" {
m.c.add(m.r.path, it.line, "%s: %s", spec.key, msg)
} else if seen[it.v] {
m.c.add(m.r.path, it.line, "%s: duplicate reference %q", spec.key, it.v)
}
seen[it.v] = true
}
}
}
func (m *fm) checkRef(spec refSpec, v, self string) string {
if durableRe.MatchString(v) || slices.Contains([]string{"this pr", "this pull request", "this commit"}, strings.ToLower(v)) {
return fmt.Sprintf("%q is not durable; use a full URL", v)
}
r, msg := parseRef(v, false)
if msg != "" {
return msg
}
k := spec.kinds
switch {
case r.remote:
if k&kURL == 0 {
return "URLs are not accepted here; accepts " + k.String()
}
return ""
case k&kFile == 0 && !(k&kAdr != 0 && adrPathRe.MatchString(v)) && !(k&kPlan != 0 && planPathRe.MatchString(v)):
return fmt.Sprintf("%q is not accepted here; accepts %s", v, k)
case v == self:
return "a record must not reference itself"
}
return m.c.exists(r.path)
}
func (k kinds) String() string {
var s []string
for _, e := range []struct {
k kinds
s string
}{{kAdr, "/docs/adr/<id>.md"}, {kPlan, "/docs/plans/<id>.md"}, {kURL, "http(s) URLs"}, {kFile, "repo-root file paths"}} {
if k&e.k != 0 {
s = append(s, e.s)
}
}
return strings.Join(s, ", ")
}
+32
View File
@@ -0,0 +1,32 @@
---
title: @TITLE_YAML@
date: @DATE@
status: proposed
implementation: not-started
summary: "{{docs:todo}}"
tags:
- "{{docs:todo}}"
supersedes: []
superseded_by: []
related: []
tracking: []
implemented_by: []
---
# @TITLE@
## Context and Problem Statement
{{docs:todo}}
## Considered Options
{{docs:todo}}
## Decision Outcome
{{docs:todo}}
### Consequences
{{docs:todo}}
+20
View File
@@ -0,0 +1,20 @@
---
status: draft
tracking: []
adrs: []
completion: []
---
# @TITLE@
## Scope
{{docs:todo}}
## Steps
{{docs:todo}}
## Validation
{{docs:todo}}