feat(retro): корпус хуков по сегментам и known, выгрузка блокировок за период (#29)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
60d31c25db
commit
4d52ef773d
@ -1,14 +1,19 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Выгрузка блокировок хуков из транскриптов Claude Code в JSONL — сырьё для регрессионных тестов хуков (B2).
|
"""Выгрузка блокировок хуков из транскриптов Claude Code в JSONL — сырьё для регрессионных тестов хуков (B2).
|
||||||
|
|
||||||
Запуск: python3 hook_blocks_export.py <каталог-транскриптов> [<каталог> ...] > blocks.jsonl
|
Запуск: python3 hook_blocks_export.py [--since ГГГГ-ММ-ДД] [--id-prefix <дата>] <каталог-транскриптов> [...]
|
||||||
Одна строка — одна блокировка: сессия, время, cwd и ветка на момент вызова, инструмент, команда или путь файла,
|
Одна строка — одна блокировка: сессия, время, cwd и ветка на момент вызова, инструмент, команда или путь файла,
|
||||||
первая строка ответа хука. Поле expected не заполняется: вердикт (block/allow) проставляется при разметке.
|
первая строка ответа хука. Поле expected не заполняется: вердикт (block/allow) проставляется при разметке.
|
||||||
Выгружаются только ошибки хуков (результат начинается с `PreToolUse:<инструмент> hook error`); вывод команд,
|
Выгружаются только ошибки хуков (результат начинается с `PreToolUse:<инструмент> hook error`); вывод команд,
|
||||||
в котором встретилось слово BLOCKED (файлы памяти, исходники хуков), не попадает.
|
в котором встретилось слово BLOCKED (файлы памяти, исходники хуков), не попадает.
|
||||||
Похожие на секреты подстроки заменяются на <redacted>.
|
--since — только сессии с первой меткой времени не раньше даты; --id-prefix (по умолчанию — дата --since) —
|
||||||
|
поле id вида <префикс>-NNN (срез ретро, B12). Похожие на секреты подстроки заменяются на <redacted>.
|
||||||
"""
|
"""
|
||||||
import json, sys, glob, os, re
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
HOOK_ERROR = re.compile(r"\s*PreToolUse:\S+ hook error")
|
HOOK_ERROR = re.compile(r"\s*PreToolUse:\S+ hook error")
|
||||||
|
|
||||||
@ -42,15 +47,33 @@ def hook_of(txt, tool):
|
|||||||
return "other"
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
for d in sys.argv[1:]:
|
def _records(path):
|
||||||
for f in sorted(glob.glob(os.path.join(os.path.expanduser(d), "*.jsonl"))):
|
for line in open(path, encoding="utf-8", errors="replace"):
|
||||||
sid = os.path.basename(f)[:8]
|
|
||||||
pending = {} # tool_use_id -> (name, input, cwd, branch, ts)
|
|
||||||
for line in open(f, encoding="utf-8", errors="replace"):
|
|
||||||
try:
|
try:
|
||||||
j = json.loads(line)
|
j = json.loads(line)
|
||||||
except Exception:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
|
if isinstance(j, dict):
|
||||||
|
yield j
|
||||||
|
|
||||||
|
|
||||||
|
def first_ts(path):
|
||||||
|
for j in _records(path):
|
||||||
|
if isinstance(j.get("timestamp"), str) and j["timestamp"]:
|
||||||
|
return j["timestamp"]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def export(dirs, since=None, prefix=None):
|
||||||
|
"""Записи выгрузки по каталогам транскриптов, по порядку файлов и строк."""
|
||||||
|
n = 0
|
||||||
|
for d in dirs:
|
||||||
|
for f in sorted(glob.glob(os.path.join(os.path.expanduser(d), "*.jsonl"))):
|
||||||
|
if since and first_ts(f)[:10] < since:
|
||||||
|
continue
|
||||||
|
sid = os.path.basename(f)[:8]
|
||||||
|
pending = {} # tool_use_id -> (name, input, cwd, branch, ts)
|
||||||
|
for j in _records(f):
|
||||||
m = j.get("message") or {}
|
m = j.get("message") or {}
|
||||||
cont = m.get("content")
|
cont = m.get("content")
|
||||||
if j.get("type") == "assistant":
|
if j.get("type") == "assistant":
|
||||||
@ -65,15 +88,39 @@ for d in sys.argv[1:]:
|
|||||||
txt = b.get("content", "")
|
txt = b.get("content", "")
|
||||||
if isinstance(txt, list):
|
if isinstance(txt, list):
|
||||||
txt = "\n".join(x.get("text", "") for x in txt if isinstance(x, dict))
|
txt = "\n".join(x.get("text", "") for x in txt if isinstance(x, dict))
|
||||||
if not HOOK_ERROR.match(txt):
|
if not isinstance(txt, str) or not HOOK_ERROR.match(txt):
|
||||||
continue # обычный вывод команды, даже если в нём есть слово BLOCKED
|
continue # обычный вывод команды, даже если в нём есть слово BLOCKED
|
||||||
name, inp, cwd, branch, ts = pending.get(b.get("tool_use_id"), ("?", {}, "", "", ""))
|
name, inp, cwd, branch, ts = pending.get(b.get("tool_use_id"), ("?", {}, "", "", ""))
|
||||||
if name not in ("Bash", "Edit", "Write", "MultiEdit"):
|
if name not in ("Bash", "Edit", "Write", "MultiEdit"):
|
||||||
continue
|
continue
|
||||||
msg = next((l for l in txt.splitlines() if "BLOCKED" in l), "")
|
msg = next((l for l in txt.splitlines() if "BLOCKED" in l), "")
|
||||||
print(json.dumps({
|
rec = {
|
||||||
"session": sid, "ts": ts, "cwd": cwd, "branch": branch, "tool": name,
|
"session": sid, "ts": ts, "cwd": cwd, "branch": branch, "tool": name,
|
||||||
"command": redact(inp.get("command", "")) if name == "Bash" else None,
|
"command": redact(inp.get("command", "")) if name == "Bash" else None,
|
||||||
"file_path": inp.get("file_path") if name != "Bash" else None,
|
"file_path": inp.get("file_path") if name != "Bash" else None,
|
||||||
"hook": hook_of(txt, name), "message": redact(msg[:300]), "expected": None,
|
"hook": hook_of(txt, name), "message": redact(msg[:300]), "expected": None,
|
||||||
}, ensure_ascii=False))
|
}
|
||||||
|
if prefix:
|
||||||
|
n += 1
|
||||||
|
rec = dict({"id": "%s-%03d" % (prefix, n)}, **rec)
|
||||||
|
yield rec
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
args, dirs, since, prefix = argv[1:], [], None, None
|
||||||
|
i = 0
|
||||||
|
while i < len(args):
|
||||||
|
if args[i] == "--since" and i + 1 < len(args):
|
||||||
|
since, i = args[i + 1], i + 2
|
||||||
|
elif args[i] == "--id-prefix" and i + 1 < len(args):
|
||||||
|
prefix, i = args[i + 1], i + 2
|
||||||
|
else:
|
||||||
|
dirs.append(args[i])
|
||||||
|
i += 1
|
||||||
|
for rec in export(dirs, since, prefix or since):
|
||||||
|
print(json.dumps(rec, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv))
|
||||||
|
|||||||
@ -2,19 +2,22 @@
|
|||||||
"""Сборка регрессионного корпуса хуков из сырой выгрузки блокировок (B2, #6).
|
"""Сборка регрессионного корпуса хуков из сырой выгрузки блокировок (B2, #6).
|
||||||
|
|
||||||
Запуск из корня репозитория:
|
Запуск из корня репозитория:
|
||||||
python3 docs/retro/scripts/hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl
|
python3 docs/retro/scripts/hook_cases_build.py [--fixtures-dir <каталог>] > skills/harness-doctor/fixtures/hook-cases.jsonl
|
||||||
Вход: docs/retro/fixtures/hook-blocks.jsonl (hook_blocks_export.py, 12.10.2026, 126 записей).
|
Вход: <fixtures>/hook-blocks.jsonl (hook_blocks_export.py, 12.10.2026, 126 записей) и пары ретро
|
||||||
Выход: первая строка — {"home": …, "layout": […]} (раскладка песочницы), дальше по записи на случай:
|
hook-blocks-<дата>.jsonl + hook-labels-<дата>.json (B12, #29).
|
||||||
поля выгрузки + id, expected (allow|block), why (класс). Вердикт размечен вручную по спеке
|
Выход — сегменты: строка {"home": …, "layout": […]} (раскладка песочницы), дальше по записи на случай:
|
||||||
|
поля выгрузки + id, expected (allow|block), why (класс)[, known]. Первый сегмент — пилот 12.10 и живые случаи,
|
||||||
|
дальше по сегменту на пару ретро; записи со skip не входят. Вердикт размечен вручную по спеке
|
||||||
openspec/specs/hook-guards/spec.md, а не прогоном хука.
|
openspec/specs/hook-guards/spec.md, а не прогоном хука.
|
||||||
"""
|
"""
|
||||||
|
import glob
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
RAW = os.path.join(HERE, "..", "fixtures", "hook-blocks.jsonl")
|
FIX = os.path.join(HERE, "..", "fixtures")
|
||||||
HOME = "/Users/andrey.epifancev"
|
HOME = "/Users/andrey.epifancev"
|
||||||
AW, CH = "projects/awp-ca", "projects/cc-skills/claude-harness"
|
AW, CH = "projects/awp-ca", "projects/cc-skills/claude-harness"
|
||||||
|
|
||||||
@ -77,9 +80,35 @@ def why_of(cid, r):
|
|||||||
return "devnull" if DEVNULL.search(r.get("command") or "") else "read-only"
|
return "devnull" if DEVNULL.search(r.get("command") or "") else "read-only"
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def label_segments(fix):
|
||||||
|
"""Сегменты ретро: пары hook-blocks-<дата>.jsonl + hook-labels-<дата>.json. Разметка должна совпадать с выгрузкой."""
|
||||||
|
for lp in sorted(glob.glob(os.path.join(fix, "hook-labels-*.json"))):
|
||||||
|
date = os.path.basename(lp)[len("hook-labels-"):-len(".json")]
|
||||||
|
lab = json.load(open(lp, encoding="utf-8"))
|
||||||
|
cases = lab.get("cases", {})
|
||||||
|
recs = [json.loads(l) for l in open(os.path.join(fix, "hook-blocks-%s.jsonl" % date), encoding="utf-8")
|
||||||
|
if l.strip()]
|
||||||
|
ids = [r.get("id") for r in recs]
|
||||||
|
missing, extra = [i for i in ids if i not in cases], sorted(set(cases) - set(ids))
|
||||||
|
if missing or extra:
|
||||||
|
raise SystemExit("hook-labels-%s.json: нет разметки для %s; лишняя разметка %s"
|
||||||
|
% (date, ", ".join(missing) or "—", ", ".join(extra) or "—"))
|
||||||
|
yield {"home": lab["home"], "layout": lab["layout"]}
|
||||||
|
for r in recs:
|
||||||
|
c = cases[r["id"]]
|
||||||
|
if "skip" in c:
|
||||||
|
continue
|
||||||
|
r.update({"expected": c["expected"], "why": c["why"]})
|
||||||
|
if c.get("known"):
|
||||||
|
r["known"] = c["known"]
|
||||||
|
yield r
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
fix = argv[argv.index("--fixtures-dir") + 1] if "--fixtures-dir" in argv else FIX
|
||||||
print(json.dumps({"home": HOME, "layout": LAYOUT}, ensure_ascii=False))
|
print(json.dumps({"home": HOME, "layout": LAYOUT}, ensure_ascii=False))
|
||||||
for n, line in enumerate(l for l in open(RAW, encoding="utf-8") if l.strip()):
|
raw = os.path.join(fix, "hook-blocks.jsonl")
|
||||||
|
for n, line in enumerate(l for l in open(raw, encoding="utf-8") if l.strip()):
|
||||||
r = json.loads(line)
|
r = json.loads(line)
|
||||||
cid = "c%03d" % (n + 1)
|
cid = "c%03d" % (n + 1)
|
||||||
r.update({"id": cid, "expected": "block" if cid in BLOCK else "allow", "why": why_of(cid, r)})
|
r.update({"id": cid, "expected": "block" if cid in BLOCK else "allow", "why": why_of(cid, r)})
|
||||||
@ -88,7 +117,9 @@ def main():
|
|||||||
print(json.dumps({"id": cid, "session": "live-1015", "cwd": HOME + "/" + CH, "branch": "main",
|
print(json.dumps({"id": cid, "session": "live-1015", "cwd": HOME + "/" + CH, "branch": "main",
|
||||||
"tool": "Bash", "command": cmd, "file_path": None, "hook": "guard-main(Bash)",
|
"tool": "Bash", "command": cmd, "file_path": None, "hook": "guard-main(Bash)",
|
||||||
"message": "", "expected": "allow", "why": why}, ensure_ascii=False))
|
"message": "", "expected": "allow", "why": why}, ensure_ascii=False))
|
||||||
|
for obj in label_segments(fix):
|
||||||
|
print(json.dumps(obj, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main(sys.argv)
|
||||||
|
|||||||
@ -115,7 +115,7 @@ rm -rf "$T"
|
|||||||
HC="$CC/skills/harness-doctor/scripts/hook_corpus.py"
|
HC="$CC/skills/harness-doctor/scripts/hook_corpus.py"
|
||||||
if [ -f "$HC" ] && [ -f "$CC/hooks/harness_target.py" ]; then
|
if [ -f "$HC" ] && [ -f "$CC/hooks/harness_target.py" ]; then
|
||||||
out=$(python3 "$HC" "$CC/hooks" --summary 2>&1); rc=$?
|
out=$(python3 "$HC" "$CC/hooks" --summary 2>&1); rc=$?
|
||||||
printf '%s\n' "$out" | grep '^MISMATCH' | sed 's/^MISMATCH / /'
|
printf '%s\n' "$out" | grep -E '^(MISMATCH|KNOWN-FIXED) ' | sed 's/^/ /'
|
||||||
[ "$rc" = 0 ] && ok "$(printf '%s\n' "$out" | tail -1)" || warn "$(printf '%s\n' "$out" | tail -1) — расхождения выше"
|
[ "$rc" = 0 ] && ok "$(printf '%s\n' "$out" | tail -1)" || warn "$(printf '%s\n' "$out" | tail -1) — расхождения выше"
|
||||||
else miss "корпус хуков (skills/harness-doctor/scripts/hook_corpus.py, hooks/harness_target.py)"; fi
|
else miss "корпус хуков (skills/harness-doctor/scripts/hook_corpus.py, hooks/harness_target.py)"; fi
|
||||||
|
|
||||||
|
|||||||
@ -2,11 +2,12 @@
|
|||||||
"""Регрессионный корпус хуков (B2): прогон fixtures/hook-cases.jsonl через guard-main и require-plan в песочнице.
|
"""Регрессионный корпус хуков (B2): прогон fixtures/hook-cases.jsonl через guard-main и require-plan в песочнице.
|
||||||
|
|
||||||
Запуск: python3 hook_corpus.py <каталог хуков> [--summary] [--fixtures <jsonl>]
|
Запуск: python3 hook_corpus.py <каталог хуков> [--summary] [--fixtures <jsonl>]
|
||||||
Песочница строится во временном каталоге по раскладке из первой строки корпуса; префикс домашнего каталога записей
|
Корпус — сегменты: строка с ключом layout ({home, layout}) открывает сегмент, следующие строки — его записи.
|
||||||
заменяется на её home, HOME хуков подменяется туда же. Bash идёт через guard-main, Edit/Write — через guard-main и
|
Для каждого сегмента строится своя песочница; префикс home записей заменяется на её home, HOME хуков — туда же.
|
||||||
require-plan (блок, если блокирует любой).
|
Bash идёт через guard-main, Edit/Write — через guard-main и require-plan (блок, если блокирует любой).
|
||||||
Вывод: строка на запись «id<TAB>why<TAB>ожидалось<TAB>получено<TAB>команда» и итог «total N/M»;
|
Запись с known — известная ошибка хуков: расхождение — KNOWN (не ошибка прогона), совпадение — KNOWN-FIXED (ошибка:
|
||||||
с --summary — только расхождения и «корпус хуков: N/M». Код выхода 0 — всё совпало, 1 — нет.
|
снять known). Вывод: строка «id<TAB>why<TAB>ожидалось<TAB>получено<TAB>статус<TAB>команда» и итог «total N/M»;
|
||||||
|
с --summary — только MISMATCH и KNOWN-FIXED и «корпус хуков: N/M[, известных ошибок K]». Код 0 — нет ошибок.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@ -52,6 +53,23 @@ def verdict(hooks, case, prefix, home, env):
|
|||||||
return "allow"
|
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):
|
def main(argv):
|
||||||
args = argv[1:]
|
args = argv[1:]
|
||||||
summary = "--summary" in args
|
summary = "--summary" in args
|
||||||
@ -64,29 +82,33 @@ def main(argv):
|
|||||||
return 1
|
return 1
|
||||||
hooks = os.path.abspath(pos[0])
|
hooks = os.path.abspath(pos[0])
|
||||||
lines = [json.loads(l) for l in open(fx, encoding="utf-8") if l.strip()]
|
lines = [json.loads(l) for l in open(fx, encoding="utf-8") if l.strip()]
|
||||||
head, cases = lines[0], lines[1:]
|
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-")
|
tmp = tempfile.mkdtemp(prefix="hook-corpus-")
|
||||||
try:
|
try:
|
||||||
home = os.path.join(tmp, "home")
|
total = good = known = 0
|
||||||
|
for n, (head, cases) in enumerate(segments(lines)):
|
||||||
|
home = os.path.join(tmp, "seg-%d" % n, "home")
|
||||||
try:
|
try:
|
||||||
build(home, head["layout"])
|
build(home, head["layout"])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("корпус хуков: песочница не собрана (%s)" % e)
|
print("корпус хуков: песочница сегмента %d не собрана (%s)" % (n + 1, e))
|
||||||
return 1
|
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 = dict(env0, HOME=home)
|
||||||
env["HOME"] = home
|
|
||||||
ok = 0
|
|
||||||
for c in cases:
|
for c in cases:
|
||||||
got = verdict(hooks, c, head["home"], home, env)
|
got = verdict(hooks, c, head["home"], home, env)
|
||||||
ok += got == c["expected"]
|
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"], "~")
|
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]])
|
row = "\t".join([c["id"], c["why"], c["expected"], got, st, " ".join(body.split())[:70]])
|
||||||
if not summary:
|
if not summary:
|
||||||
print(row)
|
print(row)
|
||||||
elif got != c["expected"]:
|
elif st in ("MISMATCH", "KNOWN-FIXED"):
|
||||||
print("MISMATCH " + row)
|
print(st + " " + row)
|
||||||
print(("корпус хуков: %d/%d" if summary else "total %d/%d") % (ok, len(cases)))
|
tail = ", известных ошибок %d" % known if known else ""
|
||||||
return 0 if ok == len(cases) else 1
|
print(("корпус хуков: %d/%d%s" if summary else "total %d/%d%s") % (good, total, tail))
|
||||||
|
return 0 if good == total else 1
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmp, ignore_errors=True)
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,55 @@ F="$ROOT/skills/harness-doctor/fixtures/hook-cases.jsonl"
|
|||||||
assert_file "hook_corpus.py существует" "$R"
|
assert_file "hook_corpus.py существует" "$R"
|
||||||
assert_file "корпус hook-cases.jsonl" "$F"
|
assert_file "корпус hook-cases.jsonl" "$F"
|
||||||
assert_eq "корпус совпадает со сборкой из выгрузки" "$(python3 "$ROOT/docs/retro/scripts/hook_cases_build.py" | cmp -s - "$F" && echo same)" "same"
|
assert_eq "корпус совпадает со сборкой из выгрузки" "$(python3 "$ROOT/docs/retro/scripts/hook_cases_build.py" | cmp -s - "$F" && echo same)" "same"
|
||||||
while IFS=$'\t' read -r id why exp got cmd; do
|
while IFS=$'\t' read -r id why exp got st cmd; do
|
||||||
case "$id" in total*) continue ;; esac
|
case "$id" in total*) continue ;; esac
|
||||||
assert_eq "корпус $id ($why): $cmd" "$got" "$exp"
|
case "$st" in
|
||||||
|
ok|KNOWN) ok "корпус $id ($why)" ;;
|
||||||
|
*) fail "корпус $id ($why): $cmd" "ожидалось $exp, получено $got ($st)" ;;
|
||||||
|
esac
|
||||||
done < <(python3 "$R" "$ROOT/hooks")
|
done < <(python3 "$R" "$ROOT/hooks")
|
||||||
|
# сегменты и known (#29): синтетический корпус из двух сегментов с разными home
|
||||||
|
S="$TMP/seg"; mkdir -p "$S"
|
||||||
|
cat > "$S/c.jsonl" <<'EOF'
|
||||||
|
{"home": "/h1", "layout": [{"path": "m", "branch": "main"}]}
|
||||||
|
{"id": "a1", "why": "main", "tool": "Write", "file_path": "/h1/m/a.md", "cwd": "/h1/m", "expected": "block"}
|
||||||
|
{"id": "a2", "why": "known-x", "tool": "Write", "file_path": "/h1/m/b.md", "cwd": "/h1/m", "expected": "allow", "known": "RT-21"}
|
||||||
|
{"home": "/h2", "layout": [{"path": "r", "branch": "feat/x"}]}
|
||||||
|
{"id": "b1", "why": "branch", "tool": "Write", "file_path": "/h2/r/a.md", "cwd": "/h2/r", "expected": "allow"}
|
||||||
|
EOF
|
||||||
|
out=$(python3 "$R" "$ROOT/hooks" --summary --fixtures "$S/c.jsonl"); rc=$?
|
||||||
|
assert_eq "два сегмента и известная ошибка: exit 0" "$rc" "0"
|
||||||
|
assert_eq "итог с известными ошибками" "$out" "корпус хуков: 3/3, известных ошибок 1"
|
||||||
|
sed 's/"expected": "allow", "known"/"expected": "block", "known"/' "$S/c.jsonl" > "$S/f.jsonl"
|
||||||
|
out=$(python3 "$R" "$ROOT/hooks" --summary --fixtures "$S/f.jsonl"); rc=$?
|
||||||
|
assert_eq "known совпал с вердиктом: exit 1" "$rc" "1"
|
||||||
|
assert_grep "KNOWN-FIXED с id записи" '^KNOWN-FIXED a2' <(printf '%s' "$out")
|
||||||
|
# разметка ретро (#29): hook-labels-<дата>.json + hook-blocks-<дата>.jsonl → новый сегмент корпуса
|
||||||
|
B="$ROOT/docs/retro/scripts/hook_cases_build.py"
|
||||||
|
FX="$TMP/fx"; mkdir -p "$FX"; cp "$ROOT/docs/retro/fixtures/hook-blocks.jsonl" "$FX/"
|
||||||
|
cat > "$FX/hook-blocks-2026-11-01.jsonl" <<'EOF'
|
||||||
|
{"id": "2026-11-01-001", "session": "s", "ts": "2026-11-02T10:00:00Z", "cwd": "/h3/r", "branch": "main", "tool": "Bash", "command": "ls x 2>/dev/null", "file_path": null, "hook": "guard-main(Bash)", "message": "BLOCKED", "expected": null}
|
||||||
|
{"id": "2026-11-01-002", "session": "s", "ts": "2026-11-02T10:01:00Z", "cwd": "/h3/r", "branch": "main", "tool": "Bash", "command": "echo x > f", "file_path": null, "hook": "guard-main(Bash)", "message": "BLOCKED", "expected": null}
|
||||||
|
{"id": "2026-11-01-003", "session": "s", "ts": "2026-11-02T10:02:00Z", "cwd": "/h3/r", "branch": "main", "tool": "Bash", "command": "cat $X", "file_path": null, "hook": "guard-main(Bash)", "message": "BLOCKED", "expected": null}
|
||||||
|
EOF
|
||||||
|
cat > "$FX/hook-labels-2026-11-01.json" <<'EOF'
|
||||||
|
{"home": "/h3", "layout": [{"path": "r", "branch": "main"}],
|
||||||
|
"cases": {"2026-11-01-001": {"expected": "allow", "why": "devnull"},
|
||||||
|
"2026-11-01-002": {"expected": "block", "why": "main-redirect"},
|
||||||
|
"2026-11-01-003": {"skip": "цель в переменной, состояние неизвестно"}}}
|
||||||
|
EOF
|
||||||
|
out=$(python3 "$B" --fixtures-dir "$FX"); rc=$?
|
||||||
|
assert_eq "сборка с разметкой ретро: exit 0" "$rc" "0"
|
||||||
|
assert_eq "в корпусе два сегмента" "$(printf '%s\n' "$out" | grep -c '"layout"')" "2"
|
||||||
|
assert_eq "сегмент ретро: две записи" "$(printf '%s\n' "$out" | grep -c '"2026-11-01-00[12]"')" "2"
|
||||||
|
assert_eq "запись со skip не в корпусе" "$(printf '%s\n' "$out" | grep -c '2026-11-01-003')" "0"
|
||||||
|
printf '%s\n' "$out" > "$FX/cases.jsonl"
|
||||||
|
assert_eq "корпус с сегментом ретро проходит хуки" "$(python3 "$R" "$ROOT/hooks" --summary --fixtures "$FX/cases.jsonl" | tail -1)" "корпус хуков: 131/131"
|
||||||
|
python3 - "$FX/hook-labels-2026-11-01.json" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
p = sys.argv[1]; d = json.load(open(p)); del d["cases"]["2026-11-01-002"]; d["cases"]["2026-11-01-009"] = {"skip": "x"}
|
||||||
|
json.dump(d, open(p, "w"))
|
||||||
|
PY
|
||||||
|
out=$(python3 "$B" --fixtures-dir "$FX" 2>&1 >/dev/null); rc=$?
|
||||||
|
assert_eq "разметка не совпадает с выгрузкой: exit 1" "$rc" "1"
|
||||||
|
assert_grep "сообщение называет недостающий и лишний id" '2026-11-01-002.*2026-11-01-009' <(printf '%s' "$out")
|
||||||
|
|||||||
@ -13,3 +13,13 @@ out=$(python3 "$X" "$T")
|
|||||||
assert_eq "две записи: вывод cat с BLOCKED не выгружен" "$(printf '%s\n' "$out" | grep -c .)" "2"
|
assert_eq "две записи: вывод cat с BLOCKED не выгружен" "$(printf '%s\n' "$out" | grep -c .)" "2"
|
||||||
assert_eq "новый префикс → guard-main(Bash)" "$(printf '%s\n' "$out" | sed -n 1p | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["hook"], r["command"])')" "guard-main(Bash) echo x > f"
|
assert_eq "новый префикс → guard-main(Bash)" "$(printf '%s\n' "$out" | sed -n 1p | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["hook"], r["command"])')" "guard-main(Bash) echo x > f"
|
||||||
assert_eq "старая фраза → guard-main(Edit/Write)" "$(printf '%s\n' "$out" | sed -n 2p | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["hook"], r["file_path"])')" "guard-main(Edit/Write) /r/a.md"
|
assert_eq "старая фраза → guard-main(Edit/Write)" "$(printf '%s\n' "$out" | sed -n 2p | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["hook"], r["file_path"])')" "guard-main(Edit/Write) /r/a.md"
|
||||||
|
# --since и --id-prefix (B12, #29): только сессии не раньше даты, id <дата>-NNN; модуль импортируется без выгрузки
|
||||||
|
T2="$TMP/tr2"; mkdir -p "$T2"
|
||||||
|
blk='{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"u1","is_error":true,"content":"PreToolUse:Bash hook error: BLOCKED [guard-main]: запись в f"}]}}'
|
||||||
|
printf '%s\n%s\n' '{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"2026-10-01T10:00:00Z","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo a > f"}}]}}' "$blk" > "$T2/old.jsonl"
|
||||||
|
printf '%s\n%s\n' '{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"2026-11-02T10:00:00Z","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo b > f"}}]}}' "$blk" > "$T2/new.jsonl"
|
||||||
|
out=$(python3 "$X" --since 2026-11-01 --id-prefix 2026-11-05 "$T2")
|
||||||
|
assert_eq "--since: только сессия не раньше даты" "$(printf '%s\n' "$out" | grep -c .)" "1"
|
||||||
|
assert_eq "--id-prefix: id <дата>-NNN" "$(printf '%s\n' "$out" | python3 -c 'import json,sys; r=json.loads(sys.stdin.readline()); print(r["id"], r["command"])')" "2026-11-05-001 echo b > f"
|
||||||
|
assert_eq "без флагов поля id нет (выгрузка 12.10 не меняется)" "$(python3 "$X" "$T2" | python3 -c 'import json,sys; print(sum("id" in json.loads(l) for l in sys.stdin))')" "0"
|
||||||
|
assert_eq "импорт модуля не выгружает" "$(python3 -c "import sys; sys.argv=['x', '$T2']; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import hook_blocks_export" 2>&1)" ""
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user