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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 " +
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user