mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery superseded: a newer head of the same pull request
ADR 0290 rule 9: once the import is checked record by record, hq keeps no copy. The records and efforts are removed; every link or path to them reads "ADR NNNN" or "research NNN"; a design's decisions: lists "ADR NNNN". 02-DECISIONS keeps a pointer README with the reading order's six topics, which the knowledge base reads through the records module; 01-RESEARCH keeps a pointer README. The checks drop the rules that judged records (the knowledge base holds each at propose; checks/README.md says where) and refuse a record written here again. Playbooks 01 and 02 and the skills write and read records with the knowledge base's verbs.
174 lines
8.1 KiB
Python
174 lines
8.1 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.
|
|
|
|
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())
|