diff --git a/00-META/checks/cycle_test.go b/00-META/checks/cycle_test.go new file mode 100644 index 00000000..f7b2bd10 --- /dev/null +++ b/00-META/checks/cycle_test.go @@ -0,0 +1,169 @@ +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) + } +}