Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions changelog.d/+silent-exception-lint.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add silent-exception lint gate and `# silent: <reason>` annotations to prevent unlogged exception swallowing
73 changes: 73 additions & 0 deletions scripts/lint_silent_exceptions.py
Original file line number Diff line number Diff line change
@@ -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: <reason>'"
)

Comment on lines +13 to +33
# 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
Comment on lines +35 to +37
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
Comment on lines +37 to +41
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: <reason>'"
)
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: <reason>' to justify, or add logging.",
file=sys.stderr,
)
return 1 if violations else 0


if __name__ == "__main__":
raise SystemExit(main())
2 changes: 1 addition & 1 deletion src/milo/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions src/milo/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/milo/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/milo/dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/milo/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
Expand Down
2 changes: 1 addition & 1 deletion src/milo/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
Expand Down
2 changes: 1 addition & 1 deletion src/milo/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading