#!/usr/bin/env python3 """Регрессионный корпус хуков (B2): прогон fixtures/hook-cases.jsonl через guard-main и require-plan в песочнице. Запуск: python3 hook_corpus.py <каталог хуков> [--summary] [--fixtures ] Корпус — сегменты: строка с ключом layout ({home, layout}) открывает сегмент, следующие строки — его записи. Для каждого сегмента строится своя песочница; префикс home записей заменяется на её home, HOME хуков — туда же. Bash идёт через guard-main, Edit/Write — через guard-main и require-plan (блок, если блокирует любой). Запись с known — известная ошибка хуков: расхождение — KNOWN (не ошибка прогона), совпадение — KNOWN-FIXED (ошибка: снять known). Вывод: строка «idwhyожидалосьполученостатускоманда» и итог «total N/M»; с --summary — только MISMATCH и KNOWN-FIXED и «корпус хуков: N/M[, известных ошибок K]». Код 0 — нет ошибок. """ import json import os import shutil import subprocess import sys import tempfile HERE = os.path.dirname(os.path.abspath(__file__)) def _git(*args): subprocess.run(["git", "-c", "user.name=corpus", "-c", "user.email=corpus@example.invalid"] + list(args), check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def build(home, layout): for e in layout: p = os.path.join(home, e["path"]) if "worktree_of" in e: _git("-C", os.path.join(home, e["worktree_of"]), "worktree", "add", "-q", p, "-b", e["branch"]) elif "branch" in e: os.makedirs(p, exist_ok=True) _git("init", "-q", p) _git("-C", p, "symbolic-ref", "HEAD", "refs/heads/" + e["branch"]) _git("-C", p, "commit", "-q", "--allow-empty", "-m", "init") else: os.makedirs(p, exist_ok=True) def verdict(hooks, case, prefix, home, env): swap = lambda s: s.replace(prefix, home) if isinstance(s, str) else s tool = case["tool"] ti = {"command": swap(case.get("command"))} if tool == "Bash" else {"file_path": swap(case.get("file_path"))} payload = json.dumps({"cwd": swap(case.get("cwd")), "tool_name": tool, "tool_input": ti}) for name in ["guard-main"] + ([] if tool == "Bash" else ["require-plan"]): r = subprocess.run(["bash", os.path.join(hooks, name + ".sh")], input=payload, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, timeout=30) if r.returncode == 2: return "block" if r.returncode != 0: return "error" return "allow" def segments(lines): """[(шапка, [записи])]: строка с ключом layout открывает сегмент.""" segs = [] for obj in lines: if "layout" in obj: segs.append((obj, [])) elif segs: segs[-1][1].append(obj) return segs def status(case, got): if got == case["expected"]: return "KNOWN-FIXED" if case.get("known") else "ok" return "KNOWN" if case.get("known") else "MISMATCH" def main(argv): args = argv[1:] summary = "--summary" in args fx = os.path.join(HERE, "..", "fixtures", "hook-cases.jsonl") if "--fixtures" in args: fx = args[args.index("--fixtures") + 1] pos = [a for k, a in enumerate(args) if not a.startswith("--") and (k == 0 or args[k - 1] != "--fixtures")] if not pos: print(__doc__.strip().splitlines()[2]) return 1 hooks = os.path.abspath(pos[0]) lines = [json.loads(l) for l in open(fx, encoding="utf-8") if l.strip()] env0 = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")} tmp = tempfile.mkdtemp(prefix="hook-corpus-") try: total = good = known = 0 for n, (head, cases) in enumerate(segments(lines)): home = os.path.join(tmp, "seg-%d" % n, "home") try: build(home, head["layout"]) except Exception as e: print("корпус хуков: песочница сегмента %d не собрана (%s)" % (n + 1, e)) return 1 env = dict(env0, HOME=home) for c in cases: got = verdict(hooks, c, head["home"], home, env) st = status(c, got) total += 1 good += st in ("ok", "KNOWN") known += st == "KNOWN" body = (c.get("command") or c.get("file_path") or "").replace(head["home"], "~") row = "\t".join([c["id"], c["why"], c["expected"], got, st, " ".join(body.split())[:70]]) if not summary: print(row) elif st in ("MISMATCH", "KNOWN-FIXED"): print(st + " " + row) tail = ", известных ошибок %d" % known if known else "" print(("корпус хуков: %d/%d%s" if summary else "total %d/%d%s") % (good, total, tail)) return 0 if good == total else 1 finally: shutil.rmtree(tmp, ignore_errors=True) if __name__ == "__main__": sys.exit(main(sys.argv))