codehound 1.3.0__tar.gz → 1.4.1__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 (34) hide show
  1. {codehound-1.3.0 → codehound-1.4.1}/PKG-INFO +85 -21
  2. {codehound-1.3.0 → codehound-1.4.1}/README.md +84 -20
  3. {codehound-1.3.0 → codehound-1.4.1}/src/codehound/__init__.py +5 -4
  4. {codehound-1.3.0 → codehound-1.4.1}/src/codehound/checks/__init__.py +20 -0
  5. codehound-1.4.1/src/codehound/checks/async_property.py +54 -0
  6. codehound-1.4.1/src/codehound/checks/bare_except.py +88 -0
  7. codehound-1.4.1/src/codehound/checks/collections_abc_import.py +83 -0
  8. codehound-1.4.1/src/codehound/checks/discarded_future.py +93 -0
  9. codehound-1.4.1/src/codehound/checks/floating_process.py +130 -0
  10. codehound-1.4.1/src/codehound/checks/lru_cache_on_method.py +79 -0
  11. codehound-1.4.1/src/codehound/checks/removed_asyncio_task_methods.py +66 -0
  12. codehound-1.4.1/src/codehound/checks/removed_getargspec.py +58 -0
  13. codehound-1.4.1/src/codehound/checks/unclosed_socket.py +140 -0
  14. codehound-1.4.1/src/codehound/checks/unprotected_lock.py +105 -0
  15. codehound-1.4.1/tests/test_checks.py +959 -0
  16. codehound-1.3.0/tests/test_checks.py +0 -469
  17. {codehound-1.3.0 → codehound-1.4.1}/.gitignore +0 -0
  18. {codehound-1.3.0 → codehound-1.4.1}/LICENSE +0 -0
  19. {codehound-1.3.0 → codehound-1.4.1}/pyproject.toml +0 -0
  20. {codehound-1.3.0 → codehound-1.4.1}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
  21. {codehound-1.3.0 → codehound-1.4.1}/src/codehound/checks/blocking_async.py +0 -0
  22. {codehound-1.3.0 → codehound-1.4.1}/src/codehound/checks/datetime_utcnow.py +0 -0
  23. {codehound-1.3.0 → codehound-1.4.1}/src/codehound/checks/floating_task.py +0 -0
  24. {codehound-1.3.0 → codehound-1.4.1}/src/codehound/checks/floating_thread.py +0 -0
  25. {codehound-1.3.0 → codehound-1.4.1}/src/codehound/checks/get_event_loop.py +0 -0
  26. {codehound-1.3.0 → codehound-1.4.1}/src/codehound/checks/loop_closure_capture.py +0 -0
  27. {codehound-1.3.0 → codehound-1.4.1}/src/codehound/checks/mutable_defaults.py +0 -0
  28. {codehound-1.3.0 → codehound-1.4.1}/src/codehound/checks/resource_leak.py +0 -0
  29. {codehound-1.3.0 → codehound-1.4.1}/src/codehound/checks/unawaited_coroutine.py +0 -0
  30. {codehound-1.3.0 → codehound-1.4.1}/src/codehound/cli.py +0 -0
  31. {codehound-1.3.0 → codehound-1.4.1}/src/codehound/core.py +0 -0
  32. {codehound-1.3.0 → codehound-1.4.1}/src/codehound/sarif.py +0 -0
  33. {codehound-1.3.0 → codehound-1.4.1}/src/codehound/terminal.py +0 -0
  34. {codehound-1.3.0 → codehound-1.4.1}/tests/test_output_formats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codehound
3
- Version: 1.3.0
3
+ Version: 1.4.1
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 — seven of the ten rules are backed by a bug that was actually found and merged into a major open-source AI framework; the other three are hardening rules verified against real false positives instead.**
27
+ **An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty checks, seven backed by a bug that was actually found and merged into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~20-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/)
@@ -34,7 +34,7 @@ Description-Content-Type: text/markdown
34
34
 
35
35
  Most linters flag style. `codehound` flags the *subtle correctness and async-safety bugs* that slip past code review and only bite in production — event-loop stalls, shared mutable state, leaked file descriptors, fire-and-forget tasks that get garbage-collected mid-run.
36
36
 
37
- Each of the six checks below isn't theoretical. **I wrote it after finding — and fixing, via a merged pull request — that exact bug in a real, popular framework** (agno 25k⭐, crewAI 30k⭐, mem0, huggingface_hub).
37
+ Most of the checks below aren't theoretical. **I wrote them after finding — and fixing, via a merged pull request — that exact bug in a real, popular framework** (agno 25k⭐, crewAI 30k⭐, mem0, llama_index, accelerate).
38
38
 
