python-vibe-guard 0.7.1__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.
@@ -0,0 +1,52 @@
1
+ import ast
2
+ from typing import List
3
+ from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
4
+
5
+ HTTPX_BLOCKING = {"get", "post", "put", "patch", "delete", "request", "stream"}
6
+
7
+
8
+ class HttpxSyncRule(AsyncBlockingCallVisitor):
9
+ """
10
+ PYVIBE-010 — httpx top-level sync methods inside async def
11
+
12
+ httpx exposes both a synchronous API (httpx.get, httpx.post, …) and an
13
+ async API (httpx.AsyncClient). AI-generated code frequently uses the sync
14
+ top-level functions inside async handlers, blocking the OS thread for the
15
+ full HTTP round-trip.
16
+
17
+ httpx.AsyncClient() has attr='AsyncClient' — outside HTTPX_BLOCKING —
18
+ so it is never matched by this rule.
19
+
20
+ Fix: async with httpx.AsyncClient() as client: response = await client.get(url)
21
+ """
22
+
23
+ RULE_ID = "PYVIBE-010"
24
+ SEVERITY = "CRITICAL"
25
+
26
+ def visit_Call(self, node: ast.Call):
27
+ if self._current_async_func is None:
28
+ self.generic_visit(node)
29
+ return
30
+
31
+ method = self._get_httpx_method(node)
32
+ if method:
33
+ self.violations.append(Violation(
34
+ rule_id=self.RULE_ID,
35
+ severity=self.SEVERITY,
36
+ line=node.lineno,
37
+ function_name=self._current_async_func,
38
+ message=f"httpx.{method}() is synchronous — blocks the event loop for the full HTTP round-trip",
39
+ evidence=f"Use `async with httpx.AsyncClient() as client: await client.{method}(url)`",
40
+ ))
41
+
42
+ self.generic_visit(node)
43
+
44
+ def _get_httpx_method(self, node: ast.Call):
45
+ if (
46
+ isinstance(node.func, ast.Attribute)
47
+ and node.func.attr in HTTPX_BLOCKING
48
+ and isinstance(node.func.value, ast.Name)
49
+ and node.func.value.id == "httpx"
50
+ ):
51
+ return node.func.attr
52
+ return None
@@ -0,0 +1,37 @@
1
+ import ast
2
+ from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
3
+
4
+
5
+ class LoopRunUntilCompleteRule(AsyncBlockingCallVisitor):
6
+ """
7
+ PYVIBE-015 — loop.run_until_complete() inside async def
8
+
9
+ run_until_complete() blocks the calling OS thread until the coroutine
10
+ finishes. Calling it from inside an already-running async function raises
11
+ RuntimeError at runtime ("This event loop is already running") and defeats
12
+ the entire purpose of async/await. AI-generated code that wraps existing
13
+ sync code often makes this mistake when trying to "bridge" sync and async.
14
+
15
+ Fix: replace `loop.run_until_complete(coro())` with `await coro()` directly.
16
+ """
17
+
18
+ RULE_ID = "PYVIBE-015"
19
+ SEVERITY = "CRITICAL"
20
+
21
+ def visit_Call(self, node: ast.Call):
22
+ if self._current_async_func and self._is_run_until_complete(node):
23
+ self.violations.append(Violation(
24
+ rule_id=self.RULE_ID,
25
+ severity=self.SEVERITY,
26
+ line=node.lineno,
27
+ function_name=self._current_async_func,
28
+ message="loop.run_until_complete() inside async def — raises RuntimeError at runtime (event loop already running)",
29
+ evidence="Replace with `await coro()` directly; the enclosing async function is already on the event loop",
30
+ ))
31
+ self.generic_visit(node)
32
+
33
+ def _is_run_until_complete(self, node: ast.Call) -> bool:
34
+ return (
35
+ isinstance(node.func, ast.Attribute)
36
+ and node.func.attr == "run_until_complete"
37
+ )
@@ -0,0 +1,39 @@
1
+ import ast
2
+ from typing import List
3
+ from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
4
+
5
+
6
+ class OpenAsyncRule(AsyncBlockingCallVisitor):
7
+ """
8
+ PYVIBE-009 — open() builtin inside async def
9
+
10
+ The builtin open() performs synchronous file I/O. Each call blocks the
11
+ OS thread until the kernel completes the read/write, preventing the event
12
+ loop from scheduling other coroutines. AI-generated FastAPI code routinely
13
+ mixes open() with async endpoints.
14
+
15
+ aiofiles.open() is an ast.Attribute node — structurally different from the
16
+ bare ast.Name 'open', so it is never matched by this rule.
17
+
18
+ Fix: use `async with aiofiles.open(path) as f: content = await f.read()`.
19
+ """
20
+
21
+ RULE_ID = "PYVIBE-009"
22
+ SEVERITY = "CRITICAL"
23
+
24
+ def visit_Call(self, node: ast.Call):
25
+ if self._current_async_func is None:
26
+ self.generic_visit(node)
27
+ return
28
+
29
+ if isinstance(node.func, ast.Name) and node.func.id == "open":
30
+ self.violations.append(Violation(
31
+ rule_id=self.RULE_ID,
32
+ severity=self.SEVERITY,
33
+ line=node.lineno,
34
+ function_name=self._current_async_func,
35
+ message="open() performs synchronous file I/O — blocks the event loop",
36
+ evidence="Use `async with aiofiles.open(path) as f: content = await f.read()`",
37
+ ))
38
+
39
+ self.generic_visit(node)
@@ -0,0 +1,49 @@
1
+ import ast
2
+ from typing import List
3
+ from pyvibe.rules.base import Violation, AsyncBlockingCallVisitor
4
+
5
+ OS_BLOCKING = {"system", "popen", "waitpid"}
6
+
7
+
8
+ class OsBlockingRule(AsyncBlockingCallVisitor):
9
+ """
10
+ PYVIBE-011 — os.system / os.popen / os.waitpid inside async def
11
+
12
+ These OS calls block the thread until the child process or I/O completes.
13
+ Unlike subprocess, they have no direct async equivalent and are commonly
14
+ generated when AI migrates sync scripts to async without replacing the
15
+ OS interaction layer.
16
+
17
+ Fix: use asyncio.create_subprocess_shell() with await proc.communicate().
18
+ """
19
+
20
+ RULE_ID = "PYVIBE-011"
21
+ SEVERITY = "CRITICAL"
22
+
23
+ def visit_Call(self, node: ast.Call):
24
+ if self._current_async_func is None:
25
+ self.generic_visit(node)
26
+ return
27
+
28
+ method = self._get_os_blocking_method(node)
29
+ if method:
30
+ self.violations.append(Violation(
31
+ rule_id=self.RULE_ID,
32
+ severity=self.SEVERITY,
33
+ line=node.lineno,
34
+ function_name=self._current_async_func,
35
+ message=f"os.{method}() blocks the OS thread — no direct async equivalent",
36
+ evidence="Use `proc = await asyncio.create_subprocess_shell(cmd)` then `await proc.communicate()`",
37
+ ))
38
+
39
+ self.generic_visit(node)
40
+
41
+ def _get_os_blocking_method(self, node: ast.Call):
42
+ if (
43
+ isinstance(node.func, ast.Attribute)
44
+ and node.func.attr in OS_BLOCKING
45
+ and isinstance(node.func.value, ast.Name)
46
+ and node.func.value.id == "os"
47
+ ):
48
+ return node.func.attr
49
+ return None
@@ -0,0 +1,98 @@
1
+ import ast
2
+ from typing import List, Optional
3
+ from pyvibe.rules.base import Violation
4
+
5
+
6
+ def _catches_queuefull(handlers: list) -> bool:
7
+ """True if any handler catches asyncio.QueueFull, QueueFull, bare except, or Exception."""
8
+ for h in handlers:
9
+ t = h.type
10
+ if t is None: # bare except — catches everything
11
+ return True
12
+ if isinstance(t, ast.Name) and t.id in ("QueueFull", "Exception"):
13
+ return True
14
+ if isinstance(t, ast.Attribute) and t.attr in ("QueueFull", "Exception"):
15
+ return True
16
+ if isinstance(t, ast.Tuple): # except (QueueFull, ...) — tuple of types
17
+ for elt in t.elts:
18
+ if isinstance(elt, ast.Name) and elt.id in ("QueueFull", "Exception"):
19
+ return True
20
+ if isinstance(elt, ast.Attribute) and elt.attr in ("QueueFull", "Exception"):
21
+ return True
22
+ return False
23
+
24
+
25
+ class QueuePutNowaitRule(ast.NodeVisitor):
26
+ """
27
+ PYVIBE-020 — put_nowait() without asyncio.QueueFull handler
28
+
29
+ asyncio.Queue.put_nowait() raises asyncio.QueueFull immediately when the
30
+ queue is at maxsize. Unless caught, the exception propagates and the item
31
+ is silently lost — no log, no retry, no metric. This affects any queue
32
+ with maxsize > 0 (unbounded queues never raise, but maxsize is often set
33
+ later or injected).
34
+
35
+ Fix: wrap in try/except asyncio.QueueFull and decide explicitly what to
36
+ do — log and drop, or switch to `await queue.put()` to block instead.
37
+
38
+ Fires in any function context (sync and async). The guard is the absence
39
+ of an enclosing try/except that catches asyncio.QueueFull or Exception.
40
+ """
41
+
42
+ RULE_ID = "PYVIBE-020"
43
+ SEVERITY = "WARNING"
44
+
45
+ def __init__(self):
46
+ self.violations: List[Violation] = []
47
+ self._current_func: Optional[str] = None
48
+ self._protected_depth: int = 0 # try/except with QueueFull handler nesting depth
49
+
50
+ def visit_FunctionDef(self, node: ast.FunctionDef):
51
+ prev = self._current_func
52
+ self._current_func = node.name
53
+ self.generic_visit(node)
54
+ self._current_func = prev
55
+
56
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
57
+ prev = self._current_func
58
+ self._current_func = node.name
59
+ self.generic_visit(node)
60
+ self._current_func = prev
61
+
62
+ def visit_Try(self, node: ast.Try):
63
+ protected = _catches_queuefull(node.handlers)
64
+ if protected:
65
+ self._protected_depth += 1
66
+ for stmt in node.body:
67
+ self.visit(stmt)
68
+ if protected:
69
+ self._protected_depth -= 1
70
+ # handlers, orelse, finalbody are NOT covered by this try's own handlers
71
+ for handler in node.handlers:
72
+ self.visit(handler)
73
+ for stmt in node.orelse:
74
+ self.visit(stmt)
75
+ for stmt in node.finalbody:
76
+ self.visit(stmt)
77
+
78
+ def visit_Call(self, node: ast.Call):
79
+ if (
80
+ isinstance(node.func, ast.Attribute)
81
+ and node.func.attr == "put_nowait"
82
+ and self._protected_depth == 0
83
+ ):
84
+ self.violations.append(Violation(
85
+ rule_id=self.RULE_ID,
86
+ severity=self.SEVERITY,
87
+ line=node.lineno,
88
+ function_name=self._current_func or "<module>",
89
+ message=(
90
+ "put_nowait() without asyncio.QueueFull handler — "
91
+ "raises silently if queue is full, item is lost"
92
+ ),
93
+ evidence=(
94
+ "Wrap in `try/except asyncio.QueueFull:` and log or handle explicitly, "
95
+ "or use `await queue.put()` to block until space is available"
96
+ ),
97
+ ))
98
+ self.generic_visit(node)
@@ -0,0 +1,255 @@
1
+ import ast
2
+ from typing import List, Optional
3
+ from pyvibe.rules.base import Violation
4
+
5
+ # Loop variable names that signal retry intent.
6
+ # Excluded: i, j, n, count, idx, chunk_start, page_num, dpynum — index/iteration variables.
7
+ _RETRY_VAR_NAMES = frozenset({"_"})
8
+ _RETRY_VAR_PREFIXES = ("attempt", "retry", "retries")
9
+
10
+
11
+ def _ends_with_retry(body: list) -> bool:
12
+ """True if the except body ends with continue.
13
+
14
+ `pass` alone is intentionally NOT counted. CLEANUP_PASS analysis (jun 2026)
15
+ showed 97/186 hits (52.2%) were pass-only handlers in cleanup blocks — all false
16
+ positives. `pass` lets the enclosing loop continue by fall-through rather than
17
+ expressing explicit retry intent.
18
+ """
19
+ if not body:
20
+ return False
21
+ last = body[-1]
22
+ return isinstance(last, ast.Continue)
23
+
24
+
25
+ def _is_retry_loop(node: ast.For) -> bool:
26
+ """True if the for loop is a bounded retry: for _ in range(N) or for attempt in range(N).
27
+
28
+ Two conditions required:
29
+ 1. Iterable must be range() — bounded count of attempts.
30
+ 2. Loop variable must be anonymous (_) or carry retry-intent semantics
31
+ (name contains 'attempt', 'retry', or 'retries').
32
+
33
+ Excluded: for i/j/n/count/idx in range(N) — these are index variables used to
34
+ iterate over elements, not retry counters. Analysis of 30 for-range hits showed
35
+ the 'i'-named group was 100% RANGE_FOREACH false positives.
36
+ """
37
+ if not (
38
+ isinstance(node.iter, ast.Call)
39
+ and isinstance(node.iter.func, ast.Name)
40
+ and node.iter.func.id == "range"
41
+ ):
42
+ return False
43
+ target = node.target
44
+ if not isinstance(target, ast.Name):
45
+ return False
46
+ varname = target.id.lower()
47
+ return varname in _RETRY_VAR_NAMES or any(p in varname for p in _RETRY_VAR_PREFIXES)
48
+
49
+
50
+ def _is_sleep_call(func_node) -> bool:
51
+ """True if func_node is asyncio.sleep, time.sleep, or bare sleep() (direct import)."""
52
+ if isinstance(func_node, ast.Attribute):
53
+ return (
54
+ func_node.attr == "sleep"
55
+ and isinstance(func_node.value, ast.Name)
56
+ and func_node.value.id in ("asyncio", "time")
57
+ )
58
+ # Bare name: `from time import sleep; sleep(N)` — no module prefix
59
+ if isinstance(func_node, ast.Name):
60
+ return func_node.id == "sleep"
61
+ return False
62
+
63
+
64
+ def _has_backoff_name(func_node) -> bool:
65
+ """True if the callable signals backoff/sleep delay.
66
+
67
+ Detects:
68
+ - Function/method name contains 'backoff' or 'jitter'
69
+ - Method name is 'asleep' (Backoff class async-sleep pattern: await backoff.asleep())
70
+ - Receiver object name contains 'backoff' or 'jitter' (e.g. backoff.sleep())
71
+ """
72
+ if isinstance(func_node, ast.Name):
73
+ name = func_node.id.lower()
74
+ return "backoff" in name or "jitter" in name
75
+ if isinstance(func_node, ast.Attribute):
76
+ attr = func_node.attr.lower()
77
+ if "backoff" in attr or "jitter" in attr or attr == "asleep":
78
+ return True
79
+ # Receiver object named after a backoff helper: backoff.sleep(), jitter.wait()
80
+ if isinstance(func_node.value, ast.Name):
81
+ receiver = func_node.value.id.lower()
82
+ return "backoff" in receiver or "jitter" in receiver
83
+ return False
84
+
85
+
86
+ def _body_has_backoff(stmts: list) -> bool:
87
+ """True if any statement contains a sleep or backoff/jitter call."""
88
+ for stmt in stmts:
89
+ for node in ast.walk(stmt):
90
+ if isinstance(node, ast.Call):
91
+ if _is_sleep_call(node.func):
92
+ return True
93
+ if _has_backoff_name(node.func):
94
+ return True
95
+ return False
96
+
97
+
98
+ def _body_has_escalation(stmts: list) -> bool:
99
+ """True if any if-block in the except body contains raise or break (escalation pattern)."""
100
+ for stmt in stmts:
101
+ for node in ast.walk(stmt):
102
+ if isinstance(node, ast.If):
103
+ for child in ast.walk(node):
104
+ if isinstance(child, (ast.Raise, ast.Break)):
105
+ return True
106
+ return False
107
+
108
+
109
+ def _is_timeout_only_handler(handler: ast.ExceptHandler) -> bool:
110
+ """True if the handler catches ONLY asyncio.TimeoutError or bare TimeoutError.
111
+
112
+ Handlers that catch TimeoutError exclusively are very likely polling loops
113
+ (e.g. `await asyncio.wait_for(event.wait(), timeout=N)`) rather than retries.
114
+ Tuple handlers like `except (asyncio.TimeoutError, ConnectionError)` return False
115
+ because the presence of other exception types indicates genuine error handling.
116
+ """
117
+ typ = handler.type
118
+ if typ is None:
119
+ return False
120
+ if isinstance(typ, ast.Name) and typ.id == "TimeoutError":
121
+ return True
122
+ return (
123
+ isinstance(typ, ast.Attribute)
124
+ and typ.attr == "TimeoutError"
125
+ and isinstance(typ.value, ast.Name)
126
+ and typ.value.id == "asyncio"
127
+ )
128
+
129
+
130
+ def _try_body_has_timeout_kwarg(stmts: list) -> bool:
131
+ """True if any call in the try body uses a 'timeout' keyword argument.
132
+
133
+ Combined with _is_timeout_only_handler, this identifies the POLL_LOOP pattern:
134
+ try:
135
+ await asyncio.wait_for(event.wait(), timeout=N) ← timeout= here
136
+ except asyncio.TimeoutError: ← TimeoutError only
137
+ continue ← not a retry
138
+
139
+ A genuine network retry would either catch Exception (not TimeoutError only)
140
+ or would catch asyncio.TimeoutError without any explicit timeout= in the try body.
141
+ """
142
+ for stmt in stmts:
143
+ for node in ast.walk(stmt):
144
+ if isinstance(node, ast.Call):
145
+ for kw in node.keywords:
146
+ if kw.arg == "timeout":
147
+ return True
148
+ return False
149
+
150
+
151
+ class RetryNoBackoffRule(ast.NodeVisitor):
152
+ """
153
+ PYVIBE-019 — retry loop without backoff inside async def
154
+
155
+ Scope (v3, jun 2026): exclusively `for _ in range(N)` or `for attempt in range(N)`
156
+ loops inside async functions. While loops are out of scope (FP rate ~90% due to
157
+ CLEANUP_PASS, MEM_PARSE, and MISSED_BACKOFF patterns; not reducible with AST alone).
158
+
159
+ Fires when:
160
+ - Loop iterates over range() with anonymous (_) or retry-intent variable name
161
+ - Handler body ends with `continue` (explicit retry)
162
+ - No sleep or backoff call detected in the handler body
163
+
164
+ Not flagged when the except handler contains:
165
+ - asyncio.sleep(N) or time.sleep(N) or bare sleep(N)
166
+ - await backoff.asleep() or any call with 'backoff'/'jitter'/'asleep' in the name
167
+ - An escalation pattern (if-block leading to raise or break)
168
+ - Catches only asyncio.TimeoutError AND try body has timeout= kwarg (POLL_LOOP pattern)
169
+
170
+ Fix: add `await asyncio.sleep(2 ** attempt)` before continue, or use
171
+ tenacity / backoff library.
172
+ """
173
+
174
+ RULE_ID = "PYVIBE-019"
175
+ SEVERITY = "WARNING"
176
+
177
+ def __init__(self):
178
+ self.violations: List[Violation] = []
179
+ self._current_async_func: Optional[str] = None
180
+ # Stack: True = enclosing loop is a qualifying retry loop (for _ in range(N)).
181
+ # Innermost value determines firing — `continue` resumes the innermost loop.
182
+ self._retry_loop_stack: List[bool] = []
183
+
184
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
185
+ prev_func = self._current_async_func
186
+ prev_stack = self._retry_loop_stack
187
+ self._current_async_func = node.name
188
+ self._retry_loop_stack = []
189
+ self.generic_visit(node)
190
+ self._current_async_func = prev_func
191
+ self._retry_loop_stack = prev_stack
192
+
193
+ def visit_FunctionDef(self, node: ast.FunctionDef):
194
+ prev_func = self._current_async_func
195
+ prev_stack = self._retry_loop_stack
196
+ self._current_async_func = None
197
+ self._retry_loop_stack = []
198
+ self.generic_visit(node)
199
+ self._current_async_func = prev_func
200
+ self._retry_loop_stack = prev_stack
201
+
202
+ def visit_Lambda(self, node: ast.Lambda):
203
+ prev_func = self._current_async_func
204
+ prev_stack = self._retry_loop_stack
205
+ self._current_async_func = None
206
+ self._retry_loop_stack = []
207
+ self.generic_visit(node)
208
+ self._current_async_func = prev_func
209
+ self._retry_loop_stack = prev_stack
210
+
211
+ def visit_For(self, node: ast.For):
212
+ self._retry_loop_stack.append(_is_retry_loop(node))
213
+ self.generic_visit(node)
214
+ self._retry_loop_stack.pop()
215
+
216
+ def visit_While(self, node: ast.While):
217
+ # While loops are explicitly out of scope for PYVIBE-019 v3.
218
+ # Push False so nested for-range retry loops can still be detected.
219
+ self._retry_loop_stack.append(False)
220
+ self.generic_visit(node)
221
+ self._retry_loop_stack.pop()
222
+
223
+ def visit_Try(self, node: ast.Try):
224
+ if (self._current_async_func
225
+ and self._retry_loop_stack
226
+ and self._retry_loop_stack[-1]):
227
+ for handler in node.handlers:
228
+ self._check_handler(handler, node.body)
229
+ self.generic_visit(node)
230
+
231
+ def _check_handler(self, handler: ast.ExceptHandler, try_body: list):
232
+ body = handler.body
233
+ if not _ends_with_retry(body):
234
+ return
235
+ if _body_has_backoff(body):
236
+ return
237
+ if _body_has_escalation(body):
238
+ return
239
+ # POLL_LOOP exclusion: `except asyncio.TimeoutError` + `timeout=` kwarg in try body
240
+ # is the canonical asyncio.wait_for polling pattern — not a retry.
241
+ if _is_timeout_only_handler(handler) and _try_body_has_timeout_kwarg(try_body):
242
+ return
243
+ self.violations.append(Violation(
244
+ rule_id=self.RULE_ID,
245
+ severity=self.SEVERITY,
246
+ line=handler.lineno,
247
+ function_name=self._current_async_func,
248
+ message=(
249
+ "retry loop without backoff — except retries immediately with no delay"
250
+ ),
251
+ evidence=(
252
+ "Add `await asyncio.sleep(2 ** attempt)` before continue, "
253
+ "or use tenacity / backoff library"
254
+ ),
255
+ ))
@@ -0,0 +1,107 @@
1
+ import ast
2
+ import re
3
+ from typing import List, Optional
4
+ from pyvibe.rules.base import Violation
5
+
6
+ _NOSEC_B110 = re.compile(r'#\s*nosec\b[^#\n]*\bB110\b')
7
+
8
+
9
+ def _is_empty_body(body: list) -> bool:
10
+ """Return True if every statement is pass or ... (Ellipsis)."""
11
+ if not body:
12
+ return True
13
+ for stmt in body:
14
+ if isinstance(stmt, ast.Pass):
15
+ continue
16
+ if (
17
+ isinstance(stmt, ast.Expr)
18
+ and isinstance(stmt.value, ast.Constant)
19
+ and stmt.value.value is ...
20
+ ):
21
+ continue
22
+ return False
23
+ return True
24
+
25
+
26
+ class SilentExceptRule(ast.NodeVisitor):
27
+ """
28
+ PYVIBE-017 — except Exception with empty body (silences errors)
29
+
30
+ A bare except or except Exception block whose body is only pass/... swallows
31
+ every exception without any record, making bugs invisible in production.
32
+ bare except also catches KeyboardInterrupt and SystemExit, preventing clean
33
+ shutdown.
34
+
35
+ Suppression: add `# nosec B110` on the `except` line to mark intentional
36
+ suppression (Bandit convention). Generic `# nosec` without B110 is NOT
37
+ honoured — specificity is required so that the opt-out is deliberate.
38
+
39
+ Fix: log the error, re-raise, or handle explicitly.
40
+ """
41
+
42
+ RULE_ID = "PYVIBE-017"
43
+ SEVERITY = "CRITICAL"
44
+
45
+ def __init__(self, source_lines: Optional[List[str]] = None):
46
+ self.violations: List[Violation] = []
47
+ self._current_func: Optional[str] = None
48
+ self._source_lines: List[str] = source_lines or []
49
+
50
+ def _is_suppressed(self, lineno: int) -> bool:
51
+ """Return True if the given 1-based line carries # nosec B110."""
52
+ if not self._source_lines:
53
+ return False
54
+ idx = lineno - 1
55
+ if 0 <= idx < len(self._source_lines):
56
+ return bool(_NOSEC_B110.search(self._source_lines[idx]))
57
+ return False
58
+
59
+ def visit_FunctionDef(self, node: ast.FunctionDef):
60
+ previous = self._current_func
61
+ self._current_func = node.name
62
+ self.generic_visit(node)
63
+ self._current_func = previous
64
+
65
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
66
+ previous = self._current_func
67
+ self._current_func = node.name
68
+ self.generic_visit(node)
69
+ self._current_func = previous
70
+
71
+ def visit_ExceptHandler(self, node: ast.ExceptHandler):
72
+ if not _is_empty_body(node.body):
73
+ self.generic_visit(node)
74
+ return
75
+
76
+ if self._is_suppressed(node.lineno):
77
+ self.generic_visit(node)
78
+ return
79
+
80
+ exc_type = node.type
81
+ if exc_type is None:
82
+ severity = "CRITICAL"
83
+ message = (
84
+ "bare except swallows all exceptions including "
85
+ "KeyboardInterrupt and SystemExit"
86
+ )
87
+ elif isinstance(exc_type, ast.Name) and exc_type.id == "Exception":
88
+ severity = "WARNING"
89
+ message = "except Exception with empty body silences all errors silently"
90
+ else:
91
+ # specific exception type — acceptable pattern
92
+ self.generic_visit(node)
93
+ return
94
+
95
+ self.violations.append(Violation(
96
+ rule_id=self.RULE_ID,
97
+ severity=severity,
98
+ line=node.lineno,
99
+ function_name=self._current_func or "<module>",
100
+ message=message,
101
+ evidence=(
102
+ "Log the error: `except Exception as e: logger.error(e)`, "
103
+ "or re-raise: `except Exception: raise`, "
104
+ "or handle explicitly"
105
+ ),
106
+ ))
107
+ self.generic_visit(node)