From 750b7449b0a6d59acb1a8dd41d1a1c816763843a Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Fri, 23 Oct 2026 14:46:56 -0700 Subject: [PATCH] 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 --- cmd/docs/main.go | 66 ++++++ cmd/docs/main_test.go | 80 ++++++++ internal/docs/adr.go | 45 ++++ internal/docs/body.go | 285 +++++++++++++++++++++++++ internal/docs/check.go | 128 ++++++++++++ internal/docs/check_test.go | 354 ++++++++++++++++++++++++++++++++ internal/docs/frontmatter.go | 229 +++++++++++++++++++++ internal/docs/graph.go | 71 +++++++ internal/docs/id.go | 126 ++++++++++++ internal/docs/links_test.go | 117 +++++++++++ internal/docs/new_test.go | 104 ++++++++++ internal/docs/plan.go | 23 +++ internal/docs/refs.go | 176 ++++++++++++++++ internal/docs/templates/adr.md | 32 +++ internal/docs/templates/plan.md | 20 ++ 15 files changed, 1856 insertions(+) create mode 100644 cmd/docs/main.go create mode 100644 cmd/docs/main_test.go create mode 100644 internal/docs/adr.go create mode 100644 internal/docs/body.go create mode 100644 internal/docs/check.go create mode 100644 internal/docs/check_test.go create mode 100644 internal/docs/frontmatter.go create mode 100644 internal/docs/graph.go create mode 100644 internal/docs/id.go create mode 100644 internal/docs/links_test.go create mode 100644 internal/docs/new_test.go create mode 100644 internal/docs/plan.go create mode 100644 internal/docs/refs.go create mode 100644 internal/docs/templates/adr.md create mode 100644 internal/docs/templates/plan.md diff --git a/cmd/docs/main.go b/cmd/docs/main.go new file mode 100644 index 0000000..871355a --- /dev/null +++ b/cmd/docs/main.go @@ -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 +} diff --git a/cmd/docs/main_test.go b/cmd/docs/main_test.go new file mode 100644 index 0000000..0530516 --- /dev/null +++ b/cmd/docs/main_test.go @@ -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) + } + } +} diff --git a/internal/docs/adr.go b/internal/docs/adr.go new file mode 100644 index 0000000..a2ea2c4 --- /dev/null +++ b/internal/docs/adr.go @@ -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 +} diff --git a/internal/docs/body.go b/internal/docs/body.go new file mode 100644 index 0000000..2fd20e5 --- /dev/null +++ b/internal/docs/body.go @@ -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())) + } +} diff --git a/internal/docs/check.go b/internal/docs/check.go new file mode 100644 index 0000000..097618b --- /dev/null +++ b/internal/docs/check.go @@ -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 +} diff --git a/internal/docs/check_test.go b/internal/docs/check_test.go new file mode 100644 index 0000000..79450d7 --- /dev/null +++ b/internal/docs/check_test.go @@ -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", "")}, 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.", "")}, 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\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) + } +} diff --git a/internal/docs/frontmatter.go b/internal/docs/frontmatter.go new file mode 100644 index 0000000..a0c1a74 --- /dev/null +++ b/internal/docs/frontmatter.go @@ -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...) +} diff --git a/internal/docs/graph.go b/internal/docs/graph.go new file mode 100644 index 0000000..ae73813 --- /dev/null +++ b/internal/docs/graph.go @@ -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) + } + } +} diff --git a/internal/docs/id.go b/internal/docs/id.go new file mode 100644 index 0000000..93cd108 --- /dev/null +++ b/internal/docs/id.go @@ -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 +} diff --git a/internal/docs/links_test.go b/internal/docs/links_test.go new file mode 100644 index 0000000..4fd7954 --- /dev/null +++ b/internal/docs/links_test.go @@ -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", "", []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\nx", 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) + } +} diff --git a/internal/docs/new_test.go b/internal/docs/new_test.go new file mode 100644 index 0000000..6ea56f9 --- /dev/null +++ b/internal/docs/new_test.go @@ -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) + } +} diff --git a/internal/docs/plan.go b/internal/docs/plan.go new file mode 100644 index 0000000..8f025b7 --- /dev/null +++ b/internal/docs/plan.go @@ -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) + } +} diff --git a/internal/docs/refs.go b/internal/docs/refs.go new file mode 100644 index 0000000..18c5257 --- /dev/null +++ b/internal/docs/refs.go @@ -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/.md"}, {kPlan, "/docs/plans/.md"}, {kURL, "http(s) URLs"}, {kFile, "repo-root file paths"}} { + if k&e.k != 0 { + s = append(s, e.s) + } + } + return strings.Join(s, ", ") +} diff --git a/internal/docs/templates/adr.md b/internal/docs/templates/adr.md new file mode 100644 index 0000000..9b53a94 --- /dev/null +++ b/internal/docs/templates/adr.md @@ -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}} diff --git a/internal/docs/templates/plan.md b/internal/docs/templates/plan.md new file mode 100644 index 0000000..50f3701 --- /dev/null +++ b/internal/docs/templates/plan.md @@ -0,0 +1,20 @@ +--- +status: draft +tracking: [] +adrs: [] +completion: [] +--- + +# @TITLE@ + +## Scope + +{{docs:todo}} + +## Steps + +{{docs:todo}} + +## Validation + +{{docs:todo}}