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.
Files changed (23) hide show
  1. {codehound-1.1.0 → codehound-1.3.0}/PKG-INFO +108 -23
  2. {codehound-1.1.0 → codehound-1.3.0}/README.md +107 -22
  3. {codehound-1.1.0 → codehound-1.3.0}/src/codehound/__init__.py +4 -4
  4. {codehound-1.1.0 → codehound-1.3.0}/src/codehound/checks/__init__.py +6 -0
  5. codehound-1.3.0/src/codehound/checks/asyncio_run_in_loop.py +62 -0
  6. codehound-1.3.0/src/codehound/checks/floating_thread.py +159 -0
  7. codehound-1.3.0/src/codehound/checks/loop_closure_capture.py +123 -0
  8. {codehound-1.1.0 → codehound-1.3.0}/src/codehound/cli.py +20 -16
  9. codehound-1.3.0/src/codehound/sarif.py +67 -0
  10. codehound-1.3.0/src/codehound/terminal.py +57 -0
  11. {codehound-1.1.0 → codehound-1.3.0}/tests/test_checks.py +187 -0
  12. codehound-1.3.0/tests/test_output_formats.py +74 -0
  13. {codehound-1.1.0 → codehound-1.3.0}/.gitignore +0 -0
  14. {codehound-1.1.0 → codehound-1.3.0}/LICENSE +0 -0
  15. {codehound-1.1.0 → codehound-1.3.0}/pyproject.toml +0 -0
  16. {codehound-1.1.0 → codehound-1.3.0}/src/codehound/checks/blocking_async.py +0 -0
  17. {codehound-1.1.0 → codehound-1.3.0}/src/codehound/checks/datetime_utcnow.py +0 -0
  18. {codehound-1.1.0 → codehound-1.3.0}/src/codehound/checks/floating_task.py +0 -0
  19. {codehound-1.1.0 → codehound-1.3.0}/src/codehound/checks/get_event_loop.py +0 -0
  20. {codehound-1.1.0 → codehound-1.3.0}/src/codehound/checks/mutable_defaults.py +0 -0
  21. {codehound-1.1.0 → codehound-1.3.0}/src/codehound/checks/resource_leak.py +0 -0
  22. {codehound-1.1.0 → codehound-1.3.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
  23. {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.1.0
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
- # 🐕 codehound
21
+ <p align="center">
22
+ <img src="assets/logo.png" alt="codehound" width="220">
23
+ </p>
22
24
 
23
- **An AST-based static analyzer that hunts *real* bugs in large Python codebases — six of the seven rules are backed by a bug that was actually found and merged into a major open-source AI framework; the seventh is a hardening rule verified against real false positives instead.**
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
  [![CI](https://github.com/kratos0718/codehound/actions/workflows/ci.yml/badge.svg)](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
26
30
  [![PyPI](https://img.shields.io/pypi/v/codehound.svg)](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 doesn't have a found-and-merged bug behind it like the other six -
124
- it targets a well-known Python correctness gotcha (the
125
- `RuntimeWarning: coroutine 'foo' was never awaited` you get when a
126
- coroutine is created and discarded) rather than one this project
127
- personally tracked down. What it does have is two real false positives
128
- caught and fixed while building it, both against agno: a bare `self.foo()`
129
- call matched against an unrelated same-named `async def foo` on a
130
- *different* class (agno's own sync/async "twin method" convention, e.g.
131
- `ZepTools`/`ZepAsyncTools`), and a plain callable parameter shadowed by an
132
- unrelated same-named async function hundreds of lines away in the same
133
- file. Scanning ~20 major Python AI/ML frameworks after fixing both turned
134
- up zero real instances - itself a result, not a null: it suggests either
135
- that mature async test suites catch this before merge, or that most real
136
- cases are cross-module calls, which this check deliberately doesn't chase
137
- (same-file name matching only, consistent with every other rule here).
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
- └── unawaited_coroutine.py (CH007)
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 - both guards exist because of real false positives caught while building it (see above). The test suite asserts both "bad code is flagged" and "correct code is not."
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
- - [ ] Cross-module resolution for CH007 (currently same-file only)
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
- # 🐕 codehound
1
+ <p align="center">
2
+ <img src="assets/logo.png" alt="codehound" width="220">
3
+ </p>
2
4
 
3
- **An AST-based static analyzer that hunts *real* bugs in large Python codebases — six of the seven rules are backed by a bug that was actually found and merged into a major open-source AI framework; the seventh is a hardening rule verified against real false positives instead.**
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
  [![CI](https://github.com/kratos0718/codehound/actions/workflows/ci.yml/badge.svg)](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
6
10
  [![PyPI](https://img.shields.io/pypi/v/codehound.svg)](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 doesn't have a found-and-merged bug behind it like the other six -
104
- it targets a well-known Python correctness gotcha (the
105
- `RuntimeWarning: coroutine 'foo' was never awaited` you get when a
106
- coroutine is created and discarded) rather than one this project
107
- personally tracked down. What it does have is two real false positives
108
- caught and fixed while building it, both against agno: a bare `self.foo()`
109
- call matched against an unrelated same-named `async def foo` on a
110
- *different* class (agno's own sync/async "twin method" convention, e.g.
111
- `ZepTools`/`ZepAsyncTools`), and a plain callable parameter shadowed by an
112
- unrelated same-named async function hundreds of lines away in the same
113
- file. Scanning ~20 major Python AI/ML frameworks after fixing both turned
114
- up zero real instances - itself a result, not a null: it suggests either
115
- that mature async test suites catch this before merge, or that most real
116
- cases are cross-module calls, which this check deliberately doesn't chase
117
- (same-file name matching only, consistent with every other rule here).
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
- └── unawaited_coroutine.py (CH007)
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 - both guards exist because of real false positives caught while building it (see above). The test suite asserts both "bad code is flagged" and "correct code is not."
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
- - [ ] Cross-module resolution for CH007 (currently same-file only)
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
- Seven checks. Six are each backed by a bug that was actually found and
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, huggingface_hub). The seventh (CH007) is a hardening rule
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.1.0"
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 = scan_path(args.path, checks, skip_dirs=frozenset(skip))
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 f in findings:
35
- print(f.as_text())
36
- counts: dict[str, int] = {}
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 a file or directory for issues")
66
- scan.add_argument("path", help="file or directory to scan")
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