codehound 1.7.0__tar.gz → 1.9.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.7.0 → codehound-1.9.0}/PKG-INFO +63 -8
- {codehound-1.7.0 → codehound-1.9.0}/README.md +61 -7
- {codehound-1.7.0 → codehound-1.9.0}/pyproject.toml +1 -1
- codehound-1.9.0/src/codehound/__init__.py +33 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/__init__.py +4 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/loop_closure_capture.py +86 -0
- codehound-1.9.0/src/codehound/checks/nondeterministic_default.py +99 -0
- codehound-1.9.0/src/codehound/checks/strip_multichar.py +88 -0
- codehound-1.9.0/src/codehound/cli.py +169 -0
- codehound-1.9.0/src/codehound/config.py +85 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/core.py +82 -5
- codehound-1.9.0/src/codehound/fixes.py +151 -0
- {codehound-1.7.0 → codehound-1.9.0}/tests/test_checks.py +166 -0
- codehound-1.9.0/tests/test_config.py +63 -0
- codehound-1.9.0/tests/test_fixes.py +83 -0
- codehound-1.9.0/tests/test_noqa.py +97 -0
- codehound-1.9.0/tests/test_parallel_scan.py +68 -0
- codehound-1.7.0/src/codehound/__init__.py +0 -26
- codehound-1.7.0/src/codehound/cli.py +0 -106
- {codehound-1.7.0 → codehound-1.9.0}/.gitignore +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/LICENSE +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/async_property.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/asyncio_coroutine_decorator.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/bare_except.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/blocking_async.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/collections_abc_import.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/datetime_utcnow.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/discarded_future.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/finally_swallows_exception.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/floating_process.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/floating_task.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/floating_thread.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/floating_timer.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/get_event_loop.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/is_literal_comparison.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/lru_cache_on_async_function.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/lru_cache_on_method.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/mutable_class_attribute.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/mutable_defaults.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/removed_getargspec.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/removed_stdlib_attribute.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/removed_stdlib_module.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/resource_leak.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/unclosed_pool.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/unclosed_socket.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/unittest_deprecated_alias.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/unprotected_lock.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/checks/unwaited_subprocess.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/sarif.py +0 -0
- {codehound-1.7.0 → codehound-1.9.0}/src/codehound/terminal.py +0 -0
- {codehound-1.7.0 → codehound-1.9.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.
|
|
3
|
+
Version: 1.9.0
|
|
4
4
|
Summary: An AST-based static analyzer that hunts real correctness and async-safety bugs in Python code.
|
|
5
5
|
Project-URL: Homepage, https://github.com/kratos0718/codehound
|
|
6
6
|
Project-URL: Issues, https://github.com/kratos0718/codehound/issues
|
|
@@ -16,6 +16,7 @@ Classifier: Topic :: Software Development :: Quality Assurance
|
|
|
16
16
|
Requires-Python: >=3.9
|
|
17
17
|
Provides-Extra: dev
|
|
18
18
|
Requires-Dist: pytest>=7; extra == 'dev'
|
|
19
|
+
Requires-Dist: tomli>=2; (python_version < '3.11') and extra == 'dev'
|
|
19
20
|
Description-Content-Type: text/markdown
|
|
20
21
|
|
|
21
22
|
<p align="center">
|
|
@@ -24,7 +25,7 @@ Description-Content-Type: text/markdown
|
|
|
24
25
|
|
|
25
26
|
<h1 align="center">codehound</h1>
|
|
26
27
|
|
|
27
|
-
**An AST-based static analyzer that hunts *real* bugs in large Python codebases — thirty-
|
|
28
|
+
**An AST-based static analyzer that hunts *real* bugs in large Python codebases — thirty-three checks, eight backed by a bug that was actually found and merged (or opened as a PR) into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~29-framework validation corpus instead of just reasoned about.**
|
|
28
29
|
|
|
29
30
|
[](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
|
|
30
31
|
[](https://pypi.org/project/codehound/)
|
|
@@ -67,7 +68,13 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
|
|
|
67
68
|
|
|
68
69
|
## How this compares
|
|
69
70
|
|
|
70
|
-
Being upfront about overlap: `codehound` is not the only tool that catches some of these patterns, and pretending otherwise wouldn't survive five minutes of someone actually checking. [Ruff](https://docs.astral.sh/ruff/)'s `RUF006` already catches a discarded `asyncio.create_task()` (CH006), `flake8-async`'s `ASYNC300` predates it. Ruff's `RUF012` already catches mutable class-level defaults (CH026), `F632` catches `is`-literal comparisons (CH025), `B006`/`UP005`/`E722` cover mutable-default-arguments/deprecated-unittest-aliases/bare-except (CH002/CH024/CH020). Pylint's `W1518` (`method-cache-max-size-none`) is close to a name-for-name match for CH011's `lru_cache`-on-instance-method leak. If you already run ruff and pylint, several of `codehound`'s checks will feel familiar.
|
|
71
|
+
Being upfront about overlap: `codehound` is not the only tool that catches some of these patterns, and pretending otherwise wouldn't survive five minutes of someone actually checking. [Ruff](https://docs.astral.sh/ruff/)'s `RUF006` already catches a discarded `asyncio.create_task()` (CH006), `flake8-async`'s `ASYNC300` predates it. Ruff's `RUF012` already catches mutable class-level defaults (CH026), `F632` catches `is`-literal comparisons (CH025), `B006`/`UP005`/`E722` cover mutable-default-arguments/deprecated-unittest-aliases/bare-except (CH002/CH024/CH020). Pylint's `W1518` (`method-cache-max-size-none`) is close to a name-for-name match for CH011's `lru_cache`-on-instance-method leak. flake8-bugbear's `B012` also overlaps with CH029 (`return`/`break`/`continue` in `finally:`), though CH029 is narrower — bugbear also flags a bare `continue` inside a `finally:` loop body, which this project hasn't verified has the same swallowed-exception risk in every case. If you already run ruff and pylint, several of `codehound`'s checks will feel familiar.
|
|
72
|
+
|
|
73
|
+
Three checks are explicit adaptations of a flake8-bugbear idea, kept deliberately narrower than the source rule after checking where the broader version's false-positive risk actually lands:
|
|
74
|
+
|
|
75
|
+
- **CH010** extends to cover a nested `def` capturing a loop variable, the same shape bugbear's `B023` covers alongside its lambda case — the same storage-based precision guard (only fires if the function is actually stored past the iteration) applies to both.
|
|
76
|
+
- **CH032** takes bugbear's `B008` idea — "a call as a default argument is suspicious" — and narrows it to a curated list of ten functions (`time.time`, `datetime.now`, `random.random`, `uuid.uuid4`, …) whose result is *never* sensibly the same across calls. B008 as written also flags `def f(x=some_factory()):`, which is frequently a deliberate compute-once memoization; that ambiguity is exactly why this project didn't just port the broader rule.
|
|
77
|
+
- **CH033** takes `B005`'s "`.strip()` with a multi-character argument is misleading" and adds one precision pass B005 doesn't: skip the argument entirely when it contains no letter or digit. Scanning ~30 real frameworks turned up over a hundred multi-character `.strip()` calls, and the overwhelming majority — `.strip('\r\n')`, `.strip('[]')`, `.strip('\'"')`, box-drawing tree glyphs — were deliberate, correct uses of the character-*set* semantics, not the substring mistake the rule exists to catch. Only the argument that reads as a word or token (`data:`, `/v1`, `THREAD#`) is the real bug; a bag of punctuation isn't. Full before/after counts in [`docs/FINDINGS.md`](docs/FINDINGS.md).
|
|
71
78
|
|
|
72
79
|
What actually seems to be missing elsewhere, as far as I've been able to find:
|
|
73
80
|
|
|
@@ -77,6 +84,8 @@ What actually seems to be missing elsewhere, as far as I've been able to find:
|
|
|
77
84
|
|
|
78
85
|
And a difference in kind, not just coverage: every check here is checked against a real corpus, not just reasoned about. [`docs/FINDINGS.md`](docs/FINDINGS.md) has a running ledger of every false positive found while building each check (with the exact framework and line), and two checks that were built, measured, and **rejected outright** when the pattern turned out to be either too common to be a defensible finding (1,911 hits) or premised on something that was actually false (the first two real hits checked turned out to be correct code). I haven't found another static-analysis tool — commercial or open-source — that publishes this kind of "we built it, checked it against real code, and turned it down" ledger. Most tools that market themselves on "catches real bugs" (Greptile, Qodo, CodeRabbit) report an aggregate detection-rate benchmark, not per-rule provenance you can click through to an actual merged fix.
|
|
79
86
|
|
|
87
|
+
**Closing the "toy project" gaps, honestly.** Ruff is a single, fast binary with editor integrations, a plugin-free config file, autofix, and inline suppression - table stakes for a tool people actually adopt, not just admire. `codehound` isn't going to out-perform a Rust tool by staying pure Python, but it now has the parts of that list that don't require rewriting the whole thing: a `[tool.codehound]` block in `pyproject.toml`, `# noqa`/`# noqa: CH001` inline suppression (same syntax flake8/ruff already use, so it doesn't collide with either), `--fix` for the two checks where the rewrite is genuinely unambiguous (CH017 always, CH004 only inside `async def` - guessing wrong on the rest would be worse than not fixing them), and scanning parallelized across a process pool once there's enough files to make that worth it. Measured, not claimed: a full scan of HuggingFace's `transformers` (thousands of files) went from 57 seconds to 12 - verified byte-identical against the sequential result first, not just "seems faster."
|
|
88
|
+
|
|
80
89
|
---
|
|
81
90
|
|
|
82
91
|
## Install
|
|
@@ -85,7 +94,7 @@ And a difference in kind, not just coverage: every check here is checked against
|
|
|
85
94
|
pip install codehound
|
|
86
95
|
```
|
|
87
96
|
|
|
88
|
-
Zero dependencies — it's ~
|
|
97
|
+
Zero dependencies — it's ~4,300 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
|
|
89
98
|
|
|
90
99
|
<details>
|
|
91
100
|
<summary>From a clone instead (for development)</summary>
|
|
@@ -111,6 +120,9 @@ codehound scan file1.py file2.py src/
|
|
|
111
120
|
# only run specific checks
|
|
112
121
|
codehound scan path/to/project --select CH001,CH006
|
|
113
122
|
|
|
123
|
+
# also skip extra directories beyond the built-in defaults
|
|
124
|
+
codehound scan path/to/project --exclude migrations,generated
|
|
125
|
+
|
|
114
126
|
# machine-readable output for CI dashboards
|
|
115
127
|
codehound scan path/to/project --format json
|
|
116
128
|
codehound scan path/to/project --format csv
|
|
@@ -118,6 +130,9 @@ codehound scan path/to/project --format csv
|
|
|
118
130
|
# GitHub Code Scanning (Security tab) can ingest this directly
|
|
119
131
|
codehound scan path/to/project --format sarif > results.sarif
|
|
120
132
|
|
|
133
|
+
# rewrite the fixable findings in place, then report what's left
|
|
134
|
+
codehound scan path/to/project --fix
|
|
135
|
+
|
|
121
136
|
# list every available check
|
|
122
137
|
codehound list
|
|
123
138
|
```
|
|
@@ -128,6 +143,32 @@ codehound list
|
|
|
128
143
|
- run: codehound scan src # fails the build on a regression
|
|
129
144
|
```
|
|
130
145
|
|
|
146
|
+
A finding you've reviewed and want to keep suppresses the same way flake8/ruff findings do - a trailing `# noqa` (everything on that line) or `# noqa: CH001` (just that code):
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
time.sleep(1) # noqa: CH001 - deliberate; this branch only runs at startup, before the loop exists
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Config file
|
|
153
|
+
|
|
154
|
+
Drop defaults into `[tool.codehound]` in `pyproject.toml` so you don't have to repeat flags on every invocation - explicit CLI flags always win over these:
|
|
155
|
+
|
|
156
|
+
```toml
|
|
157
|
+
[tool.codehound]
|
|
158
|
+
select = ["CH001", "CH006"] # same as --select
|
|
159
|
+
exclude = ["migrations"] # extra directories to skip, merged with the built-in defaults
|
|
160
|
+
paths = ["src"] # what `codehound scan` (no path args) scans
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Requires Python 3.11+ to load (uses the standard-library `tomllib`) - on 3.9/3.10 the config file is silently skipped and every flag still works exactly the same via the CLI, since nothing about codehound itself depends on being able to read it.
|
|
164
|
+
|
|
165
|
+
### `--fix`
|
|
166
|
+
|
|
167
|
+
Only two checks ship an autofix, and deliberately so - every other check either needs a judgment call (is this "leak" actually intentional?) or an import that may or may not already be in scope, and guessing wrong there is worse than just reporting the finding:
|
|
168
|
+
|
|
169
|
+
- **CH017** - `collections.<ABC>` → `collections.abc.<ABC>`, a pure rename, always safe.
|
|
170
|
+
- **CH004** - `asyncio.get_event_loop()` → `asyncio.get_running_loop()`, but *only* inside an `async def`. Outside one, `get_running_loop()` raises where `get_event_loop()` wouldn't, so those calls are left as detection-only.
|
|
171
|
+
|
|
131
172
|
### GitHub Action
|
|
132
173
|
|
|
133
174
|
```yaml
|
|
@@ -146,7 +187,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
|
|
|
146
187
|
```yaml
|
|
147
188
|
repos:
|
|
148
189
|
- repo: https://github.com/kratos0718/codehound
|
|
149
|
-
rev: v1.
|
|
190
|
+
rev: v1.9.0
|
|
150
191
|
hooks:
|
|
151
192
|
- id: codehound
|
|
152
193
|
```
|
|
@@ -166,7 +207,7 @@ repos:
|
|
|
166
207
|
| **CH007** | `unawaited-coroutine-call` | `foo()` where `foo` is `async def`, called as a bare statement — no `await`, no scheduling. The coroutine object is created and dropped; the body **never runs at all**. | hardening rule — see below |
|
|
167
208
|
| **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) |
|
|
168
209
|
| **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 |
|
|
169
|
-
| **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 |
|
|
210
|
+
| **CH010** | `loop-closure-capture` | A `lambda` *or* nested `def` 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 |
|
|
170
211
|
| **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 |
|
|
171
212
|
| **CH012** | `floating-process` | A non-daemon `multiprocessing.Process` that's `.start()`ed but never `.join()`ed — the process analog of CH009. | hardening rule |
|
|
172
213
|
| **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 |
|
|
@@ -188,6 +229,8 @@ repos:
|
|
|
188
229
|
| **CH029** | `finally-swallows-exception` | `return`/`break`/`continue` in a `finally:` block silently discards any exception from the `try:` — the caller never sees it. | hardening rule — real hits in letta |
|
|
189
230
|
| **CH030** | `lru-cache-on-async-function` | `@lru_cache`/`@cache` on `async def` caches the coroutine *object*, not its result — the second call with the same arguments crashes. | hardening rule |
|
|
190
231
|
| **CH031** | `unclosed-pool` | `multiprocessing.Pool()` never `.close()`d/`.terminate()`d — worker processes leak for the life of the parent. | hardening rule |
|
|
232
|
+
| **CH032** | `nondeterministic-default-argument` | A default argument computed from `time.time()`/`datetime.now()`/`random.random()`/`uuid.uuid4()` etc. — evaluated once at definition time, so every call using the default gets the *same* value forever. (inspired by flake8-bugbear B008, narrowed to a curated function list — see below) | hardening rule — real hit in litellm (`BudgetManager.create_budget`'s `created_at=time.time()` default) |
|
|
233
|
+
| **CH033** | `strip-multichar-argument` | `.strip()`/`.lstrip()`/`.rstrip()` called with a multi-character string that contains a letter or digit — `str.strip(chars)` removes any of those *characters*, not the substring, from each end. (inspired by flake8-bugbear B005, narrowed to skip punctuation-only character sets — see below) | hardening rule — real hit in huggingface_hub (SSE parsing: `line.lstrip("data:").rstrip("/n")`, the second almost certainly meant `"\n"`) |
|
|
191
234
|
|
|
192
235
|
`codehound list` prints this from the source of truth.
|
|
193
236
|
|
|
@@ -438,7 +481,7 @@ codehound/
|
|
|
438
481
|
|
|
439
482
|
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.
|
|
440
483
|
|
|
441
|
-
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. CH025 pairs each chained comparison's op with only its own adjacent operands, rather than matching a literal and an `is`/`is not` anywhere in the same chain independently — a real pattern found in litellm. CH027 and CH028 both recognize a handle stored as *any* object's attribute as a hand-off, matching CH009/CH016's precedent — real patterns found in dspy and weaviate-python-client respectively. CH028 also only trusts a bare `Timer(...)` when `from threading import Timer` was actually seen — real hits in agno were its own unrelated stopwatch class. CH029 skips a `try` whose `except` clauses never re-raise anywhere in their own scope — a real pattern in letta where the exception is deliberately logged and recorded, never propagated, so a `return` in `finally` isn't discarding anything live. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
|
|
484
|
+
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. CH025 pairs each chained comparison's op with only its own adjacent operands, rather than matching a literal and an `is`/`is not` anywhere in the same chain independently — a real pattern found in litellm. CH027 and CH028 both recognize a handle stored as *any* object's attribute as a hand-off, matching CH009/CH016's precedent — real patterns found in dspy and weaviate-python-client respectively. CH028 also only trusts a bare `Timer(...)` when `from threading import Timer` was actually seen — real hits in agno were its own unrelated stopwatch class. CH029 skips a `try` whose `except` clauses never re-raise anywhere in their own scope — a real pattern in letta where the exception is deliberately logged and recorded, never propagated, so a `return` in `finally` isn't discarding anything live. CH033 skips a `.strip()` argument that's every character the same (`.strip('```')`) or made entirely of punctuation/whitespace with no letter or digit (`.strip('\r\n')`, `.strip('[]')`) — real patterns found across nearly every framework scanned, all deliberate uses of the character-*set* semantics rather than the substring mistake the check exists to catch. 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."
|
|
442
485
|
|
|
443
486
|
---
|
|
444
487
|
|
|
@@ -475,9 +518,21 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
475
518
|
subprocesses, floating timers — CH023-CH028
|
|
476
519
|
- [x] 31 checks — `finally:` blocks that swallow exceptions, `lru_cache`
|
|
477
520
|
on async functions, unclosed `multiprocessing.Pool` — CH029-CH031
|
|
521
|
+
- [x] Inline `# noqa` / `# noqa: CH001` suppression
|
|
522
|
+
- [x] `[tool.codehound]` project config in `pyproject.toml` (`select`,
|
|
523
|
+
`exclude`, `paths` — Python 3.11+ to load, every flag still works
|
|
524
|
+
without it on 3.9/3.10)
|
|
525
|
+
- [x] `--fix` — CH017 always, CH004 only inside `async def` (CH002/CH003
|
|
526
|
+
turned out to need judgment calls or import-injection this tool
|
|
527
|
+
won't guess at, so they stay detection-only; see docs/ARCHITECTURE.md)
|
|
528
|
+
- [x] Parallelize scanning across files for large codebases — a full
|
|
529
|
+
HuggingFace transformers scan went from 57s to 12s (measured,
|
|
530
|
+
byte-identical output verified against the sequential run)
|
|
531
|
+
- [x] 33 checks — nested-`def` loop-closure capture alongside lambdas
|
|
532
|
+
(CH010, same shape as bugbear's B023), nondeterministic default
|
|
533
|
+
arguments, multi-character `.strip()` arguments — CH032-CH033
|
|
478
534
|
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
479
535
|
- [ ] Extend CH001 to a curated denylist of sync AI/agent SDK client calls inside async functions (vector-DB clients, LLM SDKs) — the gap flake8-async's stdlib-only denylist leaves open
|
|
480
|
-
- [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
|
|
481
536
|
|
|
482
537
|
---
|
|
483
538
|
|
|
@@ -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 — thirty-
|
|
7
|
+
**An AST-based static analyzer that hunts *real* bugs in large Python codebases — thirty-three 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
|
[](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
|
|
10
10
|
[](https://pypi.org/project/codehound/)
|
|
@@ -47,7 +47,13 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
|
|
|
47
47
|
|
|
48
48
|
## How this compares
|
|
49
49
|
|
|
50
|
-
Being upfront about overlap: `codehound` is not the only tool that catches some of these patterns, and pretending otherwise wouldn't survive five minutes of someone actually checking. [Ruff](https://docs.astral.sh/ruff/)'s `RUF006` already catches a discarded `asyncio.create_task()` (CH006), `flake8-async`'s `ASYNC300` predates it. Ruff's `RUF012` already catches mutable class-level defaults (CH026), `F632` catches `is`-literal comparisons (CH025), `B006`/`UP005`/`E722` cover mutable-default-arguments/deprecated-unittest-aliases/bare-except (CH002/CH024/CH020). Pylint's `W1518` (`method-cache-max-size-none`) is close to a name-for-name match for CH011's `lru_cache`-on-instance-method leak. If you already run ruff and pylint, several of `codehound`'s checks will feel familiar.
|
|
50
|
+
Being upfront about overlap: `codehound` is not the only tool that catches some of these patterns, and pretending otherwise wouldn't survive five minutes of someone actually checking. [Ruff](https://docs.astral.sh/ruff/)'s `RUF006` already catches a discarded `asyncio.create_task()` (CH006), `flake8-async`'s `ASYNC300` predates it. Ruff's `RUF012` already catches mutable class-level defaults (CH026), `F632` catches `is`-literal comparisons (CH025), `B006`/`UP005`/`E722` cover mutable-default-arguments/deprecated-unittest-aliases/bare-except (CH002/CH024/CH020). Pylint's `W1518` (`method-cache-max-size-none`) is close to a name-for-name match for CH011's `lru_cache`-on-instance-method leak. flake8-bugbear's `B012` also overlaps with CH029 (`return`/`break`/`continue` in `finally:`), though CH029 is narrower — bugbear also flags a bare `continue` inside a `finally:` loop body, which this project hasn't verified has the same swallowed-exception risk in every case. If you already run ruff and pylint, several of `codehound`'s checks will feel familiar.
|
|
51
|
+
|
|
52
|
+
Three checks are explicit adaptations of a flake8-bugbear idea, kept deliberately narrower than the source rule after checking where the broader version's false-positive risk actually lands:
|
|
53
|
+
|
|
54
|
+
- **CH010** extends to cover a nested `def` capturing a loop variable, the same shape bugbear's `B023` covers alongside its lambda case — the same storage-based precision guard (only fires if the function is actually stored past the iteration) applies to both.
|
|
55
|
+
- **CH032** takes bugbear's `B008` idea — "a call as a default argument is suspicious" — and narrows it to a curated list of ten functions (`time.time`, `datetime.now`, `random.random`, `uuid.uuid4`, …) whose result is *never* sensibly the same across calls. B008 as written also flags `def f(x=some_factory()):`, which is frequently a deliberate compute-once memoization; that ambiguity is exactly why this project didn't just port the broader rule.
|
|
56
|
+
- **CH033** takes `B005`'s "`.strip()` with a multi-character argument is misleading" and adds one precision pass B005 doesn't: skip the argument entirely when it contains no letter or digit. Scanning ~30 real frameworks turned up over a hundred multi-character `.strip()` calls, and the overwhelming majority — `.strip('\r\n')`, `.strip('[]')`, `.strip('\'"')`, box-drawing tree glyphs — were deliberate, correct uses of the character-*set* semantics, not the substring mistake the rule exists to catch. Only the argument that reads as a word or token (`data:`, `/v1`, `THREAD#`) is the real bug; a bag of punctuation isn't. Full before/after counts in [`docs/FINDINGS.md`](docs/FINDINGS.md).
|
|
51
57
|
|
|
52
58
|
What actually seems to be missing elsewhere, as far as I've been able to find:
|
|
53
59
|
|
|
@@ -57,6 +63,8 @@ What actually seems to be missing elsewhere, as far as I've been able to find:
|
|
|
57
63
|
|
|
58
64
|
And a difference in kind, not just coverage: every check here is checked against a real corpus, not just reasoned about. [`docs/FINDINGS.md`](docs/FINDINGS.md) has a running ledger of every false positive found while building each check (with the exact framework and line), and two checks that were built, measured, and **rejected outright** when the pattern turned out to be either too common to be a defensible finding (1,911 hits) or premised on something that was actually false (the first two real hits checked turned out to be correct code). I haven't found another static-analysis tool — commercial or open-source — that publishes this kind of "we built it, checked it against real code, and turned it down" ledger. Most tools that market themselves on "catches real bugs" (Greptile, Qodo, CodeRabbit) report an aggregate detection-rate benchmark, not per-rule provenance you can click through to an actual merged fix.
|
|
59
65
|
|
|
66
|
+
**Closing the "toy project" gaps, honestly.** Ruff is a single, fast binary with editor integrations, a plugin-free config file, autofix, and inline suppression - table stakes for a tool people actually adopt, not just admire. `codehound` isn't going to out-perform a Rust tool by staying pure Python, but it now has the parts of that list that don't require rewriting the whole thing: a `[tool.codehound]` block in `pyproject.toml`, `# noqa`/`# noqa: CH001` inline suppression (same syntax flake8/ruff already use, so it doesn't collide with either), `--fix` for the two checks where the rewrite is genuinely unambiguous (CH017 always, CH004 only inside `async def` - guessing wrong on the rest would be worse than not fixing them), and scanning parallelized across a process pool once there's enough files to make that worth it. Measured, not claimed: a full scan of HuggingFace's `transformers` (thousands of files) went from 57 seconds to 12 - verified byte-identical against the sequential result first, not just "seems faster."
|
|
67
|
+
|
|
60
68
|
---
|
|
61
69
|
|
|
62
70
|
## Install
|
|
@@ -65,7 +73,7 @@ And a difference in kind, not just coverage: every check here is checked against
|
|
|
65
73
|
pip install codehound
|
|
66
74
|
```
|
|
67
75
|
|
|
68
|
-
Zero dependencies — it's ~
|
|
76
|
+
Zero dependencies — it's ~4,300 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
|
|
69
77
|
|
|
70
78
|
<details>
|
|
71
79
|
<summary>From a clone instead (for development)</summary>
|
|
@@ -91,6 +99,9 @@ codehound scan file1.py file2.py src/
|
|
|
91
99
|
# only run specific checks
|
|
92
100
|
codehound scan path/to/project --select CH001,CH006
|
|
93
101
|
|
|
102
|
+
# also skip extra directories beyond the built-in defaults
|
|
103
|
+
codehound scan path/to/project --exclude migrations,generated
|
|
104
|
+
|
|
94
105
|
# machine-readable output for CI dashboards
|
|
95
106
|
codehound scan path/to/project --format json
|
|
96
107
|
codehound scan path/to/project --format csv
|
|
@@ -98,6 +109,9 @@ codehound scan path/to/project --format csv
|
|
|
98
109
|
# GitHub Code Scanning (Security tab) can ingest this directly
|
|
99
110
|
codehound scan path/to/project --format sarif > results.sarif
|
|
100
111
|
|
|
112
|
+
# rewrite the fixable findings in place, then report what's left
|
|
113
|
+
codehound scan path/to/project --fix
|
|
114
|
+
|
|
101
115
|
# list every available check
|
|
102
116
|
codehound list
|
|
103
117
|
```
|
|
@@ -108,6 +122,32 @@ codehound list
|
|
|
108
122
|
- run: codehound scan src # fails the build on a regression
|
|
109
123
|
```
|
|
110
124
|
|
|
125
|
+
A finding you've reviewed and want to keep suppresses the same way flake8/ruff findings do - a trailing `# noqa` (everything on that line) or `# noqa: CH001` (just that code):
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
time.sleep(1) # noqa: CH001 - deliberate; this branch only runs at startup, before the loop exists
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Config file
|
|
132
|
+
|
|
133
|
+
Drop defaults into `[tool.codehound]` in `pyproject.toml` so you don't have to repeat flags on every invocation - explicit CLI flags always win over these:
|
|
134
|
+
|
|
135
|
+
```toml
|
|
136
|
+
[tool.codehound]
|
|
137
|
+
select = ["CH001", "CH006"] # same as --select
|
|
138
|
+
exclude = ["migrations"] # extra directories to skip, merged with the built-in defaults
|
|
139
|
+
paths = ["src"] # what `codehound scan` (no path args) scans
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Requires Python 3.11+ to load (uses the standard-library `tomllib`) - on 3.9/3.10 the config file is silently skipped and every flag still works exactly the same via the CLI, since nothing about codehound itself depends on being able to read it.
|
|
143
|
+
|
|
144
|
+
### `--fix`
|
|
145
|
+
|
|
146
|
+
Only two checks ship an autofix, and deliberately so - every other check either needs a judgment call (is this "leak" actually intentional?) or an import that may or may not already be in scope, and guessing wrong there is worse than just reporting the finding:
|
|
147
|
+
|
|
148
|
+
- **CH017** - `collections.<ABC>` → `collections.abc.<ABC>`, a pure rename, always safe.
|
|
149
|
+
- **CH004** - `asyncio.get_event_loop()` → `asyncio.get_running_loop()`, but *only* inside an `async def`. Outside one, `get_running_loop()` raises where `get_event_loop()` wouldn't, so those calls are left as detection-only.
|
|
150
|
+
|
|
111
151
|
### GitHub Action
|
|
112
152
|
|
|
113
153
|
```yaml
|
|
@@ -126,7 +166,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
|
|
|
126
166
|
```yaml
|
|
127
167
|
repos:
|
|
128
168
|
- repo: https://github.com/kratos0718/codehound
|
|
129
|
-
rev: v1.
|
|
169
|
+
rev: v1.9.0
|
|
130
170
|
hooks:
|
|
131
171
|
- id: codehound
|
|
132
172
|
```
|
|
@@ -146,7 +186,7 @@ repos:
|
|
|
146
186
|
| **CH007** | `unawaited-coroutine-call` | `foo()` where `foo` is `async def`, called as a bare statement — no `await`, no scheduling. The coroutine object is created and dropped; the body **never runs at all**. | hardening rule — see below |
|
|
147
187
|
| **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) |
|
|
148
188
|
| **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 |
|
|
149
|
-
| **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 |
|
|
189
|
+
| **CH010** | `loop-closure-capture` | A `lambda` *or* nested `def` 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 |
|
|
150
190
|
| **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 |
|
|
151
191
|
| **CH012** | `floating-process` | A non-daemon `multiprocessing.Process` that's `.start()`ed but never `.join()`ed — the process analog of CH009. | hardening rule |
|
|
152
192
|
| **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 |
|
|
@@ -168,6 +208,8 @@ repos:
|
|
|
168
208
|
| **CH029** | `finally-swallows-exception` | `return`/`break`/`continue` in a `finally:` block silently discards any exception from the `try:` — the caller never sees it. | hardening rule — real hits in letta |
|
|
169
209
|
| **CH030** | `lru-cache-on-async-function` | `@lru_cache`/`@cache` on `async def` caches the coroutine *object*, not its result — the second call with the same arguments crashes. | hardening rule |
|
|
170
210
|
| **CH031** | `unclosed-pool` | `multiprocessing.Pool()` never `.close()`d/`.terminate()`d — worker processes leak for the life of the parent. | hardening rule |
|
|
211
|
+
| **CH032** | `nondeterministic-default-argument` | A default argument computed from `time.time()`/`datetime.now()`/`random.random()`/`uuid.uuid4()` etc. — evaluated once at definition time, so every call using the default gets the *same* value forever. (inspired by flake8-bugbear B008, narrowed to a curated function list — see below) | hardening rule — real hit in litellm (`BudgetManager.create_budget`'s `created_at=time.time()` default) |
|
|
212
|
+
| **CH033** | `strip-multichar-argument` | `.strip()`/`.lstrip()`/`.rstrip()` called with a multi-character string that contains a letter or digit — `str.strip(chars)` removes any of those *characters*, not the substring, from each end. (inspired by flake8-bugbear B005, narrowed to skip punctuation-only character sets — see below) | hardening rule — real hit in huggingface_hub (SSE parsing: `line.lstrip("data:").rstrip("/n")`, the second almost certainly meant `"\n"`) |
|
|
171
213
|
|
|
172
214
|
`codehound list` prints this from the source of truth.
|
|
173
215
|
|
|
@@ -418,7 +460,7 @@ codehound/
|
|
|
418
460
|
|
|
419
461
|
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.
|
|
420
462
|
|
|
421
|
-
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. CH025 pairs each chained comparison's op with only its own adjacent operands, rather than matching a literal and an `is`/`is not` anywhere in the same chain independently — a real pattern found in litellm. CH027 and CH028 both recognize a handle stored as *any* object's attribute as a hand-off, matching CH009/CH016's precedent — real patterns found in dspy and weaviate-python-client respectively. CH028 also only trusts a bare `Timer(...)` when `from threading import Timer` was actually seen — real hits in agno were its own unrelated stopwatch class. CH029 skips a `try` whose `except` clauses never re-raise anywhere in their own scope — a real pattern in letta where the exception is deliberately logged and recorded, never propagated, so a `return` in `finally` isn't discarding anything live. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
|
|
463
|
+
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. CH025 pairs each chained comparison's op with only its own adjacent operands, rather than matching a literal and an `is`/`is not` anywhere in the same chain independently — a real pattern found in litellm. CH027 and CH028 both recognize a handle stored as *any* object's attribute as a hand-off, matching CH009/CH016's precedent — real patterns found in dspy and weaviate-python-client respectively. CH028 also only trusts a bare `Timer(...)` when `from threading import Timer` was actually seen — real hits in agno were its own unrelated stopwatch class. CH029 skips a `try` whose `except` clauses never re-raise anywhere in their own scope — a real pattern in letta where the exception is deliberately logged and recorded, never propagated, so a `return` in `finally` isn't discarding anything live. CH033 skips a `.strip()` argument that's every character the same (`.strip('```')`) or made entirely of punctuation/whitespace with no letter or digit (`.strip('\r\n')`, `.strip('[]')`) — real patterns found across nearly every framework scanned, all deliberate uses of the character-*set* semantics rather than the substring mistake the check exists to catch. 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."
|
|
422
464
|
|
|
423
465
|
---
|
|
424
466
|
|
|
@@ -455,9 +497,21 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
455
497
|
subprocesses, floating timers — CH023-CH028
|
|
456
498
|
- [x] 31 checks — `finally:` blocks that swallow exceptions, `lru_cache`
|
|
457
499
|
on async functions, unclosed `multiprocessing.Pool` — CH029-CH031
|
|
500
|
+
- [x] Inline `# noqa` / `# noqa: CH001` suppression
|
|
501
|
+
- [x] `[tool.codehound]` project config in `pyproject.toml` (`select`,
|
|
502
|
+
`exclude`, `paths` — Python 3.11+ to load, every flag still works
|
|
503
|
+
without it on 3.9/3.10)
|
|
504
|
+
- [x] `--fix` — CH017 always, CH004 only inside `async def` (CH002/CH003
|
|
505
|
+
turned out to need judgment calls or import-injection this tool
|
|
506
|
+
won't guess at, so they stay detection-only; see docs/ARCHITECTURE.md)
|
|
507
|
+
- [x] Parallelize scanning across files for large codebases — a full
|
|
508
|
+
HuggingFace transformers scan went from 57s to 12s (measured,
|
|
509
|
+
byte-identical output verified against the sequential run)
|
|
510
|
+
- [x] 33 checks — nested-`def` loop-closure capture alongside lambdas
|
|
511
|
+
(CH010, same shape as bugbear's B023), nondeterministic default
|
|
512
|
+
arguments, multi-character `.strip()` arguments — CH032-CH033
|
|
458
513
|
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
459
514
|
- [ ] Extend CH001 to a curated denylist of sync AI/agent SDK client calls inside async functions (vector-DB clients, LLM SDKs) — the gap flake8-async's stdlib-only denylist leaves open
|
|
460
|
-
- [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
|
|
461
515
|
|
|
462
516
|
---
|
|
463
517
|
|
|
@@ -28,7 +28,7 @@ Issues = "https://github.com/kratos0718/codehound/issues"
|
|
|
28
28
|
codehound = "codehound.cli:main"
|
|
29
29
|
|
|
30
30
|
[project.optional-dependencies]
|
|
31
|
-
dev = ["pytest>=7"]
|
|
31
|
+
dev = ["pytest>=7", "tomli>=2; python_version < '3.11'"]
|
|
32
32
|
|
|
33
33
|
[tool.hatch.version]
|
|
34
34
|
path = "src/codehound/__init__.py"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""codehound - an AST-based static analyzer that hunts real bugs in Python code.
|
|
2
|
+
|
|
3
|
+
Thirty-three checks. Eight are each backed by a bug that was actually
|
|
4
|
+
found and fixed (or opened as a PR) in a popular open-source AI framework
|
|
5
|
+
(agno, crewAI, mem0, llama_index, accelerate, optuna, litellm). The rest
|
|
6
|
+
(CH007-CH009, CH012-CH033) are hardening rules verified against real
|
|
7
|
+
false positives across a ~29-framework validation corpus instead - see
|
|
8
|
+
docs/FINDINGS.md.
|
|
9
|
+
|
|
10
|
+
Also has the parts of a production-grade linter that don't require
|
|
11
|
+
rewriting the whole thing in Rust: inline `# noqa` suppression,
|
|
12
|
+
`[tool.codehound]` project config, `--fix` for the checks where the
|
|
13
|
+
rewrite is genuinely unambiguous, and scanning parallelized across a
|
|
14
|
+
process pool for large codebases.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from codehound.checks import ALL_CHECKS, get_checks
|
|
20
|
+
from codehound.core import Check, Finding, scan_file, scan_files, scan_path
|
|
21
|
+
|
|
22
|
+
__version__ = "1.9.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ALL_CHECKS",
|
|
26
|
+
"get_checks",
|
|
27
|
+
"Check",
|
|
28
|
+
"Finding",
|
|
29
|
+
"scan_file",
|
|
30
|
+
"scan_files",
|
|
31
|
+
"scan_path",
|
|
32
|
+
"__version__",
|
|
33
|
+
]
|
|
@@ -22,11 +22,13 @@ from codehound.checks.lru_cache_on_async_function import LruCacheOnAsyncFunction
|
|
|
22
22
|
from codehound.checks.lru_cache_on_method import LruCacheOnMethod
|
|
23
23
|
from codehound.checks.mutable_class_attribute import MutableClassAttribute
|
|
24
24
|
from codehound.checks.mutable_defaults import MutableDefaultArgument
|
|
25
|
+
from codehound.checks.nondeterministic_default import NondeterministicDefault
|
|
25
26
|
from codehound.checks.removed_asyncio_task_methods import RemovedAsyncioTaskMethods
|
|
26
27
|
from codehound.checks.removed_getargspec import RemovedGetargspec
|
|
27
28
|
from codehound.checks.removed_stdlib_attribute import RemovedStdlibAttribute
|
|
28
29
|
from codehound.checks.removed_stdlib_module import RemovedStdlibModule
|
|
29
30
|
from codehound.checks.resource_leak import UnclosedFileHandle
|
|
31
|
+
from codehound.checks.strip_multichar import StripMultichar
|
|
30
32
|
from codehound.checks.unawaited_coroutine import UnawaitedCoroutineCall
|
|
31
33
|
from codehound.checks.unclosed_pool import UnclosedPool
|
|
32
34
|
from codehound.checks.unclosed_socket import UnclosedSocket
|
|
@@ -67,6 +69,8 @@ ALL_CHECKS: list[type[Check]] = [
|
|
|
67
69
|
FinallySwallowsException,
|
|
68
70
|
LruCacheOnAsyncFunction,
|
|
69
71
|
UnclosedPool,
|
|
72
|
+
NondeterministicDefault,
|
|
73
|
+
StripMultichar,
|
|
70
74
|
]
|
|
71
75
|
|
|
72
76
|
|
|
@@ -29,6 +29,14 @@ Comprehensions (`` [lambda: i for i in range(3)] ``) are the one
|
|
|
29
29
|
exception: the ``elt``/``key``/``value`` position *is* inherently the
|
|
30
30
|
"produce and store" position, so no extra storage-context check is
|
|
31
31
|
needed there.
|
|
32
|
+
|
|
33
|
+
Same bug, same fix, different syntax: a nested ``def`` inside a ``for``
|
|
34
|
+
loop captures the loop variable exactly the same way a lambda does -
|
|
35
|
+
flake8-bugbear's B023 covers both shapes under one rule, and this check
|
|
36
|
+
now does too. ``def`` is a statement, not an expression, so "is it
|
|
37
|
+
stored" means something different: the function's *name* has to show up
|
|
38
|
+
later in a storage position (appended, assigned, returned), not the
|
|
39
|
+
``def`` itself.
|
|
32
40
|
"""
|
|
33
41
|
|
|
34
42
|
from __future__ import annotations
|
|
@@ -54,6 +62,53 @@ def _references_name_unshadowed(lam: ast.Lambda, name: str) -> bool:
|
|
|
54
62
|
return False
|
|
55
63
|
|
|
56
64
|
|
|
65
|
+
def _references_name_in_body(funcdef: ast.FunctionDef | ast.AsyncFunctionDef, name: str) -> bool:
|
|
66
|
+
"""Like ``_references_name_unshadowed`` but for a ``def``'s own
|
|
67
|
+
parameters and statement body rather than a lambda's params and
|
|
68
|
+
single expression - a same-named parameter shadows the outer loop
|
|
69
|
+
variable exactly like it would for a lambda."""
|
|
70
|
+
args = funcdef.args
|
|
71
|
+
param_names = {a.arg for a in (args.posonlyargs + args.args + args.kwonlyargs)}
|
|
72
|
+
if args.vararg:
|
|
73
|
+
param_names.add(args.vararg.arg)
|
|
74
|
+
if args.kwarg:
|
|
75
|
+
param_names.add(args.kwarg.arg)
|
|
76
|
+
if name in param_names:
|
|
77
|
+
return False
|
|
78
|
+
for stmt in funcdef.body:
|
|
79
|
+
for node in ast.walk(stmt):
|
|
80
|
+
if isinstance(node, ast.Name) and node.id == name and isinstance(node.ctx, ast.Load):
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _name_is_stored(name: str, loop_body: list[ast.stmt], parents: dict) -> bool:
|
|
86
|
+
"""True if `name` (a nested def's own name) is later used in a
|
|
87
|
+
storage position anywhere in the loop body: assigned, returned/
|
|
88
|
+
yielded, or passed to `.append()`/`.add()` - same storage shapes
|
|
89
|
+
`_is_stored` recognizes for a lambda, just checked via the Name
|
|
90
|
+
reference's parent instead of the def statement's own parent, since
|
|
91
|
+
a `def` can't be the direct value of an assignment the way a lambda
|
|
92
|
+
expression can."""
|
|
93
|
+
for stmt in loop_body:
|
|
94
|
+
for node in ast.walk(stmt):
|
|
95
|
+
if not (isinstance(node, ast.Name) and node.id == name and isinstance(node.ctx, ast.Load)):
|
|
96
|
+
continue
|
|
97
|
+
parent = parents.get(id(node))
|
|
98
|
+
if isinstance(parent, (ast.Assign, ast.AnnAssign)) and parent.value is node:
|
|
99
|
+
return True
|
|
100
|
+
if isinstance(parent, (ast.Return, ast.Yield)) and parent.value is node:
|
|
101
|
+
return True
|
|
102
|
+
if (
|
|
103
|
+
isinstance(parent, ast.Call)
|
|
104
|
+
and isinstance(parent.func, ast.Attribute)
|
|
105
|
+
and parent.func.attr in _STORAGE_METHODS
|
|
106
|
+
and node in parent.args
|
|
107
|
+
):
|
|
108
|
+
return True
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
57
112
|
def _is_stored(lam: ast.Lambda, parents: dict) -> bool:
|
|
58
113
|
"""True only if the lambda is directly stored somewhere that outlives
|
|
59
114
|
this loop iteration - not merely passed as a callback argument to a
|
|
@@ -120,4 +175,35 @@ class LoopClosureCapture(Check):
|
|
|
120
175
|
)
|
|
121
176
|
)
|
|
122
177
|
break
|
|
178
|
+
|
|
179
|
+
# Same bug, `def` instead of `lambda`: a nested function
|
|
180
|
+
# defined directly in a `for` loop's body, capturing the loop
|
|
181
|
+
# variable, whose *name* (not the def itself) is later stored
|
|
182
|
+
# somewhere that outlives this iteration.
|
|
183
|
+
if isinstance(loop, ast.For) and isinstance(loop.target, ast.Name):
|
|
184
|
+
var = loop.target.id
|
|
185
|
+
for stmt in loop.body:
|
|
186
|
+
if not isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
187
|
+
continue
|
|
188
|
+
if id(stmt) in seen:
|
|
189
|
+
continue
|
|
190
|
+
if not _references_name_in_body(stmt, var):
|
|
191
|
+
continue
|
|
192
|
+
if not _name_is_stored(stmt.name, loop.body, parents):
|
|
193
|
+
continue
|
|
194
|
+
seen.add(id(stmt))
|
|
195
|
+
findings.append(
|
|
196
|
+
Finding(
|
|
197
|
+
path=path,
|
|
198
|
+
line=stmt.lineno,
|
|
199
|
+
col=stmt.col_offset,
|
|
200
|
+
code=self.code,
|
|
201
|
+
message=(
|
|
202
|
+
f"`{stmt.name}` captures loop variable `{var}` by reference; if "
|
|
203
|
+
f"called after the loop moves on, every instance sees the same "
|
|
204
|
+
f"final value. Bind it explicitly with a default argument, e.g. "
|
|
205
|
+
f"`def {stmt.name}({var}={var}):`."
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
)
|
|
123
209
|
return findings
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""CH032 - a default argument computed from a call to a non-deterministic function.
|
|
2
|
+
|
|
3
|
+
A default value is evaluated exactly *once*, when the function is
|
|
4
|
+
defined, not on every call - the same fact CH002 is about, but this is
|
|
5
|
+
a distinct shape: a call to something like `time.time()` or
|
|
6
|
+
`datetime.now()` isn't mutable, so CH002's "shared mutable object" check
|
|
7
|
+
doesn't fire, and it's syntactically identical to a perfectly reasonable
|
|
8
|
+
default like `def f(x=DEFAULT_TIMEOUT):`. What makes it a bug is that
|
|
9
|
+
the *value itself* depends on when it's called, and every default
|
|
10
|
+
argument only ever captures the value from function-definition time.
|
|
11
|
+
Verified directly:
|
|
12
|
+
|
|
13
|
+
def f(x=time.time()):
|
|
14
|
+
return x
|
|
15
|
+
|
|
16
|
+
a = f()
|
|
17
|
+
time.sleep(0.05)
|
|
18
|
+
b = f()
|
|
19
|
+
a == b # True - both calls return the exact same timestamp
|
|
20
|
+
|
|
21
|
+
Deliberately narrow, unlike a general "any call as a default is
|
|
22
|
+
suspicious" rule (flake8-bugbear's B008, which also flags things like
|
|
23
|
+
`def f(x=some_factory()):` that may well be an intentional
|
|
24
|
+
compute-once memoization): this only flags a curated set of functions
|
|
25
|
+
where "the same value every call" can never be what the author wanted -
|
|
26
|
+
the current time, a random number, a new UUID. A generic factory call
|
|
27
|
+
as a default might be deliberate; a cached `time.time()` never is.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import ast
|
|
33
|
+
|
|
34
|
+
from codehound.core import Check, Finding
|
|
35
|
+
|
|
36
|
+
# (module, attribute) pairs whose result changes every call and would
|
|
37
|
+
# never sensibly be memoized as a shared default.
|
|
38
|
+
_NONDETERMINISTIC_CALLS: dict[tuple[str, str], str] = {
|
|
39
|
+
("time", "time"): "time.time()",
|
|
40
|
+
("time", "monotonic"): "time.monotonic()",
|
|
41
|
+
("time", "perf_counter"): "time.perf_counter()",
|
|
42
|
+
("datetime", "now"): "datetime.now()",
|
|
43
|
+
("datetime", "utcnow"): "datetime.utcnow()",
|
|
44
|
+
("date", "today"): "date.today()",
|
|
45
|
+
("random", "random"): "random.random()",
|
|
46
|
+
("random", "randint"): "random.randint()",
|
|
47
|
+
("random", "choice"): "random.choice()",
|
|
48
|
+
("uuid", "uuid4"): "uuid.uuid4()",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _nondeterministic_call_name(node: ast.expr) -> str | None:
|
|
53
|
+
if not isinstance(node, ast.Call):
|
|
54
|
+
return None
|
|
55
|
+
func = node.func
|
|
56
|
+
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
57
|
+
return _NONDETERMINISTIC_CALLS.get((func.value.id, func.attr))
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _iter_defaults(args: ast.arguments):
|
|
62
|
+
positional = args.posonlyargs + args.args
|
|
63
|
+
for arg, default in zip(reversed(positional), reversed(args.defaults)):
|
|
64
|
+
yield arg, default
|
|
65
|
+
for arg, default in zip(args.kwonlyargs, args.kw_defaults):
|
|
66
|
+
if default is not None:
|
|
67
|
+
yield arg, default
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class NondeterministicDefault(Check):
|
|
71
|
+
code = "CH032"
|
|
72
|
+
name = "nondeterministic-default-argument"
|
|
73
|
+
description = "Default argument calls a function (time/random/uuid) whose result changes every call."
|
|
74
|
+
|
|
75
|
+
def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
|
|
76
|
+
findings: list[Finding] = []
|
|
77
|
+
for node in ast.walk(tree):
|
|
78
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
79
|
+
continue
|
|
80
|
+
for arg, default in _iter_defaults(node.args):
|
|
81
|
+
call_text = _nondeterministic_call_name(default)
|
|
82
|
+
if call_text is None:
|
|
83
|
+
continue
|
|
84
|
+
name = getattr(node, "name", "<lambda>")
|
|
85
|
+
findings.append(
|
|
86
|
+
Finding(
|
|
87
|
+
path=path,
|
|
88
|
+
line=default.lineno,
|
|
89
|
+
col=default.col_offset,
|
|
90
|
+
code=self.code,
|
|
91
|
+
message=(
|
|
92
|
+
f"default value for `{arg.arg}` in `{name}` calls `{call_text}` - "
|
|
93
|
+
f"evaluated once, at definition time, not on every call. Every "
|
|
94
|
+
f"invocation using the default gets the exact same value. Use "
|
|
95
|
+
f"`{arg.arg}=None` and compute it inside the function body instead."
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
return findings
|