codehound 1.2.0__tar.gz → 1.4.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.
- {codehound-1.2.0 → codehound-1.4.0}/PKG-INFO +91 -20
- {codehound-1.2.0 → codehound-1.4.0}/README.md +90 -19
- {codehound-1.2.0 → codehound-1.4.0}/src/codehound/__init__.py +5 -4
- {codehound-1.2.0 → codehound-1.4.0}/src/codehound/checks/__init__.py +20 -0
- codehound-1.4.0/src/codehound/checks/async_property.py +54 -0
- codehound-1.4.0/src/codehound/checks/bare_except.py +88 -0
- codehound-1.4.0/src/codehound/checks/collections_abc_import.py +83 -0
- codehound-1.4.0/src/codehound/checks/discarded_future.py +93 -0
- codehound-1.4.0/src/codehound/checks/floating_process.py +130 -0
- codehound-1.4.0/src/codehound/checks/lru_cache_on_method.py +79 -0
- codehound-1.4.0/src/codehound/checks/removed_asyncio_task_methods.py +66 -0
- codehound-1.4.0/src/codehound/checks/removed_getargspec.py +58 -0
- codehound-1.4.0/src/codehound/checks/unclosed_socket.py +92 -0
- codehound-1.4.0/src/codehound/checks/unprotected_lock.py +105 -0
- {codehound-1.2.0 → codehound-1.4.0}/src/codehound/cli.py +20 -16
- codehound-1.4.0/src/codehound/sarif.py +67 -0
- codehound-1.4.0/src/codehound/terminal.py +57 -0
- {codehound-1.2.0 → codehound-1.4.0}/tests/test_checks.py +424 -0
- codehound-1.4.0/tests/test_output_formats.py +74 -0
- {codehound-1.2.0 → codehound-1.4.0}/.gitignore +0 -0
- {codehound-1.2.0 → codehound-1.4.0}/LICENSE +0 -0
- {codehound-1.2.0 → codehound-1.4.0}/pyproject.toml +0 -0
- {codehound-1.2.0 → codehound-1.4.0}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
- {codehound-1.2.0 → codehound-1.4.0}/src/codehound/checks/blocking_async.py +0 -0
- {codehound-1.2.0 → codehound-1.4.0}/src/codehound/checks/datetime_utcnow.py +0 -0
- {codehound-1.2.0 → codehound-1.4.0}/src/codehound/checks/floating_task.py +0 -0
- {codehound-1.2.0 → codehound-1.4.0}/src/codehound/checks/floating_thread.py +0 -0
- {codehound-1.2.0 → codehound-1.4.0}/src/codehound/checks/get_event_loop.py +0 -0
- {codehound-1.2.0 → codehound-1.4.0}/src/codehound/checks/loop_closure_capture.py +0 -0
- {codehound-1.2.0 → codehound-1.4.0}/src/codehound/checks/mutable_defaults.py +0 -0
- {codehound-1.2.0 → codehound-1.4.0}/src/codehound/checks/resource_leak.py +0 -0
- {codehound-1.2.0 → codehound-1.4.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
- {codehound-1.2.0 → codehound-1.4.0}/src/codehound/core.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: codehound
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.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 —
|
|
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
|
[](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
|
|
30
30
|
[](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
|
-
|
|
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 ~
|
|
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>
|
|
@@ -91,6 +91,9 @@ PYTHONPATH=src python -m codehound.cli scan path/to/project
|
|
|
91
91
|
# scan a project (skips tests/, docs/, examples/, vendored code by default)
|
|
92
92
|
codehound scan path/to/project
|
|
93
93
|
|
|
94
|
+
# scan multiple files/directories in one invocation (what pre-commit does)
|
|
95
|
+
codehound scan file1.py file2.py src/
|
|
96
|
+
|
|
94
97
|
# only run specific checks
|
|
95
98
|
codehound scan path/to/project --select CH001,CH006
|
|
96
99
|
|
|
@@ -98,6 +101,9 @@ codehound scan path/to/project --select CH001,CH006
|
|
|
98
101
|
codehound scan path/to/project --format json
|
|
99
102
|
codehound scan path/to/project --format csv
|
|
100
103
|
|
|
104
|
+
# GitHub Code Scanning (Security tab) can ingest this directly
|
|
105
|
+
codehound scan path/to/project --format sarif > results.sarif
|
|
106
|
+
|
|
101
107
|
# list every available check
|
|
102
108
|
codehound list
|
|
103
109
|
```
|
|
@@ -108,6 +114,29 @@ codehound list
|
|
|
108
114
|
- run: codehound scan src # fails the build on a regression
|
|
109
115
|
```
|
|
110
116
|
|
|
117
|
+
### GitHub Action
|
|
118
|
+
|
|
119
|
+
```yaml
|
|
120
|
+
- uses: kratos0718/codehound@v1
|
|
121
|
+
with:
|
|
122
|
+
path: src
|
|
123
|
+
# select: CH001,CH006 # optional, defaults to all checks
|
|
124
|
+
# fail-on-findings: "false" # optional, report without failing the build
|
|
125
|
+
# upload-sarif: "false" # optional, skip the Code Scanning upload
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in addition to failing the step (unless `fail-on-findings: "false"`).
|
|
129
|
+
|
|
130
|
+
### pre-commit
|
|
131
|
+
|
|
132
|
+
```yaml
|
|
133
|
+
repos:
|
|
134
|
+
- repo: https://github.com/kratos0718/codehound
|
|
135
|
+
rev: v1.4.0
|
|
136
|
+
hooks:
|
|
137
|
+
- id: codehound
|
|
138
|
+
```
|
|
139
|
+
|
|
111
140
|
---
|
|
112
141
|
|
|
113
142
|
## The checks
|
|
@@ -124,15 +153,25 @@ codehound list
|
|
|
124
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) |
|
|
125
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 |
|
|
126
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. | hardening rule — real hits across litellm, vllm, accelerate, marimo, dspy |
|
|
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 |
|
|
127
166
|
|
|
128
167
|
`codehound list` prints this from the source of truth.
|
|
129
168
|
|
|
130
|
-
CH007-
|
|
131
|
-
CH001-CH006 do -
|
|
169
|
+
CH007-CH020 don't have found-and-merged bugs behind all of them the way
|
|
170
|
+
CH001-CH006 and CH010 do - most are hardening rules for well-known Python
|
|
132
171
|
correctness gotchas rather than something this project personally
|
|
133
172
|
tracked down first. CH010 is the exception: it found a genuine, serious
|
|
134
|
-
bug on its own, in HuggingFace's `accelerate` - see below. Building
|
|
135
|
-
|
|
173
|
+
bug on its own, in HuggingFace's `accelerate` - see below. Building
|
|
174
|
+
CH007-CH010 surfaced real false positives, each one fixed before shipping:
|
|
136
175
|
|
|
137
176
|
- **CH007** (agno): a bare `self.foo()` call matched against an unrelated
|
|
138
177
|
same-named `async def foo` on a *different* class (agno's own
|
|
@@ -170,14 +209,27 @@ it's very unlikely to survive basic testing; CH007 and CH009 both only
|
|
|
170
209
|
match same-file names by design, and most real cases of either are
|
|
171
210
|
plausibly cross-module.
|
|
172
211
|
|
|
173
|
-
**
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
212
|
+
**Two checks we built and did not ship.** `exception-chaining` (`except X
|
|
213
|
+
as e: raise Y(...)` with no `from e`, discarding the real traceback -
|
|
214
|
+
overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
|
|
215
|
+
that says more about how common the pattern is than about anything worth
|
|
216
|
+
flagging: **1,911 hits across the same ~20-framework corpus**. Shipping a
|
|
217
|
+
check that fires that often would make every scan result mostly noise,
|
|
218
|
+
undermining the "a finding must be defensible" standard the rest of this
|
|
219
|
+
tool holds itself to.
|
|
220
|
+
|
|
221
|
+
`cancelled-error-swallowed` (`except asyncio.CancelledError: pass` -
|
|
222
|
+
premise: silently swallowing task cancellation is a bug) went further
|
|
223
|
+
than volume alone: the **first two real hits checked**, in two different
|
|
224
|
+
frameworks, were both correct code, not bugs. agno's was `existing_task.
|
|
225
|
+
cancel(); try: await existing_task; except CancelledError: pass` - the
|
|
226
|
+
textbook-correct way to await a task's own cancellation. letta's was an
|
|
227
|
+
explicit, logged recovery path (`except (CancelledError, ...) as e: logger
|
|
228
|
+
.info(...); <continue processing>`) with a comment literally saying it was
|
|
229
|
+
overriding the cancellation on purpose. Unlike the exception-chaining
|
|
230
|
+
volume problem, this one meant the check's core premise was false in a
|
|
231
|
+
large fraction of real occurrences - so it was deleted outright rather
|
|
232
|
+
than kept at a lower confidence tier. Both are: built, measured, and
|
|
181
233
|
deliberately left out - a real decision, not an oversight.
|
|
182
234
|
|
|
183
235
|
---
|
|
@@ -189,7 +241,9 @@ codehound/
|
|
|
189
241
|
├── core.py # file discovery, AST parsing, the Finding/Check contract,
|
|
190
242
|
│ # and a child→parent map so checks can ask "what's my
|
|
191
243
|
│ # enclosing function / am I inside a `with`?"
|
|
192
|
-
├── cli.py # `scan` / `list`, text|json|csv output, CI-friendly exit codes
|
|
244
|
+
├── cli.py # `scan` / `list`, text|json|csv|sarif output, CI-friendly exit codes
|
|
245
|
+
├── sarif.py # SARIF 2.1.0 output for GitHub Code Scanning
|
|
246
|
+
├── terminal.py # colored text output (auto-disabled for non-TTY / NO_COLOR)
|
|
193
247
|
└── checks/ # one small, independently-tested class per rule
|
|
194
248
|
├── blocking_async.py (CH001)
|
|
195
249
|
├── mutable_defaults.py (CH002)
|
|
@@ -200,12 +254,22 @@ codehound/
|
|
|
200
254
|
├── unawaited_coroutine.py (CH007)
|
|
201
255
|
├── asyncio_run_in_loop.py (CH008)
|
|
202
256
|
├── floating_thread.py (CH009)
|
|
203
|
-
|
|
257
|
+
├── loop_closure_capture.py (CH010)
|
|
258
|
+
├── lru_cache_on_method.py (CH011)
|
|
259
|
+
├── floating_process.py (CH012)
|
|
260
|
+
├── discarded_future.py (CH013)
|
|
261
|
+
├── unprotected_lock.py (CH014)
|
|
262
|
+
├── async_property.py (CH015)
|
|
263
|
+
├── unclosed_socket.py (CH016)
|
|
264
|
+
├── collections_abc_import.py (CH017)
|
|
265
|
+
├── removed_asyncio_task_methods.py (CH018)
|
|
266
|
+
├── removed_getargspec.py (CH019)
|
|
267
|
+
└── bare_except.py (CH020)
|
|
204
268
|
```
|
|
205
269
|
|
|
206
270
|
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.
|
|
207
271
|
|
|
208
|
-
**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
|
|
272
|
+
**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. 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."
|
|
209
273
|
|
|
210
274
|
---
|
|
211
275
|
|
|
@@ -227,10 +291,17 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
227
291
|
- [x] `asyncio.run()` inside a running loop — CH008
|
|
228
292
|
- [x] Non-daemon thread started without a join — CH009 (the thread analog of CH006)
|
|
229
293
|
- [x] Loop-variable closure capture in lambdas — CH010
|
|
294
|
+
- [x] Pre-commit hook — `.pre-commit-hooks.yaml`
|
|
295
|
+
- [x] GitHub Action — `action.yml`, uploads SARIF to Code Scanning
|
|
296
|
+
- [x] SARIF output — `--format sarif`
|
|
297
|
+
- [x] Colored terminal output (auto-disabled for non-TTY / `NO_COLOR`)
|
|
298
|
+
- [x] Multi-path `scan` invocation (what the pre-commit hook needs)
|
|
299
|
+
- [x] 20 checks — memory leaks (`lru_cache` on methods), floating processes,
|
|
300
|
+
discarded futures, unprotected locks, async properties, unclosed
|
|
301
|
+
sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
|
|
230
302
|
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
231
303
|
- [ ] Sync HTTP clients constructed inside async request handlers
|
|
232
304
|
- [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
|
|
233
|
-
- [ ] Pre-commit hook
|
|
234
305
|
|
|
235
306
|
---
|
|
236
307
|
|
|
@@ -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 —
|
|
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
|
[](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
|
|
10
10
|
[](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
|
-
|
|
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 ~
|
|
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>
|
|
@@ -71,6 +71,9 @@ PYTHONPATH=src python -m codehound.cli scan path/to/project
|
|
|
71
71
|
# scan a project (skips tests/, docs/, examples/, vendored code by default)
|
|
72
72
|
codehound scan path/to/project
|
|
73
73
|
|
|
74
|
+
# scan multiple files/directories in one invocation (what pre-commit does)
|
|
75
|
+
codehound scan file1.py file2.py src/
|
|
76
|
+
|
|
74
77
|
# only run specific checks
|
|
75
78
|
codehound scan path/to/project --select CH001,CH006
|
|
76
79
|
|
|
@@ -78,6 +81,9 @@ codehound scan path/to/project --select CH001,CH006
|
|
|
78
81
|
codehound scan path/to/project --format json
|
|
79
82
|
codehound scan path/to/project --format csv
|
|
80
83
|
|
|
84
|
+
# GitHub Code Scanning (Security tab) can ingest this directly
|
|
85
|
+
codehound scan path/to/project --format sarif > results.sarif
|
|
86
|
+
|
|
81
87
|
# list every available check
|
|
82
88
|
codehound list
|
|
83
89
|
```
|
|
@@ -88,6 +94,29 @@ codehound list
|
|
|
88
94
|
- run: codehound scan src # fails the build on a regression
|
|
89
95
|
```
|
|
90
96
|
|
|
97
|
+
### GitHub Action
|
|
98
|
+
|
|
99
|
+
```yaml
|
|
100
|
+
- uses: kratos0718/codehound@v1
|
|
101
|
+
with:
|
|
102
|
+
path: src
|
|
103
|
+
# select: CH001,CH006 # optional, defaults to all checks
|
|
104
|
+
# fail-on-findings: "false" # optional, report without failing the build
|
|
105
|
+
# upload-sarif: "false" # optional, skip the Code Scanning upload
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in addition to failing the step (unless `fail-on-findings: "false"`).
|
|
109
|
+
|
|
110
|
+
### pre-commit
|
|
111
|
+
|
|
112
|
+
```yaml
|
|
113
|
+
repos:
|
|
114
|
+
- repo: https://github.com/kratos0718/codehound
|
|
115
|
+
rev: v1.4.0
|
|
116
|
+
hooks:
|
|
117
|
+
- id: codehound
|
|
118
|
+
```
|
|
119
|
+
|
|
91
120
|
---
|
|
92
121
|
|
|
93
122
|
## The checks
|
|
@@ -104,15 +133,25 @@ codehound list
|
|
|
104
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) |
|
|
105
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 |
|
|
106
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. | hardening rule — real hits across litellm, vllm, accelerate, marimo, dspy |
|
|
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 |
|
|
107
146
|
|
|
108
147
|
`codehound list` prints this from the source of truth.
|
|
109
148
|
|
|
110
|
-
CH007-
|
|
111
|
-
CH001-CH006 do -
|
|
149
|
+
CH007-CH020 don't have found-and-merged bugs behind all of them the way
|
|
150
|
+
CH001-CH006 and CH010 do - most are hardening rules for well-known Python
|
|
112
151
|
correctness gotchas rather than something this project personally
|
|
113
152
|
tracked down first. CH010 is the exception: it found a genuine, serious
|
|
114
|
-
bug on its own, in HuggingFace's `accelerate` - see below. Building
|
|
115
|
-
|
|
153
|
+
bug on its own, in HuggingFace's `accelerate` - see below. Building
|
|
154
|
+
CH007-CH010 surfaced real false positives, each one fixed before shipping:
|
|
116
155
|
|
|
117
156
|
- **CH007** (agno): a bare `self.foo()` call matched against an unrelated
|
|
118
157
|
same-named `async def foo` on a *different* class (agno's own
|
|
@@ -150,14 +189,27 @@ it's very unlikely to survive basic testing; CH007 and CH009 both only
|
|
|
150
189
|
match same-file names by design, and most real cases of either are
|
|
151
190
|
plausibly cross-module.
|
|
152
191
|
|
|
153
|
-
**
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
192
|
+
**Two checks we built and did not ship.** `exception-chaining` (`except X
|
|
193
|
+
as e: raise Y(...)` with no `from e`, discarding the real traceback -
|
|
194
|
+
overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
|
|
195
|
+
that says more about how common the pattern is than about anything worth
|
|
196
|
+
flagging: **1,911 hits across the same ~20-framework corpus**. Shipping a
|
|
197
|
+
check that fires that often would make every scan result mostly noise,
|
|
198
|
+
undermining the "a finding must be defensible" standard the rest of this
|
|
199
|
+
tool holds itself to.
|
|
200
|
+
|
|
201
|
+
`cancelled-error-swallowed` (`except asyncio.CancelledError: pass` -
|
|
202
|
+
premise: silently swallowing task cancellation is a bug) went further
|
|
203
|
+
than volume alone: the **first two real hits checked**, in two different
|
|
204
|
+
frameworks, were both correct code, not bugs. agno's was `existing_task.
|
|
205
|
+
cancel(); try: await existing_task; except CancelledError: pass` - the
|
|
206
|
+
textbook-correct way to await a task's own cancellation. letta's was an
|
|
207
|
+
explicit, logged recovery path (`except (CancelledError, ...) as e: logger
|
|
208
|
+
.info(...); <continue processing>`) with a comment literally saying it was
|
|
209
|
+
overriding the cancellation on purpose. Unlike the exception-chaining
|
|
210
|
+
volume problem, this one meant the check's core premise was false in a
|
|
211
|
+
large fraction of real occurrences - so it was deleted outright rather
|
|
212
|
+
than kept at a lower confidence tier. Both are: built, measured, and
|
|
161
213
|
deliberately left out - a real decision, not an oversight.
|
|
162
214
|
|
|
163
215
|
---
|
|
@@ -169,7 +221,9 @@ codehound/
|
|
|
169
221
|
├── core.py # file discovery, AST parsing, the Finding/Check contract,
|
|
170
222
|
│ # and a child→parent map so checks can ask "what's my
|
|
171
223
|
│ # enclosing function / am I inside a `with`?"
|
|
172
|
-
├── cli.py # `scan` / `list`, text|json|csv output, CI-friendly exit codes
|
|
224
|
+
├── cli.py # `scan` / `list`, text|json|csv|sarif output, CI-friendly exit codes
|
|
225
|
+
├── sarif.py # SARIF 2.1.0 output for GitHub Code Scanning
|
|
226
|
+
├── terminal.py # colored text output (auto-disabled for non-TTY / NO_COLOR)
|
|
173
227
|
└── checks/ # one small, independently-tested class per rule
|
|
174
228
|
├── blocking_async.py (CH001)
|
|
175
229
|
├── mutable_defaults.py (CH002)
|
|
@@ -180,12 +234,22 @@ codehound/
|
|
|
180
234
|
├── unawaited_coroutine.py (CH007)
|
|
181
235
|
├── asyncio_run_in_loop.py (CH008)
|
|
182
236
|
├── floating_thread.py (CH009)
|
|
183
|
-
|
|
237
|
+
├── loop_closure_capture.py (CH010)
|
|
238
|
+
├── lru_cache_on_method.py (CH011)
|
|
239
|
+
├── floating_process.py (CH012)
|
|
240
|
+
├── discarded_future.py (CH013)
|
|
241
|
+
├── unprotected_lock.py (CH014)
|
|
242
|
+
├── async_property.py (CH015)
|
|
243
|
+
├── unclosed_socket.py (CH016)
|
|
244
|
+
├── collections_abc_import.py (CH017)
|
|
245
|
+
├── removed_asyncio_task_methods.py (CH018)
|
|
246
|
+
├── removed_getargspec.py (CH019)
|
|
247
|
+
└── bare_except.py (CH020)
|
|
184
248
|
```
|
|
185
249
|
|
|
186
250
|
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.
|
|
187
251
|
|
|
188
|
-
**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
|
|
252
|
+
**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. 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."
|
|
189
253
|
|
|
190
254
|
---
|
|
191
255
|
|
|
@@ -207,10 +271,17 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
207
271
|
- [x] `asyncio.run()` inside a running loop — CH008
|
|
208
272
|
- [x] Non-daemon thread started without a join — CH009 (the thread analog of CH006)
|
|
209
273
|
- [x] Loop-variable closure capture in lambdas — CH010
|
|
274
|
+
- [x] Pre-commit hook — `.pre-commit-hooks.yaml`
|
|
275
|
+
- [x] GitHub Action — `action.yml`, uploads SARIF to Code Scanning
|
|
276
|
+
- [x] SARIF output — `--format sarif`
|
|
277
|
+
- [x] Colored terminal output (auto-disabled for non-TTY / `NO_COLOR`)
|
|
278
|
+
- [x] Multi-path `scan` invocation (what the pre-commit hook needs)
|
|
279
|
+
- [x] 20 checks — memory leaks (`lru_cache` on methods), floating processes,
|
|
280
|
+
discarded futures, unprotected locks, async properties, unclosed
|
|
281
|
+
sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
|
|
210
282
|
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
211
283
|
- [ ] Sync HTTP clients constructed inside async request handlers
|
|
212
284
|
- [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
|
|
213
|
-
- [ ] Pre-commit hook
|
|
214
285
|
|
|
215
286
|
---
|
|
216
287
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""codehound - an AST-based static analyzer that hunts real bugs in Python code.
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
6
|
-
rules verified against real false positives
|
|
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.
|
|
15
|
+
__version__ = "1.4.0"
|
|
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
|