codehound 1.4.1__tar.gz → 1.5.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 (36) hide show
  1. {codehound-1.4.1 → codehound-1.5.0}/PKG-INFO +59 -21
  2. {codehound-1.4.1 → codehound-1.5.0}/README.md +58 -20
  3. codehound-1.5.0/src/codehound/__init__.py +26 -0
  4. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/__init__.py +4 -0
  5. codehound-1.5.0/src/codehound/checks/asyncio_coroutine_decorator.py +70 -0
  6. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/lru_cache_on_method.py +71 -0
  7. codehound-1.5.0/src/codehound/checks/removed_stdlib_module.py +142 -0
  8. {codehound-1.4.1 → codehound-1.5.0}/tests/test_checks.py +194 -0
  9. codehound-1.4.1/src/codehound/__init__.py +0 -25
  10. {codehound-1.4.1 → codehound-1.5.0}/.gitignore +0 -0
  11. {codehound-1.4.1 → codehound-1.5.0}/LICENSE +0 -0
  12. {codehound-1.4.1 → codehound-1.5.0}/pyproject.toml +0 -0
  13. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/async_property.py +0 -0
  14. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
  15. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/bare_except.py +0 -0
  16. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/blocking_async.py +0 -0
  17. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/collections_abc_import.py +0 -0
  18. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/datetime_utcnow.py +0 -0
  19. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/discarded_future.py +0 -0
  20. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/floating_process.py +0 -0
  21. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/floating_task.py +0 -0
  22. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/floating_thread.py +0 -0
  23. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/get_event_loop.py +0 -0
  24. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/loop_closure_capture.py +0 -0
  25. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/mutable_defaults.py +0 -0
  26. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
  27. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/removed_getargspec.py +0 -0
  28. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/resource_leak.py +0 -0
  29. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
  30. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/unclosed_socket.py +0 -0
  31. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/checks/unprotected_lock.py +0 -0
  32. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/cli.py +0 -0
  33. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/core.py +0 -0
  34. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/sarif.py +0 -0
  35. {codehound-1.4.1 → codehound-1.5.0}/src/codehound/terminal.py +0 -0
  36. {codehound-1.4.1 → codehound-1.5.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.4.1
3
+ Version: 1.5.0
4
4
  Summary: An AST-based static analyzer that hunts real correctness and async-safety bugs in Python code.
5
5
  Project-URL: Homepage, https://github.com/kratos0718/codehound
6
6
  Project-URL: Issues, https://github.com/kratos0718/codehound/issues
@@ -24,7 +24,7 @@ Description-Content-Type: text/markdown
24
24
 
25
25
  <h1 align="center">codehound</h1>
26
26
 
27
- **An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty 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.**
27
+ **An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty-two checks, eight backed by a bug that was actually found and merged (or opened as a PR) into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~29-framework validation corpus instead of just reasoned about.**
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/)
@@ -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 ~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.
74
+ Zero dependencies — it's ~2,600 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
75
75
 
76
76
  <details>
77
77
  <summary>From a clone instead (for development)</summary>
@@ -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.4.1
135
+ rev: v1.5.0
136
136
  hooks:
137
137
  - id: codehound
