Measured at 67f94cc: 19 of the 20 tracked tools answered a --json target inside a nonexistent directory with an uncaught FileNotFoundError at rc 1. Only queue_append_audit.py already refused (rc 2). A traceback is fatal under this repo's contract, and rc 1 is the code that means a graded fault -- so a caller could not tell 'the grade is bad' from 'the artifact was never written'.
Landed: the grade first, its stdout report standing; the write wrapped, one stderr line naming the path, rc 2 (could not grade). gate_blame.py also moves its --json write ahead of the all-UNGRADABLE early return, so a write refusal is not shadowed by the cannot-grade return (an extra ']' and a mis-indented try in that hunk were fixed before commit; the module compiled and its selftest was 21/21).
tools/write_probe.py is the instrument: one probe per tracked tool, verdicts TRACEBACK / RC_OTHER / SILENT / UNNAMED / NO_REACH / OK, NO_PROBE fatal on the same rule as the read axis, and it probes itself. It is leg 6 of verify.py, and selftest_sweep.py now carries both a read probe and a malformed probe for it.
Verification at this commit follows in the next commit with docs/TOOLS.md and the queue record.
531 lines
24 KiB
Python
531 lines
24 KiB
Python
#!/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 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 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())
|