#!/usr/bin/env python3 """Регрессионный корпус хуков (B2): прогон fixtures/hook-cases.jsonl через guard-main и require-plan в песочнице. Запуск: python3 hook_corpus.py <каталог хуков> [--summary] [--fixtures ] Песочница строится во временном каталоге по раскладке из первой строки корпуса; префикс домашнего каталога записей заменяется на её home, HOME хуков подменяется туда же. Bash идёт через guard-main, Edit/Write — через guard-main и require-plan (блок, если блокирует любой). Вывод: строка на запись «idwhyожидалосьполученокоманда» и итог «total N/M»; с --summary — только расхождения и «корпус хуков: N/M». Код выхода 0 — всё совпало, 1 — нет. """ 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 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()] head, cases = lines[0], lines[1:] tmp = tempfile.mkdtemp(prefix="hook-corpus-") try: home = os.path.join(tmp, "home") try: build(home, head["layout"]) except Exception as e: print("корпус хуков: песочница не собрана (%s)" % e) return 1 env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")} env["HOME"] = home ok = 0 for c in cases: got = verdict(hooks, c, head["home"], home, env) ok += got == c["expected"] body = (c.get("command") or c.get("file_path") or "").replace(head["home"], "~") row = "\t".join([c["id"], c["why"], c["expected"], got, " ".join(body.split())[:70]]) if not summary: print(row) elif got != c["expected"]: print("MISMATCH " + row) print(("корпус хуков: %d/%d" if summary else "total %d/%d") % (ok, len(cases))) return 0 if ok == len(cases) else 1 finally: shutil.rmtree(tmp, ignore_errors=True) if __name__ == "__main__": sys.exit(main(sys.argv))