138
138
  ```
@@ -153,7 +153,7 @@ 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 |
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; **litellm** — `Router._cached_get_model_group_info` leaked every `Router` even after its own documented `discard()` cleanup |
157
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
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
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 |
@@ -163,10 +163,12 @@ repos:
163
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
164
  | **CH019** | `removed-getargspec` | `inspect.getargspec(...)` — removed in Python 3.11 after a decade-plus deprecation; use `inspect.signature(...)`. | hardening rule |
165
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 |
166
+ | **CH021** | `removed-stdlib-module` | `import distutils` (removed 3.12) or any of the 19 PEP 594 "dead battery" modules (`cgi`, `imghdr`, `telnetlib`, `nntplib`, …, removed 3.13) — `ImportError` the moment the module loads. | hardening rule — real hit in agno (already guarded, see below) |
167
+ | **CH022** | `removed-asyncio-coroutine-decorator` | `@asyncio.coroutine` — removed in Python 3.11 after a generator-based-coroutine bridge that predates `async def`; `AttributeError` the moment the decorator line runs. | hardening rule |
166
168
 
167
169
  `codehound list` prints this from the source of truth.
168
170
 
169
- CH007-CH020 don't have found-and-merged bugs behind all of them the way
171
+ CH007-CH022 don't have found-and-merged bugs behind all of them the way
170
172
  CH001-CH006 do - most are hardening rules for well-known Python
171
173
  correctness gotchas rather than something this project personally
172
174
  tracked down first. CH010 and CH011 are the exceptions: both found
@@ -210,20 +212,38 @@ it's very unlikely to survive basic testing; CH007 and CH009 both only
210
212
  match same-file names by design, and most real cases of either are
211
213
  plausibly cross-module.
212
214
 
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.
215
+ **The optuna, llama_index, and litellm finds (CH011):** all three are
216
+ `@lru_cache(maxsize=None)` (or a fixed `maxsize`) decorating an instance
217
+ method - a strong reference to `self` retained for the life of the
218
+ process. In optuna, `_FanovaTree`'s node-lookup methods leak every tree
219
+ built for a `get_param_importances()` call (one per random-forest
220
+ estimator). In llama_index, `VectaraIndex._get_corpus_key` leaks the
221
+ index itself - and since `VectaraIndex.__del__` exists specifically to
222
+ close the index's `requests.Session` on garbage collection, the leak
223
+ silently disables that cleanup too, so an HTTP session leaks along with
224
+ every index. In litellm, `Router._cached_get_model_group_info` leaks
225
+ every `Router` that's ever served a request through it - proved this
226
+ survives even a correctly-called `router.discard()` (Router's own
227
+ documented cleanup method), so it isn't a "you forgot to clean up" bug.
228
+ All three fixed the same way: move the cache from a class-level decorator
229
+ to a per-instance one built in `__init__`, so it's freed with the
230
+ instance instead of outliving it - the exact pattern litellm's own
231
+ `cached_deployment_model_info` sibling method already used, just not yet
232
+ applied to this one. Each has a regression test verified to fail pre-fix
233
+ and pass post-fix.
225
234
  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).
235
+ [run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089),
236
+ [BerriAI/litellm#41582](https://github.com/BerriAI/litellm/pull/41582).
237
+
238
+ **A third CH011 shape needed a guard instead of a PR:** dspy's `Image` (a
239
+ pydantic model) caches `format()` the same way, but `Image` is frozen
240
+ (`model_config = ConfigDict(frozen=True)`), which makes it hashable and
241
+ equal *by field value*, not identity. Checked directly rather than
242
+ assumed: two separately constructed instances with equal fields hash
243
+ equal, and the second one's call is served from the first's cache entry
244
+ without ever being inserted itself — bounded, value-based memoization,
245
+ not a leak. CH011 now recognizes a frozen `@dataclass` or a frozen
246
+ pydantic model and skips it.
227
247
 
228
248
  **CH016 found and fixed its own false positive the day it shipped.** The
229
249
  first real-corpus scan of `unclosed-socket` turned up three hits in
@@ -237,6 +257,20 @@ as escaped when it's returned as part of a tuple/list or passed as an
237
257
  argument to any call. A full corpus rescan afterward found zero remaining
238
258
  CH016 hits.
239
259
 
260
+ **CH021 did the same thing twice, minutes apart.** The first real-corpus
261
+ scan found a false positive in vllm - `from .chunk import
262
+ chunk_gated_delta_rule`, a relative import of vllm's own local `chunk.py`
263
+ sibling module, not the removed stdlib `chunk`. `ast.ImportFrom.module`
264
+ is `"chunk"` either way; only `node.level` (the leading-dot count) tells
265
+ a relative import apart from an absolute one, and the check wasn't
266
+ checking it. Fixed, rescanned, and found a *second* false positive in
267
+ agno: `try: import imghdr except ImportError: import filetype` - a
268
+ real, deliberate fallback that already anticipates this exact removal,
269
+ not a bug waiting to happen. Added a second guard: skip an import inside
270
+ a `try:` body whose `except` catches `ImportError` (or anything
271
+ broader). A full corpus rescan after both fixes found zero remaining
272
+ CH021 hits.
273
+
240
274
  **Two checks we built and did not ship.** `exception-chaining` (`except X
241
275
  as e: raise Y(...)` with no `from e`, discarding the real traceback -
242
276
  overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
@@ -292,12 +326,14 @@ codehound/
292
326
  ├── collections_abc_import.py (CH017)
293
327
  ├── removed_asyncio_task_methods.py (CH018)
294
328
  ├── removed_getargspec.py (CH019)
295
- └── bare_except.py (CH020)
329
+ ├── bare_except.py (CH020)
330
+ ├── removed_stdlib_module.py (CH021)
331
+ └── asyncio_coroutine_decorator.py (CH022)
296
332
  ```
297
333
 
298
334
  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.
299
335
 
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."
336
+ **False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
301
337
 
302
338
  ---
303
339
 
@@ -327,6 +363,8 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
327
363
  - [x] 20 checks — memory leaks (`lru_cache` on methods), floating processes,
328
364
  discarded futures, unprotected locks, async properties, unclosed
329
365
  sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
