170 lines
6.9 KiB
Go
170 lines
6.9 KiB
Go
package checks
|
|
|
|
import (
|
|
"fmt"
|
|
"io/fs"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
)
|
|
|
|
// The development cycle, checked (cycle.py until ADR 0527; ADR 0080). Everything here reads frontmatter only,
|
|
// because a status this repository holds lives in frontmatter and nowhere else. The decision records and research
|
|
// efforts live in the knowledge base (ADR 0290), whose rules judge them.
|
|
|
|
var designStatuses = map[string]bool{"proposed": true, "designed": true, "in-progress": true, "implemented": true, "abandoned": true}
|
|
|
|
// The keys an issue report carried while its status lived here. The issue tracker holds them now (ADR 0275), and
|
|
// `issue-tracker.move` enforces the cycle's rules at the write. A report carrying one is a status in two places.
|
|
var trackerKeys = []string{"status", "located-in", "fixed-by", "amended-design", "replay", "replay-none", "resolved"}
|
|
|
|
// checkDesigns: every design parses, carries `layer:` matching its folder and a known `status:`; a to-be design
|
|
// names a decision, and once in-progress or implemented names its owning code. As-is designs need no decisions.
|
|
func checkDesigns(fsys fs.FS) []string {
|
|
var out []string
|
|
for _, layer := range []struct{ folder, name string }{{"00-as-is", "as-is"}, {"01-to-be", "to-be"}} {
|
|
entries, _ := fs.ReadDir(fsys, "03-DESIGN/"+layer.folder)
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || e.Name() == "README.md" {
|
|
continue
|
|
}
|
|
p := path.Join("03-DESIGN", layer.folder, e.Name())
|
|
front := frontmatter(readFile(fsys, p))
|
|
if front == nil {
|
|
out = append(out, "cycle: "+p+": no frontmatter")
|
|
continue
|
|
}
|
|
if front.scalar("layer") != layer.name {
|
|
out = append(out, fmt.Sprintf("cycle: %s: layer is %q; this directory is %s", p, front.scalar("layer"), layer.name))
|
|
}
|
|
status := front.scalar("status")
|
|
if !designStatuses[status] {
|
|
var known []string
|
|
for s := range designStatuses {
|
|
known = append(known, s)
|
|
}
|
|
sort.Strings(known)
|
|
out = append(out, fmt.Sprintf("cycle: %s: status %q is not one of %v", p, status, known))
|
|
}
|
|
if layer.name == "to-be" {
|
|
if len(front.list("decisions")) == 0 {
|
|
out = append(out, "cycle: "+p+": names no decisions -- no design without a decision")
|
|
}
|
|
if (status == "in-progress" || status == "implemented") && len(front.list("code")) == 0 {
|
|
out = append(out, fmt.Sprintf("cycle: %s: status %s but code: names no owner -- no development without a design that says where", p, status))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// checkIssues: no two issue folders share a number (issue 155: the number is how a record is cited), every report
|
|
// has frontmatter with `opened:`, and none carries a key the tracker holds.
|
|
func checkIssues(fsys fs.FS) []string {
|
|
var out []string
|
|
entries, _ := fs.ReadDir(fsys, "04-ISSUES")
|
|
seen := map[string]string{}
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
number := strings.SplitN(e.Name(), "-", 2)[0]
|
|
if number == "" || strings.Trim(number, "0123456789") != "" {
|
|
continue
|
|
}
|
|
if first, ok := seen[number]; ok {
|
|
out = append(out, fmt.Sprintf("cycle: 04-ISSUES/%s: is numbered %s, and so is %s -- an issue number is how it is cited, and two records "+
|
|
"answering to one means a citation that resolves to whichever the reader opened. Take the next free number across main AND every open pull request", e.Name(), number, first))
|
|
} else {
|
|
seen[number] = e.Name()
|
|
}
|
|
}
|
|
for _, e := range entries {
|
|
p := path.Join("04-ISSUES", e.Name(), "00-report.md")
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
if _, err := fs.Stat(fsys, p); err != nil {
|
|
continue
|
|
}
|
|
front := frontmatter(readFile(fsys, p))
|
|
if front == nil {
|
|
out = append(out, "cycle: "+p+": no frontmatter")
|
|
continue
|
|
}
|
|
if front.scalar("opened") == "" {
|
|
out = append(out, "cycle: "+p+": opened: says nothing -- the day the issue was opened, YYYY-MM-DD")
|
|
continue
|
|
}
|
|
var carried []string
|
|
for _, k := range trackerKeys {
|
|
if _, ok := front[k]; ok {
|
|
carried = append(carried, "`"+k+":`")
|
|
}
|
|
}
|
|
if len(carried) > 0 {
|
|
out = append(out, fmt.Sprintf("cycle: %s: carries %s, but an issue's status, owner, fix and replay live in the issue tracker (ADR 0275): "+
|
|
"move it with `issue-tracker.move`, and keep no copy here", p, strings.Join(carried, ", ")))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestCycleDesignsHoldTheChain(t *testing.T) { expectNone(t, checkDesigns(rootFS())) }
|
|
func TestCycleIssuesHoldTheChain(t *testing.T) { expectNone(t, checkIssues(rootFS())) }
|
|
|
|
func design(layer, status, extra string) *fstest.MapFile {
|
|
return doc("---\nlayer: " + layer + "\nstatus: " + status + "\n" + extra + "---\nbody\n")
|
|
}
|
|
|
|
func TestCycleDesignsCatchesEachBreak(t *testing.T) {
|
|
broken := fstest.MapFS{
|
|
"03-DESIGN/00-as-is/a-nofront.md": doc("no frontmatter"),
|
|
"03-DESIGN/00-as-is/b-wronglayer.md": design("to-be", "implemented", ""),
|
|
"03-DESIGN/00-as-is/c-badstatus.md": design("as-is", "done", ""),
|
|
"03-DESIGN/01-to-be/d-nodecision.md": design("to-be", "designed", ""),
|
|
"03-DESIGN/01-to-be/e-nocode.md": design("to-be", "in-progress", "decisions: [ADR 0001]\n"),
|
|
"03-DESIGN/01-to-be/README.md": doc("not a design"),
|
|
}
|
|
got := checkDesigns(broken)
|
|
expectProblem(t, got, "a-nofront.md: no frontmatter")
|
|
expectProblem(t, got, `b-wronglayer.md: layer is "to-be"; this directory is as-is`)
|
|
expectProblem(t, got, `c-badstatus.md: status "done" is not one of`)
|
|
expectProblem(t, got, "d-nodecision.md: names no decisions")
|
|
expectProblem(t, got, "e-nocode.md: status in-progress but code: names no owner")
|
|
if len(got) != 5 {
|
|
t.Errorf("exactly the five breaks, as-is needs no decisions and a README is no design: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestCycleDesignsAcceptsASoundPair(t *testing.T) {
|
|
sound := fstest.MapFS{
|
|
"03-DESIGN/00-as-is/a.md": design("as-is", "implemented", ""),
|
|
"03-DESIGN/01-to-be/b.md": design("to-be", "in-progress", "decisions: [ADR 0001]\ncode: [mesh-sdk]\n"),
|
|
}
|
|
expectNone(t, checkDesigns(sound))
|
|
}
|
|
|
|
func TestCycleIssuesCatchesEachBreak(t *testing.T) {
|
|
report := func(front string) *fstest.MapFile { return doc("---\n" + front + "---\nbody\n") }
|
|
broken := fstest.MapFS{
|
|
"04-ISSUES/100-a/00-report.md": report("opened: 2026-10-01\n"),
|
|
"04-ISSUES/100-b/00-report.md": report("opened: 2026-10-01\n"),
|
|
"04-ISSUES/101-noopened/00-report.md": report("title: x\n"),
|
|
"04-ISSUES/102-tracker/00-report.md": report("opened: 2026-10-01\nstatus: open\nreplay: none\n"),
|
|
"04-ISSUES/103-nofront/00-report.md": doc("plain"),
|
|
"04-ISSUES/notes/readme.md": doc("not an issue"),
|
|
}
|
|
got := checkIssues(broken)
|
|
expectProblem(t, got, "04-ISSUES/100-b: is numbered 100, and so is 100-a")
|
|
expectProblem(t, got, "101-noopened/00-report.md: opened: says nothing")
|
|
expectProblem(t, got, "102-tracker/00-report.md: carries `status:`, `replay:`")
|
|
expectProblem(t, got, "103-nofront/00-report.md: no frontmatter")
|
|
if len(got) != 4 {
|
|
t.Errorf("exactly the four breaks: %q", got)
|
|
}
|
|
}
|