The import of slice 3 (ADR 0275) was checked against every folder: no report changed since the commit it read, and the tracker holds each report's status, owner, fix, design amendment, replay and closing day. The keys are removed from all 361 reports, cycle.py refuses them in any report, and the playbooks, skills and READMEs that still pointed at them name the tracker instead.
342 lines
18 KiB
Python
342 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 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.
|
|
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: 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"}
|
|
RESEARCH_STATUSES = {"active", "graduated", "abandoned"}
|
|
|
|
# The keys an issue report carried while its status lived here. The issue tracker holds them now (ADR 0275,
|
|
# slice 3 of to-be 53), and `issue-tracker.move` enforces the cycle's rules on them at the write. A report
|
|
# carrying one of them is a status written in two places, whenever it was opened.
|
|
TRACKER_KEYS = ("status", "located-in", "fixed-by", "amended-design", "replay", "replay-none", "resolved")
|
|
|
|
# A decision that defers work names the issue that tracks it (issue 354). ADR 0244 left the code
|
|
# renames of the node-engine and the tool runner "for a later rename", with no issue, owner or date;
|
|
# two days later the operator believed them done, and nothing in the record said otherwise. A deferral
|
|
# nothing tracks is indistinguishable from work that was done. Held to it: an accepted record dated
|
|
# from DEFERRALS_FROM, and an older one corrected under the rule (a progressive insight dated from then
|
|
# on): an edit made under the rule is held to it. The phrases are the ones records use to put work off;
|
|
# "for later" alone is not one, because records say "never queued for later" and "too narrow for later".
|
|
# The tracking issue is named as tracking it, beside the deferral or in a dated insight in the same section
|
|
# or quoting it (ISSUE_REF, covers).
|
|
DEFERRALS_FROM = "2026-10-09"
|
|
DEFERRAL = re.compile(r"(?i)until (?:it is |they are |the code (?:is )?)?renamed|until the code rename|"
|
|
r"\ba later rename|not renamed here|identifier to rename|"
|
|
r"left (?:for|to) (?:a )?later|not decided here|\bpostponed\b|future work|"
|
|
r"\bdeferred\b|later work|not built here|\ba later change")
|
|
# The issue that tracks a deferral is named as tracking it -- "tracked by issue N", or "issue N tracks" --
|
|
# because a record cites issues for many reasons, and an issue cited as context beside a deferral (ADR 0273
|
|
# names issue 340 in the very sentence that leaves a channel undecided) tracks nothing.
|
|
ISSUE_REF = re.compile(r"(?i)tracked (?:by|in|as)\s+\[?issue (\d{3})|\[?issue (\d{3})\]?(?:\([^)]*\))?\s+tracks\b")
|
|
INSIGHT = re.compile(r"\*\*Progressive insights?[ \t]*[—–-][ \t]*(\d{4}-\d{2}-\d{2})")
|
|
|
|
|
|
def paragraphs(path):
|
|
"""A record's body as (first line number, section heading, chapter, text) paragraphs: frontmatter and fenced
|
|
code left out, a block quote's markers stripped, so a quoted insight reads as the paragraph it is. The
|
|
section is the nearest heading above the paragraph; a heading is its own paragraph and its own section.
|
|
The chapter is the nearest `#` or `##` heading, which a `###` subheading inside it does not change."""
|
|
text = open(path, encoding="utf-8").read()
|
|
m = re.match(r"^---\n.*?\n---\n?", text, re.S)
|
|
body, first = (text[m.end():], text[: m.end()].count("\n") + 1) if m else (text, 1)
|
|
body = re.sub(r"(?ms)^\s*```.*?^\s*```", lambda x: re.sub(r"[^\n]", " ", x.group(0)), body)
|
|
out, line, section, chapter = [], first, "", ""
|
|
for chunk in body.split("\n\n"):
|
|
stripped = "\n".join(re.sub(r"^\s*>\s?", "", l) for l in chunk.split("\n")).strip()
|
|
if stripped:
|
|
heading = re.match(r"^#{1,6}\s+(.*)", stripped)
|
|
if heading:
|
|
section = heading.group(1).strip()
|
|
if len(stripped) - len(stripped.lstrip("#")) <= 2:
|
|
chapter = section
|
|
# A list's top-level items are units of their own: a Consequences list is one paragraph, and an
|
|
# issue named in one item tracks nothing in the next.
|
|
items = re.split(r"\n(?=[-*] |\d+\. )", stripped)
|
|
at = line + chunk[: len(chunk) - len(chunk.lstrip())].count("\n")
|
|
for item in items:
|
|
out.append((at, section, chapter, item))
|
|
at += item.count("\n") + 1
|
|
line += chunk.count("\n") + 2
|
|
return out
|
|
|
|
|
|
QUOTE = re.compile(r"\"([^\"]{12,})\"|\u201c([^\u201d]{12,})\u201d")
|
|
|
|
|
|
def flat(text):
|
|
"""Text compared for a quotation: markup and case dropped, whitespace collapsed."""
|
|
return re.sub(r"\s+", " ", re.sub(r"[*`_]", "", text)).strip().lower()
|
|
|
|
|
|
def covers(insight_section, insight_text, section, text):
|
|
"""An insight speaks about a deferral paragraph when it stands in the same section, or quotes it."""
|
|
if insight_section == section:
|
|
return True
|
|
body = flat(text)
|
|
return any(flat(a or b) in body for a, b in QUOTE.findall(insight_text))
|
|
|
|
|
|
def issue_refs(text, issues):
|
|
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
|
|
|
|
# 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))
|
|
|
|
# ---- 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())
|