codehound 1.5.0__tar.gz → 1.7.0__tar.gz

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.
Files changed (44) hide show
  1. {codehound-1.5.0 → codehound-1.7.0}/PKG-INFO +118 -8
  2. {codehound-1.5.0 → codehound-1.7.0}/README.md +117 -7
  3. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/__init__.py +4 -4
  4. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/__init__.py +18 -0
  5. codehound-1.7.0/src/codehound/checks/finally_swallows_exception.py +146 -0
  6. codehound-1.7.0/src/codehound/checks/floating_timer.py +169 -0
  7. codehound-1.7.0/src/codehound/checks/is_literal_comparison.py +80 -0
  8. codehound-1.7.0/src/codehound/checks/lru_cache_on_async_function.py +74 -0
  9. codehound-1.7.0/src/codehound/checks/mutable_class_attribute.py +108 -0
  10. codehound-1.7.0/src/codehound/checks/removed_stdlib_attribute.py +75 -0
  11. codehound-1.7.0/src/codehound/checks/unclosed_pool.py +142 -0
  12. codehound-1.7.0/src/codehound/checks/unittest_deprecated_alias.py +68 -0
  13. codehound-1.7.0/src/codehound/checks/unwaited_subprocess.py +146 -0
  14. {codehound-1.5.0 → codehound-1.7.0}/tests/test_checks.py +556 -0
  15. {codehound-1.5.0 → codehound-1.7.0}/.gitignore +0 -0
  16. {codehound-1.5.0 → codehound-1.7.0}/LICENSE +0 -0
  17. {codehound-1.5.0 → codehound-1.7.0}/pyproject.toml +0 -0
  18. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/async_property.py +0 -0
  19. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/asyncio_coroutine_decorator.py +0 -0
  20. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
  21. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/bare_except.py +0 -0
  22. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/blocking_async.py +0 -0
  23. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/collections_abc_import.py +0 -0
  24. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/datetime_utcnow.py +0 -0
  25. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/discarded_future.py +0 -0
  26. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/floating_process.py +0 -0
  27. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/floating_task.py +0 -0
  28. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/floating_thread.py +0 -0
  29. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/get_event_loop.py +0 -0
  30. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/loop_closure_capture.py +0 -0
  31. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/lru_cache_on_method.py +0 -0
  32. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/mutable_defaults.py +0 -0
  33. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
  34. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/removed_getargspec.py +0 -0
  35. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/removed_stdlib_module.py +0 -0
  36. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/resource_leak.py +0 -0
  37. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
  38. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/unclosed_socket.py +0 -0
  39. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/checks/unprotected_lock.py +0 -0
  40. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/cli.py +0 -0
  41. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/core.py +0 -0
  42. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/sarif.py +0 -0
  43. {codehound-1.5.0 → codehound-1.7.0}/src/codehound/terminal.py +0 -0
  44. {codehound-1.5.0 → codehound-1.7.0}/tests/test_output_formats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codehound
3
- Version: 1.5.0
3
+ Version: 1.7.0
4
4
  Summary: An AST-based static analyzer that hunts real correctness and async-safety bugs in Python code.
5
5
  Project-URL: Homepage, https://github.com/kratos0718/codehound
6
6
  Project-URL: Issues, https://github.com/kratos0718/codehound/issues
@@ -24,7 +24,7 @@ Description-Content-Type: text/markdown
24
24
 
25
25
  <h1 align="center">codehound</h1>
26
26
 
27
- **An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty-two checks, eight backed by a bug that was actually found and merged (or opened as a PR) into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~29-framework validation corpus instead of just reasoned about.**
27
+ **An AST-based static analyzer that hunts *real* bugs in large Python codebases — thirty-one checks, eight backed by a bug that was actually found and merged (or opened as a PR) into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~29-framework validation corpus instead of just reasoned about.**
28
28
 