366
+ - [x] 22 checks — removed stdlib modules (`distutils`, PEP 594 "dead
367
+ batteries"), removed `@asyncio.coroutine` decorator — CH021-CH022
330
368
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
331
369
  - [ ] Sync HTTP clients constructed inside async request handlers
332
370
  - [ ] `--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 — 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.**
7
+ **An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty-two checks, eight backed by a bug that was actually found and merged (or opened as a PR) into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~29-framework validation corpus instead of just reasoned about.**
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/)
@@ -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 ~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.
54
+ Zero dependencies — it's ~2,600 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
55
55
 
56
56
  <details>
57
57
  <summary>From a clone instead (for development)</summary>
@@ -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.4.1
115
+ rev: v1.5.0
116
116
  hooks:
117
117
  - id: codehound
118
118
  ```
@@ -133,7 +133,7 @@ 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 |
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; **litellm** — `Router._cached_get_model_group_info` leaked every `Router` even after its own documented `discard()` cleanup |
137
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
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
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 |
@@ -143,10 +143,12 @@ repos:
143
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
144
  | **CH019** | `removed-getargspec` | `inspect.getargspec(...)` — removed in Python 3.11 after a decade-plus deprecation; use `inspect.signature(...)`. | hardening rule |
145
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 |
146
+ | **CH021** | `removed-stdlib-module` | `import distutils` (removed 3.12) or any of the 19 PEP 594 "dead battery" modules (`cgi`, `imghdr`, `telnetlib`, `nntplib`, …, removed 3.13) — `ImportError` the moment the module loads. | hardening rule — real hit in agno (already guarded, see below) |
147
+ | **CH022** | `removed-asyncio-coroutine-decorator` | `@asyncio.coroutine` — removed in Python 3.11 after a generator-based-coroutine bridge that predates `async def`; `AttributeError` the moment the decorator line runs. | hardening rule |
146
148
 
147
149
  `codehound list` prints this from the source of truth.
148
150
 
149
- CH007-CH020 don't have found-and-merged bugs behind all of them the way
151
+ CH007-CH022 don't have found-and-merged bugs behind all of them the way
150
152
  CH001-CH006 do - most are hardening rules for well-known Python
151
153
  correctness gotchas rather than something this project personally
152
154
  tracked down first. CH010 and CH011 are the exceptions: both found
@@ -190,20 +192,38 @@ it's very unlikely to survive basic testing; CH007 and CH009 both only
190
192
  match same-file names by design, and most real cases of either are
191
193
  plausibly cross-module.
192
194
 
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.
195
+ **The optuna, llama_index, and litellm finds (CH011):** all three are
196
+ `@lru_cache(maxsize=None)` (or a fixed `maxsize`) decorating an instance
197
+ method - a strong reference to `self` retained for the life of the
198
+ process. In optuna, `_FanovaTree`'s node-lookup methods leak every tree
199
+ built for a `get_param_importances()` call (one per random-forest
200
+ estimator). In llama_index, `VectaraIndex._get_corpus_key` leaks the
201
+ index itself - and since `VectaraIndex.__del__` exists specifically to
202
+ close the index's `requests.Session` on garbage collection, the leak
203
+ silently disables that cleanup too, so an HTTP session leaks along with
204
+ every index. In litellm, `Router._cached_get_model_group_info` leaks
205
+ every `Router` that's ever served a request through it - proved this
206
+ survives even a correctly-called `router.discard()` (Router's own
207
+ documented cleanup method), so it isn't a "you forgot to clean up" bug.
208
+ All three fixed the same way: move the cache from a class-level decorator
209
+ to a per-instance one built in `__init__`, so it's freed with the
210
+ instance instead of outliving it - the exact pattern litellm's own
211
+ `cached_deployment_model_info` sibling method already used, just not yet
212
+ applied to this one. Each has a regression test verified to fail pre-fix
213
+ and pass post-fix.
205
214
  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).
