codehound 1.7.0__tar.gz → 1.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codehound-1.7.0/README.md → codehound-1.8.0/PKG-INFO +67 -3
- codehound-1.7.0/PKG-INFO → codehound-1.8.0/README.md +46 -23
- {codehound-1.7.0 → codehound-1.8.0}/pyproject.toml +1 -1
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/__init__.py +9 -2
- codehound-1.8.0/src/codehound/cli.py +169 -0
- codehound-1.8.0/src/codehound/config.py +85 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/core.py +82 -5
- codehound-1.8.0/src/codehound/fixes.py +151 -0
- codehound-1.8.0/tests/test_config.py +63 -0
- codehound-1.8.0/tests/test_fixes.py +83 -0
- codehound-1.8.0/tests/test_noqa.py +97 -0
- codehound-1.8.0/tests/test_parallel_scan.py +68 -0
- codehound-1.7.0/src/codehound/cli.py +0 -106
- {codehound-1.7.0 → codehound-1.8.0}/.gitignore +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/LICENSE +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/__init__.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/async_property.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/asyncio_coroutine_decorator.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/bare_except.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/blocking_async.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/collections_abc_import.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/datetime_utcnow.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/discarded_future.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/finally_swallows_exception.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/floating_process.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/floating_task.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/floating_thread.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/floating_timer.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/get_event_loop.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/is_literal_comparison.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/loop_closure_capture.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/lru_cache_on_async_function.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/lru_cache_on_method.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/mutable_class_attribute.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/mutable_defaults.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/removed_getargspec.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/removed_stdlib_attribute.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/removed_stdlib_module.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/resource_leak.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/unclosed_pool.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/unclosed_socket.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/unittest_deprecated_alias.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/unprotected_lock.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/checks/unwaited_subprocess.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/sarif.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/src/codehound/terminal.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/tests/test_checks.py +0 -0
- {codehound-1.7.0 → codehound-1.8.0}/tests/test_output_formats.py +0 -0
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: codehound
|
|
3
|
+
Version: 1.8.0
|
|
4
|
+
Summary: An AST-based static analyzer that hunts real correctness and async-safety bugs in Python code.
|
|
5
|
+
Project-URL: Homepage, https://github.com/kratos0718/codehound
|
|
6
|
+
Project-URL: Issues, https://github.com/kratos0718/codehound/issues
|
|
7
|
+
Author: Abhinav Tarigoppula
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ast,asyncio,bug-finder,linter,static-analysis
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
19
|
+
Requires-Dist: tomli>=2; (python_version < '3.11') and extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
1
22
|
<p align="center">
|
|
2
23
|
<img src="assets/logo.png" alt="codehound" width="220">
|
|
3
24
|
</p>
|
|
@@ -57,6 +78,8 @@ What actually seems to be missing elsewhere, as far as I've been able to find:
|
|
|
57
78
|
|
|
58
79
|
And a difference in kind, not just coverage: every check here is checked against a real corpus, not just reasoned about. [`docs/FINDINGS.md`](docs/FINDINGS.md) has a running ledger of every false positive found while building each check (with the exact framework and line), and two checks that were built, measured, and **rejected outright** when the pattern turned out to be either too common to be a defensible finding (1,911 hits) or premised on something that was actually false (the first two real hits checked turned out to be correct code). I haven't found another static-analysis tool — commercial or open-source — that publishes this kind of "we built it, checked it against real code, and turned it down" ledger. Most tools that market themselves on "catches real bugs" (Greptile, Qodo, CodeRabbit) report an aggregate detection-rate benchmark, not per-rule provenance you can click through to an actual merged fix.
|
|
59
80
|
|
|
81
|
+
**Closing the "toy project" gaps, honestly.** Ruff is a single, fast binary with editor integrations, a plugin-free config file, autofix, and inline suppression - table stakes for a tool people actually adopt, not just admire. `codehound` isn't going to out-perform a Rust tool by staying pure Python, but it now has the parts of that list that don't require rewriting the whole thing: a `[tool.codehound]` block in `pyproject.toml`, `# noqa`/`# noqa: CH001` inline suppression (same syntax flake8/ruff already use, so it doesn't collide with either), `--fix` for the two checks where the rewrite is genuinely unambiguous (CH017 always, CH004 only inside `async def` - guessing wrong on the rest would be worse than not fixing them), and scanning parallelized across a process pool once there's enough files to make that worth it. Measured, not claimed: a full scan of HuggingFace's `transformers` (thousands of files) went from 57 seconds to 12 - verified byte-identical against the sequential result first, not just "seems faster."
|
|
82
|
+
|
|
60
83
|
---
|
|
61
84
|
|
|
62
85
|
## Install
|
|
@@ -65,7 +88,7 @@ And a difference in kind, not just coverage: every check here is checked against
|
|
|
65
88
|
pip install codehound
|
|
66
89
|
```
|
|
67
90
|
|
|
68
|
-
Zero dependencies — it's ~
|
|
91
|
+
Zero dependencies — it's ~4,000 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
|
|
69
92
|
|
|
70
93
|
<details>
|
|
71
94
|
<summary>From a clone instead (for development)</summary>
|
|
@@ -91,6 +114,9 @@ codehound scan file1.py file2.py src/
|
|
|
91
114
|
# only run specific checks
|
|
92
115
|
codehound scan path/to/project --select CH001,CH006
|
|
93
116
|
|
|
117
|
+
# also skip extra directories beyond the built-in defaults
|
|
118
|
+
codehound scan path/to/project --exclude migrations,generated
|
|
119
|
+
|
|
94
120
|
# machine-readable output for CI dashboards
|
|
95
121
|
codehound scan path/to/project --format json
|
|
96
122
|
codehound scan path/to/project --format csv
|
|
@@ -98,6 +124,9 @@ codehound scan path/to/project --format csv
|
|
|
98
124
|
# GitHub Code Scanning (Security tab) can ingest this directly
|
|
99
125
|
codehound scan path/to/project --format sarif > results.sarif
|
|
100
126
|
|
|
127
|
+
# rewrite the fixable findings in place, then report what's left
|
|
128
|
+
codehound scan path/to/project --fix
|
|
129
|
+
|
|
101
130
|
# list every available check
|
|
102
131
|
codehound list
|
|
103
132
|
```
|
|
@@ -108,6 +137,32 @@ codehound list
|
|
|
108
137
|
- run: codehound scan src # fails the build on a regression
|
|
109
138
|
```
|
|
110
139
|
|
|
140
|
+
A finding you've reviewed and want to keep suppresses the same way flake8/ruff findings do - a trailing `# noqa` (everything on that line) or `# noqa: CH001` (just that code):
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
time.sleep(1) # noqa: CH001 - deliberate; this branch only runs at startup, before the loop exists
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Config file
|
|
147
|
+
|
|
148
|
+
Drop defaults into `[tool.codehound]` in `pyproject.toml` so you don't have to repeat flags on every invocation - explicit CLI flags always win over these:
|
|
149
|
+
|
|
150
|
+
```toml
|
|
151
|
+
[tool.codehound]
|
|
152
|
+
select = ["CH001", "CH006"] # same as --select
|
|
153
|
+
exclude = ["migrations"] # extra directories to skip, merged with the built-in defaults
|
|
154
|
+
paths = ["src"] # what `codehound scan` (no path args) scans
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Requires Python 3.11+ to load (uses the standard-library `tomllib`) - on 3.9/3.10 the config file is silently skipped and every flag still works exactly the same via the CLI, since nothing about codehound itself depends on being able to read it.
|
|
158
|
+
|
|
159
|
+
### `--fix`
|
|
160
|
+
|
|
161
|
+
Only two checks ship an autofix, and deliberately so - every other check either needs a judgment call (is this "leak" actually intentional?) or an import that may or may not already be in scope, and guessing wrong there is worse than just reporting the finding:
|
|
162
|
+
|
|
163
|
+
- **CH017** - `collections.<ABC>` → `collections.abc.<ABC>`, a pure rename, always safe.
|
|
164
|
+
- **CH004** - `asyncio.get_event_loop()` → `asyncio.get_running_loop()`, but *only* inside an `async def`. Outside one, `get_running_loop()` raises where `get_event_loop()` wouldn't, so those calls are left as detection-only.
|
|
165
|
+
|
|
111
166
|
### GitHub Action
|
|
112
167
|
|
|
113
168
|
```yaml
|
|
@@ -126,7 +181,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
|
|
|
126
181
|
```yaml
|
|
127
182
|
repos:
|
|
128
183
|
- repo: https://github.com/kratos0718/codehound
|
|
129
|
-
rev: v1.
|
|
184
|
+
rev: v1.8.0
|
|
130
185
|
hooks:
|
|
131
186
|
- id: codehound
|
|
132
187
|
```
|
|
@@ -455,9 +510,18 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
455
510
|
subprocesses, floating timers — CH023-CH028
|
|
456
511
|
- [x] 31 checks — `finally:` blocks that swallow exceptions, `lru_cache`
|
|
457
512
|
on async functions, unclosed `multiprocessing.Pool` — CH029-CH031
|
|
513
|
+
- [x] Inline `# noqa` / `# noqa: CH001` suppression
|
|
514
|
+
- [x] `[tool.codehound]` project config in `pyproject.toml` (`select`,
|
|
515
|
+
`exclude`, `paths` — Python 3.11+ to load, every flag still works
|
|
516
|
+
without it on 3.9/3.10)
|
|
517
|
+
- [x] `--fix` — CH017 always, CH004 only inside `async def` (CH002/CH003
|
|
518
|
+
turned out to need judgment calls or import-injection this tool
|
|
519
|
+
won't guess at, so they stay detection-only; see docs/ARCHITECTURE.md)
|
|
458
520
|
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
459
521
|
- [ ] Extend CH001 to a curated denylist of sync AI/agent SDK client calls inside async functions (vector-DB clients, LLM SDKs) — the gap flake8-async's stdlib-only denylist leaves open
|
|
460
|
-
- [
|
|
522
|
+
- [x] Parallelize scanning across files for large codebases — a full
|
|
523
|
+
HuggingFace transformers scan went from 57s to 12s (measured,
|
|
524
|
+
byte-identical output verified against the sequential run)
|
|
461
525
|
|
|
462
526
|
---
|
|
463
527
|
|
|
@@ -1,23 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: codehound
|
|
3
|
-
Version: 1.7.0
|
|
4
|
-
Summary: An AST-based static analyzer that hunts real correctness and async-safety bugs in Python code.
|
|
5
|
-
Project-URL: Homepage, https://github.com/kratos0718/codehound
|
|
6
|
-
Project-URL: Issues, https://github.com/kratos0718/codehound/issues
|
|
7
|
-
Author: Abhinav Tarigoppula
|
|
8
|
-
License: MIT
|
|
9
|
-
License-File: LICENSE
|
|
10
|
-
Keywords: ast,asyncio,bug-finder,linter,static-analysis
|
|
11
|
-
Classifier: Development Status :: 4 - Beta
|
|
12
|
-
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
-
Classifier: Programming Language :: Python :: 3
|
|
15
|
-
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
|
-
Requires-Python: >=3.9
|
|
17
|
-
Provides-Extra: dev
|
|
18
|
-
Requires-Dist: pytest>=7; extra == 'dev'
|
|
19
|
-
Description-Content-Type: text/markdown
|
|
20
|
-
|
|
21
1
|
<p align="center">
|
|
22
2
|
<img src="assets/logo.png" alt="codehound" width="220">
|
|
23
3
|
</p>
|
|
@@ -77,6 +57,8 @@ What actually seems to be missing elsewhere, as far as I've been able to find:
|
|
|
77
57
|
|
|
78
58
|
And a difference in kind, not just coverage: every check here is checked against a real corpus, not just reasoned about. [`docs/FINDINGS.md`](docs/FINDINGS.md) has a running ledger of every false positive found while building each check (with the exact framework and line), and two checks that were built, measured, and **rejected outright** when the pattern turned out to be either too common to be a defensible finding (1,911 hits) or premised on something that was actually false (the first two real hits checked turned out to be correct code). I haven't found another static-analysis tool — commercial or open-source — that publishes this kind of "we built it, checked it against real code, and turned it down" ledger. Most tools that market themselves on "catches real bugs" (Greptile, Qodo, CodeRabbit) report an aggregate detection-rate benchmark, not per-rule provenance you can click through to an actual merged fix.
|
|
79
59
|
|
|
60
|
+
**Closing the "toy project" gaps, honestly.** Ruff is a single, fast binary with editor integrations, a plugin-free config file, autofix, and inline suppression - table stakes for a tool people actually adopt, not just admire. `codehound` isn't going to out-perform a Rust tool by staying pure Python, but it now has the parts of that list that don't require rewriting the whole thing: a `[tool.codehound]` block in `pyproject.toml`, `# noqa`/`# noqa: CH001` inline suppression (same syntax flake8/ruff already use, so it doesn't collide with either), `--fix` for the two checks where the rewrite is genuinely unambiguous (CH017 always, CH004 only inside `async def` - guessing wrong on the rest would be worse than not fixing them), and scanning parallelized across a process pool once there's enough files to make that worth it. Measured, not claimed: a full scan of HuggingFace's `transformers` (thousands of files) went from 57 seconds to 12 - verified byte-identical against the sequential result first, not just "seems faster."
|
|
61
|
+
|
|
80
62
|
---
|
|
81
63
|
|
|
82
64
|
## Install
|
|
@@ -85,7 +67,7 @@ And a difference in kind, not just coverage: every check here is checked against
|
|
|
85
67
|
pip install codehound
|
|
86
68
|
```
|
|
87
69
|
|
|
88
|
-
Zero dependencies — it's ~
|
|
70
|
+
Zero dependencies — it's ~4,000 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
|
|
89
71
|
|
|
90
72
|
<details>
|
|
91
73
|
<summary>From a clone instead (for development)</summary>
|
|
@@ -111,6 +93,9 @@ codehound scan file1.py file2.py src/
|
|
|
111
93
|
# only run specific checks
|
|
112
94
|
codehound scan path/to/project --select CH001,CH006
|
|
113
95
|
|
|
96
|
+
# also skip extra directories beyond the built-in defaults
|
|
97
|
+
codehound scan path/to/project --exclude migrations,generated
|
|
98
|
+
|
|
114
99
|
# machine-readable output for CI dashboards
|
|
115
100
|
codehound scan path/to/project --format json
|
|
116
101
|
codehound scan path/to/project --format csv
|
|
@@ -118,6 +103,9 @@ codehound scan path/to/project --format csv
|
|
|
118
103
|
# GitHub Code Scanning (Security tab) can ingest this directly
|
|
119
104
|
codehound scan path/to/project --format sarif > results.sarif
|
|
120
105
|
|
|
106
|
+
# rewrite the fixable findings in place, then report what's left
|
|
107
|
+
codehound scan path/to/project --fix
|
|
108
|
+
|
|
121
109
|
# list every available check
|
|
122
110
|
codehound list
|
|
123
111
|
```
|
|
@@ -128,6 +116,32 @@ codehound list
|
|
|
128
116
|
- run: codehound scan src # fails the build on a regression
|
|
129
117
|
```
|
|
130
118
|
|
|
119
|
+
A finding you've reviewed and want to keep suppresses the same way flake8/ruff findings do - a trailing `# noqa` (everything on that line) or `# noqa: CH001` (just that code):
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
time.sleep(1) # noqa: CH001 - deliberate; this branch only runs at startup, before the loop exists
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Config file
|
|
126
|
+
|
|
127
|
+
Drop defaults into `[tool.codehound]` in `pyproject.toml` so you don't have to repeat flags on every invocation - explicit CLI flags always win over these:
|
|
128
|
+
|
|
129
|
+
```toml
|
|
130
|
+
[tool.codehound]
|
|
131
|
+
select = ["CH001", "CH006"] # same as --select
|
|
132
|
+
exclude = ["migrations"] # extra directories to skip, merged with the built-in defaults
|
|
133
|
+
paths = ["src"] # what `codehound scan` (no path args) scans
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Requires Python 3.11+ to load (uses the standard-library `tomllib`) - on 3.9/3.10 the config file is silently skipped and every flag still works exactly the same via the CLI, since nothing about codehound itself depends on being able to read it.
|
|
137
|
+
|
|
138
|
+
### `--fix`
|
|
139
|
+
|
|
140
|
+
Only two checks ship an autofix, and deliberately so - every other check either needs a judgment call (is this "leak" actually intentional?) or an import that may or may not already be in scope, and guessing wrong there is worse than just reporting the finding:
|
|
141
|
+
|
|
142
|
+
- **CH017** - `collections.<ABC>` → `collections.abc.<ABC>`, a pure rename, always safe.
|
|
143
|
+
- **CH004** - `asyncio.get_event_loop()` → `asyncio.get_running_loop()`, but *only* inside an `async def`. Outside one, `get_running_loop()` raises where `get_event_loop()` wouldn't, so those calls are left as detection-only.
|
|
144
|
+
|
|
131
145
|
### GitHub Action
|
|
132
146
|
|
|
133
147
|
```yaml
|
|
@@ -146,7 +160,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
|
|
|
146
160
|
```yaml
|
|
147
161
|
repos:
|
|
148
162
|
- repo: https://github.com/kratos0718/codehound
|
|
149
|
-
rev: v1.
|
|
163
|
+
rev: v1.8.0
|
|
150
164
|
hooks:
|
|
151
165
|
- id: codehound
|
|
152
166
|
```
|
|
@@ -475,9 +489,18 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
475
489
|
subprocesses, floating timers — CH023-CH028
|
|
476
490
|
- [x] 31 checks — `finally:` blocks that swallow exceptions, `lru_cache`
|
|
477
491
|
on async functions, unclosed `multiprocessing.Pool` — CH029-CH031
|
|
492
|
+
- [x] Inline `# noqa` / `# noqa: CH001` suppression
|
|
493
|
+
- [x] `[tool.codehound]` project config in `pyproject.toml` (`select`,
|
|
494
|
+
`exclude`, `paths` — Python 3.11+ to load, every flag still works
|
|
495
|
+
without it on 3.9/3.10)
|
|
496
|
+
- [x] `--fix` — CH017 always, CH004 only inside `async def` (CH002/CH003
|
|
497
|
+
turned out to need judgment calls or import-injection this tool
|
|
498
|
+
won't guess at, so they stay detection-only; see docs/ARCHITECTURE.md)
|
|
478
499
|
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
479
500
|
- [ ] Extend CH001 to a curated denylist of sync AI/agent SDK client calls inside async functions (vector-DB clients, LLM SDKs) — the gap flake8-async's stdlib-only denylist leaves open
|
|
480
|
-
- [
|
|
501
|
+
- [x] Parallelize scanning across files for large codebases — a full
|
|
502
|
+
HuggingFace transformers scan went from 57s to 12s (measured,
|
|
503
|
+
byte-identical output verified against the sequential run)
|
|
481
504
|
|
|
482
505
|
---
|
|
483
506
|
|
|
@@ -28,7 +28,7 @@ Issues = "https://github.com/kratos0718/codehound/issues"
|
|
|
28
28
|
codehound = "codehound.cli:main"
|
|
29
29
|
|
|
30
30
|
[project.optional-dependencies]
|
|
31
|
-
dev = ["pytest>=7"]
|
|
31
|
+
dev = ["pytest>=7", "tomli>=2; python_version < '3.11'"]
|
|
32
32
|
|
|
33
33
|
[tool.hatch.version]
|
|
34
34
|
path = "src/codehound/__init__.py"
|
|
@@ -6,14 +6,20 @@ found and fixed (or opened as a PR) in a popular open-source AI framework
|
|
|
6
6
|
(CH007-CH009, CH012-CH031) are hardening rules verified against real
|
|
7
7
|
false positives across a ~29-framework validation corpus instead - see
|
|
8
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.
|
|
9
15
|
"""
|
|
10
16
|
|
|
11
17
|
from __future__ import annotations
|
|
12
18
|
|
|
13
19
|
from codehound.checks import ALL_CHECKS, get_checks
|
|
14
|
-
from codehound.core import Check, Finding, scan_file, scan_path
|
|
20
|
+
from codehound.core import Check, Finding, scan_file, scan_files, scan_path
|
|
15
21
|
|
|
16
|
-
__version__ = "1.
|
|
22
|
+
__version__ = "1.8.0"
|
|
17
23
|
|
|
18
24
|
__all__ = [
|
|
19
25
|
"ALL_CHECKS",
|
|
@@ -21,6 +27,7 @@ __all__ = [
|
|
|
21
27
|
"Check",
|
|
22
28
|
"Finding",
|
|
23
29
|
"scan_file",
|
|
30
|
+
"scan_files",
|
|
24
31
|
"scan_path",
|
|
25
32
|
"__version__",
|
|
26
33
|
]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Command-line interface: ``codehound scan <path>``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import ast
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from codehound import __version__
|
|
11
|
+
from codehound.checks import ALL_CHECKS, get_checks
|
|
12
|
+
from codehound.config import load_config
|
|
13
|
+
from codehound.core import DEFAULT_SKIP_DIRS, build_parents, iter_python_files, scan_files
|
|
14
|
+
from codehound.fixes import fix_source
|
|
15
|
+
from codehound.sarif import to_sarif
|
|
16
|
+
from codehound.terminal import format_findings_text, format_summary
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _apply_fixes(paths: list[str], skip_dirs: frozenset) -> tuple[int, int]:
|
|
20
|
+
"""Rewrite every fixable finding in place. Returns ``(files_changed, edit_count)``."""
|
|
21
|
+
files_changed = 0
|
|
22
|
+
total_edits = 0
|
|
23
|
+
for root in paths:
|
|
24
|
+
for path in iter_python_files(root, skip_dirs):
|
|
25
|
+
try:
|
|
26
|
+
with open(path, encoding="utf-8") as fh:
|
|
27
|
+
source = fh.read()
|
|
28
|
+
except (OSError, UnicodeDecodeError):
|
|
29
|
+
continue
|
|
30
|
+
try:
|
|
31
|
+
tree = ast.parse(source, filename=path)
|
|
32
|
+
except SyntaxError:
|
|
33
|
+
continue
|
|
34
|
+
parents = build_parents(tree)
|
|
35
|
+
new_source, count = fix_source(source, tree, parents)
|
|
36
|
+
if count:
|
|
37
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
38
|
+
fh.write(new_source)
|
|
39
|
+
files_changed += 1
|
|
40
|
+
total_edits += count
|
|
41
|
+
return files_changed, total_edits
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cmd_scan(args: argparse.Namespace) -> int:
|
|
45
|
+
config = load_config()
|
|
46
|
+
|
|
47
|
+
select_arg = args.select or (",".join(config.select) if config.select else None)
|
|
48
|
+
selected = [s.strip() for s in select_arg.split(",")] if select_arg else None
|
|
49
|
+
checks = get_checks(selected)
|
|
50
|
+
if not checks:
|
|
51
|
+
print(f"No checks matched: {select_arg}", file=sys.stderr)
|
|
52
|
+
return 2
|
|
53
|
+
|
|
54
|
+
paths = args.paths or config.paths or ["."]
|
|
55
|
+
|
|
56
|
+
skip = set(DEFAULT_SKIP_DIRS)
|
|
57
|
+
if args.include_tests:
|
|
58
|
+
skip -= {"tests", "test", "testing"}
|
|
59
|
+
skip |= set(config.exclude)
|
|
60
|
+
if args.exclude:
|
|
61
|
+
skip |= {e.strip() for e in args.exclude.split(",") if e.strip()}
|
|
62
|
+
|
|
63
|
+
if args.fix:
|
|
64
|
+
files_changed, edit_count = _apply_fixes(paths, frozenset(skip))
|
|
65
|
+
if edit_count:
|
|
66
|
+
print(f"Fixed {edit_count} issue(s) in {files_changed} file(s).", file=sys.stderr)
|
|
67
|
+
|
|
68
|
+
all_files: list[str] = []
|
|
69
|
+
for path in paths:
|
|
70
|
+
all_files.extend(iter_python_files(path, frozenset(skip)))
|
|
71
|
+
findings = scan_files(all_files, checks)
|
|
72
|
+
|
|
73
|
+
if args.format == "json":
|
|
74
|
+
print(json.dumps([f.as_dict() for f in findings], indent=2))
|
|
75
|
+
elif args.format == "csv":
|
|
76
|
+
print("path,line,col,code,message")
|
|
77
|
+
for f in findings:
|
|
78
|
+
msg = f.message.replace('"', "'")
|
|
79
|
+
print(f'{f.path},{f.line},{f.col},{f.code},"{msg}"')
|
|
80
|
+
elif args.format == "sarif":
|
|
81
|
+
print(json.dumps(to_sarif(findings, ALL_CHECKS), indent=2))
|
|
82
|
+
else: # text
|
|
83
|
+
for line in format_findings_text(findings):
|
|
84
|
+
print(line)
|
|
85
|
+
print(f"\n{format_summary(findings)}", file=sys.stderr)
|
|
86
|
+
|
|
87
|
+
if findings and not args.exit_zero:
|
|
88
|
+
return 1
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _cmd_list(_args: argparse.Namespace) -> int:
|
|
93
|
+
for cls in ALL_CHECKS:
|
|
94
|
+
print(f"{cls.code} {cls.name}\n {cls.description}")
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
99
|
+
parser = argparse.ArgumentParser(
|
|
100
|
+
prog="codehound",
|
|
101
|
+
description="AST-based static analyzer that hunts real bugs in Python code.",
|
|
102
|
+
)
|
|
103
|
+
parser.add_argument("--version", action="version", version=f"codehound {__version__}")
|
|
104
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
105
|
+
|
|
106
|
+
scan = sub.add_parser("scan", help="scan one or more files/directories for issues")
|
|
107
|
+
scan.add_argument(
|
|
108
|
+
"paths",
|
|
109
|
+
nargs="*",
|
|
110
|
+
metavar="path",
|
|
111
|
+
help=(
|
|
112
|
+
"file(s) or director(y/ies) to scan (accepts multiple, for pre-commit). "
|
|
113
|
+
"Defaults to `paths` in pyproject.toml's [tool.codehound], then `.`"
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
scan.add_argument(
|
|
117
|
+
"--select",
|
|
118
|
+
help=(
|
|
119
|
+
"comma-separated check codes/names to run (default: `select` in "
|
|
120
|
+
"pyproject.toml's [tool.codehound], else all), e.g. CH001,CH006"
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
scan.add_argument(
|
|
124
|
+
"--exclude",
|
|
125
|
+
help=(
|
|
126
|
+
"comma-separated extra directory names to skip, merged with the "
|
|
127
|
+
"built-in defaults and pyproject.toml's [tool.codehound] `exclude`"
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
scan.add_argument(
|
|
131
|
+
"--format",
|
|
132
|
+
choices=["text", "json", "csv", "sarif"],
|
|
133
|
+
default="text",
|
|
134
|
+
help="output format (default: text; sarif for GitHub Code Scanning)",
|
|
135
|
+
)
|
|
136
|
+
scan.add_argument(
|
|
137
|
+
"--include-tests",
|
|
138
|
+
action="store_true",
|
|
139
|
+
help="also scan tests/ directories (skipped by default)",
|
|
140
|
+
)
|
|
141
|
+
scan.add_argument(
|
|
142
|
+
"--exit-zero",
|
|
143
|
+
action="store_true",
|
|
144
|
+
help="always exit 0, even when issues are found",
|
|
145
|
+
)
|
|
146
|
+
scan.add_argument(
|
|
147
|
+
"--fix",
|
|
148
|
+
action="store_true",
|
|
149
|
+
help=(
|
|
150
|
+
"rewrite fixable findings in place before reporting (currently CH017, "
|
|
151
|
+
"and CH004 only inside async functions - see docs/ARCHITECTURE.md)"
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
scan.set_defaults(func=_cmd_scan)
|
|
155
|
+
|
|
156
|
+
listp = sub.add_parser("list", help="list available checks")
|
|
157
|
+
listp.set_defaults(func=_cmd_list)
|
|
158
|
+
|
|
159
|
+
return parser
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main(argv: list[str] | None = None) -> int:
|
|
163
|
+
parser = build_parser()
|
|
164
|
+
args = parser.parse_args(argv)
|
|
165
|
+
return args.func(args)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Project config: ``[tool.codehound]`` in ``pyproject.toml``.
|
|
2
|
+
|
|
3
|
+
Loaded with the standard-library ``tomllib`` (Python 3.11+), falling back
|
|
4
|
+
to the ``tomli`` backport *only if it happens to already be installed*
|
|
5
|
+
(it's listed in the ``dev`` extras so the test suite can exercise config
|
|
6
|
+
loading on 3.9/3.10 too) - never a required install. If neither is
|
|
7
|
+
available, config loading is silently skipped rather than failing: the
|
|
8
|
+
CLI still works identically with explicit flags either way, so a missing
|
|
9
|
+
TOML parser never blocks a scan, it only means the config-file
|
|
10
|
+
convenience isn't available on that Python. This keeps the
|
|
11
|
+
zero-runtime-dependency guarantee: nothing here is ever a hard
|
|
12
|
+
requirement to run `codehound scan`.
|
|
13
|
+
|
|
14
|
+
Recognized keys, all optional:
|
|
15
|
+
|
|
16
|
+
[tool.codehound]
|
|
17
|
+
select = ["CH001", "CH006"] # same as --select
|
|
18
|
+
exclude = ["migrations"] # extra directory names to skip, merged
|
|
19
|
+
# with the built-in DEFAULT_SKIP_DIRS
|
|
20
|
+
paths = ["src"] # default paths when none given on the CLI
|
|
21
|
+
|
|
22
|
+
Explicit CLI flags always take precedence over the config file - the
|
|
23
|
+
config only fills in what wasn't passed on the command line.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import tomllib
|
|
32
|
+
except ImportError: # Python < 3.11
|
|
33
|
+
try:
|
|
34
|
+
import tomli as tomllib # type: ignore[import-not-found,no-redef]
|
|
35
|
+
except ImportError:
|
|
36
|
+
tomllib = None # type: ignore[assignment]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CodehoundConfig:
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
select: list[str] | None = None,
|
|
43
|
+
exclude: list[str] | None = None,
|
|
44
|
+
paths: list[str] | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.select = select
|
|
47
|
+
self.exclude = exclude or []
|
|
48
|
+
self.paths = paths
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_EMPTY = CodehoundConfig()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_config(start_dir: str = ".") -> CodehoundConfig:
|
|
55
|
+
"""Read ``[tool.codehound]`` from ``pyproject.toml`` in ``start_dir``.
|
|
56
|
+
|
|
57
|
+
Returns an empty config (every field ``None``/``[]``) if the file
|
|
58
|
+
doesn't exist, has no ``[tool.codehound]`` table, or ``tomllib`` isn't
|
|
59
|
+
available on this Python version - never raises for any of those.
|
|
60
|
+
"""
|
|
61
|
+
if tomllib is None:
|
|
62
|
+
return _EMPTY
|
|
63
|
+
|
|
64
|
+
pyproject_path = os.path.join(start_dir, "pyproject.toml")
|
|
65
|
+
if not os.path.isfile(pyproject_path):
|
|
66
|
+
return _EMPTY
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
with open(pyproject_path, "rb") as fh:
|
|
70
|
+
data = tomllib.load(fh)
|
|
71
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
72
|
+
return _EMPTY
|
|
73
|
+
|
|
74
|
+
table = data.get("tool", {}).get("codehound", {})
|
|
75
|
+
if not isinstance(table, dict):
|
|
76
|
+
return _EMPTY
|
|
77
|
+
|
|
78
|
+
select = table.get("select")
|
|
79
|
+
exclude = table.get("exclude")
|
|
80
|
+
paths = table.get("paths")
|
|
81
|
+
return CodehoundConfig(
|
|
82
|
+
select=list(select) if isinstance(select, list) else None,
|
|
83
|
+
exclude=list(exclude) if isinstance(exclude, list) else None,
|
|
84
|
+
paths=list(paths) if isinstance(paths, list) else None,
|
|
85
|
+
)
|
|
@@ -9,9 +9,16 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import ast
|
|
11
11
|
import os
|
|
12
|
+
import re
|
|
13
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
12
14
|
from dataclasses import dataclass
|
|
13
15
|
from typing import Iterator
|
|
14
16
|
|
|
17
|
+
# Below this many files, a process pool's startup cost isn't worth it -
|
|
18
|
+
# sequential scanning wins on small inputs (a handful of files, which is
|
|
19
|
+
# also exactly pre-commit's typical invocation shape).
|
|
20
|
+
_MIN_FILES_FOR_PARALLEL = 16
|
|
21
|
+
|
|
15
22
|
|
|
16
23
|
# Directories we never want to descend into. Third-party and generated code is
|
|
17
24
|
# not ours to fix, and test/example dirs deliberately contain "bad" patterns.
|
|
@@ -181,6 +188,44 @@ def attr_call_parts(node: ast.AST):
|
|
|
181
188
|
return None, attr
|
|
182
189
|
|
|
183
190
|
|
|
191
|
+
# --- Inline suppression (# noqa) --------------------------------------------------
|
|
192
|
+
|
|
193
|
+
_NOQA_RE = re.compile(r"#\s*noqa\b(?::\s*(?P<codes>[A-Za-z0-9_, ]+))?", re.IGNORECASE)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parse_noqa_lines(source: str) -> dict[int, set[str] | None]:
|
|
197
|
+
"""Map 1-indexed line number -> suppressed codes, or ``None`` for a bare
|
|
198
|
+
``# noqa`` that suppresses every finding on that line.
|
|
199
|
+
|
|
200
|
+
Same convention flake8/ruff use: a trailing ``# noqa`` (optionally
|
|
201
|
+
``# noqa: CH001,CH002``) on the offending physical line. This is a
|
|
202
|
+
text-level scan, not an AST one - a real ``# noqa`` inside a string
|
|
203
|
+
literal would false-suppress, but that's the same trade-off every
|
|
204
|
+
other tool using this convention already makes, and matching their
|
|
205
|
+
exact syntax means a codebase can suppress codehound and flake8/ruff
|
|
206
|
+
findings side by side on the same line.
|
|
207
|
+
"""
|
|
208
|
+
suppressed: dict[int, set[str] | None] = {}
|
|
209
|
+
for lineno, line in enumerate(source.splitlines(), start=1):
|
|
210
|
+
match = _NOQA_RE.search(line)
|
|
211
|
+
if match is None:
|
|
212
|
+
continue
|
|
213
|
+
codes_text = match.group("codes")
|
|
214
|
+
if codes_text is None:
|
|
215
|
+
suppressed[lineno] = None
|
|
216
|
+
else:
|
|
217
|
+
codes = {c.strip().upper() for c in re.split(r"[,\s]+", codes_text) if c.strip()}
|
|
218
|
+
suppressed[lineno] = codes
|
|
219
|
+
return suppressed
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _is_suppressed(finding: Finding, noqa_lines: dict[int, set[str] | None]) -> bool:
|
|
223
|
+
if finding.line not in noqa_lines:
|
|
224
|
+
return False
|
|
225
|
+
codes = noqa_lines[finding.line]
|
|
226
|
+
return codes is None or finding.code.upper() in codes
|
|
227
|
+
|
|
228
|
+
|
|
184
229
|
# --- File discovery and orchestration ---------------------------------------------
|
|
185
230
|
|
|
186
231
|
|
|
@@ -210,16 +255,48 @@ def scan_file(path: str, checks: list[Check]) -> list[Finding]:
|
|
|
210
255
|
findings: list[Finding] = []
|
|
211
256
|
for check in checks:
|
|
212
257
|
findings.extend(check.run(tree, parents, path))
|
|
258
|
+
noqa_lines = _parse_noqa_lines(source)
|
|
259
|
+
if noqa_lines:
|
|
260
|
+
findings = [f for f in findings if not _is_suppressed(f, noqa_lines)]
|
|
213
261
|
return findings
|
|
214
262
|
|
|
215
263
|
|
|
216
|
-
def
|
|
217
|
-
|
|
264
|
+
def _scan_file_worker(args: tuple[str, list[Check]]) -> list[Finding]:
|
|
265
|
+
path, checks = args
|
|
266
|
+
return scan_file(path, checks)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def scan_files(
|
|
270
|
+
paths: list[str],
|
|
218
271
|
checks: list[Check],
|
|
219
|
-
|
|
272
|
+
workers: int | None = None,
|
|
220
273
|
) -> list[Finding]:
|
|
274
|
+
"""Scan an already-expanded list of file paths (no directory walking),
|
|
275
|
+
in parallel across a process pool once there are enough files to make
|
|
276
|
+
that worthwhile - one call, one pool, regardless of how many separate
|
|
277
|
+
root paths a CLI invocation was given.
|
|
278
|
+
"""
|
|
279
|
+
if not paths:
|
|
280
|
+
return []
|
|
281
|
+
if workers is None:
|
|
282
|
+
workers = os.cpu_count() or 1
|
|
283
|
+
|
|
221
284
|
findings: list[Finding] = []
|
|
222
|
-
|
|
223
|
-
|
|
285
|
+
if workers <= 1 or len(paths) < _MIN_FILES_FOR_PARALLEL:
|
|
286
|
+
for path in paths:
|
|
287
|
+
findings.extend(scan_file(path, checks))
|
|
288
|
+
else:
|
|
289
|
+
with ProcessPoolExecutor(max_workers=workers) as executor:
|
|
290
|
+
for result in executor.map(_scan_file_worker, [(p, checks) for p in paths]):
|
|
291
|
+
findings.extend(result)
|
|
224
292
|
findings.sort(key=lambda f: (f.path, f.line, f.col, f.code))
|
|
225
293
|
return findings
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def scan_path(
|
|
297
|
+
root: str,
|
|
298
|
+
checks: list[Check],
|
|
299
|
+
skip_dirs: frozenset = DEFAULT_SKIP_DIRS,
|
|
300
|
+
workers: int | None = None,
|
|
301
|
+
) -> list[Finding]:
|
|
302
|
+
return scan_files(list(iter_python_files(root, skip_dirs)), checks, workers=workers)
|