Delete merge-check.sh and the four Python checks; the Go tests replace them (issue 1296, H6)
This commit is contained in:
+30
-31
@@ -1,20 +1,19 @@
|
||||
# Checks
|
||||
|
||||
Since ADR 0527 the same four checks are also Go tests, in this folder (`*_test.go`) with `go.mod` at the root: run
|
||||
`go test ./...` from the root. Each check is a function over a file tree that returns its problems, tested on hq's tree
|
||||
and on a deliberately broken tree built in memory. The Python files and `merge-check.sh` run beside them until the
|
||||
delivery of R4g is done and hq's trunk requires `mesh/check` (ADR 0527 §3); then they go (issue 1296).
|
||||
The four checks are Go tests (ADR 0527), in this folder (`*_test.go`) with `go.mod` at the root: run
|
||||
`go test ./...` from the root. The checker runs the same as `mesh/check` on every pull request. Each check is a
|
||||
function over a file tree that returns its problems, tested on hq's tree and on a deliberately broken tree built
|
||||
in memory. The Python files and `merge-check.sh` are gone (issue 1296).
|
||||
|
||||
```
|
||||
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
|
||||
records_test.go structure: links, no record written here, citations in the form "ADR NNNN"
|
||||
index_test.go the reading order's topics are written, and no list of records is stored
|
||||
cycle_test.go the development cycle: a design's layer, status, decisions and code; an issue report's frontmatter
|
||||
words_test.go the glossary's retired words are not used, no daemon's line is written, and no word is defined twice
|
||||
```
|
||||
|
||||
Non-zero exit on any problem, so it can be a gate rather than a report.
|
||||
A failing test fails `go test`, so it can be a gate rather than a report. With `MESH_CATALOG_DIR` set, the words
|
||||
test also compares the catalogue's copies of the retired words and the daemons' lines with the glossary.
|
||||
|
||||
**Why this exists.** Until now nothing in this repository was verified by anything but reading,
|
||||
which is how a superseded decision stayed live in the constitution for days and in
|
||||
@@ -31,7 +30,7 @@ indistinguishable from one that cannot.
|
||||
| `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 |
|
||||
| *(index test)* | 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
|
||||
@@ -44,22 +43,22 @@ acceptance and raise a condition when one fails (mesh-knowledge's `rules.go`, on
|
||||
|
||||
| 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 |
|
||||
| The records test's `rests-on`, for a record's `extends:` | the knowledge base's `rests-on` at propose, and the whole-set rule `rests-on-live` |
|
||||
| The records test's `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 |
|
||||
| The records test's `live-citation`, in a record | the knowledge base's `live-citation` |
|
||||
| The records test's `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 |
|
||||
| The records test's `supersession` | the knowledge base's `supersedes-accepted` and `self-link`, and the whole-set rule `supersession` |
|
||||
| The records test's `partial-supersession` | the knowledge base's `changes` link to a clause, `changes-in-force` and `supersedes-accepted`, and the whole-set rule `partial` |
|
||||
| The records test's `clauses` | the knowledge base's `has-clause`, `clause-numbering`, `clause-lead` and `clause-size` |
|
||||
| The records test's `numbering` | the knowledge base's `title` and `reserved` |
|
||||
| The records test's `topics`, The index test's (a record's topic) | the knowledge base's `topic`, against the reading order The index test's still checks |
|
||||
| The records test's `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 |
|
||||
| the cycle test research: a known status, `became:` when graduated | the knowledge base's `research-status`, `overview` and `became` |
|
||||
| the cycle test no two decision records share a number | the knowledge base's `reserved`, and the issue tracker's counter behind `knowledge-base.reserve` |
|
||||
| the cycle test 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) |
|
||||
| the cycle test a deferral names the issue that tracks it | the knowledge base's `deferral` |
|
||||
| the words test 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
|
||||
|
||||
@@ -77,7 +76,7 @@ acceptance and raise a condition when one fails (mesh-knowledge's `rules.go`, on
|
||||
State what incident it would have caught, and make it fail before you make it pass. A check
|
||||
whose failure has never been observed is a guess about its own correctness.
|
||||
|
||||
## cycle.py
|
||||
## The cycle test
|
||||
|
||||
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
|
||||
@@ -89,9 +88,9 @@ and no status key — `status:`, `located-in:`, `fixed-by:`, `amended-design:`,
|
||||
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`
|
||||
with them (the table above).
|
||||
|
||||
## words.py
|
||||
## The words test
|
||||
|
||||
One word per thing, checked (ADR 0244).
|
||||
It reads the glossary's *Not:* lines (the retired words, each with its scope) and *Identifier until
|
||||
@@ -106,7 +105,7 @@ of the tools' retired words (`retired-words`) with the glossary.
|
||||
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
|
||||
(`daemonLines` in the words test); 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 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.
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The development cycle, checked.
|
||||
|
||||
The knowledge flow (00-META/process/00-overview.md) says work moves idea -> research ->
|
||||
decision -> to-be design -> code, and symptom -> issue -> diagnosis -> fix. Those are rules,
|
||||
and a rule states how it is checked (AGENTS.md) -- this is how. Everything here reads only
|
||||
frontmatter, because a status this repository holds lives in frontmatter and nowhere else.
|
||||
|
||||
What is enforced:
|
||||
|
||||
design every 03-DESIGN doc parses, carries `layer:` matching its directory, and a
|
||||
known `status:`. A TO-BE doc names at least one decision (`decisions:`) -- no
|
||||
design without a decision -- and once `in-progress` or `implemented` it names
|
||||
its owning code (`code:`) -- no development without a design that says where.
|
||||
issues every report has frontmatter, and no two records share a number -- the number is
|
||||
how a record is cited. An issue's status, owner, fix and replay live in the issue
|
||||
tracker since the import of slice 3 (ADR 0275, to-be 53), where `issue-tracker.move`
|
||||
refuses at the write what this check refused at merge until then: a status outside
|
||||
the five, `located` without an owner, `resolved` without a fix, a core issue
|
||||
resolved without its replay (ADR 0237), `wontfix` without a reason. A report
|
||||
carries none of those keys, only the day it was `opened:` -- the tracker holds
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
python3 00-META/checks/cycle.py
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
DESIGN_STATUSES = {"proposed", "designed", "in-progress", "implemented", "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")
|
||||
|
||||
|
||||
def rel(path):
|
||||
return os.path.relpath(path, ROOT)
|
||||
|
||||
|
||||
def frontmatter(path):
|
||||
"""The YAML block between the first two --- lines, as {key: raw-value-string}.
|
||||
|
||||
Minimal on purpose, like records.py: enough for the fields these checks read. A list
|
||||
value (block or inline) is joined into its items; a scalar stays a string.
|
||||
"""
|
||||
text = open(path, encoding="utf-8").read()
|
||||
m = re.match(r"^---\n(.*?)\n---", text, re.S)
|
||||
if not m:
|
||||
return None
|
||||
front, out, key = m.group(1), {}, None
|
||||
for line in front.split("\n"):
|
||||
item = re.match(r"^\s+-\s*(.+?)\s*$", line)
|
||||
if item and key:
|
||||
out[key].append(item.group(1))
|
||||
continue
|
||||
kv = re.match(r"^([A-Za-z-]+):\s*(.*)$", line)
|
||||
if not kv:
|
||||
continue
|
||||
key, value = kv.group(1), kv.group(2).strip()
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
out[key] = [v.strip() for v in value[1:-1].split(",") if v.strip()]
|
||||
elif value == "":
|
||||
out[key] = [] # a block list may follow; stays [] if nothing does
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def listy(front, key):
|
||||
v = front.get(key)
|
||||
if v is None:
|
||||
return []
|
||||
return v if isinstance(v, list) else ([v] if str(v).strip() else [])
|
||||
|
||||
|
||||
def main():
|
||||
failures = []
|
||||
|
||||
def bad(path, why):
|
||||
failures.append(" %s: %s" % (rel(path), why))
|
||||
|
||||
# ---- design ------------------------------------------------------------------------
|
||||
for layer, name in (("00-as-is", "as-is"), ("01-to-be", "to-be")):
|
||||
for path in sorted(glob.glob(os.path.join(ROOT, "03-DESIGN", layer, "*.md"))):
|
||||
if os.path.basename(path) == "README.md":
|
||||
continue
|
||||
front = frontmatter(path)
|
||||
if front is None:
|
||||
bad(path, "no frontmatter")
|
||||
continue
|
||||
if front.get("layer") != name:
|
||||
bad(path, "layer is %r; this directory is %s" % (front.get("layer"), name))
|
||||
status = front.get("status")
|
||||
if status not in DESIGN_STATUSES:
|
||||
bad(path, "status %r is not one of %s" % (status, sorted(DESIGN_STATUSES)))
|
||||
if name == "to-be":
|
||||
if not listy(front, "decisions"):
|
||||
bad(path, "names no decisions -- no design without a decision")
|
||||
if status in ("in-progress", "implemented") and not listy(front, "code"):
|
||||
bad(path, "status %s but code: names no owner -- no development "
|
||||
"without a design that says where" % status)
|
||||
|
||||
# ---- issues ------------------------------------------------------------------------
|
||||
# Two records may not share a number. Numbers are taken as "next free after main", and work
|
||||
# sits on unmerged branches for days -- so two people reading the same main allocate the same
|
||||
# number, and nothing said so. It happened twice in one evening between two machines, and the
|
||||
# second collision landed on main with all three checks passing (issue 155). An issue number is
|
||||
# how every other record cites this one; two records answering to it means a pointer that
|
||||
# resolves to whichever the reader happened to open.
|
||||
seen = {}
|
||||
for folder in sorted(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", ""))):
|
||||
name = os.path.basename(os.path.normpath(folder))
|
||||
number = name.split("-", 1)[0]
|
||||
if not number.isdigit():
|
||||
continue
|
||||
if number in seen:
|
||||
bad(os.path.join("04-ISSUES", name),
|
||||
"is numbered %s, and so is %s -- an issue number is how it is cited, and two "
|
||||
"records answering to one means a citation that resolves to whichever the reader "
|
||||
"opened. Take the next free number across main AND every open pull request"
|
||||
% (number, seen[number]))
|
||||
else:
|
||||
seen[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.
|
||||
for path in sorted(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))):
|
||||
front = frontmatter(path)
|
||||
if front is None:
|
||||
bad(path, "no frontmatter")
|
||||
continue
|
||||
opened = str(front.get("opened") or "")
|
||||
if not opened:
|
||||
bad(path, "opened: says nothing -- the day the issue was opened, YYYY-MM-DD")
|
||||
continue
|
||||
carried = [key for key in TRACKER_KEYS if key in front]
|
||||
if carried:
|
||||
bad(path, "carries %s, but an issue's status, owner, fix and replay live in the issue tracker "
|
||||
"(ADR 0275): move it with `issue-tracker.move`, and keep no copy here"
|
||||
% ", ".join("`%s:`" % key for key in carried))
|
||||
|
||||
checked = (
|
||||
len(glob.glob(os.path.join(ROOT, "03-DESIGN", "0*", "*.md")))
|
||||
+ len(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md")))
|
||||
)
|
||||
if failures:
|
||||
print("cycle: %d document(s) break the development cycle:" % len(failures))
|
||||
print("\n".join(failures))
|
||||
return 1
|
||||
print("cycle: %d documents checked, the chain holds" % checked)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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 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
|
||||
|
||||
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 io
|
||||
import re
|
||||
import sys
|
||||
|
||||
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 knowledge base (mesh-knowledge, reference.go) reads 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 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 a generated list"),
|
||||
]
|
||||
|
||||
|
||||
def reading_order(text=None):
|
||||
"""The topics, in order, as (topic, label) pairs, from the README."""
|
||||
if text is None:
|
||||
text = io.open(README, encoding='utf-8').read()
|
||||
return [(m.group("topic").strip(), m.group("label").strip()) for m in TOPIC_LINE.finditer(text)]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
problems = []
|
||||
if not topics:
|
||||
problems.append("%s writes no reading order: no line of the form "
|
||||
"'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 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 in the reading order, no stored list" % len(topics))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,227 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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:
|
||||
|
||||
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
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
LINK = re.compile(r"\[[^\]]*\]\((?!https?:|mailto:)([^)]+)\)")
|
||||
|
||||
# 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():
|
||||
for base, dirs, files in os.walk(ROOT):
|
||||
dirs[:] = [d for d in dirs if d not in (".git", ".claude")]
|
||||
for name in sorted(files):
|
||||
if name.endswith(".md"):
|
||||
yield os.path.join(base, name)
|
||||
|
||||
|
||||
def rel(path):
|
||||
return os.path.relpath(path, ROOT)
|
||||
|
||||
|
||||
def read(path):
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
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 a few
|
||||
scalar fields and one list, would cost more than it returns.
|
||||
"""
|
||||
if not text.startswith("---\n"):
|
||||
return {}
|
||||
end = text.find("\n---", 4)
|
||||
if end == -1:
|
||||
return {}
|
||||
fields, key = {}, None
|
||||
for line in text[4:end].split("\n"):
|
||||
item = re.match(r"^\s+-\s+(.*)$", line)
|
||||
if item and key:
|
||||
fields.setdefault(key, []).append(item.group(1).strip())
|
||||
continue
|
||||
pair = re.match(r"^([A-Za-z_-]+):\s*(.*)$", line)
|
||||
if pair:
|
||||
key = pair.group(1)
|
||||
value = pair.group(2).strip()
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
# inline list: `decisions: [a, b]`, `code: []`
|
||||
inner = value[1:-1].strip()
|
||||
fields[key] = [v.strip() for v in inner.split(",") if v.strip()]
|
||||
elif value:
|
||||
fields[key] = value
|
||||
else:
|
||||
fields[key] = []
|
||||
return fields
|
||||
|
||||
|
||||
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:
|
||||
def __init__(self):
|
||||
self.items = []
|
||||
|
||||
def add(self, check, location, message):
|
||||
self.items.append((check, location, message))
|
||||
|
||||
def report(self):
|
||||
if not self.items:
|
||||
print("records: all checks passed")
|
||||
return 0
|
||||
by_check = {}
|
||||
for check, location, message in self.items:
|
||||
by_check.setdefault(check, []).append((location, message))
|
||||
for check in sorted(by_check):
|
||||
print(f"\n{check} — {len(by_check[check])} problem(s)")
|
||||
for location, message in by_check[check]:
|
||||
print(f" {location}\n {message}")
|
||||
print(f"\nrecords: {len(self.items)} problem(s)")
|
||||
return 1
|
||||
|
||||
|
||||
def check_links(failures):
|
||||
"""Every relative link resolves to something that exists."""
|
||||
for path in markdown_files():
|
||||
folder = os.path.dirname(path)
|
||||
for number, line in enumerate(read(path).split("\n"), 1):
|
||||
for target in LINK.findall(line):
|
||||
target = target.split("#")[0].strip()
|
||||
if not target:
|
||||
continue
|
||||
if not os.path.exists(os.path.normpath(os.path.join(folder, target))):
|
||||
failures.add("links", f"{rel(path)}:{number}", f"link does not resolve: {target}")
|
||||
|
||||
|
||||
def check_no_records(failures):
|
||||
"""No decision record or research effort is written into this repository again (ADR 0290 rule 9).
|
||||
|
||||
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 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(
|
||||
"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_decisions_entries(failures):
|
||||
"""A design's `decisions:` names each record as "ADR NNNN".
|
||||
|
||||
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.
|
||||
"""
|
||||
for path in markdown_files():
|
||||
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)
|
||||
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):
|
||||
"""A design document naming specific code may not still call itself `designed`.
|
||||
|
||||
**Naming a file is a claim that the file implements this**, so the two fields have to agree.
|
||||
They drifted: ten to-be documents named working, lab-proven code — several with a *What was
|
||||
built* or *Raised, and observed* section — while still saying nothing had been built.
|
||||
|
||||
Deliberately weak, and that is the point of it being mechanical. It cannot tell whether the
|
||||
prose is true, only that a document has stopped claiming to be unbuilt once it points at
|
||||
something. `code: [mesh-controller]` — a repository with no path — is a plan and stays
|
||||
`designed`.
|
||||
"""
|
||||
for path in markdown_files():
|
||||
if not rel(path).startswith("03-DESIGN/01-to-be/") or path.endswith("README.md"):
|
||||
continue
|
||||
front = frontmatter(read(path))
|
||||
if front.get("status") != "designed":
|
||||
continue
|
||||
for entry in front.get("code") or []:
|
||||
named = re.sub(r"\s*\(.*\)$", "", entry).strip().split(None, 1)
|
||||
if len(named) > 1:
|
||||
failures.add(
|
||||
"status-vs-code",
|
||||
rel(path),
|
||||
f"`designed`, but names {named[1]!r} in {named[0]}. Naming a file claims "
|
||||
f"it implements this — use `in-progress`, or `implemented` once it is "
|
||||
f"defensible from that repository's main branch.",
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
def main():
|
||||
failures = Failures()
|
||||
check_links(failures)
|
||||
check_no_records(failures)
|
||||
check_decisions_entries(failures)
|
||||
check_mentions(failures)
|
||||
check_status_against_code(failures)
|
||||
return failures.report()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,6 +1,6 @@
|
||||
# Words allowed — documents `words.py` does not hold to the glossary's retired words
|
||||
# Words allowed — documents the words test does not hold to the glossary's retired words
|
||||
|
||||
Read by [`words.py`](words.py) (ADR 0244).
|
||||
Read by the words test (`words_test.go`) (ADR 0244).
|
||||
One row per document. *Until* is the date by which it is reworded — past it, the row fails like the words
|
||||
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),
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One word per thing, checked (ADR 0244).
|
||||
|
||||
The glossary (00-META/glossary.md) is the authority on the mesh's words. It names the words it
|
||||
retired on one fixed kind of line, so a program can read them:
|
||||
|
||||
*Not:* ~~control plane~~, ~~overlay~~ (hq)
|
||||
|
||||
A struck word with no scope is retired everywhere; `(hq)` retires it in this repository's prose only;
|
||||
`(tools)` in the descriptions of the mesh's tools only. And it names code that still carries an old
|
||||
name until that code is renamed:
|
||||
|
||||
*Identifier until renamed:* `mesh-host` — the repository, binary and service unit
|
||||
|
||||
An identifier is allowed inside a code span and nowhere else.
|
||||
|
||||
What is enforced:
|
||||
|
||||
retired no retired word of scope `hq` (or none), and no identifier, in running prose of a
|
||||
checked document. Running prose is what is left once code blocks, code spans, block
|
||||
quotes, text in quotation marks, struck-through text, link targets, HTML comments and
|
||||
frontmatter are taken out: a quotation keeps the words it quotes, a link target is a file
|
||||
name, and an identifier lives in a code span. A word's plural is the word: "alerts" fails
|
||||
as "alert" does.
|
||||
unique no head word (a bolded word opening a glossary entry) heads two entries, and no head
|
||||
word is also struck through on a *Not:* line.
|
||||
tools list with `--list tools` it prints the words retired in the tools' descriptions, which is the
|
||||
copy the catalogue's own check keeps (mesh-catalog `retired-words`). With
|
||||
MESH_CATALOG_DIR set to a checkout of the catalogue it also fails when that copy differs.
|
||||
|
||||
lines no daemon's line in a checked document (ADR 0272, its insight of 2026-10-09). The operator
|
||||
types one command, `nox` (`mesh-cli`); the node-engine (`nox-mesh-host`) and the
|
||||
controller (`mesh-controller`) are daemons, and a person never types their names — so a
|
||||
document names a `nox` line, never `mesh-controller push` or `mesh-host hand-over`. The
|
||||
lines are DAEMON_LINES below: each daemon's commands, and which of them are a line only
|
||||
with an argument after them ("the mesh-controller module" is prose; `mesh-controller
|
||||
module check …` is a line). A line is looked for in the document's own words — code
|
||||
blocks and code spans included, since that is where a line to type is written — with
|
||||
quotations, block quotes, link targets and comments taken out. `--list lines` prints the
|
||||
list, which the catalogue's own check keeps a copy of (mesh-catalog `retired-lines`); with
|
||||
MESH_CATALOG_DIR set it also fails when that copy differs. The node-engine's install-time
|
||||
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; 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.
|
||||
|
||||
python3 00-META/checks/words.py
|
||||
python3 00-META/checks/words.py --list tools
|
||||
python3 00-META/checks/words.py --list lines
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
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): issues opened from it on are held to it.
|
||||
FROM = "2026-10-07"
|
||||
|
||||
ALWAYS = ("00-META/", "03-DESIGN/", "AGENTS.md", "README.md")
|
||||
|
||||
# The daemons' lines (ADR 0272, its insight of 2026-10-09), and the day that rule began. "alone" is a
|
||||
# command that is a line by itself; "with an argument" one that is a line only when written as one.
|
||||
LINES_FROM = "2026-10-09"
|
||||
DAEMON_LINES = {
|
||||
"mesh-controller": {
|
||||
"alone": ("adopt assign broker cancel cleanup collect conditions converge doctor durations hand-act "
|
||||
"hand-acts healers identity kill licence migrate operator pause pin plan prepare push rebuild "
|
||||
"rehearse replay resume retire rollout rotate secret settings status token unassign unpin "
|
||||
"upgrade").split(),
|
||||
"with an argument": ("artifacts ask builds bus collection data delivery images mirrors module node "
|
||||
"overlay plans queue seat seats take version").split(),
|
||||
},
|
||||
"mesh-host": {
|
||||
"alone": "apply bundle enrol enroll hand-over help inventory key overlay owned profile reconcile run version".split(),
|
||||
"with an argument": [],
|
||||
},
|
||||
"nox-mesh-host": {"alone": ["hand-over"], "with an argument": []},
|
||||
}
|
||||
|
||||
NOT_LINE = re.compile(r"^\s*(?:[-*]\s+)?\*Not:\*(.*)$", re.M)
|
||||
STRUCK = re.compile(r"~~([^~]+)~~(?:\s*\((hq|tools)\))?")
|
||||
IDENT_LINE = re.compile(r"^\s*(?:[-*]\s+)?\*Identifier until renamed:\*(.*)$", re.M)
|
||||
CODE_SPAN = re.compile(r"`([^`]+)`")
|
||||
HEAD = re.compile(r"^\s*-\s+\*\*([^*]+)\*\*", re.M)
|
||||
|
||||
|
||||
def rel(path):
|
||||
return os.path.relpath(path, ROOT)
|
||||
|
||||
|
||||
def read(path):
|
||||
with open(os.path.join(ROOT, path), encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
def frontmatter_field(text, name):
|
||||
if not text.startswith("---\n"):
|
||||
return None
|
||||
end = text.find("\n---", 4)
|
||||
m = re.search(r"^%s:\s*(\S+)" % name, text[4:end], re.M)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def glossary():
|
||||
"""The retired words with their scope, the identifiers, and the head words."""
|
||||
text = read(GLOSSARY)
|
||||
retired = []
|
||||
for line in NOT_LINE.findall(text):
|
||||
for word, scope in STRUCK.findall(line):
|
||||
for one in word.split(" / "):
|
||||
retired.append((one.strip(), scope or "all"))
|
||||
identifiers = []
|
||||
for line in IDENT_LINE.findall(text):
|
||||
identifiers += [i.strip() for i in CODE_SPAN.findall(line)]
|
||||
heads = []
|
||||
for head in HEAD.findall(text):
|
||||
heads += [h.strip() for h in head.split(" / ")]
|
||||
return retired, identifiers, heads
|
||||
|
||||
|
||||
def pattern(word):
|
||||
"""A whole-word, case-insensitive match; a space in the word matches a space, a line break or a hyphen.
|
||||
|
||||
The plural is the word too: its last part may end in `s` or `es` ("alerts", "control planes"), since
|
||||
a retired word does not come back by being counted. Anything else joined on is another word.
|
||||
"""
|
||||
parts = [re.escape(p) for p in word.split()]
|
||||
return re.compile(r"(?i)(?<![\w-])" + r"[\s-]+".join(parts) + r"(?:e?s)?(?![\w-])")
|
||||
|
||||
|
||||
def blank(match):
|
||||
return re.sub(r"[^\n]", " ", match.group(0))
|
||||
|
||||
|
||||
def prose(text, glossary_file=False):
|
||||
"""The text with everything that is not running prose blanked, keeping offsets and line numbers."""
|
||||
if text.startswith("---\n"):
|
||||
end = text.find("\n---", 4)
|
||||
if end != -1:
|
||||
text = re.sub(r"[^\n]", " ", text[: end + 4]) + text[end + 4:]
|
||||
steps = [
|
||||
r"(?ms)^\s*```.*?^\s*```", # code blocks
|
||||
r"(?s)<!--.*?-->", # comments
|
||||
r"(?m)^\s*>.*$", # block quotes
|
||||
r"`[^`\n]+`", # code spans
|
||||
r"\]\([^)\s]*\)", # link targets
|
||||
r"~~[^~\n]+~~", # struck through: a word named as retired
|
||||
r"\"[^\"\n]*\"", # "quoted"
|
||||
r"\u201c[^\u201d]*\u201d", # curly double quotes
|
||||
r"\u2018[^\u2019\n]*\u2019", # curly single quotes
|
||||
]
|
||||
if glossary_file:
|
||||
steps[:0] = [NOT_LINE.pattern, IDENT_LINE.pattern]
|
||||
for step in steps:
|
||||
text = re.sub(step, blank, text)
|
||||
return text
|
||||
|
||||
|
||||
def issues(since=FROM):
|
||||
"""The issue reports opened on or after `since`, file by file."""
|
||||
out = []
|
||||
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
|
||||
|
||||
|
||||
def checked_documents(since=FROM):
|
||||
docs = []
|
||||
for entry in ALWAYS:
|
||||
path = os.path.join(ROOT, entry)
|
||||
if os.path.isfile(path):
|
||||
docs.append(entry)
|
||||
continue
|
||||
for base, dirs, files in os.walk(path):
|
||||
dirs.sort()
|
||||
for name in sorted(files):
|
||||
if name.endswith(".md"):
|
||||
docs.append(rel(os.path.join(base, name)))
|
||||
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.
|
||||
"""
|
||||
out, problems = {}, []
|
||||
if not os.path.isfile(os.path.join(ROOT, ALLOWED)):
|
||||
return out, problems
|
||||
today = datetime.date.today().isoformat()
|
||||
for line in read(ALLOWED).splitlines():
|
||||
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
||||
if len(cells) != 3 or not cells[0].startswith("`"):
|
||||
continue
|
||||
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 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))
|
||||
continue
|
||||
out[doc] = until
|
||||
return out, problems
|
||||
|
||||
|
||||
def check_retired(retired, identifiers):
|
||||
problems = []
|
||||
allowance, problems_allowed = allowed()
|
||||
problems += problems_allowed
|
||||
words = [(w, pattern(w), "retired") for w, scope in retired if scope in ("all", "hq")]
|
||||
words += [(i, pattern(i), "an identifier, allowed only in a code span") for i in identifiers]
|
||||
used = set()
|
||||
for doc in checked_documents():
|
||||
text = prose(read(doc), glossary_file=(doc == GLOSSARY))
|
||||
for word, rx, why in words:
|
||||
for m in rx.finditer(text):
|
||||
if doc in allowance:
|
||||
used.add(doc)
|
||||
continue
|
||||
line = text.count("\n", 0, m.start()) + 1
|
||||
problems.append("%s:%d: %r is %s (glossary)" % (doc, line, m.group(0).replace("\n", " "), why))
|
||||
for doc in allowance:
|
||||
if doc not in used:
|
||||
problems.append("%s: %s no longer uses a retired word — remove its allowance" % (ALLOWED, doc))
|
||||
return problems
|
||||
|
||||
|
||||
def lines_list():
|
||||
"""The daemons' lines as the catalogue's copy lists them: `<daemon> <command>`, ` …` when an argument must follow."""
|
||||
out = []
|
||||
for daemon, kinds in DAEMON_LINES.items():
|
||||
out += ["%s %s" % (daemon, c) for c in kinds["alone"]]
|
||||
out += ["%s %s …" % (daemon, c) for c in kinds["with an argument"]]
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def line_pattern(daemon, command, with_argument):
|
||||
"""The line whole: the daemon, white space, the command. One that is a line only with an argument is matched
|
||||
as a line is written — at the start of a line or a code span, a prompt allowed, an argument after it."""
|
||||
d, c = re.escape(daemon), re.escape(command)
|
||||
if with_argument:
|
||||
return re.compile(r"(?m)(?:^|`)[ \t]*(?:\$[ \t]*)?(" + d + r"\s+" + c + r"\s+[^\s`])")
|
||||
return re.compile(r"(?<![\w-])" + d + r"\s+" + c + r"(?![\w-])")
|
||||
|
||||
|
||||
def own_words(text):
|
||||
"""The text with what is not the document's own words blanked: as prose(), but a code block and a code span
|
||||
are kept, since a line to type is written in one."""
|
||||
if text.startswith("---\n"):
|
||||
end = text.find("\n---", 4)
|
||||
if end != -1:
|
||||
text = re.sub(r"[^\n]", " ", text[: end + 4]) + text[end + 4:]
|
||||
for step in (
|
||||
r"(?s)<!--.*?-->", # comments
|
||||
r"(?m)^\s*>.*$", # block quotes
|
||||
r"\]\([^)\s]*\)", # link targets
|
||||
r"~~[^~\n]+~~", # struck through
|
||||
r"\"[^\"\n]*\"", # "quoted"
|
||||
r"\u201c[^\u201d]*\u201d", # curly double quotes
|
||||
r"\u2018[^\u2019\n]*\u2019", # curly single quotes
|
||||
):
|
||||
text = re.sub(step, blank, text)
|
||||
return text
|
||||
|
||||
|
||||
def check_lines():
|
||||
problems = []
|
||||
patterns = []
|
||||
for daemon, kinds in DAEMON_LINES.items():
|
||||
patterns += [(daemon, c, line_pattern(daemon, c, False)) for c in kinds["alone"]]
|
||||
patterns += [(daemon, c, line_pattern(daemon, c, True)) for c in kinds["with an argument"]]
|
||||
for doc in checked_documents(LINES_FROM):
|
||||
text = own_words(read(doc))
|
||||
for daemon, command, rx in patterns:
|
||||
for m in rx.finditer(text):
|
||||
line = text.count("\n", 0, m.start()) + 1
|
||||
problems.append("%s:%d: `%s %s` is a daemon's line — the operator types `nox …` (ADR 0272)"
|
||||
% (doc, line, daemon, command))
|
||||
return problems
|
||||
|
||||
|
||||
def check_unique(retired, heads):
|
||||
problems = []
|
||||
seen = {}
|
||||
for head in heads:
|
||||
key = head.lower()
|
||||
if key in seen:
|
||||
problems.append("%s: %r heads two entries" % (GLOSSARY, head))
|
||||
seen[key] = True
|
||||
for word, _ in retired:
|
||||
if word.lower() in seen:
|
||||
problems.append("%s: %r is a head word and also retired" % (GLOSSARY, word))
|
||||
return problems
|
||||
|
||||
|
||||
def tools_list(retired):
|
||||
return sorted({w for w, scope in retired if scope in ("all", "tools")}, key=str.lower)
|
||||
|
||||
|
||||
def check_copy(retired):
|
||||
catalogue = os.environ.get("MESH_CATALOG_DIR")
|
||||
if not catalogue:
|
||||
print("NOT COMPARED: MESH_CATALOG_DIR is not set, so the catalogue's copy of the list was not read")
|
||||
return []
|
||||
path = os.path.join(catalogue, "retired-words")
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
copy = [l.strip() for l in handle if l.strip() and not l.startswith("#")]
|
||||
except OSError as err:
|
||||
return ["the catalogue's copy of the retired words cannot be read: %s" % err]
|
||||
want = tools_list(retired)
|
||||
problems = []
|
||||
if sorted(copy, key=str.lower) != want:
|
||||
problems.append("the catalogue's retired-words differs from the glossary: it should list %s" % ", ".join(want))
|
||||
path = os.path.join(catalogue, "retired-lines")
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
copy = [l.strip() for l in handle if l.strip() and not l.startswith("#")]
|
||||
except OSError as err:
|
||||
return problems + ["the catalogue's copy of the daemons' lines cannot be read: %s" % err]
|
||||
if sorted(copy) != lines_list():
|
||||
problems.append("the catalogue's retired-lines differs from this check's list: regenerate it with "
|
||||
"`python3 00-META/checks/words.py --list lines`")
|
||||
return problems
|
||||
|
||||
|
||||
def main(argv):
|
||||
retired, identifiers, heads = glossary()
|
||||
if argv[1:2] == ["--list"]:
|
||||
scope = argv[2] if len(argv) > 2 else "tools"
|
||||
if scope == "lines":
|
||||
words = lines_list()
|
||||
else:
|
||||
words = tools_list(retired) if scope == "tools" else sorted({w for w, s in retired if s in ("all", scope)})
|
||||
print("\n".join(words))
|
||||
return 0
|
||||
if not retired:
|
||||
print("words: the glossary names no retired word on a *Not:* line — the check would pass on anything")
|
||||
return 1
|
||||
problems = check_unique(retired, heads) + check_retired(retired, identifiers) + check_lines() + check_copy(retired)
|
||||
for p in problems:
|
||||
print(p)
|
||||
if problems:
|
||||
print("words: %d problem(s)" % len(problems))
|
||||
return 1
|
||||
print("words: %d retired words and %d identifiers, none in running prose; %d daemons' lines, none written; "
|
||||
"%d head words, each once" % (len(retired), len(identifiers), len(lines_list()), len(heads)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
+7
-7
@@ -14,7 +14,7 @@ and the domain decides what that word means. Other domains use the word as it is
|
||||
change its meaning. Every word is defined once, in the domain that owns it; a section lists the words
|
||||
it uses from other domains under *Uses*, with no second definition.
|
||||
|
||||
Two fixed lines follow an entry where they apply, and `00-META/checks/words.py` reads them:
|
||||
Two fixed lines follow an entry where they apply, and the words test in `00-META/checks/` reads them:
|
||||
|
||||
- *Not:* followed by struck-through words — the words this entry replaced. A struck word with no scope
|
||||
is retired everywhere; *(hq)* retires it in this repository's prose only; *(tools)* in the
|
||||
@@ -35,7 +35,7 @@ is listed under [Homonyms](#homonyms), with the qualified form each domain uses.
|
||||
names, except *observability*, which is now **Health and repair**. Where a domain's records live in
|
||||
the controller, the domain owns that store alone (ADR 0008).
|
||||
*Context* in that sense is not used in new writing; the word stays ordinary English, so this is
|
||||
checked by review, not by `words.py`.
|
||||
checked by review, not by the words test.
|
||||
- **machine** — any computer: hardware, a virtual machine, before, during or outside its membership of
|
||||
the mesh. Prose about the computer itself — its disks, its kernel, the network it sits on, what was on
|
||||
it before the mesh — says machine.
|
||||
@@ -680,7 +680,7 @@ asking, never the act. **Decided by** ADR 0152, 0175, 0234, 0259, 0272, 0383, 03
|
||||
say `mesh-cli`. The operator also calls the mesh itself *nox mesh*. *nox* is also the agent (ADR 0414 §7): see
|
||||
[Homonyms](#homonyms), and say which is meant. It is the one command the operator types: every line the mesh addresses to the operator — a
|
||||
condition's words, a verb's answer, a README, a design — names a `nox` line, never a daemon's (the controller's
|
||||
or the node-engine's binary followed by a command), which `words.py` and the catalogue's check refuse
|
||||
or the node-engine's binary followed by a command), which the words test and the catalogue's check refuse
|
||||
(ADR 0272,
|
||||
its insight of 2026-10-09).
|
||||
- **channel / intake** — the two kinded benches the mesh talks to its operator through: `channel`
|
||||
@@ -912,12 +912,12 @@ Not a domain of the mesh but of the way it is built, listed because its words me
|
||||
- **tracks** — of an issue and a condition: the issue links the condition's key with a link of kind `condition`. The
|
||||
**tracking line** is the line the router says in every message about a condition: the issues that track it and
|
||||
where each stands, or that none does (ADR 0285).
|
||||
- **hq check** — one of `00-META/checks/`, run by `merge-check.sh` as this repository's check.
|
||||
- **hq check** — one of the Go tests in `00-META/checks/`, run by `go test ./...` and by the checker as `mesh/check`.
|
||||
|
||||
## Homonyms
|
||||
|
||||
A word below means different things in different domains. In a governing document it is never bare;
|
||||
it is always the qualified form. **Checked by review, not by `words.py`:** a word list cannot tell one
|
||||
it is always the qualified form. **Checked by review, not by the words test:** a word list cannot tell one
|
||||
sense from another, so the reviewer looks for the bare word in the diff. Where a homonym is settled by
|
||||
renaming one sense, the old sense moves to a *Not:* line and becomes mechanical.
|
||||
|
||||
@@ -991,12 +991,12 @@ renaming one sense, the old sense moves to a *Not:* line and becomes mechanical.
|
||||
|
||||
A new word for an existing thing lands here first, in the domain that owns it, in the same change that
|
||||
introduces it in code. A word moves to another domain only with a decision record. A retired word goes
|
||||
on a *Not:* line and nowhere else on this page, and `words.py` then fails on it in running prose. A
|
||||
on a *Not:* line and nowhere else on this page, and the words test then fails on it in running prose. A
|
||||
decision record keeps whatever word it was written with — those are immutable — so a word
|
||||
retired here may still appear in one, and the *Not:* lines are how to read it.
|
||||
|
||||
**How it is checked** (ADR 0244):
|
||||
`python3 00-META/checks/words.py`, run by `merge-check.sh` on every pull request, fails on a retired
|
||||
The words test in `00-META/checks/` (`go test ./...`, run by the checker as `mesh/check` on every pull request) fails on a retired
|
||||
word or a bare identifier in running prose in `00-META/`, `03-DESIGN/`, `AGENTS.md`, `README.md`, and
|
||||
issues opened from 2026-10-07 — code spans, quotations and link targets excepted — and on a
|
||||
head word that heads two entries or is also retired. The words retired with no scope or with *(tools)*
|
||||
|
||||
@@ -67,11 +67,11 @@ A drawn overview of them, the cycle, a feature's steps, the review loop and an i
|
||||
## The cycle is checked
|
||||
|
||||
The flow above is a rule, and a rule states how it is checked:
|
||||
[`00-META/checks/cycle.py`](../checks/cycle.py) refuses a to-be design that names no
|
||||
the cycle test in [`00-META/checks/`](../checks/) refuses a to-be design that names no
|
||||
decision, an `in-progress`/`implemented` design that names no owning code, two issue folders
|
||||
sharing a number or an issue report carrying a status key; a `graduated` research effort that
|
||||
does not say what it became is refused by the knowledge base's `propose` (ADR 0290). Run it with `records.py`, `index.py` and
|
||||
`words.py` before any HQ merge (`go test ./...` at the root runs all four as Go tests, and `sh merge-check.sh` runs the Python ones until the delivery of R4g is done: ADR 0527). The issue rules the check
|
||||
does not say what it became is refused by the knowledge base's `propose` (ADR 0290). Run it with the records, index and
|
||||
words tests before any HQ merge: `go test ./...` at the root runs all four, and the checker runs the same as `mesh/check` (ADR 0527). The issue rules the check
|
||||
once held — `located` with an owner, `resolved` with a fix, a core issue with its replay — are
|
||||
refused at the write by the issue tracker's `move`
|
||||
(ADR 0275). What the checks cannot see — that code work actually started from a
|
||||
|
||||
@@ -119,7 +119,7 @@ for the operator's Approve on a channel that proves who answered.
|
||||
- Do not put status in prose. It is the effort's `status`, and the prose must not restate it.
|
||||
- Do not give a `became` link while the effort is open — playbook 02 gives it at closure.
|
||||
- Do not write into `03-DESIGN` from an open effort.
|
||||
- Do not write an effort, or a copy of one, into this repository: `records.py` refuses a new path under
|
||||
- Do not write an effort, or a copy of one, into this repository: the records test refuses a new path under
|
||||
`01-RESEARCH/`.
|
||||
|
||||
## Closing
|
||||
@@ -136,5 +136,5 @@ after every acceptance and raise a condition when one fails. The habits of steps
|
||||
against the rules, one version per settled set, the cheapest level, nothing left open) are the lead's, read by
|
||||
the reviewer; of the pre-check, `propose` refuses a link to nothing and a retired word, and the review returns
|
||||
a record named but not linked, an untracked deferral and a figure not as measured. That this repository holds
|
||||
no effort is `records.py`'s `no-records` check, on every pull request. An active effort with no new version
|
||||
no effort is the records test's `no-records` check, on every pull request. An active effort with no new version
|
||||
for seven days is the smell of research left behind: its lead closes or continues it.
|
||||
|
||||
@@ -153,7 +153,7 @@ Implementation state is a third axis, independent of both design and decision.
|
||||
- Do not edit an as-is document to describe an intention. That is what the to-be layer is for.
|
||||
- Do not change a decision record's meaning. Supersede it. Correcting a fact it got wrong, while
|
||||
the decision stands, is a progressive insight — marked and dated in the record, never silent.
|
||||
- Do not write a decision record, or a copy of one, into this repository: `records.py` refuses a new path
|
||||
- Do not write a decision record, or a copy of one, into this repository: the records test refuses a new path
|
||||
under `02-DECISIONS/` other than its README.
|
||||
|
||||
## How it is checked
|
||||
@@ -162,8 +162,8 @@ The rules on a record are the knowledge base's: `propose` refuses one that break
|
||||
record (its module's table test holds one case per rule), and the four rules that judge the whole record set
|
||||
(an accepted decision cited by nothing, a supersession named by one side only, a changed clause nothing
|
||||
changes, an accepted record extending a superseded one) run after every acceptance and raise a condition
|
||||
when one fails. In this repository, `records.py` checks that a design's `decisions:` entries and every
|
||||
"ADR NNNN" in the text are well formed, and that no record is written here; `index.py` checks the reading
|
||||
when one fails. In this repository, the records test checks that a design's `decisions:` entries and every
|
||||
"ADR NNNN" in the text are well formed, and that no record is written here; the index test checks the reading
|
||||
order's six topics, which the knowledge base reads. The three habits of graduating step 2 (side by side, clauses
|
||||
from the direction, the pre-check) are the lead's, read by the reviewer; `propose` refuses a link to nothing,
|
||||
a deferral no issue tracks and a retired word, and the review returns a record named but not linked.
|
||||
|
||||
@@ -66,7 +66,7 @@ import read, which every imported issue names (`imported.commit` in `issue-track
|
||||
```
|
||||
|
||||
Then `# NNN. The title`, the title as the tracker holds it, and the symptom as observed, as in the tracker's
|
||||
report. **No status key**: `cycle.py` refuses `status:`, `located-in:`, `fixed-by:`, `amended-design:`,
|
||||
report. **No status key**: the cycle test refuses `status:`, `located-in:`, `fixed-by:`, `amended-design:`,
|
||||
`replay:` or `replay-none:` in any report, because the tracker holds them and two places holding one fact
|
||||
drift.
|
||||
|
||||
@@ -107,7 +107,7 @@ import read, which every imported issue names (`imported.commit` in `issue-track
|
||||
it, pointing at the new issue. Somebody arriving at a record to learn why the code is the way it
|
||||
is must not have to already know there was a sequel.
|
||||
- A number never moves. A folder carries the number the tracker gave; a folder whose number collides with
|
||||
another (one numbered before the tracker, or by hand) is a fault, and `cycle.py` refuses two records
|
||||
another (one numbered before the tracker, or by hand) is a fault, and the cycle test refuses two records
|
||||
sharing a number.
|
||||
- An issue whose answer is a general lesson should also have that lesson written where a search by
|
||||
the symptom's literal words finds it, so the next person searching it finds it. Both, not either.
|
||||
@@ -117,6 +117,6 @@ import read, which every imported issue names (`imported.commit` in `issue-track
|
||||
- **A rule states how it is checked.** The cycle's rules on an issue — a status one of the six, `diagnosing` and
|
||||
`fixing` with a worker and no other current one, `located` with an owner, `resolved` only from `fixing` and with a
|
||||
fix, a core issue with its replay, `wontfix` with a reason — are refused at the write by
|
||||
`issue-tracker.move` (mesh-catalog `modules/mesh-issues`, its table test over the rules). `cycle.py` checks what
|
||||
`issue-tracker.move` (mesh-catalog `modules/mesh-issues`, its table test over the rules). the cycle test checks what
|
||||
this repository still holds: no two folders share a number, every report has frontmatter with the day it was
|
||||
`opened:`, and no report carries a status key.
|
||||
|
||||
@@ -66,7 +66,7 @@ A design becomes **milestones**, and each milestone becomes **issues**:
|
||||
|
||||
## How it is checked
|
||||
|
||||
Read by the lead and the reviewer today; a check in hq's merge-check is tracked by issue 845.
|
||||
Read by the lead and the reviewer today; a check among hq's Go tests is tracked by issue 845.
|
||||
|
||||
- A design in `status: in-progress` has a *Milestones* section, each milestone with its outcome and its
|
||||
issues; an in-progress design without one is the smell of building without a breakdown.
|
||||
|
||||
@@ -421,7 +421,7 @@ const P = [
|
||||
prod:"A research effort in the knowledge base, status active, every version kept. Nothing in hq.",
|
||||
next:"02",bounds:"Setup 30 min · proposing 15 min · each experiment 30 min · direction 30 min · report every 10 min",
|
||||
prev:["Research that measured nothing","A recommendation the operator must argue back","Efforts copied into hq"],
|
||||
check:"propose refuses rule breaks; the knowledge base reviews; records.py on every PR."},
|
||||
check:"propose refuses rule breaks; the knowledge base reviews; the records test on every PR."},
|
||||
{n:"02",name:"Graduation & design change",kind:"idea",c:"--dec",
|
||||
trig:"A research effort concludes, or an existing design must change.",
|
||||
who:"The lead, on the larger model; the decision is recorded before the design moves.",
|
||||
@@ -429,7 +429,7 @@ const P = [
|
||||
prod:"Decision records; a to-be design with frontmatter; glossary entries; later the as-is.",
|
||||
next:"04",bounds:"One sitting of about an hour",
|
||||
prev:["A decision rewritten silently","A deferral nothing tracks","A design amended without a decision"],
|
||||
check:"propose refuses; records.py and index.py; set-wide rules raise conditions."},
|
||||
check:"propose refuses; the records and index tests; set-wide rules raise conditions."},
|
||||
{n:"03",name:"Issues",kind:"symptom",c:"--sym",
|
||||
trig:"Something is wrong: an unenforced rule, a stated behaviour that does not happen, a silent failure.",
|
||||
who:"Anyone opens; a worker claims it by moving it.",
|
||||
@@ -437,7 +437,7 @@ const P = [
|
||||
prod:"A tracker record with notes and links; for a design issue, its narrative folder in hq.",
|
||||
next:"02 · 07 · 08",bounds:"A worker idle for a day is stale",
|
||||
prev:["Numbers that collide","Two agents starting the same work","Status held in two places"],
|
||||
check:"The tracker's move refuses at the write; cycle.py checks the folders."},
|
||||
check:"The tracker's move refuses at the write; the cycle test checks the folders."},
|
||||
{n:"04",name:"Work breakdown & handoff",kind:"idea",c:"--dec",
|
||||
trig:"A to-be design is settled and its building is about to start.",
|
||||
who:"The lead who will run the feature, on the larger model.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: in-progress
|
||||
code: [hq 00-META/checks/words.py, mesh-catalog merge-check.sh]
|
||||
code: [hq 00-META/checks/words_test.go, mesh-catalog merge-check.sh]
|
||||
updated: 2026-10-20
|
||||
decisions:
|
||||
- "ADR 0429"
|
||||
@@ -127,7 +127,7 @@ rule stands with the new word: where a domain's records live in the controller,
|
||||
|
||||
## How it is checked
|
||||
|
||||
- **In this repository**, the hq check `words.py` runs on every pull request as part of the repository
|
||||
- **In this repository**, the hq check (the words test, `go test ./...`) runs on every pull request as part of the repository
|
||||
check. It reads the glossary's *Not:* and *Identifier* lines and fails on a retired word or a bare
|
||||
identifier in running prose — quotations, code and link targets excepted — in `00-META/`, both layers of
|
||||
the designs, `AGENTS.md`, `README.md`, and research and issues dated from the decision onward. It fails
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ On 2026-10-09 the operator took the rename as long implemented and live. It is n
|
||||
2. **The rule**, from 2026-10-09: a decision that defers work names the issue that tracks it, with an
|
||||
owner, or the deferral is not accepted. Playbooks [02](../../00-META/process/02-graduation.md)
|
||||
(graduation) and [04](../../00-META/process/04-build-handoff.md) (handoff) say it;
|
||||
[`cycle.py`](../../00-META/checks/cycle.py) checks it: an accepted record dated from 2026-10-09, or an
|
||||
the cycle test (`cycle.py` then) checks it: an accepted record dated from 2026-10-09, or an
|
||||
older one corrected under the rule, whose text puts work off ("until renamed", "left for later", "not
|
||||
decided here", "postponed", "future work", "deferred", "a later change") names the issue as tracking it
|
||||
("tracked by issue N"), in that paragraph or list item, or in a dated progressive insight in the same
|
||||
|
||||
@@ -22,8 +22,9 @@ Work enters as an **idea** (playbook [01 — research](00-META/process/01-resear
|
||||
[04](00-META/process/04-build-handoff.md), on a feature branch per playbook
|
||||
[07](00-META/process/07-feature-branches.md)) — and on shipping the as-is is updated and the
|
||||
design flips to `implemented`. **No design without a decision; no development without a design
|
||||
that names its owner.** Enforced by [`00-META/checks/cycle.py`](00-META/checks/cycle.py)
|
||||
alongside `records.py`, `index.py` and `words.py` — run all four before any merge here.
|
||||
that names its owner.** Enforced by the cycle test in
|
||||
[`00-META/checks/`](00-META/checks/) alongside the records, index and words tests — run `go test ./...` at the
|
||||
root before any merge here.
|
||||
|
||||
## Where to look (before assuming anything)
|
||||
|
||||
@@ -49,16 +50,14 @@ One name per thing. [`00-META/glossary.md`](00-META/glossary.md) is the authorit
|
||||
vocabulary, organised by the mesh's domains (ADR 0244):
|
||||
*controller* (not "control plane"), *foundation* (not "substrate"), *node-engine* (not "the host"), a
|
||||
*node* is a machine the mesh has adopted, *seat* / *bench* / *claim*, *package* vs *artifact*. Use those
|
||||
words. A retired word is named on its replacement's *Not:* line, and `00-META/checks/words.py` fails on it
|
||||
words. A retired word is named on its replacement's *Not:* line, and the words test in `00-META/checks/` fails on it
|
||||
in running prose; a quotation keeps its words.
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Markdown only** — but for the four checks, Go tests in the package `00-META/checks/` with `go.mod` at the
|
||||
root and no other Go code (ADR 0527), which the checker runs on every pull request as `mesh/check`;
|
||||
until they replace them, the Python checks beside them and `merge-check.sh` at the root keep running as
|
||||
`mesh/repo-check` (ADR 0238); and
|
||||
`00-META/process/playbooks.html`, a drawn overview of the playbooks kept for a later docs site (the operator,
|
||||
until they replace them, `00-META/process/playbooks.html`, a drawn overview of the playbooks kept for a later docs site (the operator,
|
||||
2026-10-19); a change to a playbook's steps updates it in the same pull request. Nothing checks that
|
||||
yet: issue 1222 asks for the check.
|
||||
A to-be design may also keep its reference mockup, the static HTML page the operator picked, in a
|
||||
@@ -72,7 +71,7 @@ in running prose; a quotation keeps its words.
|
||||
Never create a central status file; cross-cutting views are generated when they are read.
|
||||
- **No decision record or research effort is written here.** They live whole in the knowledge base and
|
||||
are written with `knowledge-base.reserve` and `knowledge-base.propose` (playbooks
|
||||
[01](00-META/process/01-research.md) and [02](00-META/process/02-graduation.md)); `records.py` refuses a
|
||||
[01](00-META/process/01-research.md) and [02](00-META/process/02-graduation.md)); the records test refuses a
|
||||
new path under `02-DECISIONS/` or `01-RESEARCH/`. A record is cited as "ADR NNNN" or "research NNN".
|
||||
- **Decision records hold their meaning.** Supersede with a new record rather than rewriting
|
||||
what was decided, the options weighed, or a consequence another record relies on. A **progressive
|
||||
|
||||
@@ -40,7 +40,7 @@ Implementation lives in the code repositories — see
|
||||
|
||||
## Rules
|
||||
|
||||
- Markdown only, but for the checks and one drawn overview of the playbooks (`00-META/process/playbooks.html`).
|
||||
- Markdown only, but for the checks (Go tests, `go test ./...` at the root, run by the checker as `mesh/check`) and one drawn overview of the playbooks (`00-META/process/playbooks.html`).
|
||||
- No new top-level folders without explicit confirmation.
|
||||
- Knowledge flows `GENESIS → RESEARCH → DESIGN`. Research graduates into design only after
|
||||
analysis against GENESIS confirms alignment, and only through a recorded decision.
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/sh
|
||||
# mesh-check-toolchain: go
|
||||
#
|
||||
# This repository's own check (ADR 0238): the second layer of a pull request's merge check,
|
||||
# `mesh/repo-check`, run by the build seat in the mesh's Go toolchain, which carries Python for it. No
|
||||
# module is built from here, so no gate runs (`mesh/merge-gate` says the change touches none). The four
|
||||
# checks every merge here must pass (AGENTS.md): the documents' structure (and that no decision record or
|
||||
# research effort is written here: they live in the knowledge base, ADR 0290), the reading order, the cycle,
|
||||
# and the words (ADR 0244: no retired word in running prose, no word defined twice).
|
||||
set -eu
|
||||
python3 00-META/checks/records.py
|
||||
python3 00-META/checks/index.py
|
||||
python3 00-META/checks/cycle.py
|
||||
python3 00-META/checks/words.py
|
||||
Reference in New Issue
Block a user