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:
2026-11-04 11:56:31 +01:00
parent 17ba95bca6
commit bd9764ef9a
8 changed files with 196 additions and 7 deletions
@@ -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
}