The playbook, the README and the status skill knew five statuses; the cycle check knew a sixth, 'fixed', and not 'wontfix'. Eleven issues sat in the sixth for weeks with their fixes shipped, one step short of closed. They are resolved; the check refuses the word from now on and accepts the one the playbook allows.
189 lines
8.3 KiB
Python
189 lines
8.3 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).
|
|
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"}
|
|
|
|
|
|
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 ------------------------------------------------------------------------
|
|
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)
|
|
|
|
# ---- 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")
|
|
|
|
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())
|