diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ed7793c..ca70452 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,6 +30,12 @@ repos: language: system types: [python] pass_filenames: false + - id: silent-exceptions + name: silent exception lint + entry: uv run python scripts/lint_silent_exceptions.py + language: system + types: [python] + pass_filenames: false - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 diff --git a/changelog.d/+silent-exception-lint.added.md b/changelog.d/+silent-exception-lint.added.md new file mode 100644 index 0000000..24e999c --- /dev/null +++ b/changelog.d/+silent-exception-lint.added.md @@ -0,0 +1 @@ +Add silent-exception lint gate and `# silent: ` annotations to prevent unlogged exception swallowing diff --git a/scripts/lint_silent_exceptions.py b/scripts/lint_silent_exceptions.py new file mode 100755 index 0000000..77a70ac --- /dev/null +++ b/scripts/lint_silent_exceptions.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Lint for silent exception swallowing patterns that ruff S110 misses. + +Checks production code (src/) for: +1. contextlib.suppress(Exception) without a ``# silent:`` justification +2. ``except ...: continue`` without logging or a ``# silent:`` comment + +Exit code 0 = clean, 1 = violations found. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +_SUPPRESS_RE = re.compile(r"contextlib\.suppress\(\s*Exception\s*\)") +_SILENT_TAG = re.compile(r"#\s*silent:") + +SRC = Path("src") + + +def _check_file(path: Path) -> list[str]: + violations: list[str] = [] + lines = path.read_text().splitlines() + + for i, line in enumerate(lines, 1): + # Pattern 1: contextlib.suppress(Exception) without justification + if _SUPPRESS_RE.search(line) and not _SILENT_TAG.search(line): + violations.append( + f"{path}:{i}: contextlib.suppress(Exception) without '# silent: '" + ) + + # Pattern 2: bare except-continue (no logging on same or next line) + stripped = line.strip() + if stripped in ("continue", "continue # noqa"): + # Walk back to find enclosing except + for j in range(i - 2, max(i - 5, -1), -1): + prev = lines[j].strip() if 0 <= j < len(lines) else "" + if prev.startswith("except"): + # Check if there's logging between except and continue + block = "\n".join(lines[j : i - 1]) + if "log" not in block and "warn" not in block and "# silent:" not in block: + violations.append( + f"{path}:{i}: except-continue without logging or '# silent: '" + ) + break + + return violations + + +def main() -> int: + if not SRC.exists(): + return 0 + + violations: list[str] = [] + for path in sorted(SRC.rglob("*.py")): + violations.extend(_check_file(path)) + + for v in violations: + print(v, file=sys.stderr) + + if violations: + print( + f"\n{len(violations)} silent-exception violation(s). " + "Add '# silent: ' to justify, or add logging.", + file=sys.stderr, + ) + return 1 if violations else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/milo/_compat.py b/src/milo/_compat.py index b124d61..8e0f65c 100644 --- a/src/milo/_compat.py +++ b/src/milo/_compat.py @@ -126,7 +126,7 @@ def _loop() -> None: try: cur = os.get_terminal_size() except OSError: - continue + continue # silent: terminal may be detached if cur != prev: prev = cur callback(cur.columns, cur.lines) diff --git a/src/milo/app.py b/src/milo/app.py index 617b4f3..e441086 100644 --- a/src/milo/app.py +++ b/src/milo/app.py @@ -357,13 +357,13 @@ def _on_state_change() -> None: # Each cleanup step is individually guarded so a failure in one # does not prevent the rest from running. if tick_thread is not None: - with contextlib.suppress(Exception): + with contextlib.suppress(Exception): # silent: teardown must not propagate stop_tick.set() - with contextlib.suppress(Exception): + with contextlib.suppress(Exception): # silent: teardown must not propagate renderer.stop() - with contextlib.suppress(Exception): + with contextlib.suppress(Exception): # silent: teardown must not propagate stop_resize() - with contextlib.suppress(Exception): + with contextlib.suppress(Exception): # silent: teardown must not propagate store.shutdown() final_state = store.state diff --git a/src/milo/config.py b/src/milo/config.py index e1a0e05..d4a9612 100644 --- a/src/milo/config.py +++ b/src/milo/config.py @@ -289,7 +289,7 @@ def _load_file(filepath: str) -> dict[str, Any]: ) raise ImportError(msg) from e with open(path) as f: - return yaml.safe_load(f) or {} + return yaml.safe_load(f) or {} # empty YAML returns None; default to {} if suffix == ".json": import json diff --git a/src/milo/dev.py b/src/milo/dev.py index 2691f5c..2b428d6 100644 --- a/src/milo/dev.py +++ b/src/milo/dev.py @@ -72,7 +72,7 @@ def _check_changes(self) -> list[Path]: try: mtime = p.stat().st_mtime except OSError: - continue + continue # silent: file may vanish between is_file and stat if p in self._mtimes: if mtime > self._mtimes[p]: changed.append(p) diff --git a/src/milo/gateway.py b/src/milo/gateway.py index cbd3297..51ffeca 100644 --- a/src/milo/gateway.py +++ b/src/milo/gateway.py @@ -183,7 +183,7 @@ def _run_gateway() -> None: request = json.loads(line) except json.JSONDecodeError: _write_error(None, -32700, "Parse error") - continue + continue # silent: error already sent via JSON-RPC req_id = request.get("id") method = request.get("method", "") diff --git a/src/milo/mcp.py b/src/milo/mcp.py index 18c5f1b..2f01870 100644 --- a/src/milo/mcp.py +++ b/src/milo/mcp.py @@ -160,7 +160,7 @@ def run_mcp_server(cli: CLI) -> None: request = json.loads(line) except json.JSONDecodeError: _write_error(None, -32700, "Parse error") - continue + continue # silent: error already sent via JSON-RPC req_id = request.get("id") method = request.get("method", "") diff --git a/src/milo/registry.py b/src/milo/registry.py index 29b24f2..83e341b 100644 --- a/src/milo/registry.py +++ b/src/milo/registry.py @@ -166,7 +166,7 @@ def _health_check_entry(name: str, info: dict[str, Any]) -> HealthResult: stale=stale, ) except json.JSONDecodeError: - continue + continue # silent: skip malformed lines; falls through to unreachable return HealthResult( name=name,