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 +27 -0
- failroute/__main__.py +6 -0
- failroute/analyzer.py +595 -0
- failroute/cli.py +142 -0
- failroute/sarif.py +138 -0
- failroute-0.3.0.dist-info/METADATA +205 -0
- failroute-0.3.0.dist-info/RECORD +11 -0
- failroute-0.3.0.dist-info/WHEEL +5 -0
- failroute-0.3.0.dist-info/entry_points.txt +2 -0
- failroute-0.3.0.dist-info/licenses/LICENSE +21 -0
- failroute-0.3.0.dist-info/top_level.txt +1 -0
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
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,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
|