ADR 0244 left the code renames of the node-engine and the tool runner for later with no issue, owner or date, and two days on the operator believed them live. Open the issue with the inventory, the risk and a staged plan; add the rule to playbooks 02 and 04, as a dated insight on ADR 0244, and as a cycle.py check that found the same gap in ADR 0270 (issue 355).
314 lines
16 KiB
Python
314 lines
16 KiB
Python
#!/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 status 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 a known `status:`; once `located`, `located-in:` names the owner;
|
|
once `resolved`, `fixed-by:` says what fixed it (prose counts --
|
|
"nothing, the capability existed" is an answer). And no two records share a
|
|
number -- the number is how a record is cited.
|
|
replays a core issue -- one opened from 2026-10-07 whose `located-in:` names a core repository --
|
|
resolves only with `replay:` (the replay's id in mesh-lab's replays register) or
|
|
`replay-none:` (why no replay is possible): ADR 0237, to-be 45 §9. A replay is what
|
|
proves a fix fails before and passes after; without one, a fixed class comes back
|
|
through a different door (research 031: four such chains in one week).
|
|
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.
|
|
|
|
Deliberately NOT enforced: `resolved` issues may leave `located-in` empty (a symptom that
|
|
turned out not to be a defect has no owner), and 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"}
|
|
ISSUE_STATUSES = {"open", "diagnosing", "located", "resolved", "wontfix"}
|
|
RESEARCH_STATUSES = {"active", "graduated", "abandoned"}
|
|
|
|
# The core, as to-be 45 names it, by where its code lives: the controller, the node-engine, the node
|
|
# tools and the console, the SDK's loops, and the catalogue's bus, forge (the announcer of merges) and
|
|
# build agent. A `located-in:` entry naming any of these makes an issue a core issue.
|
|
CORE = re.compile(r"\b(mesh-controller|mesh-host|mesh-tools|mesh-sdk|node-tools|"
|
|
r"mesh-catalog\s+modules/(nats|gitea|build-agent))\b")
|
|
|
|
# The day the rule began (ADR 0237): issues opened before it are not held to it.
|
|
REPLAYS_FROM = "2026-10-07"
|
|
REPLAY_ID = re.compile(r"^R\d+\b")
|
|
|
|
# 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".
|
|
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(?! record)|"
|
|
r"\bdeferred\b|later work|not built here|\ba later change")
|
|
ISSUE_REF = re.compile(r"\bissue (\d{3})\b|04-ISSUES/(\d{3})-")
|
|
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, text) paragraphs: frontmatter and fenced code left out,
|
|
a block quote's markers stripped, so a quoted insight reads as the paragraph it is."""
|
|
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 = [], 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:
|
|
out.append((line, stripped))
|
|
line += chunk.count("\n") + 2
|
|
return out
|
|
|
|
|
|
def issue_refs(text, issues):
|
|
return {n for a, b in ISSUE_REF.findall(text) for n in (a, b) if n and n in issues}
|
|
|
|
|
|
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
|
|
|
|
# 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
|
|
|
|
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
|
|
status = front.get("status")
|
|
if status not in ISSUE_STATUSES:
|
|
bad(path, "status %r is not one of %s" % (status, sorted(ISSUE_STATUSES)))
|
|
if status in ("located", "resolved") and not listy(front, "located-in"):
|
|
bad(path, "status %s but located-in is empty" % status)
|
|
if status == "resolved" and not listy(front, "fixed-by"):
|
|
bad(path, "status %s but fixed-by says nothing" % status)
|
|
# A core issue resolves with its replay, or with why none is possible (ADR 0237).
|
|
replay = " ".join(listy(front, "replay"))
|
|
if replay and not REPLAY_ID.match(replay):
|
|
bad(path, "replay: %r names no replay -- an id from mesh-lab's replays register, R<issue>" % replay)
|
|
core = any(CORE.search(entry) for entry in listy(front, "located-in"))
|
|
opened = str(front.get("opened") or "")
|
|
if (status == "resolved" and core and opened >= REPLAYS_FROM and not replay
|
|
and not " ".join(listy(front, "replay-none")).strip()):
|
|
bad(path, "a core issue resolved with no replay: name it in `replay:` (mesh-lab replays "
|
|
"register.go) or say in `replay-none:` why none is possible (ADR 0237)")
|
|
|
|
# ---- 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 that puts work off names the issue that tracks it, or the record carries a
|
|
# dated progressive insight that names the issue and says what it tracks. 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, text) for line, 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
|
|
covering = set()
|
|
for d, text in insights:
|
|
if DEFERRAL.search(text):
|
|
covering |= issue_refs(text, seen)
|
|
for line, text in paras:
|
|
m = DEFERRAL.search(text)
|
|
if not m or issue_refs(text, seen) or covering:
|
|
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))
|
|
print("\n".join(failures))
|
|
return 1
|
|
print("cycle: %d documents checked, the chain holds" % checked)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|