cycle.py skipped a rejected option only when "Options rejected" was its nearest heading; a record that lists its options under ### subheadings of "## Considered Options" had them held as deferrals.
353 lines
18 KiB
Python
353 lines
18 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".
|
|
# The tracking issue is named as tracking it, beside the deferral or in a dated insight in the same section
|
|
# or quoting it (ISSUE_REF, covers).
|
|
DEFERRALS_FROM = "2026-10-09"
|
|
DEFERRAL = re.compile(r"(?i)until (?:it is |they are |the code (?:is )?)?renamed|until the code rename|"
|
|
r"\ba later rename|not renamed here|identifier to rename|"
|
|
r"left (?:for|to) (?:a )?later|not decided here|\bpostponed\b|future work|"
|
|
r"\bdeferred\b|later work|not built here|\ba later change")
|
|
# The issue that tracks a deferral is named as tracking it -- "tracked by issue N", or "issue N tracks" --
|
|
# because a record cites issues for many reasons, and an issue cited as context beside a deferral (ADR 0273
|
|
# names issue 340 in the very sentence that leaves a channel undecided) tracks nothing.
|
|
ISSUE_REF = re.compile(r"(?i)tracked (?:by|in|as)\s+\[?issue (\d{3})|\[?issue (\d{3})\]?(?:\([^)]*\))?\s+tracks\b")
|
|
INSIGHT = re.compile(r"\*\*Progressive insights?[ \t]*[—–-][ \t]*(\d{4}-\d{2}-\d{2})")
|
|
|
|
|
|
def paragraphs(path):
|
|
"""A record's body as (first line number, section heading, chapter, text) paragraphs: frontmatter and fenced
|
|
code left out, a block quote's markers stripped, so a quoted insight reads as the paragraph it is. The
|
|
section is the nearest heading above the paragraph; a heading is its own paragraph and its own section.
|
|
The chapter is the nearest `#` or `##` heading, which a `###` subheading inside it does not change."""
|
|
text = open(path, encoding="utf-8").read()
|
|
m = re.match(r"^---\n.*?\n---\n?", text, re.S)
|
|
body, first = (text[m.end():], text[: m.end()].count("\n") + 1) if m else (text, 1)
|
|
body = re.sub(r"(?ms)^\s*```.*?^\s*```", lambda x: re.sub(r"[^\n]", " ", x.group(0)), body)
|
|
out, line, section, chapter = [], first, "", ""
|
|
for chunk in body.split("\n\n"):
|
|
stripped = "\n".join(re.sub(r"^\s*>\s?", "", l) for l in chunk.split("\n")).strip()
|
|
if stripped:
|
|
heading = re.match(r"^#{1,6}\s+(.*)", stripped)
|
|
if heading:
|
|
section = heading.group(1).strip()
|
|
if len(stripped) - len(stripped.lstrip("#")) <= 2:
|
|
chapter = section
|
|
# A list's top-level items are units of their own: a Consequences list is one paragraph, and an
|
|
# issue named in one item tracks nothing in the next.
|
|
items = re.split(r"\n(?=[-*] |\d+\. )", stripped)
|
|
at = line + chunk[: len(chunk) - len(chunk.lstrip())].count("\n")
|
|
for item in items:
|
|
out.append((at, section, chapter, item))
|
|
at += item.count("\n") + 1
|
|
line += chunk.count("\n") + 2
|
|
return out
|
|
|
|
|
|
QUOTE = re.compile(r"\"([^\"]{12,})\"|\u201c([^\u201d]{12,})\u201d")
|
|
|
|
|
|
def flat(text):
|
|
"""Text compared for a quotation: markup and case dropped, whitespace collapsed."""
|
|
return re.sub(r"\s+", " ", re.sub(r"[*`_]", "", text)).strip().lower()
|
|
|
|
|
|
def covers(insight_section, insight_text, section, text):
|
|
"""An insight speaks about a deferral paragraph when it stands in the same section, or quotes it."""
|
|
if insight_section == section:
|
|
return True
|
|
body = flat(text)
|
|
return any(flat(a or b) in body for a, b in QUOTE.findall(insight_text))
|
|
|
|
|
|
def issue_refs(text, issues):
|
|
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 or list item that puts work off names the issue that tracks it, or a dated
|
|
# progressive insight that speaks about it (its section, or a quotation of it) does. An issue that has
|
|
# since resolved still counts: the deferred work landed, which is what the pointer was for.
|
|
for name, (path, status) in records.items():
|
|
front = frontmatter(path) or {}
|
|
decided = str(front.get("date") or "")
|
|
if status != "accepted":
|
|
continue
|
|
paras = paragraphs(path)
|
|
insights = [(d, sec, text) for line, sec, _, text in paras for d in INSIGHT.findall(text)]
|
|
held = decided >= DEFERRALS_FROM or any(d >= DEFERRALS_FROM for d, _, _ in insights)
|
|
if not held:
|
|
continue
|
|
# A dated insight naming an issue covers the deferrals it speaks about -- those in its own section,
|
|
# or ones it quotes -- and no other: one insight must not excuse every deferral in the record.
|
|
tracking = [(sec, text) for d, sec, text in insights if issue_refs(text, seen)]
|
|
for line, section, chapter, text in paras:
|
|
m = DEFERRAL.search(text)
|
|
# A rejected option is a decision not to do it, not work put off -- judged by its chapter, so an
|
|
# option under a `###` subheading of "## Considered Options" is skipped as well.
|
|
if re.match(r"(?i)(options? rejected|considered options|rejected)", chapter):
|
|
continue
|
|
if not m or issue_refs(text, seen):
|
|
continue
|
|
if any(covers(isec, itext, section, text) for isec, itext in tracking):
|
|
continue
|
|
bad(path, "line %d defers work (%r) and names no issue that tracks it -- a deferral nothing "
|
|
"tracks reads as done (issue 354). Name the issue beside it, with its owner, or in a "
|
|
"dated progressive insight that says what it tracks"
|
|
% (line + text.count("\n", 0, m.start()), m.group(0)))
|
|
|
|
checked = (
|
|
len(glob.glob(os.path.join(ROOT, "03-DESIGN", "0*", "*.md")))
|
|
+ len(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md")))
|
|
+ len(glob.glob(os.path.join(ROOT, "01-RESEARCH", "*", "00-overview.md")))
|
|
+ len(records)
|
|
)
|
|
if failures:
|
|
print("cycle: %d document(s) break the development cycle:" % len(failures))
|
|
print("\n".join(failures))
|
|
return 1
|
|
print("cycle: %d documents checked, the chain holds" % checked)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|