Remove the decision records and research efforts, which the knowledge base now holds whole (issue 426)
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery superseded: a newer head of the same pull request
ADR 0290 rule 9: once the import is checked record by record, hq keeps no copy. The records and efforts are removed; every link or path to them reads "ADR NNNN" or "research NNN"; a design's decisions: lists "ADR NNNN". 02-DECISIONS keeps a pointer README with the reading order's six topics, which the knowledge base reads through the records module; 01-RESEARCH keeps a pointer README. The checks drop the rules that judged records (the knowledge base holds each at propose; checks/README.md says where) and refuse a record written here again. Playbooks 01 and 02 and the skills write and read records with the knowledge base's verbs.
This commit is contained in:
+60
-58
@@ -1,9 +1,9 @@
|
||||
# Checks
|
||||
|
||||
```
|
||||
python3 00-META/checks/records.py structure: links, citations, supersession, topics
|
||||
python3 00-META/checks/index.py every record's topic is in the reading order, and no list of records is stored
|
||||
python3 00-META/checks/index.py --print the records, by topic, in reading order (generated, never written)
|
||||
python3 00-META/checks/records.py structure: links, no record written here, citations in the form "ADR NNNN"
|
||||
python3 00-META/checks/index.py the reading order's topics are written, and no list of records is stored
|
||||
python3 00-META/checks/cycle.py the development cycle: a design's layer, status, decisions and code; an issue report's frontmatter
|
||||
python3 00-META/checks/words.py the glossary's retired words are not used, no daemon's line is written, and no word is defined twice
|
||||
python3 00-META/checks/words.py --list tools the words the catalogue's copy must list
|
||||
python3 00-META/checks/words.py --list lines the daemons' lines the catalogue's copy must list
|
||||
@@ -23,31 +23,49 @@ indistinguishable from one that cannot.
|
||||
| Check | Asserts | Found |
|
||||
|---|---|---|
|
||||
| `links` | every relative link resolves | — (run ad hoc during authoring; now permanent) |
|
||||
| `rests-on` | `decisions:` and `extends:` name records that exist and are **accepted** | the class behind both incidents |
|
||||
| `live-citation` | a governing document citing a **superseded** record names its replacement in the same paragraph | `01-to-be/README.md` citing ADR 0022 as live guidance |
|
||||
| `supersession` | if A says it was superseded by B, B says it supersedes A | ADR 0012 never declared that it superseded 0011 |
|
||||
| `partial-supersession` | a record's `supersedes-in-part:` names records that exist, each carrying a dated *Superseded in part* note naming it under its heading; and every such note is named back by its superseder's field | 11 of 21 partial supersessions had no note on the record they change, and 6 more were said only in prose ([research 045](../../01-RESEARCH/045-a-knowledge-base-the-mesh-writes/00-overview.md)) |
|
||||
| `clauses` | an accepted record dated from 2026-10-11 writes its `## Decision` section as numbered clauses with a bold lead ([ADR 0290](../../02-DECISIONS/0290-a-decision-record-lives-whole-in-the-knowledge-base-made-of-clauses.md) rule 3) | 226 of 263 decision records wrote their decision as prose, so the knowledge base must draft their clauses |
|
||||
| `numbering` | the number in the filename is the number in the heading | — |
|
||||
| `topics` | every record names a topic of the reading order in `02-DECISIONS/README.md` | — |
|
||||
| *(index.py)* | the reading order is written, every record's topic is one of it, and the README stores no list of records ([ADR 0248](../../02-DECISIONS/0248-the-decision-index-is-generated-when-it-is-read-and-never-stored.md)) | a stored list made every two open decisions conflict ([issue 297](../../04-ISSUES/297-two-open-decisions-always-conflict/00-report.md)); it failed on the README of `main` before the list was removed |
|
||||
| `no-records` | `02-DECISIONS/` and `01-RESEARCH/` hold their README and nothing else: a decision record or research effort lives in the knowledge base (ADR 0290 rule 9) | written with the change that removed the records (issue 426); it failed on a record file put back in `02-DECISIONS/` before it passed |
|
||||
| `decisions-form` | every entry of a design's `decisions:` is "ADR NNNN", four digits | written with issue 426; it failed on an entry `ADR 290` before it passed |
|
||||
| `mention-form` | every "ADR N" in the text has four digits, and every "research N" three; a code span is an example and is not read | written with issue 426; it failed on `ADR 12` and `research 45` before it passed |
|
||||
| *(index.py)* | the reading order's six topics are written in `02-DECISIONS/README.md`, none twice, and the README stores no list of records (ADR 0248). The knowledge base reads these lines and refuses a record whose topic is not one of them | a stored list made every two open decisions conflict ([issue 297](../../04-ISSUES/297-two-open-decisions-always-conflict/00-report.md)); it failed on the README of `main` before the list was removed |
|
||||
| `status-vs-code` | a to-be document naming specific code is not still `designed` | **ten documents**, several with a *What was built* section, describing lab-proven code |
|
||||
|
||||
## Where the rules on records went
|
||||
|
||||
The decision records and research efforts left this repository for the knowledge base (ADR 0290, issue 426),
|
||||
and the rules these checks held over them left with them. The knowledge base holds each as a rule of its own,
|
||||
named in its refusal: `knowledge-base.propose` refuses a record that breaks one of the rules that judge one
|
||||
record, naming every reason at once, and the four rules that judge the whole record set run after every
|
||||
acceptance and raise a condition when one fails (mesh-knowledge's `rules.go`, one table-test case per rule).
|
||||
|
||||
| Rule here until issue 426 | Where it lives now |
|
||||
|---|---|
|
||||
| `records.py` `rests-on`, for a record's `extends:` | the knowledge base's `rests-on` at propose, and the whole-set rule `rests-on-live` |
|
||||
| `records.py` `rests-on`, for a design's `decisions:` naming an accepted record | **nowhere yet**: this repository cannot read the knowledge base. The reviewer checks each new entry with `knowledge-base.get`. Designs become the knowledge base's own kind under issue 428 |
|
||||
| `records.py` `live-citation`, in a record | the knowledge base's `live-citation` |
|
||||
| `records.py` `live-citation`, in `00-META/` and `03-DESIGN/01-to-be/` | **nowhere yet**, as above: the reviewer checks a cited record's status with `knowledge-base.get`; issue 428 |
|
||||
| `records.py` `supersession` | the knowledge base's `supersedes-accepted` and `self-link`, and the whole-set rule `supersession` |
|
||||
| `records.py` `partial-supersession` | the knowledge base's `changes` link to a clause, `changes-in-force` and `supersedes-accepted`, and the whole-set rule `partial` |
|
||||
| `records.py` `clauses` | the knowledge base's `has-clause`, `clause-numbering`, `clause-lead` and `clause-size` |
|
||||
| `records.py` `numbering` | the knowledge base's `title` and `reserved` |
|
||||
| `records.py` `topics`, `index.py` (a record's topic) | the knowledge base's `topic`, against the reading order `index.py` still checks |
|
||||
| `records.py` `insights` | the knowledge base's `insight-form`; an accepted record changes by a new version marked as a progressive insight |
|
||||
| `index.py --print` | `knowledge-base.holds` with a topic |
|
||||
| `cycle.py` research: a known status, `became:` when graduated | the knowledge base's `research-status`, `overview` and `became` |
|
||||
| `cycle.py` no two decision records share a number | the knowledge base's `reserved`, and the issue tracker's counter behind `knowledge-base.reserve` |
|
||||
| `cycle.py` an accepted decision cited by nothing | the knowledge base's whole-set rule `cited`: a record accepted there is cited by another record or names a home (`homes` at propose) |
|
||||
| `cycle.py` a deferral names the issue that tracks it | the knowledge base's `deferral` |
|
||||
| `words.py` over research efforts from 2026-10-07, and `kept` in `words-allowed.md` | the knowledge base's `retired-word` and `daemon-line`, from this glossary |
|
||||
|
||||
## What is deliberately not checked
|
||||
|
||||
- **`02-DECISIONS/` and `01-RESEARCH/` may cite superseded records freely.** A decision record
|
||||
discusses history; research records what was observed. Flagging those would produce noise on
|
||||
correct documents, and a check that cries wolf gets suppressed — which costs more than not
|
||||
having it.
|
||||
- **`03-DESIGN/00-as-is/` may rest on a superseded record.** It describes what runs, and what
|
||||
runs was built under whatever was decided at the time
|
||||
([ADR 0006](../../02-DECISIONS/0006-the-substrate-and-the-control-plane.md):
|
||||
*as-is describing a superseded decision is exactly what as-is is for*).
|
||||
- **Whether a citation's prose is still true.** Only whether the record it points at is live.
|
||||
A document can cite an accepted record and describe it wrongly, and nothing here notices.
|
||||
|
||||
So "governing" means `00-META/` and `03-DESIGN/01-to-be/` — the documents that tell somebody
|
||||
what to do.
|
||||
- **Whether a record a document cites exists, or is still accepted.** A checkout cannot read the knowledge
|
||||
base, and a check that called the mesh would make every merge here depend on it. Until designs become the
|
||||
knowledge base's own kind (issue 428), the reviewer asks `knowledge-base.get` for each record a change
|
||||
cites in `00-META/` or `03-DESIGN/01-to-be/` (the documents that tell somebody what to do). An as-is
|
||||
document may rest on a superseded record: it describes what runs, and what runs was built under whatever
|
||||
was decided at the time (ADR 0006: *as-is describing a superseded decision is exactly what as-is is for*).
|
||||
- **Whether a citation's prose is still true.** A document can cite an accepted record and describe it
|
||||
wrongly, and nothing here notices.
|
||||
|
||||
## Adding a check
|
||||
|
||||
@@ -56,51 +74,35 @@ whose failure has never been observed is a guess about its own correctness.
|
||||
|
||||
## cycle.py
|
||||
|
||||
The development cycle, checked ([ADR 0080](../../02-DECISIONS/0080-the-development-cycle-is-checked.md)):
|
||||
a to-be design names a decision, an in-progress/implemented design names its owning code, a graduated
|
||||
research overview says what it became, and no two issue records share a number (issue 155 — the
|
||||
number is how a record is cited, and `main` lags every open pull request, so two people reading it
|
||||
allocate the same one). An issue report carries the day it was `opened:` and no status key — `status:`,
|
||||
`located-in:`, `fixed-by:`, `amended-design:`, `replay:`, `replay-none:` or `resolved:` — because an issue's
|
||||
status lives in the issue tracker ([ADR 0275](../../02-DECISIONS/0275-the-mesh-numbers-and-tracks-its-issues-in-a-module-of-its-own-and-hq-keeps-only-the-narrative-of-a-design-or-governance-issue.md));
|
||||
it failed on all 361 reports that carried them before they were removed. The rules on an issue's status this
|
||||
check held until then — an owner for a located issue, a fix for a resolved one, and a core issue's `replay`
|
||||
or `replay-none` ([ADR 0237](../../02-DECISIONS/0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md))
|
||||
— are refused at the write by `issue-tracker.move`. And an accepted record that puts work
|
||||
off — "until renamed", "left for later", "not decided here", "postponed", "future work", "deferred", "a later
|
||||
change" — names the issue that tracks it, as tracking it ("tracked by issue N", or "issue N tracks"), in that
|
||||
paragraph or list item, or in a dated progressive insight that speaks about it: one in the same section, or one
|
||||
that quotes it. An issue cited for another reason tracks nothing, and one insight does not excuse every deferral
|
||||
in a record. The tracking issue counts when it has a folder in `04-ISSUES/`, or when its number is above every
|
||||
folder's: since ADR 0275 an issue from 365 on lives only in the issue tracker, which a checkout cannot read, and the
|
||||
tracker hands out numbers in order (issue 436). A made-up number above the folders therefore passes; the reviewer
|
||||
checks that the issue exists, with `issue-tracker.get`. A rejected option is not work put off and is not held, judged by its `##` section, so an option under a
|
||||
`###` subheading of "Considered Options" is skipped too
|
||||
([issue 354](../../04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md):
|
||||
ADR 0244 deferred the code renames with no issue, and two days later the operator believed them done). Held to
|
||||
it: a record dated from 2026-10-09, and an older one corrected under the rule. Measured from ADR 0244's date
|
||||
it found deferrals without an issue in ADR 0241, 0244 and 0270; held from 2026-10-09 it failed on 0270 and 0273
|
||||
(whose "not decided here" sits beside a citation of issue 340 that tracks none of it), and on 0244's four
|
||||
deferral places once its insight brought it under the rule — on three of them while the insight, in §6, named
|
||||
the issue without quoting them — before each named its issue (355, 357, 354). `python3 00-META/checks/cycle.py`
|
||||
The development cycle, checked (ADR 0080):
|
||||
every design parses, its `layer:` matches its folder and its `status:` is known; a to-be design names a
|
||||
decision in `decisions:`; an in-progress or implemented design names its owning code; and no two issue
|
||||
records share a number (issue 155 — the number is how a record is cited, and `main` lags every open pull
|
||||
request, so two people reading it allocate the same one). An issue report carries the day it was `opened:`
|
||||
and no status key — `status:`, `located-in:`, `fixed-by:`, `amended-design:`, `replay:`, `replay-none:` or
|
||||
`resolved:` — because an issue's status lives in the issue tracker (ADR 0275); it failed on all 361 reports
|
||||
that carried them before they were removed. The rules on an issue's status this check held until then — an
|
||||
owner for a located issue, a fix for a resolved one, and a core issue's `replay` or `replay-none` (ADR 0237)
|
||||
— are refused at the write by `issue-tracker.move`. Its rules on research efforts and decision records left
|
||||
with them (the table above). `python3 00-META/checks/cycle.py`
|
||||
|
||||
## words.py
|
||||
|
||||
One word per thing, checked ([ADR 0244](../../02-DECISIONS/0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md)).
|
||||
One word per thing, checked (ADR 0244).
|
||||
It reads the glossary's *Not:* lines (the retired words, each with its scope) and *Identifier until
|
||||
renamed* lines, and fails on a retired word or a bare identifier in running prose — what is left once code,
|
||||
quotations, struck-through text, link targets, comments and frontmatter are taken out — in `00-META/`,
|
||||
`03-DESIGN/`, `AGENTS.md`, `README.md`, and research and issues dated from 2026-10-07. Decision records are
|
||||
never checked. It also fails when a glossary head word heads two entries or is also retired.
|
||||
[`words-allowed.md`](words-allowed.md) names a document that could not be reworded at once, with a date, or
|
||||
a graduated research effort kept as written. With `MESH_CATALOG_DIR` set it compares the catalogue's copy
|
||||
`03-DESIGN/`, `AGENTS.md`, `README.md`, and issues opened from 2026-10-07. Decision records and research
|
||||
efforts live in the knowledge base, which holds a proposed record to the same words (the table above). It
|
||||
also fails when a glossary head word heads two entries or is also retired.
|
||||
[`words-allowed.md`](words-allowed.md) names a document that could not be reworded at once, with a date. With `MESH_CATALOG_DIR` set it compares the catalogue's copy
|
||||
of the tools' retired words (`retired-words`) with the glossary.
|
||||
|
||||
It also fails on a **daemon's line** ([ADR 0272](../../02-DECISIONS/0272-the-operator-reaches-the-controller-with-mesh-cli-on-every-node-and-a-node-is-the-terminal-only-where-no-agent-runs-as-its-operator.md),
|
||||
It also fails on a **daemon's line** (ADR 0272,
|
||||
its insight of 2026-10-09): the operator types one command, `nox`, and a document names a `nox` line, never the
|
||||
controller's or the node-engine's binary followed by one of its commands. The lines are the check's own list
|
||||
(`DAEMON_LINES`); a command that is a line only with an argument after it is matched as a line is written. Looked
|
||||
for in the same documents, from 2026-10-09 for research and issues, in the document's own words — code blocks and
|
||||
for in the same documents, from 2026-10-09 for issues, in the document's own words — code blocks and
|
||||
spans included, quotations and block quotes taken out. With `MESH_CATALOG_DIR` set it compares the catalogue's copy
|
||||
(`retired-lines`) with the list.
|
||||
|
||||
|
||||
+6
-181
@@ -22,12 +22,12 @@ What is enforced:
|
||||
them, and two places holding one fact drift. The keys the reports opened before
|
||||
the import carried were removed once the import held them; the commit it read
|
||||
is on every imported issue.
|
||||
research a known `status:`; a `graduated` overview says what it `became:`, and every
|
||||
target it names exists.
|
||||
decisions every accepted record is REACHABLE from the cycle: cited by a design doc's
|
||||
frontmatter, a research overview, an issue report, a 00-META document, or another
|
||||
record's extends/supersedes chain. A decision nothing points at is one nobody will
|
||||
find by following pointers -- which is how records go stale in people's heads.
|
||||
|
||||
The decision records and research efforts live in the knowledge base (ADR 0290, issue 426), and the
|
||||
rules this check held over them are the knowledge base's now: a research effort's status and what it
|
||||
became, and a decision's duplicate number, are refused by `knowledge-base.propose`; an accepted decision
|
||||
cited by nothing is the knowledge base's whole-set rule `cited`; a deferral that names no issue is its
|
||||
rule `deferral`. README.md beside this file says where each went.
|
||||
|
||||
Deliberately NOT enforced: as-is docs need no decisions (they describe what exists, not what
|
||||
was decided).
|
||||
@@ -43,89 +43,12 @@ import sys
|
||||
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
DESIGN_STATUSES = {"proposed", "designed", "in-progress", "implemented", "abandoned"}
|
||||
RESEARCH_STATUSES = {"active", "graduated", "abandoned"}
|
||||
|
||||
# The keys an issue report carried while its status lived here. The issue tracker holds them now (ADR 0275,
|
||||
# slice 3 of to-be 53), and `issue-tracker.move` enforces the cycle's rules on them at the write. A report
|
||||
# carrying one of them is a status written in two places, whenever it was opened.
|
||||
TRACKER_KEYS = ("status", "located-in", "fixed-by", "amended-design", "replay", "replay-none", "resolved")
|
||||
|
||||
# A decision that defers work names the issue that tracks it (issue 354). ADR 0244 left the code
|
||||
# renames of the node-engine and the tool runner "for a later rename", with no issue, owner or date;
|
||||
# two days later the operator believed them done, and nothing in the record said otherwise. A deferral
|
||||
# nothing tracks is indistinguishable from work that was done. Held to it: an accepted record dated
|
||||
# from DEFERRALS_FROM, and an older one corrected under the rule (a progressive insight dated from then
|
||||
# on): an edit made under the rule is held to it. The phrases are the ones records use to put work off;
|
||||
# "for later" alone is not one, because records say "never queued for later" and "too narrow for later".
|
||||
# The tracking issue is named as tracking it, beside the deferral or in a dated insight in the same section
|
||||
# or quoting it (ISSUE_REF, covers).
|
||||
DEFERRALS_FROM = "2026-10-09"
|
||||
DEFERRAL = re.compile(r"(?i)until (?:it is |they are |the code (?:is )?)?renamed|until the code rename|"
|
||||
r"\ba later rename|not renamed here|identifier to rename|"
|
||||
r"left (?:for|to) (?:a )?later|not decided here|\bpostponed\b|future work|"
|
||||
r"\bdeferred\b|later work|not built here|\ba later change")
|
||||
# The issue that tracks a deferral is named as tracking it -- "tracked by issue N", or "issue N tracks" --
|
||||
# because a record cites issues for many reasons, and an issue cited as context beside a deferral (ADR 0273
|
||||
# names issue 340 in the very sentence that leaves a channel undecided) tracks nothing.
|
||||
ISSUE_REF = re.compile(r"(?i)tracked (?:by|in|as)\s+\[?issue (\d{3})|\[?issue (\d{3})\]?(?:\([^)]*\))?\s+tracks\b")
|
||||
INSIGHT = re.compile(r"\*\*Progressive insights?[ \t]*[—–-][ \t]*(\d{4}-\d{2}-\d{2})")
|
||||
|
||||
|
||||
def paragraphs(path):
|
||||
"""A record's body as (first line number, section heading, chapter, text) paragraphs: frontmatter and fenced
|
||||
code left out, a block quote's markers stripped, so a quoted insight reads as the paragraph it is. The
|
||||
section is the nearest heading above the paragraph; a heading is its own paragraph and its own section.
|
||||
The chapter is the nearest `#` or `##` heading, which a `###` subheading inside it does not change."""
|
||||
text = open(path, encoding="utf-8").read()
|
||||
m = re.match(r"^---\n.*?\n---\n?", text, re.S)
|
||||
body, first = (text[m.end():], text[: m.end()].count("\n") + 1) if m else (text, 1)
|
||||
body = re.sub(r"(?ms)^\s*```.*?^\s*```", lambda x: re.sub(r"[^\n]", " ", x.group(0)), body)
|
||||
out, line, section, chapter = [], first, "", ""
|
||||
for chunk in body.split("\n\n"):
|
||||
stripped = "\n".join(re.sub(r"^\s*>\s?", "", l) for l in chunk.split("\n")).strip()
|
||||
if stripped:
|
||||
heading = re.match(r"^#{1,6}\s+(.*)", stripped)
|
||||
if heading:
|
||||
section = heading.group(1).strip()
|
||||
if len(stripped) - len(stripped.lstrip("#")) <= 2:
|
||||
chapter = section
|
||||
# A list's top-level items are units of their own: a Consequences list is one paragraph, and an
|
||||
# issue named in one item tracks nothing in the next.
|
||||
items = re.split(r"\n(?=[-*] |\d+\. )", stripped)
|
||||
at = line + chunk[: len(chunk) - len(chunk.lstrip())].count("\n")
|
||||
for item in items:
|
||||
out.append((at, section, chapter, item))
|
||||
at += item.count("\n") + 1
|
||||
line += chunk.count("\n") + 2
|
||||
return out
|
||||
|
||||
|
||||
QUOTE = re.compile(r"\"([^\"]{12,})\"|\u201c([^\u201d]{12,})\u201d")
|
||||
|
||||
|
||||
def flat(text):
|
||||
"""Text compared for a quotation: markup and case dropped, whitespace collapsed."""
|
||||
return re.sub(r"\s+", " ", re.sub(r"[*`_]", "", text)).strip().lower()
|
||||
|
||||
|
||||
def covers(insight_section, insight_text, section, text):
|
||||
"""An insight speaks about a deferral paragraph when it stands in the same section, or quotes it."""
|
||||
if insight_section == section:
|
||||
return True
|
||||
body = flat(text)
|
||||
return any(flat(a or b) in body for a, b in QUOTE.findall(insight_text))
|
||||
|
||||
|
||||
def issue_refs(text, issues):
|
||||
"""The issues a text names as tracking it: one with a folder here, or one numbered above every folder.
|
||||
|
||||
Since ADR 0275 an issue from 365 on lives only in the issue tracker, and ADR 0291 removes the folders; a
|
||||
checkout alone cannot see those, so a number above the highest folder counts as one the tracker holds
|
||||
(issue 436). The tracker hands out numbers in order, so such a number was issued after the last folder.
|
||||
"""
|
||||
top = max((int(k) for k in issues if k.isdigit()), default=0)
|
||||
return {n for a, b in ISSUE_REF.findall(text) for n in (a, b) if n and (n in issues or int(n) > top)}
|
||||
|
||||
|
||||
def rel(path):
|
||||
return os.path.relpath(path, ROOT)
|
||||
@@ -216,22 +139,6 @@ def main():
|
||||
else:
|
||||
seen[number] = name
|
||||
|
||||
# And decision records, which 155's fix left out: on 2026-10-02 two ADRs numbered 0169 landed
|
||||
# on main from two sessions within the hour, and every check passed.
|
||||
seen_records = {}
|
||||
for path in sorted(glob.glob(os.path.join(ROOT, "02-DECISIONS", "[0-9]*.md"))):
|
||||
name = os.path.basename(path)
|
||||
number = name.split("-", 1)[0]
|
||||
if not number.isdigit():
|
||||
continue
|
||||
if number in seen_records:
|
||||
bad(os.path.join("02-DECISIONS", name),
|
||||
"is numbered %s, and so is %s -- a record's number is how it is cited. Take the next "
|
||||
"free number across main AND every open pull request; the branch that lands last "
|
||||
"renumbers" % (number, seen_records[number]))
|
||||
else:
|
||||
seen_records[number] = name
|
||||
|
||||
# Since the import of slice 3 (ADR 0275), an issue's status, owner, fix and replay live in the issue
|
||||
# tracker, and its `move` refuses at the write what this check refused at merge. A report carries none
|
||||
# of those keys.
|
||||
@@ -250,91 +157,9 @@ def main():
|
||||
"(ADR 0275): move it with `issue-tracker.move`, and keep no copy here"
|
||||
% ", ".join("`%s:`" % key for key in carried))
|
||||
|
||||
# ---- research ----------------------------------------------------------------------
|
||||
for path in sorted(glob.glob(os.path.join(ROOT, "01-RESEARCH", "*", "00-overview.md"))):
|
||||
front = frontmatter(path)
|
||||
if front is None:
|
||||
bad(path, "no frontmatter")
|
||||
continue
|
||||
status = front.get("status")
|
||||
if status not in RESEARCH_STATUSES:
|
||||
bad(path, "status %r is not one of %s" % (status, sorted(RESEARCH_STATUSES)))
|
||||
if status == "graduated":
|
||||
became = listy(front, "became")
|
||||
if not became:
|
||||
bad(path, "graduated but became: names nothing")
|
||||
for target in became:
|
||||
if not os.path.exists(os.path.join(ROOT, target)):
|
||||
bad(path, "became names %s, which does not exist" % target)
|
||||
|
||||
# ---- decisions -------------------------------------------------------------------
|
||||
records = {}
|
||||
for path in sorted(glob.glob(os.path.join(ROOT, "02-DECISIONS", "[0-9]*.md"))):
|
||||
front = frontmatter(path)
|
||||
records[os.path.basename(path)] = (path, (front or {}).get("status"))
|
||||
|
||||
cited = set()
|
||||
sources = (glob.glob(os.path.join(ROOT, "03-DESIGN", "*", "*.md"))
|
||||
+ glob.glob(os.path.join(ROOT, "01-RESEARCH", "*", "00-overview.md"))
|
||||
+ glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))
|
||||
+ glob.glob(os.path.join(ROOT, "00-META", "**", "*.md"), recursive=True))
|
||||
for path in sources:
|
||||
text = open(path, encoding="utf-8").read()
|
||||
if os.sep + "03-DESIGN" + os.sep in path:
|
||||
# A design doc's governing citations live in frontmatter; a prose mention is
|
||||
# commentary, not a home.
|
||||
m = re.match(r"^---\n(.*?)\n---", text, re.S)
|
||||
text = m.group(1) if m else ""
|
||||
for m in re.finditer(r"([0-9]{4}-[^\s\)\],#]+\.md)", text):
|
||||
cited.add(os.path.basename(m.group(1)))
|
||||
for name in records:
|
||||
front = frontmatter(records[name][0]) or {}
|
||||
for key in ("extends", "supersedes", "superseded-by"):
|
||||
v = front.get(key)
|
||||
if isinstance(v, str) and v:
|
||||
cited.add(os.path.basename(v))
|
||||
for name, (path, status) in records.items():
|
||||
if status == "accepted" and name not in cited:
|
||||
bad(path, "an accepted decision nothing in the cycle cites -- give it a home in a "
|
||||
"design doc's decisions:, a research became:, an issue, or 00-META")
|
||||
|
||||
# ---- deferrals -------------------------------------------------------------------
|
||||
# A paragraph or list item that puts work off names the issue that tracks it, or a dated
|
||||
# progressive insight that speaks about it (its section, or a quotation of it) does. An issue that has
|
||||
# since resolved still counts: the deferred work landed, which is what the pointer was for.
|
||||
for name, (path, status) in records.items():
|
||||
front = frontmatter(path) or {}
|
||||
decided = str(front.get("date") or "")
|
||||
if status != "accepted":
|
||||
continue
|
||||
paras = paragraphs(path)
|
||||
insights = [(d, sec, text) for line, sec, _, text in paras for d in INSIGHT.findall(text)]
|
||||
held = decided >= DEFERRALS_FROM or any(d >= DEFERRALS_FROM for d, _, _ in insights)
|
||||
if not held:
|
||||
continue
|
||||
# A dated insight naming an issue covers the deferrals it speaks about -- those in its own section,
|
||||
# or ones it quotes -- and no other: one insight must not excuse every deferral in the record.
|
||||
tracking = [(sec, text) for d, sec, text in insights if issue_refs(text, seen)]
|
||||
for line, section, chapter, text in paras:
|
||||
m = DEFERRAL.search(text)
|
||||
# A rejected option is a decision not to do it, not work put off -- judged by its chapter, so an
|
||||
# option under a `###` subheading of "## Considered Options" is skipped as well.
|
||||
if re.match(r"(?i)(options? rejected|considered options|rejected)", chapter):
|
||||
continue
|
||||
if not m or issue_refs(text, seen):
|
||||
continue
|
||||
if any(covers(isec, itext, section, text) for isec, itext in tracking):
|
||||
continue
|
||||
bad(path, "line %d defers work (%r) and names no issue that tracks it -- a deferral nothing "
|
||||
"tracks reads as done (issue 354). Name the issue beside it, with its owner, or in a "
|
||||
"dated progressive insight that says what it tracks"
|
||||
% (line + text.count("\n", 0, m.start()), m.group(0)))
|
||||
|
||||
checked = (
|
||||
len(glob.glob(os.path.join(ROOT, "03-DESIGN", "0*", "*.md")))
|
||||
+ len(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md")))
|
||||
+ len(glob.glob(os.path.join(ROOT, "01-RESEARCH", "*", "00-overview.md")))
|
||||
+ len(records)
|
||||
)
|
||||
if failures:
|
||||
print("cycle: %d document(s) break the development cycle:" % len(failures))
|
||||
|
||||
+24
-64
@@ -1,23 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The reading order of the decision records: its topics are written, its list is generated.
|
||||
"""The reading order of the decision records: its topics are written here, its records are not.
|
||||
|
||||
A number identifies a record and never changes, so the folder listing is creation order rather
|
||||
than reading order. The reading order is the six topics `02-DECISIONS/README.md` writes down, in
|
||||
order, and each record's own `topic:`. The list of records under each topic is generated from
|
||||
that frontmatter when somebody reads it, and never stored (ADR 0248, hq issue 297): a stored
|
||||
list was a line every decision pull request added to one file, so any two open at once conflicted.
|
||||
A number identifies a record and never changes, so the reading order is not the order of the numbers.
|
||||
It is the six topics `02-DECISIONS/README.md` writes down, in order, and each record's own topic. The
|
||||
records live in the knowledge base (ADR 0290, issue 426), which reads these lines through the records
|
||||
module and refuses a record whose topic is not one of them; the list of records under a topic is its
|
||||
answer to `knowledge-base.holds` with that topic, never stored here (ADR 0248, hq issue 297).
|
||||
|
||||
python3 00-META/checks/index.py check
|
||||
python3 00-META/checks/index.py --print print the list, by topic, in reading order
|
||||
|
||||
It fails when the README's reading order is missing, when a record's topic is not one of its
|
||||
topics, and when the README stores a list of records again — the index markers, or a line of
|
||||
the generated list. The last one is what keeps the conflict from coming back.
|
||||
It fails when the README's reading order is missing or names a topic twice, and when the README stores
|
||||
a list of records again — the index markers, or a line of a generated list. The last one is what keeps
|
||||
the conflict of issue 297 from coming back. Whether every record's topic is one of the six is the
|
||||
knowledge base's rule `topic`, judged when a record is proposed.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
@@ -25,13 +23,14 @@ README = "02-DECISIONS/README.md"
|
||||
|
||||
# A topic in the README's reading order, e.g.
|
||||
# 1. **What the mesh is** — `topic: the mesh`. What it is for, ...
|
||||
# The records module's `records_decisions` reads the same line, so the form is a contract.
|
||||
# The knowledge base (mesh-knowledge, reference.go) and the records module's `records_decisions` read the
|
||||
# same line, so the form is a contract.
|
||||
TOPIC_LINE = re.compile(r"^\d+\.\s+\*\*(?P<label>[^*]+)\*\*\s+—\s+`topic:\s*(?P<topic>[^`]+)`", re.M)
|
||||
|
||||
# What a stored list looks like: the old markers, or a line of the list this script prints.
|
||||
# What a stored list looks like: the old markers, or a line of the list index.py used to print.
|
||||
STORED = [
|
||||
(re.compile(r"<!--\s*index:(start|end)\s*-->"), "an index marker"),
|
||||
(re.compile(r"^- \*\*\d{4}\*\* — \[", re.M), "a line of the generated list"),
|
||||
(re.compile(r"^- \*\*\d{4}\*\* — \[", re.M), "a line of a generated list"),
|
||||
]
|
||||
|
||||
|
||||
@@ -42,72 +41,33 @@ def reading_order(text=None):
|
||||
return [(m.group("topic").strip(), m.group("label").strip()) for m in TOPIC_LINE.finditer(text)]
|
||||
|
||||
|
||||
def field(text, name):
|
||||
m = re.search(r'^%s:\s*(.+)$' % name, text, re.M)
|
||||
return m.group(1).strip() if m else None
|
||||
|
||||
|
||||
def records():
|
||||
out = []
|
||||
for path in sorted(glob.glob('02-DECISIONS/0*.md')):
|
||||
text = io.open(path, encoding='utf-8').read()
|
||||
heading = re.search(r'^# \d+\.\s*(.+)$', text, re.M)
|
||||
out.append({
|
||||
"file": os.path.basename(path),
|
||||
"number": os.path.basename(path)[:4],
|
||||
"title": heading.group(1).strip() if heading else "(no heading)",
|
||||
"topic": field(text, "topic"),
|
||||
"status": field(text, "status"),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def render(topics, rs):
|
||||
lines = []
|
||||
for topic, label in topics:
|
||||
rows = [r for r in rs if r["topic"] == topic]
|
||||
if not rows:
|
||||
continue
|
||||
lines.append("### %s" % label)
|
||||
lines.append("")
|
||||
for r in rows:
|
||||
mark = "" if r["status"] == "accepted" else " *(%s)*" % r["status"]
|
||||
lines.append("- **%s** — [%s](%s)%s" % (r["number"], r["title"], r["file"], mark))
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
if "--print" in sys.argv:
|
||||
print("index: the records live in the knowledge base (ADR 0290). Read a topic's with "
|
||||
"`knowledge-base.holds` and that topic, through the mesh MCP server.")
|
||||
return 2
|
||||
|
||||
text = io.open(README, encoding='utf-8').read()
|
||||
topics = reading_order(text)
|
||||
rs = records()
|
||||
|
||||
if "--print" in sys.argv:
|
||||
print(render(topics, rs))
|
||||
return 0
|
||||
|
||||
problems = []
|
||||
if not topics:
|
||||
problems.append("%s writes no reading order: no line of the form "
|
||||
"'1. **Label** — `topic: name`. ...'" % README)
|
||||
"'1. **Label** — `topic: name`. ...'; the knowledge base refuses every "
|
||||
"record without it" % README)
|
||||
names = [t for t, _ in topics]
|
||||
if len(set(names)) != len(names):
|
||||
problems.append("%s names a topic twice in its reading order" % README)
|
||||
for pattern, what in STORED:
|
||||
if pattern.search(text):
|
||||
problems.append("%s stores the list of records (%s). The list is generated when read "
|
||||
"(ADR 0248): `python3 00-META/checks/index.py --print`" % (README, what))
|
||||
known = set(names)
|
||||
for r in rs if known else []: # with no reading order, every record would be named; one line says why
|
||||
if r["topic"] not in known:
|
||||
problems.append("%s: topic %r is not one of the reading order's: %s"
|
||||
% (r["file"], r["topic"], ", ".join(names)))
|
||||
problems.append("%s stores a list of records (%s). The records live in the knowledge base, "
|
||||
"and a topic's list is `knowledge-base.holds` with that topic" % (README, what))
|
||||
|
||||
for p in problems:
|
||||
print("index: " + p)
|
||||
if problems:
|
||||
return 1
|
||||
print("index: %d topics, %d records, no stored list" % (len(topics), len(rs)))
|
||||
print("index: %d topics in the reading order, no stored list" % len(topics))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+69
-356
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Structural checks over HQ's own records.
|
||||
"""Structural checks over HQ's own documents.
|
||||
|
||||
Every check here exists because the thing it checks for actually happened. See README.md
|
||||
for which incident is behind which check. Run from the repository root:
|
||||
@@ -7,6 +7,11 @@ for which incident is behind which check. Run from the repository root:
|
||||
python3 00-META/checks/records.py
|
||||
|
||||
Exits non-zero if anything fails, so it can be a gate rather than a report.
|
||||
|
||||
The decision records and research efforts live in the knowledge base (ADR 0290, issue 426), and the
|
||||
rules that judged them here are the knowledge base's now: `propose` refuses a record that breaks one.
|
||||
README.md beside this file says where each rule went. What stays here judges what this repository
|
||||
still holds, and keeps the records from coming back.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -15,14 +20,17 @@ import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Where a citation is *guidance* rather than history. A document here tells somebody what to
|
||||
# do, so a link to a superseded record is an instruction to follow a withdrawn decision.
|
||||
# 02-DECISIONS and 01-RESEARCH are deliberately absent: they record what was decided and what
|
||||
# was observed, and both legitimately discuss superseded records at length.
|
||||
GOVERNING = ("00-META/", "03-DESIGN/01-to-be/")
|
||||
|
||||
LINK = re.compile(r"\[[^\]]*\]\((?!https?:|mailto:)([^)]+)\)")
|
||||
ADR_FILE = re.compile(r"^(\d{4})-")
|
||||
|
||||
# The two folders the records left. Each keeps a README that points at the knowledge base, and
|
||||
# 02-DECISIONS/README.md also writes the reading order's topics, which the knowledge base reads.
|
||||
RECORD_FOLDERS = ("02-DECISIONS", "01-RESEARCH")
|
||||
POINTERS = {"02-DECISIONS/README.md", "01-RESEARCH/README.md"}
|
||||
|
||||
# A decision record is named "ADR NNNN", four digits, and a research effort "research NNN", three.
|
||||
DECISION_ENTRY = re.compile(r"^ADR \d{4}$")
|
||||
ADR_MENTION = re.compile(r"\bADRs?\s+(\d+)\b")
|
||||
RESEARCH_MENTION = re.compile(r"\b[Rr]esearch\s+(\d+)\b")
|
||||
|
||||
|
||||
def markdown_files():
|
||||
@@ -45,7 +53,7 @@ def read(path):
|
||||
def frontmatter(text):
|
||||
"""Minimal frontmatter reader — enough for the fields these checks use.
|
||||
|
||||
Not a YAML parser on purpose: a dependency in a repository that has none, to read four
|
||||
Not a YAML parser on purpose: a dependency in a repository that has none, to read a few
|
||||
scalar fields and one list, would cost more than it returns.
|
||||
"""
|
||||
if not text.startswith("---\n"):
|
||||
@@ -74,24 +82,11 @@ def frontmatter(text):
|
||||
return fields
|
||||
|
||||
|
||||
def load_records():
|
||||
"""Every decision record, by its four-digit number."""
|
||||
records = {}
|
||||
folder = os.path.join(ROOT, "02-DECISIONS")
|
||||
for name in sorted(os.listdir(folder)):
|
||||
match = ADR_FILE.match(name)
|
||||
if not name.endswith(".md") or not match:
|
||||
continue
|
||||
path = os.path.join(folder, name)
|
||||
text = read(path)
|
||||
records[match.group(1)] = {
|
||||
"number": match.group(1),
|
||||
"name": name,
|
||||
"path": path,
|
||||
"text": text,
|
||||
"front": frontmatter(text),
|
||||
}
|
||||
return records
|
||||
def unquote(value):
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
class Failures:
|
||||
@@ -129,337 +124,62 @@ def check_links(failures):
|
||||
failures.add("links", f"{rel(path)}:{number}", f"link does not resolve: {target}")
|
||||
|
||||
|
||||
def check_rests_on(failures, records):
|
||||
"""A document's `decisions:` and a record's `extends:` must name a live record.
|
||||
def check_no_records(failures):
|
||||
"""No decision record or research effort is written into this repository again (ADR 0290 rule 9).
|
||||
|
||||
These are the load-bearing citations: the document declares that it rests on that
|
||||
decision. Resting on a withdrawn one is the defect this whole check set exists for.
|
||||
The knowledge base is the only place a record is read; a file here would be a second copy that
|
||||
drifts from it, and nothing would read it. The folders keep their pointer READMEs and nothing else.
|
||||
"""
|
||||
for path in markdown_files():
|
||||
front = frontmatter(read(path))
|
||||
cited = list(front.get("decisions", []) or [])
|
||||
extends = front.get("extends")
|
||||
if isinstance(extends, str) and extends:
|
||||
cited.append(extends)
|
||||
|
||||
for entry in cited:
|
||||
match = ADR_FILE.match(os.path.basename(entry))
|
||||
if not match:
|
||||
failures.add("rests-on", rel(path), f"not a decision record: {entry}")
|
||||
continue
|
||||
number = match.group(1)
|
||||
if number not in records:
|
||||
failures.add("rests-on", rel(path), f"no such record: {entry}")
|
||||
continue
|
||||
status = records[number]["front"].get("status")
|
||||
if status != "accepted":
|
||||
# A proposed record may extend another proposed one. Decisions are drafted in
|
||||
# chains -- 0059 extends 0057 while both await review -- and refusing that would
|
||||
# mean either drafting out of order or marking records accepted to satisfy a
|
||||
# check, which is the failure this repository already made once.
|
||||
if frontmatter(read(path)).get("status") == "proposed":
|
||||
continue
|
||||
# An as-is document describes what runs, and what runs was built under
|
||||
# whatever was decided at the time. ADR 0056: "as-is describing a superseded
|
||||
# decision is exactly what as-is is for."
|
||||
if rel(path).startswith("03-DESIGN/00-as-is/"):
|
||||
continue
|
||||
# A withdrawn record's citations are history. It instructs nobody -- every reader
|
||||
# is sent to its superseder -- so what it was built on may itself be withdrawn.
|
||||
# Refusing that would mean rewriting the lineage of a record whose reasoning is
|
||||
# the thing the immutability rule protects.
|
||||
if frontmatter(read(path)).get("status") == "superseded":
|
||||
continue
|
||||
# An extension that supersedes legitimately names what it replaced.
|
||||
this = ADR_FILE.match(os.path.basename(path))
|
||||
supersedes = records[number]["front"].get("superseded-by", "")
|
||||
if this and supersedes and os.path.basename(path) in str(supersedes):
|
||||
for folder in RECORD_FOLDERS:
|
||||
base = os.path.join(ROOT, folder)
|
||||
if not os.path.isdir(base):
|
||||
failures.add("no-records", folder, "the folder is gone; it keeps a README that points at the "
|
||||
"knowledge base (and, for 02-DECISIONS, the reading order the knowledge base reads)")
|
||||
continue
|
||||
for current, dirs, files in os.walk(base):
|
||||
for name in sorted(files + [d + "/" for d in dirs]):
|
||||
path = rel(os.path.join(current, name))
|
||||
if path.rstrip("/") in POINTERS:
|
||||
continue
|
||||
failures.add(
|
||||
"rests-on",
|
||||
rel(path),
|
||||
f"rests on ADR {number}, which is '{status}' — a document may not rest on a "
|
||||
f"record that is not accepted",
|
||||
"no-records",
|
||||
path,
|
||||
"a decision record or research effort lives in the knowledge base, never here: "
|
||||
"write it with knowledge-base.reserve and knowledge-base.propose (playbooks 01 and 02)",
|
||||
)
|
||||
|
||||
|
||||
def check_live_citations(failures, records):
|
||||
"""In a governing document, a link to a superseded record must name its replacement.
|
||||
def check_decisions_entries(failures):
|
||||
"""A design's `decisions:` names each record as "ADR NNNN".
|
||||
|
||||
The reader of a rule needs to know the rule was withdrawn, and needs somewhere to go.
|
||||
Naming the superseder in the same paragraph is both, and it is what a person would
|
||||
write anyway.
|
||||
A to-be design rests on the records it names, and the knowledge base answers for a record by its
|
||||
number; an entry in another form is a citation nothing can follow.
|
||||
"""
|
||||
superseded = {
|
||||
number: record["front"].get("superseded-by", "")
|
||||
for number, record in records.items()
|
||||
if record["front"].get("status") == "superseded"
|
||||
}
|
||||
|
||||
for path in markdown_files():
|
||||
if not any(rel(path).startswith(prefix) for prefix in GOVERNING):
|
||||
if not rel(path).startswith("03-DESIGN/"):
|
||||
continue
|
||||
for entry in frontmatter(read(path)).get("decisions", []) or []:
|
||||
if not DECISION_ENTRY.match(unquote(entry)):
|
||||
failures.add("decisions-form", rel(path),
|
||||
f"decisions: entry {entry!r} is not 'ADR NNNN' (four digits)")
|
||||
|
||||
|
||||
def check_mentions(failures):
|
||||
"""In running text, a decision record is "ADR NNNN" (four digits) and a research effort "research NNN"
|
||||
(three): the form the knowledge base and the issue tracker read a citation in. A code span is an example,
|
||||
and is not read."""
|
||||
for path in markdown_files():
|
||||
text = read(path)
|
||||
offset = 0
|
||||
for paragraph in text.split("\n\n"):
|
||||
line_no = text[:offset].count("\n") + 1
|
||||
offset += len(paragraph) + 2
|
||||
targets = LINK.findall(paragraph)
|
||||
for target in targets:
|
||||
match = ADR_FILE.match(os.path.basename(target.split("#")[0]))
|
||||
if not match or match.group(1) not in superseded:
|
||||
continue
|
||||
number = match.group(1)
|
||||
replacement = os.path.basename(str(superseded[number]))
|
||||
if not replacement:
|
||||
failures.add(
|
||||
"live-citation",
|
||||
f"{rel(path)}:{line_no}",
|
||||
f"cites superseded ADR {number}, which names no superseder",
|
||||
)
|
||||
continue
|
||||
if not any(replacement in t for t in targets):
|
||||
failures.add(
|
||||
"live-citation",
|
||||
f"{rel(path)}:{line_no}",
|
||||
f"cites superseded ADR {number} without naming its replacement "
|
||||
f"({replacement}) in the same paragraph",
|
||||
)
|
||||
|
||||
|
||||
def check_supersession_symmetry(failures, records):
|
||||
"""If A says it was superseded by B, B must say it supersedes A."""
|
||||
for number, record in records.items():
|
||||
front = record["front"]
|
||||
status = front.get("status")
|
||||
by = os.path.basename(str(front.get("superseded-by", "")))
|
||||
|
||||
if status == "superseded" and not by:
|
||||
failures.add("supersession", rel(record["path"]), "marked superseded but names no superseder")
|
||||
continue
|
||||
if by and status != "superseded":
|
||||
failures.add("supersession", rel(record["path"]), f"names a superseder but status is '{status}'")
|
||||
if not by:
|
||||
continue
|
||||
|
||||
match = ADR_FILE.match(by)
|
||||
if not match or match.group(1) not in records:
|
||||
failures.add("supersession", rel(record["path"]), f"superseder does not exist: {by}")
|
||||
continue
|
||||
other = records[match.group(1)]
|
||||
# `supersedes:` may name one record or several. One decision replacing two is a real
|
||||
# situation -- two records that built and refined the same wrong mechanism are withdrawn
|
||||
# by the one record that removes it -- and a check that allows only one would force
|
||||
# either a chain of pro-forma records or an unmarked supersession.
|
||||
claimed = other["front"].get("supersedes", "")
|
||||
if isinstance(claimed, str):
|
||||
claimed = [claimed] if claimed else []
|
||||
claims = [os.path.basename(str(entry)) for entry in claimed]
|
||||
if record["name"] not in claims:
|
||||
failures.add(
|
||||
"supersession",
|
||||
rel(other["path"]),
|
||||
f"ADR {number} says this supersedes it; this record does not say so "
|
||||
f"(supersedes: {', '.join(claims) or 'absent'})",
|
||||
)
|
||||
|
||||
|
||||
PARTIAL_MARK = re.compile(
|
||||
r"^> \*\*Superseded in part — (\d{4}-\d{2}-\d{2}), by \[ADR (\d{4})\]\(", re.M
|
||||
)
|
||||
|
||||
|
||||
# The form these notes had before the check: it named the later record but nothing read it.
|
||||
NARROWED_MARK = re.compile(r"^> \*\*Narrowed, not replaced — [^\n]*\[ADR \d{4}\]\(", re.M)
|
||||
|
||||
|
||||
def check_partial_supersession(failures, records):
|
||||
"""A record superseded in part says so under its heading, and its superseder names it.
|
||||
|
||||
A partial supersession was written only in the newer record's prose six times, and 11 of the
|
||||
21 `supersedes-in-part:` links had no note on the record they change, so a reader who arrived
|
||||
at the older record by a citation read a rule that no longer held. Both directions are
|
||||
checked: the field on the newer record and the dated note on the older one, naming each other.
|
||||
The note is dated by the newer record's `date:`, and the older form of the note, which named a
|
||||
later record and was read by nothing, is refused so it cannot be a way round this check.
|
||||
"""
|
||||
for number, record in records.items():
|
||||
claimed = record["front"].get("supersedes-in-part", [])
|
||||
if isinstance(claimed, str):
|
||||
claimed = [claimed] if claimed else []
|
||||
for entry in claimed:
|
||||
match = ADR_FILE.match(os.path.basename(entry))
|
||||
if not match or match.group(1) not in records:
|
||||
failures.add("partial-supersession", rel(record["path"]), f"no such record: {entry}")
|
||||
continue
|
||||
older = records[match.group(1)]
|
||||
marks = [by for _, by in PARTIAL_MARK.findall(older["text"])]
|
||||
if number not in marks:
|
||||
failures.add(
|
||||
"partial-supersession",
|
||||
rel(older["path"]),
|
||||
f"ADR {number} supersedes this in part; this record carries no "
|
||||
f"'> **Superseded in part — YYYY-MM-DD, by [ADR {number}](…).**' note",
|
||||
)
|
||||
for line in NARROWED_MARK.findall(record["text"]):
|
||||
failures.add(
|
||||
"partial-supersession",
|
||||
rel(record["path"]),
|
||||
f"a note names a later record in the old form ({line.strip()[:60]}…); write it as "
|
||||
f"'> **Superseded in part — YYYY-MM-DD, by [ADR NNNN](…).**' and declare it in that "
|
||||
f"record's `supersedes-in-part:`",
|
||||
)
|
||||
for date, by in PARTIAL_MARK.findall(record["text"]):
|
||||
if by in records and date != str(records[by]["front"].get("date", "")):
|
||||
failures.add(
|
||||
"partial-supersession",
|
||||
rel(record["path"]),
|
||||
f"the note for ADR {by} is dated {date}; ADR {by} is dated "
|
||||
f"{records[by]['front'].get('date', 'nowhere')}",
|
||||
)
|
||||
if by not in records:
|
||||
failures.add("partial-supersession", rel(record["path"]), f"note names no such record: ADR {by}")
|
||||
continue
|
||||
names = records[by]["front"].get("supersedes-in-part", [])
|
||||
if isinstance(names, str):
|
||||
names = [names] if names else []
|
||||
if record["name"] not in [os.path.basename(str(n)) for n in names]:
|
||||
failures.add(
|
||||
"partial-supersession",
|
||||
rel(records[by]["path"]),
|
||||
f"ADR {number} says this supersedes it in part; this record's "
|
||||
f"`supersedes-in-part:` does not name it",
|
||||
)
|
||||
|
||||
|
||||
CLAUSES_FROM = "2026-10-11"
|
||||
DECISION_SECTION = re.compile(r"^## Decision[^\n]*\n(.*?)(?=^## |\Z)", re.M | re.S)
|
||||
CLAUSE = re.compile(r"^\d+\. \*\*", re.M)
|
||||
|
||||
|
||||
def check_clauses(failures, records):
|
||||
"""A record dated from 2026-10-11 writes its decision as numbered clauses (ADR 0290 rule 3).
|
||||
|
||||
The knowledge base reads a decision's clauses from the merged text, so a clause's wording is the reviewed
|
||||
one. 226 of 263 records written before then are prose, and their clauses had to be drafted and confirmed
|
||||
instead; a new record is not added to them.
|
||||
"""
|
||||
for number, record in records.items():
|
||||
front = record["front"]
|
||||
if front.get("status") != "accepted" or str(front.get("date", "")) < CLAUSES_FROM:
|
||||
continue
|
||||
section = DECISION_SECTION.search(record["text"])
|
||||
if not section or not CLAUSE.search(section.group(1)):
|
||||
failures.add(
|
||||
"clauses",
|
||||
rel(record["path"]),
|
||||
"dated from 2026-10-11, and its `## Decision` section has no numbered clause "
|
||||
"('1. **…**'); write the decision as numbered clauses with a bold lead",
|
||||
)
|
||||
|
||||
|
||||
def check_topics(failures, records):
|
||||
"""Every record names a topic of the reading order.
|
||||
|
||||
The topic is what puts a record in the reading order, so a record without one — or with one
|
||||
nobody defined — disappears from the generated list rather than appearing in the wrong place.
|
||||
That is the quiet failure, so it is the one checked. The topics are the ones
|
||||
`02-DECISIONS/README.md` writes down, read the way `index.py` reads them, so there is one list.
|
||||
"""
|
||||
from index import reading_order
|
||||
known = {topic for topic, _ in reading_order()}
|
||||
for number, record in sorted(records.items()):
|
||||
topic = record["front"].get("topic")
|
||||
if not topic:
|
||||
failures.add("topics", rel(record["path"]),
|
||||
"no topic, so it has no place in the reading order")
|
||||
elif topic not in known:
|
||||
failures.add("topics", rel(record["path"]),
|
||||
"topic %r is not one of: %s" % (topic, ", ".join(sorted(known))))
|
||||
|
||||
|
||||
def check_numbering(failures, records):
|
||||
"""The number in the filename is the number in the heading."""
|
||||
for number, record in records.items():
|
||||
heading = re.search(r"^# (\d+)\.", record["text"], re.M)
|
||||
if not heading:
|
||||
failures.add("numbering", rel(record["path"]), "no '# N. Title' heading")
|
||||
elif heading.group(1) != number.lstrip("0"):
|
||||
failures.add(
|
||||
"numbering",
|
||||
rel(record["path"]),
|
||||
f"filename says {number}, heading says {heading.group(1)}",
|
||||
)
|
||||
|
||||
|
||||
def check_progressive_insights(failures, records):
|
||||
"""A correction made inside a record is marked and dated, or it is a silent rewrite.
|
||||
|
||||
A record may be corrected in place when a *fact* in it went stale and the decision still
|
||||
stands (`02-DECISIONS/README.md`, "Progressive insight"). The whole safety of that allowance
|
||||
is that the correction is legible in the record rather than only in a diff nobody reads, so
|
||||
the form is what is checked here: every mention of an insight is the marker, the marker
|
||||
carries an ISO date, and that date is not earlier than the decision's own — an insight
|
||||
predating the decision it corrects is a copied marker, not a correction.
|
||||
|
||||
What this cannot check is an edit made with no marker at all. Nothing mechanical can; that
|
||||
one is the reviewer's, reading the diff. The check keeps the *marked* path honest so that an
|
||||
unmarked change stands out as the anomaly it is.
|
||||
"""
|
||||
phrase = re.compile(r"progressive insight", re.I)
|
||||
# Both patterns stay on one line: a bold run does not span paragraphs, and `[^*]*` across
|
||||
# newlines will happily join an unrelated `**` far above to the marker below, reporting the
|
||||
# whole span between them. It did exactly that the first time this ran.
|
||||
# Trailing words after the date are allowed — "— 2026-09-26, correcting the one above." — so
|
||||
# an insight can say what it relates to. Only the date's presence and position are fixed.
|
||||
marker = re.compile(r"\*\*Progressive insights?[ \t]*[\u2014\u2013-][ \t]*(\d{4}-\d{2}-\d{2})[^*\n]*\*\*")
|
||||
loose = re.compile(r"\*\*[^*\n]*[Pp]rogressive insights?[^*\n]*\*\*")
|
||||
iso = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
for number, record in sorted(records.items()):
|
||||
text = record["text"]
|
||||
if not phrase.search(text):
|
||||
continue
|
||||
decided = str(record["front"].get("date", ""))
|
||||
good = [(m.start(), m.end(), m.group(1)) for m in marker.finditer(text)]
|
||||
|
||||
for m in loose.finditer(text):
|
||||
if any(s <= m.start() and m.end() <= e for s, e, _ in good):
|
||||
continue
|
||||
# A bold run carrying a link is discussing an insight — usually another record's —
|
||||
# rather than marking one. A marker never needs to cite anything.
|
||||
if "](" in m.group(0):
|
||||
continue
|
||||
failures.add("insights", rel(record["path"]),
|
||||
"a progressive insight is not in the dated marked form "
|
||||
"'**Progressive insight \u2014 YYYY-MM-DD.**': %s" % m.group(0))
|
||||
|
||||
for _, _, stamp in good:
|
||||
if decided and iso.match(decided) and stamp < decided:
|
||||
failures.add("insights", rel(record["path"]),
|
||||
"a progressive insight dated %s predates the decision (%s)"
|
||||
% (stamp, decided))
|
||||
|
||||
covered = [(s, e) for s, e, _ in good]
|
||||
for m in phrase.finditer(text):
|
||||
if any(s <= m.start() and m.end() <= e for s, e in covered):
|
||||
continue
|
||||
line = text.rfind("\n", 0, m.start()) + 1
|
||||
end = text.find("\n", m.end())
|
||||
whole = text[line:end if end != -1 else len(text)]
|
||||
if whole.lstrip().startswith("#"):
|
||||
continue
|
||||
# A line that also carries a link is discussing the rule, not marking a correction:
|
||||
# a marker never needs to cite anything, and a record that reasons about the policy
|
||||
# must be able to name it. Bare prose with no citation is the informal marking this
|
||||
# is here to catch.
|
||||
if "](" in whole:
|
||||
continue
|
||||
if loose.search(text, line, text.find("\n", m.end()) + 1 or len(text)):
|
||||
continue
|
||||
failures.add("insights", rel(record["path"]),
|
||||
"'progressive insight' appears unmarked; a correction is marked and "
|
||||
"dated, or it is a silent rewrite")
|
||||
for number, line in enumerate(text.split("\n"), 1):
|
||||
line = re.sub(r"`[^`]*`", "", line)
|
||||
for m in ADR_MENTION.finditer(line):
|
||||
if len(m.group(1)) != 4:
|
||||
failures.add("mention-form", f"{rel(path)}:{number}",
|
||||
f"{m.group(0)!r}: a decision record is named 'ADR NNNN', four digits")
|
||||
for m in RESEARCH_MENTION.finditer(line):
|
||||
if len(m.group(1)) != 3:
|
||||
failures.add("mention-form", f"{rel(path)}:{number}",
|
||||
f"{m.group(0)!r}: a research effort is named 'research NNN', three digits")
|
||||
|
||||
|
||||
def check_status_against_code(failures):
|
||||
@@ -495,18 +215,11 @@ def check_status_against_code(failures):
|
||||
|
||||
def main():
|
||||
failures = Failures()
|
||||
records = load_records()
|
||||
check_links(failures)
|
||||
check_rests_on(failures, records)
|
||||
check_live_citations(failures, records)
|
||||
check_supersession_symmetry(failures, records)
|
||||
check_partial_supersession(failures, records)
|
||||
check_clauses(failures, records)
|
||||
check_numbering(failures, records)
|
||||
check_topics(failures, records)
|
||||
check_no_records(failures)
|
||||
check_decisions_entries(failures)
|
||||
check_mentions(failures)
|
||||
check_status_against_code(failures)
|
||||
check_progressive_insights(failures, records)
|
||||
print(f"records: {len(records)} decision records checked")
|
||||
return failures.report()
|
||||
|
||||
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
# Words allowed — documents `words.py` does not hold to the glossary's retired words
|
||||
|
||||
Read by [`words.py`](words.py) ([ADR 0244](../../02-DECISIONS/0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md)).
|
||||
Read by [`words.py`](words.py) (ADR 0244).
|
||||
One row per document. *Until* is the date by which it is reworded — past it, the row fails like the words
|
||||
themselves — or `kept` for a graduated research effort, which records what was said and keeps its words
|
||||
the way a decision record does. A row whose document no longer uses a retired word fails too, so this
|
||||
list only shrinks.
|
||||
themselves. A row whose document no longer uses a retired word fails too, so this list only shrinks. The
|
||||
research efforts this list kept as written (research 034 and 042) live in the knowledge base now (ADR 0290),
|
||||
which keeps a record's words as it was imported.
|
||||
|
||||
| Document | Until | Why |
|
||||
|---|---|---|
|
||||
| `01-RESEARCH/034-the-mesh-in-domains/00-overview.md` | kept | the effort that studied these words; its findings quote and propose them as they stood before ADR 0244 |
|
||||
| `01-RESEARCH/034-the-mesh-in-domains/01-the-concepts-in-use.md` | kept | as above: the inventory of the words in use |
|
||||
| `01-RESEARCH/034-the-mesh-in-domains/02-the-domains.md` | kept | as above: the proposed domains, in the words of the day |
|
||||
| `01-RESEARCH/034-the-mesh-in-domains/03-the-clashes.md` | kept | as above: every clash names the words that clashed |
|
||||
| `01-RESEARCH/034-the-mesh-in-domains/04-how-the-glossary-is-checked.md` | kept | as above: the check's own examples |
|
||||
| `01-RESEARCH/034-the-mesh-in-domains/05-a-glossary-by-domain.md` | kept | as above: the proposed glossary, with the entries of the day |
|
||||
| `01-RESEARCH/042-the-mesh-keeps-its-own-issues/00-overview.md` | kept | the effort that settled the word: it records the operator's ask in the words it was made in |
|
||||
|
||||
+25
-32
@@ -42,14 +42,14 @@ What is enforced:
|
||||
subcommands (`nox-mesh-host enrol`, `key`, `reconcile`, …) are not lines here: they are
|
||||
typed at a machine no mesh reaches yet, and the engine's usage says so.
|
||||
|
||||
Checked documents: 00-META/, 03-DESIGN/ (both layers), AGENTS.md and README.md, always; a research
|
||||
effort initiated, or an issue opened, on or after FROM (on or after LINES_FROM for the lines). Never
|
||||
02-DECISIONS/: a decision record keeps the words it was written with.
|
||||
Checked documents: 00-META/, 03-DESIGN/ (both layers), AGENTS.md and README.md, always; an issue opened
|
||||
on or after FROM (on or after LINES_FROM for the lines). The decision records and research efforts live in
|
||||
the knowledge base (ADR 0290, issue 426), which holds a record proposed there to the same words through its
|
||||
rules `retired-word` and `daemon-line`, read from this glossary.
|
||||
|
||||
A document the check fails on that cannot be reworded in the change that found it is named, with a date
|
||||
and a reason, in words-allowed.md beside this file. An entry past its date fails like the word itself, and
|
||||
an entry for a document that no longer needs it fails too. `kept` instead of a date is allowed only for a
|
||||
graduated research effort, which is a record of what was said, like a decision record.
|
||||
an entry for a document that no longer needs it fails too.
|
||||
|
||||
python3 00-META/checks/words.py
|
||||
python3 00-META/checks/words.py --list tools
|
||||
@@ -65,7 +65,7 @@ ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
GLOSSARY = "00-META/glossary.md"
|
||||
ALLOWED = "00-META/checks/words-allowed.md"
|
||||
|
||||
# The day the rule began (ADR 0244): research and issues from it on are held to it.
|
||||
# The day the rule began (ADR 0244): issues opened from it on are held to it.
|
||||
FROM = "2026-10-07"
|
||||
|
||||
ALWAYS = ("00-META/", "03-DESIGN/", "AGENTS.md", "README.md")
|
||||
@@ -168,21 +168,22 @@ def prose(text, glossary_file=False):
|
||||
return text
|
||||
|
||||
|
||||
def research_and_issues(since=FROM):
|
||||
"""The research efforts and issue reports dated on or after `since`, file by file."""
|
||||
def issues(since=FROM):
|
||||
"""The issue reports opened on or after `since`, file by file."""
|
||||
out = []
|
||||
for folder, key, first in (("01-RESEARCH", "initiated", "00-overview.md"), ("04-ISSUES", "opened", "00-report.md")):
|
||||
base = os.path.join(ROOT, folder)
|
||||
for effort in sorted(os.listdir(base)):
|
||||
head = os.path.join(base, effort, first)
|
||||
if not os.path.isfile(head):
|
||||
continue
|
||||
date = frontmatter_field(read(rel(head)), key)
|
||||
if not date or date < since:
|
||||
continue
|
||||
for name in sorted(os.listdir(os.path.join(base, effort))):
|
||||
if name.endswith(".md"):
|
||||
out.append("%s/%s/%s" % (folder, effort, name))
|
||||
base = os.path.join(ROOT, "04-ISSUES")
|
||||
if not os.path.isdir(base):
|
||||
return out
|
||||
for issue in sorted(os.listdir(base)):
|
||||
head = os.path.join(base, issue, "00-report.md")
|
||||
if not os.path.isfile(head):
|
||||
continue
|
||||
date = frontmatter_field(read(rel(head)), "opened")
|
||||
if not date or date < since:
|
||||
continue
|
||||
for name in sorted(os.listdir(os.path.join(base, issue))):
|
||||
if name.endswith(".md"):
|
||||
out.append("04-ISSUES/%s/%s" % (issue, name))
|
||||
return out
|
||||
|
||||
|
||||
@@ -198,15 +199,13 @@ def checked_documents(since=FROM):
|
||||
for name in sorted(files):
|
||||
if name.endswith(".md"):
|
||||
docs.append(rel(os.path.join(base, name)))
|
||||
return docs + research_and_issues(since)
|
||||
return docs + issues(since)
|
||||
|
||||
|
||||
def allowed():
|
||||
"""words-allowed.md: one row per document — | `document` | until | why |.
|
||||
|
||||
`until` is a date, after which the allowance fails like the words themselves, or `kept` for a
|
||||
document that records what was said and must keep its words: only a research effort that has
|
||||
graduated may be kept, because once it has become a decision it is a record like one.
|
||||
`until` is a date, after which the allowance fails like the words themselves.
|
||||
"""
|
||||
out, problems = {}, []
|
||||
if not os.path.isfile(os.path.join(ROOT, ALLOWED)):
|
||||
@@ -219,14 +218,8 @@ def allowed():
|
||||
doc, until, why = cells[0].strip("`"), cells[1], cells[2]
|
||||
if not why:
|
||||
problems.append("%s: the allowance for %s gives no reason" % (ALLOWED, doc))
|
||||
if until == "kept":
|
||||
overview = os.path.join(os.path.dirname(doc), "00-overview.md")
|
||||
status = frontmatter_field(read(overview), "status") if os.path.isfile(os.path.join(ROOT, overview)) else None
|
||||
if not doc.startswith("01-RESEARCH/") or status != "graduated":
|
||||
problems.append("%s: %s is kept, but only a graduated research effort may be" % (ALLOWED, doc))
|
||||
continue
|
||||
elif not re.match(r"^\d{4}-\d{2}-\d{2}$", until):
|
||||
problems.append("%s: the allowance for %s has neither a date nor `kept`" % (ALLOWED, doc))
|
||||
if not re.match(r"^\d{4}-\d{2}-\d{2}$", until):
|
||||
problems.append("%s: the allowance for %s has no date" % (ALLOWED, doc))
|
||||
continue
|
||||
elif until < today:
|
||||
problems.append("%s: the allowance for %s ran out on %s — reword it" % (ALLOWED, doc, until))
|
||||
|
||||
Reference in New Issue
Block a user