diff --git a/Makefile b/Makefile index 34473a0..4a00324 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,12 @@ PY := python3 TOOLS := tools -.PHONY: help register check +.PHONY: help register check checked help: @echo "make register - rebuild REGISTER.md from findings/" @echo "make check - verify the index is current, then report what is going quiet" + @echo "make checked - record a check outcome: make checked ARGS=\"RISK-F-0002 clean\"" register: @$(PY) $(TOOLS)/register_index.py @@ -14,3 +15,7 @@ check: @$(PY) $(TOOLS)/register_index.py --check @echo @$(PY) $(TOOLS)/register_check.py + +checked: + @$(PY) $(TOOLS)/record_check.py $(ARGS) + @$(PY) $(TOOLS)/register_index.py diff --git a/tools/record_check.py b/tools/record_check.py new file mode 100644 index 0000000..a6c92e7 --- /dev/null +++ b/tools/record_check.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Record the outcome of a check and move the finding along the ladder. + + python3 tools/record_check.py RISK-F-0002 clean + python3 tools/record_check.py RISK-F-0002 moved "flex-auth shipped the fix" + python3 tools/record_check.py RISK-F-0002 defer 2026-09-01 "operator: after the migration" + +`clean` climbs one rung, `moved` resets to `instant`, `defer` parks it until a +date by explicit operator decision. Writes the front-matter and appends a dated +line to the finding's Reviews section — the check is not recorded until it is +written down, which is the same rule the register applies to everyone else. +""" +from __future__ import annotations + +import datetime as dt +import re +import sys + +import register_lib as lib + + +def fail(msg: str) -> None: + print(msg) + raise SystemExit(2) + + +def main(argv: list[str]) -> int: + if len(argv) < 2: + fail(__doc__) + fid, outcome, rest = argv[0], argv[1], argv[2:] + matches = [f for f in lib.findings() if f["id"] == fid] + if not matches: + fail(f"no finding with id {fid}") + f = matches[0] + path = f["_path"] + text = path.read_text(encoding="utf-8") + now = lib.now() + stamp = now.strftime("%Y-%m-%dT%H:%M:%SZ") + rung = f.get("cadence", "instant") + + if outcome == "clean": + new_rung = lib.climb(rung) + streak = int(f.get("clean_streak", 0)) + 1 + nxt = now + lib.interval(new_rung) + note = rest[0] if rest else "checked, nothing moved" + line = f"- **{now:%Y-%m-%d}** — clean check: {note}. Cadence {rung} → {new_rung} ({streak} clean in a row); next check {nxt:%Y-%m-%d %H:%MZ}." + defer = "" + elif outcome == "moved": + if not rest: + fail("`moved` needs a reason: what changed") + new_rung, streak, nxt = lib.reset(), 0, now + line = f"- **{now:%Y-%m-%d}** — not clean: {rest[0]} Cadence {rung} → instant; checked again immediately." + defer = "" + elif outcome == "defer": + if len(rest) < 2: + fail("`defer` needs a date and the operator's reason") + until, why = rest[0], rest[1] + new_rung, streak, nxt = rung, int(f.get("clean_streak", 0)), lib.moment(until) + line = f"- **{now:%Y-%m-%d}** — deferred to {until} by explicit operator decision: {why}" + defer = until + else: + fail("outcome must be one of: clean, moved, defer") + + subs = { + "last_checked": stamp, + "next_check": nxt.strftime("%Y-%m-%dT%H:%M:%SZ"), + "cadence": new_rung, + "clean_streak": str(streak), + } + if defer: + subs["deferred_to"] = defer + for key, value in subs.items(): + quoted = f'"{value}"' if key not in ("cadence", "clean_streak") else value + if re.search(rf"(?m)^{key}:", text): + text = re.sub(rf"(?m)^{key}:.*$", f"{key}: {quoted}", text) + else: + text = text.replace("\n---\n", f"\n{key}: {quoted}\n---\n", 1) + + if "\n## Reviews\n" in text: + text = text.rstrip("\n") + "\n" + line + "\n" + else: + text = text.rstrip("\n") + "\n\n## Reviews\n\n" + line + "\n" + path.write_text(text, encoding="utf-8") + print(f"{fid}: {outcome} — cadence {rung} → {new_rung}, next check {subs['next_check']}") + print("REGISTER.md is stale; run `make register`.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/register_check.py b/tools/register_check.py index 707d068..202db66 100644 --- a/tools/register_check.py +++ b/tools/register_check.py @@ -26,6 +26,16 @@ def main() -> int: lines.append(f" {quiet}") lines.append("") + seen: dict[str, str] = {} + dupes = [] + for f in lib.findings(): + prior = seen.get(f["id"]) + if prior: + dupes.append(f"{f['id']} — filed twice: {prior} and {f['_path'].name}; renumber the later commit") + seen[f["id"]] = f["_path"].name + if dupes: + section("Duplicate ids", dupes, "none") + unknown = [ f"{f['id']} — status '{f.get('status')}' is not one of {', '.join(lib.KNOWN_STATUSES)}; watched anyway" for f in fs