Check every link reference definition, decode destinations per CommonMark, report correct lines for empty headings, autolinks and nested definitions, reject symlinked record directories and wrong-case path components, and refuse titles that cannot pass. Only top-level headings count (spec section 6). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
377 lines
26 KiB
Go
377 lines
26 KiB
Go
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: "null lowercase", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: null")}, want: []string{aPath + ":6: must not be null"}},
|
|
{name: "null uppercase", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: NULL")}, want: []string{aPath + ":6: must not be 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: "mailto in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - mailto:a@example.com")}, want: []string{aPath + ":14: scheme"}},
|
|
{name: "anchor in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - \"#anchor\"")}, want: []string{aPath + ":14: relative"}},
|
|
{name: "symlink escape in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - /docs/out/f.md")}, links: map[string]string{"docs/out": "@outside"}, want: []string{aPath + ":14: outside the repository"}},
|
|
{name: "percent-encoded self reference", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - /docs/adr/20261023T204512345Z-use%2Dqueue.md")}, want: []string{aPath + ":14: itself"}},
|
|
{name: "supersedes rejects plan path", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: accepted", "supersedes: []", "supersedes:\n - /"+pPath)}, want: []string{aPath + ":10: supersedes"}},
|
|
{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 deprecated", files: map[string]string{
|
|
aPath: sub(adrBase, "status: proposed", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+bPath),
|
|
bPath: sub(bBase, "status: accepted", "status: deprecated", "supersedes: []", "supersedes:\n - /"+aPath)}},
|
|
{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 + ":8: H1"}},
|
|
{name: "empty ADR H1 is on its own line", files: map[string]string{aPath: sub(adrBase, "# Use Queue", "#")}, want: []string{aPath + ":16: must equal title"}},
|
|
{name: "headings inside blockquotes and lists do not count", files: map[string]string{aPath: adrBase + "\n> ## Consequences\n\n* ## Decision Outcome\n"}},
|
|
{name: "H4 does not end the Decision Outcome prose", files: map[string]string{aPath: sub(adrBase, "Chosen option: Queue.", "#### Detail\n\nChosen.")}},
|
|
{name: "plan second H1", files: map[string]string{pPath: planBase + "\n# Again\n"}, want: []string{pPath + ":22: more than one H1"}},
|
|
{name: "plan repeated section", files: map[string]string{pPath: planBase + "\n## Scope\n\nMore.\n"}, want: []string{pPath + ":22: appears more than once"}},
|
|
{name: "plan section without prose", files: map[string]string{pPath: sub(planBase, "1. Do it.", "<!-- later -->")}, want: []string{pPath + ":14: has no prose"}},
|
|
{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 TestCheckSymlinkedDocsDir(t *testing.T) {
|
|
res, err := Check(tree(t, nil, map[string]string{"docs/plans": "@outside"}))
|
|
must(t, err)
|
|
expect(t, res.Problems, []string{"docs/plans:1: real directory"})
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|