29
29
  [![CI](https://github.com/kratos0718/codehound/actions/workflows/ci.yml/badge.svg)](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
30
30
  [![PyPI](https://img.shields.io/pypi/v/codehound.svg)](https://pypi.org/project/codehound/)
@@ -65,13 +65,27 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
65
65
 
66
66
  ---
67
67
 
68
+ ## How this compares
69
+
70
+ Being upfront about overlap: `codehound` is not the only tool that catches some of these patterns, and pretending otherwise wouldn't survive five minutes of someone actually checking. [Ruff](https://docs.astral.sh/ruff/)'s `RUF006` already catches a discarded `asyncio.create_task()` (CH006), `flake8-async`'s `ASYNC300` predates it. Ruff's `RUF012` already catches mutable class-level defaults (CH026), `F632` catches `is`-literal comparisons (CH025), `B006`/`UP005`/`E722` cover mutable-default-arguments/deprecated-unittest-aliases/bare-except (CH002/CH024/CH020). Pylint's `W1518` (`method-cache-max-size-none`) is close to a name-for-name match for CH011's `lru_cache`-on-instance-method leak. If you already run ruff and pylint, several of `codehound`'s checks will feel familiar.
71
+
72
+ What actually seems to be missing elsewhere, as far as I've been able to find:
73
+
74
+ - **Floating threading/multiprocessing primitives.** `flake8-async`'s blocking-call rules (`ASYNC2xx`) and Ruff's `RUF006` cover `asyncio` specifically; I couldn't find any linter — Ruff, flake8-async, pylint, bandit — that flags a `threading.Thread`/`multiprocessing.Process`/`threading.Timer`/`multiprocessing.Pool` that's started but never joined, cancelled, or closed (CH009, CH012, CH028, CH031). This isn't a niche pattern; it's the exact same bug as the asyncio case, just one abstraction layer down, and nothing else checks for it.
75
+ - **Unawaited coroutines.** Calling an `async def` function as a bare statement — no `await`, no scheduling — silently drops the whole call (CH007). Ruff has an [open, unresolved issue](https://github.com/astral-sh/ruff/issues/9833) asking for exactly this; as of writing, nothing ships it.
76
+ - **Blocking calls to non-stdlib clients inside async functions.** `flake8-async`'s `ASYNC2xx` family only recognizes a fixed denylist (`requests`, `httpx`, `urllib3`, `subprocess`, `open`, `time.sleep`) — a synchronous call to a vector-DB client, an LLM SDK, or any other third-party library inside an `async def` isn't on anyone's list yet. CH001 is on that same denylist today; extending it to common AI/agent SDKs (the exact libraries the frameworks this tool is validated against actually use) is on the roadmap below.
77
+
78
+ And a difference in kind, not just coverage: every check here is checked against a real corpus, not just reasoned about. [`docs/FINDINGS.md`](docs/FINDINGS.md) has a running ledger of every false positive found while building each check (with the exact framework and line), and two checks that were built, measured, and **rejected outright** when the pattern turned out to be either too common to be a defensible finding (1,911 hits) or premised on something that was actually false (the first two real hits checked turned out to be correct code). I haven't found another static-analysis tool — commercial or open-source — that publishes this kind of "we built it, checked it against real code, and turned it down" ledger. Most tools that market themselves on "catches real bugs" (Greptile, Qodo, CodeRabbit) report an aggregate detection-rate benchmark, not per-rule provenance you can click through to an actual merged fix.
79
+
80
+ ---
81
+
68
82
  ## Install
69
83
 
70
84
  ```bash
71
85
  pip install codehound
72
86
  ```
73
87
 
74
- Zero dependencies — it's ~2,600 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
88
+ Zero dependencies — it's ~3,600 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
75
89
 
76
90
  <details>
77
91
  <summary>From a clone instead (for development)</summary>
@@ -132,7 +146,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
132
146
  ```yaml
133
147
  repos:
134
148
  - repo: https://github.com/kratos0718/codehound
135
- rev: v1.5.0
149
+ rev: v1.7.0
136
150
  hooks:
137
151
  - id: codehound
138
152
  ```
@@ -165,10 +179,19 @@ repos:
165
179
  | **CH020** | `bare-except` | A bare `except:` (or unused `except BaseException:`) — also catches `KeyboardInterrupt`/`SystemExit`, so Ctrl-C stops working and `sys.exit()` gets silently absorbed. | hardening rule — real hits in agno, llama_index, marimo, litellm |
166
180
  | **CH021** | `removed-stdlib-module` | `import distutils` (removed 3.12) or any of the 19 PEP 594 "dead battery" modules (`cgi`, `imghdr`, `telnetlib`, `nntplib`, …, removed 3.13) — `ImportError` the moment the module loads. | hardening rule — real hit in agno (already guarded, see below) |
167
181
  | **CH022** | `removed-asyncio-coroutine-decorator` | `@asyncio.coroutine` — removed in Python 3.11 after a generator-based-coroutine bridge that predates `async def`; `AttributeError` the moment the decorator line runs. | hardening rule |
182
+ | **CH023** | `removed-stdlib-attribute` | A specific removed function on a module that still imports fine — `time.clock()` (3.8), `platform.linux_distribution()`/`.dist()` (3.8), `cgi.escape()` (3.8), `base64.encodestring()`/`.decodestring()` (3.9). | hardening rule — real hit in scikit-learn |
183
+ | **CH024** | `unittest-deprecated-alias` | `self.assertEquals(...)`/`self.failUnless(...)` and a dozen other legacy `unittest.TestCase` aliases — removed in Python 3.12. | hardening rule |
184
+ | **CH025** | `is-literal-comparison` | `x is 1000` / `x is not "foo"` — `is` checks identity, not equality; relies on CPython's small-int caching / string interning, neither guaranteed. (pyflakes F632) | hardening rule — real false positive fixed in litellm's own code, see below |
185
+ | **CH026** | `mutable-class-attribute` | `class C: items = []` mutated via `self.items.append(...)` without ever being reassigned per instance — every instance shares and mutates the *same* list. | **vllm**, **llama_index**, **optuna**, **transformers** — see below |
186
+ | **CH027** | `unwaited-subprocess` | `subprocess.Popen(...)` never `.wait()`ed/`.communicate()`d with, and not context-managed — risks a zombie process and a full pipe buffer deadlocking the child. | hardening rule — real hit in dspy (already handled, see below) |
187
+ | **CH028** | `floating-timer` | `threading.Timer(...)` started but never `.cancel()`ed or handed off — nothing can stop the callback from firing later, on stale context. | hardening rule — real hits in marimo, transformers |
188
+ | **CH029** | `finally-swallows-exception` | `return`/`break`/`continue` in a `finally:` block silently discards any exception from the `try:` — the caller never sees it. | hardening rule — real hits in letta |
189
+ | **CH030** | `lru-cache-on-async-function` | `@lru_cache`/`@cache` on `async def` caches the coroutine *object*, not its result — the second call with the same arguments crashes. | hardening rule |
190
+ | **CH031** | `unclosed-pool` | `multiprocessing.Pool()` never `.close()`d/`.terminate()`d — worker processes leak for the life of the parent. | hardening rule |
168
191
 
169
192
  `codehound list` prints this from the source of truth.
170
193
 
171
- CH007-CH022 don't have found-and-merged bugs behind all of them the way
194
+ CH007-CH031 don't have found-and-merged bugs behind all of them the way
172
195
  CH001-CH006 do - most are hardening rules for well-known Python
173
196
  correctness gotchas rather than something this project personally
174
197
  tracked down first. CH010 and CH011 are the exceptions: both found
@@ -271,6 +294,79 @@ a `try:` body whose `except` catches `ImportError` (or anything
271
294
  broader). A full corpus rescan after both fixes found zero remaining
272
295
  CH021 hits.
273
296
 
297
+ **CH026 found four real, previously-unreported bugs on its first real
298
+ scan.** All four are the exact same shape: a class-level mutable default
299
+ (`items = []`) mutated in place via `self.items.append(...)` (or
300
+ subscript assignment) with no per-instance reassignment anywhere, so
301
+ every instance of the class shares and corrupts the *same* object. In
302
+ vllm's `AXK1ForCausalLM`, `self.packed_modules_mapping["qkv_proj"] =
303
+ [...]` patches a routing table shared by every instance of the model
304
+ class. In llama_index's `ZapierToolSpec`, `self.spec_functions.append(...)`
305
+ means a second tool-spec instance (a different API key, a different
306
+ user) inherits every action name the first instance ever registered. In
307
+ optuna's CLI `_Studies` command, `self._study_list_header.append(...)`
308
+ does the same to a table-header list. In HuggingFace transformers'
309
+ `CodeGenTokenizer`, `self.model_input_names.append("token_type_ids")`
310
+ means constructing one tokenizer with `return_token_type_ids=True`
311
+ silently changes what field every *other* `CodeGenTokenizer` instance in
312
+ the same process expects, regardless of how it was configured - exactly
313
+ the "spooky action at a distance" class of bug this project exists to
314
+ catch. None have PRs yet: vllm and transformers both require AI-assisted
315
+ PRs to carry an explicit disclosure, which this project's own policy
316
+ doesn't do, so those two are documented here rather than filed.
317
+
318
+ **CH028 hit the exact same name-collision problem CH018/CH022 already
319
+ had a guard for, because that guard didn't get reused.** The very first
320
+ corpus scan came back with ~30 hits, almost all in agno - which doesn't
321
+ use `threading.Timer` at all. `from agno.utils.timer import Timer` is
322
+ agno's own unrelated stopwatch class, called as `Timer()` with zero
323
+ arguments (real `threading.Timer` requires `interval` and `function` and
324
+ would raise `TypeError` immediately). Fixed by requiring `from threading
325
+ import Timer` before trusting a bare `Timer(...)` call - the same guard
326
+ already built for CH022's `coroutine` minutes earlier in the same
327
+ session, just not applied here the first time. Also missed CH009's
328
+ `daemon=True` escape entirely (found in weaviate-python-client's
329
+ watchdog timer, `_timeout_timer.daemon = True`, a deliberate
330
+ "outlive the caller" choice) - added both a constructor-kwarg and a
331
+ post-construction-assignment check for it, matching CH009 exactly. Real
332
+ hits remain in marimo (a non-daemon browser-opening timer, never
333
+ cancelled) and transformers (a chained, never-captured checkpoint-retry
334
+ timer).
335
+
336
+ **CH025 and CH027 each found one real bug in the first scan, and one
337
+ real gap in the check.** CH025 (is-literal-comparison) flagged litellm's
338
+ `if "usage" in response_obj is not None:` - but for the wrong reason.
339
+ `ast.Compare` puts every operand and every op from a chained comparison
340
+ in one node; checking "is there a literal anywhere" and "is there an
341
+ `is`/`is not` anywhere" independently, without pairing each op with its
342
+ own adjacent operands, matched the string literal (paired with `in`)
343
+ against the wrong op (`is not`, actually comparing `response_obj` to the
344
+ allowed singleton `None`). Fixed by walking the chain as adjacent
345
+ `(left, op, right)` triples. CH027 (unwaited-subprocess) flagged dspy's
346
+ `process = subprocess.Popen(...)`, followed by `lm.process = process` -
347
+ a real hand-off to a different object, reaped later through a separate
348
+ `terminate_process(lm.process)` call, the same "stored as any object's
349
+ attribute" escape CH009/CH016/CH028 already needed. Added it.
350
+
351
+ **CH029 found a real bug in letta on day one, and its own precision gap
352
+ right after.** The first corpus scan flagged `except Exception as e:
353
+ result["error"] = str(e)` followed by `finally: return result` in
354
+ letta's job-callback dispatcher - but that except deliberately never
355
+ re-raises (the code comment says so directly: "callback failures should
356
+ not affect job completion"), so nothing is actually pending to swallow
357
+ by the time `finally` runs. Fixed by skipping a `try` whose `except`
358
+ clauses never re-raise anywhere in their own scope - there's nothing
359
+ live left to discard at that point. The same scan also turned up a
360
+ *more serious*, still-real instance in two of letta's LLM streaming
361
+ adapters: `except BaseException: <log, then re-raise a typed error>`
362
+ immediately followed by `finally: if not stream_started: return` -
363
+ except `stream_started` is unconditionally set `True` a few lines
364
+ earlier with no other assignment anywhere in the function, so today
365
+ that specific `return` is dead code, not a currently-live swallow. Read
366
+ carefully rather than assumed, and not filed as a bug report since it
367
+ isn't actually firing right now - but flagged as exactly the kind of
368
+ fragile code a future refactor could silently turn into a real one.
369
+
274
370
  **Two checks we built and did not ship.** `exception-chaining` (`except X
275
371
  as e: raise Y(...)` with no `from e`, discarding the real traceback -
276
372
  overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
@@ -328,12 +424,21 @@ codehound/
328
424
  ├── removed_getargspec.py (CH019)
329
425
  ├── bare_except.py (CH020)
330
426
  ├── removed_stdlib_module.py (CH021)
331
- └── asyncio_coroutine_decorator.py (CH022)
427
+ ├── asyncio_coroutine_decorator.py (CH022)
428
+ ├── removed_stdlib_attribute.py (CH023)
429
+ ├── unittest_deprecated_alias.py (CH024)
430
+ ├── is_literal_comparison.py (CH025)
431
+ ├── mutable_class_attribute.py (CH026)
432
+ ├── unwaited_subprocess.py (CH027)
433
+ ├── floating_timer.py (CH028)
434
+ ├── finally_swallows_exception.py (CH029)
435
+ ├── lru_cache_on_async_function.py (CH030)
436
+ └── unclosed_pool.py (CH031)
332
437
  ```
333
438
 
334
439
  Each check receives a parsed `ast` tree plus the precomputed parent map and returns `Finding`s. Adding a rule is one file + one registry line + a test. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for a full walkthrough of the engine, the parent map, and the design decisions.
335
440
 
336
- **False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
441
+ **False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. CH025 pairs each chained comparison's op with only its own adjacent operands, rather than matching a literal and an `is`/`is not` anywhere in the same chain independently — a real pattern found in litellm. CH027 and CH028 both recognize a handle stored as *any* object's attribute as a hand-off, matching CH009/CH016's precedent — real patterns found in dspy and weaviate-python-client respectively. CH028 also only trusts a bare `Timer(...)` when `from threading import Timer` was actually seen — real hits in agno were its own unrelated stopwatch class. CH029 skips a `try` whose `except` clauses never re-raise anywhere in their own scope — a real pattern in letta where the exception is deliberately logged and recorded, never propagated, so a `return` in `finally` isn't discarding anything live. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
337
442
 
338
443
  ---
339
444
 
@@ -365,8 +470,13 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
365
470
  sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
366
471
  - [x] 22 checks — removed stdlib modules (`distutils`, PEP 594 "dead
367
472
  batteries"), removed `@asyncio.coroutine` decorator — CH021-CH022
473
+ - [x] 28 checks — removed stdlib functions, deprecated unittest aliases,
474
+ `is`-literal comparisons, mutable class attributes, unwaited
475
+ subprocesses, floating timers — CH023-CH028
476
+ - [x] 31 checks — `finally:` blocks that swallow exceptions, `lru_cache`
477
+ on async functions, unclosed `multiprocessing.Pool` — CH029-CH031
368
478
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
369
- - [ ] Sync HTTP clients constructed inside async request handlers
479
+ - [ ] Extend CH001 to a curated denylist of sync AI/agent SDK client calls inside async functions (vector-DB clients, LLM SDKs) — the gap flake8-async's stdlib-only denylist leaves open
370
480
  - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
371
481
 
372
482
  ---
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h1 align="center">codehound</h1>
6
6
 
7
- **An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty-two checks, eight backed by a bug that was actually found and merged (or opened as a PR) into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~29-framework validation corpus instead of just reasoned about.**
7
+ **An AST-based static analyzer that hunts *real* bugs in large Python codebases — thirty-one checks, eight backed by a bug that was actually found and merged (or opened as a PR) into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~29-framework validation corpus instead of just reasoned about.**
8
8
 
9
9
  [![CI](https://github.com/kratos0718/codehound/actions/workflows/ci.yml/badge.svg)](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
10
10
  [![PyPI](https://img.shields.io/pypi/v/codehound.svg)](https://pypi.org/project/codehound/)
@@ -45,13 +45,27 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
45
45
 
46
46
  ---
47
47
 
48
+ ## How this compares
49
+
50
+ Being upfront about overlap: `codehound` is not the only tool that catches some of these patterns, and pretending otherwise wouldn't survive five minutes of someone actually checking. [Ruff](https://docs.astral.sh/ruff/)'s `RUF006` already catches a discarded `asyncio.create_task()` (CH006), `flake8-async`'s `ASYNC300` predates it. Ruff's `RUF012` already catches mutable class-level defaults (CH026), `F632` catches `is`-literal comparisons (CH025), `B006`/`UP005`/`E722` cover mutable-default-arguments/deprecated-unittest-aliases/bare-except (CH002/CH024/CH020). Pylint's `W1518` (`method-cache-max-size-none`) is close to a name-for-name match for CH011's `lru_cache`-on-instance-method leak. If you already run ruff and pylint, several of `codehound`'s checks will feel familiar.
51
+
52
+ What actually seems to be missing elsewhere, as far as I've been able to find:
53
+
54
+ - **Floating threading/multiprocessing primitives.** `flake8-async`'s blocking-call rules (`ASYNC2xx`) and Ruff's `RUF006` cover `asyncio` specifically; I couldn't find any linter — Ruff, flake8-async, pylint, bandit — that flags a `threading.Thread`/`multiprocessing.Process`/`threading.Timer`/`multiprocessing.Pool` that's started but never joined, cancelled, or closed (CH009, CH012, CH028, CH031). This isn't a niche pattern; it's the exact same bug as the asyncio case, just one abstraction layer down, and nothing else checks for it.
55
+ - **Unawaited coroutines.** Calling an `async def` function as a bare statement — no `await`, no scheduling — silently drops the whole call (CH007). Ruff has an [open, unresolved issue](https://github.com/astral-sh/ruff/issues/9833) asking for exactly this; as of writing, nothing ships it.
56
+ - **Blocking calls to non-stdlib clients inside async functions.** `flake8-async`'s `ASYNC2xx` family only recognizes a fixed denylist (`requests`, `httpx`, `urllib3`, `subprocess`, `open`, `time.sleep`) — a synchronous call to a vector-DB client, an LLM SDK, or any other third-party library inside an `async def` isn't on anyone's list yet. CH001 is on that same denylist today; extending it to common AI/agent SDKs (the exact libraries the frameworks this tool is validated against actually use) is on the roadmap below.
57
+
58
+ And a difference in kind, not just coverage: every check here is checked against a real corpus, not just reasoned about. [`docs/FINDINGS.md`](docs/FINDINGS.md) has a running ledger of every false positive found while building each check (with the exact framework and line), and two checks that were built, measured, and **rejected outright** when the pattern turned out to be either too common to be a defensible finding (1,911 hits) or premised on something that was actually false (the first two real hits checked turned out to be correct code). I haven't found another static-analysis tool — commercial or open-source — that publishes this kind of "we built it, checked it against real code, and turned it down" ledger. Most tools that market themselves on "catches real bugs" (Greptile, Qodo, CodeRabbit) report an aggregate detection-rate benchmark, not per-rule provenance you can click through to an actual merged fix.
59
+
60
+ ---
61
+
48
62
  ## Install
49
63
 
50
64
  ```bash
51
65
  pip install codehound
52
66
  ```
53
67
 
54
- Zero dependencies — it's ~2,600 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
68
+ Zero dependencies — it's ~3,600 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
55
69
 
56
70
  <details>
57
71
  <summary>From a clone instead (for development)</summary>
@@ -112,7 +126,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
112
126
  ```yaml
113
127
  repos:
114
128
  - repo: https://github.com/kratos0718/codehound
115
- rev: v1.5.0
129
+ rev: v1.7.0
116
130
  hooks:
117
131
  - id: codehound
118
132
  ```
@@ -145,10 +159,19 @@ repos:
145
159
  | **CH020** | `bare-except` | A bare `except:` (or unused `except BaseException:`) — also catches `KeyboardInterrupt`/`SystemExit`, so Ctrl-C stops working and `sys.exit()` gets silently absorbed. | hardening rule — real hits in agno, llama_index, marimo, litellm |
146
160
  | **CH021** | `removed-stdlib-module` | `import distutils` (removed 3.12) or any of the 19 PEP 594 "dead battery" modules (`cgi`, `imghdr`, `telnetlib`, `nntplib`, …, removed 3.13) — `ImportError` the moment the module loads. | hardening rule — real hit in agno (already guarded, see below) |
147
161
  | **CH022** | `removed-asyncio-coroutine-decorator` | `@asyncio.coroutine` — removed in Python 3.11 after a generator-based-coroutine bridge that predates `async def`; `AttributeError` the moment the decorator line runs. | hardening rule |
162
+ | **CH023** | `removed-stdlib-attribute` | A specific removed function on a module that still imports fine — `time.clock()` (3.8), `platform.linux_distribution()`/`.dist()` (3.8), `cgi.escape()` (3.8), `base64.encodestring()`/`.decodestring()` (3.9). | hardening rule — real hit in scikit-learn |
163
+ | **CH024** | `unittest-deprecated-alias` | `self.assertEquals(...)`/`self.failUnless(...)` and a dozen other legacy `unittest.TestCase` aliases — removed in Python 3.12. | hardening rule |
164
+ | **CH025** | `is-literal-comparison` | `x is 1000` / `x is not "foo"` — `is` checks identity, not equality; relies on CPython's small-int caching / string interning, neither guaranteed. (pyflakes F632) | hardening rule — real false positive fixed in litellm's own code, see below |
165
+ | **CH026** | `mutable-class-attribute` | `class C: items = []` mutated via `self.items.append(...)` without ever being reassigned per instance — every instance shares and mutates the *same* list. | **vllm**, **llama_index**, **optuna**, **transformers** — see below |
166
+ | **CH027** | `unwaited-subprocess` | `subprocess.Popen(...)` never `.wait()`ed/`.communicate()`d with, and not context-managed — risks a zombie process and a full pipe buffer deadlocking the child. | hardening rule — real hit in dspy (already handled, see below) |
167
+ | **CH028** | `floating-timer` | `threading.Timer(...)` started but never `.cancel()`ed or handed off — nothing can stop the callback from firing later, on stale context. | hardening rule — real hits in marimo, transformers |
168
+ | **CH029** | `finally-swallows-exception` | `return`/`break`/`continue` in a `finally:` block silently discards any exception from the `try:` — the caller never sees it. | hardening rule — real hits in letta |
169
+ | **CH030** | `lru-cache-on-async-function` | `@lru_cache`/`@cache` on `async def` caches the coroutine *object*, not its result — the second call with the same arguments crashes. | hardening rule |
170
+ | **CH031** | `unclosed-pool` | `multiprocessing.Pool()` never `.close()`d/`.terminate()`d — worker processes leak for the life of the parent. | hardening rule |
148
171
 
149
172
  `codehound list` prints this from the source of truth.
150
173
 
151
- CH007-CH022 don't have found-and-merged bugs behind all of them the way
174
+ CH007-CH031 don't have found-and-merged bugs behind all of them the way
152
175
  CH001-CH006 do - most are hardening rules for well-known Python
153
176
  correctness gotchas rather than something this project personally
154
177
  tracked down first. CH010 and CH011 are the exceptions: both found
@@ -251,6 +274,79 @@ a `try:` body whose `except` catches `ImportError` (or anything
251
274
  broader). A full corpus rescan after both fixes found zero remaining
252
275
  CH021 hits.
253
276
 
277
+ **CH026 found four real, previously-unreported bugs on its first real
278
+ scan.** All four are the exact same shape: a class-level mutable default
279
+ (`items = []`) mutated in place via `self.items.append(...)` (or
280
+ subscript assignment) with no per-instance reassignment anywhere, so
281
+ every instance of the class shares and corrupts the *same* object. In
282
+ vllm's `AXK1ForCausalLM`, `self.packed_modules_mapping["qkv_proj"] =
283
+ [...]` patches a routing table shared by every instance of the model
284
+ class. In llama_index's `ZapierToolSpec`, `self.spec_functions.append(...)`
285
+ means a second tool-spec instance (a different API key, a different
286
+ user) inherits every action name the first instance ever registered. In
287
+ optuna's CLI `_Studies` command, `self._study_list_header.append(...)`
288
+ does the same to a table-header list. In HuggingFace transformers'
289
+ `CodeGenTokenizer`, `self.model_input_names.append("token_type_ids")`
290
+ means constructing one tokenizer with `return_token_type_ids=True`
291
+ silently changes what field every *other* `CodeGenTokenizer` instance in
292
+ the same process expects, regardless of how it was configured - exactly
293
+ the "spooky action at a distance" class of bug this project exists to
294
+ catch. None have PRs yet: vllm and transformers both require AI-assisted
295
+ PRs to carry an explicit disclosure, which this project's own policy
296
+ doesn't do, so those two are documented here rather than filed.
297
+
298
+ **CH028 hit the exact same name-collision problem CH018/CH022 already
299
+ had a guard for, because that guard didn't get reused.** The very first
300
+ corpus scan came back with ~30 hits, almost all in agno - which doesn't
301
+ use `threading.Timer` at all. `from agno.utils.timer import Timer` is
302
+ agno's own unrelated stopwatch class, called as `Timer()` with zero
303
+ arguments (real `threading.Timer` requires `interval` and `function` and
304
+ would raise `TypeError` immediately). Fixed by requiring `from threading
305
+ import Timer` before trusting a bare `Timer(...)` call - the same guard
306
+ already built for CH022's `coroutine` minutes earlier in the same
307
+ session, just not applied here the first time. Also missed CH009's
308
+ `daemon=True` escape entirely (found in weaviate-python-client's
309
+ watchdog timer, `_timeout_timer.daemon = True`, a deliberate
310
+ "outlive the caller" choice) - added both a constructor-kwarg and a
311
+ post-construction-assignment check for it, matching CH009 exactly. Real
312
+ hits remain in marimo (a non-daemon browser-opening timer, never
313
+ cancelled) and transformers (a chained, never-captured checkpoint-retry
314
+ timer).
315
+
316
+ **CH025 and CH027 each found one real bug in the first scan, and one
317
+ real gap in the check.** CH025 (is-literal-comparison) flagged litellm's
318
+ `if "usage" in response_obj is not None:` - but for the wrong reason.
319
+ `ast.Compare` puts every operand and every op from a chained comparison
320
+ in one node; checking "is there a literal anywhere" and "is there an
321
+ `is`/`is not` anywhere" independently, without pairing each op with its
322
+ own adjacent operands, matched the string literal (paired with `in`)
323
+ against the wrong op (`is not`, actually comparing `response_obj` to the
324
+ allowed singleton `None`). Fixed by walking the chain as adjacent
325
+ `(left, op, right)` triples. CH027 (unwaited-subprocess) flagged dspy's
326
+ `process = subprocess.Popen(...)`, followed by `lm.process = process` -
327
+ a real hand-off to a different object, reaped later through a separate
328
+ `terminate_process(lm.process)` call, the same "stored as any object's
329
+ attribute" escape CH009/CH016/CH028 already needed. Added it.
330
+
331
+ **CH029 found a real bug in letta on day one, and its own precision gap
332
+ right after.** The first corpus scan flagged `except Exception as e:
333
+ result["error"] = str(e)` followed by `finally: return result` in
334
+ letta's job-callback dispatcher - but that except deliberately never
335
+ re-raises (the code comment says so directly: "callback failures should
336
+ not affect job completion"), so nothing is actually pending to swallow
337
+ by the time `finally` runs. Fixed by skipping a `try` whose `except`
338
+ clauses never re-raise anywhere in their own scope - there's nothing
339
+ live left to discard at that point. The same scan also turned up a
340
+ *more serious*, still-real instance in two of letta's LLM streaming
341
+ adapters: `except BaseException: <log, then re-raise a typed error>`
342
+ immediately followed by `finally: if not stream_started: return` -
343
+ except `stream_started` is unconditionally set `True` a few lines
344
+ earlier with no other assignment anywhere in the function, so today
345
+ that specific `return` is dead code, not a currently-live swallow. Read
346
+ carefully rather than assumed, and not filed as a bug report since it
347
+ isn't actually firing right now - but flagged as exactly the kind of
348
+ fragile code a future refactor could silently turn into a real one.
349
+
254
350
  **Two checks we built and did not ship.** `exception-chaining` (`except X
255
351
  as e: raise Y(...)` with no `from e`, discarding the real traceback -
256
352
  overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
@@ -308,12 +404,21 @@ codehound/
308
404
  ├── removed_getargspec.py (CH019)
309
405
  ├── bare_except.py (CH020)
310
406
  ├── removed_stdlib_module.py (CH021)
311
- └── asyncio_coroutine_decorator.py (CH022)
407
+ ├── asyncio_coroutine_decorator.py (CH022)
408
+ ├── removed_stdlib_attribute.py (CH023)
409
+ ├── unittest_deprecated_alias.py (CH024)
410
+ ├── is_literal_comparison.py (CH025)
411
+ ├── mutable_class_attribute.py (CH026)
412
+ ├── unwaited_subprocess.py (CH027)
413
+ ├── floating_timer.py (CH028)
414
+ ├── finally_swallows_exception.py (CH029)
415
+ ├── lru_cache_on_async_function.py (CH030)
416
+ └── unclosed_pool.py (CH031)
312
417
  ```
313
418
 
314
419
  Each check receives a parsed `ast` tree plus the precomputed parent map and returns `Finding`s. Adding a rule is one file + one registry line + a test. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for a full walkthrough of the engine, the parent map, and the design decisions.
315
420
 
316
- **False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
421
+ **False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. CH025 pairs each chained comparison's op with only its own adjacent operands, rather than matching a literal and an `is`/`is not` anywhere in the same chain independently — a real pattern found in litellm. CH027 and CH028 both recognize a handle stored as *any* object's attribute as a hand-off, matching CH009/CH016's precedent — real patterns found in dspy and weaviate-python-client respectively. CH028 also only trusts a bare `Timer(...)` when `from threading import Timer` was actually seen — real hits in agno were its own unrelated stopwatch class. CH029 skips a `try` whose `except` clauses never re-raise anywhere in their own scope — a real pattern in letta where the exception is deliberately logged and recorded, never propagated, so a `return` in `finally` isn't discarding anything live. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
317
422
 
318
423
  ---
319
424
 
@@ -345,8 +450,13 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
345
450
  sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
346
451
  - [x] 22 checks — removed stdlib modules (`distutils`, PEP 594 "dead
347
452
  batteries"), removed `@asyncio.coroutine` decorator — CH021-CH022
453
+ - [x] 28 checks — removed stdlib functions, deprecated unittest aliases,
454
+ `is`-literal comparisons, mutable class attributes, unwaited
455
+ subprocesses, floating timers — CH023-CH028
456
+ - [x] 31 checks — `finally:` blocks that swallow exceptions, `lru_cache`
457
+ on async functions, unclosed `multiprocessing.Pool` — CH029-CH031
348
458
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
349
- - [ ] Sync HTTP clients constructed inside async request handlers
459
+ - [ ] Extend CH001 to a curated denylist of sync AI/agent SDK client calls inside async functions (vector-DB clients, LLM SDKs) — the gap flake8-async's stdlib-only denylist leaves open
350
460
  - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
351
461
 
352
462
  ---
@@ -1,9 +1,9 @@
1
1
  """codehound - an AST-based static analyzer that hunts real bugs in Python code.
2
2
 
3
- Twenty-two checks. Eight are each backed by a bug that was actually found
4
- and fixed (or opened as a PR) in a popular open-source AI framework
3
+ Thirty-one checks. Eight are each backed by a bug that was actually
4
+ found and fixed (or opened as a PR) in a popular open-source AI framework
5
5
  (agno, crewAI, mem0, llama_index, accelerate, optuna, litellm). The rest
6
- (CH007-CH009, CH012-CH022) are hardening rules verified against real
6
+ (CH007-CH009, CH012-CH031) are hardening rules verified against real
7
7
  false positives across a ~29-framework validation corpus instead - see
8
8
  docs/FINDINGS.md.
9
9
  """
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
  from codehound.checks import ALL_CHECKS, get_checks
14
14
  from codehound.core import Check, Finding, scan_file, scan_path
15
15
 
16
- __version__ = "1.5.0"
16
+ __version__ = "1.7.0"
17
17
 
18
18
  __all__ = [
19
19
  "ALL_CHECKS",
@@ -10,20 +10,29 @@ from codehound.checks.blocking_async import BlockingCallInAsync
10
10
  from codehound.checks.collections_abc_import import CollectionsAbcImport
11
11
  from codehound.checks.datetime_utcnow import DeprecatedDatetimeUtcnow
12
12
  from codehound.checks.discarded_future import DiscardedFuture
13
+ from codehound.checks.finally_swallows_exception import FinallySwallowsException
13
14
  from codehound.checks.floating_process import FloatingProcess
14
15
  from codehound.checks.floating_task import FloatingTask
15
16
  from codehound.checks.floating_thread import FloatingThread
17
+ from codehound.checks.floating_timer import FloatingTimer
16
18
  from codehound.checks.get_event_loop import DeprecatedGetEventLoop
19
+ from codehound.checks.is_literal_comparison import IsLiteralComparison
17
20
  from codehound.checks.loop_closure_capture import LoopClosureCapture
21
+ from codehound.checks.lru_cache_on_async_function import LruCacheOnAsyncFunction
18
22
  from codehound.checks.lru_cache_on_method import LruCacheOnMethod
23
+ from codehound.checks.mutable_class_attribute import MutableClassAttribute
19
24
  from codehound.checks.mutable_defaults import MutableDefaultArgument
20
25
  from codehound.checks.removed_asyncio_task_methods import RemovedAsyncioTaskMethods
21
26
  from codehound.checks.removed_getargspec import RemovedGetargspec
27
+ from codehound.checks.removed_stdlib_attribute import RemovedStdlibAttribute
22
28
  from codehound.checks.removed_stdlib_module import RemovedStdlibModule
23
29
  from codehound.checks.resource_leak import UnclosedFileHandle
24
30
  from codehound.checks.unawaited_coroutine import UnawaitedCoroutineCall
31
+ from codehound.checks.unclosed_pool import UnclosedPool
25
32
  from codehound.checks.unclosed_socket import UnclosedSocket
33
+ from codehound.checks.unittest_deprecated_alias import UnittestDeprecatedAlias
26
34
  from codehound.checks.unprotected_lock import UnprotectedLockAcquire
35
+ from codehound.checks.unwaited_subprocess import UnwaitedSubprocess
27
36
  from codehound.core import Check
28
37
 
29
38
  ALL_CHECKS: list[type[Check]] = [
@@ -49,6 +58,15 @@ ALL_CHECKS: list[type[Check]] = [
49
58
  BareExcept,
50
59
  RemovedStdlibModule,
51
60
  AsyncioCoroutineDecorator,
61
+ RemovedStdlibAttribute,
62
+ UnittestDeprecatedAlias,
63
+ IsLiteralComparison,
64
+ MutableClassAttribute,
65
+ UnwaitedSubprocess,
66
+ FloatingTimer,
67
+ FinallySwallowsException,
68
+ LruCacheOnAsyncFunction,
69
+ UnclosedPool,
52
70
  ]
53
71
 
54
72
 
@@ -0,0 +1,146 @@
1
+ """CH029 - ``return``/``break``/``continue`` in a ``finally:`` block silently drops any pending exception.
2
+
3
+ If the `try:` body raises, and the matching `finally:` block runs a
4
+ `return`, or a `break`/`continue` that jumps to a loop outside the
5
+ `finally` block itself, the exception is discarded entirely - not
6
+ logged, not re-raised, nothing. Verified directly:
7
+
8
+ def f():
9
+ try:
10
+ raise ValueError("boom")
11
+ finally:
12
+ return 5
13
+
14
+ `f()` returns `5`. The `ValueError` never happens as far as the caller
15
+ can tell. This is documented CPython behavior, not a bug in Python
16
+ itself, but it means an exception can vanish from a `finally:` block
17
+ that looks like ordinary cleanup code, with nothing in the syntax
18
+ calling out that it swallows errors - pylint's own `lost-exception`
19
+ (W0150) exists for exactly this.
20
+
21
+ A `break`/`continue` fully contained within a loop that itself lives
22
+ inside the `finally:` block is a different, local loop and does not
23
+ escape - only a `break`/`continue` whose target loop is *outside* the
24
+ `finally:` block (an ancestor of the whole `try` statement) swallows
25
+ the pending exception. Verified directly for both shapes.
26
+
27
+ Not flagged when the `try` has at least one `except` clause and none of
28
+ them ever re-raise: at that point every exception the `try` body could
29
+ produce has already been fully handled by the time control reaches
30
+ `finally`, so a `return`/`break`/`continue` there is just the
31
+ function's normal exit for the already-handled path, not a swallow.
32
+ Real pattern found in letta: `except Exception as e: result["error"] =
33
+ str(e)` (logged, recorded, deliberately not re-raised, the docstring
34
+ literally says "callback failures should not affect job completion")
35
+ followed by `finally: return result` - nothing is pending to discard.
36
+ A `try` with *no* `except` at all is unaffected by this guard, since
37
+ nothing there could have absorbed anything.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import ast
43
+
44
+ from codehound.core import Check, Finding
45
+
46
+
47
+ def _has_escaping_return(stmts: list[ast.stmt]) -> ast.Return | None:
48
+ def walk(node: ast.AST) -> ast.Return | None:
49
+ if isinstance(node, ast.Return):
50
+ return node
51
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
52
+ return None
53
+ for child in ast.iter_child_nodes(node):
54
+ found = walk(child)
55
+ if found is not None:
56
+ return found
57
+ return None
58
+
59
+ for stmt in stmts:
60
+ found = walk(stmt)
61
+ if found is not None:
62
+ return found
63
+ return None
64
+
65
+
66
+ def _owning_loop_is_outside_finally(node: ast.AST, try_node: ast.Try, parents: dict) -> bool:
67
+ cur: ast.AST | None = node
68
+ while cur is not None and cur is not try_node:
69
+ if isinstance(cur, (ast.For, ast.AsyncFor, ast.While)):
70
+ return False
71
+ cur = parents.get(id(cur))
72
+ return True
73
+
74
+
75
+ def _find_escaping_break_or_continue(
76
+ stmts: list[ast.stmt], try_node: ast.Try, parents: dict
77
+ ) -> ast.stmt | None:
78
+ for stmt in stmts:
79
+ for node in ast.walk(stmt):
80
+ if isinstance(node, (ast.Break, ast.Continue)) and _owning_loop_is_outside_finally(
81
+ node, try_node, parents
82
+ ):
83
+ return node
84
+ return None
85
+
86
+
87
+ def _has_raise_in_own_scope(handler: ast.ExceptHandler) -> bool:
88
+ """Any `raise` in the handler's own reachable body - not counting a
89
+ nested try/except's own handler, which is a different scope."""
90
+ found = False
91
+
92
+ def walk(node: ast.AST) -> None:
93
+ nonlocal found
94
+ if found:
95
+ return
96
+ for child in ast.iter_child_nodes(node):
97
+ if isinstance(child, ast.Raise):
98
+ found = True
99
+ return
100
+ if isinstance(child, ast.ExceptHandler):
101
+ continue
102
+ walk(child)
103
+
104
+ walk(handler)
105
+ return found
106
+
107
+
108
+ def _fully_absorbs_exceptions(try_node: ast.Try) -> bool:
109
+ if not try_node.handlers:
110
+ return False
111
+ return all(not _has_raise_in_own_scope(h) for h in try_node.handlers)
112
+
113
+
114
+ class FinallySwallowsException(Check):
115
+ code = "CH029"
116
+ name = "finally-swallows-exception"
117
+ description = "return/break/continue in a finally: block silently discards any exception from the try:."
118
+
119
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
120
+ findings: list[Finding] = []
121
+ for node in ast.walk(tree):
122
+ if not isinstance(node, ast.Try) or not node.finalbody:
123
+ continue
124
+ if _fully_absorbs_exceptions(node):
125
+ continue
126
+ culprit: ast.stmt | None = _has_escaping_return(node.finalbody)
127
+ kind = "return"
128
+ if culprit is None:
129
+ culprit = _find_escaping_break_or_continue(node.finalbody, node, parents)
130
+ kind = "break" if isinstance(culprit, ast.Break) else "continue"
131
+ if culprit is None:
132
+ continue
133
+ findings.append(
134
+ Finding(
135
+ path=path,
136
+ line=culprit.lineno,
137
+ col=culprit.col_offset,
138
+ code=self.code,
139
+ message=(
140
+ f"`{kind}` in this `finally:` block silently discards any exception "
141
+ f"raised in the `try:` body - the caller never sees it. Move the "
142
+ f"{kind} out of `finally:`, or re-raise explicitly if that's intended."
143
+ ),
144
+ )
145
+ )
146
+ return findings