39
39
  ---
40
40
 
@@ -71,7 +71,7 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
71
71
  pip install codehound
72
72
  ```
73
73
 
74
- Zero dependencies — it's ~750 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
74
+ Zero dependencies — it's ~2,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.
75
75
 
76
76
  <details>
77
77
  <summary>From a clone instead (for development)</summary>
@@ -117,7 +117,7 @@ codehound list
117
117
  ### GitHub Action
118
118
 
119
119
  ```yaml
120
- - uses: kratos0718/codehound@v1.2.0
120
+ - uses: kratos0718/codehound@v1
121
121
  with:
122
122
  path: src
123
123
  # select: CH001,CH006 # optional, defaults to all checks
@@ -132,7 +132,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
132
132
  ```yaml
133
133
  repos:
134
134
  - repo: https://github.com/kratos0718/codehound
135
- rev: v1.2.0
135
+ rev: v1.4.1
136
136
  hooks:
137
137
  - id: codehound
138
138
  ```
@@ -153,15 +153,26 @@ repos:
153
153
  | **CH008** | `asyncio-run-in-running-loop` | `asyncio.run(...)` called from inside an `async def` — always raises `RuntimeError`, immediately, every time. | hardening rule — zero corpus hits (see below) |
154
154
  | **CH009** | `floating-thread` | A non-daemon `threading.Thread` that's `.start()`ed but never `.join()`ed — the thread analog of CH006. | hardening rule — see below |
155
155
  | **CH010** | `loop-closure-capture` | A `lambda` inside a `for` loop (or comprehension) that's *stored* (appended, assigned, returned) and captures the loop variable by reference — every stored instance ends up sharing the loop's **final** value. | **accelerate** (HuggingFace) — `MegatronEngine.get_module_config`'s `param_sync_func` list, PR #4273 |
156
+ | **CH011** | `lru-cache-on-method` | `@lru_cache`/`@cache` decorating an instance method — the cache holds a strong reference to `self` forever, so every instance that ever calls the method leaks for the process lifetime. | **optuna** — `_FanovaTree`'s node-lookup methods leaked every tree built for a `get_param_importances()` call; **llama_index** — `VectaraIndex._get_corpus_key` leaked the index *and* broke its own `__del__`-based HTTP session cleanup |
157
+ | **CH012** | `floating-process` | A non-daemon `multiprocessing.Process` that's `.start()`ed but never `.join()`ed — the process analog of CH009. | hardening rule |
158
+ | **CH013** | `discarded-future` | `ThreadPoolExecutor`/`ProcessPoolExecutor.submit(...)` called as a bare statement — the returned `Future` (and any exception raised inside the submitted work) is silently discarded. | hardening rule — real hits in litellm, accelerate, langchain |
159
+ | **CH014** | `unprotected-lock-acquire` | `lock.acquire()` outside a `with`, whose matching `.release()` isn't inside a `finally:` — an exception between acquire and release deadlocks every future caller of that lock. | hardening rule — real hits in vllm, accelerate, torchtune |
160
+ | **CH015** | `async-property` | `@property`/`@cached_property` wrapping an `async def` — accessing the attribute hands back an un-awaited coroutine object, not the value. | hardening rule |
161
+ | **CH016** | `unclosed-socket` | `socket.socket(...)` stored without a context manager or matching `.close()` — the socket analog of CH005; leaks the file descriptor. | hardening rule |
162
+ | **CH017** | `collections-abc-import` | `from collections import Mapping` (or `Sequence`, `Iterable`, …) — the ABCs were removed from `collections` itself in Python 3.10; they live in `collections.abc`. | hardening rule |
163
+ | **CH018** | `removed-asyncio-task-methods` | `asyncio.Task.current_task()` / `.all_tasks()` — both removed in Python 3.9; use `asyncio.current_task()` / `asyncio.all_tasks()`. | hardening rule |
164
+ | **CH019** | `removed-getargspec` | `inspect.getargspec(...)` — removed in Python 3.11 after a decade-plus deprecation; use `inspect.signature(...)`. | hardening rule |
165
+ | **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 |
156
166
 
157
167
  `codehound list` prints this from the source of truth.
158
168
 
159
- CH007-CH010 don't have found-and-merged bugs behind all of them the way
160
- CH001-CH006 do - three are hardening rules for well-known Python
169
+ CH007-CH020 don't have found-and-merged bugs behind all of them the way
170
+ CH001-CH006 do - most are hardening rules for well-known Python
161
171
  correctness gotchas rather than something this project personally
