failroute 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
failroute/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """failroute — static detection of failure-routing anti-patterns in Python.
2
+
3
+ Failure-routing is the practice of converting an underlying failure into a
4
+ *success-like* outcome at the wrong layer: swallowing an exception and
5
+ returning a default truthy/"no error" value, transforming a metric failure
6
+ into a 0.0/False score, or logging-and-continuing where the caller is
7
+ contractually entitled to know the operation failed.
8
+
9
+ This module implements an AST-based scanner that flags those patterns, so
10
+ tests / CI can treat them like the correctness bugs they are.
11
+ """
12
+
13
+ from failroute.analyzer import FailureMode, Finding, scan_path, scan_repo, scan_source
14
+ from failroute.cli import main
15
+ from failroute.sarif import to_sarif, to_sarif_json
16
+
17
+ __all__ = [
18
+ "FailureMode",
19
+ "Finding",
20
+ "scan_source",
21
+ "scan_path",
22
+ "scan_repo",
23
+ "main",
24
+ "to_sarif",
25
+ "to_sarif_json",
26
+ ]
27
+ __version__ = "0.3.0"
failroute/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m failroute`` invocation."""
2
+
3
+ from failroute.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
failroute/analyzer.py ADDED
@@ -0,0 +1,595 @@
1
+ """AST-based scanner for failure-routing anti-patterns.
2
+
3
+ Design notes
4
+ ------------
5
+ The scanner walks every ``ExceptHandler`` and inspects what its body does
6
+ about the exception:
7
+
8
+ * **No action** (body is ``pass``, ``...``, a bare comment-holder, or the
9
+ handler re-raises unconditionally without recording anything): the caller
10
+ can never learn the operation failed. This is the classic "swallowed
11
+ exception" defect and the root cause behind score/result corruption in
12
+ eval frameworks.
13
+
14
+ * **Silent fallback value** (body returns/logs/assigns a constant while the
15
+ handler never re-raises): the failure is converted into a default-looking
16
+ value (``None``, ``0``, ``0.0``, ``False``, ``""``, ``{}``, ``[]``) at the
17
+ wrong layer. Concretely this is what turns an LLM judge outage into a
18
+ "0.0 score" or a network error into "no download" in the repos we audit.
19
+
20
+ * **Catch-all with a masked exception**: ``except Exception`` whose body
21
+ raises a bare ``raise`` from a *different* exception, or logs then falls
22
+ through to a later ``return`` that looks successful.
23
+
24
+ * **Name shadowing** (``except E as e:`` whose body rebinds ``e``): Python
25
+ deletes the binding when the handler exits, so any later use of the name
26
+ raises ``NameError`` — and mid-handler the original exception object is lost.
27
+
28
+ The heuristics deliberately err on the side of *reporting*: reviewers
29
+ (and CI gates) can triage quickly, and a finding is always worth a look at
30
+ the surrounding lines.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import ast
36
+ import logging
37
+ from dataclasses import dataclass
38
+ from enum import Enum
39
+ from pathlib import Path
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ #: Exception types whose handler is almost always informational (KeyboardInterrupt
44
+ #: at module level, SystemExit for CLI tools...) and is therefore not a finding.
45
+ _IGNORED_EXC_NAMES = {
46
+ "KeyboardInterrupt",
47
+ "SystemExit",
48
+ "GeneratorExit",
49
+ # Swallowing these is idiomatic control flow, not failure routing:
50
+ # - StopIteration: how iterators terminate (PEP 479 makes generators special).
51
+ # - CancelledError: async tasks routinely absorb cancellation in cleanup paths;
52
+ # flagging every one of them drowns real findings.
53
+ "StopIteration",
54
+ "CancelledError",
55
+ }
56
+
57
+ #: Return statements whose payload is a constant that "looks like" a fallback.
58
+ _FALLBACK_CONSTANT_NAMES: set[str] = {
59
+ "None",
60
+ "False",
61
+ "True", # ambiguous (legit in boolean scorers) — flagged as a hint only
62
+ "0",
63
+ "0.0",
64
+ "1", # ambiguous
65
+ "1.0", # ambiguous
66
+ '""',
67
+ "''",
68
+ "b''",
69
+ 'b""',
70
+ "[]",
71
+ "{}",
72
+ "()",
73
+ }
74
+
75
+ #: Attribute names of calls that terminate the process — treated like a raise
76
+ #: because the caller never observes a fallback value afterwards.
77
+ _ACTIVE_CALL_NAMES: set[str] = {"exit", "_exit"}
78
+
79
+
80
+ #: Aggregated finding kinds, kept coarse so consumers can group by root cause.
81
+ class FailureMode(str, Enum):
82
+ NO_ACTION = "no-action" # pass / bare / unconditional re-raise, nothing recorded
83
+ SILENT_FALLBACK = "silent-fallback" # returns a constant, never re-raises
84
+ MASKED_EXCEPTION = "masked-exception" # catch-all that swallows the original error
85
+ NAME_SHADOWING = "name-shadowing" # except as e: ... success path uses a stale value
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class Finding:
90
+ """A single anti-pattern occurrence."""
91
+
92
+ file: str
93
+ lineno: int
94
+ end_lineno: int
95
+ mode: FailureMode
96
+ exc_name: str | None
97
+ handler_text: str = ""
98
+ message: str = ""
99
+
100
+ def to_dict(self) -> dict:
101
+ return {
102
+ "file": self.file,
103
+ "lineno": self.lineno,
104
+ "end_lineno": self.end_lineno,
105
+ "mode": self.mode.value,
106
+ "exc_name": self.exc_name,
107
+ "handler_text": self.handler_text,
108
+ "message": self.message,
109
+ }
110
+
111
+
112
+ def _is_catch_all(exc_type: ast.expr | None) -> bool:
113
+ """True when the handler catches every exception (bare except / except Exception)."""
114
+ if exc_type is None:
115
+ return True
116
+ if isinstance(exc_type, ast.Attribute):
117
+ return exc_type.attr == "Exception"
118
+ if isinstance(exc_type, ast.Name):
119
+ return exc_type.id == "Exception"
120
+ return False
121
+
122
+
123
+ def _constant_value(node: ast.expr) -> str | None:
124
+ """Render simple constant expressions to a canonical string, else None."""
125
+ if isinstance(node, ast.Constant):
126
+ if isinstance(node.value, bool):
127
+ return "True" if node.value else "False"
128
+ if node.value is None:
129
+ return "None"
130
+ if node.value == 0 and isinstance(node.value, int):
131
+ return "0"
132
+ if node.value == 0.0 and isinstance(node.value, float):
133
+ return "0.0"
134
+ if node.value in ("", b""):
135
+ return repr(node.value)
136
+ return None
137
+ if isinstance(node, ast.List) and not node.elts:
138
+ return "[]"
139
+ if isinstance(node, ast.Dict) and not node.keys:
140
+ return "{}"
141
+ if isinstance(node, ast.Tuple) and not node.elts:
142
+ return "()"
143
+ if (
144
+ isinstance(node, ast.UnaryOp)
145
+ and isinstance(node.op, ast.USub)
146
+ and isinstance(node.operand, ast.Constant)
147
+ ):
148
+ v = node.operand.value
149
+ if v == 0:
150
+ return "0"
151
+ if v == 0.0:
152
+ return "0.0"
153
+ return None
154
+
155
+
156
+ def _body_has_raise(statements: list[ast.stmt]) -> bool:
157
+ """True when any statement in ``statements`` (its own scope) raises."""
158
+ for child in _walk_scope(statements):
159
+ if isinstance(child, ast.Raise):
160
+ return True
161
+ return False
162
+
163
+
164
+ def _walk_scope(nodes: list[ast.stmt]):
165
+ """Yield nodes belonging to the *enclosing* scope of ``nodes``.
166
+
167
+ Descends into control-flow statements but **not** into nested
168
+ function/lambda/class bodies: a ``return None`` inside a callback defined
169
+ in the handler belongs to the callback's contract, not to the handler's.
170
+ """
171
+ stack = list(nodes)
172
+ while stack:
173
+ node = stack.pop()
174
+ yield node
175
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
176
+ continue
177
+ stack.extend(ast.iter_child_nodes(node))
178
+
179
+
180
+ def _body_has_call(statements: list[ast.stmt], names: set[str]) -> bool:
181
+ for child in _walk_scope(statements):
182
+ if isinstance(child, ast.Raise):
183
+ return True
184
+ if isinstance(child, ast.Call):
185
+ func = getattr(child, "func", None)
186
+ if func is not None:
187
+ name = _call_name(func)
188
+ if name in names:
189
+ return True
190
+ return False
191
+
192
+
193
+ def _call_name(func: ast.expr) -> str | None:
194
+ if isinstance(func, ast.Name):
195
+ return func.id
196
+ if isinstance(func, ast.Attribute):
197
+ return func.attr
198
+ return None
199
+
200
+
201
+ def _is_constant_fallback_return(stmts: list[ast.stmt]) -> str | None:
202
+ """Return the constant name when the handler's *final* statement returns it."""
203
+ for stmt in reversed(stmts):
204
+ if isinstance(stmt, ast.Return):
205
+ if stmt.value is None:
206
+ return "None"
207
+ value = _constant_value(stmt.value)
208
+ if value is not None and value in _FALLBACK_CONSTANT_NAMES:
209
+ return value
210
+ return None
211
+ return None
212
+
213
+
214
+ class _HandlerVisitor(ast.NodeVisitor):
215
+ """Collect failure-routing findings inside a single exception handler.
216
+
217
+ The body is walked in statement order with an *alive* flag: an
218
+ unconditional ``raise`` or ``return`` terminates control flow, so
219
+ statements after it are unreachable and are never reported (a fallback
220
+ return after a bare raise is dead code, not a masking defect).
221
+ """
222
+
223
+ def __init__(self, *, file: str, handler: ast.ExceptHandler, exc_name: str | None) -> None:
224
+ self.file = file
225
+ self.handler = handler
226
+ self.exc_name = exc_name
227
+ self.findings: list[Finding] = []
228
+ self._has_conditional_raise = self._scan_conditional_raises()
229
+ self._has_fallback_return = self._scan_fallback_returns()
230
+ self._shadows_exc_name = self._scan_name_shadowing()
231
+ # A handler that terminates the process never lets a caller observe
232
+ # an assigned fallback value, so assignments are not "silent".
233
+ self._exits_process = _body_has_call(self.handler.body, _ACTIVE_CALL_NAMES)
234
+
235
+ def visit(self, node: ast.AST) -> None: # noqa: D102
236
+ if self._body_is_effectively_empty():
237
+ self.findings.append(
238
+ _finding(
239
+ self.file,
240
+ self.handler,
241
+ FailureMode.NO_ACTION,
242
+ self.exc_name,
243
+ message="exception handler does nothing; the failure is silently discarded",
244
+ )
245
+ )
246
+ return
247
+
248
+ # Rebinding the caught exception variable is reported independently of
249
+ # everything else: it is a NameError hazard, not a routing decision.
250
+ if self._shadows_exc_name:
251
+ self.findings.append(
252
+ _finding(
253
+ self.file,
254
+ self.handler,
255
+ FailureMode.NAME_SHADOWING,
256
+ self.exc_name,
257
+ message=f"exception variable {self.exc_name!r} is rebound inside the "
258
+ "handler; Python deletes the binding when the handler exits, so any "
259
+ "later use of that name raises NameError",
260
+ )
261
+ )
262
+
263
+ # A conditional raise combined with a fallback return produces a
264
+ # branch-dependent outcome (masked failure); report it once up front.
265
+ # The masking contract is about *catch-all* handlers — a typed handler
266
+ # that conditionally re-raises is usually deliberate retry logic.
267
+ if _is_catch_all(self.handler.type) and self._has_conditional_raise and self._has_fallback_return:
268
+ self.findings.append(
269
+ _finding(
270
+ self.file,
271
+ self.handler,
272
+ FailureMode.MASKED_EXCEPTION,
273
+ self.exc_name,
274
+ message="catch-all conditionally re-raises but also falls back to a "
275
+ "constant return; the failure outcome depends on the branch",
276
+ )
277
+ )
278
+ return
279
+
280
+ # Handlers that log the failure at a severity worth reading are leaving
281
+ # a trace; they are informational, not silent corruption. Only the
282
+ # silent variants are findings.
283
+ if _handler_logs_error(self.handler):
284
+ return
285
+
286
+ for stmt in self.handler.body:
287
+ alive, _reported = self._examine(stmt)
288
+ if not alive:
289
+ break
290
+
291
+ def _examine(self, stmt: ast.stmt) -> tuple[bool, bool]:
292
+ """Examine one statement; returns (control_flow_continues, reported)."""
293
+ if isinstance(stmt, ast.Raise):
294
+ return False, False
295
+ if isinstance(stmt, ast.Return):
296
+ value = _constant_value(stmt.value) if stmt.value is not None else "None"
297
+ if value in _FALLBACK_CONSTANT_NAMES:
298
+ self.findings.append(
299
+ _finding(
300
+ self.file,
301
+ self.handler,
302
+ FailureMode.SILENT_FALLBACK,
303
+ self.exc_name,
304
+ message=(
305
+ f"exception handler returns constant {value!r} without re-raising; "
306
+ "the caller cannot distinguish failure from a legitimate value of "
307
+ "the same shape"
308
+ ),
309
+ )
310
+ )
311
+ return False, True
312
+ return False, False
313
+ if isinstance(stmt, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
314
+ # Only constant fallback values are actionable; assignments that
315
+ # derive from the exception itself (e.g. error_msg = str(e)) are
316
+ # part of error handling, not silent fallbacks. Assignments in a
317
+ # handler that ends the process are never caller-observable.
318
+ value = _constant_value(stmt.value) if stmt.value is not None else None
319
+ if (
320
+ value in _FALLBACK_CONSTANT_NAMES
321
+ and not self._exits_process
322
+ and not _body_has_call([stmt], _ACTIVE_CALL_NAMES)
323
+ ):
324
+ self.findings.append(
325
+ _finding(
326
+ self.file,
327
+ self.handler,
328
+ FailureMode.SILENT_FALLBACK,
329
+ self.exc_name,
330
+ message=f"exception handler assigns fallback constant {value!r} without "
331
+ "re-raising or recording the error",
332
+ )
333
+ )
334
+ return True, True
335
+ return True, False
336
+
337
+ def _scan_conditional_raises(self) -> bool:
338
+ """True when the handler itself raises inside a branch (if/while/for).
339
+
340
+ Nested function bodies are excluded: a ``raise`` inside a callback
341
+ defined in the handler belongs to the callback, not to the handler's
342
+ own control flow.
343
+ """
344
+ for stmt in self.handler.body:
345
+ if isinstance(stmt, (ast.If, ast.While, ast.For)) and _body_has_raise([stmt]):
346
+ return True
347
+ return False
348
+
349
+ def _scan_fallback_returns(self) -> bool:
350
+ """True when the handler itself returns a fallback constant anywhere."""
351
+ for node in _walk_scope(self.handler.body):
352
+ if isinstance(node, ast.Return):
353
+ value = node.value if node.value is not None else None
354
+ rendered = _constant_value(value) if value is not None else "None"
355
+ if rendered in _FALLBACK_CONSTANT_NAMES:
356
+ return True
357
+ return False
358
+
359
+ def _scan_name_shadowing(self) -> bool:
360
+ """True when the handler rebinds the caught exception variable.
361
+
362
+ Python implicitly deletes the ``except E as name`` binding when the
363
+ handler exits, and any rebinding inside the body loses the original
364
+ exception object mid-handler.
365
+ """
366
+ if not self.exc_name:
367
+ return False
368
+ for node in _walk_scope(self.handler.body):
369
+ targets: list[ast.expr] = []
370
+ if isinstance(node, ast.Assign):
371
+ targets = node.targets
372
+ elif isinstance(node, (ast.AnnAssign, ast.AugAssign, ast.For, ast.NamedExpr)):
373
+ targets = [node.target]
374
+ elif isinstance(node, (ast.With, ast.AsyncFor)):
375
+ targets = [item.optional_vars for item in node.items if item.optional_vars]
376
+ for target in targets:
377
+ if isinstance(target, ast.Name) and target.id == self.exc_name:
378
+ return True
379
+ return False
380
+
381
+ def _body_is_effectively_empty(self) -> bool:
382
+ for stmt in self.handler.body:
383
+ if isinstance(stmt, ast.Pass):
384
+ continue
385
+ if (
386
+ isinstance(stmt, ast.Expr)
387
+ and isinstance(stmt.value, ast.Constant)
388
+ and stmt.value.value is Ellipsis
389
+ ):
390
+ continue
391
+ return False
392
+ return True
393
+
394
+
395
+ def _finding(
396
+ file: str, handler: ast.ExceptHandler, mode: FailureMode, exc_name: str | None, *, message: str
397
+ ) -> Finding:
398
+ text = _render_handler(handler)
399
+ return Finding(
400
+ file=file,
401
+ lineno=handler.lineno,
402
+ end_lineno=getattr(handler, "end_lineno", handler.lineno),
403
+ mode=mode,
404
+ exc_name=exc_name,
405
+ handler_text=text,
406
+ message=message,
407
+ )
408
+
409
+
410
+ def _render_handler(handler: ast.ExceptHandler, limit: int = 240) -> str:
411
+ try:
412
+ start = handler.lineno
413
+ end = getattr(handler, "end_lineno", start)
414
+ if end > start:
415
+ return f"except ... (lines {start}-{end})"
416
+ except Exception: # pragma: no cover - defensive
417
+ pass
418
+ return f"except ... (line {handler.lineno})"
419
+
420
+
421
+ def scan_tree(tree: ast.AST, *, file: str = "<source>", source: str | None = None) -> list[Finding]:
422
+ """Scan a parsed AST for failure-routing anti-patterns.
423
+
424
+ ``source`` (the original file text) is used to detect explicit
425
+ opt-out markers: ``# pragma: no cover`` (defensive code) and
426
+ ``# failroute: ignore`` (reviewed-and-accepted in a code review).
427
+ """
428
+ findings: list[Finding] = []
429
+ # Split once per file, not once per handler (large modules have hundreds).
430
+ source_lines = source.splitlines() if source is not None else None
431
+
432
+ class Walker(ast.NodeVisitor):
433
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None: # noqa: D102
434
+ exc_name: str | None = None
435
+ if node.name:
436
+ exc_name = node.name
437
+ # Skip handlers for exceptions we've explicitly decided are noise.
438
+ if _is_ignored_handler(node) or _handler_marked_off(node, source_lines):
439
+ return
440
+ visitor = _HandlerVisitor(file=file, handler=node, exc_name=exc_name)
441
+ visitor.visit(node)
442
+ findings.extend(visitor.findings)
443
+ # Recurse into nested handlers inside this handler's body.
444
+ for child in ast.walk(node):
445
+ if child is not node and isinstance(child, ast.ExceptHandler):
446
+ self.visit_ExceptHandler(child)
447
+
448
+ Walker().visit(tree)
449
+ return findings
450
+
451
+
452
+ def _handler_marked_off(handler: ast.ExceptHandler, lines: list[str] | None) -> bool:
453
+ """True when the handler's source slice carries an opt-out marker.
454
+
455
+ Two markers are honoured:
456
+
457
+ * ``# pragma: no cover`` — explicitly defensive code.
458
+ * ``# failroute: ignore`` — reviewed and accepted (e.g. a documented
459
+ fallback whose semantics the team wants to keep).
460
+
461
+ Matching is text-based and line-scoped: a marker anywhere inside the
462
+ handler's lines suppresses all findings for that handler.
463
+ """
464
+ if lines is None:
465
+ return False
466
+ try:
467
+ start = handler.lineno
468
+ end = getattr(handler, "end_lineno", start) + 1
469
+ slice_text = "\n".join(lines[start - 1 : end])
470
+ except Exception: # defensive - slicing failures should never crash a scan
471
+ return False
472
+ return "# pragma: no cover" in slice_text or "# failroute: ignore" in slice_text
473
+
474
+
475
+ #: ``<logger>.<severity>(...)`` calls that record a failure worth reading.
476
+ #: A bare ``print(...)`` or ``logger.debug(...)`` does not qualify: printing
477
+ #: progress text is not failure recording.
478
+ _LOG_SEVERITY_METHODS = {"error", "warning", "warn", "exception", "critical", "fatal"}
479
+ _LOGGER_BASE_NAMES = {"logger", "logging", "log", "_logger", "sentry_sdk"}
480
+
481
+
482
+ def _handler_logs_error(handler: ast.ExceptHandler) -> bool:
483
+ """True when the handler records the failure somewhere routable.
484
+
485
+ Two tiers:
486
+
487
+ - **Catch-all handlers** (bare ``except:`` / ``except Exception:``) must
488
+ log at a severity worth reading (``warning``+) — a ``debug`` line or a
489
+ ``print`` is not a trace that survives production triage.
490
+ - **Typed handlers** name an anticipated failure mode; recording it at
491
+ *any* level (even ``logger.info``) is enough, because the exception
492
+ type itself already documents the routing decision.
493
+ """
494
+ catch_all = _is_catch_all(handler.type)
495
+ for stmt in handler.body:
496
+ for child in ast.walk(stmt):
497
+ if not isinstance(child, ast.Call):
498
+ continue
499
+ func = child.func
500
+ if isinstance(func, ast.Attribute):
501
+ base = func.value
502
+ base_is_loggerish = (isinstance(base, ast.Name) and base.id in _LOGGER_BASE_NAMES) or (
503
+ isinstance(base, ast.Attribute) and base.attr in _LOGGER_BASE_NAMES
504
+ )
505
+ if func.attr == "capture_exception":
506
+ if base_is_loggerish:
507
+ return True
508
+ elif base_is_loggerish and (not catch_all or func.attr in _LOG_SEVERITY_METHODS):
509
+ return True
510
+ return False
511
+
512
+
513
+ def _is_ignored_handler(node: ast.ExceptHandler) -> bool:
514
+ if node.type is None:
515
+ return False
516
+ if isinstance(node.type, ast.Name) and node.type.id in _IGNORED_EXC_NAMES:
517
+ return True
518
+ if isinstance(node.type, ast.Attribute) and node.type.attr in _IGNORED_EXC_NAMES:
519
+ return True
520
+ return False
521
+
522
+
523
+ def scan_source(source: str, *, file: str = "<source>") -> list[Finding]:
524
+ """Scan a source string and return findings."""
525
+ tree = ast.parse(source, filename=file)
526
+ return scan_tree(tree, file=file, source=source)
527
+
528
+
529
+ def scan_path(path: Path, *, follow_links: bool = False) -> list[Finding]:
530
+ """Scan a single file (or a directory tree) for findings."""
531
+ path = Path(path)
532
+ findings: list[Finding] = []
533
+ if path.is_file():
534
+ if path.suffix != ".py":
535
+ return findings
536
+ try:
537
+ source = path.read_text(encoding="utf-8", errors="replace")
538
+ except OSError as exc: # pragma: no cover - read errors are environment-specific
539
+ logger.warning("skipping %s: %s", path, exc)
540
+ return findings
541
+ try:
542
+ findings.extend(scan_source(source, file=str(path)))
543
+ except SyntaxError:
544
+ logger.debug("skipping %s: not parseable as Python", path)
545
+ except RecursionError:
546
+ # Deeply nested generated files (payload resources, vendored
547
+ # schemas) can exceed the interpreter's recursion budget during
548
+ # the AST walk. A scanner must never crash on hostile input.
549
+ logger.debug("skipping %s: AST nesting exceeds recursion budget", path)
550
+ return findings
551
+
552
+ for sub in sorted(path.rglob("*.py")):
553
+ if sub.is_dir():
554
+ continue
555
+ findings.extend(scan_path(sub))
556
+ return findings
557
+
558
+
559
+ def scan_repo(
560
+ root: Path,
561
+ *,
562
+ skip_dirs: set[str] | None = None,
563
+ exclude: set[str] | None = None,
564
+ ) -> list[Finding]:
565
+ """Scan a repository checkout, skipping conventional junk directories.
566
+
567
+ ``exclude`` holds repo-relative paths (``tests/corpus``) that are skipped
568
+ even though they are valid Python -- used to keep intentional fixtures out
569
+ of self-scans.
570
+ """
571
+ skip_dirs = skip_dirs or {
572
+ ".git",
573
+ ".venv",
574
+ "venv",
575
+ "node_modules",
576
+ "build",
577
+ "dist",
578
+ ".tox",
579
+ "__pycache__",
580
+ ".mypy_cache",
581
+ ".pytest_cache",
582
+ ".ruff_cache",
583
+ }
584
+ exclude = {e.strip("/") for e in (exclude or set())}
585
+ findings: list[Finding] = []
586
+ root = Path(root)
587
+ for sub in sorted(root.rglob("*.py")):
588
+ rel = sub.relative_to(root)
589
+ if any(part in skip_dirs for part in rel.parts):
590
+ continue
591
+ rel_str = str(rel).replace("\\", "/")
592
+ if any(rel_str == ex or rel_str.startswith(ex + "/") for ex in exclude):
593
+ continue
594
+ findings.extend(scan_path(sub))
595
+ return findings
failroute/cli.py ADDED
@@ -0,0 +1,142 @@
1
+ """Command-line interface for failroute."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from failroute.analyzer import Finding, scan_path, scan_repo
11
+ from failroute.sarif import to_sarif_json
12
+
13
+
14
+ def _version() -> str:
15
+ from importlib import metadata
16
+
17
+ return metadata.version("failroute")
18
+
19
+
20
+ _EPILOG = """\
21
+ exit status:
22
+ 0 scan completed, no findings
23
+ 1 scan completed, findings above threshold
24
+ 2 usage or input error
25
+ """
26
+
27
+
28
+ def build_parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(
30
+ prog="failroute",
31
+ description="Detect failure-routing anti-patterns (silently swallowed exceptions, "
32
+ "silent fallback returns) in Python source.",
33
+ epilog=_EPILOG,
34
+ formatter_class=argparse.RawDescriptionHelpFormatter,
35
+ )
36
+ parser.add_argument(
37
+ "path",
38
+ nargs="?",
39
+ default=".",
40
+ help="file or directory to scan (default: current directory)",
41
+ )
42
+ parser.add_argument(
43
+ "--repo",
44
+ action="store_true",
45
+ help="treat PATH as a repository checkout and skip conventional junk dirs (.git, .venv, build, ...)",
46
+ )
47
+ parser.add_argument(
48
+ "--format",
49
+ choices=("text", "json", "sarif"),
50
+ default="text",
51
+ help="output format (default: text)",
52
+ )
53
+ parser.add_argument(
54
+ "--json",
55
+ action="store_true",
56
+ help="alias for --format json (one object per line; kept for backwards compatibility)",
57
+ )
58
+ parser.add_argument(
59
+ "--output",
60
+ type=Path,
61
+ default=None,
62
+ help="write results to FILE instead of stdout (implies --format unless --format given)",
63
+ )
64
+ parser.add_argument(
65
+ "--threshold",
66
+ type=int,
67
+ default=0,
68
+ help="exit 1 when more findings than this are emitted (default: 0)",
69
+ )
70
+ parser.add_argument(
71
+ "--quiet",
72
+ action="store_true",
73
+ help="only print the finding count summary",
74
+ )
75
+ parser.add_argument(
76
+ "--exclude",
77
+ action="append",
78
+ default=[],
79
+ metavar="PATH",
80
+ help="repo-relative path to skip, repeatable (e.g. --exclude tests/corpus); "
81
+ "implies repository-style traversal",
82
+ )
83
+ parser.add_argument(
84
+ "--version",
85
+ action="version",
86
+ version=f"%(prog)s {_version()}",
87
+ )
88
+ return parser
89
+
90
+
91
+ def _sort_key(finding: Finding) -> tuple[str, int, str]:
92
+ return (finding.file, finding.lineno, finding.mode.value)
93
+
94
+
95
+ def _render(findings: list[Finding], fmt: str) -> str:
96
+ if fmt == "json":
97
+ return "\n".join(json.dumps(f.to_dict(), ensure_ascii=False) for f in findings)
98
+ if fmt == "sarif":
99
+ return to_sarif_json(findings)
100
+ return "\n".join(f"{f.file}:{f.lineno}: {f.mode.value}: {f.message}" for f in findings)
101
+
102
+
103
+ def main(argv: list[str] | None = None) -> int:
104
+ parser = build_parser()
105
+ args = parser.parse_args(argv)
106
+
107
+ path = Path(args.path)
108
+ if not path.exists():
109
+ print(f"failroute: error: no such path: {path}", file=sys.stderr)
110
+ return 2
111
+
112
+ # --json is a legacy alias; --format wins when both are supplied.
113
+ fmt = args.format
114
+ if args.json and args.format == "text":
115
+ fmt = "json"
116
+
117
+ if args.repo or args.exclude:
118
+ findings = scan_repo(path, exclude=set(args.exclude))
119
+ else:
120
+ findings = scan_path(path)
121
+
122
+ # Deterministic order regardless of filesystem enumeration.
123
+ findings.sort(key=_sort_key)
124
+
125
+ rendered = _render(findings, fmt)
126
+
127
+ if args.output is not None:
128
+ args.output.write_text(rendered + "\n", encoding="utf-8")
129
+ elif not args.quiet and rendered:
130
+ print(rendered)
131
+
132
+ # Quiet mode suppresses findings but still reports the count on stderr,
133
+ # so scripts can rely on the summary line for logging.
134
+ if args.output is not None:
135
+ print(f"{len(findings)} finding(s) written to {args.output}", file=sys.stderr)
136
+ else:
137
+ print(f"{len(findings)} finding(s)", file=sys.stderr)
138
+ return 0 if len(findings) <= args.threshold else 1
139
+
140
+
141
+ if __name__ == "__main__": # pragma: no cover
142
+ raise SystemExit(main())
failroute/sarif.py ADDED
@@ -0,0 +1,138 @@
1
+ """SARIF 2.1.0 output for failroute.
2
+
3
+ Emits findings in the Static Analysis Results Interchange Format so the
4
+ tool can plug directly into GitHub code scanning (``upload-sarif`` action)
5
+ or any SARIF-consuming CI. Severity mapping:
6
+
7
+ * ``silent-fallback`` -> error (silent data corruption)
8
+ * ``masked-exception`` -> warning (branch-dependent outcome)
9
+ * ``no-action`` -> warning (dropped failure, no trace)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from failroute.analyzer import FailureMode, Finding
17
+
18
+ # Kept local to avoid a circular import with failroute/__init__.py.
19
+ __version__ = "0.2.0"
20
+ _RULE_ID_PREFIX = "failroute"
21
+
22
+
23
+ def _level(mode: FailureMode) -> str:
24
+ if mode is FailureMode.SILENT_FALLBACK:
25
+ return "error"
26
+ return "warning"
27
+
28
+
29
+ def _rule_descriptions() -> dict[str, dict[str, str]]:
30
+ return {
31
+ "no-action": {
32
+ "name": "NoAction",
33
+ "shortDescription": "Exception is silently discarded (pass/...)",
34
+ "fullDescription": (
35
+ "The exception handler does nothing: the failure is dropped and callers "
36
+ "can never learn the operation failed. Log the error, re-raise, or "
37
+ "return an explicit failure signal."
38
+ ),
39
+ "defaultLevel": "warning",
40
+ },
41
+ "silent-fallback": {
42
+ "name": "SilentFallback",
43
+ "shortDescription": "Failure is converted to a constant fallback value",
44
+ "fullDescription": (
45
+ "The handler returns or assigns a constant fallback (None/0/0.0/False/[]/{}...)"
46
+ " without re-raising or recording the error, so the caller cannot distinguish "
47
+ "a real failure from a legitimate value of the same shape. Route the failure "
48
+ "to the correct outcome instead (propagate, log at error level, or return an "
49
+ "explicit error object)."
50
+ ),
51
+ "defaultLevel": "error",
52
+ },
53
+ "masked-exception": {
54
+ "name": "MaskedException",
55
+ "shortDescription": "Branch-dependent failure outcome",
56
+ "fullDescription": (
57
+ "The handler conditionally re-raises but also falls back to a constant return; "
58
+ "whether the caller sees failure or success depends on the branch. Make the "
59
+ "failure path unconditional or propagate explicitly."
60
+ ),
61
+ "defaultLevel": "warning",
62
+ },
63
+ }
64
+
65
+
66
+ def to_sarif(findings: list[Finding], *, repo_uri: str | None = None) -> dict[str, Any]:
67
+ """Render findings as a SARIF 2.1.0 run document."""
68
+ rules = _rule_descriptions()
69
+ results: list[dict[str, Any]] = []
70
+
71
+ for finding in findings:
72
+ rule_id = f"{_RULE_ID_PREFIX}/{finding.mode.value}"
73
+ location: dict[str, Any] = {
74
+ "physicalLocation": {
75
+ "artifactLocation": {"uri": finding.file},
76
+ "region": {
77
+ "startLine": finding.lineno,
78
+ "endLine": finding.end_lineno or finding.lineno,
79
+ },
80
+ }
81
+ }
82
+ if repo_uri:
83
+ location["logicalLocations"] = [
84
+ {"fullyQualifiedName": f"{rule_id} in {finding.file}:{finding.lineno}"}
85
+ ]
86
+
87
+ results.append(
88
+ {
89
+ "ruleId": rule_id,
90
+ "level": _level(finding.mode),
91
+ "message": {"text": finding.message},
92
+ "locations": [location],
93
+ "properties": {
94
+ "exc_name": finding.exc_name or "",
95
+ "mode": finding.mode.value,
96
+ "handler_text": finding.handler_text,
97
+ },
98
+ }
99
+ )
100
+
101
+ rules_metadata = []
102
+ for rule_name, meta in rules.items():
103
+ rule_id = f"{_RULE_ID_PREFIX}/{rule_name}"
104
+ rules_metadata.append(
105
+ {
106
+ "id": rule_id,
107
+ "name": meta["name"],
108
+ "shortDescription": {"text": meta["shortDescription"]},
109
+ "fullDescription": {"text": meta["fullDescription"]},
110
+ "defaultConfiguration": {"level": meta["defaultLevel"]},
111
+ "helpUri": "https://github.com/feiiiiii5/failroute#readme",
112
+ }
113
+ )
114
+
115
+ return {
116
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
117
+ "version": "2.1.0",
118
+ "runs": [
119
+ {
120
+ "tool": {
121
+ "driver": {
122
+ "name": "failroute",
123
+ "version": __version__,
124
+ "informationUri": "https://github.com/feiiiiii5/failroute",
125
+ "rules": rules_metadata,
126
+ }
127
+ },
128
+ "results": results,
129
+ }
130
+ ],
131
+ }
132
+
133
+
134
+ def to_sarif_json(findings: list[Finding], *, repo_uri: str | None = None) -> str:
135
+ """Render findings as a JSON-encoded SARIF document."""
136
+ import json
137
+
138
+ return json.dumps(to_sarif(findings, repo_uri=repo_uri), indent=2)
@@ -0,0 +1,205 @@
1
+ Metadata-Version: 2.4
2
+ Name: failroute
3
+ Version: 0.3.0
4
+ Summary: Static detection of failure-routing anti-patterns in Python
5
+ Author: fc
6
+ License: MIT
7
+ Keywords: static-analysis,exception-handling,code-quality,llm,reliability
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Quality Assurance
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest>=7; extra == "test"
22
+ Dynamic: license-file
23
+
24
+ # failroute
25
+
26
+ Static detection of **failure-routing** anti-patterns in Python: the practice of
27
+ converting an underlying failure into a success-like outcome at the wrong layer.
28
+
29
+ Failure-routing is the root cause behind some of the most insidious correctness
30
+ bugs in real LLM/eval/agent codebases:
31
+
32
+ ```python
33
+ # Before — a judge API outage becomes a perfect "0.0 score" with no way to tell
34
+ try:
35
+ score = await llm_judge(prompt)
36
+ except Exception:
37
+ return 0.0 # ← silent fallback: failure looks like a legitimate low score
38
+
39
+ # After — the failure propagates; callers can route it to the right outcome
40
+ return await llm_judge(prompt)
41
+ ```
42
+
43
+ ## What it detects
44
+
45
+ | Mode | Pattern |
46
+ | --- | --- |
47
+ | `no-action` | `except ...: pass` — the exception is discarded, callers never learn |
48
+ | `silent-fallback` | handler returns/assigns a constant (`None`, `0`, `0.0`, `False`, `[]`, …) without re-raising |
49
+ | `masked-exception` | **catch-all** handler re-raises conditionally yet also falls through to a success-looking return |
50
+ | `name-shadowing` | `except E as e:` whose body rebinds `e` — Python deletes the binding at handler exit, so later uses raise `NameError` |
51
+
52
+ Findings are emitted as `file:line: mode: message`, or as JSON for CI.
53
+
54
+ ### Logging exemption (two tiers)
55
+
56
+ A handler that *records* the failure is informational, not silent — but what
57
+ counts as a record depends on how wide the handler is:
58
+
59
+ - **Catch-all handlers** (`except:` / `except Exception:`) must log at a
60
+ severity worth reading (`warning`+). A `debug` line or a bare `print(...)`
61
+ does not survive production triage, so it does not exempt.
62
+ - **Typed handlers** name an anticipated failure mode; recording it at *any*
63
+ level (even `logger.info`) is enough.
64
+
65
+ ## Usage
66
+
67
+ ```console
68
+ $ failroute path/to/file.py
69
+ $ failroute path/to/dir # recursive
70
+ $ failroute --repo . # skip .git/.venv/build/...
71
+ $ failroute --repo . --exclude tests/corpus # repeatable path exclusions
72
+ $ failroute --json --repo . | jq 'select(.mode=="silent-fallback")'
73
+ $ failroute --format sarif --output results.sarif --repo . # code scanning
74
+ $ failroute --threshold 5 # exit 1 when more than 5 findings
75
+ $ python -m failroute . # module form (no console script needed)
76
+ ```
77
+
78
+ Exit codes: `0` clean, `1` findings above threshold, `2` usage error.
79
+
80
+ ### Output formats
81
+
82
+ ```console
83
+ $ failroute --format text path/ # default: file:line: mode: message
84
+ $ failroute --format json path/ # one JSON object per finding
85
+ $ failroute --format sarif --output scan.sarif path/ # SARIF 2.1.0
86
+ ```
87
+
88
+ SARIF output plugs straight into [GitHub code scanning](
89
+ https://docs.github.com/en/code-security/code-scanning) via the
90
+ `upload-sarif` action, so findings appear inline on pull requests:
91
+
92
+ ```yaml
93
+ - run: failroute --format sarif --output results.sarif --repo .
94
+ - uses: github/codeql-action/upload-sarif@v3
95
+ with:
96
+ sarif_file: results.sarif
97
+ ```
98
+
99
+ Or use the bundled composite action, which installs failroute, scans, and
100
+ uploads SARIF in one step:
101
+
102
+ ```yaml
103
+ - uses: feiiiiii5/failroute/action@main
104
+ with:
105
+ path: src
106
+ exclude: tests/corpus fixtures
107
+ threshold: "0"
108
+ ```
109
+
110
+ Severity mapping: `silent-fallback` → `error`, `no-action` and
111
+ `masked-exception` → `warning`.
112
+
113
+ ### Suppressing findings
114
+
115
+ Reviewed-and-accepted handlers can be opted out with a line marker (the
116
+ scanner honors both):
117
+
118
+ ```python
119
+ try:
120
+ return best_effort()
121
+ except Exception: # failroute: ignore - documented fallback semantics
122
+ return None
123
+ ```
124
+
125
+ `# pragma: no cover` markers are honored as well (explicitly defensive code).
126
+
127
+ ## Examples that trip it
128
+
129
+ ```python
130
+ def classify(text): # no-action
131
+ try:
132
+ return model.predict(text)
133
+ except Exception:
134
+ pass # 💥 swallowed
135
+
136
+ def score(prompt): # silent-fallback
137
+ try:
138
+ return judge(prompt)
139
+ except Exception:
140
+ return 0.0 # 💥 outage == "0.0 score"
141
+
142
+ def fetch(url): # silent-fallback (assign)
143
+ data = None
144
+ try:
145
+ data = download(url)
146
+ except Exception:
147
+ data = {"items": []} # 💥 error looks like an empty result
148
+ return data
149
+ ```
150
+
151
+ ## What it does *not* flag (by design)
152
+
153
+ * `except KeyboardInterrupt` / `except SystemExit` — normally intentional.
154
+ * Handlers that re-raise unconditionally without a fallback.
155
+ * `except` bodies that log at `warning`/`error` **and** re-raise — the failure
156
+ still propagates; we only flag the success-looking path.
157
+
158
+ Run `failroute` on its own checkout as a smoke test:
159
+
160
+ ```console
161
+ $ pip install -e .
162
+ $ failroute --repo . # expected: zero findings (self-hosting)
163
+ ```
164
+
165
+ ## Benchmarks & validation
166
+
167
+ All numbers below are reproducible from this checkout; nothing here is
168
+ copy-pasted from a run that cannot be re-executed.
169
+
170
+ ### Labelled corpus (precision / recall)
171
+
172
+ `tests/corpus/` holds 19 hand-labelled exception handlers (10 positives across
173
+ all three modes, 9 negatives covering re-raise, log-and-raise, derived values,
174
+ dead code, opt-out markers, and non-fallback constants). Ground truth lives in
175
+ `tests/corpus/manifest.json` and was written from the *semantics* of each
176
+ fixture, independently of tool output.
177
+
178
+ ```
179
+ corpus v1 TP=10 FP=0 FN=0 TN=9
180
+ precision=1.0 recall=1.0
181
+ ```
182
+
183
+ Re-run: `python tools/benchmark.py` (also enforced by `pytest`).
184
+
185
+ ### What syntactic linters miss
186
+
187
+ Against the source packages of 8 real AI/eval repositories (garak,
188
+ inspect_ai, pydantic-ai, uqlm, trl, smolagents, deepteam, fickling),
189
+ failroute reported **647 findings**; ruff's exception-handling rules
190
+ (`S110` try-except-pass, `S112` try-except-continue) reported **80**, of which
191
+ 70 overlap failroute's `no-action` mode. The remaining **390 findings are
192
+ silent-fallback / masked-exception handlers** -- failures converted into
193
+ success-looking values -- a class syntactic rules cannot express by
194
+ construction.
195
+
196
+ Re-run: `python tools/compare_ruff.py <repo> [<repo> ...]`.
197
+ Results are checked into `bench/`.
198
+
199
+ ## Development
200
+
201
+ ```console
202
+ $ pip install -e ".[test]"
203
+ $ pytest
204
+ $ ruff check .
205
+ ```
@@ -0,0 +1,11 @@
1
+ failroute/__init__.py,sha256=LlhH5_ytJ6zErxDP1KO3JeoK71pRFaerOJ9aDIPfH1E,908
2
+ failroute/__main__.py,sha256=_cDCxmDWSHr8AdBDUZa3pl0MO1Xqmy67eqVKeUEdYV8,137
3
+ failroute/analyzer.py,sha256=YBz8jVOMUyRtpHzsDHPHcVyzmT4nkp9ACS4HzmDrTDA,22992
4
+ failroute/cli.py,sha256=yJTsUo56JmS9hVzmnFEdBAtVeYZ9b7-tnZvED9eN9K4,4163
5
+ failroute/sarif.py,sha256=Q17qfCxuVMkdpqYevZtzqve3SktBRY_Gji-CBgyrhKU,5056
6
+ failroute-0.3.0.dist-info/licenses/LICENSE,sha256=EE5laedDLTMkAgqW8xGaOMnrQmfL0WrMvyb1-_erXD0,1066
7
+ failroute-0.3.0.dist-info/METADATA,sha256=_fPNdI6hb4ANe8wQP03DCYk48cI2SkHcR0sy6ZTYgk4,6973
8
+ failroute-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
9
+ failroute-0.3.0.dist-info/entry_points.txt,sha256=Umd9xLIy-vdy7Syg33jSIy3oR7BQ0CabE19pA5Fy6Xw,49
10
+ failroute-0.3.0.dist-info/top_level.txt,sha256=s-KPRrjUueQkxfQ6jdbWtf9dbqjS3ehtQzTkMTRWR9Y,10
11
+ failroute-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ failroute = failroute.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 feiiiiii5
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ failroute