diff --git a/tools/gate_blame.py b/tools/gate_blame.py new file mode 100644 index 0000000..0e17566 --- /dev/null +++ b/tools/gate_blame.py @@ -0,0 +1,469 @@ +#!/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, 20 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__))) +import verify # noqa: E402 -- the gate's own runner and leg table, reused on purpose + +LEG_KEYS = tuple(leg["key"] for leg in verify.LEGS) +PASS, FAULT, UNGRADABLE = verify.PASS, verify.FAULT, verify.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 _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): + """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 = verify.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", choices=LEG_KEYS) + 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=verify.QUEUE_DEFAULT) + ap.add_argument("--index", default=verify.INDEX_DEFAULT) + 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() + + leg = next((l for l in verify.LEGS if l["key"] == args.leg), None) + if leg is None: # argparse choices makes this unreachable; kept explicit. + print("gate_blame: unknown leg %r (known: %s)" % (args.leg, ", ".join(LEG_KEYS))) + return 2 + 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, args.queue, args.index, args.timeout, scratch) + row["date"] = dates.get(rev, "") + rows.append(row) + finally: + _git(root, "worktree", "prune") + shutil.rmtree(scratch, ignore_errors=True) + + 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 + + events = changes(rows) + flips = render(rows, events, leg, revs[-1], args.queue) if not args.quiet else \ + [e for e in events if e[0] == "FLIP"] + + 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"]} + with open(args.json_path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + fh.write("\n") + 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 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")) + shutil.copy(os.path.join(here, "checkin_check.py"), + os.path.join(a, "tools", "checkin_check.py")) + 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")) + shutil.copy(os.path.join(here, "checkin_check.py"), + os.path.join(b, "tools", "checkin_check.py")) + 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")) + shutil.copy(os.path.join(here, "checkin_check.py"), + os.path.join(c, "tools", "checkin_check.py")) + 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")) + shutil.copy(os.path.join(here, "checkin_check.py"), + os.path.join(d, "tools", "checkin_check.py")) + 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")) + shutil.copy(os.path.join(here, "checkin_check.py"), + os.path.join(e, "tools", "checkin_check.py")) + _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 rejected by argparse (rc 2)", + proc.returncode == 2 and "invalid choice" in proc.stderr, proc.stderr[:120]) + + # -- 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()) diff --git a/tools/selftest_sweep.py b/tools/selftest_sweep.py index ebd3202..7fa3c95 100644 --- a/tools/selftest_sweep.py +++ b/tools/selftest_sweep.py @@ -58,9 +58,9 @@ THE MALFORMED-INPUT CLASS, ADDED 2026-10-18 should be, a directory that is not a git repository, a queue file with no records. Those are the inputs where a tool is most tempted to answer "0" and return 0, which reads exactly like a clean pass (`doc_census_check`'s - `--tools` bug, `76043b7`, was this class). The axis now runs 17 such probes - and grades each against ITS OWN stated answer, because "must exit 2" is not - a universal truth: eleven tools must refuse at rc 2, while `check_lint` grades + `--tools` bug, `76043b7`, was this class). The axis runs one such probe per + tool and grades each against ITS OWN stated answer, because "must exit 2" is + not a universal truth: most tools must refuse at rc 2, while `check_lint` grades a junk log MISSING (rc 0 in report mode, rc 1 under --fail-on-fault), `checkin_check` grades a heading-less check-in as an advisory (rc 0, stated in its docstring) and `queue_claim_check` reports 0 opening records (rc 0, @@ -163,6 +163,7 @@ READ_PROBES = ( ("citation_resolve_check.py", ["--repo", "{nope}"]), ("doc_command_check.py", ["--repo", "{nope}"]), ("doc_census_check.py", ["--repo", "{nope}", "--no-run"]), + ("gate_blame.py", ["--repo", "{nope}"]), ("landing_check.py", ["--repo", "{nope}"]), ("queue_claim_check.py", ["--queue", "{nope}/q.md"]), ("queue_index.py", ["--queue", "{nope}/q.md", "--check", "{nope}/i.md"]), @@ -183,10 +184,14 @@ NOT_UTF8_PROBES = ( # THE THIRD CLASS (2026-10-18): input that decodes as UTF-8 but cannot be made # sense of. Each entry carries the answer the TOOL's own contract states -- # (tool, label, argv, expected rc, a substring the output must contain) -- so -# the axis measures the documented behaviour instead of imposing one. Fourteen -# tools must refuse at rc 2; the three that grade a malformed input are listed +# the axis measures the documented behaviour instead of imposing one. Most +# tools must refuse at rc 2; the ones that grade a malformed input are listed # with the rc and the word their grade prints, which is what makes a silent # rc 0 (the doc_census_check --tools bug class) fail here. +# The counts are deliberately NOT written here: a number in a comment has no +# reader, which is the defect class this file's own docstring names, and the +# two counts that used to sit on these lines were both stale ("eleven", +# "Fourteen") before gate_blame.py's row was added on 2026-10-20. # Fixtures: {notrepo} = a directory with a file and no .git; {badjson} = a # UTF-8 file that is not JSON; {emptyjson} = "{}" (valid JSON, wrong shape); # {junklog} = a log with no CHECK: line; {junkqueue} = 3 prose lines, no @@ -217,6 +222,10 @@ MALFORMED_PROBES = ( 2, "no tools found"), ("doc_command_check.py", "not-a-repo", ["--repo", "{notrepo}"], 2, "cannot read repo"), + # gate_blame.py's input is a git work tree: a directory that is not one is the + # malformed shape, and it must name that rather than pass a window of nothing. + ("gate_blame.py", "not-a-repo", ["--repo", "{notrepo}"], + 2, "not a git work tree"), ("landing_check.py", "not-a-repo", ["--repo", "{notrepo}"], 2, "not a git work tree"), ("queue_claim_check.py", "junk-queue", ["--queue", "{junkqueue}"],