codehound 1.1.0__tar.gz → 1.3.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.1.0 → codehound-1.3.0}/PKG-INFO +108 -23
- {codehound-1.1.0 → codehound-1.3.0}/README.md +107 -22
- {codehound-1.1.0 → codehound-1.3.0}/src/codehound/__init__.py +4 -4
- {codehound-1.1.0 → codehound-1.3.0}/src/codehound/checks/__init__.py +6 -0
- codehound-1.3.0/src/codehound/checks/asyncio_run_in_loop.py +62 -0
- codehound-1.3.0/src/codehound/checks/floating_thread.py +159 -0
- codehound-1.3.0/src/codehound/checks/loop_closure_capture.py +123 -0
- {codehound-1.1.0 → codehound-1.3.0}/src/codehound/cli.py +20 -16
- codehound-1.3.0/src/codehound/sarif.py +67 -0
- codehound-1.3.0/src/codehound/terminal.py +57 -0
- {codehound-1.1.0 → codehound-1.3.0}/tests/test_checks.py +187 -0
- codehound-1.3.0/tests/test_output_formats.py +74 -0
- {codehound-1.1.0 → codehound-1.3.0}/.gitignore +0 -0
- {codehound-1.1.0 → codehound-1.3.0}/LICENSE +0 -0
- {codehound-1.1.0 → codehound-1.3.0}/pyproject.toml +0 -0
- {codehound-1.1.0 → codehound-1.3.0}/src/codehound/checks/blocking_async.py +0 -0
- {codehound-1.1.0 → codehound-1.3.0}/src/codehound/checks/datetime_utcnow.py +0 -0
- {codehound-1.1.0 → codehound-1.3.0}/src/codehound/checks/floating_task.py +0 -0
- {codehound-1.1.0 → codehound-1.3.0}/src/codehound/checks/get_event_loop.py +0 -0
- {codehound-1.1.0 → codehound-1.3.0}/src/codehound/checks/mutable_defaults.py +0 -0
- {codehound-1.1.0 → codehound-1.3.0}/src/codehound/checks/resource_leak.py +0 -0
- {codehound-1.1.0 → codehound-1.3.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
- {codehound-1.1.0 → codehound-1.3.0}/src/codehound/core.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: codehound
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.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
|
|
@@ -18,9 +18,13 @@ Provides-Extra: dev
|
|
|
18
18
|
Requires-Dist: pytest>=7; extra == 'dev'
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
<p align="center">
|
|
22
|
+
<img src="assets/logo.png" alt="codehound" width="220">
|
|
23
|
+
</p>
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
<h1 align="center">codehound</h1>
|
|
26
|
+
|
|
27
|
+
**An AST-based static analyzer that hunts *real* bugs in large Python codebases — seven of the ten rules are backed by a bug that was actually found and merged into a major open-source AI framework; the other three are hardening rules verified against real false positives instead.**
|
|
24
28
|
|
|
25
29
|
[](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
|
|
26
30
|
[](https://pypi.org/project/codehound/)
|
|
@@ -87,6 +91,9 @@ PYTHONPATH=src python -m codehound.cli scan path/to/project
|
|
|
87
91
|
# scan a project (skips tests/, docs/, examples/, vendored code by default)
|
|
88
92
|
codehound scan path/to/project
|
|
89
93
|
|
|
94
|
+
# scan multiple files/directories in one invocation (what pre-commit does)
|
|
95
|
+
codehound scan file1.py file2.py src/
|
|
96
|
+
|
|
90
97
|
# only run specific checks
|
|
91
98
|
codehound scan path/to/project --select CH001,CH006
|
|
92
99
|
|
|
@@ -94,6 +101,9 @@ codehound scan path/to/project --select CH001,CH006
|
|
|
94
101
|
codehound scan path/to/project --format json
|
|
95
102
|
codehound scan path/to/project --format csv
|
|
96
103
|
|
|
104
|
+
# GitHub Code Scanning (Security tab) can ingest this directly
|
|
105
|
+
codehound scan path/to/project --format sarif > results.sarif
|
|
106
|
+
|
|
97
107
|
# list every available check
|
|
98
108
|
codehound list
|
|
99
109
|
```
|
|
@@ -104,6 +114,29 @@ codehound list
|
|
|
104
114
|
- run: codehound scan src # fails the build on a regression
|
|
105
115
|
```
|
|
106
116
|
|
|
117
|
+
### GitHub Action
|
|
118
|
+
|
|
119
|
+
```yaml
|
|
120
|
+
- uses: kratos0718/codehound@v1.2.0
|
|
121
|
+
with:
|
|
122
|
+
path: src
|
|
123
|
+
# select: CH001,CH006 # optional, defaults to all checks
|
|
124
|
+
# fail-on-findings: "false" # optional, report without failing the build
|
|
125
|
+
# upload-sarif: "false" # optional, skip the Code Scanning upload
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in addition to failing the step (unless `fail-on-findings: "false"`).
|
|
129
|
+
|
|
130
|
+
### pre-commit
|
|
131
|
+
|
|
132
|
+
```yaml
|
|
133
|
+
repos:
|
|
134
|
+
- repo: https://github.com/kratos0718/codehound
|
|
135
|
+
rev: v1.2.0
|
|
136
|
+
hooks:
|
|
137
|
+
- id: codehound
|
|
138
|
+
```
|
|
139
|
+
|
|
107
140
|
---
|
|
108
141
|
|
|
109
142
|
## The checks
|
|
@@ -117,24 +150,64 @@ codehound list
|
|
|
117
150
|
| **CH005** | `unclosed-file-handle` | `f = open(...)` with no `with` and no matching `.close()` — leaks descriptors until `RLIMIT_NOFILE` is exhausted. | agno `OpenAITools.transcribe_audio` |
|
|
118
151
|
| **CH006** | `floating-task` | `asyncio.create_task(...)` whose result is discarded — the loop keeps only a *weak* reference, so the task can be GC'd before it finishes. (Ruff RUF006) | hardening rule — the most under-caught async bug |
|
|
119
152
|
| **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 |
|
|
153
|
+
| **CH008** | `asyncio-run-in-running-loop` | `asyncio.run(...)` called from inside an `async def` — always raises `RuntimeError`, immediately, every time. | hardening rule — zero corpus hits (see below) |
|
|
154
|
+
| **CH009** | `floating-thread` | A non-daemon `threading.Thread` that's `.start()`ed but never `.join()`ed — the thread analog of CH006. | hardening rule — see below |
|
|
155
|
+
| **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 |
|
|
120
156
|
|
|
121
157
|
`codehound list` prints this from the source of truth.
|
|
122
158
|
|
|
123
|
-
CH007
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
`
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
159
|
+
CH007-CH010 don't have found-and-merged bugs behind all of them the way
|
|
160
|
+
CH001-CH006 do - three are hardening rules for well-known Python
|
|
161
|
+
correctness gotchas rather than something this project personally
|
|
162
|
+
tracked down first. CH010 is the exception: it found a genuine, serious
|
|
163
|
+
bug on its own, in HuggingFace's `accelerate` - see below. Building all
|
|
164
|
+
four surfaced real false positives, each one fixed before shipping:
|
|
165
|
+
|
|
166
|
+
- **CH007** (agno): a bare `self.foo()` call matched against an unrelated
|
|
167
|
+
same-named `async def foo` on a *different* class (agno's own
|
|
168
|
+
sync/async "twin method" convention, e.g. `ZepTools`/`ZepAsyncTools`),
|
|
169
|
+
and a plain callable parameter shadowed by an unrelated same-named
|
|
170
|
+
async function hundreds of lines away in the same file.
|
|
171
|
+
- **CH009** (llama_index): a thread handed off through a *different*
|
|
172
|
+
object's attribute, not `self` - `chat_response.write_response_to_history_thread
|
|
173
|
+
= thread`, with `chat_response` itself returned and the thread joined
|
|
174
|
+
later once the caller finishes consuming the stream.
|
|
175
|
+
- **CH010** (marimo): `sorted(rows, key=lambda row: row[sort_arg.by])`
|
|
176
|
+
inside `for sort_arg in ...` - the lambda references the loop variable,
|
|
177
|
+
but `sorted()` calls it *immediately*, synchronously, before the next
|
|
178
|
+
iteration moves `sort_arg` on. Nothing outlives the iteration. This
|
|
179
|
+
reshaped the check entirely: it now only fires when a lambda is
|
|
180
|
+
directly *stored* (`.append(...)`, assignment, `return`), not merely
|
|
181
|
+
passed as a callback argument to something that consumes it on the spot.
|
|
182
|
+
|
|
183
|
+
**The `accelerate` find (CH010):** `MegatronEngine.get_module_config`
|
|
184
|
+
builds one callback per model chunk for distributed-training parameter
|
|
185
|
+
sync: `[lambda x: self.optimizer.finish_param_sync(model_index, x) for
|
|
186
|
+
model_index in range(len(self.module))]`. Every lambda captures
|
|
187
|
+
`model_index` by reference; by the time any of them actually runs, the
|
|
188
|
+
comprehension has finished and `model_index` holds its final value for
|
|
189
|
+
*all* of them - whichever chunk's callback fires, it reports the
|
|
190
|
+
*last* chunk's index. Fixed with the standard default-argument capture
|
|
191
|
+
(`model_index=model_index`) and a regression test that fails on the
|
|
192
|
+
pre-fix code (all three callbacks report index 2) and passes on the fix.
|
|
193
|
+
PR: [huggingface/accelerate#4273](https://github.com/huggingface/accelerate/pull/4273).
|
|
194
|
+
|
|
195
|
+
Scanning ~20 major Python AI/ML frameworks with the fixed CH007/CH008/CH009
|
|
196
|
+
turned up zero further real instances beyond the ones above - itself a
|
|
197
|
+
result, not a null: CH008's bug fails immediately and unconditionally, so
|
|
198
|
+
it's very unlikely to survive basic testing; CH007 and CH009 both only
|
|
199
|
+
match same-file names by design, and most real cases of either are
|
|
200
|
+
plausibly cross-module.
|
|
201
|
+
|
|
202
|
+
**One check we built and did not ship: CH011 `exception-chaining`**
|
|
203
|
+
(`except X as e: raise Y(...)` with no `from e`, discarding the real
|
|
204
|
+
traceback - overlaps flake8-bugbear B904). It worked exactly as designed,
|
|
205
|
+
but at a scale that says more about how common the pattern is than about
|
|
206
|
+
anything worth flagging: **1,911 hits across the same ~20-framework
|
|
207
|
+
corpus**. Shipping a check that fires that often would make every scan
|
|
208
|
+
result mostly CH011 noise, undermining the "a finding must be defensible"
|
|
209
|
+
standard the rest of this tool holds itself to. Built, measured, and
|
|
210
|
+
deliberately left out - a real decision, not an oversight.
|
|
138
211
|
|
|
139
212
|
---
|
|
140
213
|
|
|
@@ -145,7 +218,9 @@ codehound/
|
|
|
145
218
|
├── core.py # file discovery, AST parsing, the Finding/Check contract,
|
|
146
219
|
│ # and a child→parent map so checks can ask "what's my
|
|
147
220
|
│ # enclosing function / am I inside a `with`?"
|
|
148
|
-
├── cli.py # `scan` / `list`, text|json|csv output, CI-friendly exit codes
|
|
221
|
+
├── cli.py # `scan` / `list`, text|json|csv|sarif output, CI-friendly exit codes
|
|
222
|
+
├── sarif.py # SARIF 2.1.0 output for GitHub Code Scanning
|
|
223
|
+
├── terminal.py # colored text output (auto-disabled for non-TTY / NO_COLOR)
|
|
149
224
|
└── checks/ # one small, independently-tested class per rule
|
|
150
225
|
├── blocking_async.py (CH001)
|
|
151
226
|
├── mutable_defaults.py (CH002)
|
|
@@ -153,12 +228,15 @@ codehound/
|
|
|
153
228
|
├── get_event_loop.py (CH004)
|
|
154
229
|
├── resource_leak.py (CH005)
|
|
155
230
|
├── floating_task.py (CH006)
|
|
156
|
-
|
|
231
|
+
├── unawaited_coroutine.py (CH007)
|
|
232
|
+
├── asyncio_run_in_loop.py (CH008)
|
|
233
|
+
├── floating_thread.py (CH009)
|
|
234
|
+
└── loop_closure_capture.py (CH010)
|
|
157
235
|
```
|
|
158
236
|
|
|
159
237
|
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.
|
|
160
238
|
|
|
161
|
-
**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
|
|
239
|
+
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. All four of those guards exist because of real false positives caught while building the checks (see above). The test suite asserts both "bad code is flagged" and "correct code is not."
|
|
162
240
|
|
|
163
241
|
---
|
|
164
242
|
|
|
@@ -177,10 +255,17 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
177
255
|
|
|
178
256
|
- [x] `await` on a non-awaited coroutine (missing-await detection) — CH007
|
|
179
257
|
- [x] PyPI release — `pip install codehound`
|
|
180
|
-
- [
|
|
258
|
+
- [x] `asyncio.run()` inside a running loop — CH008
|
|
259
|
+
- [x] Non-daemon thread started without a join — CH009 (the thread analog of CH006)
|
|
260
|
+
- [x] Loop-variable closure capture in lambdas — CH010
|
|
261
|
+
- [x] Pre-commit hook — `.pre-commit-hooks.yaml`
|
|
262
|
+
- [x] GitHub Action — `action.yml`, uploads SARIF to Code Scanning
|
|
263
|
+
- [x] SARIF output — `--format sarif`
|
|
264
|
+
- [x] Colored terminal output (auto-disabled for non-TTY / `NO_COLOR`)
|
|
265
|
+
- [x] Multi-path `scan` invocation (what the pre-commit hook needs)
|
|
266
|
+
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
181
267
|
- [ ] Sync HTTP clients constructed inside async request handlers
|
|
182
268
|
- [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
|
|
183
|
-
- [ ] Pre-commit hook
|
|
184
269
|
|
|
185
270
|
---
|
|
186
271
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/logo.png" alt="codehound" width="220">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<h1 align="center">codehound</h1>
|
|
6
|
+
|
|
7
|
+
**An AST-based static analyzer that hunts *real* bugs in large Python codebases — seven of the ten rules are backed by a bug that was actually found and merged into a major open-source AI framework; the other three are hardening rules verified against real false positives instead.**
|
|
4
8
|
|
|
5
9
|
[](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
|
|
6
10
|
[](https://pypi.org/project/codehound/)
|
|
@@ -67,6 +71,9 @@ PYTHONPATH=src python -m codehound.cli scan path/to/project
|
|
|
67
71
|
# scan a project (skips tests/, docs/, examples/, vendored code by default)
|
|
68
72
|
codehound scan path/to/project
|
|
69
73
|
|
|
74
|
+
# scan multiple files/directories in one invocation (what pre-commit does)
|
|
75
|
+
codehound scan file1.py file2.py src/
|
|
76
|
+
|
|
70
77
|
# only run specific checks
|
|
71
78
|
codehound scan path/to/project --select CH001,CH006
|
|
72
79
|
|
|
@@ -74,6 +81,9 @@ codehound scan path/to/project --select CH001,CH006
|
|
|
74
81
|
codehound scan path/to/project --format json
|
|
75
82
|
codehound scan path/to/project --format csv
|
|
76
83
|
|
|
84
|
+
# GitHub Code Scanning (Security tab) can ingest this directly
|
|
85
|
+
codehound scan path/to/project --format sarif > results.sarif
|
|
86
|
+
|
|
77
87
|
# list every available check
|
|
78
88
|
codehound list
|
|
79
89
|
```
|
|
@@ -84,6 +94,29 @@ codehound list
|
|
|
84
94
|
- run: codehound scan src # fails the build on a regression
|
|
85
95
|
```
|
|
86
96
|
|
|
97
|
+
### GitHub Action
|
|
98
|
+
|
|
99
|
+
```yaml
|
|
100
|
+
- uses: kratos0718/codehound@v1.2.0
|
|
101
|
+
with:
|
|
102
|
+
path: src
|
|
103
|
+
# select: CH001,CH006 # optional, defaults to all checks
|
|
104
|
+
# fail-on-findings: "false" # optional, report without failing the build
|
|
105
|
+
# upload-sarif: "false" # optional, skip the Code Scanning upload
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in addition to failing the step (unless `fail-on-findings: "false"`).
|
|
109
|
+
|
|
110
|
+
### pre-commit
|
|
111
|
+
|
|
112
|
+
```yaml
|
|
113
|
+
repos:
|
|
114
|
+
- repo: https://github.com/kratos0718/codehound
|
|
115
|
+
rev: v1.2.0
|
|
116
|
+
hooks:
|
|
117
|
+
- id: codehound
|
|
118
|
+
```
|
|
119
|
+
|
|
87
120
|
---
|
|
88
121
|
|
|
89
122
|
## The checks
|
|
@@ -97,24 +130,64 @@ codehound list
|
|
|
97
130
|
| **CH005** | `unclosed-file-handle` | `f = open(...)` with no `with` and no matching `.close()` — leaks descriptors until `RLIMIT_NOFILE` is exhausted. | agno `OpenAITools.transcribe_audio` |
|
|
98
131
|
| **CH006** | `floating-task` | `asyncio.create_task(...)` whose result is discarded — the loop keeps only a *weak* reference, so the task can be GC'd before it finishes. (Ruff RUF006) | hardening rule — the most under-caught async bug |
|
|
99
132
|
| **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 |
|
|
133
|
+
| **CH008** | `asyncio-run-in-running-loop` | `asyncio.run(...)` called from inside an `async def` — always raises `RuntimeError`, immediately, every time. | hardening rule — zero corpus hits (see below) |
|
|
134
|
+
| **CH009** | `floating-thread` | A non-daemon `threading.Thread` that's `.start()`ed but never `.join()`ed — the thread analog of CH006. | hardening rule — see below |
|
|
135
|
+
| **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 |
|
|
100
136
|
|
|
101
137
|
`codehound list` prints this from the source of truth.
|
|
102
138
|
|
|
103
|
-
CH007
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
`
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
139
|
+
CH007-CH010 don't have found-and-merged bugs behind all of them the way
|
|
140
|
+
CH001-CH006 do - three are hardening rules for well-known Python
|
|
141
|
+
correctness gotchas rather than something this project personally
|
|
142
|
+
tracked down first. CH010 is the exception: it found a genuine, serious
|
|
143
|
+
bug on its own, in HuggingFace's `accelerate` - see below. Building all
|
|
144
|
+
four surfaced real false positives, each one fixed before shipping:
|
|
145
|
+
|
|
146
|
+
- **CH007** (agno): a bare `self.foo()` call matched against an unrelated
|
|
147
|
+
same-named `async def foo` on a *different* class (agno's own
|
|
148
|
+
sync/async "twin method" convention, e.g. `ZepTools`/`ZepAsyncTools`),
|
|
149
|
+
and a plain callable parameter shadowed by an unrelated same-named
|
|
150
|
+
async function hundreds of lines away in the same file.
|
|
151
|
+
- **CH009** (llama_index): a thread handed off through a *different*
|
|
152
|
+
object's attribute, not `self` - `chat_response.write_response_to_history_thread
|
|
153
|
+
= thread`, with `chat_response` itself returned and the thread joined
|
|
154
|
+
later once the caller finishes consuming the stream.
|
|
155
|
+
- **CH010** (marimo): `sorted(rows, key=lambda row: row[sort_arg.by])`
|
|
156
|
+
inside `for sort_arg in ...` - the lambda references the loop variable,
|
|
157
|
+
but `sorted()` calls it *immediately*, synchronously, before the next
|
|
158
|
+
iteration moves `sort_arg` on. Nothing outlives the iteration. This
|
|
159
|
+
reshaped the check entirely: it now only fires when a lambda is
|
|
160
|
+
directly *stored* (`.append(...)`, assignment, `return`), not merely
|
|
161
|
+
passed as a callback argument to something that consumes it on the spot.
|
|
162
|
+
|
|
163
|
+
**The `accelerate` find (CH010):** `MegatronEngine.get_module_config`
|
|
164
|
+
builds one callback per model chunk for distributed-training parameter
|
|
165
|
+
sync: `[lambda x: self.optimizer.finish_param_sync(model_index, x) for
|
|
166
|
+
model_index in range(len(self.module))]`. Every lambda captures
|
|
167
|
+
`model_index` by reference; by the time any of them actually runs, the
|
|
168
|
+
comprehension has finished and `model_index` holds its final value for
|
|
169
|
+
*all* of them - whichever chunk's callback fires, it reports the
|
|
170
|
+
*last* chunk's index. Fixed with the standard default-argument capture
|
|
171
|
+
(`model_index=model_index`) and a regression test that fails on the
|
|
172
|
+
pre-fix code (all three callbacks report index 2) and passes on the fix.
|
|
173
|
+
PR: [huggingface/accelerate#4273](https://github.com/huggingface/accelerate/pull/4273).
|
|
174
|
+
|
|
175
|
+
Scanning ~20 major Python AI/ML frameworks with the fixed CH007/CH008/CH009
|
|
176
|
+
turned up zero further real instances beyond the ones above - itself a
|
|
177
|
+
result, not a null: CH008's bug fails immediately and unconditionally, so
|
|
178
|
+
it's very unlikely to survive basic testing; CH007 and CH009 both only
|
|
179
|
+
match same-file names by design, and most real cases of either are
|
|
180
|
+
plausibly cross-module.
|
|
181
|
+
|
|
182
|
+
**One check we built and did not ship: CH011 `exception-chaining`**
|
|
183
|
+
(`except X as e: raise Y(...)` with no `from e`, discarding the real
|
|
184
|
+
traceback - overlaps flake8-bugbear B904). It worked exactly as designed,
|
|
185
|
+
but at a scale that says more about how common the pattern is than about
|
|
186
|
+
anything worth flagging: **1,911 hits across the same ~20-framework
|
|
187
|
+
corpus**. Shipping a check that fires that often would make every scan
|
|
188
|
+
result mostly CH011 noise, undermining the "a finding must be defensible"
|
|
189
|
+
standard the rest of this tool holds itself to. Built, measured, and
|
|
190
|
+
deliberately left out - a real decision, not an oversight.
|
|
118
191
|
|
|
119
192
|
---
|
|
120
193
|
|
|
@@ -125,7 +198,9 @@ codehound/
|
|
|
125
198
|
├── core.py # file discovery, AST parsing, the Finding/Check contract,
|
|
126
199
|
│ # and a child→parent map so checks can ask "what's my
|
|
127
200
|
│ # enclosing function / am I inside a `with`?"
|
|
128
|
-
├── cli.py # `scan` / `list`, text|json|csv output, CI-friendly exit codes
|
|
201
|
+
├── cli.py # `scan` / `list`, text|json|csv|sarif output, CI-friendly exit codes
|
|
202
|
+
├── sarif.py # SARIF 2.1.0 output for GitHub Code Scanning
|
|
203
|
+
├── terminal.py # colored text output (auto-disabled for non-TTY / NO_COLOR)
|
|
129
204
|
└── checks/ # one small, independently-tested class per rule
|
|
130
205
|
├── blocking_async.py (CH001)
|
|
131
206
|
├── mutable_defaults.py (CH002)
|
|
@@ -133,12 +208,15 @@ codehound/
|
|
|
133
208
|
├── get_event_loop.py (CH004)
|
|
134
209
|
├── resource_leak.py (CH005)
|
|
135
210
|
├── floating_task.py (CH006)
|
|
136
|
-
|
|
211
|
+
├── unawaited_coroutine.py (CH007)
|
|
212
|
+
├── asyncio_run_in_loop.py (CH008)
|
|
213
|
+
├── floating_thread.py (CH009)
|
|
214
|
+
└── loop_closure_capture.py (CH010)
|
|
137
215
|
```
|
|
138
216
|
|
|
139
217
|
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.
|
|
140
218
|
|
|
141
|
-
**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
|
|
219
|
+
**False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. All four of those guards exist because of real false positives caught while building the checks (see above). The test suite asserts both "bad code is flagged" and "correct code is not."
|
|
142
220
|
|
|
143
221
|
---
|
|
144
222
|
|
|
@@ -157,10 +235,17 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
|
|
|
157
235
|
|
|
158
236
|
- [x] `await` on a non-awaited coroutine (missing-await detection) — CH007
|
|
159
237
|
- [x] PyPI release — `pip install codehound`
|
|
160
|
-
- [
|
|
238
|
+
- [x] `asyncio.run()` inside a running loop — CH008
|
|
239
|
+
- [x] Non-daemon thread started without a join — CH009 (the thread analog of CH006)
|
|
240
|
+
- [x] Loop-variable closure capture in lambdas — CH010
|
|
241
|
+
- [x] Pre-commit hook — `.pre-commit-hooks.yaml`
|
|
242
|
+
- [x] GitHub Action — `action.yml`, uploads SARIF to Code Scanning
|
|
243
|
+
- [x] SARIF output — `--format sarif`
|
|
244
|
+
- [x] Colored terminal output (auto-disabled for non-TTY / `NO_COLOR`)
|
|
245
|
+
- [x] Multi-path `scan` invocation (what the pre-commit hook needs)
|
|
246
|
+
- [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
|
|
161
247
|
- [ ] Sync HTTP clients constructed inside async request handlers
|
|
162
248
|
- [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
|
|
163
|
-
- [ ] Pre-commit hook
|
|
164
249
|
|
|
165
250
|
---
|
|
166
251
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"""codehound - an AST-based static analyzer that hunts real bugs in Python code.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Ten checks. Seven are each backed by a bug that was actually found and
|
|
4
4
|
fixed in a popular open-source AI framework (agno, crewAI, mem0,
|
|
5
|
-
llama_index,
|
|
6
|
-
verified against real false positives instead - see docs/FINDINGS.md.
|
|
5
|
+
llama_index, accelerate). The other three (CH007-CH009) are hardening
|
|
6
|
+
rules verified against real false positives instead - see docs/FINDINGS.md.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
from codehound.checks import ALL_CHECKS, get_checks
|
|
12
12
|
from codehound.core import Check, Finding, scan_file, scan_path
|
|
13
13
|
|
|
14
|
-
__version__ = "1.
|
|
14
|
+
__version__ = "1.3.0"
|
|
15
15
|
|
|
16
16
|
__all__ = [
|
|
17
17
|
"ALL_CHECKS",
|
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from codehound.checks.asyncio_run_in_loop import AsyncioRunInRunningLoop
|
|
5
6
|
from codehound.checks.blocking_async import BlockingCallInAsync
|
|
6
7
|
from codehound.checks.datetime_utcnow import DeprecatedDatetimeUtcnow
|
|
7
8
|
from codehound.checks.floating_task import FloatingTask
|
|
9
|
+
from codehound.checks.floating_thread import FloatingThread
|
|
8
10
|
from codehound.checks.get_event_loop import DeprecatedGetEventLoop
|
|
11
|
+
from codehound.checks.loop_closure_capture import LoopClosureCapture
|
|
9
12
|
from codehound.checks.mutable_defaults import MutableDefaultArgument
|
|
10
13
|
from codehound.checks.resource_leak import UnclosedFileHandle
|
|
11
14
|
from codehound.checks.unawaited_coroutine import UnawaitedCoroutineCall
|
|
@@ -19,6 +22,9 @@ ALL_CHECKS: list[type[Check]] = [
|
|
|
19
22
|
UnclosedFileHandle,
|
|
20
23
|
FloatingTask,
|
|
21
24
|
UnawaitedCoroutineCall,
|
|
25
|
+
AsyncioRunInRunningLoop,
|
|
26
|
+
FloatingThread,
|
|
27
|
+
LoopClosureCapture,
|
|
22
28
|
]
|
|
23
29
|
|
|
24
30
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""CH008 - ``asyncio.run()`` called while a loop is already running.
|
|
2
|
+
|
|
3
|
+
``asyncio.run()`` creates a new event loop and refuses to run if one is
|
|
4
|
+
already active on the current thread - calling it from inside a coroutine
|
|
5
|
+
raises ``RuntimeError: asyncio.run() cannot be called from a running event
|
|
6
|
+
loop`` immediately, every time, unconditionally. Unlike most of the other
|
|
7
|
+
checks here, this isn't a subtle production-only failure; it fails the
|
|
8
|
+
first time the code path executes. It shows up anyway, usually from a
|
|
9
|
+
function that was written and tested as a synchronous entry point, then
|
|
10
|
+
got called from newly-async'd calling code without anyone changing its
|
|
11
|
+
body.
|
|
12
|
+
|
|
13
|
+
Scoped to the *immediate* enclosing function only, matching how the other
|
|
14
|
+
checks avoid cross-function analysis: `async def outer(): def inner():
|
|
15
|
+
asyncio.run(...)` does not flag, since `inner` is nested but still
|
|
16
|
+
synchronous itself - whether calling it while a loop is running is
|
|
17
|
+
actually safe depends on what thread it runs on, which this checker has
|
|
18
|
+
no way to know.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import ast
|
|
24
|
+
|
|
25
|
+
from codehound.core import Check, Finding, enclosing_function
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AsyncioRunInRunningLoop(Check):
|
|
29
|
+
code = "CH008"
|
|
30
|
+
name = "asyncio-run-in-running-loop"
|
|
31
|
+
description = "asyncio.run() called from inside an async function; always raises RuntimeError."
|
|
32
|
+
|
|
33
|
+
def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
|
|
34
|
+
findings: list[Finding] = []
|
|
35
|
+
for node in ast.walk(tree):
|
|
36
|
+
if not isinstance(node, ast.Call):
|
|
37
|
+
continue
|
|
38
|
+
func = node.func
|
|
39
|
+
if not (
|
|
40
|
+
isinstance(func, ast.Attribute)
|
|
41
|
+
and func.attr == "run"
|
|
42
|
+
and isinstance(func.value, ast.Name)
|
|
43
|
+
and func.value.id == "asyncio"
|
|
44
|
+
):
|
|
45
|
+
continue
|
|
46
|
+
fn = enclosing_function(node, parents)
|
|
47
|
+
if not isinstance(fn, ast.AsyncFunctionDef):
|
|
48
|
+
continue
|
|
49
|
+
findings.append(
|
|
50
|
+
Finding(
|
|
51
|
+
path=path,
|
|
52
|
+
line=node.lineno,
|
|
53
|
+
col=node.col_offset,
|
|
54
|
+
code=self.code,
|
|
55
|
+
message=(
|
|
56
|
+
f"`asyncio.run(...)` inside async function `{fn.name}` always raises "
|
|
57
|
+
f"RuntimeError - a loop is already running here; `await` the coroutine "
|
|
58
|
+
f"directly instead."
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
return findings
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""CH009 - Non-daemon ``threading.Thread`` started without a matching ``join()``.
|
|
2
|
+
|
|
3
|
+
The thread analog of CH006's floating-task: a plain ``threading.Thread``
|
|
4
|
+
that gets ``.start()``ed but never joined keeps running independently of
|
|
5
|
+
whoever created it. Unlike a daemon thread (an explicit, intentional
|
|
6
|
+
"let this die with the process" choice), a *non*-daemon thread that's
|
|
7
|
+
never joined can outlive the function that spawned it, hold the
|
|
8
|
+
interpreter open past when the caller expects the program to exit, or
|
|
9
|
+
race with cleanup code that assumed the work was done because the
|
|
10
|
+
function that started it returned.
|
|
11
|
+
|
|
12
|
+
Two shapes:
|
|
13
|
+
- Chained: ``threading.Thread(target=f).start()`` - the thread object is
|
|
14
|
+
never even captured, so joining it later is structurally impossible.
|
|
15
|
+
- Assigned: ``t = threading.Thread(target=f); t.start()`` - captured, but
|
|
16
|
+
no matching ``t.join()`` (and not returned, not stored as an attribute
|
|
17
|
+
of anything, not marked ``daemon=True``) before the enclosing function
|
|
18
|
+
returns.
|
|
19
|
+
|
|
20
|
+
A real false positive found while building this: llama_index's chat
|
|
21
|
+
engines create the thread, then do ``chat_response.write_response_to_history_thread
|
|
22
|
+
= thread`` and return ``chat_response`` - the thread isn't lost, it's
|
|
23
|
+
handed off through a *different* object's attribute (not ``self``), and
|
|
24
|
+
gets joined later once the caller finishes consuming the stream
|
|
25
|
+
(``chat_engine/types.py``). The escape check below treats a thread
|
|
26
|
+
assigned as *any* object's attribute as a deliberate hand-off, not just
|
|
27
|
+
``self.<attr>``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import ast
|
|
33
|
+
|
|
34
|
+
from codehound.core import Check, Finding, enclosing_function
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_thread_call(node: ast.expr) -> bool:
|
|
38
|
+
if not isinstance(node, ast.Call):
|
|
39
|
+
return False
|
|
40
|
+
func = node.func
|
|
41
|
+
if isinstance(func, ast.Name):
|
|
42
|
+
return func.id == "Thread"
|
|
43
|
+
if isinstance(func, ast.Attribute):
|
|
44
|
+
return func.attr == "Thread" and isinstance(func.value, ast.Name) and func.value.id == "threading"
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _has_daemon_true_kwarg(call: ast.Call) -> bool:
|
|
49
|
+
for kw in call.keywords:
|
|
50
|
+
if kw.arg == "daemon" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
|
|
51
|
+
return True
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FloatingThread(Check):
|
|
56
|
+
code = "CH009"
|
|
57
|
+
name = "floating-thread"
|
|
58
|
+
description = "Non-daemon threading.Thread started without a matching join()."
|
|
59
|
+
|
|
60
|
+
def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
|
|
61
|
+
findings: list[Finding] = []
|
|
62
|
+
|
|
63
|
+
# Chained: threading.Thread(...).start() with no assignment at all.
|
|
64
|
+
for node in ast.walk(tree):
|
|
65
|
+
if not isinstance(node, ast.Expr) or not isinstance(node.value, ast.Call):
|
|
66
|
+
continue
|
|
67
|
+
call = node.value
|
|
68
|
+
if not (isinstance(call.func, ast.Attribute) and call.func.attr == "start"):
|
|
69
|
+
continue
|
|
70
|
+
receiver = call.func.value
|
|
71
|
+
if not _is_thread_call(receiver):
|
|
72
|
+
continue
|
|
73
|
+
if _has_daemon_true_kwarg(receiver):
|
|
74
|
+
continue
|
|
75
|
+
findings.append(
|
|
76
|
+
Finding(
|
|
77
|
+
path=path,
|
|
78
|
+
line=node.lineno,
|
|
79
|
+
col=node.col_offset,
|
|
80
|
+
code=self.code,
|
|
81
|
+
message=(
|
|
82
|
+
"`threading.Thread(...).start()` - the thread is never captured, so it "
|
|
83
|
+
"can never be joined; either keep a reference and join() it, or pass "
|
|
84
|
+
"`daemon=True` if letting it outlive this scope is intentional."
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Assigned: t = threading.Thread(...); ... t.start() ... [no t.join()]
|
|
90
|
+
for node in ast.walk(tree):
|
|
91
|
+
if not isinstance(node, ast.Assign) or not _is_thread_call(node.value):
|
|
92
|
+
continue
|
|
93
|
+
if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name):
|
|
94
|
+
continue
|
|
95
|
+
name = node.targets[0].id
|
|
96
|
+
call = node.value
|
|
97
|
+
if _has_daemon_true_kwarg(call):
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
fn = enclosing_function(node, parents)
|
|
101
|
+
if fn is None:
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
started = False
|
|
105
|
+
joined = False
|
|
106
|
+
escapes = False
|
|
107
|
+
for n in ast.walk(fn):
|
|
108
|
+
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and isinstance(
|
|
109
|
+
n.func.value, ast.Name
|
|
110
|
+
) and n.func.value.id == name:
|
|
111
|
+
if n.func.attr == "start":
|
|
112
|
+
started = True
|
|
113
|
+
elif n.func.attr == "join":
|
|
114
|
+
joined = True
|
|
115
|
+
elif isinstance(n, ast.Assign):
|
|
116
|
+
for tgt in n.targets:
|
|
117
|
+
if (
|
|
118
|
+
isinstance(tgt, ast.Attribute)
|
|
119
|
+
and tgt.attr != "daemon"
|
|
120
|
+
and isinstance(n.value, ast.Name)
|
|
121
|
+
and n.value.id == name
|
|
122
|
+
):
|
|
123
|
+
# Stored as an attribute of *any* object (not just
|
|
124
|
+
# self) - e.g. a response object that hands the
|
|
125
|
+
# thread off to its own caller for later joining.
|
|
126
|
+
# Real pattern: llama_index's chat_engine classes
|
|
127
|
+
# stash the thread on the StreamingAgentChatResponse
|
|
128
|
+
# they return, which joins it once the stream is
|
|
129
|
+
# fully consumed (chat_engine/types.py).
|
|
130
|
+
escapes = True
|
|
131
|
+
if (
|
|
132
|
+
isinstance(tgt, ast.Attribute)
|
|
133
|
+
and isinstance(tgt.value, ast.Name)
|
|
134
|
+
and tgt.value.id == name
|
|
135
|
+
and tgt.attr == "daemon"
|
|
136
|
+
and isinstance(n.value, ast.Constant)
|
|
137
|
+
and n.value.value is True
|
|
138
|
+
):
|
|
139
|
+
escapes = True # daemon set post-construction
|
|
140
|
+
elif isinstance(n, ast.Return) and isinstance(n.value, ast.Name) and n.value.id == name:
|
|
141
|
+
escapes = True
|
|
142
|
+
|
|
143
|
+
if not started or joined or escapes:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
findings.append(
|
|
147
|
+
Finding(
|
|
148
|
+
path=path,
|
|
149
|
+
line=node.lineno,
|
|
150
|
+
col=node.col_offset,
|
|
151
|
+
code=self.code,
|
|
152
|
+
message=(
|
|
153
|
+
f"`{name} = threading.Thread(...)` in `{fn.name}` is started but never "
|
|
154
|
+
f"joined; it can outlive this function. Call `{name}.join()`, return it "
|
|
155
|
+
f"to the caller, or pass `daemon=True` if that's intentional."
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return findings
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""CH010 - A lambda captures a ``for``-loop variable by reference, not value.
|
|
2
|
+
|
|
3
|
+
Python closures capture *variables*, not their values at creation time.
|
|
4
|
+
``[lambda: i for i in range(3)]`` creates three lambdas that all read the
|
|
5
|
+
same cell - by the time any of them is called, the loop has finished and
|
|
6
|
+
``i`` is ``2`` in all three. The fix is to bind the value as a default
|
|
7
|
+
argument (``lambda i=i: i``), which evaluates ``i`` at lambda-creation
|
|
8
|
+
time instead of call time.
|
|
9
|
+
|
|
10
|
+
This only bites when the lambda is called *after* the loop variable has
|
|
11
|
+
moved on: stored in a list and used later, returned, handed to a
|
|
12
|
+
callback registry. The overwhelmingly common way a lambda appears inside
|
|
13
|
+
a loop is the *opposite* of that - passed straight into a higher-order
|
|
14
|
+
function that calls it immediately and returns, all within the same
|
|
15
|
+
iteration (``sorted(rows, key=lambda r: r[field])``, ``filter(...)``,
|
|
16
|
+
``max(..., key=...)``). That shape is completely safe: nothing outlives
|
|
17
|
+
the iteration. A real false positive of exactly this kind was found
|
|
18
|
+
scanning marimo's table sorter (``sorted(key=lambda row: row[sort_arg.by])``
|
|
19
|
+
inside ``for sort_arg in ...``) before this check was scoped correctly.
|
|
20
|
+
|
|
21
|
+
So this only fires when the lambda is directly *stored* rather than
|
|
22
|
+
passed as a callback: the argument to ``.append(...)``/``.add(...)``, the
|
|
23
|
+
value of an assignment (`` x = lambda: ...``, `` d[k] = lambda: ...``), or
|
|
24
|
+
returned/yielded. A lambda passed as an argument to anything else -
|
|
25
|
+
``sorted``, ``filter``, ``map``, a comprehension's own condition - is not
|
|
26
|
+
flagged, because nothing has captured it past this iteration.
|
|
27
|
+
|
|
28
|
+
Comprehensions (`` [lambda: i for i in range(3)] ``) are the one
|
|
29
|
+
exception: the ``elt``/``key``/``value`` position *is* inherently the
|
|
30
|
+
"produce and store" position, so no extra storage-context check is
|
|
31
|
+
needed there.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import ast
|
|
37
|
+
|
|
38
|
+
from codehound.core import Check, Finding
|
|
39
|
+
|
|
40
|
+
_STORAGE_METHODS = {"append", "add"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _references_name_unshadowed(lam: ast.Lambda, name: str) -> bool:
|
|
44
|
+
param_names = {a.arg for a in (lam.args.posonlyargs + lam.args.args + lam.args.kwonlyargs)}
|
|
45
|
+
if lam.args.vararg:
|
|
46
|
+
param_names.add(lam.args.vararg.arg)
|
|
47
|
+
if lam.args.kwarg:
|
|
48
|
+
param_names.add(lam.args.kwarg.arg)
|
|
49
|
+
if name in param_names:
|
|
50
|
+
return False
|
|
51
|
+
for node in ast.walk(lam.body):
|
|
52
|
+
if isinstance(node, ast.Name) and node.id == name and isinstance(node.ctx, ast.Load):
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_stored(lam: ast.Lambda, parents: dict) -> bool:
|
|
58
|
+
"""True only if the lambda is directly stored somewhere that outlives
|
|
59
|
+
this loop iteration - not merely passed as a callback argument to a
|
|
60
|
+
function (sorted/filter/map/...) that consumes it on the spot."""
|
|
61
|
+
parent = parents.get(id(lam))
|
|
62
|
+
if isinstance(parent, (ast.Assign, ast.AnnAssign)):
|
|
63
|
+
return True
|
|
64
|
+
if isinstance(parent, (ast.Return, ast.Yield)):
|
|
65
|
+
return True
|
|
66
|
+
if (
|
|
67
|
+
isinstance(parent, ast.Call)
|
|
68
|
+
and isinstance(parent.func, ast.Attribute)
|
|
69
|
+
and parent.func.attr in _STORAGE_METHODS
|
|
70
|
+
and lam in parent.args
|
|
71
|
+
):
|
|
72
|
+
return True
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LoopClosureCapture(Check):
|
|
77
|
+
code = "CH010"
|
|
78
|
+
name = "loop-closure-capture"
|
|
79
|
+
description = "Lambda captures a for-loop variable by reference; all instances see the final value."
|
|
80
|
+
|
|
81
|
+
def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
|
|
82
|
+
findings: list[Finding] = []
|
|
83
|
+
seen: set[int] = set()
|
|
84
|
+
|
|
85
|
+
for loop in ast.walk(tree):
|
|
86
|
+
targets: list[str] = []
|
|
87
|
+
body_nodes: list[ast.AST] = []
|
|
88
|
+
require_storage = False
|
|
89
|
+
if isinstance(loop, ast.For) and isinstance(loop.target, ast.Name):
|
|
90
|
+
targets = [loop.target.id]
|
|
91
|
+
body_nodes = loop.body
|
|
92
|
+
require_storage = True
|
|
93
|
+
elif isinstance(loop, (ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp)):
|
|
94
|
+
gens = [g for g in loop.generators if isinstance(g.target, ast.Name)]
|
|
95
|
+
targets = [g.target.id for g in gens]
|
|
96
|
+
body_nodes = [loop.elt] if not isinstance(loop, ast.DictComp) else [loop.key, loop.value]
|
|
97
|
+
else:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
for container in body_nodes:
|
|
101
|
+
for node in ast.walk(container):
|
|
102
|
+
if not isinstance(node, ast.Lambda) or id(node) in seen:
|
|
103
|
+
continue
|
|
104
|
+
if require_storage and not _is_stored(node, parents):
|
|
105
|
+
continue
|
|
106
|
+
for var in targets:
|
|
107
|
+
if _references_name_unshadowed(node, var):
|
|
108
|
+
seen.add(id(node))
|
|
109
|
+
findings.append(
|
|
110
|
+
Finding(
|
|
111
|
+
path=path,
|
|
112
|
+
line=node.lineno,
|
|
113
|
+
col=node.col_offset,
|
|
114
|
+
code=self.code,
|
|
115
|
+
message=(
|
|
116
|
+
f"lambda captures loop variable `{var}` by reference; if "
|
|
117
|
+
f"called after the loop moves on, every instance sees the "
|
|
118
|
+
f"same final value. Bind it explicitly: `lambda {var}={var}: ...`."
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
break
|
|
123
|
+
return findings
|
|
@@ -9,6 +9,8 @@ import sys
|
|
|
9
9
|
from codehound import __version__
|
|
10
10
|
from codehound.checks import ALL_CHECKS, get_checks
|
|
11
11
|
from codehound.core import DEFAULT_SKIP_DIRS, scan_path
|
|
12
|
+
from codehound.sarif import to_sarif
|
|
13
|
+
from codehound.terminal import format_findings_text, format_summary
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
def _cmd_scan(args: argparse.Namespace) -> int:
|
|
@@ -21,7 +23,10 @@ def _cmd_scan(args: argparse.Namespace) -> int:
|
|
|
21
23
|
skip = set(DEFAULT_SKIP_DIRS)
|
|
22
24
|
if args.include_tests:
|
|
23
25
|
skip -= {"tests", "test", "testing"}
|
|
24
|
-
findings =
|
|
26
|
+
findings = []
|
|
27
|
+
for path in args.paths:
|
|
28
|
+
findings.extend(scan_path(path, checks, skip_dirs=frozenset(skip)))
|
|
29
|
+
findings.sort(key=lambda f: (f.path, f.line, f.col, f.code))
|
|
25
30
|
|
|
26
31
|
if args.format == "json":
|
|
27
32
|
print(json.dumps([f.as_dict() for f in findings], indent=2))
|
|
@@ -30,18 +35,12 @@ def _cmd_scan(args: argparse.Namespace) -> int:
|
|
|
30
35
|
for f in findings:
|
|
31
36
|
msg = f.message.replace('"', "'")
|
|
32
37
|
print(f'{f.path},{f.line},{f.col},{f.code},"{msg}"')
|
|
38
|
+
elif args.format == "sarif":
|
|
39
|
+
print(json.dumps(to_sarif(findings, ALL_CHECKS), indent=2))
|
|
33
40
|
else: # text
|
|
34
|
-
for
|
|
35
|
-
print(
|
|
36
|
-
|
|
37
|
-
for f in findings:
|
|
38
|
-
counts[f.code] = counts.get(f.code, 0) + 1
|
|
39
|
-
summary = ", ".join(f"{k}: {v}" for k, v in sorted(counts.items()))
|
|
40
|
-
print(
|
|
41
|
-
f"\nFound {len(findings)} issue(s)"
|
|
42
|
-
+ (f" ({summary})" if summary else ""),
|
|
43
|
-
file=sys.stderr,
|
|
44
|
-
)
|
|
41
|
+
for line in format_findings_text(findings):
|
|
42
|
+
print(line)
|
|
43
|
+
print(f"\n{format_summary(findings)}", file=sys.stderr)
|
|
45
44
|
|
|
46
45
|
if findings and not args.exit_zero:
|
|
47
46
|
return 1
|
|
@@ -62,17 +61,22 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
62
61
|
parser.add_argument("--version", action="version", version=f"codehound {__version__}")
|
|
63
62
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
64
63
|
|
|
65
|
-
scan = sub.add_parser("scan", help="scan
|
|
66
|
-
scan.add_argument(
|
|
64
|
+
scan = sub.add_parser("scan", help="scan one or more files/directories for issues")
|
|
65
|
+
scan.add_argument(
|
|
66
|
+
"paths",
|
|
67
|
+
nargs="+",
|
|
68
|
+
metavar="path",
|
|
69
|
+
help="file(s) or director(y/ies) to scan (accepts multiple, for pre-commit)",
|
|
70
|
+
)
|
|
67
71
|
scan.add_argument(
|
|
68
72
|
"--select",
|
|
69
73
|
help="comma-separated check codes/names to run (default: all), e.g. CH001,CH006",
|
|
70
74
|
)
|
|
71
75
|
scan.add_argument(
|
|
72
76
|
"--format",
|
|
73
|
-
choices=["text", "json", "csv"],
|
|
77
|
+
choices=["text", "json", "csv", "sarif"],
|
|
74
78
|
default="text",
|
|
75
|
-
help="output format (default: text)",
|
|
79
|
+
help="output format (default: text; sarif for GitHub Code Scanning)",
|
|
76
80
|
)
|
|
77
81
|
scan.add_argument(
|
|
78
82
|
"--include-tests",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""SARIF 2.1.0 output, so a `codehound` run can feed GitHub's Code Scanning
|
|
2
|
+
tab directly (``github/codeql-action/upload-sarif``) instead of only being
|
|
3
|
+
readable as CI log text.
|
|
4
|
+
|
|
5
|
+
Deliberately minimal - just the fields GitHub's ingester actually needs:
|
|
6
|
+
one ``tool.driver`` with a ``rules`` array (so finding codes get a name and
|
|
7
|
+
description in the UI instead of a bare code), and one ``result`` per
|
|
8
|
+
finding with a single physical location. No fingerprinting, no nested
|
|
9
|
+
regions, no multi-run merging - those are real SARIF features this
|
|
10
|
+
doesn't need yet.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from codehound import __version__
|
|
16
|
+
from codehound.core import Check, Finding
|
|
17
|
+
|
|
18
|
+
SARIF_SCHEMA = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _rule(check_cls: type[Check]) -> dict:
|
|
22
|
+
return {
|
|
23
|
+
"id": check_cls.code,
|
|
24
|
+
"name": check_cls.name,
|
|
25
|
+
"shortDescription": {"text": check_cls.description},
|
|
26
|
+
"helpUri": "https://github.com/kratos0718/codehound#the-checks",
|
|
27
|
+
"properties": {"tags": ["correctness", "codehound"]},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _result(finding: Finding) -> dict:
|
|
32
|
+
return {
|
|
33
|
+
"ruleId": finding.code,
|
|
34
|
+
"level": "error",
|
|
35
|
+
"message": {"text": finding.message},
|
|
36
|
+
"locations": [
|
|
37
|
+
{
|
|
38
|
+
"physicalLocation": {
|
|
39
|
+
"artifactLocation": {"uri": finding.path.replace("\\", "/")},
|
|
40
|
+
"region": {
|
|
41
|
+
"startLine": max(finding.line, 1),
|
|
42
|
+
"startColumn": max(finding.col + 1, 1),
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def to_sarif(findings: list[Finding], all_checks: list[type[Check]]) -> dict:
|
|
51
|
+
return {
|
|
52
|
+
"$schema": SARIF_SCHEMA,
|
|
53
|
+
"version": "2.1.0",
|
|
54
|
+
"runs": [
|
|
55
|
+
{
|
|
56
|
+
"tool": {
|
|
57
|
+
"driver": {
|
|
58
|
+
"name": "codehound",
|
|
59
|
+
"informationUri": "https://github.com/kratos0718/codehound",
|
|
60
|
+
"version": __version__,
|
|
61
|
+
"rules": [_rule(c) for c in all_checks],
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"results": [_result(f) for f in findings],
|
|
65
|
+
}
|
|
66
|
+
],
|
|
67
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Colored text output for a terminal, plain text everywhere else.
|
|
2
|
+
|
|
3
|
+
Color only when stdout is actually a terminal, and never when ``NO_COLOR``
|
|
4
|
+
is set (https://no-color.org) or ``TERM=dumb`` - the same convention ruff,
|
|
5
|
+
eslint, and most modern CLI tools follow, so piping to a file or into `less`
|
|
6
|
+
never ends up with raw escape codes in it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
_RED = "\033[31m"
|
|
15
|
+
_YELLOW = "\033[33m"
|
|
16
|
+
_CYAN = "\033[36m"
|
|
17
|
+
_DIM = "\033[2m"
|
|
18
|
+
_BOLD = "\033[1m"
|
|
19
|
+
_RESET = "\033[0m"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _color_enabled(stream=None) -> bool:
|
|
23
|
+
stream = stream or sys.stdout
|
|
24
|
+
if os.environ.get("NO_COLOR") is not None:
|
|
25
|
+
return False
|
|
26
|
+
if os.environ.get("TERM") == "dumb":
|
|
27
|
+
return False
|
|
28
|
+
return hasattr(stream, "isatty") and stream.isatty()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def format_finding_text(finding, color: bool) -> str:
|
|
32
|
+
if not color:
|
|
33
|
+
return finding.as_text()
|
|
34
|
+
return (
|
|
35
|
+
f"{_CYAN}{finding.path}{_RESET}:{_DIM}{finding.line}:{finding.col}{_RESET}: "
|
|
36
|
+
f"{_RED}{_BOLD}{finding.code}{_RESET} {finding.message}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def format_findings_text(findings, stream=None) -> list[str]:
|
|
41
|
+
color = _color_enabled(stream)
|
|
42
|
+
return [format_finding_text(f, color) for f in findings]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def format_summary(findings, stream=None) -> str:
|
|
46
|
+
color = _color_enabled(stream)
|
|
47
|
+
counts: dict[str, int] = {}
|
|
48
|
+
for f in findings:
|
|
49
|
+
counts[f.code] = counts.get(f.code, 0) + 1
|
|
50
|
+
summary = ", ".join(f"{k}: {v}" for k, v in sorted(counts.items()))
|
|
51
|
+
count_str = str(len(findings))
|
|
52
|
+
if color and findings:
|
|
53
|
+
count_str = f"{_YELLOW}{_BOLD}{count_str}{_RESET}"
|
|
54
|
+
line = f"Found {count_str} issue(s)"
|
|
55
|
+
if summary:
|
|
56
|
+
line += f" ({summary})"
|
|
57
|
+
return line
|
|
@@ -280,3 +280,190 @@ def test_ch007_ignores_name_shadowed_by_parameter():
|
|
|
280
280
|
" ...\n"
|
|
281
281
|
)
|
|
282
282
|
assert _run(code, ["CH007"]) == []
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# --- CH008 asyncio-run-in-running-loop ---------------------------------------------
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_ch008_flags_asyncio_run_inside_async_def():
|
|
289
|
+
code = (
|
|
290
|
+
"import asyncio\n"
|
|
291
|
+
"async def f():\n"
|
|
292
|
+
" asyncio.run(g())\n"
|
|
293
|
+
)
|
|
294
|
+
findings = _run(code, ["CH008"])
|
|
295
|
+
assert len(findings) == 1
|
|
296
|
+
assert findings[0].code == "CH008"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_ch008_ignores_asyncio_run_in_sync_function():
|
|
300
|
+
code = (
|
|
301
|
+
"import asyncio\n"
|
|
302
|
+
"def main():\n"
|
|
303
|
+
" asyncio.run(g())\n"
|
|
304
|
+
)
|
|
305
|
+
assert _run(code, ["CH008"]) == []
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_ch008_ignores_asyncio_run_in_nested_sync_function():
|
|
309
|
+
# inner() is itself sync; whether calling it from a running loop is
|
|
310
|
+
# safe depends on what thread it runs on, which this check can't know.
|
|
311
|
+
code = (
|
|
312
|
+
"import asyncio\n"
|
|
313
|
+
"async def outer():\n"
|
|
314
|
+
" def inner():\n"
|
|
315
|
+
" asyncio.run(g())\n"
|
|
316
|
+
" inner()\n"
|
|
317
|
+
)
|
|
318
|
+
assert _run(code, ["CH008"]) == []
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# --- CH009 floating-thread ----------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_ch009_flags_chained_start_with_no_reference():
|
|
325
|
+
code = (
|
|
326
|
+
"import threading\n"
|
|
327
|
+
"def f():\n"
|
|
328
|
+
" threading.Thread(target=work).start()\n"
|
|
329
|
+
)
|
|
330
|
+
findings = _run(code, ["CH009"])
|
|
331
|
+
assert len(findings) == 1
|
|
332
|
+
assert findings[0].code == "CH009"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_ch009_ignores_chained_daemon_thread():
|
|
336
|
+
code = (
|
|
337
|
+
"import threading\n"
|
|
338
|
+
"def f():\n"
|
|
339
|
+
" threading.Thread(target=work, daemon=True).start()\n"
|
|
340
|
+
)
|
|
341
|
+
assert _run(code, ["CH009"]) == []
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_ch009_flags_assigned_thread_never_joined():
|
|
345
|
+
code = (
|
|
346
|
+
"import threading\n"
|
|
347
|
+
"def f():\n"
|
|
348
|
+
" t = threading.Thread(target=work)\n"
|
|
349
|
+
" t.start()\n"
|
|
350
|
+
)
|
|
351
|
+
findings = _run(code, ["CH009"])
|
|
352
|
+
assert len(findings) == 1
|
|
353
|
+
assert findings[0].code == "CH009"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def test_ch009_ignores_assigned_thread_that_is_joined():
|
|
357
|
+
code = (
|
|
358
|
+
"import threading\n"
|
|
359
|
+
"def f():\n"
|
|
360
|
+
" t = threading.Thread(target=work)\n"
|
|
361
|
+
" t.start()\n"
|
|
362
|
+
" t.join()\n"
|
|
363
|
+
)
|
|
364
|
+
assert _run(code, ["CH009"]) == []
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def test_ch009_ignores_thread_returned_to_caller():
|
|
368
|
+
code = (
|
|
369
|
+
"import threading\n"
|
|
370
|
+
"def f():\n"
|
|
371
|
+
" t = threading.Thread(target=work)\n"
|
|
372
|
+
" t.start()\n"
|
|
373
|
+
" return t\n"
|
|
374
|
+
)
|
|
375
|
+
assert _run(code, ["CH009"]) == []
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_ch009_ignores_thread_handed_off_via_another_objects_attribute():
|
|
379
|
+
# Real false positive found in llama_index: the thread is stashed on a
|
|
380
|
+
# *different* object's attribute (not self), which is itself returned;
|
|
381
|
+
# that object joins the thread later once its caller finishes with it.
|
|
382
|
+
code = (
|
|
383
|
+
"import threading\n"
|
|
384
|
+
"def f():\n"
|
|
385
|
+
" response = ChatResponse()\n"
|
|
386
|
+
" t = threading.Thread(target=work)\n"
|
|
387
|
+
" response.write_response_to_history_thread = t\n"
|
|
388
|
+
" t.start()\n"
|
|
389
|
+
" return response\n"
|
|
390
|
+
)
|
|
391
|
+
assert _run(code, ["CH009"]) == []
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def test_ch009_ignores_daemon_set_after_construction():
|
|
395
|
+
code = (
|
|
396
|
+
"import threading\n"
|
|
397
|
+
"def f():\n"
|
|
398
|
+
" t = threading.Thread(target=work)\n"
|
|
399
|
+
" t.daemon = True\n"
|
|
400
|
+
" t.start()\n"
|
|
401
|
+
)
|
|
402
|
+
assert _run(code, ["CH009"]) == []
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_ch009_ignores_thread_never_started():
|
|
406
|
+
# Created but never run at all - not a "floating" thread, just inert.
|
|
407
|
+
code = (
|
|
408
|
+
"import threading\n"
|
|
409
|
+
"def f():\n"
|
|
410
|
+
" t = threading.Thread(target=work)\n"
|
|
411
|
+
" return t.name\n"
|
|
412
|
+
)
|
|
413
|
+
assert _run(code, ["CH009"]) == []
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# --- CH010 loop-closure-capture ------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def test_ch010_flags_lambda_capturing_loop_variable_in_list():
|
|
420
|
+
code = "callbacks = []\nfor i in range(3):\n callbacks.append(lambda: i)\n"
|
|
421
|
+
findings = _run(code, ["CH010"])
|
|
422
|
+
assert len(findings) == 1
|
|
423
|
+
assert findings[0].code == "CH010"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def test_ch010_flags_lambda_in_list_comprehension():
|
|
427
|
+
code = "callbacks = [lambda: i for i in range(3)]\n"
|
|
428
|
+
findings = _run(code, ["CH010"])
|
|
429
|
+
assert len(findings) == 1
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def test_ch010_ignores_default_arg_capture():
|
|
433
|
+
code = "callbacks = []\nfor i in range(3):\n callbacks.append(lambda i=i: i)\n"
|
|
434
|
+
assert _run(code, ["CH010"]) == []
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def test_ch010_ignores_lambda_not_referencing_loop_var():
|
|
438
|
+
code = "callbacks = []\nfor i in range(3):\n callbacks.append(lambda: 42)\n"
|
|
439
|
+
assert _run(code, ["CH010"]) == []
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def test_ch010_ignores_immediately_invoked_lambda():
|
|
443
|
+
code = "results = []\nfor i in range(3):\n results.append((lambda: i)())\n"
|
|
444
|
+
assert _run(code, ["CH010"]) == []
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def test_ch010_ignores_lambda_passed_as_sort_key():
|
|
448
|
+
# Real false positive found in marimo: sorted() calls the key function
|
|
449
|
+
# immediately, synchronously, using the loop variable's *current* value
|
|
450
|
+
# - nothing outlives the iteration, even though field is referenced.
|
|
451
|
+
code = (
|
|
452
|
+
"for sort_arg in by:\n"
|
|
453
|
+
" rows = sorted(rows, key=lambda row: row[sort_arg.by])\n"
|
|
454
|
+
)
|
|
455
|
+
assert _run(code, ["CH010"]) == []
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def test_ch010_ignores_lambda_passed_to_filter():
|
|
459
|
+
code = "for prefix in prefixes:\n kept = list(filter(lambda x: x.startswith(prefix), items))\n"
|
|
460
|
+
assert _run(code, ["CH010"]) == []
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def test_ch010_still_flags_lambda_appended_even_when_named_like_a_key_fn():
|
|
464
|
+
# Contrast case: same "lambda referencing loop var" shape, but this
|
|
465
|
+
# time it really is stored (appended) rather than consumed on the spot.
|
|
466
|
+
code = "keys = []\nfor field in fields:\n keys.append(lambda row: row[field])\n"
|
|
467
|
+
findings = _run(code, ["CH010"])
|
|
468
|
+
assert len(findings) == 1
|
|
469
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Tests for the non-default output formats: SARIF and colored text.
|
|
2
|
+
|
|
3
|
+
Scan results themselves are covered by test_checks.py; these tests only
|
|
4
|
+
check that each format serializes a Finding correctly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from codehound.checks import ALL_CHECKS
|
|
10
|
+
from codehound.core import Finding
|
|
11
|
+
from codehound.sarif import to_sarif
|
|
12
|
+
from codehound.terminal import format_finding_text, format_summary
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _sample_finding() -> Finding:
|
|
16
|
+
return Finding(path="pkg/mod.py", line=10, col=4, code="CH002", message="test message")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_sarif_has_required_top_level_shape():
|
|
20
|
+
doc = to_sarif([_sample_finding()], ALL_CHECKS)
|
|
21
|
+
assert doc["version"] == "2.1.0"
|
|
22
|
+
assert "$schema" in doc
|
|
23
|
+
assert len(doc["runs"]) == 1
|
|
24
|
+
run = doc["runs"][0]
|
|
25
|
+
assert run["tool"]["driver"]["name"] == "codehound"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_sarif_includes_a_rule_entry_per_check():
|
|
29
|
+
doc = to_sarif([], ALL_CHECKS)
|
|
30
|
+
rule_ids = {r["id"] for r in doc["runs"][0]["tool"]["driver"]["rules"]}
|
|
31
|
+
assert rule_ids == {c.code for c in ALL_CHECKS}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_sarif_result_location_and_1_indexed_column():
|
|
35
|
+
doc = to_sarif([_sample_finding()], ALL_CHECKS)
|
|
36
|
+
result = doc["runs"][0]["results"][0]
|
|
37
|
+
assert result["ruleId"] == "CH002"
|
|
38
|
+
loc = result["locations"][0]["physicalLocation"]
|
|
39
|
+
assert loc["artifactLocation"]["uri"] == "pkg/mod.py"
|
|
40
|
+
# Finding.col is a 0-indexed ast col_offset; SARIF columns are 1-indexed.
|
|
41
|
+
assert loc["region"]["startLine"] == 10
|
|
42
|
+
assert loc["region"]["startColumn"] == 5
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_sarif_with_no_findings_has_empty_results():
|
|
46
|
+
doc = to_sarif([], ALL_CHECKS)
|
|
47
|
+
assert doc["runs"][0]["results"] == []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_colored_text_contains_plain_text_content():
|
|
51
|
+
finding = _sample_finding()
|
|
52
|
+
colored = format_finding_text(finding, color=True)
|
|
53
|
+
plain = format_finding_text(finding, color=False)
|
|
54
|
+
assert plain == finding.as_text()
|
|
55
|
+
assert "\033[" in colored
|
|
56
|
+
assert "CH002" in colored
|
|
57
|
+
assert "test message" in colored
|
|
58
|
+
assert "pkg/mod.py" in colored
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_summary_reports_correct_counts():
|
|
62
|
+
findings = [
|
|
63
|
+
Finding(path="a.py", line=1, col=0, code="CH001", message="x"),
|
|
64
|
+
Finding(path="b.py", line=2, col=0, code="CH001", message="y"),
|
|
65
|
+
Finding(path="c.py", line=3, col=0, code="CH002", message="z"),
|
|
66
|
+
]
|
|
67
|
+
summary = format_summary(findings)
|
|
68
|
+
assert "Found 3 issue(s)" in summary
|
|
69
|
+
assert "CH001: 2" in summary
|
|
70
|
+
assert "CH002: 1" in summary
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_summary_with_no_findings():
|
|
74
|
+
assert format_summary([]) == "Found 0 issue(s)"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|