mesh/merge-gate pass: builds mesh-knowledge → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A changes link onto a drafted clause was refused, so records that really change such a rule could only cite it (ADR 0294, 0301, 0320); and return renumbered ADR 0281's clauses, pointing other records' links at other clauses. The changed drafted clause keeps its number and stays unserved; return refuses a record whose clauses another record changed or links by number. Issue 494, ADR 0321.
618 lines
24 KiB
Go
618 lines
24 KiB
Go
package main
|
|
|
|
// The rules a record is held to (novox/hq ADR 0290 rule 4, to-be 55 §4 and §5).
|
|
//
|
|
// **29 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. Each has an id, said in the refusal,
|
|
// and one case in the table test (TestEveryPerRecordRuleRefusesAProposal).
|
|
//
|
|
// **4 rules judge the whole record set** (wholeSetRules) and run after every acceptance, and in the check
|
|
// process every few minutes: a failure is kept as a finding, which the module's health answers, so the mesh
|
|
// raises a condition naming the rule and the records. It does not undo the acceptance.
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// Rule is one rule that judges one record: its id and what it holds.
|
|
type Rule struct {
|
|
ID string
|
|
Holds string
|
|
// From is the hq check it comes from.
|
|
From string
|
|
}
|
|
|
|
// PerRecordRules are the 29, 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)"},
|
|
{"title", "it has a title, one line of at most 200 characters", "records.py numbering"},
|
|
{"topic", "a decision's topic is one of the reading order's", "records.py topics, index.py"},
|
|
{"date", "its date is a day, YYYY-MM-DD", "records.py insights, cycle.py deferrals"},
|
|
{"deciders", "a decision names who decided it", "records.py (deciders on every record)"},
|
|
{"research-status", "a research effort is active, graduated or abandoned", "cycle.py research"},
|
|
{"has-clause", "a decision has at least one clause", "records.py clauses"},
|
|
{"clause-numbering", "its clauses are numbered from 1, in order, each once", "records.py clauses"},
|
|
{"clause-lead", "every clause opens with a bold lead", "records.py clauses"},
|
|
{"clause-size", "every clause says something, in at most 2000 bytes", "records.py clauses"},
|
|
{"context", "a decision has a Context section", "the decision record's form"},
|
|
{"consequences", "a decision has a Consequences section", "the decision record's form"},
|
|
{"checked", "a decision says how it is checked", "AGENTS.md: a rule states how it is checked"},
|
|
{"overview", "a research effort has its overview", "cycle.py research"},
|
|
{"link-kind", "every link is of a known kind and goes out from the record or one of its clauses", "records.py links"},
|
|
{"link-target", "every link names a record, and a clause, that exist", "records.py links, rests-on"},
|
|
{"link-fits", "a link's kind fits its ends", "cycle.py research became, records.py supersession"},
|
|
{"self-link", "no link points at the record itself", "records.py supersession"},
|
|
{"rests-on", "a record it extends is accepted or proposed, never superseded or withdrawn", "records.py rests-on"},
|
|
{"supersedes-accepted", "a record it supersedes or changes is accepted", "records.py supersession, partial-supersession"},
|
|
{"changes-in-force", "a clause it changes is in force or changed, never superseded or proposed; a drafted one may be changed", "records.py partial-supersession"},
|
|
{"live-citation", "a superseded record it cites is cited beside the record that replaced it", "records.py live-citation"},
|
|
{"became", "a graduated research effort names what it became", "cycle.py research"},
|
|
{"text-links", "every link its text writes to a decision record or research effort names one the knowledge base holds", "records.py links"},
|
|
{"retired-word", "its prose uses no word the glossary retired", "words.py retired"},
|
|
{"daemon-line", "it writes no daemon's line", "words.py lines"},
|
|
{"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"},
|
|
}
|
|
|
|
// Refusal is one rule a record breaks, and how.
|
|
type Refusal struct {
|
|
Rule string `json:"rule"`
|
|
Says string `json:"says"`
|
|
}
|
|
|
|
func (r Refusal) String() string { return r.Rule + ": " + r.Says }
|
|
|
|
// Lookup is the served version of a record the knowledge base holds, or nil.
|
|
type Lookup func(Key) *Record
|
|
|
|
// MostClauseBytes is the most one clause may hold.
|
|
const MostClauseBytes = 2000
|
|
|
|
var (
|
|
isDay = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
|
deferral = regexp.MustCompile(`(?i)until (?:it is |they are |the code (?:is )?)?renamed|until the code rename|` +
|
|
`\ba later rename|not renamed here|identifier to rename|left (?:for|to) (?:a )?later|not decided here|` +
|
|
`\bpostponed\b|future work|\bdeferred\b|later work|not built here|\ba later change`)
|
|
issueRef = regexp.MustCompile(`(?i)tracked (?:by|in|as)\s+\[?issue (\d{3,5})|\[?issue (\d{3,5})\]?(?:\([^)]*\))?\s+tracks\b`)
|
|
insightPhrase = regexp.MustCompile(`(?i)progressive insights?`)
|
|
insightMarker = regexp.MustCompile(`\*\*Progressive insights?[ \t]*[—–-][ \t]*(\d{4}-\d{2}-\d{2})[^*\n]*\*\*`)
|
|
)
|
|
|
|
// decisionSection is the text of a record's Decision section.
|
|
func decisionSection(r *Record) (string, bool) {
|
|
for _, s := range r.Sections {
|
|
if s.Name == "Decision" || strings.HasPrefix(s.Name, "Decision ") || strings.HasPrefix(s.Name, "Decision:") {
|
|
return s.Text, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func hasSection(r *Record, names ...string) bool {
|
|
for _, s := range r.Sections {
|
|
for _, n := range names {
|
|
if strings.EqualFold(strings.TrimSpace(s.Name), n) && strings.TrimSpace(s.Text) != "" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// allText is every word of a record an agent reads: its title, clauses and sections.
|
|
func allText(r *Record) string {
|
|
var b strings.Builder
|
|
b.WriteString(r.Title + "\n\n")
|
|
for _, c := range r.Clauses {
|
|
b.WriteString(c.Text + "\n\n")
|
|
}
|
|
for _, s := range r.Sections {
|
|
b.WriteString(s.Text + "\n\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// JudgeRecord holds a proposed record to the 29 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 {
|
|
var out []Refusal
|
|
refuse := func(rule, format string, a ...any) {
|
|
if Holds(rule, r.Kind, r.Date) {
|
|
out = append(out, Refusal{Rule: rule, Says: fmt.Sprintf(format, a...)})
|
|
}
|
|
}
|
|
decision := r.Kind == KindDecision
|
|
if r.Kind != KindDecision && r.Kind != KindResearch {
|
|
refuse("kind", "%q is neither decision nor research", r.Kind)
|
|
}
|
|
switch {
|
|
case held == nil:
|
|
refuse("reserved", "%s was never reserved: reserve gives the number", r.Key())
|
|
case held.Current.Status == StatusWithdrawn:
|
|
refuse("reserved", "%s was reserved and withdrawn: reserve a new number", r.Key())
|
|
}
|
|
title := strings.TrimSpace(r.Title)
|
|
if title == "" || strings.Contains(title, "\n") || len([]rune(title)) > 200 {
|
|
refuse("title", "the title is empty, more than one line, or longer than 200 characters")
|
|
}
|
|
if decision && !contains(ref.Topics, r.Topic) {
|
|
refuse("topic", "%q is not a topic of the reading order: %s", r.Topic, strings.Join(ref.Topics, ", "))
|
|
}
|
|
if !isDay.MatchString(r.Date) {
|
|
refuse("date", "%q is not a day written YYYY-MM-DD", r.Date)
|
|
}
|
|
if decision && len(r.Deciders) == 0 {
|
|
refuse("deciders", "a decision names who decided it")
|
|
}
|
|
if !decision && !contains(ResearchStatuses, r.Effort) {
|
|
refuse("research-status", "%q is not active, graduated or abandoned", r.Effort)
|
|
}
|
|
if decision && len(r.Clauses) == 0 {
|
|
refuse("has-clause", "a decision is made of clauses, and this one has none")
|
|
}
|
|
for i, c := range r.Clauses {
|
|
if c.N != i+1 {
|
|
refuse("clause-numbering", "clause %d stands %d. in order: clauses are numbered 1, 2, 3 … each once", c.N, i+1)
|
|
}
|
|
if c.Lead() == "" {
|
|
refuse("clause-lead", "clause %d does not open with a bold lead (**What is decided.** …)", c.N)
|
|
}
|
|
if strings.TrimSpace(c.Text) == "" || len(c.Text) > MostClauseBytes {
|
|
refuse("clause-size", "clause %d is empty or longer than %d bytes", c.N, MostClauseBytes)
|
|
}
|
|
}
|
|
if decision && !hasSection(r, "Context") {
|
|
refuse("context", "a decision has a Context section: why it was decided")
|
|
}
|
|
if decision && !hasSection(r, "Consequences") {
|
|
refuse("consequences", "a decision has a Consequences section: what follows from it, and what got harder")
|
|
}
|
|
if decision && !hasSection(r, "How it is checked") {
|
|
refuse("checked", "a decision has a section \"How it is checked\": a rule says how it is verified")
|
|
}
|
|
if !decision && !hasSection(r, "00-overview.md", "overview") {
|
|
refuse("overview", "a research effort has its overview, a section named 00-overview.md")
|
|
}
|
|
self := r.Key()
|
|
superseders := map[Key]bool{}
|
|
for _, l := range r.Links {
|
|
if to, ok := l.To.Record(); ok && l.Kind == LinkSupersedes {
|
|
superseders[to] = true
|
|
}
|
|
}
|
|
cited := map[Key]bool{}
|
|
for _, l := range r.Links {
|
|
if to, ok := l.To.Record(); ok {
|
|
cited[to] = true
|
|
}
|
|
}
|
|
for _, l := range r.Links {
|
|
if !contains(LinkKinds, l.Kind) {
|
|
refuse("link-kind", "%q is not a kind of link: %s", l.Kind, strings.Join(LinkKinds, ", "))
|
|
continue
|
|
}
|
|
if l.From > 0 && r.Clause(l.From) == nil {
|
|
refuse("link-kind", "the link %s goes out from clause %d, which the record does not have", l, l.From)
|
|
}
|
|
to, isRecord := l.To.Record()
|
|
if isRecord && to == self {
|
|
refuse("self-link", "the link %s points at the record itself", l)
|
|
continue
|
|
}
|
|
var target *Record
|
|
if isRecord {
|
|
target = lookup(to)
|
|
if target == nil || target.Status == StatusReserved || target.Status == StatusWithdrawn {
|
|
refuse("link-target", "the link %s names %s, which the knowledge base does not hold", l, to)
|
|
continue
|
|
}
|
|
if l.To.Clause > 0 && target.Clause(l.To.Clause) == nil {
|
|
refuse("link-target", "the link %s names clause %d, which %s does not have", l, l.To.Clause, to)
|
|
continue
|
|
}
|
|
}
|
|
kept := held != nil && held.Current.Accepted() && hasLink(held.Current, l)
|
|
switch {
|
|
case kept:
|
|
// An insight keeps what the accepted record linked, as hq wrote it: a change hq named by record waits
|
|
// for its clause to be judged.
|
|
case l.Kind == LinkBecame:
|
|
if r.Kind != KindResearch || !(l.To.Kind == KindDecision || l.To.Kind == TargetDesign) || l.To.Clause > 0 {
|
|
refuse("link-fits", "became goes from a research effort to a decision record or a design: not %s", l)
|
|
}
|
|
case l.Kind == LinkChanges:
|
|
if !decision || l.To.Kind != KindDecision || l.To.Clause == 0 {
|
|
refuse("link-fits", "changes goes from a decision (or a clause of it) to a clause of another decision, named as 0276 §4: not %s", l)
|
|
}
|
|
case l.Kind == LinkSupersedes || l.Kind == LinkExtends:
|
|
if r.Kind != l.To.Kind || !isRecord || l.To.Clause > 0 {
|
|
refuse("link-fits", "%s goes from a record to a record of its kind: not %s", l.Kind, l)
|
|
}
|
|
case l.Kind == LinkRestsOn:
|
|
if !decision || l.To.Kind != KindDecision || l.To.Clause > 0 {
|
|
refuse("link-fits", "rests-on goes from a decision to a decision it stands on: not %s", l)
|
|
}
|
|
case l.Kind == LinkTouches:
|
|
if r.Kind != KindResearch || l.To.Clause > 0 {
|
|
refuse("link-fits", "touches goes from a research effort to a record, an issue or a design: not %s", l)
|
|
}
|
|
}
|
|
if target == nil {
|
|
continue
|
|
}
|
|
if held != nil && held.Current.Accepted() && hasLink(held.Current, l) {
|
|
// An insight keeps what the accepted record linked: judged when it was accepted.
|
|
continue
|
|
}
|
|
switch l.Kind {
|
|
case LinkExtends, LinkRestsOn:
|
|
if target.Status == StatusSuperseded && !superseders[to] {
|
|
refuse("rests-on", "%s %s %s, which is superseded", self, l.Kind, to)
|
|
}
|
|
case LinkSupersedes:
|
|
if !target.Accepted() || target.Status == StatusSuperseded {
|
|
refuse("supersedes-accepted", "%s supersedes %s, which is %s, not accepted", self, to, target.Status)
|
|
}
|
|
case LinkChanges:
|
|
if !target.Accepted() || target.Status == StatusSuperseded {
|
|
refuse("supersedes-accepted", "%s changes %s, which is %s, not accepted", self, to, target.Status)
|
|
} else if c := target.Clause(l.To.Clause); c != nil {
|
|
// A drafted clause may be changed (novox/hq issue 494, ADR 0321): the change is recorded against the clause the
|
|
// knowledge base holds, by its number, which nothing renumbers once another record links it (Return).
|
|
if c.Status != ClauseInForce && c.Status != ClauseChanged {
|
|
refuse("changes-in-force", "%s changes %s §%d, which is %s", self, to, l.To.Clause, c.Status)
|
|
}
|
|
}
|
|
case LinkCites:
|
|
if target.Status == StatusSuperseded {
|
|
named := false
|
|
for _, by := range supersedersOf(target) {
|
|
named = named || cited[by]
|
|
}
|
|
if !named {
|
|
refuse("live-citation", "%s cites %s, which is superseded, and not the record that replaced it", self, to)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if r.Kind == KindResearch && r.Effort == StatusGraduated {
|
|
became := false
|
|
for _, l := range r.Links {
|
|
became = became || l.Kind == LinkBecame
|
|
}
|
|
if !became {
|
|
refuse("became", "a graduated research effort names what it became, as a became link")
|
|
}
|
|
}
|
|
for _, s := range r.Sections {
|
|
for _, m := range mdLink.FindAllStringSubmatch(s.Text, -1) {
|
|
target := m[1]
|
|
var k Key
|
|
switch {
|
|
case researchFolder.MatchString(target):
|
|
n := atoi(researchFolder.FindStringSubmatch(target)[1])
|
|
k = Key{KindResearch, n}
|
|
case adrFile.MatchString(target) && !strings.Contains(target, "04-ISSUES") && !strings.Contains(target, "03-DESIGN"):
|
|
k = Key{KindDecision, atoi(adrFile.FindStringSubmatch(target)[1])}
|
|
default:
|
|
continue
|
|
}
|
|
if k == self {
|
|
continue
|
|
}
|
|
if t := lookup(k); t == nil || t.Status == StatusReserved || t.Status == StatusWithdrawn {
|
|
refuse("text-links", "the section %s links %s, which the knowledge base does not hold", s.Name, k)
|
|
}
|
|
}
|
|
}
|
|
text := allText(r)
|
|
if words := ref.RetiredIn(text); len(words) > 0 {
|
|
refuse("retired-word", "it uses %s, retired by the glossary", quoteAll(unique(words)))
|
|
}
|
|
if lines := ref.LinesIn(text); len(lines) > 0 {
|
|
refuse("daemon-line", "it writes %s, a daemon's line: the operator types nox", quoteAll(unique(lines)))
|
|
}
|
|
for _, s := range r.Sections {
|
|
if strings.Contains(strings.ToLower(s.Name), "option") || strings.Contains(strings.ToLower(s.Name), "rejected") {
|
|
continue
|
|
}
|
|
for _, p := range paragraphsOf(s.Text) {
|
|
if m := deferral.FindString(p); m != "" && !issueRef.MatchString(p) {
|
|
refuse("deferral", "the section %s puts work off (%q) and names no issue that tracks it (\"tracked by issue N\")", s.Name, m)
|
|
}
|
|
}
|
|
}
|
|
for _, c := range r.Clauses {
|
|
if m := deferral.FindString(c.Text); m != "" && !issueRef.MatchString(c.Text) {
|
|
refuse("deferral", "clause %d puts work off (%q) and names no issue that tracks it (\"tracked by issue N\")", c.N, m)
|
|
}
|
|
}
|
|
marked := insightMarker.FindAllStringSubmatchIndex(text, -1)
|
|
for _, loc := range insightPhrase.FindAllStringIndex(text, -1) {
|
|
inside := false
|
|
for _, mk := range marked {
|
|
inside = inside || (mk[0] <= loc[0] && loc[1] <= mk[1])
|
|
}
|
|
line := lineAround(text, loc[0])
|
|
if inside || strings.Contains(line, "](") || strings.HasPrefix(strings.TrimSpace(line), "#") {
|
|
continue
|
|
}
|
|
refuse("insight-form", "it mentions a progressive insight outside the dated marked form **Progressive insight — YYYY-MM-DD.**")
|
|
break
|
|
}
|
|
for _, mk := range insightMarker.FindAllStringSubmatch(text, -1) {
|
|
if isDay.MatchString(r.Date) && mk[1] < r.Date {
|
|
refuse("insight-form", "a progressive insight dated %s predates the record (%s)", mk[1], r.Date)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func hasLink(r *Record, l Link) bool {
|
|
for _, x := range r.Links {
|
|
if x.Kind == l.Kind && x.From == l.From && x.To == l.To {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func lineAround(text string, at int) string {
|
|
start := strings.LastIndex(text[:at], "\n") + 1
|
|
end := strings.Index(text[at:], "\n")
|
|
if end < 0 {
|
|
return text[start:]
|
|
}
|
|
return text[start : at+end]
|
|
}
|
|
|
|
// paragraphsOf cuts a text at blank lines and a list's items.
|
|
func paragraphsOf(text string) []string {
|
|
var out []string
|
|
for _, chunk := range strings.Split(text, "\n\n") {
|
|
for _, item := range regexp.MustCompile(`\n(?:[-*] |\d+\. )`).Split(chunk, -1) {
|
|
if strings.TrimSpace(item) != "" {
|
|
out = append(out, item)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// supersedersOf are the records a record says superseded it: its superseded-by, a file name as hq wrote it or
|
|
// "ADR NNNN" as an acceptance here writes it.
|
|
func supersedersOf(r *Record) []Key {
|
|
var out []Key
|
|
for _, by := range strings.Split(r.Extra["superseded-by"], ",") {
|
|
by = strings.TrimSpace(by)
|
|
if m := adrFile.FindStringSubmatch(by); m != nil {
|
|
out = append(out, Key{KindDecision, atoi(m[1])})
|
|
} else if ref, err := ParseRef(by); err == nil {
|
|
if k, ok := ref.Record(); ok {
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func atoi(s string) int {
|
|
n := 0
|
|
for _, c := range s {
|
|
n = n*10 + int(c-'0')
|
|
}
|
|
return n
|
|
}
|
|
|
|
func contains(list []string, s string) bool {
|
|
for _, x := range list {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func unique(list []string) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, s := range list {
|
|
if !seen[strings.ToLower(s)] {
|
|
seen[strings.ToLower(s)] = true
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func quoteAll(list []string) string {
|
|
q := make([]string, len(list))
|
|
for i, s := range list {
|
|
q[i] = fmt.Sprintf("%q", s)
|
|
}
|
|
return strings.Join(q, ", ")
|
|
}
|
|
|
|
// ---- the whole record set ----------------------------------------------------------------------------------
|
|
|
|
// WholeSetRules are the 4 rules that judge the whole record set.
|
|
var WholeSetRules = []Rule{
|
|
{"cited", "a decision accepted here is cited by another record or names a home in hq", "cycle.py decisions"},
|
|
{"supersession", "a superseded record is named by the record that superseded it, and every record an accepted record supersedes is superseded", "records.py supersession"},
|
|
{"partial", "a changed clause is changed by an accepted record's changes link, and every clause an accepted record changes is changed or superseded", "records.py partial-supersession"},
|
|
{"rests-on-live", "no accepted record extends a superseded one, but the record that superseded it", "records.py rests-on"},
|
|
}
|
|
|
|
// JudgeSet holds the served versions of every record to the 4 whole-set rules. An imported record was held to
|
|
// hq's own checks at the commit it was read at, so the first rule judges only a record accepted here.
|
|
func JudgeSet(records []*Record) []Finding {
|
|
byKey := map[Key]*Record{}
|
|
for _, r := range records {
|
|
byKey[r.Key()] = r
|
|
}
|
|
inbound := map[Key][]struct {
|
|
from *Record
|
|
l Link
|
|
}{}
|
|
for _, r := range records {
|
|
if !r.Accepted() && r.Status != StatusSuperseded {
|
|
continue
|
|
}
|
|
for _, l := range r.Links {
|
|
if to, ok := l.To.Record(); ok {
|
|
inbound[to] = append(inbound[to], struct {
|
|
from *Record
|
|
l Link
|
|
}{r, l})
|
|
}
|
|
}
|
|
}
|
|
var out []Finding
|
|
add := func(rule, says string, keys ...Key) {
|
|
names := make([]string, len(keys))
|
|
for i, k := range keys {
|
|
names[i] = k.String()
|
|
}
|
|
out = append(out, Finding{Rule: rule, Records: names, Says: says})
|
|
}
|
|
for _, r := range records {
|
|
k := r.Key()
|
|
if r.Kind == KindDecision && r.Status == StatusAccepted && !r.AcceptedAt.IsZero() && len(r.Homes) == 0 {
|
|
cited := false
|
|
for _, in := range inbound[k] {
|
|
cited = cited || in.from.Key() != k
|
|
}
|
|
if !cited {
|
|
add("cited", fmt.Sprintf("%s is accepted and nothing cites it: no record links it and it names no home", k), k)
|
|
}
|
|
}
|
|
if r.Status == StatusSuperseded {
|
|
by := false
|
|
for _, in := range inbound[k] {
|
|
// The record that superseded it may since have been superseded itself, as hq's 0125 by 0127 by 0131.
|
|
by = by || in.l.Kind == LinkSupersedes
|
|
}
|
|
if !by {
|
|
add("supersession", fmt.Sprintf("%s is superseded and no record says it supersedes it", k), k)
|
|
}
|
|
}
|
|
if r.Accepted() {
|
|
for _, l := range r.Links {
|
|
to, ok := l.To.Record()
|
|
target := byKey[to]
|
|
if !ok || target == nil {
|
|
continue
|
|
}
|
|
switch l.Kind {
|
|
case LinkSupersedes:
|
|
if target.Status != StatusSuperseded {
|
|
add("supersession", fmt.Sprintf("%s supersedes %s, which is %s", k, to, target.Status), k, to)
|
|
}
|
|
case LinkChanges:
|
|
if l.To.Clause == 0 {
|
|
continue
|
|
}
|
|
if c := target.Clause(l.To.Clause); c != nil && c.Status != ClauseChanged && c.Status != ClauseSuperseded {
|
|
add("partial", fmt.Sprintf("%s changes %s §%d, which is %s", k, to, l.To.Clause, c.Status), k, to)
|
|
}
|
|
case LinkExtends, LinkRestsOn:
|
|
if target.Status == StatusSuperseded {
|
|
superseder := false
|
|
for _, in := range inbound[to] {
|
|
superseder = superseder || (in.l.Kind == LinkSupersedes && in.from.Key() == k)
|
|
}
|
|
if !superseder {
|
|
add("rests-on-live", fmt.Sprintf("%s %s %s, which is superseded", k, l.Kind, to), k, to)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, c := range r.Clauses {
|
|
if c.Status != ClauseChanged {
|
|
continue
|
|
}
|
|
by := false
|
|
for _, in := range inbound[k] {
|
|
by = by || (in.l.Kind == LinkChanges && in.l.To.Clause == c.N && in.from.Accepted())
|
|
}
|
|
if !by {
|
|
add("partial", fmt.Sprintf("%s §%d is changed and no accepted record changes it", k, c.N), k)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// UncitedOnAcceptance is the whole-set rule `cited` judged ahead, on a decision's working version as it would
|
|
// stand once accepted, over the records as JudgeSet reads them: "" when it names a home or a record accepted (or
|
|
// superseded) links it, else what the rule would say. hq held this rule at the merge of the record's own pull
|
|
// request, which carried the design citing it; the knowledge base holds it in the review, before the judgement, so
|
|
// an acceptance never leaves a decision cited by nothing (novox/hq issue 504). It is not refused at propose: no
|
|
// record can link a number only reserved, so a record that will cite a new one is proposed after it, accepted, and
|
|
// the new one proposed again. A citing record changed by a correction after the acceptance is what the rule, run
|
|
// after every acceptance, still says. An imported record was held to hq's checks, and its correction is not judged
|
|
// here, as JudgeSet does not judge it.
|
|
func UncitedOnAcceptance(r *Record, all []*Entry) string {
|
|
if r.Kind != KindDecision || len(r.Homes) > 0 || (r.Pending != nil && r.AcceptedAt.IsZero()) {
|
|
return ""
|
|
}
|
|
k := r.Key()
|
|
for _, e := range all {
|
|
from := e.Current
|
|
if from.Key() == k || !(from.Accepted() || from.Status == StatusSuperseded) {
|
|
continue
|
|
}
|
|
for _, l := range from.Links {
|
|
if to, ok := l.To.Record(); ok && to == k {
|
|
return ""
|
|
}
|
|
}
|
|
}
|
|
return fmt.Sprintf("%s would be accepted with nothing citing it: it names no home and no accepted record links it. Name "+
|
|
"its home (homes: the hq design or playbook that cites it), or have the record that links it accepted first; then "+
|
|
"propose this one again", k)
|
|
}
|
|
|
|
// RuleFrom is the day each dated rule began to hold a record, as hq's checks hold it: a record dated earlier was
|
|
// never held to it, and a dated correction of it (a progressive insight, which keeps the record's date) is not.
|
|
// words.py held the retired words from 2026-10-07 and the daemons' lines from 2026-10-09; cycle.py the deferrals
|
|
// from 2026-10-09; records.py the clauses from 2026-10-11 (ADR 0290 rule 3). The decision record's sections, and
|
|
// a superseded record cited alone, were held by no check of hq's (words.py and live-citation never read decision
|
|
// records): they hold from the day the knowledge base takes the write, 2026-10-11. Every other rule holds every
|
|
// record, as hq's checks held it.
|
|
var RuleFrom = map[string]string{
|
|
"retired-word": "2026-10-07",
|
|
"daemon-line": "2026-10-09",
|
|
"deferral": "2026-10-09",
|
|
"has-clause": "2026-10-11",
|
|
"clause-numbering": "2026-10-11",
|
|
"clause-lead": "2026-10-11",
|
|
"clause-size": "2026-10-11",
|
|
"context": "2026-10-11",
|
|
"consequences": "2026-10-11",
|
|
"checked": "2026-10-11",
|
|
"live-citation": "2026-10-11",
|
|
}
|
|
|
|
// DecisionFrom overrides RuleFrom for a decision record: hq's words check never read 02-DECISIONS ("a decision
|
|
// record keeps the words it was written with"), so its retired words and daemons' lines hold a decision only from
|
|
// the day the knowledge base takes the write.
|
|
var DecisionFrom = map[string]string{
|
|
"retired-word": "2026-10-11",
|
|
"daemon-line": "2026-10-11",
|
|
}
|
|
|
|
// Holds says whether a rule holds a record of this kind and date. A record with no day is held to every rule.
|
|
func Holds(rule, kind, date string) bool {
|
|
from, dated := RuleFrom[rule]
|
|
if d, ok := DecisionFrom[rule]; ok && kind == KindDecision {
|
|
from = d
|
|
}
|
|
return !dated || !isDay.MatchString(date) || date >= from
|
|
}
|