215
+ [run-llama/llama_index#23089](https://github.com/run-llama/llama_index/pull/23089),
216
+ [BerriAI/litellm#41582](https://github.com/BerriAI/litellm/pull/41582).
217
+
218
+ **A third CH011 shape needed a guard instead of a PR:** dspy's `Image` (a
219
+ pydantic model) caches `format()` the same way, but `Image` is frozen
220
+ (`model_config = ConfigDict(frozen=True)`), which makes it hashable and
221
+ equal *by field value*, not identity. Checked directly rather than
222
+ assumed: two separately constructed instances with equal fields hash
223
+ equal, and the second one's call is served from the first's cache entry
224
+ without ever being inserted itself — bounded, value-based memoization,
225
+ not a leak. CH011 now recognizes a frozen `@dataclass` or a frozen
226
+ pydantic model and skips it.
207
227
 
208
228
  **CH016 found and fixed its own false positive the day it shipped.** The
209
229
  first real-corpus scan of `unclosed-socket` turned up three hits in
@@ -217,6 +237,20 @@ as escaped when it's returned as part of a tuple/list or passed as an
217
237
  argument to any call. A full corpus rescan afterward found zero remaining
218
238
  CH016 hits.
219
239
 
240
+ **CH021 did the same thing twice, minutes apart.** The first real-corpus
241
+ scan found a false positive in vllm - `from .chunk import
242
+ chunk_gated_delta_rule`, a relative import of vllm's own local `chunk.py`
243
+ sibling module, not the removed stdlib `chunk`. `ast.ImportFrom.module`
244
+ is `"chunk"` either way; only `node.level` (the leading-dot count) tells
245
+ a relative import apart from an absolute one, and the check wasn't
246
+ checking it. Fixed, rescanned, and found a *second* false positive in
247
+ agno: `try: import imghdr except ImportError: import filetype` - a
248
+ real, deliberate fallback that already anticipates this exact removal,
249
+ not a bug waiting to happen. Added a second guard: skip an import inside
250
+ a `try:` body whose `except` catches `ImportError` (or anything
251
+ broader). A full corpus rescan after both fixes found zero remaining
252
+ CH021 hits.
253
+
220
254
  **Two checks we built and did not ship.** `exception-chaining` (`except X
221
255
  as e: raise Y(...)` with no `from e`, discarding the real traceback -
222
256
  overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
@@ -272,12 +306,14 @@ codehound/
272
306
  ├── collections_abc_import.py (CH017)
273
307
  ├── removed_asyncio_task_methods.py (CH018)
274
308
  ├── removed_getargspec.py (CH019)
275
- └── bare_except.py (CH020)
309
+ ├── bare_except.py (CH020)
310
+ ├── removed_stdlib_module.py (CH021)
311
+ └── asyncio_coroutine_decorator.py (CH022)
276
312
  ```
277
313
 
278
314
  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.
279
315
 
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."
316
+ **False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
281
317
 
282
318
  ---
283
319
 
@@ -307,6 +343,8 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
307
343
  - [x] 20 checks — memory leaks (`lru_cache` on methods), floating processes,
308
344
  discarded futures, unprotected locks, async properties, unclosed
309
345
  sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
346
+ - [x] 22 checks — removed stdlib modules (`distutils`, PEP 594 "dead
347
+ batteries"), removed `@asyncio.coroutine` decorator — CH021-CH022
310
348
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
311
349
  - [ ] Sync HTTP clients constructed inside async request handlers
312
350
  - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
@@ -0,0 +1,26 @@
1
+ """codehound - an AST-based static analyzer that hunts real bugs in Python code.
2
+
3
+ Twenty-two checks. Eight are each backed by a bug that was actually found
4
+ and fixed (or opened as a PR) in a popular open-source AI framework
5
+ (agno, crewAI, mem0, llama_index, accelerate, optuna, litellm). The rest
6
+ (CH007-CH009, CH012-CH022) are hardening rules verified against real
7
+ false positives across a ~29-framework validation corpus instead - see
8
+ docs/FINDINGS.md.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from codehound.checks import ALL_CHECKS, get_checks
14
+ from codehound.core import Check, Finding, scan_file, scan_path
15
+
16
+ __version__ = "1.5.0"
17
+
18
+ __all__ = [
19
+ "ALL_CHECKS",
20
+ "get_checks",
21
+ "Check",
22
+ "Finding",
23
+ "scan_file",
24
+ "scan_path",
25
+ "__version__",
26
+ ]
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from codehound.checks.async_property import AsyncProperty
6
+ from codehound.checks.asyncio_coroutine_decorator import AsyncioCoroutineDecorator
6
7
  from codehound.checks.asyncio_run_in_loop import AsyncioRunInRunningLoop
7
8
  from codehound.checks.bare_except import BareExcept
8
9
  from codehound.checks.blocking_async import BlockingCallInAsync
@@ -18,6 +19,7 @@ from codehound.checks.lru_cache_on_method import LruCacheOnMethod
18
19
  from codehound.checks.mutable_defaults import MutableDefaultArgument
19
20
  from codehound.checks.removed_asyncio_task_methods import RemovedAsyncioTaskMethods
20
21
  from codehound.checks.removed_getargspec import RemovedGetargspec
22
+ from codehound.checks.removed_stdlib_module import RemovedStdlibModule
21
23
  from codehound.checks.resource_leak import UnclosedFileHandle
22
24
  from codehound.checks.unawaited_coroutine import UnawaitedCoroutineCall
23
25
  from codehound.checks.unclosed_socket import UnclosedSocket
@@ -45,6 +47,8 @@ ALL_CHECKS: list[type[Check]] = [
45
47
  RemovedAsyncioTaskMethods,
46
48
  RemovedGetargspec,
47
49
  BareExcept,
50
+ RemovedStdlibModule,
51
+ AsyncioCoroutineDecorator,
48
52
  ]
49
53
 
50
54
 
@@ -0,0 +1,70 @@
1
+ """CH022 - ``@asyncio.coroutine`` was removed in Python 3.11.
2
+
3
+ The generator-based coroutine decorator predates `async def` (added in
4
+ 3.5) and was kept around for years as a bridge for old code, deprecated
5
+ since 3.8 with an explicit `DeprecationWarning`, then removed outright in
6
+ 3.11 - `AttributeError: module 'asyncio' has no attribute 'coroutine'`
7
+ the moment the decorator line runs, not something that surfaces later.
8
+ `async def` is the direct replacement; there's no decorator to swap in.
9
+
10
+ A bare `@coroutine` is only trusted to mean `asyncio.coroutine` if the
11
+ file actually imported it via `from asyncio import coroutine` - the same
12
+ name-collision guard CH018 uses for `Task`, since `coroutine` alone is
13
+ common enough to plausibly be someone's own decorator.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import ast
19
+
20
+ from codehound.core import Check, Finding
21
+
22
+
23
+ def _imports_coroutine_from_asyncio(tree: ast.AST) -> bool:
24
+ for node in ast.walk(tree):
25
+ if isinstance(node, ast.ImportFrom) and node.module == "asyncio":
26
+ if any(alias.name == "coroutine" for alias in node.names):
27
+ return True
28
+ return False
29
+
30
+
31
+ def _is_asyncio_coroutine_attr(node: ast.expr) -> bool:
32
+ return (
33
+ isinstance(node, ast.Attribute)
34
+ and node.attr == "coroutine"
35
+ and isinstance(node.value, ast.Name)
36
+ and node.value.id == "asyncio"
37
+ )
38
+
39
+
40
+ class AsyncioCoroutineDecorator(Check):
41
+ code = "CH022"
42
+ name = "removed-asyncio-coroutine-decorator"
43
+ description = "@asyncio.coroutine was removed in Python 3.11; use `async def` instead."
44
+
45
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
46
+ findings: list[Finding] = []
47
+ trust_bare_name = _imports_coroutine_from_asyncio(tree)
48
+ for node in ast.walk(tree):
49
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
50
+ continue
51
+ for dec in node.decorator_list:
52
+ is_match = _is_asyncio_coroutine_attr(dec) or (
53
+ trust_bare_name and isinstance(dec, ast.Name) and dec.id == "coroutine"
54
+ )
55
+ if not is_match:
56
+ continue
57
+ findings.append(
58
+ Finding(
59
+ path=path,
60
+ line=dec.lineno,
61
+ col=dec.col_offset,
62
+ code=self.code,
63
+ message=(
64
+ f"`@{ast.unparse(dec)}` on `{node.name}` - removed in Python 3.11; "
65
+ f"define `{node.name}` with `async def` instead."
66
+ ),
67
+ )
68
+ )
69
+ break
70
+ return findings
@@ -12,6 +12,19 @@ instance alive), not a style nit.
12
12
  Only fires on a method - a plain module-level function decorated the same
13
13
  way is exactly what ``lru_cache`` is for, and caches correctly with no
14
14
  leak (nothing but the arguments themselves are held).
15
+
16
+ Also doesn't fire on a *frozen* value class (a stdlib ``@dataclass(frozen=
17
+ True)`` or a pydantic model with ``model_config = ConfigDict(frozen=True)``
18
+ / ``class Config: frozen = True``). A frozen class is hashable and equal by
19
+ its field values, not by identity - verified directly: two separately
20
+ constructed instances with the same field values hash equal, and a second,
21
+ distinct instance's call is served straight from the first instance's
22
+ cache entry without ever being inserted itself (dspy's `Image.format()`,
23
+ checked this way, turned out to be exactly this - real, bounded-by-value
24
+ memoization on an immutable type, not a leak). The cache still only ever
25
+ holds as many distinct instances as there are distinct field-value
26
+ combinations seen, capped at `maxsize`, which is the same bound an
27
+ equivalent free function cached by value would have.
15
28
  """
16
29
 
17
30
  from __future__ import annotations
@@ -32,6 +45,62 @@ def _is_cache_decorator(node: ast.expr) -> bool:
32
45
  return False
33
46
 
34
47
 
48
+ def _is_frozen_dataclass(decorators: list[ast.expr]) -> bool:
49
+ for d in decorators:
50
+ if not isinstance(d, ast.Call):
51
+ continue
52
+ target = d.func
53
+ name = target.id if isinstance(target, ast.Name) else getattr(target, "attr", None)
54
+ if name != "dataclass":
55
+ continue
56
+ for kw in d.keywords:
57
+ if kw.arg == "frozen" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
58
+ return True
59
+ return False
60
+
61
+
62
+ def _is_frozen_pydantic_model(cls: ast.ClassDef) -> bool:
63
+ for stmt in cls.body:
64
+ # model_config = ConfigDict(..., frozen=True, ...) or {"frozen": True}
65
+ if (
66
+ isinstance(stmt, ast.Assign)
67
+ and len(stmt.targets) == 1
68
+ and isinstance(stmt.targets[0], ast.Name)
69
+ and stmt.targets[0].id == "model_config"
70
+ ):
71
+ value = stmt.value
72
+ if isinstance(value, ast.Call):
73
+ for kw in value.keywords:
74
+ if kw.arg == "frozen" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
75
+ return True
76
+ elif isinstance(value, ast.Dict):
77
+ for k, v in zip(value.keys, value.values):
78
+ if (
79
+ isinstance(k, ast.Constant)
80
+ and k.value == "frozen"
81
+ and isinstance(v, ast.Constant)
82
+ and v.value is True
83
+ ):
84
+ return True
85
+ # Old-style pydantic v1: `class Config: frozen = True`
86
+ if isinstance(stmt, ast.ClassDef) and stmt.name == "Config":
87
+ for inner in stmt.body:
88
+ if (
89
+ isinstance(inner, ast.Assign)
90
+ and len(inner.targets) == 1
91
+ and isinstance(inner.targets[0], ast.Name)
92
+ and inner.targets[0].id == "frozen"
93
+ and isinstance(inner.value, ast.Constant)
94
+ and inner.value.value is True
95
+ ):
96
+ return True
97
+ return False
98
+
99
+
100
+ def _is_frozen_value_class(cls: ast.ClassDef) -> bool:
101
+ return _is_frozen_dataclass(cls.decorator_list) or _is_frozen_pydantic_model(cls)
102
+
103
+
35
104
  def _is_static_or_classmethod(decorators: list[ast.expr]) -> bool:
36
105
  for d in decorators:
37
106
  target = d.func if isinstance(d, ast.Call) else d
@@ -56,6 +125,8 @@ class LruCacheOnMethod(Check):
56
125
  continue
57
126
  if _is_static_or_classmethod(node.decorator_list):
58
127
  continue
128
+ if _is_frozen_value_class(parent):
129
+ continue
59
130
  if not node.args.args or node.args.args[0].arg not in ("self",):
60
131
  continue
61
132
  for dec in node.decorator_list:
@@ -0,0 +1,142 @@
1
+ """CH021 - importing a stdlib module that no longer exists.
2
+
3
+ ``distutils`` was deprecated in 3.10 and removed outright in 3.12 (PEP
4
+ 632) - still one of the most common "why won't this install on the new
5
+ Python" reports, since it was the default build backend for `setup.py`
6
+ for two decades. `asynchat`/`asyncore`/`imp` were removed the same
7
+ release. Python 3.13 then removed the 19 modules PEP 594 called the
8
+ standard library's "dead batteries" - old data-format, multimedia, and
9
+ platform-specific modules with long-unmaintained implementations and
10
+ better replacements on PyPI (`aifc`, `audioop`, `cgi`, `cgitb`, `chunk`,
11
+ `crypt`, `imghdr`, `mailcap`, `msilib`, `nis`, `nntplib`, `ossaudiodev`,
12
+ `pipes`, `sndhdr`, `spwd`, `sunau`, `telnetlib`, `uu`, `xdrlib`).
13
+
14
+ Same failure mode as CH017/CH019: `ImportError` the moment the module is
15
+ loaded on the Python version it was removed in, not a subtle runtime
16
+ bug - but real, and common in anything with an older dependency tree or
17
+ a `setup.py` that hasn't been touched since distutils was normal.
18
+
19
+ Not flagged when the import sits in a `try:` body whose `except` catches
20
+ `ImportError` (or anything broader) - a real, deliberate pattern found in
21
+ agno: `try: import imghdr except ImportError: import filetype` explicitly
22
+ anticipates and falls back from exactly this removal, so it isn't a bug
23
+ waiting to happen, it's already handled.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import ast
29
+
30
+ from codehound.core import Check, Finding
31
+
32
+ _IMPORT_ERROR_NAMES = {"ImportError", "ModuleNotFoundError", "Exception", "BaseException"}
33
+
34
+
35
+ def _handles_import_error(handler: ast.ExceptHandler) -> bool:
36
+ if handler.type is None:
37
+ return True
38
+ candidates = handler.type.elts if isinstance(handler.type, ast.Tuple) else [handler.type]
39
+ return any(isinstance(c, ast.Name) and c.id in _IMPORT_ERROR_NAMES for c in candidates)
40
+
41
+
42
+ def _is_guarded_by_import_error_handler(node: ast.AST, parents: dict) -> bool:
43
+ """True if `node` sits in the `try:` body of a Try with a matching
44
+ handler, at any enclosing level up to the function/class/module
45
+ boundary."""
46
+ cur = node
47
+ while cur is not None:
48
+ p = parents.get(id(cur))
49
+ if p is None:
50
+ return False
51
+ if isinstance(p, ast.Try) and cur in p.body and any(_handles_import_error(h) for h in p.handlers):
52
+ return True
53
+ if isinstance(p, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Module)):
54
+ return False
55
+ cur = p
56
+ return False
57
+
58
+ _REMOVED_IN: dict[str, str] = {
59
+ "distutils": "3.12",
60
+ "asynchat": "3.12",
61
+ "asyncore": "3.12",
62
+ "imp": "3.12",
63
+ "aifc": "3.13",
64
+ "audioop": "3.13",
65
+ "cgi": "3.13",
66
+ "cgitb": "3.13",
67
+ "chunk": "3.13",
68
+ "crypt": "3.13",
69
+ "imghdr": "3.13",
70
+ "mailcap": "3.13",
71
+ "msilib": "3.13",
72
+ "nis": "3.13",
73
+ "nntplib": "3.13",
74
+ "ossaudiodev": "3.13",
75
+ "pipes": "3.13",
76
+ "sndhdr": "3.13",
77
+ "spwd": "3.13",
78
+ "sunau": "3.13",
79
+ "telnetlib": "3.13",
80
+ "uu": "3.13",
81
+ "xdrlib": "3.13",
82
+ }
83
+
84
+
85
+ class RemovedStdlibModule(Check):
86
+ code = "CH021"
87
+ name = "removed-stdlib-module"
88
+ description = "Importing a stdlib module removed in a later Python version (distutils, PEP 594 modules, etc)."
89
+
90
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
91
+ findings: list[Finding] = []
92
+ for node in ast.walk(tree):
93
+ if isinstance(node, ast.Import):
94
+ if _is_guarded_by_import_error_handler(node, parents):
95
+ continue
96
+ for alias in node.names:
97
+ top_level = alias.name.split(".")[0]
98
+ version = _REMOVED_IN.get(top_level)
99
+ if version is not None:
100
+ findings.append(
101
+ Finding(
102
+ path=path,
103
+ line=node.lineno,
104
+ col=node.col_offset,
105
+ code=self.code,
106
+ message=(
107
+ f"`import {alias.name}` - the `{top_level}` module was removed "
108
+ f"in Python {version}; there is no drop-in stdlib replacement, "
109
+ f"see its migration notes."
110
+ ),
111
+ )
112
+ )
113
+ elif (
114
+ isinstance(node, ast.ImportFrom)
115
+ and node.module is not None
116
+ and node.level == 0
117
+ and not _is_guarded_by_import_error_handler(node, parents)
118
+ ):
119
+ # `node.level` is the number of leading dots (0 = absolute). A
120
+ # relative `from .chunk import x` has `node.module == "chunk"`
121
+ # too - indistinguishable from the real stdlib module by name
122
+ # alone. Real false positive found in vllm's own local
123
+ # `fla/ops/chunk.py` sibling module, imported via `from .chunk
124
+ # import chunk_gated_delta_rule`.
125
+ top_level = node.module.split(".")[0]
126
+ version = _REMOVED_IN.get(top_level)
127
+ if version is not None:
128
+ names = ", ".join(alias.name for alias in node.names)
129
+ findings.append(
130
+ Finding(
131
+ path=path,
132
+ line=node.lineno,
133
+ col=node.col_offset,
134
+ code=self.code,
135
+ message=(
136
+ f"`from {node.module} import {names}` - the `{top_level}` module "
137
+ f"was removed in Python {version}; there is no drop-in stdlib "
138
+ f"replacement, see its migration notes."
139
+ ),
140
+ )
141
+ )
142
+ return findings
@@ -517,6 +517,92 @@ def test_ch011_ignores_uncached_instance_method():
517
517
  assert _run(code, ["CH011"]) == []
518
518
 
519
519
 
520
+ def test_ch011_ignores_frozen_dataclass():
521
+ # A frozen dataclass is hashable and equal by field value, not identity -
522
+ # verified empirically: two separately constructed instances with the
523
+ # same field values hash equal, and lru_cache serves the second one
524
+ # straight from the first's cache entry without ever storing it. Bounded
525
+ # by maxsize the same way an equivalent free function cached by value
526
+ # would be - not a leak.
527
+ code = (
528
+ "from dataclasses import dataclass\n"
529
+ "from functools import lru_cache\n"
530
+ "@dataclass(frozen=True)\n"
531
+ "class Point:\n"
532
+ " x: int\n"
533
+ " @lru_cache\n"
534
+ " def scaled(self, factor):\n"
535
+ " return self.x * factor\n"
536
+ )
537
+ assert _run(code, ["CH011"]) == []
538
+
539
+
540
+ def test_ch011_ignores_frozen_pydantic_model():
541
+ # Real false positive found in dspy: Image (model_config =
542
+ # ConfigDict(frozen=True)) caches format() with @lru_cache(maxsize=32) -
543
+ # confirmed via a REPL check that this is value-based memoization, not a
544
+ # per-instance leak.
545
+ code = (
546
+ "import pydantic\n"
547
+ "from functools import lru_cache\n"
548
+ "class Image(pydantic.BaseModel):\n"
549
+ " url: str\n"
550
+ " model_config = pydantic.ConfigDict(frozen=True)\n"
551
+ " @lru_cache(maxsize=32)\n"
552
+ " def format(self):\n"
553
+ " return self.url.upper()\n"
554
+ )
555
+ assert _run(code, ["CH011"]) == []
556
+
557
+
558
+ def test_ch011_ignores_frozen_pydantic_model_v1_style_config():
559
+ code = (
560
+ "import pydantic\n"
561
+ "from functools import lru_cache\n"
562
+ "class Image(pydantic.BaseModel):\n"
563
+ " url: str\n"
564
+ " class Config:\n"
565
+ " frozen = True\n"
566
+ " @lru_cache\n"
567
+ " def format(self):\n"
568
+ " return self.url.upper()\n"
569
+ )
570
+ assert _run(code, ["CH011"]) == []
571
+
572
+
573
+ def test_ch011_still_flags_mutable_dataclass():
574
+ # Guard against the frozen-class check over-suppressing: a plain (not
575
+ # frozen) dataclass is not hashable by value, so this must still flag.
576
+ code = (
577
+ "from dataclasses import dataclass\n"
578
+ "from functools import lru_cache\n"
579
+ "@dataclass\n"
580
+ "class Point:\n"
581
+ " x: int\n"
582
+ " @lru_cache\n"
583
+ " def scaled(self, factor):\n"
584
+ " return self.x * factor\n"
585
+ )
586
+ findings = _run(code, ["CH011"])
587
+ assert len(findings) == 1
588
+ assert findings[0].code == "CH011"
589
+
590
+
591
+ def test_ch011_still_flags_pydantic_model_without_frozen_config():
592
+ code = (
593
+ "import pydantic\n"
594
+ "from functools import lru_cache\n"
595
+ "class Image(pydantic.BaseModel):\n"
596
+ " url: str\n"
597
+ " @lru_cache\n"
598
+ " def format(self):\n"
599
+ " return self.url.upper()\n"
600
+ )
601
+ findings = _run(code, ["CH011"])
602
+ assert len(findings) == 1
603
+ assert findings[0].code == "CH011"
604
+
605
+
520
606
  # --- CH012 floating-process ----------------------------------------------------------
521
607
 
522
608
 
@@ -957,3 +1043,111 @@ def test_ch020_flags_base_exception_with_raise_only_in_nested_handler():
957
1043
  )
958
1044
  assert len(_run(code, ["CH020"])) == 1
959
1045
 
1046
+
1047
+ # --- CH021 removed-stdlib-module ------------------------------------------------------
1048
+
1049
+
1050
+ def test_ch021_flags_distutils_import():
1051
+ findings = _run("import distutils\n", ["CH021"])
1052
+ assert len(findings) == 1
1053
+ assert findings[0].code == "CH021"
1054
+
1055
+
1056
+ def test_ch021_flags_distutils_submodule_import():
1057
+ findings = _run("import distutils.core\n", ["CH021"])
1058
+ assert len(findings) == 1
1059
+
1060
+
1061
+ def test_ch021_flags_distutils_from_import():
1062
+ code = "from distutils.core import setup\n"
1063
+ findings = _run(code, ["CH021"])
1064
+ assert len(findings) == 1
1065
+
1066
+
1067
+ def test_ch021_flags_pep594_dead_battery_modules():
1068
+ for mod in ["cgi", "imghdr", "telnetlib", "nntplib", "asynchat", "asyncore", "imp"]:
1069
+ findings = _run(f"import {mod}\n", ["CH021"])
1070
+ assert len(findings) == 1, f"expected {mod} to be flagged"
1071
+
1072
+
1073
+ def test_ch021_ignores_unrelated_modules():
1074
+ code = "import os\nimport sys\nfrom collections import OrderedDict\n"
1075
+ assert _run(code, ["CH021"]) == []
1076
+
1077
+
1078
+ def test_ch021_ignores_similarly_named_local_module():
1079
+ # A module named e.g. `imp` in the removed set shouldn't false-positive
1080
+ # on an unrelated attribute/name access - only actual import statements.
1081
+ code = "imp = 5\nprint(imp)\n"
1082
+ assert _run(code, ["CH021"]) == []
1083
+
1084
+
1085
+ def test_ch021_ignores_relative_import_of_same_named_local_module():
1086
+ # Real false positive found in vllm: `from .chunk import
1087
+ # chunk_gated_delta_rule` imports a local sibling module named
1088
+ # chunk.py, not the removed stdlib `chunk` module - `node.module` is
1089
+ # "chunk" either way, so only `node.level == 0` (absolute) tells them
1090
+ # apart.
1091
+ code = "from .chunk import chunk_gated_delta_rule\n"
1092
+ assert _run(code, ["CH021"]) == []
1093
+
1094
+
1095
+ def test_ch021_ignores_import_guarded_by_import_error_handler():
1096
+ # Real false positive found in agno: `try: import imghdr except
1097
+ # ImportError: import filetype` explicitly anticipates and falls back
1098
+ # from the removal - already handled, not a bug waiting to happen.
1099
+ code = "try:\n import imghdr\nexcept ImportError:\n import filetype\n"
1100
+ assert _run(code, ["CH021"]) == []
1101
+
1102
+
1103
+ def test_ch021_ignores_from_import_guarded_by_broad_except():
1104
+ code = "try:\n from distutils.core import setup\nexcept Exception:\n setup = None\n"
1105
+ assert _run(code, ["CH021"]) == []
1106
+
1107
+
1108
+ def test_ch021_still_flags_import_in_try_with_unrelated_handler():
1109
+ # A try/except that doesn't actually catch ImportError provides no
1110
+ # real protection, so this must still be flagged.
1111
+ code = "try:\n import distutils\nexcept ValueError:\n pass\n"
1112
+ findings = _run(code, ["CH021"])
1113
+ assert len(findings) == 1
1114
+
1115
+
1116
+ def test_ch021_still_flags_import_outside_any_try():
1117
+ code = "import distutils\ntry:\n risky()\nexcept ImportError:\n pass\n"
1118
+ findings = _run(code, ["CH021"])
1119
+ assert len(findings) == 1
1120
+
1121
+
1122
+ # --- CH022 removed-asyncio-coroutine-decorator -----------------------------------------
1123
+
1124
+
1125
+ def test_ch022_flags_asyncio_coroutine_decorator():
1126
+ code = "import asyncio\n@asyncio.coroutine\ndef f():\n yield from g()\n"
1127
+ findings = _run(code, ["CH022"])
1128
+ assert len(findings) == 1
1129
+ assert findings[0].code == "CH022"
1130
+
1131
+
1132
+ def test_ch022_flags_bare_coroutine_when_imported_from_asyncio():
1133
+ code = "from asyncio import coroutine\n@coroutine\ndef f():\n yield from g()\n"
1134
+ findings = _run(code, ["CH022"])
1135
+ assert len(findings) == 1
1136
+
1137
+
1138
+ def test_ch022_ignores_bare_coroutine_not_imported_from_asyncio():
1139
+ # Real name-collision guard, same shape as CH018's for `Task`: a
1140
+ # same-named decorator from somewhere else shouldn't be misidentified.
1141
+ code = "from mylib import coroutine\n@coroutine\ndef f():\n return 1\n"
1142
+ assert _run(code, ["CH022"]) == []
1143
+
1144
+
1145
+ def test_ch022_ignores_async_def_without_decorator():
1146
+ code = "async def f():\n return 1\n"
1147
+ assert _run(code, ["CH022"]) == []
1148
+
1149
+
1150
+ def test_ch022_ignores_unrelated_attribute_named_coroutine():
1151
+ code = "import asyncio\n@other.coroutine\ndef f():\n return 1\n"
1152
+ assert _run(code, ["CH022"]) == []
1153
+
@@ -1,25 +0,0 @@
1
- """codehound - an AST-based static analyzer that hunts real bugs in Python code.
2
-
3
- Twenty checks. Seven are each backed by a bug that was actually found and
4
- fixed in a popular open-source AI framework (agno, crewAI, mem0,
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.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- from codehound.checks import ALL_CHECKS, get_checks
13
- from codehound.core import Check, Finding, scan_file, scan_path
14
-
15
- __version__ = "1.4.1"
16
-
17
- __all__ = [
18
- "ALL_CHECKS",
19
- "get_checks",
20
- "Check",
21
- "Finding",
22
- "scan_file",
23
- "scan_path",
24
- "__version__",
25
- ]
File without changes
File without changes
File without changes