diff --git a/codec.py b/codec.py index bd21627..bed1c89 100644 --- a/codec.py +++ b/codec.py @@ -280,11 +280,11 @@ def close_session(): with open(SESSION_ALIVE) as f: pid = int(f.read().strip()) os.kill(pid, 15) print(f"[CODEC] Session process {pid} terminated") - except: pass + except Exception: pass try: os.unlink(SESSION_ALIVE) - except: pass + except Exception: pass try: os.unlink(TASK_QUEUE_FILE) - except: pass + except Exception: pass subprocess.Popen(["osascript", "-e", 'tell application "Terminal" to close (every window whose name contains "python3.13 /var/folders")'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) @@ -633,7 +633,7 @@ def do_stop_voice(): if rec_duration < 0.5: print(f"[CODEC] Recording too short ({rec_duration:.1f}s) — ignored") try: os.unlink(audio) - except: pass + except Exception: pass return if os.path.getsize(audio) < 1000: try: os.unlink(audio) @@ -861,7 +861,7 @@ def on_release(key): ovl = state.get("overlay_proc") if ovl: try: ovl.terminate() - except: pass + except Exception: pass state["overlay_proc"] = None threading.Thread(target=lambda: subprocess.run( ['afplay', '/System/Library/Sounds/Pop.aiff'], diff --git a/codec_core.py b/codec_core.py index 5c729d3..8df280e 100644 --- a/codec_core.py +++ b/codec_core.py @@ -292,7 +292,7 @@ def speak_text(text): tmp.close() subprocess.run(["afplay", tmp.name], timeout=30) try: os.unlink(tmp.name) - except: pass + except Exception: pass finally: tts_playing = False tts_finished_at = time.time() diff --git a/codec_dictate.py b/codec_dictate.py index 5a4e52f..8b9479e 100644 --- a/codec_dictate.py +++ b/codec_dictate.py @@ -118,7 +118,7 @@ def hide_overlay(): try: overlay_proc.terminate() overlay_proc = None - except: + except Exception: pass # ── SHOW PROCESSING OVERLAY ─────────────────────────────────────────────────── @@ -151,7 +151,7 @@ def show_processing(): stderr=subprocess.DEVNULL ) return p - except: + except Exception: return None # ── LIVE DICTATION (hands-free, double-tap Option) ────────────────────────── @@ -213,21 +213,21 @@ def _producer(): ) if live_stop_event.is_set(): try: os.unlink(tmp.name) - except: pass + except Exception: pass break if os.path.exists(tmp.name) and os.path.getsize(tmp.name) >= 1000: try: q.put(tmp.name, timeout=1) except queue.Full: try: os.unlink(tmp.name) - except: pass + except Exception: pass else: try: os.unlink(tmp.name) - except: pass + except Exception: pass except Exception as e: print(f"[DICTATE] Producer error: {e}") try: os.unlink(tmp.name) - except: pass + except Exception: pass prod = threading.Thread(target=_producer, daemon=True) prod.start() @@ -247,7 +247,7 @@ def _producer(): wf.close() if _np.abs(data).mean() < 150: continue - except: + except Exception: pass with open(path, "rb") as f: r = requests.post(WHISPER_SERVER, @@ -271,7 +271,7 @@ def _producer(): print(f"[DICTATE] Live chunk error: {e}") finally: try: os.unlink(path) - except: pass + except Exception: pass prod.join(timeout=3) return full_text.strip() @@ -312,11 +312,11 @@ def stop_live_dictation(): # Kill overlay — tkinter mainloop sometimes ignores SIGTERM, so SIGKILL it if live_overlay: try: live_overlay.terminate() - except: pass + except Exception: pass try: live_overlay.wait(timeout=0.5) except Exception: try: live_overlay.kill() - except: pass + except Exception: pass live_overlay = None # Wait for thread if live_thread: @@ -380,7 +380,7 @@ def transcribe_and_type(audio_path): if proc_overlay: try: proc_overlay.terminate() - except: + except Exception: pass if not text or is_hallucination(text): @@ -441,12 +441,12 @@ def transcribe_and_type(audio_path): if proc_overlay: try: proc_overlay.terminate() - except: + except Exception: pass finally: try: os.unlink(audio_path) - except: + except Exception: pass # ── KEYBOARD LISTENER ───────────────────────────────────────────────────────── @@ -469,7 +469,7 @@ def on_press(key): try: recording_proc.terminate() recording_proc.wait(timeout=2) - except: pass + except Exception: pass recording_proc = None recording_path = None hide_overlay() @@ -549,14 +549,14 @@ def _cleanup(): global recording_proc if recording_proc: try: recording_proc.terminate(); recording_proc.wait(timeout=2) - except: pass + except Exception: pass recording_proc = None hide_overlay() if live_active: stop_live_dictation() for f in _glob.glob(os.path.join(tempfile.gettempdir(), "dictate_*.wav")): try: os.unlink(f) - except: pass + except Exception: pass atexit.register(_cleanup) import signal signal.signal(signal.SIGTERM, lambda *a: (print("[DICTATE] SIGTERM received"), _cleanup(), sys.exit(0))) diff --git a/codec_textassist.py b/codec_textassist.py index e1a30c2..5d116fe 100755 --- a/codec_textassist.py +++ b/codec_textassist.py @@ -7,7 +7,7 @@ def get_config(): try: with open(os.path.expanduser("~/.codec/config.json")) as f: return json.load(f) - except: return {} + except Exception: return {} def call_qwen(text, mode): cfg = get_config() @@ -109,7 +109,7 @@ def overlay(text, color, duration): # Kill processing overlay now that we have the result if _proc_overlay: try: _proc_overlay.terminate() - except: pass + except Exception: pass if MODE in ("explain", "translate"): # Show result in a styled floating window (no Terminal) title = "CODEC Explain" if MODE == "explain" else "CODEC Translate" @@ -182,5 +182,5 @@ def overlay(text, color, duration): except Exception: if _proc_overlay: try: _proc_overlay.terminate() - except: pass + except Exception: pass overlay("Error - check terminal", "#ff3333", 3000) diff --git a/docs/audits/PHASE-1-CODE-QUALITY.md b/docs/audits/PHASE-1-CODE-QUALITY.md index ae16736..ca3ed1f 100644 --- a/docs/audits/PHASE-1-CODE-QUALITY.md +++ b/docs/audits/PHASE-1-CODE-QUALITY.md @@ -209,6 +209,8 @@ Both scan `SKILLS_DIR` independently, so a skill file is loaded twice in differe **Location:** `codec_dashboard.py:1114-1115`, `codec_dashboard.py:2998`, several others. > **Partially closed by PR-3B** (branch `fix/pr3b-silent-except-cleanup`). The 20 A-3 sites — A-22's named HIGH-confidence subset — are all fixed (see A-3 closure). The clearest standalone HIDING_BUG the audit named — the post-LLM `[SKILL:name:query]` tag resolution (audit's old line 2998, now `codec_dashboard.py:3033`) — was a bare `except Exception: pass` that let a raw `[SKILL:...]` tag leak into the user's chat with zero footprint; now it logs `log.warning` + emits a `post_llm_skill_tag_failed` audit (behavior unchanged — tag stays, chat still returns). **Deliberately deferred (PR-3B-2):** the audit's "~50 HIDING_BUG" was an *estimate*, not an enumerated list, and the other named sites turned out to be **legitimate graceful-degradation paths** on inspection — e.g. `codec_voice.py:692` (Gemini vision → local Qwen fallback, already prints + falls back), `:750` (optional observer injection, explicitly "non-fatal"), `:773` (TTS returns None, caller handles it). The audit's named `codec_dashboard.py:1114` vision-DB-save site no longer exists in that form (code evolved). Aggressively narrowing working fallback paths risks regressions for no clear bug, so the residual A-22 sweep is split to a focused per-site survey (PR-3B-2) rather than rushed — protecting the "never break working code" invariant. + +> **Survey completed + closed by PR-3B-2** (branch `fix/pr3b2-silent-except-survey`). Per-site survey of every remaining genuinely-silent `except: pass` in production (22 one-liners + their multi-line kin). **Finding: none are bug-hiders** — they're all legitimate resource teardown (`proc.terminate()`/`.kill()`/`.wait()`, `os.unlink()` temp cleanup) or best-effort stat gathering (`skills/system_info.py`). The genuine silent bug-hiders were already fixed in PR-3B (A-3 subset + the post-LLM tag). So no control-flow changes were made to those (correct — adding logging/audit to legitimate cleanup would be noise, and `codec_audit._write` must stay silent by design). **The one concrete anti-pattern the survey DID surface + fix: 36 BARE `except:`** (no exception type) across 12 production files (`codec_dictate.py` ×16, `codec.py` ×5, `skills/system_info.py` ×4, `codec_textassist.py` ×3, `codec_core.py`, `setup_codec.py`, + 6 skills) — a bare except also swallows `KeyboardInterrupt`/`SystemExit`/`GeneratorExit`, so Ctrl-C and clean shutdown could be silently eaten. All 36 converted to `except Exception:` (string-template `except:` inside the deprecated `build_session_script` generator left untouched — they're code-gen strings, not real handlers). Pinned by an AST-based regression guard (`tests/test_no_bare_except.py`) so bare excepts can't return. Full suite 1365 passing, zero new failures; manifest regenerated (7 skill files touched). **A-22 fully closed.** **Description:** Categorization survey of 814 `except Exception` instances + 30 bare `except:` outside generated code: - **LEGITIMATE_NONCRITICAL** (majority, ~80%): teardown of subprocess proc, tempfile cleanup, optional-import probing, "config file might not exist" loads, `log.debug` already in the handler. Examples: `codec.py:850-867` (recording-process cleanup with `log.debug`), `codec_core.py:191-194` (process-alive check), `codec_audit.py:_write` (audit must never raise by design). - **HIDING_BUG** (minority, ~50 instances): line 1114 (`save vision response to DB`) catches `Exception` and just logs a warning, but image-message persistence failure means the chat panel will be silently missing the image. Line 2998 (post-LLM skill routing) silently swallows `Exception` while resolving a `[SKILL:name:query]` tag — if the skill blows up, the user gets a raw tag in their chat. Several `codec_voice.py` exception-handlers also fall in this bucket (line 691, 712, 750, 772, 921, etc.). diff --git a/docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md b/docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md index b771943..5ee8b25 100644 --- a/docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md +++ b/docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md @@ -238,6 +238,7 @@ Mirror the Intake Phase 3 wave pattern. 7 waves planned; sizes are PR-counts, NO **PR count estimate:** 4-5 - PR-3A: A-1 + A-2 — delete the ~733 LOC of dead `build_session_script` + `skills/codec.py` fork. **High-leverage**: investors and contributors will read these files first. ✅ (branch `fix/pr3a-delete-dead-build-session-script`; verify-first dead-code trace confirmed both unreachable before deletion; codec.py orphan removed 1170→894 LOC + skills/codec.py fork deleted = ~734 LOC; `codec_core.build_session_script` deprecated copy KEPT per A-1; 1325 passing, fixed a pre-existing test, zero new failures) - PR-3B: A-3 + A-22 — rewrite the silent-except sites with proper narrow-except + `log_event` audit ✅ A-3 fully closed (20 sites: keyboard/dashboard/skills, narrowed + re-leveled + better messages); A-22 partially closed (the named post-LLM `[SKILL:]` tag-resolution bug fixed). **Residual A-22 sweep → PR-3B-2**: the "~50" was an estimate; the other named sites are legitimate graceful-degradation fallback paths (codec_voice Gemini→local, observer injection, TTS) — narrowing them risks breaking working code, so a focused per-site survey is split out rather than rushed. (branch `fix/pr3b-silent-except-cleanup`) +- PR-3B-2: A-22 survey + bare-except fix ✅ (branch `fix/pr3b2-silent-except-survey`). Per-site survey concluded the remaining silent `except: pass` are all legitimate cleanup/best-effort (no bug-hiders left — those were fixed in PR-3B). Concrete fix: converted all **36 bare `except:` → `except Exception:`** across 12 production files (they swallowed KeyboardInterrupt/SystemExit). AST regression guard added (`tests/test_no_bare_except.py`). Full suite 1365 passing, zero new. **A-22 fully closed.** - PR-3C: A-16 + A-17 + A-21 — wire `WAKE_PHRASES` (deduped homophone keywords + length-guarded phrase match in a testable `_is_wake_utterance`) + wire `draft_keywords` into `codec_core.is_draft` + remove dead `AGENT_NAME` constant ✅ (branch `fix/pr3c-wire-config-knobs`; 13 tests; zero net-new ruff; full suite 1338 passing). **A-4 (skill-loader unification) deliberately split out → its own PR**: it refactors the LIVE multi-file skill-dispatch path (`codec_core.load_skills` is called from codec.py + dashboard ×2 + voice + agent_runner), needs a careful voice-path test pass, and doesn't belong bundled with these contained config-wiring fixes. - PR-3D: A-5 + A-6 + A-7 — extract helpers from the 3 monolithic functions (`_dispatch_inner`, `chat_completion`, `Agent.run`) - PR-3E: A-11 + A-12 — unify vision + 51-site `chat/completions` through `codec_llm_proxy` diff --git a/setup_codec.py b/setup_codec.py index 128f3c2..e2f8866 100644 --- a/setup_codec.py +++ b/setup_codec.py @@ -109,7 +109,7 @@ def check_port(port): s.connect(("localhost", port)) s.close() return True - except: + except Exception: return False # ══════════════════════════════════════════════════════════════════════════════ diff --git a/skills/.manifest.json b/skills/.manifest.json index 327ae0d..6be4304 100644 --- a/skills/.manifest.json +++ b/skills/.manifest.json @@ -4,7 +4,7 @@ "skills": { "active_window.py": "31fdd25c071a29dfb0174d3b6c042c20370d5e88f4109c20fa5dc687bc25981c", "ai_news_digest.py": "2f5b58d95cd74ea95eb43bc21b65ef4b3cf9ca38c055b0a88e4be213665c700a", - "app_switch.py": "aaba996619786edee552fd35b3a90bcacc5e30dfc3b6a4a3b479ff1ed97ffe58", + "app_switch.py": "c42155935805466ad6455d5a6ea47c61565edc6779b8d214063af466a4b2649f", "ask_user.py": "5bb8009a2dc133dafafc2847b7d0ad7c5b31db91b5d687b1c0d90ecd9a7830bc", "audit_report.py": "1ed0be5e6f49bb5f05826f69a81343a76f42d067e308de459191006b585dfa93", "audit_verify.py": "887c68f340adc2d93b3ec307dbf149f1bb231478f31db817c5032bc04798eb0e", @@ -13,7 +13,7 @@ "backup_status.py": "79caed39a5d64e7a8f9606a4b8a6c683908ff763394e18b035727716904eacbc", "bitcoin_price.py": "892e646d36a2e41eb0a71b468f60a6377fe3c04768100dac94abe5225632011f", "brightness.py": "a8e7b93a0b4f4c90baa1565ed73235e9f34eea622e0699afd11ae79dfab87eb3", - "calculator.py": "1023241909682c992ca0829c4b93b3cded9f2e539becef1d17aeaa10201dde5b", + "calculator.py": "f93d047ece1da993796fa33be77d114da459f766833e121f391820fed4b7061e", "chrome_automate.py": "89f8ea61180bad1136582e0ed51ee72fbe57c62a4a501ff518c421f363fd67d9", "chrome_click_cdp.py": "6453e5a08a8a3596f688a8b3815812d366c622cc6d9402d0ae4c572cf00a9e0f", "chrome_close.py": "9c62736a5030ba1c511f6a0b198e2e7cf1c7b64714cf5b4df198cc20b5043b75", @@ -42,7 +42,7 @@ "health_check.py": "ee295d393b1e1424dc07002d72a06a070e324737e4ed6b6deb5a0e60863471aa", "imessage_send.py": "ba8a2577d78b5dc93f4c2c3882e5447661bb192d596182b001de4b35068ab18c", "json_formatter.py": "995ad15bff5405cfde9ff9dd312998e7bac4337b4ad48e60c27342c7d27e54b2", - "lucy.py": "696d21c81ea37e156d254b0a7bba2ea769e37cd906452e48b9dc2891edc9c40f", + "lucy.py": "7c595d5605cd9913a8afa331ae0013861d6996f378f8a59aca0249f1b2f3a474", "memory_entities.py": "5865b8a0bf7c29e2c2e9ee4b3bc1d1c20c90ef19df833bd3d4fce3e30b4da36d", "memory_history.py": "acd89c56c1d17b8bde3b47e2e40ae60186e14c8f3ede22484dde55346b6821ce", "memory_save.py": "92fef3ef623e74110988a94835e0936851f99bcbc6abf95fdc4cae55b5e9806a", @@ -52,12 +52,12 @@ "network_info.py": "d255584410a412b9f76f4ea2ddebc0e9a5523f046aa5b752316bf2e22e221461", "notes.py": "f0bab6652eafad881b40cddd354619ed82c2f2fb04e58c15a77ae04812b1fb02", "notification_reader.py": "7fbff9f9063c39b0c4c0ea2568ecaab352c1fc4911382f3dd347f601b4a75e59", - "password_generator.py": "17f8e23d876d74cb100a652507d4dbb88a92d81c954e7728df24375f143ea21d", + "password_generator.py": "8affc3dcaaa57301fb808fd1600fc6b57a7d579c2b2fffb9513732e7db436c94", "philips_hue.py": "bcc2ef1b7e6c0e5e5f0165aae397fd6a3fd27367a3f100b949cd0efddac43496", "pilot.py": "33fe0a8cb1f3f577f3ddf801ad6d4e1cb3c9ceae8e6b748edd29ef33149ec1c7", "plugin_approve.py": "c93861353be2a9ebe08df212ca167bea646962dbeafe704b1864cd78a8cf57cc", "pm2_control.py": "1f67e3158920987c1f0d2452fcadd7405624b28a799297483a2d11f5d5c90d17", - "pomodoro.py": "ab6816d25c16e7132f899b4584d7fa6d5082d0c50811ffbd79d087e34c8861a5", + "pomodoro.py": "6c438ca3ce37afd10e9e96d3bc848e9e9407298761b06543b0aa00010317092d", "process_manager.py": "742126eb470b89f91231b58d6e30da6a6683fdb435a2b05f6b070954853a19f9", "python_exec.py": "e3e5bc4fe55b260858d7b510cd6ccd7dcb43958c2828200130af9e51db0fa7e8", "qr_generator.py": "c5a6c451bb52f1b37d3a59bcc6db467c2c9c3e8b37f8ac74fd54315ca520ddcb", @@ -68,14 +68,14 @@ "shift_report.py": "f11cd03f2a3a860564623a04a053f527cfff1f53be659754e201eb31bb9f8bb3", "skill_forge.py": "07876ce6fda960c56b3d7f9a9fb1c028dfacd00393a3c96fdc209f8a5fb972d8", "stuck.py": "dfddfe4dfa1a9d5c017f53e53033a7eb33114a517cd6fa937d9d44e65083f1c9", - "system_info.py": "02d8bbf6d69fd08895bf24ee7daccc002bca6f19b3bc161c1035cb01badefa1f", + "system_info.py": "4cc32e0ad9cb81f34309734ef3388f7cde63407de9b23bb0cd589571a4c43ecb", "terminal.py": "e156caa2208acbb3802345f7b4f8746a5a14b2b0eac8e428c772e5b099ba2903", "time_date.py": "b5a8f9341d8d4954833607eac41b5eb99e65e3bd30d0514a8b034245a7ca08dc", "timer.py": "e7e77b82c5f3b3d60612455c57669ed91d2ffd71c7f084ee16317259785ba7f7", "translate.py": "8ed19b54a1ab0bf83a640219cecafc28d7e5089bfffab11e9a1cebde36637407", "tts_say.py": "b20b9ac44740cd4fc1896191d42b4491f6e99a7587a1b41cf87ff7dca5dd875f", "volume.py": "2ca223e5048db83055dd1ccac21fbeef76d4c44cda047ab852f4eaf318193633", - "weather.py": "d52115291ad124c4671f8e3d7e337f11c824f7cb593e55c85c8e8f99070625fb", + "weather.py": "b787288d8fa7f42541435c016a36d09283b31035f414e33ab48817c7d16dd134", "web_fetch.py": "e305aab7590a48b37a6f7fc170e76f471851dc69a043620725674758fed72f2a", "web_search.py": "fff37c6bc28b7cfce7d682454a4127107d66998c72f95e976a95821aa5584748" } diff --git a/skills/app_switch.py b/skills/app_switch.py index db351c5..8d47130 100644 --- a/skills/app_switch.py +++ b/skills/app_switch.py @@ -80,5 +80,5 @@ def run(task, app="", ctx=""): # Try open -a as fallback subprocess.run(["open", "-a", app_name], capture_output=True, timeout=5) return f"Opening {app_name}." - except: + except Exception: return f"Couldn't find {app_name}." diff --git a/skills/calculator.py b/skills/calculator.py index 02e98f4..83cbedb 100644 --- a/skills/calculator.py +++ b/skills/calculator.py @@ -25,5 +25,5 @@ def run(task, app="", ctx=""): if isinstance(result, float) and result == int(result): result = int(result) return f"{safe} = {result}" - except: + except Exception: return None diff --git a/skills/lucy.py b/skills/lucy.py index 5ae09e1..2ce2ad4 100644 --- a/skills/lucy.py +++ b/skills/lucy.py @@ -28,7 +28,7 @@ def run(task, app="", ctx=""): if isinstance(data, dict) and data.get("output"): return data["output"] return "CODEC workflow responded but no output parsed" - except: + except Exception: return r.text[:500] if r.text else "CODEC workflow processed but no response" else: return f"CODEC workflow error (status {r.status_code})" diff --git a/skills/password_generator.py b/skills/password_generator.py index 0e3483a..c33fd3d 100644 --- a/skills/password_generator.py +++ b/skills/password_generator.py @@ -14,7 +14,7 @@ def run(task, app="", ctx=""): if 4 <= n <= 128: length = n break - except: + except Exception: pass chars = string.ascii_letters + string.digits + "!@#$%&*" password = ''.join(secrets.choice(chars) for _ in range(length)) diff --git a/skills/pomodoro.py b/skills/pomodoro.py index 2dc3511..24457a3 100644 --- a/skills/pomodoro.py +++ b/skills/pomodoro.py @@ -74,7 +74,7 @@ def run(task, app="", ctx=""): if 1 <= n <= 120: minutes = n break - except: + except Exception: pass started = time.time() diff --git a/skills/system_info.py b/skills/system_info.py index e880686..d2b07d5 100644 --- a/skills/system_info.py +++ b/skills/system_info.py @@ -13,12 +13,12 @@ def run(task, app="", ctx=""): r = subprocess.run(["uptime"], capture_output=True, text=True, timeout=5) up = r.stdout.strip() parts.append("Uptime: " + up.split("up")[1].split(",")[0].strip() if "up" in up else up) - except: pass + except Exception: pass try: # Memory r = subprocess.run(["bash", "-c", "vm_stat | head -5"], capture_output=True, text=True, timeout=5) parts.append("Memory stats available") - except: pass + except Exception: pass try: # Disk r = subprocess.run(["df", "-h", "/"], capture_output=True, text=True, timeout=5) @@ -26,11 +26,11 @@ def run(task, app="", ctx=""): if len(lines) > 1: cols = lines[1].split() parts.append(f"Disk: {cols[2]} used of {cols[1]} ({cols[4]} full)") - except: pass + except Exception: pass try: # CPU load r = subprocess.run(["bash", "-c", "sysctl -n vm.loadavg"], capture_output=True, text=True, timeout=5) parts.append("Load: " + r.stdout.strip()) - except: pass + except Exception: pass return " | ".join(parts) if parts else "Couldn't get system info." diff --git a/skills/weather.py b/skills/weather.py index b6a2373..c31f053 100644 --- a/skills/weather.py +++ b/skills/weather.py @@ -55,6 +55,6 @@ def _home_city() -> str: r2.encoding = "utf-8" if r2.status_code == 200: return f"Weather in {home}: {r2.text.strip()}" - except: + except Exception: pass return f"Couldn't fetch weather for {location}. Network may be unavailable." diff --git a/tests/test_no_bare_except.py b/tests/test_no_bare_except.py new file mode 100644 index 0000000..6ef57b4 --- /dev/null +++ b/tests/test_no_bare_except.py @@ -0,0 +1,48 @@ +"""Regression guard: no bare `except:` in production code (A-22 / PR-3B-2). + +Bare `except:` also catches KeyboardInterrupt / SystemExit / GeneratorExit, +so Ctrl-C and clean shutdown can be silently swallowed. PR-3B-2 converted +all 36 production bare-excepts to `except Exception:`. This AST-based check +pins that — it only sees real code (string-template `except:` inside the +deprecated build_session_script generator is invisible to the AST walker), +and skips the tests/ tree. + +Reference: docs/audits/PHASE-1-CODE-QUALITY.md finding A-22. +""" +from __future__ import annotations + +import ast +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + + +def _production_py_files(): + """All top-level + routes/ + skills/ .py files, excluding tests + vendored.""" + files = [] + files += sorted(REPO.glob("*.py")) + files += sorted((REPO / "routes").glob("*.py")) + files += sorted((REPO / "skills").glob("*.py")) + return [f for f in files if "test" not in f.name.lower()] + + +def _bare_except_lines(path: Path): + """Return line numbers of bare `except:` (ExceptHandler with no type).""" + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + except (SyntaxError, OSError): + return [] # unparseable files aren't this test's concern + return [n.lineno for n in ast.walk(tree) + if isinstance(n, ast.ExceptHandler) and n.type is None] + + +def test_no_bare_except_in_production(): + offenders = {} + for f in _production_py_files(): + lines = _bare_except_lines(f) + if lines: + offenders[f.relative_to(REPO).as_posix()] = lines + assert not offenders, ( + "Bare `except:` found in production code (use `except Exception:` so " + f"KeyboardInterrupt/SystemExit propagate): {offenders}" + )