162
- tracked down first. CH010 is the exception: it found a genuine, serious
163
- bug on its own, in HuggingFace's `accelerate` - see below. Building all
164
- four surfaced real false positives, each one fixed before shipping:
172
+ tracked down first. CH010 and CH011 are the exceptions: both found
173
+ genuine bugs on their own, in HuggingFace's `accelerate`, optuna, and
174
+ llama_index - see below. Building CH007-CH010 surfaced real false
175
+ positives, each one fixed before shipping:
165
176
 
166
177
  - **CH007** (agno): a bare `self.foo()` call matched against an unrelated
167
178
  same-named `async def foo` on a *different* class (agno's own
@@ -199,14 +210,54 @@ it's very unlikely to survive basic testing; CH007 and CH009 both only
199
210
  match same-file names by design, and most real cases of either are
200
211
  plausibly cross-module.
201
212
 
202
- **One check we built and did not ship: CH011 `exception-chaining`**
203
- (`except X as e: raise Y(...)` with no `from e`, discarding the real
204
- traceback - overlaps flake8-bugbear B904). It worked exactly as designed,
205
- but at a scale that says more about how common the pattern is than about
206
- anything worth flagging: **1,911 hits across the same ~20-framework
207
- corpus**. Shipping a check that fires that often would make every scan
208
- result mostly CH011 noise, undermining the "a finding must be defensible"
209
- standard the rest of this tool holds itself to. Built, measured, and
213
+ **The optuna and llama_index finds (CH011):** both are `@lru_cache(maxsize=
214
+ None)` decorating an instance method - a strong reference to `self`
215
+ retained forever. In optuna, `_FanovaTree`'s node-lookup methods leak
216
+ every tree built for a `get_param_importances()` call (one per
217
+ random-forest estimator). In llama_index, `VectaraIndex._get_corpus_key`
218
+ leaks the index itself - and since `VectaraIndex.__del__` exists
219
+ specifically to close the index's `requests.Session` on garbage
220
+ collection, the leak silently disables that cleanup too, so an HTTP
221
+ session leaks along with every index. Both fixed the same way: move the
222
+ cache from a class-level decorator to a per-instance one built in
223
+ `__init__`, so it's freed with the instance instead of outliving it. Both
224
+ have a regression test verified to fail pre-fix and pass post-fix.
225
+ PRs: [optuna/optuna#6859](https://github.com/optuna/optuna/pull/6859),
226
+ [run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089).
227
+
228
+ **CH016 found and fixed its own false positive the day it shipped.** The
229
+ first real-corpus scan of `unclosed-socket` turned up three hits in
230
+ vllm's distributed process-group rendezvous code - all three sockets were
231
+ actually handed off correctly (returned inside a tuple, collected into a
232
+ list that's itself returned, passed as an argument into a function that
233
+ takes ownership), just not in a shape CH005 (the check this one was
234
+ modeled on) ever needed to recognize, since files aren't handed off this
235
+ way nearly as often as rendezvous sockets are. Fixed by treating a name
236
+ as escaped when it's returned as part of a tuple/list or passed as an
237
+ argument to any call. A full corpus rescan afterward found zero remaining
238
+ CH016 hits.
239
+
240
+ **Two checks we built and did not ship.** `exception-chaining` (`except X
241
+ as e: raise Y(...)` with no `from e`, discarding the real traceback -
242
+ overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
243
+ that says more about how common the pattern is than about anything worth
244
+ flagging: **1,911 hits across the same ~20-framework corpus**. Shipping a
245
+ check that fires that often would make every scan result mostly noise,
246
+ undermining the "a finding must be defensible" standard the rest of this
247
+ tool holds itself to.
248
+
249
+ `cancelled-error-swallowed` (`except asyncio.CancelledError: pass` -
250
+ premise: silently swallowing task cancellation is a bug) went further
251
+ than volume alone: the **first two real hits checked**, in two different
252
+ frameworks, were both correct code, not bugs. agno's was `existing_task.
253
+ cancel(); try: await existing_task; except CancelledError: pass` - the
254
+ textbook-correct way to await a task's own cancellation. letta's was an
255
+ explicit, logged recovery path (`except (CancelledError, ...) as e: logger
256
+ .info(...); <continue processing>`) with a comment literally saying it was
257
+ overriding the cancellation on purpose. Unlike the exception-chaining
258
+ volume problem, this one meant the check's core premise was false in a
259
+ large fraction of real occurrences - so it was deleted outright rather
260
+ than kept at a lower confidence tier. Both are: built, measured, and
210
261
  deliberately left out - a real decision, not an oversight.
211
262
 
212
263
  ---
@@ -231,12 +282,22 @@ codehound/
231
282
  ├── unawaited_coroutine.py (CH007)
232
283
  ├── asyncio_run_in_loop.py (CH008)
233
284
  ├── floating_thread.py (CH009)
234
- └── loop_closure_capture.py (CH010)
285
+ ├── loop_closure_capture.py (CH010)
286
+ ├── lru_cache_on_method.py (CH011)
287
+ ├── floating_process.py (CH012)
288
+ ├── discarded_future.py (CH013)
289
+ ├── unprotected_lock.py (CH014)
290
+ ├── async_property.py (CH015)
291
+ ├── unclosed_socket.py (CH016)
292
+ ├── collections_abc_import.py (CH017)
293
+ ├── removed_asyncio_task_methods.py (CH018)
294
+ ├── removed_getargspec.py (CH019)
295
+ └── bare_except.py (CH020)
235
296
  ```
236
297
 
237
298
  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.
238
299
 
239
- **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. All four of those guards exist because of real false positives caught while building the checks (see above). The test suite asserts both "bad code is flagged" and "correct code is not."
300
+ **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. 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."
240
301
 
241
302
  ---
242
303
 
@@ -263,6 +324,9 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
263
324
  - [x] SARIF output — `--format sarif`
264
325
  - [x] Colored terminal output (auto-disabled for non-TTY / `NO_COLOR`)
265
326
  - [x] Multi-path `scan` invocation (what the pre-commit hook needs)
327
+ - [x] 20 checks — memory leaks (`lru_cache` on methods), floating processes,
328
+ discarded futures, unprotected locks, async properties, unclosed
329
+ sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
266
330
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
267
331
  - [ ] Sync HTTP clients constructed inside async request handlers
268
332
  - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
@@ -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 — seven of the ten rules are backed by a bug that was actually found and merged into a major open-source AI framework; the other three are hardening rules verified against real false positives instead.**
7
+ **An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty checks, seven backed by a bug that was actually found and merged into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~20-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/)
@@ -14,7 +14,7 @@
14
14
 
15
15
  Most linters flag style. `codehound` flags the *subtle correctness and async-safety bugs* that slip past code review and only bite in production — event-loop stalls, shared mutable state, leaked file descriptors, fire-and-forget tasks that get garbage-collected mid-run.
16
16
 
17
- Each of the six checks below isn't theoretical. **I wrote it after finding — and fixing, via a merged pull request — that exact bug in a real, popular framework** (agno 25k⭐, crewAI 30k⭐, mem0, huggingface_hub).
17
+ Most of the checks below aren't theoretical. **I wrote them after finding — and fixing, via a merged pull request — that exact bug in a real, popular framework** (agno 25k⭐, crewAI 30k⭐, mem0, llama_index, accelerate).
18
18
 
19
19
  ---
20
20
 
@@ -51,7 +51,7 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
51
51
  pip install codehound
52
52
  ```
53
53
 
54
- Zero dependencies — it's ~750 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
54
+ Zero dependencies — it's ~2,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.
55
55
 
56
56
  <details>
57
57
  <summary>From a clone instead (for development)</summary>
@@ -97,7 +97,7 @@ codehound list
97
97
  ### GitHub Action
98
98
 
99
99
  ```yaml
100
- - uses: kratos0718/codehound@v1.2.0
100
+ - uses: kratos0718/codehound@v1
101
101
  with:
102
102
  path: src
103
103
  # select: CH001,CH006 # optional, defaults to all checks
@@ -112,7 +112,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
112
112
  ```yaml
113
113
  repos:
114
114
  - repo: https://github.com/kratos0718/codehound
115
- rev: v1.2.0
115
+ rev: v1.4.1
116
116
  hooks:
117
117
  - id: codehound
118
118
  ```
@@ -133,15 +133,26 @@ repos:
133
133
  | **CH008** | `asyncio-run-in-running-loop` | `asyncio.run(...)` called from inside an `async def` — always raises `RuntimeError`, immediately, every time. | hardening rule — zero corpus hits (see below) |
134
134
  | **CH009** | `floating-thread` | A non-daemon `threading.Thread` that's `.start()`ed but never `.join()`ed — the thread analog of CH006. | hardening rule — see below |
135
135
  | **CH010** | `loop-closure-capture` | A `lambda` inside a `for` loop (or comprehension) that's *stored* (appended, assigned, returned) and captures the loop variable by reference — every stored instance ends up sharing the loop's **final** value. | **accelerate** (HuggingFace) — `MegatronEngine.get_module_config`'s `param_sync_func` list, PR #4273 |
136
+ | **CH011** | `lru-cache-on-method` | `@lru_cache`/`@cache` decorating an instance method — the cache holds a strong reference to `self` forever, so every instance that ever calls the method leaks for the process lifetime. | **optuna** — `_FanovaTree`'s node-lookup methods leaked every tree built for a `get_param_importances()` call; **llama_index** — `VectaraIndex._get_corpus_key` leaked the index *and* broke its own `__del__`-based HTTP session cleanup |
137
+ | **CH012** | `floating-process` | A non-daemon `multiprocessing.Process` that's `.start()`ed but never `.join()`ed — the process analog of CH009. | hardening rule |
138
+ | **CH013** | `discarded-future` | `ThreadPoolExecutor`/`ProcessPoolExecutor.submit(...)` called as a bare statement — the returned `Future` (and any exception raised inside the submitted work) is silently discarded. | hardening rule — real hits in litellm, accelerate, langchain |
139
+ | **CH014** | `unprotected-lock-acquire` | `lock.acquire()` outside a `with`, whose matching `.release()` isn't inside a `finally:` — an exception between acquire and release deadlocks every future caller of that lock. | hardening rule — real hits in vllm, accelerate, torchtune |
140
+ | **CH015** | `async-property` | `@property`/`@cached_property` wrapping an `async def` — accessing the attribute hands back an un-awaited coroutine object, not the value. | hardening rule |
141
+ | **CH016** | `unclosed-socket` | `socket.socket(...)` stored without a context manager or matching `.close()` — the socket analog of CH005; leaks the file descriptor. | hardening rule |
142
+ | **CH017** | `collections-abc-import` | `from collections import Mapping` (or `Sequence`, `Iterable`, …) — the ABCs were removed from `collections` itself in Python 3.10; they live in `collections.abc`. | hardening rule |
143
+ | **CH018** | `removed-asyncio-task-methods` | `asyncio.Task.current_task()` / `.all_tasks()` — both removed in Python 3.9; use `asyncio.current_task()` / `asyncio.all_tasks()`. | hardening rule |
144
+ | **CH019** | `removed-getargspec` | `inspect.getargspec(...)` — removed in Python 3.11 after a decade-plus deprecation; use `inspect.signature(...)`. | hardening rule |
145
+ | **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 |
136
146
 
137
147
  `codehound list` prints this from the source of truth.
138
148
 
139
- CH007-CH010 don't have found-and-merged bugs behind all of them the way
140
- CH001-CH006 do - three are hardening rules for well-known Python
149
+ CH007-CH020 don't have found-and-merged bugs behind all of them the way
150
+ CH001-CH006 do - most are hardening rules for well-known Python
141
151
  correctness gotchas rather than something this project personally
142
- tracked down first. CH010 is the exception: it found a genuine, serious
143
- bug on its own, in HuggingFace's `accelerate` - see below. Building all
144
- four surfaced real false positives, each one fixed before shipping:
152
+ tracked down first. CH010 and CH011 are the exceptions: both found
153
+ genuine bugs on their own, in HuggingFace's `accelerate`, optuna, and
154
+ llama_index - see below. Building CH007-CH010 surfaced real false
155
+ positives, each one fixed before shipping:
145
156
 
146
157
  - **CH007** (agno): a bare `self.foo()` call matched against an unrelated
147
158
  same-named `async def foo` on a *different* class (agno's own
@@ -179,14 +190,54 @@ it's very unlikely to survive basic testing; CH007 and CH009 both only
179
190
  match same-file names by design, and most real cases of either are
180
191
  plausibly cross-module.
181
192
 
182
- **One check we built and did not ship: CH011 `exception-chaining`**
183
- (`except X as e: raise Y(...)` with no `from e`, discarding the real
184
- traceback - overlaps flake8-bugbear B904). It worked exactly as designed,
185
- but at a scale that says more about how common the pattern is than about
186
- anything worth flagging: **1,911 hits across the same ~20-framework
187
- corpus**. Shipping a check that fires that often would make every scan
188
- result mostly CH011 noise, undermining the "a finding must be defensible"
189
- standard the rest of this tool holds itself to. Built, measured, and
193
+ **The optuna and llama_index finds (CH011):** both are `@lru_cache(maxsize=
194
+ None)` decorating an instance method - a strong reference to `self`
195
+ retained forever. In optuna, `_FanovaTree`'s node-lookup methods leak
196
+ every tree built for a `get_param_importances()` call (one per
197
+ random-forest estimator). In llama_index, `VectaraIndex._get_corpus_key`
198
+ leaks the index itself - and since `VectaraIndex.__del__` exists
199
+ specifically to close the index's `requests.Session` on garbage
200
+ collection, the leak silently disables that cleanup too, so an HTTP
201
+ session leaks along with every index. Both fixed the same way: move the
202
+ cache from a class-level decorator to a per-instance one built in
203
+ `__init__`, so it's freed with the instance instead of outliving it. Both
204
+ have a regression test verified to fail pre-fix and pass post-fix.
205
+ PRs: [optuna/optuna#6859](https://github.com/optuna/optuna/pull/6859),
206
+ [run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089).
207
+
208
+ **CH016 found and fixed its own false positive the day it shipped.** The
209
+ first real-corpus scan of `unclosed-socket` turned up three hits in
210
+ vllm's distributed process-group rendezvous code - all three sockets were
211
+ actually handed off correctly (returned inside a tuple, collected into a
212
+ list that's itself returned, passed as an argument into a function that
213
+ takes ownership), just not in a shape CH005 (the check this one was
214
+ modeled on) ever needed to recognize, since files aren't handed off this
215
+ way nearly as often as rendezvous sockets are. Fixed by treating a name
216
+ as escaped when it's returned as part of a tuple/list or passed as an
217
+ argument to any call. A full corpus rescan afterward found zero remaining
218
+ CH016 hits.
219
+
220
+ **Two checks we built and did not ship.** `exception-chaining` (`except X
221
+ as e: raise Y(...)` with no `from e`, discarding the real traceback -
222
+ overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
223
+ that says more about how common the pattern is than about anything worth
224
+ flagging: **1,911 hits across the same ~20-framework corpus**. Shipping a
225
+ check that fires that often would make every scan result mostly noise,
226
+ undermining the "a finding must be defensible" standard the rest of this
227
+ tool holds itself to.
228
+
229
+ `cancelled-error-swallowed` (`except asyncio.CancelledError: pass` -
230
+ premise: silently swallowing task cancellation is a bug) went further
231
+ than volume alone: the **first two real hits checked**, in two different
232
+ frameworks, were both correct code, not bugs. agno's was `existing_task.
233
+ cancel(); try: await existing_task; except CancelledError: pass` - the
234
+ textbook-correct way to await a task's own cancellation. letta's was an
235
+ explicit, logged recovery path (`except (CancelledError, ...) as e: logger
236
+ .info(...); <continue processing>`) with a comment literally saying it was
237
+ overriding the cancellation on purpose. Unlike the exception-chaining
238
+ volume problem, this one meant the check's core premise was false in a
239
+ large fraction of real occurrences - so it was deleted outright rather
240
+ than kept at a lower confidence tier. Both are: built, measured, and
190
241
  deliberately left out - a real decision, not an oversight.
191
242
 
192
243
  ---
@@ -211,12 +262,22 @@ codehound/
211
262
  ├── unawaited_coroutine.py (CH007)
212
263
  ├── asyncio_run_in_loop.py (CH008)
213
264
  ├── floating_thread.py (CH009)
214
- └── loop_closure_capture.py (CH010)
265
+ ├── loop_closure_capture.py (CH010)
266
+ ├── lru_cache_on_method.py (CH011)
267
+ ├── floating_process.py (CH012)
268
+ ├── discarded_future.py (CH013)
269
+ ├── unprotected_lock.py (CH014)
270
+ ├── async_property.py (CH015)
271
+ ├── unclosed_socket.py (CH016)
272
+ ├── collections_abc_import.py (CH017)
273
+ ├── removed_asyncio_task_methods.py (CH018)
274
+ ├── removed_getargspec.py (CH019)
275
+ └── bare_except.py (CH020)
215
276
  ```
216
277
 
217
278
  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.
218
279
 
219
- **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. All four of those guards exist because of real false positives caught while building the checks (see above). The test suite asserts both "bad code is flagged" and "correct code is not."
280
+ **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. 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."
220
281
 
221
282
  ---
222
283
 
@@ -243,6 +304,9 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
243
304
  - [x] SARIF output — `--format sarif`
244
305
  - [x] Colored terminal output (auto-disabled for non-TTY / `NO_COLOR`)
245
306
  - [x] Multi-path `scan` invocation (what the pre-commit hook needs)
307
+ - [x] 20 checks — memory leaks (`lru_cache` on methods), floating processes,
308
+ discarded futures, unprotected locks, async properties, unclosed
309
+ sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
246
310
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
247
311
  - [ ] Sync HTTP clients constructed inside async request handlers
248
312
  - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
@@ -1,9 +1,10 @@
1
1
  """codehound - an AST-based static analyzer that hunts real bugs in Python code.
2
2
 
3
- Ten checks. Seven are each backed by a bug that was actually found and
3
+ Twenty checks. Seven are each backed by a bug that was actually found and
4
4
  fixed in a popular open-source AI framework (agno, crewAI, mem0,
5
- llama_index, accelerate). The other three (CH007-CH009) are hardening
6
- rules verified against real false positives instead - see docs/FINDINGS.md.
5
+ llama_index, accelerate). The rest (CH007-CH009, CH011-CH020) are
6
+ hardening rules verified against real false positives across a
7
+ ~20-framework validation corpus instead - see docs/FINDINGS.md.
7
8
  """
8
9
 
9
10
  from __future__ import annotations
@@ -11,7 +12,7 @@ from __future__ import annotations
11
12
  from codehound.checks import ALL_CHECKS, get_checks
12
13
  from codehound.core import Check, Finding, scan_file, scan_path
13
14
 
14
- __version__ = "1.3.0"
15
+ __version__ = "1.4.1"
15
16
 
16
17
  __all__ = [
17
18
  "ALL_CHECKS",
@@ -2,16 +2,26 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from codehound.checks.async_property import AsyncProperty
5
6
  from codehound.checks.asyncio_run_in_loop import AsyncioRunInRunningLoop
7
+ from codehound.checks.bare_except import BareExcept
6
8
  from codehound.checks.blocking_async import BlockingCallInAsync
9
+ from codehound.checks.collections_abc_import import CollectionsAbcImport
7
10
  from codehound.checks.datetime_utcnow import DeprecatedDatetimeUtcnow
11
+ from codehound.checks.discarded_future import DiscardedFuture
12
+ from codehound.checks.floating_process import FloatingProcess
8
13
  from codehound.checks.floating_task import FloatingTask
9
14
  from codehound.checks.floating_thread import FloatingThread
10
15
  from codehound.checks.get_event_loop import DeprecatedGetEventLoop
11
16
  from codehound.checks.loop_closure_capture import LoopClosureCapture
17
+ from codehound.checks.lru_cache_on_method import LruCacheOnMethod
12
18
  from codehound.checks.mutable_defaults import MutableDefaultArgument
19
+ from codehound.checks.removed_asyncio_task_methods import RemovedAsyncioTaskMethods
20
+ from codehound.checks.removed_getargspec import RemovedGetargspec
13
21
  from codehound.checks.resource_leak import UnclosedFileHandle
14
22
  from codehound.checks.unawaited_coroutine import UnawaitedCoroutineCall
23
+ from codehound.checks.unclosed_socket import UnclosedSocket
24
+ from codehound.checks.unprotected_lock import UnprotectedLockAcquire
15
25
  from codehound.core import Check
16
26
 
17
27
  ALL_CHECKS: list[type[Check]] = [
@@ -25,6 +35,16 @@ ALL_CHECKS: list[type[Check]] = [
25
35
  AsyncioRunInRunningLoop,
26
36
  FloatingThread,
27
37
  LoopClosureCapture,
38
+ LruCacheOnMethod,
39
+ FloatingProcess,
40
+ DiscardedFuture,
41
+ UnprotectedLockAcquire,
42
+ AsyncProperty,
43
+ UnclosedSocket,
44
+ CollectionsAbcImport,
45
+ RemovedAsyncioTaskMethods,
46
+ RemovedGetargspec,
47
+ BareExcept,
28
48
  ]
29
49
 
30
50
 
@@ -0,0 +1,54 @@
1
+ """CH015 - ``@property`` on an ``async def`` method returns an unawaited coroutine.
2
+
3
+ Accessing a property never uses ``await`` - ``obj.thing`` is a plain
4
+ attribute access, so Python has no way to know it should await anything
5
+ even if the getter is a coroutine function. ``@property async def thing`` is
6
+ syntactically legal, but every access silently hands back a coroutine
7
+ object instead of the value, which is truthy, doesn't equal what you
8
+ compare it to, and (if never awaited) is a hidden
9
+ ``RuntimeWarning: coroutine 'thing' was never awaited`` waiting to happen
10
+ the first time someone actually uses the ``@property`` the way properties
11
+ are meant to be used.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ast
17
+
18
+ from codehound.core import Check, Finding
19
+
20
+
21
+ def _is_property_decorator(dec: ast.expr) -> bool:
22
+ if isinstance(dec, ast.Name):
23
+ return dec.id == "property"
24
+ if isinstance(dec, ast.Attribute):
25
+ return dec.attr in ("property", "cached_property")
26
+ return False
27
+
28
+
29
+ class AsyncProperty(Check):
30
+ code = "CH015"
31
+ name = "async-property"
32
+ description = "@property wrapping an async def returns an unawaited coroutine, not the value."
33
+
34
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
35
+ findings: list[Finding] = []
36
+ for node in ast.walk(tree):
37
+ if not isinstance(node, ast.AsyncFunctionDef):
38
+ continue
39
+ if not any(_is_property_decorator(d) for d in node.decorator_list):
40
+ continue
41
+ findings.append(
42
+ Finding(
43
+ path=path,
44
+ line=node.lineno,
45
+ col=node.col_offset,
46
+ code=self.code,
47
+ message=(
48
+ f"`{node.name}` is an `async def` wrapped in `@property` - accessing it "
49
+ f"never awaits anything, so every access returns an unawaited coroutine "
50
+ f"object, not the value."
51
+ ),
52
+ )
53
+ )
54
+ return findings
@@ -0,0 +1,88 @@
1
+ """CH020 - Bare ``except:`` (or unused ``except BaseException:``) catches everything.
2
+
3
+ A bare `except:` (or the explicit `except BaseException:`) doesn't just
4
+ catch the exception you meant to handle - it catches `KeyboardInterrupt`
5
+ and `SystemExit` too, meaning Ctrl-C stops working and `sys.exit()` gets
6
+ silently absorbed instead of actually exiting.
7
+
8
+ Not flagged when the handler either **uses** the bound exception (a real,
9
+ deliberate pattern found while building this: agno's background-thread
10
+ runner, `except BaseException as e: thread_error.append(e)`, surfacing
11
+ the exception to the consumer via a queue afterward - legitimate because
12
+ it's a worker thread reporting failures back, not discarding them) or
13
+ **re-raises anything** (another real pattern found the same way: agno's
14
+ `except BaseException: <reset internal state>; raise` - properly
15
+ propagates the original error to the caller after cleanup, so nothing is
16
+ actually swallowed). A bare `except:` with neither has no way to inspect
17
+ or propagate the exception at all, so it's flagged unconditionally.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import ast
23
+
24
+ from codehound.core import Check, Finding
25
+
26
+
27
+ def _name_is_referenced(scope: ast.AST, name: str) -> bool:
28
+ for node in ast.walk(scope):
29
+ if isinstance(node, ast.Name) and node.id == name and isinstance(node.ctx, ast.Load):
30
+ return True
31
+ return False
32
+
33
+
34
+ def _has_raise_in_own_scope(handler: ast.ExceptHandler) -> bool:
35
+ """Any `raise` in the handler's own reachable body - not counting a
36
+ nested try/except's own handler, which is a different scope."""
37
+ found = False
38
+
39
+ def walk(node: ast.AST) -> None:
40
+ nonlocal found
41
+ if found:
42
+ return
43
+ for child in ast.iter_child_nodes(node):
44
+ if isinstance(child, ast.Raise):
45
+ found = True
46
+ return
47
+ if isinstance(child, ast.ExceptHandler):
48
+ continue
49
+ walk(child)
50
+
51
+ walk(handler)
52
+ return found
53
+
54
+
55
+ class BareExcept(Check):
56
+ code = "CH020"
57
+ name = "bare-except"
58
+ description = "Bare `except:` (or unused `except BaseException:`) also catches KeyboardInterrupt/SystemExit."
59
+
60
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
61
+ findings: list[Finding] = []
62
+ for node in ast.walk(tree):
63
+ if not isinstance(node, ast.ExceptHandler):
64
+ continue
65
+ is_bare = node.type is None
66
+ is_base_exception = isinstance(node.type, ast.Name) and node.type.id == "BaseException"
67
+ if not (is_bare or is_base_exception):
68
+ continue
69
+ if _has_raise_in_own_scope(node):
70
+ continue
71
+ if is_base_exception and node.name is not None:
72
+ if any(_name_is_referenced(stmt, node.name) for stmt in node.body):
73
+ continue
74
+ findings.append(
75
+ Finding(
76
+ path=path,
77
+ line=node.lineno,
78
+ col=node.col_offset,
79
+ code=self.code,
80
+ message=(
81
+ "bare `except:`" if is_bare else "`except BaseException:`"
82
+ )
83
+ + " also catches KeyboardInterrupt/SystemExit, and the exception isn't "
84
+ "used anywhere in the handler; catch the specific exception(s) this "
85
+ "handler is actually meant for.",
86
+ )
87
+ )
88
+ return findings