#!/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. The decision records and research efforts live in the knowledge base (ADR 0290, issue 426), and the rules this check held over them are the knowledge base's now: a research effort's status and what it became, and a decision's duplicate number, are refused by `knowledge-base.propose`; an accepted decision cited by nothing is the knowledge base's whole-set rule `cited`; a deferral that names no issue is its rule `deferral`. README.md beside this file says where each went. 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"} # 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") 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 # 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)) checked = ( len(glob.glob(os.path.join(ROOT, "03-DESIGN", "0*", "*.md"))) + len(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))) ) 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())