diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6e4dfce..11f45573 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,9 @@ jobs: - name: hook e2e coverage gate (positive + negative test per detector) run: python3 scripts/check_hook_test_coverage.py + - name: silent hook exception gate + run: python3 scripts/check_no_silent_hook_except.py + - name: rule wiring gate (every phrase checker has a caller or a stated reason) run: python3 scripts/check_rules_are_wired.py diff --git a/engine/hooks/answer-overrides-menu/claude_posttooluse.py b/engine/hooks/answer-overrides-menu/claude_posttooluse.py index 69828f55..1a4b5679 100755 --- a/engine/hooks/answer-overrides-menu/claude_posttooluse.py +++ b/engine/hooks/answer-overrides-menu/claude_posttooluse.py @@ -21,7 +21,8 @@ def main() -> None: return try: text = decide(payload) - except Exception: + except Exception as exc: + print(f"catstack-hook-error answer-overrides-menu: {type(exc).__name__}: {exc}", file=sys.stderr) return if not text: return diff --git a/engine/hooks/auto-pr/claude_stop_autopr.py b/engine/hooks/auto-pr/claude_stop_autopr.py index 83699786..0f4c4f7d 100644 --- a/engine/hooks/auto-pr/claude_stop_autopr.py +++ b/engine/hooks/auto-pr/claude_stop_autopr.py @@ -30,7 +30,8 @@ def main() -> None: deliver=False, debounce=True, ) - except Exception: + except Exception as exc: + print(f"catstack-hook-error auto-pr: {type(exc).__name__}: {exc}", file=sys.stderr) return if not message: return diff --git a/engine/hooks/auto-pr/cursor_session.py b/engine/hooks/auto-pr/cursor_session.py index 7ca01125..ef0c3c06 100644 --- a/engine/hooks/auto-pr/cursor_session.py +++ b/engine/hooks/auto-pr/cursor_session.py @@ -24,7 +24,8 @@ def main() -> None: payload if isinstance(payload, dict) else {}, argv=sys.argv[1:], ) - except Exception: + except Exception as exc: + print(f"catstack-hook-error auto-pr: {type(exc).__name__}: {exc}", file=sys.stderr) print(json.dumps({"followup_message": ""})) return print(json.dumps({"followup_message": message or ""})) diff --git a/engine/hooks/bug-complaint-leak/claude_posttooluse.py b/engine/hooks/bug-complaint-leak/claude_posttooluse.py index 6588431a..d9e3c046 100644 --- a/engine/hooks/bug-complaint-leak/claude_posttooluse.py +++ b/engine/hooks/bug-complaint-leak/claude_posttooluse.py @@ -96,7 +96,8 @@ def main() -> None: return try: process(payload) - except Exception: + except Exception as exc: + print(f"catstack-hook-error bug-complaint-leak: {type(exc).__name__}: {exc}", file=sys.stderr) return diff --git a/engine/hooks/bug-complaint-leak/claude_pretooluse_grep.py b/engine/hooks/bug-complaint-leak/claude_pretooluse_grep.py index 1d908c85..68acabe4 100644 --- a/engine/hooks/bug-complaint-leak/claude_pretooluse_grep.py +++ b/engine/hooks/bug-complaint-leak/claude_pretooluse_grep.py @@ -72,7 +72,8 @@ def main() -> None: from state import save_state save_state(payload, state) - except Exception: + except Exception as exc: + print(f"catstack-hook-error bug-complaint-leak: {type(exc).__name__}: {exc}", file=sys.stderr) return diff --git a/engine/hooks/bug-complaint-leak/claude_prompt_submit.py b/engine/hooks/bug-complaint-leak/claude_prompt_submit.py index f419a6c5..ce75bbd5 100644 --- a/engine/hooks/bug-complaint-leak/claude_prompt_submit.py +++ b/engine/hooks/bug-complaint-leak/claude_prompt_submit.py @@ -34,7 +34,8 @@ def main() -> None: } ) ) - except Exception: + except Exception as exc: + print(f"catstack-hook-error bug-complaint-leak: {type(exc).__name__}: {exc}", file=sys.stderr) return diff --git a/engine/hooks/bug-complaint-leak/cursor_before_submit.py b/engine/hooks/bug-complaint-leak/cursor_before_submit.py index 3c6e5285..8333171a 100644 --- a/engine/hooks/bug-complaint-leak/cursor_before_submit.py +++ b/engine/hooks/bug-complaint-leak/cursor_before_submit.py @@ -26,7 +26,8 @@ def main() -> None: quotes = extract_quoted_symptoms(prompt) remember_bug_complaint(payload, prompt, quotes, checklist) print(json.dumps({"continue": True})) - except Exception: + except Exception as exc: + print(f"catstack-hook-error bug-complaint-leak: {type(exc).__name__}: {exc}", file=sys.stderr) print(json.dumps({"continue": True})) diff --git a/engine/hooks/bug-complaint-leak/cursor_post_tool_use.py b/engine/hooks/bug-complaint-leak/cursor_post_tool_use.py index f053b9f3..7021d1dc 100644 --- a/engine/hooks/bug-complaint-leak/cursor_post_tool_use.py +++ b/engine/hooks/bug-complaint-leak/cursor_post_tool_use.py @@ -26,7 +26,8 @@ def main() -> None: state["cursor_checklist_pending"] = False save_state(payload, state) print(json.dumps({"additional_context": checklist})) - except Exception: + except Exception as exc: + print(f"catstack-hook-error bug-complaint-leak: {type(exc).__name__}: {exc}", file=sys.stderr) return diff --git a/engine/hooks/build-the-lever/claude_posttooluse.py b/engine/hooks/build-the-lever/claude_posttooluse.py index 35410bcb..53866ca8 100644 --- a/engine/hooks/build-the-lever/claude_posttooluse.py +++ b/engine/hooks/build-the-lever/claude_posttooluse.py @@ -30,7 +30,8 @@ def main() -> None: } ) ) - except Exception: + except Exception as exc: + print(f"catstack-hook-error build-the-lever: {type(exc).__name__}: {exc}", file=sys.stderr) return diff --git a/engine/hooks/build-the-lever/claude_prompt_submit.py b/engine/hooks/build-the-lever/claude_prompt_submit.py index c39c7a80..01fcbbf5 100644 --- a/engine/hooks/build-the-lever/claude_prompt_submit.py +++ b/engine/hooks/build-the-lever/claude_prompt_submit.py @@ -30,7 +30,8 @@ def main() -> None: } ) ) - except Exception: + except Exception as exc: + print(f"catstack-hook-error build-the-lever: {type(exc).__name__}: {exc}", file=sys.stderr) return diff --git a/engine/hooks/build-the-lever/codex_posttooluse.py b/engine/hooks/build-the-lever/codex_posttooluse.py index 894075ca..d4028f04 100644 --- a/engine/hooks/build-the-lever/codex_posttooluse.py +++ b/engine/hooks/build-the-lever/codex_posttooluse.py @@ -30,7 +30,8 @@ def main() -> None: } ) ) - except Exception: + except Exception as exc: + print(f"catstack-hook-error build-the-lever: {type(exc).__name__}: {exc}", file=sys.stderr) return diff --git a/engine/hooks/build-the-lever/codex_prompt_submit.py b/engine/hooks/build-the-lever/codex_prompt_submit.py index 79659ecc..23c68fb8 100644 --- a/engine/hooks/build-the-lever/codex_prompt_submit.py +++ b/engine/hooks/build-the-lever/codex_prompt_submit.py @@ -30,7 +30,8 @@ def main() -> None: } ) ) - except Exception: + except Exception as exc: + print(f"catstack-hook-error build-the-lever: {type(exc).__name__}: {exc}", file=sys.stderr) return diff --git a/engine/hooks/build-the-lever/cursor_before_submit.py b/engine/hooks/build-the-lever/cursor_before_submit.py index 8829d221..f22df18c 100644 --- a/engine/hooks/build-the-lever/cursor_before_submit.py +++ b/engine/hooks/build-the-lever/cursor_before_submit.py @@ -21,7 +21,8 @@ def main() -> None: if is_bulk_work(extract_prompt_text(payload if isinstance(payload, dict) else {})): remember_bulk_prompt(payload) print(json.dumps({"continue": True})) - except Exception: + except Exception as exc: + print(f"catstack-hook-error build-the-lever: {type(exc).__name__}: {exc}", file=sys.stderr) print(json.dumps({"continue": True})) diff --git a/engine/hooks/build-the-lever/cursor_post_tool_use.py b/engine/hooks/build-the-lever/cursor_post_tool_use.py index f430b654..6d02a1c0 100644 --- a/engine/hooks/build-the-lever/cursor_post_tool_use.py +++ b/engine/hooks/build-the-lever/cursor_post_tool_use.py @@ -25,7 +25,8 @@ def main() -> None: record_file_mutation(payload if isinstance(payload, dict) else {}) if consume_prompt_pending(payload) or should_inject_for_edits(payload): print(json.dumps({"additional_context": reminder_text()})) - except Exception: + except Exception as exc: + print(f"catstack-hook-error build-the-lever: {type(exc).__name__}: {exc}", file=sys.stderr) return diff --git a/engine/hooks/cat-mode-default/claude_pretooluse_agent.py b/engine/hooks/cat-mode-default/claude_pretooluse_agent.py index e2cc7239..28731a8d 100644 --- a/engine/hooks/cat-mode-default/claude_pretooluse_agent.py +++ b/engine/hooks/cat-mode-default/claude_pretooluse_agent.py @@ -24,7 +24,8 @@ def main() -> None: return try: updated = agent_updated_input(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error cat-mode-default: {type(exc).__name__}: {exc}", file=sys.stderr) return if updated is None: return diff --git a/engine/hooks/cat-mode-default/claude_prompt_submit.py b/engine/hooks/cat-mode-default/claude_prompt_submit.py index d94e6b1d..ed0911d4 100644 --- a/engine/hooks/cat-mode-default/claude_prompt_submit.py +++ b/engine/hooks/cat-mode-default/claude_prompt_submit.py @@ -19,7 +19,8 @@ def main() -> None: return try: context = decide(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error cat-mode-default: {type(exc).__name__}: {exc}", file=sys.stderr) return if context is None: return diff --git a/engine/hooks/categorical-scope-guard/detect.py b/engine/hooks/categorical-scope-guard/detect.py index 5c5b3d50..bc0f1912 100644 --- a/engine/hooks/categorical-scope-guard/detect.py +++ b/engine/hooks/categorical-scope-guard/detect.py @@ -12,6 +12,7 @@ mutation by status but the filter values or the human turns could not be read. """ from __future__ import annotations +import sys import json import os @@ -830,6 +831,7 @@ def decide(command: str, window_loader) -> Verdict: try: found = scoped_mutation_filters(command) except Exception as exc: + print(f"catstack-hook-error categorical-scope-guard: {type(exc).__name__}: {exc}", file=sys.stderr) return Verdict(UNCHECKED, ( f"categorical-scope-guard: UNCHECKED -- the command parser failed ({exc!r}) on a command that " "mentions a status or state. Blocked rather than passed; drop the status filter or rephrase the command." diff --git a/engine/hooks/external-claim-gate/claude_pretooluse.py b/engine/hooks/external-claim-gate/claude_pretooluse.py index cf5009e7..b533a938 100644 --- a/engine/hooks/external-claim-gate/claude_pretooluse.py +++ b/engine/hooks/external-claim-gate/claude_pretooluse.py @@ -42,6 +42,7 @@ def main() -> None: try: findings = evaluate(command, cwd) except Exception as exc: + print(f"catstack-hook-error external-claim-gate: {type(exc).__name__}: {exc}", file=sys.stderr) _refuse_if_destination(command, f"the detector failed ({exc!r})") return if findings: diff --git a/engine/hooks/frustration-watchdog/claude_stop_check.py b/engine/hooks/frustration-watchdog/claude_stop_check.py index 3284059e..89a01493 100755 --- a/engine/hooks/frustration-watchdog/claude_stop_check.py +++ b/engine/hooks/frustration-watchdog/claude_stop_check.py @@ -217,7 +217,8 @@ def main(): try: msgs = human_user_messages(transcript_path) kinds = impatience_kinds(msgs) - except Exception: + except Exception as exc: + print(f"catstack-hook-error frustration-watchdog: {type(exc).__name__}: {exc}", file=sys.stderr) return # fail open: a broken watchdog must never brick a session if not kinds: return @@ -227,6 +228,7 @@ def main(): refused = turn_has_hook_refusal(transcript_path) unchecked = None except Exception as e: + print(f"catstack-hook-error frustration-watchdog: {type(e).__name__}: {e}", file=sys.stderr) refused = False unchecked = f"{type(e).__name__}: {e}" head = ( diff --git a/engine/hooks/frustration-watchdog/tests/test_hooks.py b/engine/hooks/frustration-watchdog/tests/test_hooks.py index f4338369..cac956e7 100755 --- a/engine/hooks/frustration-watchdog/tests/test_hooks.py +++ b/engine/hooks/frustration-watchdog/tests/test_hooks.py @@ -318,7 +318,8 @@ def test_unreadable_tool_results_use_default_wording_and_say_so(self): with patch.object(claude_stop_check, "turn_has_hook_refusal", side_effect=OSError("disk gone")): blocked, err = self.run_lines([human(WAITING)] + tool_turn(HOOK_REFUSAL_TEXT)) self.assertTrue(blocked) - self.assertTrue(err.startswith(DEFAULT_WAITING_FEEDBACK)) + self.assertTrue(err.startswith("catstack-hook-error frustration-watchdog: OSError: disk gone\n")) + self.assertIn(DEFAULT_WAITING_FEEDBACK, err) self.assertIn("could not read this turn's tool results (OSError: disk gone)", err) diff --git a/engine/hooks/hook-freshness/claude_prompt_submit.py b/engine/hooks/hook-freshness/claude_prompt_submit.py index 3cc5d026..cbec4272 100644 --- a/engine/hooks/hook-freshness/claude_prompt_submit.py +++ b/engine/hooks/hook-freshness/claude_prompt_submit.py @@ -18,7 +18,8 @@ def main() -> None: return try: out = decide_json(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error hook-freshness: {type(exc).__name__}: {exc}", file=sys.stderr) return if out: print(out) diff --git a/engine/hooks/llm-judge/judge.py b/engine/hooks/llm-judge/judge.py index cc53ad82..a81f19b7 100644 --- a/engine/hooks/llm-judge/judge.py +++ b/engine/hooks/llm-judge/judge.py @@ -218,6 +218,7 @@ def run_job(path: str) -> dict: job.setdefault("id", stem) result = verdict(job, ask(str(job["prompt"]))) except Exception as exc: + print(f"catstack-hook-error llm-judge: {type(exc).__name__}: {exc}", file=sys.stderr) log(f"job {job.get('id')} failed: {type(exc).__name__}: {exc}\n{traceback.format_exc()}") result = verdict(job, {"outcome": "unchecked", "attempts": []}) result["reason"] = clip(f"judge error {type(exc).__name__}", str(exc)) diff --git a/engine/hooks/named-verb-guard/claude_stop_check.py b/engine/hooks/named-verb-guard/claude_stop_check.py index bda9b140..99c6b3d7 100755 --- a/engine/hooks/named-verb-guard/claude_stop_check.py +++ b/engine/hooks/named-verb-guard/claude_stop_check.py @@ -20,7 +20,8 @@ def main() -> None: return try: message = decide(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error named-verb-guard: {type(exc).__name__}: {exc}", file=sys.stderr) return if not message: return diff --git a/engine/hooks/narrow-the-scope/claude_posttooluse.py b/engine/hooks/narrow-the-scope/claude_posttooluse.py index c2a96560..baf3655c 100644 --- a/engine/hooks/narrow-the-scope/claude_posttooluse.py +++ b/engine/hooks/narrow-the-scope/claude_posttooluse.py @@ -17,7 +17,8 @@ def main() -> None: return try: text = observe(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error narrow-the-scope: {type(exc).__name__}: {exc}", file=sys.stderr) return if not text: return diff --git a/engine/hooks/no-comments/claude_pretooluse.py b/engine/hooks/no-comments/claude_pretooluse.py index a2603989..fecfd2f7 100644 --- a/engine/hooks/no-comments/claude_pretooluse.py +++ b/engine/hooks/no-comments/claude_pretooluse.py @@ -17,7 +17,8 @@ def main() -> None: return try: message = decide(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error no-comments: {type(exc).__name__}: {exc}", file=sys.stderr) return if not message: return diff --git a/engine/hooks/playbook-router/claude_prompt_submit.py b/engine/hooks/playbook-router/claude_prompt_submit.py index 9a00048e..8e400af4 100644 --- a/engine/hooks/playbook-router/claude_prompt_submit.py +++ b/engine/hooks/playbook-router/claude_prompt_submit.py @@ -14,7 +14,8 @@ def main() -> None: return try: context = decide(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error playbook-router: {type(exc).__name__}: {exc}", file=sys.stderr) return if context is None: return diff --git a/engine/hooks/prove-it-ship-gate/claude_stop_check.py b/engine/hooks/prove-it-ship-gate/claude_stop_check.py index 49c87088..b8846281 100644 --- a/engine/hooks/prove-it-ship-gate/claude_stop_check.py +++ b/engine/hooks/prove-it-ship-gate/claude_stop_check.py @@ -20,7 +20,8 @@ def main() -> None: return try: message = decide(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error prove-it-ship-gate: {type(exc).__name__}: {exc}", file=sys.stderr) return if not message: return diff --git a/engine/hooks/reflect-on-thrash/claude_stop_reflect.py b/engine/hooks/reflect-on-thrash/claude_stop_reflect.py index 97750e32..051cb0cf 100644 --- a/engine/hooks/reflect-on-thrash/claude_stop_reflect.py +++ b/engine/hooks/reflect-on-thrash/claude_stop_reflect.py @@ -23,7 +23,8 @@ def main() -> None: message = decide( payload if isinstance(payload, dict) else {}, ) - except Exception: + except Exception as exc: + print(f"catstack-hook-error reflect-on-thrash: {type(exc).__name__}: {exc}", file=sys.stderr) return if message: sys.stderr.write(message + "\n") diff --git a/engine/hooks/reflect-on-thrash/cursor_session.py b/engine/hooks/reflect-on-thrash/cursor_session.py index e7c9515c..e38081da 100644 --- a/engine/hooks/reflect-on-thrash/cursor_session.py +++ b/engine/hooks/reflect-on-thrash/cursor_session.py @@ -26,7 +26,8 @@ def main() -> None: payload if isinstance(payload, dict) else {}, argv=sys.argv[1:], ) - except Exception: + except Exception as exc: + print(f"catstack-hook-error reflect-on-thrash: {type(exc).__name__}: {exc}", file=sys.stderr) print(json.dumps({"followup_message": ""})) return print(json.dumps({"followup_message": message or ""})) diff --git a/engine/hooks/reflect-on-thrash/detect.py b/engine/hooks/reflect-on-thrash/detect.py index 0beabc99..367163d3 100644 --- a/engine/hooks/reflect-on-thrash/detect.py +++ b/engine/hooks/reflect-on-thrash/detect.py @@ -144,7 +144,8 @@ def thrash_hits(path: str) -> list[str]: result = token_audit.audit_codex(path) return _hits_from_flags(list(result.get("flags") or [])) return _cursor_duplicate_hits(path) - except Exception: + except Exception as exc: + print(f"catstack-hook-error reflect-on-thrash: {type(exc).__name__}: {exc}", file=sys.stderr) return [] diff --git a/engine/hooks/repeat-error-stop/claude_posttooluse.py b/engine/hooks/repeat-error-stop/claude_posttooluse.py index 49624836..4fae4648 100644 --- a/engine/hooks/repeat-error-stop/claude_posttooluse.py +++ b/engine/hooks/repeat-error-stop/claude_posttooluse.py @@ -16,7 +16,8 @@ def main() -> None: try: payload = json.load(sys.stdin) kind, reason = record_result(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error repeat-error-stop: {type(exc).__name__}: {exc}", file=sys.stderr) return if kind == "block": print(json.dumps({ diff --git a/engine/hooks/repeat-error-stop/claude_pretooluse.py b/engine/hooks/repeat-error-stop/claude_pretooluse.py index 76e0f37d..8ee5322a 100644 --- a/engine/hooks/repeat-error-stop/claude_pretooluse.py +++ b/engine/hooks/repeat-error-stop/claude_pretooluse.py @@ -12,7 +12,8 @@ def main() -> None: try: payload = json.load(sys.stdin) blocked, reason = tool_block_reason(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error repeat-error-stop: {type(exc).__name__}: {exc}", file=sys.stderr) return if blocked: sys.stderr.write(reason + "\n") diff --git a/engine/hooks/repeat-error-stop/claude_prompt_reset.py b/engine/hooks/repeat-error-stop/claude_prompt_reset.py index 7730b272..34dd9b71 100644 --- a/engine/hooks/repeat-error-stop/claude_prompt_reset.py +++ b/engine/hooks/repeat-error-stop/claude_prompt_reset.py @@ -12,7 +12,8 @@ def main() -> None: try: payload = json.load(sys.stdin) handle_prompt(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error repeat-error-stop: {type(exc).__name__}: {exc}", file=sys.stderr) return diff --git a/engine/hooks/repeat-error-stop/codex_posttooluse.py b/engine/hooks/repeat-error-stop/codex_posttooluse.py index 9f68b605..5252870e 100644 --- a/engine/hooks/repeat-error-stop/codex_posttooluse.py +++ b/engine/hooks/repeat-error-stop/codex_posttooluse.py @@ -12,7 +12,8 @@ def main() -> None: try: payload = json.load(sys.stdin) kind, reason = record_result(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error repeat-error-stop: {type(exc).__name__}: {exc}", file=sys.stderr) return if kind == "block": print(json.dumps({ diff --git a/engine/hooks/repeat-error-stop/codex_pretooluse.py b/engine/hooks/repeat-error-stop/codex_pretooluse.py index e9f9be38..13c5ad10 100644 --- a/engine/hooks/repeat-error-stop/codex_pretooluse.py +++ b/engine/hooks/repeat-error-stop/codex_pretooluse.py @@ -12,7 +12,8 @@ def main() -> None: try: payload = json.load(sys.stdin) blocked, reason = tool_block_reason(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error repeat-error-stop: {type(exc).__name__}: {exc}", file=sys.stderr) return if blocked: print(json.dumps({ diff --git a/engine/hooks/repeat-error-stop/codex_prompt_reset.py b/engine/hooks/repeat-error-stop/codex_prompt_reset.py index eed9a447..35358878 100644 --- a/engine/hooks/repeat-error-stop/codex_prompt_reset.py +++ b/engine/hooks/repeat-error-stop/codex_prompt_reset.py @@ -12,7 +12,8 @@ def main() -> None: try: payload = json.load(sys.stdin) handle_prompt(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error repeat-error-stop: {type(exc).__name__}: {exc}", file=sys.stderr) return diff --git a/engine/hooks/repeat-error-stop/cursor_before_submit.py b/engine/hooks/repeat-error-stop/cursor_before_submit.py index 1f9e42bc..57526937 100644 --- a/engine/hooks/repeat-error-stop/cursor_before_submit.py +++ b/engine/hooks/repeat-error-stop/cursor_before_submit.py @@ -12,7 +12,8 @@ def main() -> None: try: payload = json.load(sys.stdin) handle_prompt(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error repeat-error-stop: {type(exc).__name__}: {exc}", file=sys.stderr) pass print(json.dumps({"continue": True})) diff --git a/engine/hooks/repeat-error-stop/cursor_post_tool_use.py b/engine/hooks/repeat-error-stop/cursor_post_tool_use.py index 8141997a..8deb3d55 100644 --- a/engine/hooks/repeat-error-stop/cursor_post_tool_use.py +++ b/engine/hooks/repeat-error-stop/cursor_post_tool_use.py @@ -12,7 +12,8 @@ def main() -> None: try: payload = json.load(sys.stdin) kind, reason = record_result(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error repeat-error-stop: {type(exc).__name__}: {exc}", file=sys.stderr) return if kind in ("block", "nudge"): print(json.dumps({"additional_context": reason})) diff --git a/engine/hooks/repeat-error-stop/cursor_pretool.py b/engine/hooks/repeat-error-stop/cursor_pretool.py index 38232cd2..49acd115 100644 --- a/engine/hooks/repeat-error-stop/cursor_pretool.py +++ b/engine/hooks/repeat-error-stop/cursor_pretool.py @@ -12,7 +12,8 @@ def main() -> None: try: payload = json.load(sys.stdin) blocked, reason = tool_block_reason(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error repeat-error-stop: {type(exc).__name__}: {exc}", file=sys.stderr) blocked, reason = False, "" if blocked: print(json.dumps({"continue": False, "user_message": reason})) diff --git a/engine/hooks/restart-risk-check/claude_stop_restart_check.py b/engine/hooks/restart-risk-check/claude_stop_restart_check.py index 150144dd..41c8f9ba 100644 --- a/engine/hooks/restart-risk-check/claude_stop_restart_check.py +++ b/engine/hooks/restart-risk-check/claude_stop_restart_check.py @@ -20,7 +20,8 @@ def main() -> None: return try: message = decide(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error restart-risk-check: {type(exc).__name__}: {exc}", file=sys.stderr) return if not message: return diff --git a/engine/hooks/restated-constraint/claude_prompt_submit.py b/engine/hooks/restated-constraint/claude_prompt_submit.py index 4229e55a..eaa26157 100755 --- a/engine/hooks/restated-constraint/claude_prompt_submit.py +++ b/engine/hooks/restated-constraint/claude_prompt_submit.py @@ -18,7 +18,8 @@ def main() -> None: return try: context = decide(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error restated-constraint: {type(exc).__name__}: {exc}", file=sys.stderr) return if not context: return diff --git a/engine/hooks/scope-lock/claude_pretool_scope.py b/engine/hooks/scope-lock/claude_pretool_scope.py index 35ab215d..c85dc1be 100644 --- a/engine/hooks/scope-lock/claude_pretool_scope.py +++ b/engine/hooks/scope-lock/claude_pretool_scope.py @@ -12,7 +12,8 @@ def main() -> None: try: payload = json.load(sys.stdin) blocked, reason = tool_block_reason(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error scope-lock: {type(exc).__name__}: {exc}", file=sys.stderr) return if blocked: sys.stderr.write(reason + "\n") diff --git a/engine/hooks/scope-lock/claude_prompt_scope.py b/engine/hooks/scope-lock/claude_prompt_scope.py index c4f03d76..86dd0c75 100644 --- a/engine/hooks/scope-lock/claude_prompt_scope.py +++ b/engine/hooks/scope-lock/claude_prompt_scope.py @@ -20,7 +20,8 @@ def main() -> None: "additionalContext": instruction, } })) - except Exception: + except Exception as exc: + print(f"catstack-hook-error scope-lock: {type(exc).__name__}: {exc}", file=sys.stderr) return diff --git a/engine/hooks/scope-lock/codex_pretool_scope.py b/engine/hooks/scope-lock/codex_pretool_scope.py index 28c8ce5d..49fcb371 100644 --- a/engine/hooks/scope-lock/codex_pretool_scope.py +++ b/engine/hooks/scope-lock/codex_pretool_scope.py @@ -12,7 +12,8 @@ def main() -> None: try: payload = json.load(sys.stdin) blocked, reason = tool_block_reason(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error scope-lock: {type(exc).__name__}: {exc}", file=sys.stderr) return if blocked: print(json.dumps({ diff --git a/engine/hooks/scope-lock/codex_prompt_scope.py b/engine/hooks/scope-lock/codex_prompt_scope.py index 5f9d0256..d5f37d41 100644 --- a/engine/hooks/scope-lock/codex_prompt_scope.py +++ b/engine/hooks/scope-lock/codex_prompt_scope.py @@ -20,7 +20,8 @@ def main() -> None: "additionalContext": instruction, } })) - except Exception: + except Exception as exc: + print(f"catstack-hook-error scope-lock: {type(exc).__name__}: {exc}", file=sys.stderr) return diff --git a/engine/hooks/scope-lock/cursor_before_submit.py b/engine/hooks/scope-lock/cursor_before_submit.py index edb1b8f0..de612a96 100644 --- a/engine/hooks/scope-lock/cursor_before_submit.py +++ b/engine/hooks/scope-lock/cursor_before_submit.py @@ -12,7 +12,8 @@ def main() -> None: try: payload = json.load(sys.stdin) process_prompt(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error scope-lock: {type(exc).__name__}: {exc}", file=sys.stderr) pass print(json.dumps({"continue": True})) diff --git a/engine/hooks/scope-lock/cursor_pretool_scope.py b/engine/hooks/scope-lock/cursor_pretool_scope.py index 603780da..74bb901c 100644 --- a/engine/hooks/scope-lock/cursor_pretool_scope.py +++ b/engine/hooks/scope-lock/cursor_pretool_scope.py @@ -12,7 +12,8 @@ def main() -> None: try: payload = json.load(sys.stdin) blocked, reason = tool_block_reason(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error scope-lock: {type(exc).__name__}: {exc}", file=sys.stderr) blocked, reason = False, "" if blocked: print(json.dumps({"continue": False, "user_message": reason})) diff --git a/engine/hooks/split-scope/claude_prompt_submit.py b/engine/hooks/split-scope/claude_prompt_submit.py index 988b6033..19809081 100755 --- a/engine/hooks/split-scope/claude_prompt_submit.py +++ b/engine/hooks/split-scope/claude_prompt_submit.py @@ -31,7 +31,8 @@ def main() -> None: } ) ) - except Exception: + except Exception as exc: + print(f"catstack-hook-error split-scope: {type(exc).__name__}: {exc}", file=sys.stderr) _fail_open("prompt detection") diff --git a/engine/hooks/split-scope/codex_prompt_submit.py b/engine/hooks/split-scope/codex_prompt_submit.py index cbd7e960..d4423341 100755 --- a/engine/hooks/split-scope/codex_prompt_submit.py +++ b/engine/hooks/split-scope/codex_prompt_submit.py @@ -31,7 +31,8 @@ def main() -> None: } ) ) - except Exception: + except Exception as exc: + print(f"catstack-hook-error split-scope: {type(exc).__name__}: {exc}", file=sys.stderr) _fail_open("prompt detection") diff --git a/engine/hooks/split-scope/cursor_before_submit.py b/engine/hooks/split-scope/cursor_before_submit.py index 622244fd..d2839840 100755 --- a/engine/hooks/split-scope/cursor_before_submit.py +++ b/engine/hooks/split-scope/cursor_before_submit.py @@ -19,7 +19,8 @@ def main() -> None: payload = json.load(sys.stdin) if isinstance(payload, dict) and plans_multi_slice_work(extract_prompt_text(payload)): remember_cursor_prompt(payload) - except Exception: + except Exception as exc: + print(f"catstack-hook-error split-scope: {type(exc).__name__}: {exc}", file=sys.stderr) _fail_open("prompt detection") diff --git a/engine/hooks/split-scope/cursor_post_tool_use.py b/engine/hooks/split-scope/cursor_post_tool_use.py index 25b6ae42..e0586ba0 100755 --- a/engine/hooks/split-scope/cursor_post_tool_use.py +++ b/engine/hooks/split-scope/cursor_post_tool_use.py @@ -21,7 +21,8 @@ def main() -> None: return if consume_cursor_prompt(payload): print(json.dumps({"additional_context": reminder_text()})) - except Exception: + except Exception as exc: + print(f"catstack-hook-error split-scope: {type(exc).__name__}: {exc}", file=sys.stderr) _fail_open("pending reminder delivery") diff --git a/engine/hooks/verdict-flip-watch/claude_stop_check.py b/engine/hooks/verdict-flip-watch/claude_stop_check.py index b5a8ddd6..e2521d7d 100644 --- a/engine/hooks/verdict-flip-watch/claude_stop_check.py +++ b/engine/hooks/verdict-flip-watch/claude_stop_check.py @@ -19,7 +19,8 @@ def main() -> None: return try: message = decide(payload if isinstance(payload, dict) else {}) - except Exception: + except Exception as exc: + print(f"catstack-hook-error verdict-flip-watch: {type(exc).__name__}: {exc}", file=sys.stderr) return if message: sys.stderr.write(message + "\n") diff --git a/engine/hooks/verdict-flip-watch/detect.py b/engine/hooks/verdict-flip-watch/detect.py index 8ad2180a..c0af44bf 100644 --- a/engine/hooks/verdict-flip-watch/detect.py +++ b/engine/hooks/verdict-flip-watch/detect.py @@ -28,6 +28,7 @@ wrong-check-reflect vocabulary. """ from __future__ import annotations +import sys import hashlib import json @@ -201,7 +202,8 @@ def decide(payload: dict) -> str | None: return None try: target = find_flip(transcript_path) - except Exception: + except Exception as exc: + print(f"catstack-hook-error verdict-flip-watch: {type(exc).__name__}: {exc}", file=sys.stderr) return None if not target or already_noted(transcript_path, target): return None diff --git a/engine/hooks/wrong-check-reflect/detect.py b/engine/hooks/wrong-check-reflect/detect.py index fd2b7d2c..d411ded3 100644 --- a/engine/hooks/wrong-check-reflect/detect.py +++ b/engine/hooks/wrong-check-reflect/detect.py @@ -1,4 +1,5 @@ from __future__ import annotations +import sys import functools import hashlib @@ -201,5 +202,6 @@ def enqueue_judge(payload: dict) -> str | None: def try_enqueue_judge(payload: dict) -> None: try: enqueue_judge(payload) - except Exception: + except Exception as exc: + print(f"catstack-hook-error wrong-check-reflect: {type(exc).__name__}: {exc}", file=sys.stderr) return diff --git a/engine/hooks/wrong-check-reflect/tests/test_hooks.py b/engine/hooks/wrong-check-reflect/tests/test_hooks.py index 4165be66..edcb2845 100644 --- a/engine/hooks/wrong-check-reflect/tests/test_hooks.py +++ b/engine/hooks/wrong-check-reflect/tests/test_hooks.py @@ -219,10 +219,10 @@ def test_judge_enqueue_failure_leaves_reply_untouched(self): body, cursor_err = run_cursor(payload) codex_err = run_codex_notify([json.dumps(payload)]) self.assertFalse(blocked) - self.assertEqual(err, "") + self.assertEqual(err, "catstack-hook-error wrong-check-reflect: RuntimeError: boom\n") self.assertEqual(body, {"followup_message": ""}) - self.assertEqual(cursor_err, "") - self.assertEqual(codex_err, "") + self.assertEqual(cursor_err, "catstack-hook-error wrong-check-reflect: RuntimeError: boom\n") + self.assertEqual(codex_err, "catstack-hook-error wrong-check-reflect: RuntimeError: boom\n") class TestSubagentTranscript(unittest.TestCase): diff --git a/scripts/check_no_silent_hook_except.py b/scripts/check_no_silent_hook_except.py new file mode 100644 index 00000000..704d82ff --- /dev/null +++ b/scripts/check_no_silent_hook_except.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +import argparse +import ast +import tempfile +from pathlib import Path + +LOGGER_METHODS = { + "critical", + "debug", + "error", + "exception", + "info", + "log", + "warn", + "warning", +} + +PROMISED_CATCH = ( + "silent-exception", + "silent-bare", +) +PROMISED_ALLOW = ( + "narrow-oserror", + "stderr-print", + "reraises", + "logger-call", +) +PROMISED_FIXTURES = { + "silent-exception": "def main():\n try:\n run()\n except Exception:\n return\n", + "silent-bare": "def main():\n try:\n run()\n except:\n return\n", + "narrow-oserror": "def main():\n try:\n run()\n except OSError:\n return\n", + "stderr-print": ( + "import sys\n\n" + "def main():\n" + " try:\n" + " run()\n" + " except Exception as exc:\n" + " print(f\"catstack-hook-error demo: {type(exc).__name__}: {exc}\", file=sys.stderr)\n" + " return\n" + ), + "reraises": "def main():\n try:\n run()\n except Exception:\n raise\n", + "logger-call": ( + "import logging\n\n" + "LOGGER = logging.getLogger(__name__)\n\n" + "def main():\n" + " try:\n" + " run()\n" + " except Exception as exc:\n" + " LOGGER.exception(\"hook failed: %s\", exc)\n" + ), +} + + +class HandlerResult: + def __init__(self, path: Path, handler: ast.ExceptHandler) -> None: + self.path = path + self.handler = handler + + +class ScanResult: + def __init__(self, checked: int, silent: list[HandlerResult], unchecked: list[str]) -> None: + self.checked = checked + self.silent = silent + self.unchecked = unchecked + + def exit_code(self) -> int: + if self.silent: + return 1 + if self.unchecked: + return 2 + return 0 + + +def hook_files(root: Path) -> list[Path]: + hooks = root / "engine" / "hooks" + if not hooks.is_dir(): + return [] + found = [] + for path in sorted(hooks.rglob("*.py")): + parts = path.relative_to(hooks).parts + if "tests" in parts or parts[0] == "_runner": + continue + found.append(path) + return found + + +def flags_exemplar(exemplar: str) -> bool: + source = PROMISED_FIXTURES[exemplar] + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + path = root / "engine" / "hooks" / "demo" / "hook.py" + path.parent.mkdir(parents=True) + path.write_text(source, encoding="utf-8") + return bool(scan(root).silent) + + +def is_broad_type(node: ast.AST | None) -> bool: + if node is None: + return True + if isinstance(node, ast.Name): + return node.id in {"BaseException", "Exception"} + if isinstance(node, ast.Tuple): + return any(is_broad_type(elt) for elt in node.elts) + return False + + +def logging_names(tree: ast.Module) -> tuple[set[str], set[str]]: + modules = set() + loggers = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name == "logging": + modules.add(alias.asname or alias.name) + elif isinstance(node, ast.Assign) and is_logging_get_logger(node.value, modules | {"logging"}): + for target in node.targets: + if isinstance(target, ast.Name): + loggers.add(target.id) + elif isinstance(node, ast.AnnAssign) and node.value is not None: + if is_logging_get_logger(node.value, modules | {"logging"}) and isinstance(node.target, ast.Name): + loggers.add(node.target.id) + return modules | {"logging"}, loggers + + +def is_logging_get_logger(node: ast.AST, modules: set[str]) -> bool: + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "getLogger" + and isinstance(node.func.value, ast.Name) + and node.func.value.id in modules + ) + + +class BodySignalVisitor(ast.NodeVisitor): + def __init__(self, logging_modules: set[str], logger_names: set[str]) -> None: + self.logging_modules = logging_modules + self.logger_names = logger_names + self.has_signal = False + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + return + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + return + + def visit_Lambda(self, node: ast.Lambda) -> None: + return + + def visit_Raise(self, node: ast.Raise) -> None: + self.has_signal = True + + def visit_Call(self, node: ast.Call) -> None: + if is_stderr_print(node) or is_stderr_write(node) or is_logger_call( + node, self.logging_modules, self.logger_names + ): + self.has_signal = True + return + self.generic_visit(node) + + +def is_sys_stderr(node: ast.AST) -> bool: + return ( + isinstance(node, ast.Attribute) + and node.attr == "stderr" + and isinstance(node.value, ast.Name) + and node.value.id == "sys" + ) + + +def is_stderr_print(node: ast.Call) -> bool: + if not isinstance(node.func, ast.Name) or node.func.id != "print": + return False + return any(keyword.arg == "file" and is_sys_stderr(keyword.value) for keyword in node.keywords) + + +def is_stderr_write(node: ast.Call) -> bool: + return ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "write" + and is_sys_stderr(node.func.value) + ) + + +def is_logger_call(node: ast.Call, logging_modules: set[str], logger_names: set[str]) -> bool: + if not isinstance(node.func, ast.Attribute) or node.func.attr not in LOGGER_METHODS: + return False + if isinstance(node.func.value, ast.Name): + return node.func.value.id in logging_modules or node.func.value.id in logger_names + return False + + +def has_signal(handler: ast.ExceptHandler, logging_modules: set[str], logger_names: set[str]) -> bool: + visitor = BodySignalVisitor(logging_modules, logger_names) + for node in handler.body: + visitor.visit(node) + return visitor.has_signal + + +def scan(root: Path) -> ScanResult: + silent = [] + unchecked = [] + checked = 0 + for path in hook_files(root): + checked += 1 + try: + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) + except (OSError, SyntaxError) as exc: + unchecked.append(f"unchecked: {path}: {exc}") + continue + logging_modules, logger_names = logging_names(tree) + for node in ast.walk(tree): + if isinstance(node, ast.ExceptHandler) and is_broad_type(node.type): + if not has_signal(node, logging_modules, logger_names): + silent.append(HandlerResult(path, node)) + return ScanResult(checked, silent, unchecked) + + +def print_result(result: ScanResult) -> None: + for hit in result.silent: + print(f"{hit.path}:{hit.handler.lineno}: silent broad exception handler") + for item in result.unchecked: + print(item) + print(f"checked {result.checked} file(s)") + + +def has_import_sys(tree: ast.Module) -> bool: + for node in tree.body: + if isinstance(node, ast.Import): + if any(alias.name == "sys" and alias.asname is None for alias in node.names): + return True + return False + + +def import_sys_index(tree: ast.Module, lines: list[str]) -> int: + index = 0 + if lines and lines[0].startswith("#!"): + index = 1 + body = list(tree.body) + if body and isinstance(body[0], ast.Expr): + value = body[0].value + if isinstance(value, ast.Constant) and isinstance(value.value, str): + index = max(index, body[0].end_lineno or body[0].lineno) + body = body[1:] + for node in body: + if isinstance(node, ast.ImportFrom) and node.module == "__future__": + index = max(index, node.end_lineno or node.lineno) + continue + break + return index + + +def find_header_colon(line: str, col: int) -> int: + colon = line.find(":", col) + if colon < 0: + raise ValueError("except header has no colon") + return colon + + +def bound_header(line: str, handler: ast.ExceptHandler) -> tuple[str, str]: + colon = find_header_colon(line, handler.col_offset) + head = line[:colon].rstrip() + tail = line[colon + 1 :] + if handler.name: + return line[: colon + 1], tail + if handler.type is None: + replacement = line[: handler.col_offset] + "except BaseException as exc" + else: + replacement = head + " as exc" + return replacement + ":", tail + + +def body_indent(lines: list[str], handler: ast.ExceptHandler) -> str: + first = handler.body[0] + if first.lineno != handler.lineno: + line = lines[first.lineno - 1] + return line[: len(line) - len(line.lstrip())] + return lines[handler.lineno - 1][: handler.col_offset] + " " + + +def hook_name(root: Path, path: Path) -> str: + hooks = root / "engine" / "hooks" + return path.relative_to(hooks).parts[0] + + +def fix_file(root: Path, path: Path, handlers: list[ast.ExceptHandler]) -> None: + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) + lines = source.splitlines(keepends=True) + for handler in sorted(handlers, key=lambda item: (item.lineno, item.col_offset), reverse=True): + line_index = handler.lineno - 1 + header, tail = bound_header(lines[line_index], handler) + indent = body_indent(lines, handler) + exc_name = handler.name or "exc" + text = f'{indent}print(f"catstack-hook-error {hook_name(root, path)}: {{type({exc_name}).__name__}}: {{{exc_name}}}", file=sys.stderr)\n' + if tail.strip(): + ending = "\n" if lines[line_index].endswith("\n") else "" + original_tail = tail.strip() + lines[line_index] = header + ending + lines.insert(line_index + 1, text) + lines.insert(line_index + 2, f"{indent}{original_tail}\n") + else: + lines[line_index] = header + tail + insert_at = handler.body[0].lineno - 1 + lines.insert(insert_at, text) + if not has_import_sys(tree): + lines.insert(import_sys_index(tree, lines), "import sys\n") + path.write_text("".join(lines), encoding="utf-8") + + +def apply_fix(root: Path, result: ScanResult) -> None: + by_path: dict[Path, list[ast.ExceptHandler]] = {} + for hit in result.silent: + by_path.setdefault(hit.path, []).append(hit.handler) + for path, handlers in by_path.items(): + fix_file(root, path, handlers) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--fix", action="store_true") + parser.add_argument("--root", default=Path(__file__).resolve().parents[1]) + args = parser.parse_args(argv) + root = Path(args.root).resolve() + result = scan(root) + if args.fix and result.silent: + apply_fix(root, result) + result = scan(root) + print_result(result) + return result.exit_code() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_check_no_silent_hook_except.py b/tests/test_check_no_silent_hook_except.py new file mode 100644 index 00000000..dc09a973 --- /dev/null +++ b/tests/test_check_no_silent_hook_except.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +SCRIPT = REPO / "scripts" / "check_no_silent_hook_except.py" + + +def write_hook(root: Path, name: str, source: str) -> Path: + path = root / "engine" / "hooks" / name / "hook.py" + path.parent.mkdir(parents=True) + path.write_text(source, encoding="utf-8") + return path + + +def run_check(root: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), "--root", str(root), *args], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + +class TestNoSilentHookExcept(unittest.TestCase): + def test_silent_broad_handler_fails_with_file_and_line(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + path = write_hook( + root, + "demo", + "def main():\n try:\n run()\n except Exception:\n return\n", + ) + result = run_check(root) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn(f"{path}:4: silent broad exception handler", result.stdout) + self.assertIn("checked 1 file(s)", result.stdout) + + def test_inserted_stderr_print_passes(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + write_hook( + root, + "demo", + "import sys\n\ndef main():\n try:\n run()\n except Exception as exc:\n print(f\"catstack-hook-error demo: {type(exc).__name__}: {exc}\", file=sys.stderr)\n return\n", + ) + result = run_check(root) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn("checked 1 file(s)", result.stdout) + + def test_narrow_handler_passes(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + write_hook( + root, + "demo", + "def main():\n try:\n run()\n except OSError:\n return\n", + ) + result = run_check(root) + self.assertEqual(result.returncode, 0, result.stdout) + + def test_syntax_error_is_unchecked_and_exits_two(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + path = write_hook(root, "demo", "def main(:\n pass\n") + result = run_check(root) + self.assertEqual(result.returncode, 2, result.stdout) + self.assertIn(f"unchecked: {path}:", result.stdout) + self.assertIn("checked 1 file(s)", result.stdout) + + def test_fix_rewrites_silent_handler_and_preserves_other_lines(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + path = write_hook( + root, + "demo", + "from __future__ import annotations\n\nVALUE = 3\n\ndef main():\n before()\n try:\n run()\n except Exception:\n return\n after()\n", + ) + before = path.read_text(encoding="utf-8").splitlines() + fixed = run_check(root, "--fix") + after_fix = run_check(root) + after = path.read_text(encoding="utf-8").splitlines() + self.assertEqual(fixed.returncode, 0, fixed.stdout) + self.assertEqual(after_fix.returncode, 0, after_fix.stdout) + self.assertEqual(before[0], after[0]) + self.assertEqual(before[2], after[3]) + self.assertEqual(before[4], after[5]) + self.assertEqual(before[10], after[12]) + self.assertIn("import sys", after) + self.assertIn(" except Exception as exc:", after) + self.assertIn( + ' print(f"catstack-hook-error demo: {type(exc).__name__}: {exc}", file=sys.stderr)', + after, + ) + + +if __name__ == "__main__": + unittest.main()