codehound 1.6.0__tar.gz → 1.8.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 (52) hide show
  1. {codehound-1.6.0 → codehound-1.8.0}/PKG-INFO +94 -9
  2. {codehound-1.6.0 → codehound-1.8.0}/README.md +92 -8
  3. {codehound-1.6.0 → codehound-1.8.0}/pyproject.toml +1 -1
  4. codehound-1.8.0/src/codehound/__init__.py +33 -0
  5. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/__init__.py +6 -0
  6. codehound-1.8.0/src/codehound/checks/finally_swallows_exception.py +146 -0
  7. codehound-1.8.0/src/codehound/checks/lru_cache_on_async_function.py +74 -0
  8. codehound-1.8.0/src/codehound/checks/unclosed_pool.py +142 -0
  9. codehound-1.8.0/src/codehound/cli.py +169 -0
  10. codehound-1.8.0/src/codehound/config.py +85 -0
  11. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/core.py +82 -5
  12. codehound-1.8.0/src/codehound/fixes.py +151 -0
  13. {codehound-1.6.0 → codehound-1.8.0}/tests/test_checks.py +233 -0
  14. codehound-1.8.0/tests/test_config.py +63 -0
  15. codehound-1.8.0/tests/test_fixes.py +83 -0
  16. codehound-1.8.0/tests/test_noqa.py +97 -0
  17. codehound-1.8.0/tests/test_parallel_scan.py +68 -0
  18. codehound-1.6.0/src/codehound/__init__.py +0 -26
  19. codehound-1.6.0/src/codehound/cli.py +0 -106
  20. {codehound-1.6.0 → codehound-1.8.0}/.gitignore +0 -0
  21. {codehound-1.6.0 → codehound-1.8.0}/LICENSE +0 -0
  22. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/async_property.py +0 -0
  23. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/asyncio_coroutine_decorator.py +0 -0
  24. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
  25. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/bare_except.py +0 -0
  26. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/blocking_async.py +0 -0
  27. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/collections_abc_import.py +0 -0
  28. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/datetime_utcnow.py +0 -0
  29. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/discarded_future.py +0 -0
  30. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/floating_process.py +0 -0
  31. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/floating_task.py +0 -0
  32. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/floating_thread.py +0 -0
  33. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/floating_timer.py +0 -0
  34. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/get_event_loop.py +0 -0
  35. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/is_literal_comparison.py +0 -0
  36. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/loop_closure_capture.py +0 -0
  37. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/lru_cache_on_method.py +0 -0
  38. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/mutable_class_attribute.py +0 -0
  39. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/mutable_defaults.py +0 -0
  40. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
  41. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/removed_getargspec.py +0 -0
  42. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/removed_stdlib_attribute.py +0 -0
  43. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/removed_stdlib_module.py +0 -0
  44. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/resource_leak.py +0 -0
  45. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
  46. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/unclosed_socket.py +0 -0
  47. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/unittest_deprecated_alias.py +0 -0
  48. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/unprotected_lock.py +0 -0
  49. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/checks/unwaited_subprocess.py +0 -0
  50. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/sarif.py +0 -0
  51. {codehound-1.6.0 → codehound-1.8.0}/src/codehound/terminal.py +0 -0
  52. {codehound-1.6.0 → codehound-1.8.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.6.0
3
+ Version: 1.8.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
@@ -16,6 +16,7 @@ Classifier: Topic :: Software Development :: Quality Assurance
16
16
  Requires-Python: >=3.9
17
17
  Provides-Extra: dev
18
18
  Requires-Dist: pytest>=7; extra == 'dev'
19
+ Requires-Dist: tomli>=2; (python_version < '3.11') and extra == 'dev'
19
20
  Description-Content-Type: text/markdown
20
21
 
21
22
  <p align="center">
@@ -24,7 +25,7 @@ Description-Content-Type: text/markdown
24
25
 
25
26
  <h1 align="center">codehound</h1>
26
27
 
27
- **An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty-eight 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
+ **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
29
 
29
30
  [![CI](https://github.com/kratos0718/codehound/actions/workflows/ci.yml/badge.svg)](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
30
31
  [![PyPI](https://img.shields.io/pypi/v/codehound.svg)](https://pypi.org/project/codehound/)
@@ -65,13 +66,29 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
65
66
 
66
67
  ---
67
68
 
69
+ ## How this compares
70
+
71
+ 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.
72
+
73
+ What actually seems to be missing elsewhere, as far as I've been able to find:
74
+
75
+ - **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.
76
+ - **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.
77
+ - **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.
78
+
79
+ 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.
80
+
81
+ **Closing the "toy project" gaps, honestly.** Ruff is a single, fast binary with editor integrations, a plugin-free config file, autofix, and inline suppression - table stakes for a tool people actually adopt, not just admire. `codehound` isn't going to out-perform a Rust tool by staying pure Python, but it now has the parts of that list that don't require rewriting the whole thing: a `[tool.codehound]` block in `pyproject.toml`, `# noqa`/`# noqa: CH001` inline suppression (same syntax flake8/ruff already use, so it doesn't collide with either), `--fix` for the two checks where the rewrite is genuinely unambiguous (CH017 always, CH004 only inside `async def` - guessing wrong on the rest would be worse than not fixing them), and scanning parallelized across a process pool once there's enough files to make that worth it. Measured, not claimed: a full scan of HuggingFace's `transformers` (thousands of files) went from 57 seconds to 12 - verified byte-identical against the sequential result first, not just "seems faster."
82
+
83
+ ---
84
+
68
85
  ## Install
69
86
 
70
87
  ```bash
71
88
  pip install codehound
72
89
  ```
73
90
 
74
- Zero dependencies — it's ~3,200 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
91
+ Zero dependencies — it's ~4,000 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
92
 
76
93
  <details>
77
94
  <summary>From a clone instead (for development)</summary>
@@ -97,6 +114,9 @@ codehound scan file1.py file2.py src/
97
114
  # only run specific checks
98
115
  codehound scan path/to/project --select CH001,CH006
99
116
 
117
+ # also skip extra directories beyond the built-in defaults
118
+ codehound scan path/to/project --exclude migrations,generated
119
+
100
120
  # machine-readable output for CI dashboards
101
121
  codehound scan path/to/project --format json
102
122
  codehound scan path/to/project --format csv
@@ -104,6 +124,9 @@ codehound scan path/to/project --format csv
104
124
  # GitHub Code Scanning (Security tab) can ingest this directly
105
125
  codehound scan path/to/project --format sarif > results.sarif
106
126
 
127
+ # rewrite the fixable findings in place, then report what's left
128
+ codehound scan path/to/project --fix
129
+
107
130
  # list every available check
108
131
  codehound list
109
132
  ```
@@ -114,6 +137,32 @@ codehound list
114
137
  - run: codehound scan src # fails the build on a regression
115
138
  ```
116
139
 
140
+ A finding you've reviewed and want to keep suppresses the same way flake8/ruff findings do - a trailing `# noqa` (everything on that line) or `# noqa: CH001` (just that code):
141
+
142
+ ```python
143
+ time.sleep(1) # noqa: CH001 - deliberate; this branch only runs at startup, before the loop exists
144
+ ```
145
+
146
+ ### Config file
147
+
148
+ Drop defaults into `[tool.codehound]` in `pyproject.toml` so you don't have to repeat flags on every invocation - explicit CLI flags always win over these:
149
+
150
+ ```toml
151
+ [tool.codehound]
152
+ select = ["CH001", "CH006"] # same as --select
153
+ exclude = ["migrations"] # extra directories to skip, merged with the built-in defaults
154
+ paths = ["src"] # what `codehound scan` (no path args) scans
155
+ ```
156
+
157
+ Requires Python 3.11+ to load (uses the standard-library `tomllib`) - on 3.9/3.10 the config file is silently skipped and every flag still works exactly the same via the CLI, since nothing about codehound itself depends on being able to read it.
158
+
159
+ ### `--fix`
160
+
161
+ Only two checks ship an autofix, and deliberately so - every other check either needs a judgment call (is this "leak" actually intentional?) or an import that may or may not already be in scope, and guessing wrong there is worse than just reporting the finding:
162
+
163
+ - **CH017** - `collections.<ABC>` → `collections.abc.<ABC>`, a pure rename, always safe.
164
+ - **CH004** - `asyncio.get_event_loop()` → `asyncio.get_running_loop()`, but *only* inside an `async def`. Outside one, `get_running_loop()` raises where `get_event_loop()` wouldn't, so those calls are left as detection-only.
165
+
117
166
  ### GitHub Action
118
167
 
119
168
  ```yaml
@@ -132,7 +181,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
132
181
  ```yaml
133
182
  repos:
134
183
  - repo: https://github.com/kratos0718/codehound
135
- rev: v1.6.0
184
+ rev: v1.8.0
136
185
  hooks:
137
186
  - id: codehound
138
187
  ```
@@ -171,10 +220,13 @@ repos:
171
220
  | **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 |
172
221
  | **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) |
173
222
  | **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 |
223
+ | **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 |
224
+ | **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 |
225
+ | **CH031** | `unclosed-pool` | `multiprocessing.Pool()` never `.close()`d/`.terminate()`d — worker processes leak for the life of the parent. | hardening rule |
174
226
 
175
227
  `codehound list` prints this from the source of truth.
176
228
 
177
- CH007-CH028 don't have found-and-merged bugs behind all of them the way
229
+ CH007-CH031 don't have found-and-merged bugs behind all of them the way
178
230
  CH001-CH006 do - most are hardening rules for well-known Python
179
231
  correctness gotchas rather than something this project personally
180
232
  tracked down first. CH010 and CH011 are the exceptions: both found
@@ -331,6 +383,25 @@ a real hand-off to a different object, reaped later through a separate
331
383
  `terminate_process(lm.process)` call, the same "stored as any object's
332
384
  attribute" escape CH009/CH016/CH028 already needed. Added it.
333
385
 
386
+ **CH029 found a real bug in letta on day one, and its own precision gap
387
+ right after.** The first corpus scan flagged `except Exception as e:
388
+ result["error"] = str(e)` followed by `finally: return result` in
389
+ letta's job-callback dispatcher - but that except deliberately never
390
+ re-raises (the code comment says so directly: "callback failures should
391
+ not affect job completion"), so nothing is actually pending to swallow
392
+ by the time `finally` runs. Fixed by skipping a `try` whose `except`
393
+ clauses never re-raise anywhere in their own scope - there's nothing
394
+ live left to discard at that point. The same scan also turned up a
395
+ *more serious*, still-real instance in two of letta's LLM streaming
396
+ adapters: `except BaseException: <log, then re-raise a typed error>`
397
+ immediately followed by `finally: if not stream_started: return` -
398
+ except `stream_started` is unconditionally set `True` a few lines
399
+ earlier with no other assignment anywhere in the function, so today
400
+ that specific `return` is dead code, not a currently-live swallow. Read
401
+ carefully rather than assumed, and not filed as a bug report since it
402
+ isn't actually firing right now - but flagged as exactly the kind of
403
+ fragile code a future refactor could silently turn into a real one.
404
+
334
405
  **Two checks we built and did not ship.** `exception-chaining` (`except X
335
406
  as e: raise Y(...)` with no `from e`, discarding the real traceback -
336
407
  overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
@@ -394,12 +465,15 @@ codehound/
394
465
  ├── is_literal_comparison.py (CH025)
395
466
  ├── mutable_class_attribute.py (CH026)
396
467
  ├── unwaited_subprocess.py (CH027)
397
- └── floating_timer.py (CH028)
468
+ ├── floating_timer.py (CH028)
469
+ ├── finally_swallows_exception.py (CH029)
470
+ ├── lru_cache_on_async_function.py (CH030)
471
+ └── unclosed_pool.py (CH031)
398
472
  ```
399
473
 
400
474
  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.
401
475
 
402
- **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. 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."
476
+ **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."
403
477
 
404
478
  ---
405
479
 
@@ -434,9 +508,20 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
434
508
  - [x] 28 checks — removed stdlib functions, deprecated unittest aliases,
435
509
  `is`-literal comparisons, mutable class attributes, unwaited
436
510
  subprocesses, floating timers — CH023-CH028
511
+ - [x] 31 checks — `finally:` blocks that swallow exceptions, `lru_cache`
512
+ on async functions, unclosed `multiprocessing.Pool` — CH029-CH031
513
+ - [x] Inline `# noqa` / `# noqa: CH001` suppression
514
+ - [x] `[tool.codehound]` project config in `pyproject.toml` (`select`,
515
+ `exclude`, `paths` — Python 3.11+ to load, every flag still works
516
+ without it on 3.9/3.10)
517
+ - [x] `--fix` — CH017 always, CH004 only inside `async def` (CH002/CH003
518
+ turned out to need judgment calls or import-injection this tool
519
+ won't guess at, so they stay detection-only; see docs/ARCHITECTURE.md)
437
520
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
438
- - [ ] Sync HTTP clients constructed inside async request handlers
439
- - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
521
+ - [ ] 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
522
+ - [x] Parallelize scanning across files for large codebases a full
523
+ HuggingFace transformers scan went from 57s to 12s (measured,
524
+ byte-identical output verified against the sequential run)
440
525
 
441
526
  ---
442
527
 
@@ -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-eight 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,29 @@ 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
+ **Closing the "toy project" gaps, honestly.** Ruff is a single, fast binary with editor integrations, a plugin-free config file, autofix, and inline suppression - table stakes for a tool people actually adopt, not just admire. `codehound` isn't going to out-perform a Rust tool by staying pure Python, but it now has the parts of that list that don't require rewriting the whole thing: a `[tool.codehound]` block in `pyproject.toml`, `# noqa`/`# noqa: CH001` inline suppression (same syntax flake8/ruff already use, so it doesn't collide with either), `--fix` for the two checks where the rewrite is genuinely unambiguous (CH017 always, CH004 only inside `async def` - guessing wrong on the rest would be worse than not fixing them), and scanning parallelized across a process pool once there's enough files to make that worth it. Measured, not claimed: a full scan of HuggingFace's `transformers` (thousands of files) went from 57 seconds to 12 - verified byte-identical against the sequential result first, not just "seems faster."
61
+
62
+ ---
63
+
48
64
  ## Install
49
65
 
50
66
  ```bash
51
67
  pip install codehound
52
68
  ```
53
69
 
54
- Zero dependencies — it's ~3,200 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
70
+ Zero dependencies — it's ~4,000 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
71
 
56
72
  <details>
57
73
  <summary>From a clone instead (for development)</summary>
@@ -77,6 +93,9 @@ codehound scan file1.py file2.py src/
77
93
  # only run specific checks
78
94
  codehound scan path/to/project --select CH001,CH006
79
95
 
96
+ # also skip extra directories beyond the built-in defaults
97
+ codehound scan path/to/project --exclude migrations,generated
98
+
80
99
  # machine-readable output for CI dashboards
81
100
  codehound scan path/to/project --format json
82
101
  codehound scan path/to/project --format csv
@@ -84,6 +103,9 @@ codehound scan path/to/project --format csv
84
103
  # GitHub Code Scanning (Security tab) can ingest this directly
85
104
  codehound scan path/to/project --format sarif > results.sarif
86
105
 
106
+ # rewrite the fixable findings in place, then report what's left
107
+ codehound scan path/to/project --fix
108
+
87
109
  # list every available check
88
110
  codehound list
89
111
  ```
@@ -94,6 +116,32 @@ codehound list
94
116
  - run: codehound scan src # fails the build on a regression
95
117
  ```
96
118
 
119
+ A finding you've reviewed and want to keep suppresses the same way flake8/ruff findings do - a trailing `# noqa` (everything on that line) or `# noqa: CH001` (just that code):
120
+
121
+ ```python
122
+ time.sleep(1) # noqa: CH001 - deliberate; this branch only runs at startup, before the loop exists
123
+ ```
124
+
125
+ ### Config file
126
+
127
+ Drop defaults into `[tool.codehound]` in `pyproject.toml` so you don't have to repeat flags on every invocation - explicit CLI flags always win over these:
128
+
129
+ ```toml
130
+ [tool.codehound]
131
+ select = ["CH001", "CH006"] # same as --select
132
+ exclude = ["migrations"] # extra directories to skip, merged with the built-in defaults
133
+ paths = ["src"] # what `codehound scan` (no path args) scans
134
+ ```
135
+
136
+ Requires Python 3.11+ to load (uses the standard-library `tomllib`) - on 3.9/3.10 the config file is silently skipped and every flag still works exactly the same via the CLI, since nothing about codehound itself depends on being able to read it.
137
+
138
+ ### `--fix`
139
+
140
+ Only two checks ship an autofix, and deliberately so - every other check either needs a judgment call (is this "leak" actually intentional?) or an import that may or may not already be in scope, and guessing wrong there is worse than just reporting the finding:
141
+
142
+ - **CH017** - `collections.<ABC>` → `collections.abc.<ABC>`, a pure rename, always safe.
143
+ - **CH004** - `asyncio.get_event_loop()` → `asyncio.get_running_loop()`, but *only* inside an `async def`. Outside one, `get_running_loop()` raises where `get_event_loop()` wouldn't, so those calls are left as detection-only.
144
+
97
145
  ### GitHub Action
98
146
 
99
147
  ```yaml
@@ -112,7 +160,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
112
160
  ```yaml
113
161
  repos:
114
162
  - repo: https://github.com/kratos0718/codehound
115
- rev: v1.6.0
163
+ rev: v1.8.0
116
164
  hooks:
117
165
  - id: codehound
118
166
  ```
@@ -151,10 +199,13 @@ repos:
151
199
  | **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 |
152
200
  | **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) |
153
201
  | **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 |
202
+ | **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 |
203
+ | **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 |
204
+ | **CH031** | `unclosed-pool` | `multiprocessing.Pool()` never `.close()`d/`.terminate()`d — worker processes leak for the life of the parent. | hardening rule |
154
205
 
155
206
  `codehound list` prints this from the source of truth.
156
207
 
157
- CH007-CH028 don't have found-and-merged bugs behind all of them the way
208
+ CH007-CH031 don't have found-and-merged bugs behind all of them the way
158
209
  CH001-CH006 do - most are hardening rules for well-known Python
159
210
  correctness gotchas rather than something this project personally
160
211
  tracked down first. CH010 and CH011 are the exceptions: both found
@@ -311,6 +362,25 @@ a real hand-off to a different object, reaped later through a separate
311
362
  `terminate_process(lm.process)` call, the same "stored as any object's
312
363
  attribute" escape CH009/CH016/CH028 already needed. Added it.
313
364
 
365
+ **CH029 found a real bug in letta on day one, and its own precision gap
366
+ right after.** The first corpus scan flagged `except Exception as e:
367
+ result["error"] = str(e)` followed by `finally: return result` in
368
+ letta's job-callback dispatcher - but that except deliberately never
369
+ re-raises (the code comment says so directly: "callback failures should
370
+ not affect job completion"), so nothing is actually pending to swallow
371
+ by the time `finally` runs. Fixed by skipping a `try` whose `except`
372
+ clauses never re-raise anywhere in their own scope - there's nothing
373
+ live left to discard at that point. The same scan also turned up a
374
+ *more serious*, still-real instance in two of letta's LLM streaming
375
+ adapters: `except BaseException: <log, then re-raise a typed error>`
376
+ immediately followed by `finally: if not stream_started: return` -
377
+ except `stream_started` is unconditionally set `True` a few lines
378
+ earlier with no other assignment anywhere in the function, so today
379
+ that specific `return` is dead code, not a currently-live swallow. Read
380
+ carefully rather than assumed, and not filed as a bug report since it
381
+ isn't actually firing right now - but flagged as exactly the kind of
382
+ fragile code a future refactor could silently turn into a real one.
383
+
314
384
  **Two checks we built and did not ship.** `exception-chaining` (`except X
315
385
  as e: raise Y(...)` with no `from e`, discarding the real traceback -
316
386
  overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
@@ -374,12 +444,15 @@ codehound/
374
444
  ├── is_literal_comparison.py (CH025)
375
445
  ├── mutable_class_attribute.py (CH026)
376
446
  ├── unwaited_subprocess.py (CH027)
377
- └── floating_timer.py (CH028)
447
+ ├── floating_timer.py (CH028)
448
+ ├── finally_swallows_exception.py (CH029)
449
+ ├── lru_cache_on_async_function.py (CH030)
450
+ └── unclosed_pool.py (CH031)
378
451
  ```
379
452
 
380
453
  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.
381
454
 
382
- **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. 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."
455
+ **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."
383
456
 
384
457
  ---
385
458
 
@@ -414,9 +487,20 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
414
487
  - [x] 28 checks — removed stdlib functions, deprecated unittest aliases,
415
488
  `is`-literal comparisons, mutable class attributes, unwaited
416
489
  subprocesses, floating timers — CH023-CH028
490
+ - [x] 31 checks — `finally:` blocks that swallow exceptions, `lru_cache`
491
+ on async functions, unclosed `multiprocessing.Pool` — CH029-CH031
492
+ - [x] Inline `# noqa` / `# noqa: CH001` suppression
493
+ - [x] `[tool.codehound]` project config in `pyproject.toml` (`select`,
494
+ `exclude`, `paths` — Python 3.11+ to load, every flag still works
495
+ without it on 3.9/3.10)
496
+ - [x] `--fix` — CH017 always, CH004 only inside `async def` (CH002/CH003
497
+ turned out to need judgment calls or import-injection this tool
498
+ won't guess at, so they stay detection-only; see docs/ARCHITECTURE.md)
417
499
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
418
- - [ ] Sync HTTP clients constructed inside async request handlers
419
- - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
500
+ - [ ] 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
501
+ - [x] Parallelize scanning across files for large codebases a full
502
+ HuggingFace transformers scan went from 57s to 12s (measured,
503
+ byte-identical output verified against the sequential run)
420
504
 
421
505
  ---
422
506
 
@@ -28,7 +28,7 @@ Issues = "https://github.com/kratos0718/codehound/issues"
28
28
  codehound = "codehound.cli:main"
29
29
 
30
30
  [project.optional-dependencies]
31
- dev = ["pytest>=7"]
31
+ dev = ["pytest>=7", "tomli>=2; python_version < '3.11'"]
32
32
 
33
33
  [tool.hatch.version]
34
34
  path = "src/codehound/__init__.py"
@@ -0,0 +1,33 @@
1
+ """codehound - an AST-based static analyzer that hunts real bugs in Python code.
2
+
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
+ (agno, crewAI, mem0, llama_index, accelerate, optuna, litellm). The rest
6
+ (CH007-CH009, CH012-CH031) are hardening rules verified against real
7
+ false positives across a ~29-framework validation corpus instead - see
8
+ docs/FINDINGS.md.
9
+
10
+ Also has the parts of a production-grade linter that don't require
11
+ rewriting the whole thing in Rust: inline `# noqa` suppression,
12
+ `[tool.codehound]` project config, `--fix` for the checks where the
13
+ rewrite is genuinely unambiguous, and scanning parallelized across a
14
+ process pool for large codebases.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from codehound.checks import ALL_CHECKS, get_checks
20
+ from codehound.core import Check, Finding, scan_file, scan_files, scan_path
21
+
22
+ __version__ = "1.8.0"
23
+
24
+ __all__ = [
25
+ "ALL_CHECKS",
26
+ "get_checks",
27
+ "Check",
28
+ "Finding",
29
+ "scan_file",
30
+ "scan_files",
31
+ "scan_path",
32
+ "__version__",
33
+ ]
@@ -10,6 +10,7 @@ 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,6 +18,7 @@ from codehound.checks.floating_timer import FloatingTimer
17
18
  from codehound.checks.get_event_loop import DeprecatedGetEventLoop
18
19
  from codehound.checks.is_literal_comparison import IsLiteralComparison
19
20
  from codehound.checks.loop_closure_capture import LoopClosureCapture
21
+ from codehound.checks.lru_cache_on_async_function import LruCacheOnAsyncFunction
20
22
  from codehound.checks.lru_cache_on_method import LruCacheOnMethod
21
23
  from codehound.checks.mutable_class_attribute import MutableClassAttribute
22
24
  from codehound.checks.mutable_defaults import MutableDefaultArgument
@@ -26,6 +28,7 @@ from codehound.checks.removed_stdlib_attribute import RemovedStdlibAttribute
26
28
  from codehound.checks.removed_stdlib_module import RemovedStdlibModule
27
29
  from codehound.checks.resource_leak import UnclosedFileHandle
28
30
  from codehound.checks.unawaited_coroutine import UnawaitedCoroutineCall
31
+ from codehound.checks.unclosed_pool import UnclosedPool
29
32
  from codehound.checks.unclosed_socket import UnclosedSocket
30
33
  from codehound.checks.unittest_deprecated_alias import UnittestDeprecatedAlias
31
34
  from codehound.checks.unprotected_lock import UnprotectedLockAcquire
@@ -61,6 +64,9 @@ ALL_CHECKS: list[type[Check]] = [
61
64
  MutableClassAttribute,
62
65
  UnwaitedSubprocess,
63
66
  FloatingTimer,
67
+ FinallySwallowsException,
68
+ LruCacheOnAsyncFunction,
69
+ UnclosedPool,
64
70
  ]
65
71
 
66
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