From cb733d1b7ceac3e417a56034be8598227de0c214 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 7 Oct 2026 23:38:13 +0200 Subject: [PATCH] records: list the decision records by topic when asked, since hq stores no list (hq ADR 0248) --- modules/records/README.md | 1 + modules/records/cmd/records/decisions.go | 142 ++++++++++++++++++++ modules/records/cmd/records/main.go | 9 +- modules/records/cmd/records/records_test.go | 60 +++++++++ modules/records/module.json | 1 + 5 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 modules/records/cmd/records/decisions.go diff --git a/modules/records/README.md b/modules/records/README.md index 68c3167a..ed23fa2c 100644 --- a/modules/records/README.md +++ b/modules/records/README.md @@ -19,6 +19,7 @@ lagging behind it, and the lag is in `records_status`. | `records_search` `{query, limit?}` | where a phrase appears, as written: document, line, nearest heading, and the commit read | | `records_read` `{path}` | one document, whole | | `records_list` `{folder?}` | what a folder holds | +| `records_decisions` `{topic?}` | the decision records by topic, in the reading order `02-DECISIONS/README.md` writes: number, title, path and status, generated when asked and never stored (hq ADR 0248) | | `records_status` | repository, forge, commit and its date, last sync, document count, last error | | `records_sync` | bring the checkout up to date now | diff --git a/modules/records/cmd/records/decisions.go b/modules/records/cmd/records/decisions.go new file mode 100644 index 00000000..54abf6e3 --- /dev/null +++ b/modules/records/cmd/records/decisions.go @@ -0,0 +1,142 @@ +package main + +// The decision records, by topic, in reading order (novox/hq ADR 0248, issue 297). +// +// The repository writes its reading order in `02-DECISIONS/README.md`, one line per topic: +// +// 1. **What the mesh is** — `topic: the mesh`. What it is for, ... +// +// and each record names its own `topic:`. The list of records under each topic is generated when it is +// read and never stored, because a stored list was a line every decision added to one file, and any two +// decisions open at once conflicted there. This is the same list hq's own `index.py --print` prints, for a +// reader on the mesh rather than in a checkout. Nothing is kept between calls: every answer is read from +// the checkout at the commit it names. + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// DecisionsFolder is where the records and their README live. +const DecisionsFolder = "02-DECISIONS" + +var ( + topicLine = regexp.MustCompile("(?m)^\\d+\\.\\s+\\*\\*([^*]+)\\*\\*\\s+—\\s+`topic:\\s*([^`]+)`") + recordFile = regexp.MustCompile(`^(\d{4})-.+\.md$`) + recordTitle = regexp.MustCompile(`(?m)^# \d+\.\s*(.+)$`) +) + +// Decision is one record, as the list names it. +type Decision struct { + Number string `json:"number"` + Title string `json:"title"` + Path string `json:"path"` + Status string `json:"status"` +} + +// Topic is one topic of the reading order and the records under it, by number. +type Topic struct { + Topic string `json:"topic"` + Label string `json:"label"` + Decisions []Decision `json:"decisions"` +} + +// DecisionsAnswer is the reading order with its records, and what fits none of its topics. +type DecisionsAnswer struct { + Topics []Topic `json:"topics"` + // Unfiled is every record whose topic is not one of the reading order's — hq's `index.py` fails on it, + // so on a merged commit it is empty. + Unfiled []Decision `json:"unfiled,omitempty"` + Commit string `json:"commit"` +} + +// frontField is one `name: value` line of a document's frontmatter. +func frontField(text, name string) string { + if !strings.HasPrefix(text, "---\n") { + return "" + } + end := strings.Index(text[4:], "\n---") + if end < 0 { + return "" + } + for _, line := range strings.Split(text[4:4+end], "\n") { + if v, ok := strings.CutPrefix(line, name+":"); ok { + return strings.TrimSpace(v) + } + } + return "" +} + +// Decisions is the records by topic, in the reading order the README writes; with a topic named, that +// topic alone. A repository that writes no reading order is answered with why, not with an empty list. +func (r *Records) Decisions(only string) (DecisionsAnswer, error) { + folder := filepath.Join(r.Dir(), DecisionsFolder) + readme, err := os.ReadFile(filepath.Join(folder, "README.md")) + if err != nil { + return DecisionsAnswer{}, fmt.Errorf("the repository holds no %s/README.md, so it writes no reading order", DecisionsFolder) + } + answer := DecisionsAnswer{Topics: []Topic{}} + at := map[string]int{} + for _, m := range topicLine.FindAllStringSubmatch(string(readme), -1) { + name := strings.TrimSpace(m[2]) + at[name] = len(answer.Topics) + answer.Topics = append(answer.Topics, Topic{Topic: name, Label: strings.TrimSpace(m[1]), Decisions: []Decision{}}) + } + if len(answer.Topics) == 0 { + return DecisionsAnswer{}, fmt.Errorf("%s/README.md writes no reading order: no line of the form "+ + "\"1. **Label** — `topic: name`. ...\"", DecisionsFolder) + } + only = strings.TrimSpace(only) + if _, ok := at[only]; only != "" && !ok { + names := make([]string, len(answer.Topics)) + for i, t := range answer.Topics { + names[i] = t.Topic + } + return DecisionsAnswer{}, fmt.Errorf("%q is not a topic of the reading order: %s", only, strings.Join(names, ", ")) + } + + entries, err := os.ReadDir(folder) + if err != nil { + return DecisionsAnswer{}, err + } + names := []string{} + for _, e := range entries { + if e.Type().IsRegular() && recordFile.MatchString(e.Name()) { + names = append(names, e.Name()) + } + } + sort.Strings(names) + for _, name := range names { + raw, err := os.ReadFile(filepath.Join(folder, name)) + if err != nil { + continue + } + text := string(raw) + d := Decision{ + Number: recordFile.FindStringSubmatch(name)[1], + Title: "(no heading)", + Path: DecisionsFolder + "/" + name, + Status: frontField(text, "status"), + } + if m := recordTitle.FindStringSubmatch(text); m != nil { + d.Title = strings.TrimSpace(m[1]) + } + topic := frontField(text, "topic") + i, ok := at[topic] + switch { + case !ok && only == "": + answer.Unfiled = append(answer.Unfiled, d) + case ok && (only == "" || only == topic): + answer.Topics[i].Decisions = append(answer.Topics[i].Decisions, d) + } + } + if only != "" { + answer.Topics = []Topic{answer.Topics[at[only]]} + } + answer.Commit = r.Standing().Commit + return answer, nil +} diff --git a/modules/records/cmd/records/main.go b/modules/records/cmd/records/main.go index 1c423aeb..e0e69c5b 100644 --- a/modules/records/cmd/records/main.go +++ b/modules/records/cmd/records/main.go @@ -1,6 +1,6 @@ // records: the mesh's record — decisions, designs and issues — read where it is written (novox/hq ADR 0025, // ADR 0153). A Go bundle the node's runtime launches; it keeps a checkout of one repository current on every -// merge the forge announces and on a timer, and answers five questions about it. stdout is the MCP channel; +// merge the forge announces and on a timer, and answers six questions about it. stdout is the MCP channel; // what this module says, it says on stderr. package main @@ -51,6 +51,11 @@ func tools(r *Records) []stdio.Tool { Description: "What a folder of the repository holds: its sub-folders and its documents. The root when no folder is named.", Input: map[string]any{"folder": str("a folder inside the repository (optional)")}, Run: func(a map[string]any) (any, error) { return r.List(strArg(a, "folder")) }}, + {Name: "records_decisions", + Description: "The decision records by topic, in the reading order the repository writes in 02-DECISIONS/README.md: " + + "each record's number, title, path and status. Generated from the records when asked, never stored (novox/hq ADR 0248).", + Input: map[string]any{"topic": str("one topic of the reading order, e.g. \"how we work\" (optional; every topic when omitted)")}, + Run: func(a map[string]any) (any, error) { return r.Decisions(strArg(a, "topic")) }}, {Name: "records_status", Description: "Where the checkout stands: the repository, the forge it is read from, the commit and its date, when it was last brought up to date.", Run: func(map[string]any) (any, error) { return r.Standing(), nil }}, @@ -118,7 +123,7 @@ func keep(r *Records) { func main() { r, err := FromEnv(os.Getenv) if err != nil { - // Without a repository to read there is nothing to answer: no tools rather than five that fail. + // Without a repository to read there is nothing to answer: no tools rather than six that fail. fmt.Fprintf(os.Stderr, "[records] no tools — %v\n", err) if err := stdio.Serve("", nil); err != nil { os.Exit(1) diff --git a/modules/records/cmd/records/records_test.go b/modules/records/cmd/records/records_test.go index b1ac8215..58010e09 100644 --- a/modules/records/cmd/records/records_test.go +++ b/modules/records/cmd/records/records_test.go @@ -145,3 +145,63 @@ func TestTheReaderRefusesToGuess(t *testing.T) { t.Fatalf("%+v %v", r, err) } } + +// The decision records by topic, in the order the README writes, generated from each record's own +// frontmatter (novox/hq ADR 0248): a record lands under its topic, by number, with its status; a record +// whose topic the reading order does not name is unfiled rather than lost; a topic asked for alone is +// answered alone; a repository with no reading order says so. +func TestTheDecisionsAreListedByTopicInTheReadingOrderTheReadmeWrites(t *testing.T) { + origin := aRepository(t) + write(t, filepath.Join(origin, "02-DECISIONS/README.md"), "# 02-DECISIONS\n\n## Reading order\n\n"+ + "1. **What the mesh is** — `topic: the mesh`. What it is for.\n"+ + "2. **How we work** — `topic: how we work`. This repository.\n") + write(t, filepath.Join(origin, "02-DECISIONS/0001-a-decision.md"), + "---\ntopic: how we work\nstatus: accepted\n---\n\n# 1. A decision\n") + write(t, filepath.Join(origin, "02-DECISIONS/0003-a-later-one.md"), + "---\ntopic: the mesh\nstatus: superseded\nsuperseded-by: 02-DECISIONS/0004-x.md\n---\n\n# 3. A later one\n") + write(t, filepath.Join(origin, "02-DECISIONS/0002-an-earlier-one.md"), + "---\ntopic: the mesh\nstatus: accepted\n---\n\n# 2. An earlier one\n") + write(t, filepath.Join(origin, "02-DECISIONS/0005-astray.md"), + "---\ntopic: elsewhere\nstatus: proposed\n---\n\n# 5. Astray\n") + run(t, origin, "add", "-A") + run(t, origin, "commit", "--quiet", "-m", "records") + + r := &Records{Base: t.TempDir(), Origin: "http://forge.invalid:3000", Repository: "novox/hq", URL: origin} + r.Sync() + got, err := r.Decisions("") + if err != nil { + t.Fatal(err) + } + if len(got.Topics) != 2 || got.Topics[0].Topic != "the mesh" || got.Topics[1].Label != "How we work" { + t.Fatalf("not the reading order: %+v", got.Topics) + } + mesh := got.Topics[0].Decisions + if len(mesh) != 2 || mesh[0].Number != "0002" || mesh[1].Number != "0003" || mesh[1].Status != "superseded" || + mesh[0].Title != "An earlier one" || mesh[0].Path != "02-DECISIONS/0002-an-earlier-one.md" { + t.Fatalf("the mesh's records: %+v", mesh) + } + if len(got.Topics[1].Decisions) != 1 || got.Topics[1].Decisions[0].Number != "0001" { + t.Fatalf("how we work's records: %+v", got.Topics[1].Decisions) + } + if len(got.Unfiled) != 1 || got.Unfiled[0].Number != "0005" { + t.Fatalf("a record off the reading order is not unfiled: %+v", got.Unfiled) + } + if got.Commit == "" { + t.Fatal("the answer names no commit") + } + + one, err := r.Decisions("how we work") + if err != nil || len(one.Topics) != 1 || one.Topics[0].Topic != "how we work" || len(one.Unfiled) != 0 { + t.Fatalf("one topic: %+v %v", one, err) + } + if _, err := r.Decisions("nowhere"); err == nil || !strings.Contains(err.Error(), "the mesh, how we work") { + t.Fatalf("an unknown topic is not refused with the topics: %v", err) + } + + write(t, filepath.Join(origin, "02-DECISIONS/README.md"), "# 02-DECISIONS\n\nNo order here.\n") + run(t, origin, "commit", "--quiet", "-am", "no order") + r.Sync() + if _, err := r.Decisions(""); err == nil || !strings.Contains(err.Error(), "writes no reading order") { + t.Fatalf("a README with no reading order is not said: %v", err) + } +} diff --git a/modules/records/module.json b/modules/records/module.json index 28065105..1ba903da 100644 --- a/modules/records/module.json +++ b/modules/records/module.json @@ -15,6 +15,7 @@ "records_search", "records_read", "records_list", + "records_decisions", "records_status", "records_sync" ],