#!/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; 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. 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. 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): research and issues 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)(?", # 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 research_and_issues(since=FROM): """The research efforts and issue reports dated 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)) 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 + research_and_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. """ 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 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)) 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: ` `, ` …` 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"(?", # 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))