"""E22.5 S3 — every swallowed exception has been ruled on. A handler that neither logs nor re-raises is a decision: someone looked at it and decided the failure is not worth a line. 114 of them were in `app/` at the start of this epic, and almost none carried that decision anywhere — which means nobody could tell the deliberate ones from the ones that were never thought about. That is the whole defect: not that they are silent, but that their silence says nothing. So the rule is not "log everything". It is: a silent handler carries `# silent-ok: ` on the `except` line or the line above it. Ruff's S110 and BLE001 sit under this and catch the two narrow shapes (`except: pass`, blind `except Exception`); this case covers the rest — the handler that quietly returns a fallback, or `continue`s past one item of a loop. """ import ast import pathlib from app import logs APP = pathlib.Path(logs.__file__).resolve().parent _MARKER = "silent-ok" _LOG_METHODS = frozenset( {"debug", "info", "warning", "warn", "error", "exception", "critical", "log"} ) def _is_log_call(node: ast.AST) -> bool: if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): return False if node.func.attr not in _LOG_METHODS: return False base, name = node.func.value, "" while isinstance(base, ast.Attribute): name = f"{base.attr}.{name}" base = base.value if isinstance(base, ast.Name): name = f"{base.id}.{name}" return "log" in name.lower() def _swallows(handler: ast.ExceptHandler) -> bool: """Neither reports nor re-raises. A nested `raise` counts: the caller still finds out.""" for node in ast.walk(handler): if isinstance(node, ast.Raise) or _is_log_call(node): return False return True def test_every_silent_handler_says_why_it_is_silent(): offenders = [] for path in sorted(APP.rglob("*.py")): src = path.read_text(encoding="utf-8") lines = src.splitlines() for node in ast.walk(ast.parse(src)): if not isinstance(node, ast.ExceptHandler) or not _swallows(node): continue # The marker may sit in the comment block attached above the `except`, on the # `except` line itself, or anywhere inside the handler — all three are this handler # and nothing else, so no justification can excuse a neighbour. start = node.lineno - 1 while start > 0 and lines[start - 1].lstrip().startswith("#"): start -= 1 end = node.end_lineno or node.lineno if _MARKER in " ".join(lines[start:end]): continue offenders.append(f"{path.relative_to(APP)}:{node.lineno}") assert offenders == [], ( f"{len(offenders)} exception handlers neither log nor re-raise and do not say why. Add a " f"log line with an event code, or `# {_MARKER}: ` — " f"{offenders}" )