207 lines
8.0 KiB
Go
207 lines
8.0 KiB
Go
package checks
|
|
|
|
import (
|
|
"fmt"
|
|
"io/fs"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
)
|
|
|
|
// Structural checks over hq's own documents (records.py until ADR 0527). The decision records and research
|
|
// efforts live in the knowledge base (ADR 0290, issue 426); what stays here judges what this repository still
|
|
// holds, and keeps the records from coming back.
|
|
|
|
var (
|
|
linkRx = regexp.MustCompile(`\[[^\]]*\]\(([^)]+)\)`)
|
|
// A decision record is named "ADR NNNN", four digits, and a research effort "research NNN", three.
|
|
decisionEntryRx = regexp.MustCompile(`^ADR \d{4}$`)
|
|
adrMentionRx = regexp.MustCompile(`\bADRs?\s+(\d+)\b`)
|
|
researchMentionRx = regexp.MustCompile(`\b[Rr]esearch\s+(\d+)\b`)
|
|
codeSpanRx = regexp.MustCompile("`[^`]*`")
|
|
recordFolders = []string{"02-DECISIONS", "01-RESEARCH"}
|
|
recordFolderPoints = map[string]bool{"02-DECISIONS/README.md": true, "01-RESEARCH/README.md": true}
|
|
)
|
|
|
|
// checkLinks: every relative link resolves to something that exists.
|
|
func checkLinks(fsys fs.FS) []string {
|
|
var out []string
|
|
for _, p := range markdownFiles(fsys) {
|
|
for n, line := range strings.Split(readFile(fsys, p), "\n") {
|
|
for _, m := range linkRx.FindAllStringSubmatch(line, -1) {
|
|
target := m[1]
|
|
if strings.HasPrefix(target, "http:") || strings.HasPrefix(target, "https:") || strings.HasPrefix(target, "mailto:") {
|
|
continue
|
|
}
|
|
target = strings.TrimSpace(strings.SplitN(target, "#", 2)[0])
|
|
if target == "" {
|
|
continue
|
|
}
|
|
if _, err := fs.Stat(fsys, path.Join(path.Dir(p), target)); err != nil {
|
|
out = append(out, fmt.Sprintf("links: %s:%d: link does not resolve: %s", p, n+1, target))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// checkNoRecords: the two folders the records left hold their pointer README and nothing else (ADR 0290 rule 9).
|
|
func checkNoRecords(fsys fs.FS) []string {
|
|
var out []string
|
|
for _, folder := range recordFolders {
|
|
if _, err := fs.Stat(fsys, folder); err != nil {
|
|
out = append(out, "no-records: "+folder+": the folder is gone; it keeps a README that points at the knowledge base")
|
|
continue
|
|
}
|
|
_ = fs.WalkDir(fsys, folder, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil || p == folder || recordFolderPoints[p] {
|
|
return nil
|
|
}
|
|
out = append(out, "no-records: "+p+": a decision record or research effort lives in the knowledge base, never here: "+
|
|
"write it with knowledge-base.reserve and knowledge-base.propose (playbooks 01 and 02)")
|
|
return nil
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// checkDecisionsEntries: a design's `decisions:` names each record as "ADR NNNN".
|
|
func checkDecisionsEntries(fsys fs.FS) []string {
|
|
var out []string
|
|
for _, p := range markdownFiles(fsys) {
|
|
if !strings.HasPrefix(p, "03-DESIGN/") {
|
|
continue
|
|
}
|
|
for _, e := range frontmatter(readFile(fsys, p)).list("decisions") {
|
|
if !decisionEntryRx.MatchString(unquote(e)) {
|
|
out = append(out, fmt.Sprintf("decisions-form: %s: decisions: entry %q is not 'ADR NNNN' (four digits)", p, e))
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// checkMentions: in running text a decision record is "ADR NNNN" and a research effort "research NNN"; a code
|
|
// span is an example and is not read.
|
|
func checkMentions(fsys fs.FS) []string {
|
|
var out []string
|
|
for _, p := range markdownFiles(fsys) {
|
|
for n, line := range strings.Split(readFile(fsys, p), "\n") {
|
|
line = codeSpanRx.ReplaceAllString(line, "")
|
|
for _, m := range adrMentionRx.FindAllStringSubmatch(line, -1) {
|
|
if len(m[1]) != 4 {
|
|
out = append(out, fmt.Sprintf("mention-form: %s:%d: %q: a decision record is named 'ADR NNNN', four digits", p, n+1, m[0]))
|
|
}
|
|
}
|
|
for _, m := range researchMentionRx.FindAllStringSubmatch(line, -1) {
|
|
if len(m[1]) != 3 {
|
|
out = append(out, fmt.Sprintf("mention-form: %s:%d: %q: a research effort is named 'research NNN', three digits", p, n+1, m[0]))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
var codeEntryRx = regexp.MustCompile(`\s*\(.*\)$`)
|
|
|
|
// checkStatusAgainstCode: a to-be design naming specific code (a path after the repository) may not still be
|
|
// `designed`. Deliberately weak: it cannot tell whether the prose is true, only that a document stopped claiming
|
|
// to be unbuilt once it points at something. `code: [mesh-controller]` is a plan and stays `designed`.
|
|
func checkStatusAgainstCode(fsys fs.FS) []string {
|
|
var out []string
|
|
for _, p := range markdownFiles(fsys) {
|
|
if !strings.HasPrefix(p, "03-DESIGN/01-to-be/") || strings.HasSuffix(p, "README.md") {
|
|
continue
|
|
}
|
|
front := frontmatter(readFile(fsys, p))
|
|
if front.scalar("status") != "designed" {
|
|
continue
|
|
}
|
|
for _, e := range front.list("code") {
|
|
named := strings.SplitN(strings.TrimSpace(codeEntryRx.ReplaceAllString(e, "")), " ", 2)
|
|
if len(named) > 1 {
|
|
out = append(out, fmt.Sprintf("status-vs-code: %s: `designed`, but names %q in %s. Naming a file claims it implements this "+
|
|
"- use `in-progress`, or `implemented` once it is defensible from that repository's main branch.", p, strings.TrimSpace(named[1]), named[0]))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func doc(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} }
|
|
|
|
// The real tree.
|
|
|
|
func TestRecordsLinksResolve(t *testing.T) { expectNone(t, checkLinks(rootFS())) }
|
|
func TestRecordsFoldersHoldOnlyPointers(t *testing.T) { expectNone(t, checkNoRecords(rootFS())) }
|
|
func TestRecordsDecisionsEntriesForm(t *testing.T) { expectNone(t, checkDecisionsEntries(rootFS())) }
|
|
func TestRecordsMentionForm(t *testing.T) { expectNone(t, checkMentions(rootFS())) }
|
|
func TestRecordsStatusAgainstCode(t *testing.T) { expectNone(t, checkStatusAgainstCode(rootFS())) }
|
|
|
|
// The checks themselves, each against a broken fixture, and against a sound one.
|
|
|
|
func TestRecordsLinksCatchesABrokenLink(t *testing.T) {
|
|
broken := fstest.MapFS{
|
|
"a/one.md": doc("see [two](two.md) and [gone](gone.md#x) and [web](https://example.com/x)\n"),
|
|
"a/two.md": doc("[up](../a/one.md#top) [anchor](#here)\n"),
|
|
}
|
|
got := checkLinks(broken)
|
|
expectProblem(t, got, "a/one.md:1: link does not resolve: gone.md")
|
|
if len(got) != 1 {
|
|
t.Errorf("only the broken link is a problem, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRecordsNoRecordsCatchesARecordPutBack(t *testing.T) {
|
|
broken := fstest.MapFS{
|
|
"02-DECISIONS/README.md": doc("pointer"),
|
|
"02-DECISIONS/0001-x.md": doc("a record"),
|
|
"01-RESEARCH/README.md": doc("pointer"),
|
|
"01-RESEARCH/045/00-x.md": doc("an effort"),
|
|
}
|
|
got := checkNoRecords(broken)
|
|
expectProblem(t, got, "02-DECISIONS/0001-x.md")
|
|
expectProblem(t, got, "01-RESEARCH/045")
|
|
expectProblem(t, checkNoRecords(fstest.MapFS{"02-DECISIONS/README.md": doc("p")}), "01-RESEARCH: the folder is gone")
|
|
}
|
|
|
|
func TestRecordsDecisionsFormCatchesAShortEntry(t *testing.T) {
|
|
broken := fstest.MapFS{
|
|
"03-DESIGN/01-to-be/1.md": doc("---\nlayer: to-be\ndecisions:\n - ADR 0290\n - ADR 290\n---\n"),
|
|
"00-META/x.md": doc("---\ndecisions: [ADR 5]\n---\n"),
|
|
}
|
|
got := checkDecisionsEntries(broken)
|
|
expectProblem(t, got, `entry "ADR 290"`)
|
|
if len(got) != 1 {
|
|
t.Errorf("only a design's entries are read, and ADR 0290 is sound: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRecordsMentionFormCatchesShortNumbers(t *testing.T) {
|
|
broken := fstest.MapFS{"x.md": doc("fine ADR 0290 and research 045\nbad ADR 12 and research 45\nexample `ADR 7` and `research 9`\n")}
|
|
got := checkMentions(broken)
|
|
expectProblem(t, got, "x.md:2: \"ADR 12\"")
|
|
expectProblem(t, got, "x.md:2: \"research 45\"")
|
|
if len(got) != 2 {
|
|
t.Errorf("code spans and sound forms are not problems: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRecordsStatusAgainstCodeCatchesADesignedDesignNamingAFile(t *testing.T) {
|
|
broken := fstest.MapFS{
|
|
"03-DESIGN/01-to-be/9.md": doc("---\nstatus: designed\ncode:\n - mesh-controller internal/x.go (the thing)\n---\n"),
|
|
"03-DESIGN/01-to-be/8.md": doc("---\nstatus: designed\ncode: [mesh-controller]\n---\n"),
|
|
"03-DESIGN/01-to-be/7.md": doc("---\nstatus: in-progress\ncode: [mesh-controller internal/x.go]\n---\n"),
|
|
}
|
|
got := checkStatusAgainstCode(broken)
|
|
expectProblem(t, got, "03-DESIGN/01-to-be/9.md: `designed`, but names \"internal/x.go\" in mesh-controller")
|
|
if len(got) != 1 {
|
|
t.Errorf("a plan without a path and an in-progress design are sound: %q", got)
|
|
}
|
|
}
|