#!/usr/bin/env python3"""hookcheck.py - フック設定の到達可能性を静的に検査する"""import json, os, re, shlex, 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) except ValueError as exc: return None, f"シェル構文エラー: {exc}" if not argv: return None, "コマンドが空です" head = argv[0] if "/" in head: return (head, None) if os.access(head, os.X_OK) else (None, f"実行可能でないパス: {head}") for directory in os.environ.get("PATH", "").split(os.pathsep): candidate = os.path.join(directory, head) if os.access(candidate, os.X_OK): return candidate, None return None, f"PATH 上に見つかりません: {head}"def 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 main(): if len(sys.argv) < 3: print("usage: hookcheck.py <hooks.json> <tools.txt> [--fullmatch]", file=sys.stderr) return 2 semantics = "fullmatch" if "--fullmatch" in sys.argv else "search" with open(sys.argv[1], encoding="utf-8") as fp: config = json.load(fp) tools = load_tool_universe(sys.argv[2]) 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())