feat: add docs checker for ADRs and implementation plans #1
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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", "", []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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
Vendored
+32
@@ -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}}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
status: draft
|
||||
tracking: []
|
||||
adrs: []
|
||||
completion: []
|
||||
---
|
||||
|
||||
# @TITLE@
|
||||
|
||||
## Scope
|
||||
|
||||
{{docs:todo}}
|
||||
|
||||
## Steps
|
||||
|
||||
{{docs:todo}}
|
||||
|
||||
## Validation
|
||||
|
||||
{{docs:todo}}
|
||||
Reference in new issue
Block a user