#!/usr/bin/env python3 """gate_blame.py -- which landing turned a gate leg red? WHY THIS EXISTS `verify.py` grades five legs, and it grades ONE tree: the one checked out now. So every question of the form "is this red mine?" costs a manual checkout and a re-run per commit. On 2026-10-20 three replies in a row answered exactly that question by hand, for the same inherited check-in fault: each re-proved the same two filenames against a parent commit, in prose, and each said so in its own words. The gate already holds the leg definitions and the grading logic; what it does not hold is time. This tool adds the time axis and nothing else. It walks the last N first-parent commits, materialises each one as its own detached worktree, runs `verify.py`'s OWN leg runner against that tree, and names the oldest commit whose verdict differs from its parent's. RULES (stated, not implied) B1 FIDELITY a revision's row is `verify.run_leg(...)` with `--repo` set to that revision's worktree, so it is graded by the revision's own tool file and its own `checkin_baseline.tsv` -- the tolerance it committed, not this host's idea of one. The leg DEFINITION (which argv, which ledger, which queue path) is this checkout's `verify.LEGS`: a leg added after a revision cannot have graded it, and that limit is printed in the header rather than hidden. B2 FLIP a flip is a change of VERDICT (PASS / FAULT / UNGRADABLE) between a commit and its first parent. A detail change at a constant verdict is printed as CHANGED and is never called a flip: two different faults are both FAULT, and naming the newer commit as the landing would be wrong. B3 FIRST the landing reported is the OLDEST commit in the window whose verdict differs from its parent -- the landing that turned it, not the newest commit that still carries it. B4 TRANSPARENT the window is printed even when nothing flips, with every row's verdict and the leg's own detail line. A tool that prints only a landing cannot be checked on the run where there is none. EXIT CODES (the repo's contract, `tools/selftest_sweep.py`) 0 ran; the leg's verdict is identical at every commit in the window 1 ran, and graded at least one verdict change (each landing is named) 2 could not grade: not a git work tree, an unknown leg, a range with no commits, git refusing a worktree, or every commit in the window UNGRADABLE. Never 1, never a traceback. READ-ONLY on the repository's content. Each revision is read from a detached worktree under a temporary directory, removed with `git worktree remove --force` and swept by `git worktree prune` on the way out; the checked-out tree, the index and the branch are untouched. The only write paths are `--json PATH` and stdout. USAGE: python3 gate_blame.py --repo . --leg checkin --range 10 python3 gate_blame.py --repo /root/troupe-standup --leg index-current --range 5 python3 gate_blame.py --repo . --leg checkin --range 24 --json /tmp/blame.json python3 gate_blame.py --repo . --selftest # fixture corpus, 21 cells """ from __future__ import annotations import argparse import contextlib import io import json import os import re import shutil import subprocess import sys import tempfile sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # These three strings mirror verify.py's verdict vocabulary. They are written as # literals ON PURPOSE: this module must import with no `verify.py` beside it. # `tool_map.py` builds a tool's `--help` oracle from a temporary copy of that one # file, so a module-level import of a sibling turns this tool into "no --help # oracle" -- measured on this file at 2c2184c, where the first render said # `- *(no --help oracle)*` for gate_blame.py while its own --help worked in-tree. PASS, FAULT, UNGRADABLE = "PASS", "FAULT", "UNGRADABLE" # The selftest size this docstring claims. Graded against the count that ran, the # same way `verify.py` grades its own: a docstring count with no reader is how the # 2026-10-19 check-in defect (docstring 50, help 42, run 50) survived. _DOC_CLAIM = re.search(r"fixture corpus, (\d+) cells", __doc__ or "") DOC_CELLS = int(_DOC_CLAIM.group(1)) if _DOC_CLAIM else 0 class CannotGrade(Exception): """rc 2: a reason this host cannot produce a timeline.""" def gate(): """verify.py -- the gate's own leg table and runner, imported on demand. Deferred rather than module-level so `--help` answers from a lone copy of this file (see the PASS/FAULT/UNGRADABLE note above). A missing verify.py is a refusal this host can name, never a traceback. """ here = os.path.dirname(os.path.abspath(__file__)) if here not in sys.path: sys.path.insert(0, here) try: import verify # noqa: PLC0415 -- deferred on purpose, see docstring except ImportError as exc: raise CannotGrade("verify.py is not beside this tool: %s" % exc) return verify def _git(repo, *args, timeout=120): return subprocess.run(["git", "-C", repo] + list(args), capture_output=True, text=True, timeout=timeout) def _first_line(text): for line in (text or "").splitlines(): if line.strip(): return line.strip() return "" def work_tree_root(repo): """The work-tree root, or CannotGrade. Mirrors verify.py's own resolution.""" if not os.path.isdir(repo): raise CannotGrade("no such directory: %s" % repo) proc = _git(repo, "rev-parse", "--show-toplevel") if proc.returncode != 0: raise CannotGrade("not a git work tree: %s (%s)" % (repo, _first_line(proc.stderr) or "rev-parse rc %d" % proc.returncode)) return proc.stdout.strip() def window(root, ref, count): """The last `count` first-parent commits ending at `ref`, oldest first.""" if count < 1: raise CannotGrade("--range must be 1 or more, got %d" % count) proc = _git(root, "rev-list", "--first-parent", "-n", str(count), ref) if proc.returncode != 0: raise CannotGrade("cannot read history at %s: %s" % (ref, _first_line(proc.stderr) or "rc %d" % proc.returncode)) revs = [line.strip() for line in proc.stdout.split() if line.strip()] if not revs: raise CannotGrade("no commits reachable from %s" % ref) return list(reversed(revs)) def stamps(root, revs): """{rev: 'YYYY-MM-DD HH:MM +0000'} in one git call, not one per commit.""" proc = _git(root, "log", "--no-walk", "--format=%H%x09%ad", "--date=iso", *revs) out = {} if proc.returncode != 0: return out for line in proc.stdout.splitlines(): parts = line.split("\t", 1) if len(parts) == 2: out[parts[0].strip()] = parts[1].strip() return out def grade_rev(root, rev, leg, queue, index, timeout, scratch, gate_mod): """One revision -> one row, graded by its own tree via verify.run_leg.""" wt = os.path.join(scratch, rev[:12]) added = _git(root, "worktree", "add", "--detach", "--quiet", wt, rev) if added.returncode != 0: return {"rev": rev, "verdict": UNGRADABLE, "rc": None, "detail": "worktree add refused: %s" % (_first_line(added.stderr) or "rc %d" % added.returncode)} try: row = gate_mod.run_leg(leg, wt, queue, index, timeout) finally: _git(root, "worktree", "remove", "--force", wt) return {"rev": rev, "verdict": row["verdict"], "rc": row["rc"], "detail": row["detail"]} def changes(rows): """Rows that differ from their parent, split into flips (verdict) and CHANGED.""" out = [] for prev, cur in zip(rows, rows[1:]): if prev["verdict"] != cur["verdict"]: out.append(("FLIP", prev, cur)) elif prev["detail"] != cur["detail"]: out.append(("CHANGED", prev, cur)) return out def render(rows, events, leg, ref, queue): print("gate_blame -- repo leg: %s (%s)" % (leg["key"], leg["tool"])) print(" window: %d commit(s) ending at %s, oldest first; leg definition from" " this checkout's verify.LEGS" % (len(rows), ref[:12])) print(" what: %s" % leg["what"]) print("") print(" %-12s %-19s %-10s %s" % ("commit", "date", "verdict", "detail")) for row in rows: detail = row["detail"] or "" if len(detail) > 96: detail = detail[:93] + "..." print(" %-12s %-19s %-10s %s" % (row["rev"][:12], (row.get("date") or "-")[:19], row["verdict"], detail)) print("") if not events: print(" no change: %s at every commit in the window" % rows[0]["verdict"]) for kind, prev, cur in events: print(" %s at %s (%s): %s -> %s" % (kind, cur["rev"][:12], (cur.get("date") or "-")[:19], prev["verdict"], cur["verdict"])) print(" detail: %s" % (cur["detail"] or "")[:200]) flips = [e for e in events if e[0] == "FLIP"] verdicts = sorted({r["verdict"] for r in rows}) print("") print("gate_blame: %d commit(s), %d flip(s), %d changed-at-same-verdict," " tip %s, verdicts seen %s" % (len(rows), len(flips), len(events) - len(flips), rows[-1]["verdict"], ",".join(verdicts))) return flips def main(argv=None): ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--repo", default=".") ap.add_argument("--leg", default="checkin", metavar="LEG", help="gate leg to attribute, named from this checkout's " "verify.LEGS (default checkin)") ap.add_argument("--range", type=int, default=10, help="first-parent commits to walk back from --ref (default 10)") ap.add_argument("--ref", default="HEAD") ap.add_argument("--queue", default=None, help="repo-relative queue path (default: verify.py's own)") ap.add_argument("--index", default=None, help="repo-relative index document (default: verify.py's own)") ap.add_argument("--timeout", type=int, default=300, help="per-revision leg timeout") ap.add_argument("--json", dest="json_path", default=None) ap.add_argument("--quiet", action="store_true") ap.add_argument("--selftest", action="store_true") args = ap.parse_args(argv) if args.selftest: return selftest() try: gate_mod = gate() except CannotGrade as exc: print("gate_blame: cannot grade: %s" % exc) return 2 leg_keys = tuple(l["key"] for l in gate_mod.LEGS) leg = next((l for l in gate_mod.LEGS if l["key"] == args.leg), None) if leg is None: print("gate_blame: unknown leg %r (known: %s)" % (args.leg, ", ".join(leg_keys))) return 2 # The queue/index defaults are verify.py's, not copies of them: a default # that drifts here would grade every revision against the wrong document. queue = args.queue if args.queue is not None else gate_mod.QUEUE_DEFAULT index = args.index if args.index is not None else gate_mod.INDEX_DEFAULT try: root = work_tree_root(args.repo) revs = window(root, args.ref, args.range) except CannotGrade as exc: print("gate_blame: cannot grade: %s" % exc) return 2 scratch = tempfile.mkdtemp(prefix="gate-blame-") rows = [] try: dates = stamps(root, revs) for rev in revs: row = grade_rev(root, rev, leg, queue, index, args.timeout, scratch, gate_mod) row["date"] = dates.get(rev, "") rows.append(row) finally: _git(root, "worktree", "prune") shutil.rmtree(scratch, ignore_errors=True) # events is a pure computation over rows; computing it before the # all-UNGRADABLE early return lets the --json write below always run, # so a write refusal still names its target (rc 2) instead of being # shadowed by the cannot-grade return. render() stays after the early # return because it prints to stdout; its `flips` return is only used by # the graded path. events = changes(rows) if args.json_path: payload = {"leg": leg["key"], "tool": leg["tool"], "ref": args.ref, "rows": rows, "flips": [{"at": cur["rev"], "from": prev["verdict"], "to": cur["verdict"], "detail": cur["detail"]} for kind, prev, cur in events if kind == "FLIP"], "changed_same_verdict": [ {"at": cur["rev"], "verdict": cur["verdict"], "was": prev["detail"], "now": cur["detail"]} for kind, prev, cur in events if kind == "CHANGED"]} try: with open(args.json_path, "w", encoding="utf-8") as fh: json.dump(payload, fh, indent=2, sort_keys=True) fh.write("\n") except OSError as exc: print("cannot write --json " + args.json_path + ": " + str(exc), file=sys.stderr) return 2 graded = [r for r in rows if r["verdict"] in (PASS, FAULT)] if not graded: if not args.quiet: for r in rows: print(" %-12s %-10s %s" % (r["rev"][:12], r["verdict"], r["detail"])) print("gate_blame: cannot grade: all %d commit(s) in the window are UNGRADABLE" % len(rows)) return 2 flips = render(rows, events, leg, revs[-1], args.queue) if not args.quiet else \ [e for e in events if e[0] == "FLIP"] return 1 if flips else 0 # ---------------------------------------------------------------- selftest --- def _sh(root, *args, date=None): env = dict(os.environ) env.update({"GIT_AUTHOR_NAME": "fixture", "GIT_AUTHOR_EMAIL": "f@example.invalid", "GIT_COMMITTER_NAME": "fixture", "GIT_COMMITTER_EMAIL": "f@example.invalid"}) if date: env["GIT_AUTHOR_DATE"] = date env["GIT_COMMITTER_DATE"] = date return subprocess.run(["git", "-C", root] + list(args), capture_output=True, text=True, env=env) TABLE = ("# Wizards\n\n| Wizard | Lane |\n|---|---|\n| volt | workhorse |\n") CLEAN = "# 2026-10-20 -- volt check-in\n\n## Did\n- nothing\n" DUP = "# 2026-10-20 -- volt check-in\n\n## Did\n- second file, same day\n" def _fixture(base, files, msg, date): """Write `files` (relpath -> text) into `base`, commit everything, return sha.""" for rel, text in files.items(): path = os.path.join(base, rel) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as fh: fh.write(text) _sh(base, "add", "-A") proc = _sh(base, "commit", "-q", "-m", msg, date=date) if proc.returncode != 0: raise RuntimeError("fixture commit failed: %s" % (proc.stdout + proc.stderr)) return _sh(base, "rev-parse", "HEAD").stdout.strip() def _repo(base): _sh(base, "init", "-q") _sh(base, "symbolic-ref", "HEAD", "refs/heads/main") def _grade(base, *args): """Run main() over a fixture, returning (rc, text).""" buf = io.StringIO() with contextlib.redirect_stdout(buf): rc = main(["--repo", base, *args]) return rc, buf.getvalue() def _install_checkin_check(base): """Copy checkin_check.py into a fixture repo's tools/ dir (DRY: inlined 5x). gate_blame grades each fixture by the tool file the fixture carries, so checkin_check.py is installed into /tools/ for fixtures A through E. The two-line shutil.copy that did this was hand-duped five times; this factors it into one self-contained definition. Behavior is unchanged: same source file, same destination, same bytes on disk. `here` is resolved from this module's own __file__ so the helper stays correct from a lone copy. """ here = os.path.dirname(os.path.abspath(__file__)) shutil.copy(os.path.join(here, "checkin_check.py"), os.path.join(base, "tools", "checkin_check.py")) def selftest(): here = os.path.dirname(os.path.abspath(__file__)) cells, failures = [], [] def cell(name, ok, detail=""): cells.append(name) if not ok: failures.append("%s%s" % (name, (": " + detail) if detail else "")) base = tempfile.mkdtemp(prefix="gate-blame-selftest-") try: # -- fixture A: PASS then FAULT (a suffix file creates a duplicate) ----- a = os.path.join(base, "a") os.makedirs(os.path.join(a, "tools")) os.makedirs(os.path.join(a, "checkins")) _install_checkin_check(a) with open(os.path.join(a, "tools", "checkin_baseline.tsv"), "w") as fh: fh.write("# empty ledger: every fault is new\n") with open(os.path.join(a, "WIZARDS-TABLE.md"), "w") as fh: fh.write(TABLE) open(os.path.join(a, "checkins", "2026-10-20-volt.md"), "w").write(CLEAN) _repo(a) c1 = _fixture(a, {}, "c1 clean", "2026-10-20T10:00:00+00:00") c2 = _fixture(a, {"checkins/2026-10-20-volt-02.md": DUP}, "c2 duplicate", "2026-10-20T11:00:00+00:00") rc, out = _grade(a, "--leg", "checkin", "--range", "2") cell("flip: PASS->FAULT exits 1", rc == 1, "rc=%d" % rc) cell("flip: names the landing commit", c2[:12] in out and "FLIP at %s" % c2[:12] in out, out.strip().splitlines()[-1] if out.strip() else "no output") cell("flip: does not name the parent", "FLIP at %s" % c1[:12] not in out) table = out.split("FLIP at")[0] if "FLIP at" in out else out cell("flip: both rows printed (B4)", c1[:12] in table and c2[:12] in table and "PASS" in table and "FAULT" in table, table) # -- fixture B: red at both commits -> no flip, tip FAULT -------------- b = os.path.join(base, "b") os.makedirs(os.path.join(b, "tools")) os.makedirs(os.path.join(b, "checkins")) _install_checkin_check(b) with open(os.path.join(b, "tools", "checkin_baseline.tsv"), "w") as fh: fh.write("# empty ledger\n") with open(os.path.join(b, "WIZARDS-TABLE.md"), "w") as fh: fh.write(TABLE) open(os.path.join(b, "checkins", "2026-10-20-volt.md"), "w").write(CLEAN) open(os.path.join(b, "checkins", "2026-10-20-volt-02.md"), "w").write(DUP) _repo(b) _fixture(b, {}, "b1 red", "2026-10-20T10:00:00+00:00") _fixture(b, {"checkins/README.md": "# not a check-in\n", "notes.txt": "x\n"}, "b2 still red, other detail", "2026-10-20T11:00:00+00:00") rc, out = _grade(b, "--leg", "checkin", "--range", "2") cell("stable red: rc 0 (no verdict change)", rc == 0, "rc=%d" % rc) cell("stable red: tip reported FAULT", "tip FAULT" in out, out.strip().splitlines()[-1:]) cell("stable red: parent not named as a landing", "FLIP at" not in out) # -- fixture C: constant verdict, changed detail -> CHANGED not FLIP --- c = os.path.join(base, "c") os.makedirs(os.path.join(c, "tools")) os.makedirs(os.path.join(c, "checkins")) _install_checkin_check(c) with open(os.path.join(c, "tools", "checkin_baseline.tsv"), "w") as fh: fh.write("# empty ledger\n") with open(os.path.join(c, "WIZARDS-TABLE.md"), "w") as fh: fh.write(TABLE) open(os.path.join(c, "checkins", "2026-10-20-volt-02.md"), "w").write(DUP) _repo(c) _fixture(c, {}, "c1 one bad name", "2026-10-20T10:00:00+00:00") _fixture(c, {"checkins/2026-10-20-volt-03.md": DUP}, "c2 two bad names", "2026-10-20T11:00:00+00:00") rc, out = _grade(c, "--leg", "checkin", "--range", "2") cell("detail-only change: rc 0", rc == 0, "rc=%d" % rc) cell("detail-only change: CHANGED printed, FLIP not", "CHANGED at" in out and "FLIP at" not in out, out) # -- fixture D: no tool at an old rev -> UNGRADABLE, then a real flip --- d = os.path.join(base, "d") os.makedirs(os.path.join(d, "checkins")) open(os.path.join(d, "checkins", "2026-10-20-volt.md"), "w").write(CLEAN) _repo(d) _fixture(d, {}, "d1 no tools dir", "2026-10-20T10:00:00+00:00") os.makedirs(os.path.join(d, "tools")) _install_checkin_check(d) with open(os.path.join(d, "tools", "checkin_baseline.tsv"), "w") as fh: fh.write("# empty ledger\n") _fixture(d, {"WIZARDS-TABLE.md": TABLE}, "d2 adds the tool", "2026-10-20T11:00:00+00:00") rc, out = _grade(d, "--leg", "checkin", "--range", "2") cell("absent tool: graded as UNGRADABLE, not a traceback", "UNGRADABLE" in out and "Traceback" not in out, out) cell("absent tool: UNGRADABLE->PASS is a flip", rc == 1, "rc=%d" % rc) # -- fixture E: empty checkins dir -> UNGRADABLE both sides, rc 2 ------- e = os.path.join(base, "e") os.makedirs(os.path.join(e, "tools")) os.makedirs(os.path.join(e, "checkins")) _install_checkin_check(e) _repo(e) _fixture(e, {"WIZARDS-TABLE.md": TABLE}, "e1 no check-ins", "2026-10-20T10:00:00+00:00") _fixture(e, {"notes.txt": "y\n"}, "e2 still none", "2026-10-20T11:00:00+00:00") rc, out = _grade(e, "--leg", "checkin", "--range", "2") cell("all-ungradable window: rc 2", rc == 2, "rc=%d" % rc) cell("all-ungradable window: says so", "cannot grade" in out, out) # -- refusals ---------------------------------------------------------- rc, out = _grade(base, "--leg", "checkin", "--range", "0") cell("refusal: --range 0 is rc 2", rc == 2 and "cannot grade" in out, "rc=%d %s" % (rc, out.strip())) notrepo = os.path.join(base, "notrepo") os.makedirs(notrepo, exist_ok=True) open(os.path.join(notrepo, "x.txt"), "w").write("x\n") rc, out = _grade(notrepo, "--leg", "checkin") cell("refusal: not a git work tree is rc 2", rc == 2 and "not a git work tree" in out, "rc=%d %s" % (rc, out.strip())) proc = subprocess.run([sys.executable, os.path.join(here, "gate_blame.py"), "--repo", a, "--leg", "nope"], capture_output=True, text=True) cell("refusal: unknown leg is rc 2 and names the known legs", proc.returncode == 2 and "unknown leg" in proc.stdout and "checkin" in proc.stdout, (proc.stdout or proc.stderr)[:140]) # -- the portability defect tool_map.py caught at 2c2184c: --help must # answer from a LONE copy of this file, with no verify.py beside it. lone = os.path.join(base, "lone") os.makedirs(lone, exist_ok=True) shutil.copy(os.path.join(here, "gate_blame.py"), os.path.join(lone, "gate_blame.py")) proc = subprocess.run([sys.executable, os.path.join(lone, "gate_blame.py"), "--help"], capture_output=True, text=True, cwd=lone) cell("--help works from a lone copy (no verify.py beside it)", proc.returncode == 0 and "--leg" in proc.stdout, (proc.stdout or proc.stderr)[:140]) # -- JSON shape -------------------------------------------------------- jpath = os.path.join(base, "blame.json") rc, out = _grade(a, "--leg", "checkin", "--range", "2", "--json", jpath) try: with open(jpath, encoding="utf-8") as fh: payload = json.load(fh) ok = (payload["leg"] == "checkin" and len(payload["rows"]) == 2 and payload["flips"] and payload["flips"][0]["at"] == c2) except Exception as exc: # noqa: BLE001 -- a bad JSON file is the cell ok, payload = False, str(exc) cell("--json: rows, leg and the flip's commit", ok, str(payload)[:160]) # -- subdirectory --repo resolves to the work-tree root ---------------- rc, out = _grade(os.path.join(a, "checkins"), "--leg", "checkin", "--range", "2") cell("subdirectory --repo: resolves to the root", rc == 1 and "FLIP at %s" % c2[:12] in out, out.strip().splitlines()[-1:]) # -- no worktree residue ---------------------------------------------- listed = _sh(a, "worktree", "list", "--porcelain").stdout cell("no worktree residue after the run", listed.count("worktree ") == 1, listed) finally: shutil.rmtree(base, ignore_errors=True) cell("docstring cell count matches the run", DOC_CELLS == len(cells) + 1, "docstring says %d, run says %d" % (DOC_CELLS, len(cells) + 1)) print("selftest: %d cells, %d passed, %d failed" % (len(cells), len(cells) - len(failures), len(failures))) for f in failures: print(" FAIL %s" % f) return 0 if not failures else 1 if __name__ == "__main__": sys.exit(main())