From bd9764ef9a6472b3265c41d31a33d05fd3a62b0c Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 11:55:14 +0100 Subject: [PATCH] CAT-K5: a section is a part with a plain title, never a file; a reference has one form A new rule, section-form (31 now), refuses from the day the records move a section whose title is a file name, a number prefix, a slash or over 200 bytes, a title twice in a record, and a text that holds a local path, a file and line, or a link to a file. Only a section that is new or changed is judged, so a record imported with file-named sections keeps them until its form-only version; write refuses at once. Issue 1966. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- modules/mesh-knowledge/README.md | 2 +- .../mesh-knowledge/cmd/mesh-knowledge/main.go | 2 +- .../cmd/mesh-knowledge/rules.go | 23 +++- .../cmd/mesh-knowledge/rules_test.go | 7 +- .../cmd/mesh-knowledge/sectionform.go | 101 ++++++++++++++++++ .../cmd/mesh-knowledge/sectionform_test.go | 63 +++++++++++ .../cmd/mesh-knowledge/writing.go | 4 + .../cmd/mesh-knowledge/writing_test.go | 1 + 8 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 modules/mesh-knowledge/cmd/mesh-knowledge/sectionform.go create mode 100644 modules/mesh-knowledge/cmd/mesh-knowledge/sectionform_test.go diff --git a/modules/mesh-knowledge/README.md b/modules/mesh-knowledge/README.md index 2a55aaec..55f45b49 100644 --- a/modules/mesh-knowledge/README.md +++ b/modules/mesh-knowledge/README.md @@ -77,7 +77,7 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel ## Writing a record 1. `reserve` a number for its kind. -2. `propose` the record whole. It is refused, naming every reason at once, when it breaks one of the **30 rules +2. `propose` the record whole. It is refused, naming every reason at once, when it breaks one of the **31 rules that judge one record or its prose**. They are hq's checks moved to the write; `PerRecordRules` in `rules.go` lists each one with the check it comes from: - 25 on the structure: kind, reserved number, title, topic, date, deciders, research status, at least one diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go index 5bf79f4a..85122f86 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go @@ -985,7 +985,7 @@ func verbTools(k *KB, caller func(map[string]any) string) []stdio.Tool { "clauses, each opening with a bold lead (\"**What is decided.** …\"), and its reasoning in sections (Context, " + "Considered Options, Consequences, How it is checked, References); for a research effort its status and documents; its " + "links (supersedes, changes \"0276 §4\", extends, became, cites), and whether it needs the operator, with the brief its ask shows. Refused, naming " + - "every reason at once and writing nothing, when it breaks one of the 30 rules that judge one record or its prose — a " + + "every reason at once and writing nothing, when it breaks one of the 31 rules that judge one record or its prose — a " + "link to nothing, a clause without a bold lead, a superseded record cited alone, an unknown topic, a retired word. A " + "proposal is revised by proposing it again; an accepted record (an active research effort excepted, which takes new versions) changes only by a progressive insight (insight: what " + "stood before). A research effort's new version may leave out its sections, and its title, date and deciders: it then " + diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go b/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go index 1376c0fc..6e56bfc7 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go @@ -2,7 +2,7 @@ package main // The rules a record is held to (novox/hq ADR 0290 rule 4, to-be 55 §4 and §5). // -// **30 rules judge one record or its prose**, and `propose` refuses a record that breaks any of them, naming every +// **31 rules judge one record or its prose**, and `propose` refuses a record that breaks any of them, naming every // one it breaks at once. They are hq's checks (00-META/checks/records.py, cycle.py, index.py, words.py) as they // judge one record, moved to the write: 25 on its structure, 4 on its prose; and the brief a record that needs // the operator is asked with (novox/hq issue 639). Each has an id, said in the refusal, @@ -28,7 +28,7 @@ type Rule struct { From string } -// PerRecordRules are the 30, in the order they are judged. +// PerRecordRules are the 31, in the order they are judged. var PerRecordRules = []Rule{ {"kind", "a record is a decision or a research effort", "cycle.py"}, {"reserved", "its number was reserved for its kind and holds no other record", "cycle.py (duplicate numbers)"}, @@ -60,6 +60,7 @@ var PerRecordRules = []Rule{ {"deferral", "a paragraph that puts work off names the issue that tracks it", "cycle.py deferrals"}, {"insight-form", "a progressive insight its text mentions is in the dated, marked form", "records.py insights"}, {"brief", "a record that needs the operator carries a brief its ask reads at a glance: a headline, a summary, at most three points, each fit for the phone", "novox/hq issue 639"}, + {"section-form", "a section is a part with a plain title, once in the record, and its text holds no local path, no file and line, and no link to a file", "to-be 55 §2.2"}, } // Refusal is one rule a record breaks, and how. @@ -120,7 +121,7 @@ func allText(r *Record) string { return b.String() } -// JudgeRecord holds a proposed record to the 30 rules. held is the entry its number holds (a reservation, the +// JudgeRecord holds a proposed record to the 31 rules. held is the entry its number holds (a reservation, the // proposal being revised, or the accepted record an insight corrects); lookup reads every other record. func JudgeRecord(r *Record, held *Entry, lookup Lookup, ref *Reference) []Refusal { return JudgeRecordOn(r, held, lookup, ref, r.Date) @@ -320,6 +321,19 @@ func JudgeRecordOn(r *Record, held *Entry, lookup Lookup, ref *Reference, day st } } } + seen := map[string]bool{} + for _, s := range r.Sections { + if seen[s.Name] { + refuse("section-form", "the section %q is in the record twice: a title is once in its record", s.Name) + } + seen[s.Name] = true + if unchangedSection(held, s) { + continue + } + for _, p := range sectionFormProblems(s.Name, s.Text) { + refuse("section-form", "%s", p) + } + } text := allText(r) if words := ref.RetiredIn(text); len(words) > 0 { refuse("retired-word", "it uses %s, retired by the glossary", quoteAll(unique(words))) @@ -616,6 +630,9 @@ var RuleFrom = map[string]string{ "checked": "2026-10-11", "live-citation": "2026-10-11", "brief": "2026-10-13", + // A section is a part with a title, never a file (to-be 55 §2.2): the day the records move to the ledger. A record + // imported with file-named sections keeps them until its form-only version, so a section left alone is not judged. + "section-form": "2026-11-04", } // DecisionFrom overrides RuleFrom for a decision record: hq's words check never read 02-DECISIONS ("a decision diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/rules_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/rules_test.go index e07634ea..5d39d596 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/rules_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/rules_test.go @@ -93,6 +93,9 @@ func TestEveryPerRecordRuleRefusesAProposal(t *testing.T) { {"brief", func(p *ProposeAsk, w *world, t *testing.T) { p.Date, p.NeedsOperator = "2026-10-13", true }}, + {"section-form", func(p *ProposeAsk, w *world, t *testing.T) { + p.Date, p.Sections[0].Name = "2026-11-04", "00-context.md" + }}, } covered := map[string]bool{} for _, c := range cases { @@ -103,8 +106,8 @@ func TestEveryPerRecordRuleRefusesAProposal(t *testing.T) { t.Errorf("the rule %s has no case", r.ID) } } - if len(PerRecordRules) != 30 { - t.Errorf("%d per-record rules; ADR 0290 counts 29, and issue 639 adds the brief", len(PerRecordRules)) + if len(PerRecordRules) != 31 { + t.Errorf("%d per-record rules; ADR 0290 counts 29, issue 639 adds the brief and to-be 55 §2.2 the form of a section", len(PerRecordRules)) } for _, c := range cases { t.Run(c.rule, func(t *testing.T) { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/sectionform.go b/modules/mesh-knowledge/cmd/mesh-knowledge/sectionform.go new file mode 100644 index 00000000..e065927d --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/sectionform.go @@ -0,0 +1,101 @@ +package main + +// A section is a part with a title, never a file (novox/hq to-be 55 §2.2, ADR 0592 §29, §30): its name is a plain title, +// at most 200 bytes, unique in its record; its text holds no local path, no reference to a file and line, and no link +// whose target is a relative path or a file. A reference has one form: the record, a section's title, then a row or +// clause, such as "research 081 › Direction › D3" or "ADR 0290 §7". + +import ( + "fmt" + "regexp" + "strings" +) + +// MostTitleBytes is the longest a section's title is. +const MostTitleBytes = 200 + +var ( + // A name that looks like a file: it has an extension, a number prefix such as 00-, or a slash. + fileExtension = regexp.MustCompile(`\.[A-Za-z][A-Za-z0-9]{0,4}$`) + numberPrefix = regexp.MustCompile(`^\d{1,3}[-_.]`) + // A local path: in a person's home folder or the temporary folder. + localPath = regexp.MustCompile(`(?:^|[\s("'` + "`" + `])(?:/home/[^\s/)]+/|/Users/[^\s/)]+/|/tmp/|/var/tmp/|~/|[A-Za-z]:\\)`) + // A reference to a file and line: a name with a known extension, a colon and a number. + fileAndLine = regexp.MustCompile(`\b[\w./-]*\w\.(?:go|md|json|ts|tsx|js|py|sh|html|css|yaml|yml|toml|txt|sql|rs|c|h):\d+\b`) + // A markdown link, and its target. + sectionLink = regexp.MustCompile(`\]\(([^)\s]+)(?:\s+"[^"]*")?\)`) +) + +// linkToFile says whether a link's target is a relative path or a file, not an address or an anchor. +func linkToFile(target string) bool { + switch { + case strings.HasPrefix(target, "#"), strings.HasPrefix(target, "mailto:"): + return false + case regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*://`).MatchString(target): + return false + } + return true +} + +// sectionNameProblems are the ways a section's name is not a plain title. +func sectionNameProblems(name string) []string { + var out []string + switch { + case strings.TrimSpace(name) == "": + return []string{"a section has no title"} + case len(name) > MostTitleBytes: + return []string{fmt.Sprintf("the section %.40q… has a title of %d bytes; a title is at most %d", name, len(name), MostTitleBytes)} + case strings.ContainsAny(name, "\n\r"): + return []string{fmt.Sprintf("the section %.40q has a title of more than one line", name)} + } + switch { + case strings.Contains(name, "/"): + out = append(out, fmt.Sprintf("the section %q has a slash in its title: a title is plain words, never a path", name)) + case numberPrefix.MatchString(name): + out = append(out, fmt.Sprintf("the section %q starts with a number prefix such as 00-: a title is plain words, never a file's name", name)) + case fileExtension.MatchString(name): + out = append(out, fmt.Sprintf("the section %q ends in a file extension: a title is plain words, never a file's name", name)) + } + return out +} + +// sectionTextProblems are the ways a section's text holds a file, not a reference of the one form. +func sectionTextProblems(name, text string) []string { + var out []string + if m := localPath.FindString(text); m != "" { + out = append(out, fmt.Sprintf("the section %q holds a local path (%s…): say what it is, not where it was on a machine", name, strings.TrimSpace(m))) + } + if m := fileAndLine.FindString(text); m != "" { + out = append(out, fmt.Sprintf("the section %q refers to a file and line (%s): name the function or the record's section instead", name, m)) + } + for _, l := range sectionLink.FindAllStringSubmatch(text, -1) { + if linkToFile(l[1]) { + out = append(out, fmt.Sprintf("the section %q links to a file (%s): refer to a record as \"ADR 0290 §7\" or \"research 081 › Direction\"", name, l[1])) + break + } + } + return out +} + +// sectionFormProblems are all of them, for one section. +func sectionFormProblems(name, text string) []string { + return append(sectionNameProblems(name), sectionTextProblems(name, text)...) +} + +// unchangedSection says whether the record already holds a section of this name and text: a record imported with +// file-named sections keeps them until its form-only version, and a section left alone is not judged again. +func unchangedSection(held *Entry, s Section) bool { + if held == nil { + return false + } + for _, r := range []*Record{held.Current, held.Pending} { + if r != nil { + for _, h := range r.Sections { + if h.Name == s.Name && h.Text == s.Text { + return true + } + } + } + } + return false +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/sectionform_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/sectionform_test.go new file mode 100644 index 00000000..e0e54455 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/sectionform_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "strings" + "testing" +) + +// A section is a part with a title, never a file; a reference has one form (to-be 55 §2.2, AC49). +func TestASectionIsAPartWithATitleNeverAFile(t *testing.T) { + for _, c := range []struct { + name, title, text string + refused string // what the refusal says; empty when the section is taken + }{ + {"a plain title", "How it is checked", "Each rule has a test; see ADR 0290 §7.", ""}, + {"a title with a dot inside", "Phase 1.5 and the rest", "text", ""}, + {"an extension", "overview.md", "text", "file extension"}, + {"a number prefix", "01 findings", "text", ""}, + {"a number prefix and dash", "01-findings", "text", "number prefix"}, + {"a slash", "design/notes", "text", "slash"}, + {"a title over 200 bytes", strings.Repeat("a", 201), "text", "at most 200"}, + {"no title", " ", "text", "no title"}, + {"a home folder", "Notes", "I read /home/someone/projects/x.txt on the machine.", "local path"}, + {"the temporary folder", "Notes", "The output was in /var/tmp/run1.", "local path"}, + {"a file and line", "Notes", "The bug is in kb.go:412 of the module.", "file and line"}, + {"a relative link", "Notes", "See [the record](0290-a-decision-record.md).", "links to a file"}, + {"a parent link", "Notes", "See [it](../01-RESEARCH/x).", "links to a file"}, + {"an address", "Notes", "See [the forge](https://forge.example/x.md) and [below](#notes).", ""}, + {"a version number", "Notes", "It is v1.2:3 in the table.", ""}, + } { + got := strings.Join(sectionFormProblems(c.title, c.text), "; ") + if (c.refused == "") != (got == "") || !strings.Contains(got, c.refused) { + t.Errorf("%s: %q", c.name, got) + } + } +} + +// The rule holds a record from the day the records move, and judges only a section that is new or changed: a record +// imported with file-named sections keeps them until its form-only version. A write refuses it at once. +func TestTheFormOfASectionIsHeldAtTheWriteAndOnlyOnWhatChanged(t *testing.T) { + w, _ := ledgerWorld(t) + r, _ := w.k.ReserveNumber("author@g14", KindResearch, "an effort") + key := Key{KindResearch, r.Number} + old := effortAsk(r.Number, StatusActive, Section{Name: "00-overview.md", Text: "An older effort, see [x](x.md)."}) + w.accepted(t, "author@g14", old) // dated before the rule: taken, file-named as it was + next := old + next.Date = "" + next.Why = "a new version" + next.Sections = append(next.Sections, Section{Name: "01-findings.md", Text: "More."}) + w.clock = w.clock.AddDate(0, 0, 30) // the day of the proposal is past the rule's first day + _, err := w.k.Propose("author@g14", next) + if err == nil || !strings.Contains(err.Error(), "- section-form:") || strings.Contains(err.Error(), "00-overview.md") { + t.Fatalf("a new file-named section, with the old one left alone: %v", err) + } + if _, err := w.k.Open("author@g14", ProposeAsk{Kind: KindResearch, Number: r.Number, Status: StatusActive}); err != nil { + t.Fatal(err) + } + if _, err := w.k.Write("author@g14", key, "02-more.md", "x"); err == nil || !strings.Contains(err.Error(), "nothing was written") { + t.Fatalf("a write of a file-named section: %v", err) + } + if _, err := w.k.Write("author@g14", key, "More", "see /tmp/x"); err == nil { + t.Fatal("a write of a local path") + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/writing.go b/modules/mesh-knowledge/cmd/mesh-knowledge/writing.go index 92673137..94934a65 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/writing.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/writing.go @@ -257,6 +257,10 @@ func (k *KB) Write(caller string, key Key, title, text string) (*Written, error) if len(text) > MostSectionBytes { return nil, fmt.Errorf("the section %q is %d bytes; a section holds at most %d: split it. Nothing was written", title, len(text), MostSectionBytes) } + // A section is a part with a title, never a file: learned on the section just written (to-be 55 §2.2, §4). + if problems := sectionFormProblems(title, text); len(problems) > 0 { + return nil, fmt.Errorf("the section is refused, and nothing was written:\n- %s", strings.Join(problems, "\n- ")) + } ctx := k.ctxFor(caller) at := -1 for i, r := range d.Sections { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/writing_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/writing_test.go index 0c9cb00f..914b9db6 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/writing_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/writing_test.go @@ -32,6 +32,7 @@ func TestADraftIsWrittenOneSectionAtATimeAndSubmittedAsAVersion(t *testing.T) { r, _ := w.k.ReserveNumber("author@g14", KindDecision, "a test record") p := valid(r.Number) secs := p.Sections + secs[0].Text = "The merge window of ADR 0276 §1 waits too long." // a reference of the one form; a link to a file is refused at the write p.Sections = nil key := Key{KindDecision, r.Number}