#!/usr/bin/env python3"""hookcheck.py - フック設定の到達可能性を静的に検査する"""import json, os, re, shlex, shutil, sys, timeKNOWN_EVENTS = { "PreToolUse", "PostToolUse", "UserPromptSubmit", "SessionStart", "SessionEnd", "Stop", "Notification",}TOOL_EVENTS = {"PreToolUse", "PostToolUse"}def load_tool_universe(path): with open(path, encoding="utf-8") as fp: return [line.strip() for line in fp if line.strip() and not line.startswith("#")]def resolve_command(command): try: argv = shlex.split(command, posix=(os.name != "nt")) except ValueError as exc: return None, f"シェル構文エラー: {exc}" if not argv: return None, "コマンドが空です" head = argv[0].strip('"') found = shutil.which(head) if found is None: if os.sep in head or (os.altsep and os.altsep in head): return None, f"実行可能なファイルではありません: {head}" return None, f"PATH 上に見つかりません: {head}" return found, Nonedef check(config, tools, semantics="search"): findings = [] for event, groups in config.get("hooks", {}).items(): if event not in KNOWN_EVENTS: findings.append(("DEAD", event, None, f"未知のイベント名: {event}")) continue for index, group in enumerate(groups): where = f"{event}[{index}]" matcher = group.get("matcher") if event in TOOL_EVENTS: if matcher is None: findings.append(("DEAD", where, None, "matcher が未定義")) else: try: rx = re.compile(matcher) except re.error as exc: findings.append(("DEAD", where, matcher, f"正規表現として不正: {exc}")) rx = None if rx is not None: test = rx.fullmatch if semantics == "fullmatch" else rx.search hits = [t for t in tools if test(t)] if not hits: findings.append(("DEAD", where, matcher, "どのツール名にも一致しません")) elif matcher != ".*" and len(hits) > 1 and not matcher.startswith("^"): findings.append(("WIDE", where, matcher, f"{len(hits)} 個に一致: {', '.join(hits)}")) elif matcher not in (None, "", ".*"): findings.append(("DEAD", where, matcher, f"{event} はツール名を持たないため matcher は評価されません")) for hook in group.get("hooks", []): command = hook.get("command", "") _, error = resolve_command(command) if error: findings.append(("DEAD", where, command, error)) return findingsdef hit_counts(config, tools, semantics): counts = {} for event, groups in config.get("hooks", {}).items(): if event not in TOOL_EVENTS: continue for index, group in enumerate(groups): matcher = group.get("matcher") if matcher is None: continue try: rx = re.compile(matcher) except re.error: continue test = rx.fullmatch if semantics == "fullmatch" else rx.search counts[f"{event}[{index}]"] = (matcher, sum(1 for t in tools if test(t))) return countsdef report_divergence(config, tools): left = hit_counts(config, tools, "search") right = hit_counts(config, tools, "fullmatch") diverged = [(w, m, a, right[w][1]) for w, (m, a) in left.items() if right[w][1] != a] for where, matcher, a, b in diverged: print(f"~ {where} [{matcher}] search={a} fullmatch={b} 照合方式に依存しています") print(f"\nDIVERGENT={len(diverged)}/{len(left)}") return 1 if diverged else 0def main(): if len(sys.argv) < 3: print("usage: hookcheck.py <hooks.json> <tools.txt> [--fullmatch|--both]", file=sys.stderr) return 2 with open(sys.argv[1], encoding="utf-8") as fp: config = json.load(fp) tools = load_tool_universe(sys.argv[2]) if "--both" in sys.argv: return report_divergence(config, tools) semantics = "fullmatch" if "--fullmatch" in sys.argv else "search" started = time.perf_counter() findings = check(config, tools, semantics) elapsed_ms = (time.perf_counter() - started) * 1000 dead = sum(1 for f in findings if f[0] == "DEAD") for level, where, subject, message in findings: mark = "x" if level == "DEAD" else "!" subject_text = f" [{subject}]" if subject else "" print(f"{mark} {where}{subject_text} {message}") print(f"\nDEAD={dead} WIDE={len(findings) - dead} semantics={semantics} {elapsed_ms:.2f}ms") return 1 if dead else 0if __name__ == "__main__": sys.exit(main())