118 lines
5.4 KiB
Python
118 lines
5.4 KiB
Python
#!/usr/bin/env python3
|
||
"""Регрессионный корпус хуков (B2): прогон fixtures/hook-cases.jsonl через guard-main и require-plan в песочнице.
|
||
|
||
Запуск: python3 hook_corpus.py <каталог хуков> [--summary] [--fixtures <jsonl>]
|
||
Корпус — сегменты: строка с ключом layout ({home, layout}) открывает сегмент, следующие строки — его записи.
|
||
Для каждого сегмента строится своя песочница; префикс home записей заменяется на её home, HOME хуков — туда же.
|
||
Bash идёт через guard-main, Edit/Write — через guard-main и require-plan (блок, если блокирует любой).
|
||
Запись с known — известная ошибка хуков: расхождение — KNOWN (не ошибка прогона), совпадение — KNOWN-FIXED (ошибка:
|
||
снять known). Вывод: строка «id<TAB>why<TAB>ожидалось<TAB>получено<TAB>статус<TAB>команда» и итог «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))
|