feat: add docs checker and record templates

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

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

No files matched your search

+354
View File
@@ -0,0 +1,354 @@
package docs
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
const (
aID = "20261023T204512345Z-use-queue"
aPath = "docs/adr/" + aID + ".md"
bPath = "docs/adr/20261024T000000000Z-second.md"
pPath = "docs/plans/20261023T210000000Z-adopt-queue.md"
// Line numbers in the expectations below refer to these documents.
adrBase = `---
title: Use Queue
date: 2026-10-23
status: proposed
implementation: not-started
summary: Run jobs on a queue.
tags:
- jobs
supersedes: []
superseded_by: []
related: []
tracking: []
implemented_by: []
---
# Use Queue
## Context and Problem Statement
Jobs block requests.
## Considered Options
* Queue
* Inline
## Decision Outcome
Chosen option: Queue.
### Consequences
* One more service.
`
// bBase is a second ADR (dated 2026-10-24) that may supersede or be superseded.
bBase = `---
title: Second
date: 2026-10-24
status: accepted
implementation: not-started
summary: Second.
tags:
- jobs
supersedes: []
superseded_by: []
related: []
tracking: []
implemented_by: []
---
# Second
## Context and Problem Statement
Why.
## Considered Options
* A
## Decision Outcome
Chosen option: A.
### Consequences
* Some.
`
planBase = `---
status: draft
tracking: []
adrs: []
completion: []
---
# Adopt Queue
## Scope
Move the job.
## Steps
1. Do it.
## Validation
It runs.
`
)
// sub replaces old with new in s once; a stale expectation fails loudly.
func sub(s string, pairs ...string) string {
for i := 0; i < len(pairs); i += 2 {
if !strings.Contains(s, pairs[i]) {
panic("fixture does not contain " + pairs[i])
}
s = strings.Replace(s, pairs[i], pairs[i+1], 1)
}
return s
}
func tree(t *testing.T, files, links map[string]string) string {
t.Helper()
root, outside := t.TempDir(), t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "f.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
for p, content := range files {
full := filepath.Join(root, p)
if strings.HasSuffix(p, "/") {
must(t, os.MkdirAll(full, 0o755))
continue
}
must(t, os.MkdirAll(filepath.Dir(full), 0o755))
must(t, os.WriteFile(full, []byte(content), 0o644))
}
for p, target := range links {
must(t, os.MkdirAll(filepath.Dir(filepath.Join(root, p)), 0o755))
must(t, os.Symlink(strings.Replace(target, "@outside", outside, 1), filepath.Join(root, p)))
}
return root
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
var wantRe = regexp.MustCompile(`^(.*?:\d+): (.*)$`)
// expect requires got and want to pair up one to one. A want is "path:line: message substring"; the path may be a suffix.
func expect(t *testing.T, got []Problem, want []string) {
t.Helper()
left := append([]Problem(nil), got...)
for _, w := range want {
m := wantRe.FindStringSubmatch(w)
found := false
for i, p := range left {
if strings.HasSuffix(fmt.Sprintf("%s:%d", p.Path, p.Line), m[1]) && strings.Contains(p.Msg, m[2]) {
left = append(left[:i], left[i+1:]...)
found = true
break
}
}
if !found {
t.Errorf("missing problem %q in %v", w, got)
}
}
for _, p := range left {
t.Errorf("unexpected problem %s", p)
}
}
var support = map[string]string{
bPath: bBase,
pPath: planBase,
"docs/spec.txt": "spec",
"docs/sp ace.md": "space",
}
// withSupport adds the shared targets to a case's files.
func withSupport(files map[string]string) map[string]string {
all := map[string]string{}
for k, v := range support {
all[k] = v
}
for k, v := range files {
all[k] = v
}
return all
}
func TestCheck(t *testing.T) {
long := strings.Repeat("a", 81)
tests := []struct {
name string
files map[string]string
links map[string]string
want []string
}{
// 1. Files and directories
{name: "dot entries and README are skipped", files: map[string]string{"docs/adr/.DS_Store": "x", "docs/adr/README.md": "x", "docs/plans/README.md": "x"}},
{name: "subdirectory", files: map[string]string{"docs/adr/sub/": ""}, want: []string{"docs/adr/sub:1: regular file"}},
{name: "non-md file", files: map[string]string{"docs/plans/notes.txt": "x"}, want: []string{"docs/plans/notes.txt:1: name must be"}},
{name: "template-like name", files: map[string]string{"docs/adr/template.md": "x"}, want: []string{"docs/adr/template.md:1: name must be"}},
{name: "symlinked record", files: map[string]string{"docs/spec.txt": "x"}, links: map[string]string{"docs/adr/20261023T204512345Z-s.md": "@outside/f.md"}, want: []string{"20261023T204512345Z-s.md:1: regular file"}},
{name: "CRLF accepted", files: map[string]string{aPath: strings.ReplaceAll(adrBase, "\n", "\r\n")}},
{name: "BOM", files: map[string]string{aPath: "\xef\xbb\xbf" + adrBase}, want: []string{aPath + ":1: byte order mark"}},
{name: "invalid UTF-8", files: map[string]string{aPath: adrBase + "\xff\n"}, want: []string{aPath + ":1: UTF-8"}},
// 2. Names and IDs
{name: "invalid timestamp", files: map[string]string{"docs/adr/20261323T204512345Z-x.md": adrBase}, want: []string{"20261323T204512345Z-x.md:1: invalid UTC timestamp"}},
{name: "slug double hyphen", files: map[string]string{"docs/adr/20261023T204512345Z-a--b.md": adrBase}, want: []string{"a--b.md:1: slug must be"}},
{name: "slug uppercase", files: map[string]string{"docs/adr/20261023T204512345Z-Ab.md": adrBase}, want: []string{"Ab.md:1: slug must be"}},
{name: "slug trailing hyphen", files: map[string]string{"docs/adr/20261023T204512345Z-ab-.md": adrBase}, want: []string{"ab-.md:1: slug must be"}},
{name: "slug 80 characters", files: map[string]string{"docs/adr/20261023T204512345Z-" + long[:80] + ".md": adrBase}},
{name: "slug 81 characters", files: map[string]string{"docs/adr/20261023T204512345Z-" + long + ".md": adrBase}, want: []string{".md:1: longer than 80"}},
{name: "duplicate ID across directories", files: map[string]string{"docs/adr/20261023T210000000Z-adopt-queue.md": adrBase, pPath: planBase},
want: []string{"docs/adr/20261023T210000000Z-adopt-queue.md:1: duplicate ID", pPath + ":1: duplicate ID"}},
// 3. Frontmatter
{name: "no frontmatter", files: map[string]string{aPath: "# Use Queue\n"}, want: []string{aPath + ":1: --- frontmatter"}},
{name: "unterminated frontmatter", files: map[string]string{aPath: "---\ntitle: x\n"}, want: []string{aPath + ":1: not closed"}},
{name: "empty frontmatter", files: map[string]string{aPath: "---\n---\n# x\n"}, want: []string{aPath + ":2: empty"}},
{name: "frontmatter is a list", files: map[string]string{aPath: "---\n- a\n---\n"}, want: []string{aPath + ":2: mapping"}},
{name: "invalid YAML", files: map[string]string{aPath: sub(adrBase, "title: Use Queue", "title: [")}, want: []string{aPath + ":3: invalid YAML"}},
{name: "multiple YAML documents", files: map[string]string{aPath: sub(adrBase, "title: Use Queue\n", "title: Use Queue\n...\nx: y\n")}, want: []string{aPath + ":2: single YAML document"}},
{name: "anchor and alias", files: map[string]string{aPath: sub(adrBase, "supersedes: []\nsuperseded_by: []", "supersedes: &a []\nsuperseded_by: *a")}, want: []string{aPath + ":9: anchors", aPath + ":10: aliases"}},
{name: "explicit tag", files: map[string]string{aPath: sub(adrBase, "summary: Run", "summary: !!str Run")}, want: []string{aPath + ":6: explicit"}},
{name: "unknown key", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by: []\nextra: x")}, want: []string{aPath + ":14: unknown key"}},
{name: "missing key", files: map[string]string{aPath: sub(adrBase, "related: []\n", "")}, want: []string{aPath + ":1: missing key \"related\""}},
{name: "duplicate key", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by: []\nsummary: again")}, want: []string{aPath + ":14: duplicate key"}},
{name: "null tilde", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: ~")}, want: []string{aPath + ":6: must not be null"}},
{name: "null empty", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary:")}, want: []string{aPath + ":6: must not be null"}},
{name: "null word", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: Null")}, want: []string{aPath + ":6: must not be null"}},
{name: "scalars are literal text", files: map[string]string{aPath: sub(adrBase, "title: Use Queue", "title: 2026", "# Use Queue", "# 2026", "date: 2026-10-23", `date: "2026-10-23"`, "summary: Run jobs on a queue.", `summary: "null"`)}},
{name: "list item is null", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - ~")}, want: []string{aPath + ":12: must not be null"}},
// 3.1 ADR keys
{name: "empty title", files: map[string]string{aPath: sub(adrBase, "title: Use Queue", `title: ""`)}, want: []string{aPath + ":2: title must be a non-empty single line"}},
{name: "multi-line summary", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: |\n a\n b")}, want: []string{aPath + ":6: summary must be a non-empty single line"}},
{name: "title is a list", files: map[string]string{aPath: sub(adrBase, "title: Use Queue", "title: [a]")}, want: []string{aPath + ":2: title must be a string"}},
{name: "invalid date", files: map[string]string{aPath: sub(adrBase, "date: 2026-10-23", "date: 2026-13-40")}, want: []string{aPath + ":3: valid YYYY-MM-DD"}},
{name: "date differs from filename", files: map[string]string{aPath: sub(adrBase, "date: 2026-10-23", "date: 2026-10-24")}, want: []string{aPath + ":3: does not match"}},
{name: "invalid status", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: done")}, want: []string{aPath + ":4: status must be one of"}},
{name: "invalid implementation", files: map[string]string{aPath: sub(adrBase, "implementation: not-started", "implementation: done")}, want: []string{aPath + ":5: implementation must be one of"}},
{name: "empty tags", files: map[string]string{aPath: sub(adrBase, "tags:\n - jobs", "tags: []")}, want: []string{aPath + ":7: tags must not be empty"}},
{name: "tags is a string", files: map[string]string{aPath: sub(adrBase, "tags:\n - jobs", "tags: jobs")}, want: []string{aPath + ":7: tags must be a list"}},
{name: "bad tag", files: map[string]string{aPath: sub(adrBase, " - jobs", " - Bad_Tag")}, want: []string{aPath + ":8: tag"}},
{name: "duplicate tag", files: map[string]string{aPath: sub(adrBase, " - jobs", " - jobs\n - jobs")}, want: []string{aPath + ":9: duplicate tag"}},
// 3.2 Plan keys
{name: "plan has unknown title key", files: map[string]string{pPath: sub(planBase, "status: draft", "status: draft\ntitle: x")}, want: []string{pPath + ":3: unknown key"}},
{name: "plan invalid status", files: map[string]string{pPath: sub(planBase, "status: draft", "status: done")}, want: []string{pPath + ":2: status must be one of"}},
// 3.3 References
{name: "related accepts ADR and plan", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - /"+bPath+"\n - /"+pPath)}},
{name: "ADR tracking accepts URL and plan", files: map[string]string{aPath: sub(adrBase, "tracking: []", "tracking:\n - https://example.com/issues/1\n - /"+pPath)}},
{name: "ADR tracking rejects ADR path", files: map[string]string{aPath: sub(adrBase, "tracking: []", "tracking:\n - /"+bPath)}, want: []string{aPath + ":13: tracking"}},
{name: "plan tracking rejects path", files: map[string]string{pPath: sub(planBase, "tracking: []", "tracking:\n - /"+aPath)}, want: []string{pPath + ":4: tracking"}},
{name: "plan tracking accepts URL", files: map[string]string{pPath: sub(planBase, "tracking: []", "tracking:\n - https://example.com/issues/1")}},
{name: "plan adrs accepts ADR", files: map[string]string{aPath: adrBase, pPath: sub(planBase, "adrs: []", "adrs:\n - /"+aPath)}},
{name: "plan adrs rejects plan", files: map[string]string{pPath: sub(planBase, "adrs: []", "adrs:\n - /"+pPath)}, want: []string{pPath + ":5: adrs"}},
{name: "implemented_by accepts URL and file", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - https://example.com/pr/1\n - /docs/spec.txt")}},
{name: "implemented_by missing file", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - /docs/missing.txt")}, want: []string{aPath + ":14: does not exist"}},
{name: "reference to missing ADR", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - /docs/adr/20261023T000000000Z-gone.md")}, want: []string{aPath + ":12: does not exist"}},
{name: "duplicate reference", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - /"+bPath+"\n - /"+bPath)}, want: []string{aPath + ":13: duplicate reference"}},
{name: "self reference", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - /"+aPath)}, want: []string{aPath + ":12: itself"}},
{name: "bare issue number", files: map[string]string{aPath: sub(adrBase, "tracking: []", "tracking:\n - \"#123\"")}, want: []string{aPath + ":13: not durable"}},
{name: "owner repo issue number", files: map[string]string{aPath: sub(adrBase, "tracking: []", "tracking:\n - example/repo#123")}, want: []string{aPath + ":13: not durable"}},
{name: "this PR placeholder", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - This PR")}, want: []string{aPath + ":14: not durable"}},
{name: "fragment in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - /docs/spec.txt#x")}, want: []string{aPath + ":14: fragments"}},
{name: "credentials in frontmatter URL", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - https://u:p@example.com/x")}, want: []string{aPath + ":14: credentials"}},
{name: "relative path in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - ../docs/spec.txt")}, want: []string{aPath + ":14: relative"}},
// 4. Status
{name: "superseded needs superseded_by", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: superseded")}, want: []string{aPath + ":4: requires superseded_by"}},
{name: "superseded_by needs superseded", files: map[string]string{aPath: sub(adrBase, "superseded_by: []", "superseded_by:\n - /"+bPath), bPath: sub(bBase, "supersedes: []", "supersedes:\n - /"+aPath)}, want: []string{aPath + ":10: requires status superseded"}},
{name: "rejected needs not-applicable", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: rejected")}, want: []string{aPath + ":5: not-applicable"}},
{name: "rejected not-applicable", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: rejected", "implementation: not-started", "implementation: not-applicable")}},
{name: "implemented needs implemented_by", files: map[string]string{aPath: sub(adrBase, "implementation: not-started", "implementation: implemented")}, want: []string{aPath + ":5: requires implemented_by"}},
{name: "implemented with implemented_by", files: map[string]string{aPath: sub(adrBase, "implementation: not-started", "implementation: implemented", "implemented_by: []", "implemented_by:\n - https://example.com/pr/1")}},
{name: "active plan needs tracking", files: map[string]string{pPath: sub(planBase, "status: draft", "status: active")}, want: []string{pPath + ":2: requires tracking"}},
{name: "active plan valid", files: map[string]string{pPath: sub(planBase, "status: draft", "status: active", "tracking: []", "tracking:\n - https://example.com/issues/1")}},
{name: "active plan with completion", files: map[string]string{pPath: sub(planBase, "status: draft", "status: active", "tracking: []", "tracking:\n - https://example.com/issues/1", "completion: []", "completion:\n - https://example.com/pr/1")}, want: []string{pPath + ":6: completion must be empty"}},
{name: "draft plan with completion", files: map[string]string{pPath: sub(planBase, "completion: []", "completion:\n - https://example.com/pr/1")}, want: []string{pPath + ":5: completion must be empty"}},
{name: "completed plan needs completion", files: map[string]string{pPath: sub(planBase, "status: draft", "status: completed")}, want: []string{pPath + ":2: requires completion"}},
{name: "cancelled plan needs completion", files: map[string]string{pPath: sub(planBase, "status: draft", "status: cancelled")}, want: []string{pPath + ":2: requires completion"}},
{name: "completed plan valid", files: map[string]string{pPath: sub(planBase, "status: draft", "status: completed", "completion: []", "completion:\n - https://example.com/pr/1")}},
// 5. Supersession
{name: "supersession valid", files: map[string]string{
aPath: sub(adrBase, "status: proposed", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+bPath),
bPath: sub(bBase, "supersedes: []", "supersedes:\n - /"+aPath)}},
{name: "supersedes not reciprocated", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: accepted", "supersedes: []", "supersedes:\n - /"+bPath)}, want: []string{aPath + ":10: must list this record in superseded_by"}},
{name: "superseded_by not reciprocated", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+bPath)}, want: []string{aPath + ":11: must list this record in supersedes"}},
{name: "superseder is proposed", files: map[string]string{
aPath: sub(adrBase, "supersedes: []", "supersedes:\n - /"+bPath),
bPath: sub(bBase, "status: accepted", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+aPath)}, want: []string{aPath + ":9: must be accepted"}},
{name: "superseder is rejected", files: map[string]string{
aPath: sub(adrBase, "status: proposed", "status: rejected", "implementation: not-started", "implementation: not-applicable", "supersedes: []", "supersedes:\n - /"+bPath),
bPath: sub(bBase, "status: accepted", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+aPath)}, want: []string{aPath + ":9: must be accepted"}},
{name: "supersession cycle", files: map[string]string{
aPath: sub(adrBase, "status: proposed", "status: superseded", "supersedes: []", "supersedes:\n - /"+bPath, "superseded_by: []", "superseded_by:\n - /"+bPath),
bPath: sub(bBase, "status: accepted", "status: superseded", "supersedes: []", "supersedes:\n - /"+aPath, "superseded_by: []", "superseded_by:\n - /"+aPath)}, want: []string{bPath + ":10: cycle"}},
// 6. Body
{name: "no H1", files: map[string]string{aPath: sub(adrBase, "# Use Queue\n\n", "")}, want: []string{aPath + ":15: first heading must be an H1"}},
{name: "H1 differs from title", files: map[string]string{aPath: sub(adrBase, "# Use Queue", "# Other")}, want: []string{aPath + ":16: must equal title"}},
{name: "two H1s", files: map[string]string{aPath: adrBase + "\n# Again\n"}, want: []string{aPath + ":35: more than one H1"}},
{name: "missing required section", files: map[string]string{aPath: sub(adrBase, "## Considered Options\n\n* Queue\n* Inline\n\n", "")}, want: []string{aPath + ":1: missing required section \"Considered Options\""}},
{name: "repeated section", files: map[string]string{aPath: adrBase + "\n## Considered Options\n\nMore.\n"}, want: []string{aPath + ":35: appears more than once"}},
{name: "heading in code does not count", files: map[string]string{aPath: sub(adrBase, "## Considered Options", "```\n## Considered Options\n```")}, want: []string{aPath + ":1: missing required section \"Considered Options\""}},
{name: "section with only a comment", files: map[string]string{aPath: sub(adrBase, "* Queue\n* Inline", "<!-- none -->")}, want: []string{aPath + ":22: has no prose"}},
{name: "section with only code", files: map[string]string{aPath: sub(adrBase, "* Queue\n* Inline", "```\nQueue\n```")}, want: []string{aPath + ":22: has no prose"}},
{name: "section with only inline code", files: map[string]string{aPath: sub(adrBase, "* Queue\n* Inline", "`Queue`")}, want: []string{aPath + ":22: has no prose"}},
{name: "subsection prose counts for the section", files: map[string]string{aPath: sub(adrBase, "Jobs block requests.", "### Detail\n\nJobs block requests.")}},
{name: "setext, closing hashes and blockquote", files: map[string]string{aPath: sub(adrBase, "## Context and Problem Statement", "## Context and Problem Statement ##", "## Considered Options\n\n* Queue\n* Inline", "Considered Options\n------\n\n> Queue or inline.")}},
{name: "extra sections allowed", files: map[string]string{aPath: adrBase + "\n## More Information\n\nSee above.\n"}},
{name: "Consequences as H2", files: map[string]string{aPath: sub(adrBase, "### Consequences", "## Consequences")},
want: []string{aPath + ":31: not a ## section", aPath + ":27: must contain a ### Consequences"}},
{name: "missing Consequences", files: map[string]string{aPath: sub(adrBase, "### Consequences\n\n* One more service.\n", "")}, want: []string{aPath + ":27: must contain a ### Consequences"}},
{name: "repeated Consequences", files: map[string]string{aPath: adrBase + "\n### Consequences\n\nAgain.\n"}, want: []string{aPath + ":35: appears more than once"}},
{name: "Consequences without prose", files: map[string]string{aPath: sub(adrBase, "* One more service.", "<!-- later -->")}, want: []string{aPath + ":31: has no prose"}},
{name: "Decision Outcome without own prose", files: map[string]string{aPath: sub(adrBase, "Chosen option: Queue.\n\n", "")}, want: []string{aPath + ":27: needs prose before"}},
{name: "plan missing section", files: map[string]string{pPath: sub(planBase, "## Steps\n\n1. Do it.\n\n", "")}, want: []string{pPath + ":1: missing required section \"Steps\""}},
{name: "plan empty H1", files: map[string]string{pPath: sub(planBase, "# Adopt Queue", "#")}, want: []string{pPath + ":7: H1"}},
{name: "plan Outcome is optional", files: map[string]string{pPath: planBase + "\n## Outcome\n\nDone.\n"}},
// 6.3 Placeholders
{name: "marker in body", files: map[string]string{aPath: sub(adrBase, "Jobs block requests.", "{{docs:todo}}")}, want: []string{aPath + ":20: unfinished placeholder"}},
{name: "marker in comment", files: map[string]string{aPath: adrBase + "\n<!-- {{docs:todo}} -->\n"}, want: []string{aPath + ":35: unfinished placeholder"}},
{name: "marker in code", files: map[string]string{aPath: adrBase + "\nUse `{{docs:todo}}` as the marker.\n\n```\n{{docs:todo}}\n```\n"}},
{name: "TODO and TBD are fine", files: map[string]string{aPath: adrBase + "\nTODO and TBD.\n"}},
{name: "marker in frontmatter string", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", `summary: "{{docs:todo}}"`)}, want: []string{aPath + ":6: unfinished placeholder"}},
{name: "marker as tag reports only the marker", files: map[string]string{aPath: sub(adrBase, " - jobs", ` - "{{docs:todo}}"`)}, want: []string{aPath + ":8: unfinished placeholder"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res, err := Check(tree(t, withSupport(tt.files), tt.links))
must(t, err)
expect(t, res.Problems, tt.want)
})
}
}
func TestCheckEmptyRoot(t *testing.T) {
res, err := Check(t.TempDir())
must(t, err)
if res.Records != 0 || len(res.Problems) != 0 {
t.Fatalf("got %+v", res)
}
}