codehound 1.5.0__tar.gz → 1.6.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 (41) hide show
  1. {codehound-1.5.0 → codehound-1.6.0}/PKG-INFO +76 -7
  2. {codehound-1.5.0 → codehound-1.6.0}/README.md +75 -6
  3. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/__init__.py +4 -4
  4. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/__init__.py +12 -0
  5. codehound-1.6.0/src/codehound/checks/floating_timer.py +169 -0
  6. codehound-1.6.0/src/codehound/checks/is_literal_comparison.py +80 -0
  7. codehound-1.6.0/src/codehound/checks/mutable_class_attribute.py +108 -0
  8. codehound-1.6.0/src/codehound/checks/removed_stdlib_attribute.py +75 -0
  9. codehound-1.6.0/src/codehound/checks/unittest_deprecated_alias.py +68 -0
  10. codehound-1.6.0/src/codehound/checks/unwaited_subprocess.py +146 -0
  11. {codehound-1.5.0 → codehound-1.6.0}/tests/test_checks.py +323 -0
  12. {codehound-1.5.0 → codehound-1.6.0}/.gitignore +0 -0
  13. {codehound-1.5.0 → codehound-1.6.0}/LICENSE +0 -0
  14. {codehound-1.5.0 → codehound-1.6.0}/pyproject.toml +0 -0
  15. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/async_property.py +0 -0
  16. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/asyncio_coroutine_decorator.py +0 -0
  17. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/asyncio_run_in_loop.py +0 -0
  18. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/bare_except.py +0 -0
  19. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/blocking_async.py +0 -0
  20. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/collections_abc_import.py +0 -0
  21. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/datetime_utcnow.py +0 -0
  22. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/discarded_future.py +0 -0
  23. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/floating_process.py +0 -0
  24. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/floating_task.py +0 -0
  25. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/floating_thread.py +0 -0
  26. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/get_event_loop.py +0 -0
  27. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/loop_closure_capture.py +0 -0
  28. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/lru_cache_on_method.py +0 -0
  29. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/mutable_defaults.py +0 -0
  30. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/removed_asyncio_task_methods.py +0 -0
  31. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/removed_getargspec.py +0 -0
  32. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/removed_stdlib_module.py +0 -0
  33. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/resource_leak.py +0 -0
  34. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/unawaited_coroutine.py +0 -0
  35. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/unclosed_socket.py +0 -0
  36. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/checks/unprotected_lock.py +0 -0
  37. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/cli.py +0 -0
  38. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/core.py +0 -0
  39. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/sarif.py +0 -0
  40. {codehound-1.5.0 → codehound-1.6.0}/src/codehound/terminal.py +0 -0
  41. {codehound-1.5.0 → codehound-1.6.0}/tests/test_output_formats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codehound
3
- Version: 1.5.0
3
+ Version: 1.6.0
4
4
  Summary: An AST-based static analyzer that hunts real correctness and async-safety bugs in Python code.
5
5
  Project-URL: Homepage, https://github.com/kratos0718/codehound
6
6
  Project-URL: Issues, https://github.com/kratos0718/codehound/issues
@@ -24,7 +24,7 @@ Description-Content-Type: text/markdown
24
24
 
25
25
  <h1 align="center">codehound</h1>
26
26
 
27
- **An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty-two checks, eight backed by a bug that was actually found and merged (or opened as a PR) into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~29-framework validation corpus instead of just reasoned about.**
27
+ **An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty-eight checks, eight backed by a bug that was actually found and merged (or opened as a PR) into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~29-framework validation corpus instead of just reasoned about.**
28
28
 
29
29
  [![CI](https://github.com/kratos0718/codehound/actions/workflows/ci.yml/badge.svg)](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
30
30
  [![PyPI](https://img.shields.io/pypi/v/codehound.svg)](https://pypi.org/project/codehound/)
@@ -71,7 +71,7 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
71
71
  pip install codehound
72
72
  ```
73
73
 
74
- Zero dependencies — it's ~2,600 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
74
+ Zero dependencies — it's ~3,200 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
75
75
 
76
76
  <details>
77
77
  <summary>From a clone instead (for development)</summary>
@@ -132,7 +132,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
132
132
  ```yaml
133
133
  repos:
134
134
  - repo: https://github.com/kratos0718/codehound
135
- rev: v1.5.0
135
+ rev: v1.6.0
136
136
  hooks:
137
137
  - id: codehound
138
138
  ```
@@ -165,10 +165,16 @@ repos:
165
165
  | **CH020** | `bare-except` | A bare `except:` (or unused `except BaseException:`) — also catches `KeyboardInterrupt`/`SystemExit`, so Ctrl-C stops working and `sys.exit()` gets silently absorbed. | hardening rule — real hits in agno, llama_index, marimo, litellm |
166
166
  | **CH021** | `removed-stdlib-module` | `import distutils` (removed 3.12) or any of the 19 PEP 594 "dead battery" modules (`cgi`, `imghdr`, `telnetlib`, `nntplib`, …, removed 3.13) — `ImportError` the moment the module loads. | hardening rule — real hit in agno (already guarded, see below) |
167
167
  | **CH022** | `removed-asyncio-coroutine-decorator` | `@asyncio.coroutine` — removed in Python 3.11 after a generator-based-coroutine bridge that predates `async def`; `AttributeError` the moment the decorator line runs. | hardening rule |
168
+ | **CH023** | `removed-stdlib-attribute` | A specific removed function on a module that still imports fine — `time.clock()` (3.8), `platform.linux_distribution()`/`.dist()` (3.8), `cgi.escape()` (3.8), `base64.encodestring()`/`.decodestring()` (3.9). | hardening rule — real hit in scikit-learn |
169
+ | **CH024** | `unittest-deprecated-alias` | `self.assertEquals(...)`/`self.failUnless(...)` and a dozen other legacy `unittest.TestCase` aliases — removed in Python 3.12. | hardening rule |
170
+ | **CH025** | `is-literal-comparison` | `x is 1000` / `x is not "foo"` — `is` checks identity, not equality; relies on CPython's small-int caching / string interning, neither guaranteed. (pyflakes F632) | hardening rule — real false positive fixed in litellm's own code, see below |
171
+ | **CH026** | `mutable-class-attribute` | `class C: items = []` mutated via `self.items.append(...)` without ever being reassigned per instance — every instance shares and mutates the *same* list. | **vllm**, **llama_index**, **optuna**, **transformers** — see below |
172
+ | **CH027** | `unwaited-subprocess` | `subprocess.Popen(...)` never `.wait()`ed/`.communicate()`d with, and not context-managed — risks a zombie process and a full pipe buffer deadlocking the child. | hardening rule — real hit in dspy (already handled, see below) |
173
+ | **CH028** | `floating-timer` | `threading.Timer(...)` started but never `.cancel()`ed or handed off — nothing can stop the callback from firing later, on stale context. | hardening rule — real hits in marimo, transformers |
168
174
 
169
175
  `codehound list` prints this from the source of truth.
170
176
 
171
- CH007-CH022 don't have found-and-merged bugs behind all of them the way
177
+ CH007-CH028 don't have found-and-merged bugs behind all of them the way
172
178
  CH001-CH006 do - most are hardening rules for well-known Python
173
179
  correctness gotchas rather than something this project personally
174
180
  tracked down first. CH010 and CH011 are the exceptions: both found
@@ -271,6 +277,60 @@ a `try:` body whose `except` catches `ImportError` (or anything
271
277
  broader). A full corpus rescan after both fixes found zero remaining
272
278
  CH021 hits.
273
279
 
280
+ **CH026 found four real, previously-unreported bugs on its first real
281
+ scan.** All four are the exact same shape: a class-level mutable default
282
+ (`items = []`) mutated in place via `self.items.append(...)` (or
283
+ subscript assignment) with no per-instance reassignment anywhere, so
284
+ every instance of the class shares and corrupts the *same* object. In
285
+ vllm's `AXK1ForCausalLM`, `self.packed_modules_mapping["qkv_proj"] =
286
+ [...]` patches a routing table shared by every instance of the model
287
+ class. In llama_index's `ZapierToolSpec`, `self.spec_functions.append(...)`
288
+ means a second tool-spec instance (a different API key, a different
289
+ user) inherits every action name the first instance ever registered. In
290
+ optuna's CLI `_Studies` command, `self._study_list_header.append(...)`
291
+ does the same to a table-header list. In HuggingFace transformers'
292
+ `CodeGenTokenizer`, `self.model_input_names.append("token_type_ids")`
293
+ means constructing one tokenizer with `return_token_type_ids=True`
294
+ silently changes what field every *other* `CodeGenTokenizer` instance in
295
+ the same process expects, regardless of how it was configured - exactly
296
+ the "spooky action at a distance" class of bug this project exists to
297
+ catch. None have PRs yet: vllm and transformers both require AI-assisted
298
+ PRs to carry an explicit disclosure, which this project's own policy
299
+ doesn't do, so those two are documented here rather than filed.
300
+
301
+ **CH028 hit the exact same name-collision problem CH018/CH022 already
302
+ had a guard for, because that guard didn't get reused.** The very first
303
+ corpus scan came back with ~30 hits, almost all in agno - which doesn't
304
+ use `threading.Timer` at all. `from agno.utils.timer import Timer` is
305
+ agno's own unrelated stopwatch class, called as `Timer()` with zero
306
+ arguments (real `threading.Timer` requires `interval` and `function` and
307
+ would raise `TypeError` immediately). Fixed by requiring `from threading
308
+ import Timer` before trusting a bare `Timer(...)` call - the same guard
309
+ already built for CH022's `coroutine` minutes earlier in the same
310
+ session, just not applied here the first time. Also missed CH009's
311
+ `daemon=True` escape entirely (found in weaviate-python-client's
312
+ watchdog timer, `_timeout_timer.daemon = True`, a deliberate
313
+ "outlive the caller" choice) - added both a constructor-kwarg and a
314
+ post-construction-assignment check for it, matching CH009 exactly. Real
315
+ hits remain in marimo (a non-daemon browser-opening timer, never
316
+ cancelled) and transformers (a chained, never-captured checkpoint-retry
317
+ timer).
318
+
319
+ **CH025 and CH027 each found one real bug in the first scan, and one
320
+ real gap in the check.** CH025 (is-literal-comparison) flagged litellm's
321
+ `if "usage" in response_obj is not None:` - but for the wrong reason.
322
+ `ast.Compare` puts every operand and every op from a chained comparison
323
+ in one node; checking "is there a literal anywhere" and "is there an
324
+ `is`/`is not` anywhere" independently, without pairing each op with its
325
+ own adjacent operands, matched the string literal (paired with `in`)
326
+ against the wrong op (`is not`, actually comparing `response_obj` to the
327
+ allowed singleton `None`). Fixed by walking the chain as adjacent
328
+ `(left, op, right)` triples. CH027 (unwaited-subprocess) flagged dspy's
329
+ `process = subprocess.Popen(...)`, followed by `lm.process = process` -
330
+ a real hand-off to a different object, reaped later through a separate
331
+ `terminate_process(lm.process)` call, the same "stored as any object's
332
+ attribute" escape CH009/CH016/CH028 already needed. Added it.
333
+
274
334
  **Two checks we built and did not ship.** `exception-chaining` (`except X
275
335
  as e: raise Y(...)` with no `from e`, discarding the real traceback -
276
336
  overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
@@ -328,12 +388,18 @@ codehound/
328
388
  ├── removed_getargspec.py (CH019)
329
389
  ├── bare_except.py (CH020)
330
390
  ├── removed_stdlib_module.py (CH021)
331
- └── asyncio_coroutine_decorator.py (CH022)
391
+ ├── asyncio_coroutine_decorator.py (CH022)
392
+ ├── removed_stdlib_attribute.py (CH023)
393
+ ├── unittest_deprecated_alias.py (CH024)
394
+ ├── is_literal_comparison.py (CH025)
395
+ ├── mutable_class_attribute.py (CH026)
396
+ ├── unwaited_subprocess.py (CH027)
397
+ └── floating_timer.py (CH028)
332
398
  ```
333
399
 
334
400
  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.
335
401
 
336
- **False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
402
+ **False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. CH025 pairs each chained comparison's op with only its own adjacent operands, rather than matching a literal and an `is`/`is not` anywhere in the same chain independently — a real pattern found in litellm. CH027 and CH028 both recognize a handle stored as *any* object's attribute as a hand-off, matching CH009/CH016's precedent — real patterns found in dspy and weaviate-python-client respectively. CH028 also only trusts a bare `Timer(...)` when `from threading import Timer` was actually seen — real hits in agno were its own unrelated stopwatch class. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
337
403
 
338
404
  ---
339
405
 
@@ -365,6 +431,9 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
365
431
  sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
366
432
  - [x] 22 checks — removed stdlib modules (`distutils`, PEP 594 "dead
367
433
  batteries"), removed `@asyncio.coroutine` decorator — CH021-CH022
434
+ - [x] 28 checks — removed stdlib functions, deprecated unittest aliases,
435
+ `is`-literal comparisons, mutable class attributes, unwaited
436
+ subprocesses, floating timers — CH023-CH028
368
437
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
369
438
  - [ ] Sync HTTP clients constructed inside async request handlers
370
439
  - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h1 align="center">codehound</h1>
6
6
 
7
- **An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty-two checks, eight backed by a bug that was actually found and merged (or opened as a PR) into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~29-framework validation corpus instead of just reasoned about.**
7
+ **An AST-based static analyzer that hunts *real* bugs in large Python codebases — twenty-eight checks, eight backed by a bug that was actually found and merged (or opened as a PR) into a major open-source AI framework, the rest hardening rules verified against real false positives across a ~29-framework validation corpus instead of just reasoned about.**
8
8
 
9
9
  [![CI](https://github.com/kratos0718/codehound/actions/workflows/ci.yml/badge.svg)](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
10
10
  [![PyPI](https://img.shields.io/pypi/v/codehound.svg)](https://pypi.org/project/codehound/)
@@ -51,7 +51,7 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
51
51
  pip install codehound
52
52
  ```
53
53
 
54
- Zero dependencies — it's ~2,600 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
54
+ Zero dependencies — it's ~3,200 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
55
55
 
56
56
  <details>
57
57
  <summary>From a clone instead (for development)</summary>
@@ -112,7 +112,7 @@ Uploads findings to the repo's **Security → Code Scanning** tab via SARIF, in
112
112
  ```yaml
113
113
  repos:
114
114
  - repo: https://github.com/kratos0718/codehound
115
- rev: v1.5.0
115
+ rev: v1.6.0
116
116
  hooks:
117
117
  - id: codehound
118
118
  ```
@@ -145,10 +145,16 @@ repos:
145
145
  | **CH020** | `bare-except` | A bare `except:` (or unused `except BaseException:`) — also catches `KeyboardInterrupt`/`SystemExit`, so Ctrl-C stops working and `sys.exit()` gets silently absorbed. | hardening rule — real hits in agno, llama_index, marimo, litellm |
146
146
  | **CH021** | `removed-stdlib-module` | `import distutils` (removed 3.12) or any of the 19 PEP 594 "dead battery" modules (`cgi`, `imghdr`, `telnetlib`, `nntplib`, …, removed 3.13) — `ImportError` the moment the module loads. | hardening rule — real hit in agno (already guarded, see below) |
147
147
  | **CH022** | `removed-asyncio-coroutine-decorator` | `@asyncio.coroutine` — removed in Python 3.11 after a generator-based-coroutine bridge that predates `async def`; `AttributeError` the moment the decorator line runs. | hardening rule |
148
+ | **CH023** | `removed-stdlib-attribute` | A specific removed function on a module that still imports fine — `time.clock()` (3.8), `platform.linux_distribution()`/`.dist()` (3.8), `cgi.escape()` (3.8), `base64.encodestring()`/`.decodestring()` (3.9). | hardening rule — real hit in scikit-learn |
149
+ | **CH024** | `unittest-deprecated-alias` | `self.assertEquals(...)`/`self.failUnless(...)` and a dozen other legacy `unittest.TestCase` aliases — removed in Python 3.12. | hardening rule |
150
+ | **CH025** | `is-literal-comparison` | `x is 1000` / `x is not "foo"` — `is` checks identity, not equality; relies on CPython's small-int caching / string interning, neither guaranteed. (pyflakes F632) | hardening rule — real false positive fixed in litellm's own code, see below |
151
+ | **CH026** | `mutable-class-attribute` | `class C: items = []` mutated via `self.items.append(...)` without ever being reassigned per instance — every instance shares and mutates the *same* list. | **vllm**, **llama_index**, **optuna**, **transformers** — see below |
152
+ | **CH027** | `unwaited-subprocess` | `subprocess.Popen(...)` never `.wait()`ed/`.communicate()`d with, and not context-managed — risks a zombie process and a full pipe buffer deadlocking the child. | hardening rule — real hit in dspy (already handled, see below) |
153
+ | **CH028** | `floating-timer` | `threading.Timer(...)` started but never `.cancel()`ed or handed off — nothing can stop the callback from firing later, on stale context. | hardening rule — real hits in marimo, transformers |
148
154
 
149
155
  `codehound list` prints this from the source of truth.
150
156
 
151
- CH007-CH022 don't have found-and-merged bugs behind all of them the way
157
+ CH007-CH028 don't have found-and-merged bugs behind all of them the way
152
158
  CH001-CH006 do - most are hardening rules for well-known Python
153
159
  correctness gotchas rather than something this project personally
154
160
  tracked down first. CH010 and CH011 are the exceptions: both found
@@ -251,6 +257,60 @@ a `try:` body whose `except` catches `ImportError` (or anything
251
257
  broader). A full corpus rescan after both fixes found zero remaining
252
258
  CH021 hits.
253
259
 
260
+ **CH026 found four real, previously-unreported bugs on its first real
261
+ scan.** All four are the exact same shape: a class-level mutable default
262
+ (`items = []`) mutated in place via `self.items.append(...)` (or
263
+ subscript assignment) with no per-instance reassignment anywhere, so
264
+ every instance of the class shares and corrupts the *same* object. In
265
+ vllm's `AXK1ForCausalLM`, `self.packed_modules_mapping["qkv_proj"] =
266
+ [...]` patches a routing table shared by every instance of the model
267
+ class. In llama_index's `ZapierToolSpec`, `self.spec_functions.append(...)`
268
+ means a second tool-spec instance (a different API key, a different
269
+ user) inherits every action name the first instance ever registered. In
270
+ optuna's CLI `_Studies` command, `self._study_list_header.append(...)`
271
+ does the same to a table-header list. In HuggingFace transformers'
272
+ `CodeGenTokenizer`, `self.model_input_names.append("token_type_ids")`
273
+ means constructing one tokenizer with `return_token_type_ids=True`
274
+ silently changes what field every *other* `CodeGenTokenizer` instance in
275
+ the same process expects, regardless of how it was configured - exactly
276
+ the "spooky action at a distance" class of bug this project exists to
277
+ catch. None have PRs yet: vllm and transformers both require AI-assisted
278
+ PRs to carry an explicit disclosure, which this project's own policy
279
+ doesn't do, so those two are documented here rather than filed.
280
+
281
+ **CH028 hit the exact same name-collision problem CH018/CH022 already
282
+ had a guard for, because that guard didn't get reused.** The very first
283
+ corpus scan came back with ~30 hits, almost all in agno - which doesn't
284
+ use `threading.Timer` at all. `from agno.utils.timer import Timer` is
285
+ agno's own unrelated stopwatch class, called as `Timer()` with zero
286
+ arguments (real `threading.Timer` requires `interval` and `function` and
287
+ would raise `TypeError` immediately). Fixed by requiring `from threading
288
+ import Timer` before trusting a bare `Timer(...)` call - the same guard
289
+ already built for CH022's `coroutine` minutes earlier in the same
290
+ session, just not applied here the first time. Also missed CH009's
291
+ `daemon=True` escape entirely (found in weaviate-python-client's
292
+ watchdog timer, `_timeout_timer.daemon = True`, a deliberate
293
+ "outlive the caller" choice) - added both a constructor-kwarg and a
294
+ post-construction-assignment check for it, matching CH009 exactly. Real
295
+ hits remain in marimo (a non-daemon browser-opening timer, never
296
+ cancelled) and transformers (a chained, never-captured checkpoint-retry
297
+ timer).
298
+
299
+ **CH025 and CH027 each found one real bug in the first scan, and one
300
+ real gap in the check.** CH025 (is-literal-comparison) flagged litellm's
301
+ `if "usage" in response_obj is not None:` - but for the wrong reason.
302
+ `ast.Compare` puts every operand and every op from a chained comparison
303
+ in one node; checking "is there a literal anywhere" and "is there an
304
+ `is`/`is not` anywhere" independently, without pairing each op with its
305
+ own adjacent operands, matched the string literal (paired with `in`)
306
+ against the wrong op (`is not`, actually comparing `response_obj` to the
307
+ allowed singleton `None`). Fixed by walking the chain as adjacent
308
+ `(left, op, right)` triples. CH027 (unwaited-subprocess) flagged dspy's
309
+ `process = subprocess.Popen(...)`, followed by `lm.process = process` -
310
+ a real hand-off to a different object, reaped later through a separate
311
+ `terminate_process(lm.process)` call, the same "stored as any object's
312
+ attribute" escape CH009/CH016/CH028 already needed. Added it.
313
+
254
314
  **Two checks we built and did not ship.** `exception-chaining` (`except X
255
315
  as e: raise Y(...)` with no `from e`, discarding the real traceback -
256
316
  overlaps flake8-bugbear B904) worked exactly as designed, but at a scale
@@ -308,12 +368,18 @@ codehound/
308
368
  ├── removed_getargspec.py (CH019)
309
369
  ├── bare_except.py (CH020)
310
370
  ├── removed_stdlib_module.py (CH021)
311
- └── asyncio_coroutine_decorator.py (CH022)
371
+ ├── asyncio_coroutine_decorator.py (CH022)
372
+ ├── removed_stdlib_attribute.py (CH023)
373
+ ├── unittest_deprecated_alias.py (CH024)
374
+ ├── is_literal_comparison.py (CH025)
375
+ ├── mutable_class_attribute.py (CH026)
376
+ ├── unwaited_subprocess.py (CH027)
377
+ └── floating_timer.py (CH028)
312
378
  ```
313
379
 
314
380
  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.
315
381
 
316
- **False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
382
+ **False-positive discipline is a feature.** CH005 won't flag a handle that's `return`ed (the caller owns it) or explicitly `.close()`d. CH006 won't flag `TaskGroup.create_task` (the group holds the reference). CH001 only fires when the *enclosing* function is `async`. CH007 scopes `self.foo()` matches to async methods on the *same* class as the call site, and bare `foo()` matches to module-level async functions that aren't shadowed by a same-named parameter. CH009 doesn't flag a thread handed off as *any* object's attribute, not just `self`. CH010 only fires when a lambda is directly stored (appended, assigned, returned), not merely passed as a callback argument that gets consumed on the spot. CH016 doesn't flag a socket returned as part of a tuple/list, or passed as an argument to any call (as opposed to being the receiver of a call on itself) — real patterns found in vllm's rendezvous code. CH020 won't flag a `BaseException` handler whose bound name is actually referenced, or whose body re-raises anywhere in its own scope (not counting a nested try/except's own handler) — both real patterns found in agno. CH021 doesn't flag a relative import (`node.level != 0`) of a same-named local module, or an import already inside a `try:`/`except ImportError:` fallback — real patterns found in vllm and agno respectively. CH025 pairs each chained comparison's op with only its own adjacent operands, rather than matching a literal and an `is`/`is not` anywhere in the same chain independently — a real pattern found in litellm. CH027 and CH028 both recognize a handle stored as *any* object's attribute as a hand-off, matching CH009/CH016's precedent — real patterns found in dspy and weaviate-python-client respectively. CH028 also only trusts a bare `Timer(...)` when `from threading import Timer` was actually seen — real hits in agno were its own unrelated stopwatch class. All of those guards exist because of real false positives caught while building the checks (see above and [`docs/FINDINGS.md`](docs/FINDINGS.md)). The test suite asserts both "bad code is flagged" and "correct code is not."
317
383
 
318
384
  ---
319
385
 
@@ -345,6 +411,9 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
345
411
  sockets, removed-in-3.9/3.10/3.11 stdlib APIs, bare `except:` — CH011-CH020
346
412
  - [x] 22 checks — removed stdlib modules (`distutils`, PEP 594 "dead
347
413
  batteries"), removed `@asyncio.coroutine` decorator — CH021-CH022
414
+ - [x] 28 checks — removed stdlib functions, deprecated unittest aliases,
415
+ `is`-literal comparisons, mutable class attributes, unwaited
416
+ subprocesses, floating timers — CH023-CH028
348
417
  - [ ] Cross-module resolution for CH007/CH009 (currently same-file only)
349
418
  - [ ] Sync HTTP clients constructed inside async request handlers
350
419
  - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
@@ -1,9 +1,9 @@
1
1
  """codehound - an AST-based static analyzer that hunts real bugs in Python code.
2
2
 
3
- Twenty-two checks. Eight are each backed by a bug that was actually found
4
- and fixed (or opened as a PR) in a popular open-source AI framework
3
+ Twenty-eight checks. Eight are each backed by a bug that was actually
4
+ found and fixed (or opened as a PR) in a popular open-source AI framework
5
5
  (agno, crewAI, mem0, llama_index, accelerate, optuna, litellm). The rest
6
- (CH007-CH009, CH012-CH022) are hardening rules verified against real
6
+ (CH007-CH009, CH012-CH028) are hardening rules verified against real
7
7
  false positives across a ~29-framework validation corpus instead - see
8
8
  docs/FINDINGS.md.
9
9
  """
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
  from codehound.checks import ALL_CHECKS, get_checks
14
14
  from codehound.core import Check, Finding, scan_file, scan_path
15
15
 
16
- __version__ = "1.5.0"
16
+ __version__ = "1.6.0"
17
17
 
18
18
  __all__ = [
19
19
  "ALL_CHECKS",
@@ -13,17 +13,23 @@ from codehound.checks.discarded_future import DiscardedFuture
13
13
  from codehound.checks.floating_process import FloatingProcess
14
14
  from codehound.checks.floating_task import FloatingTask
15
15
  from codehound.checks.floating_thread import FloatingThread
16
+ from codehound.checks.floating_timer import FloatingTimer
16
17
  from codehound.checks.get_event_loop import DeprecatedGetEventLoop
18
+ from codehound.checks.is_literal_comparison import IsLiteralComparison
17
19
  from codehound.checks.loop_closure_capture import LoopClosureCapture
18
20
  from codehound.checks.lru_cache_on_method import LruCacheOnMethod
21
+ from codehound.checks.mutable_class_attribute import MutableClassAttribute
19
22
  from codehound.checks.mutable_defaults import MutableDefaultArgument
20
23
  from codehound.checks.removed_asyncio_task_methods import RemovedAsyncioTaskMethods
21
24
  from codehound.checks.removed_getargspec import RemovedGetargspec
25
+ from codehound.checks.removed_stdlib_attribute import RemovedStdlibAttribute
22
26
  from codehound.checks.removed_stdlib_module import RemovedStdlibModule
23
27
  from codehound.checks.resource_leak import UnclosedFileHandle
24
28
  from codehound.checks.unawaited_coroutine import UnawaitedCoroutineCall
25
29
  from codehound.checks.unclosed_socket import UnclosedSocket
30
+ from codehound.checks.unittest_deprecated_alias import UnittestDeprecatedAlias
26
31
  from codehound.checks.unprotected_lock import UnprotectedLockAcquire
32
+ from codehound.checks.unwaited_subprocess import UnwaitedSubprocess
27
33
  from codehound.core import Check
28
34
 
29
35
  ALL_CHECKS: list[type[Check]] = [
@@ -49,6 +55,12 @@ ALL_CHECKS: list[type[Check]] = [
49
55
  BareExcept,
50
56
  RemovedStdlibModule,
51
57
  AsyncioCoroutineDecorator,
58
+ RemovedStdlibAttribute,
59
+ UnittestDeprecatedAlias,
60
+ IsLiteralComparison,
61
+ MutableClassAttribute,
62
+ UnwaitedSubprocess,
63
+ FloatingTimer,
52
64
  ]
53
65
 
54
66
 
@@ -0,0 +1,169 @@
1
+ """CH028 - ``threading.Timer`` started without keeping a way to cancel it.
2
+
3
+ A `Timer` schedules its callback to run *later*, on its own thread, once
4
+ the interval elapses. Unlike a plain `Thread` doing work right now, the
5
+ risk with an un-captured or un-cancelled `Timer` isn't just "outlives the
6
+ function" - it's that nothing can stop the callback from firing after
7
+ the context it was meant to act on is already gone: a retry timer that
8
+ fires after the operation it would retry has already completed, a
9
+ debounce timer that resets state on an object that was already torn
10
+ down. `.cancel()` before that happens is the only way to prevent it; if
11
+ nothing ever keeps a reference capable of calling it, the callback is
12
+ guaranteed to run no matter what changes in the meantime.
13
+
14
+ Same two shapes as CH009's floating-thread:
15
+ - Chained: ``threading.Timer(30, cb).start()`` - never captured, so
16
+ cancelling it later is structurally impossible.
17
+ - Assigned: ``t = threading.Timer(30, cb); t.start()`` - captured, but no
18
+ matching ``t.cancel()`` before the enclosing function returns, and not
19
+ handed off (returned, stored as any object's attribute), and not
20
+ marked ``daemon=True`` (a deliberate "let this outlive the caller"
21
+ choice, the same escape CH009 recognizes).
22
+
23
+ A bare ``Timer(...)`` is only trusted to mean `threading.Timer` if the
24
+ file actually imported it via `from threading import Timer` - the same
25
+ name-collision guard CH018/CH022 use. A real false positive found while
26
+ building this: agno defines its own unrelated stopwatch-style `Timer`
27
+ class (`from agno.utils.timer import Timer`, called as `Timer()` with no
28
+ arguments at all - `threading.Timer` requires `interval` and `function`
29
+ and would raise `TypeError` immediately if it were really that class),
30
+ and every one of its ~30 corpus hits was this same collision.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import ast
36
+
37
+ from codehound.core import Check, Finding, enclosing_function
38
+
39
+
40
+ def _imports_timer_from_threading(tree: ast.AST) -> bool:
41
+ for node in ast.walk(tree):
42
+ if isinstance(node, ast.ImportFrom) and node.module == "threading":
43
+ if any(alias.name == "Timer" for alias in node.names):
44
+ return True
45
+ return False
46
+
47
+
48
+ def _is_timer_call(node: ast.expr, trust_bare_name: bool) -> bool:
49
+ if not isinstance(node, ast.Call):
50
+ return False
51
+ func = node.func
52
+ if isinstance(func, ast.Attribute):
53
+ return func.attr == "Timer" and isinstance(func.value, ast.Name) and func.value.id == "threading"
54
+ if isinstance(func, ast.Name):
55
+ return trust_bare_name and func.id == "Timer"
56
+ return False
57
+
58
+
59
+ def _has_daemon_true_kwarg(call: ast.Call) -> bool:
60
+ for kw in call.keywords:
61
+ if kw.arg == "daemon" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
62
+ return True
63
+ return False
64
+
65
+
66
+ class FloatingTimer(Check):
67
+ code = "CH028"
68
+ name = "floating-timer"
69
+ description = "threading.Timer started without keeping a way to cancel() it."
70
+
71
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
72
+ findings: list[Finding] = []
73
+ trust_bare_name = _imports_timer_from_threading(tree)
74
+
75
+ # Chained: threading.Timer(...).start() with no assignment at all.
76
+ for node in ast.walk(tree):
77
+ if not isinstance(node, ast.Expr) or not isinstance(node.value, ast.Call):
78
+ continue
79
+ call = node.value
80
+ if not (isinstance(call.func, ast.Attribute) and call.func.attr == "start"):
81
+ continue
82
+ receiver = call.func.value
83
+ if not _is_timer_call(receiver, trust_bare_name):
84
+ continue
85
+ if isinstance(receiver, ast.Call) and _has_daemon_true_kwarg(receiver):
86
+ continue
87
+ findings.append(
88
+ Finding(
89
+ path=path,
90
+ line=node.lineno,
91
+ col=node.col_offset,
92
+ code=self.code,
93
+ message=(
94
+ "`threading.Timer(...).start()` - the timer is never captured, so it can "
95
+ "never be cancelled; keep a reference so it can be cancel()ed if the "
96
+ "context it would act on goes away before it fires."
97
+ ),
98
+ )
99
+ )
100
+
101
+ # Assigned: t = threading.Timer(...); ... t.start() ... [no t.cancel()]
102
+ for node in ast.walk(tree):
103
+ if not isinstance(node, ast.Assign) or not _is_timer_call(node.value, trust_bare_name):
104
+ continue
105
+ if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name):
106
+ continue
107
+ name = node.targets[0].id
108
+ if _has_daemon_true_kwarg(node.value):
109
+ continue
110
+
111
+ fn = enclosing_function(node, parents)
112
+ if fn is None:
113
+ continue
114
+
115
+ started = False
116
+ cancelled = False
117
+ escapes = False
118
+ for n in ast.walk(fn):
119
+ if (
120
+ isinstance(n, ast.Call)
121
+ and isinstance(n.func, ast.Attribute)
122
+ and isinstance(n.func.value, ast.Name)
123
+ and n.func.value.id == name
124
+ ):
125
+ if n.func.attr == "start":
126
+ started = True
127
+ elif n.func.attr == "cancel":
128
+ cancelled = True
129
+ elif isinstance(n, ast.Assign):
130
+ for tgt in n.targets:
131
+ if (
132
+ isinstance(tgt, ast.Attribute)
133
+ and tgt.attr != "daemon"
134
+ and isinstance(n.value, ast.Name)
135
+ and n.value.id == name
136
+ ):
137
+ # Handed off as any object's attribute - the
138
+ # receiver can cancel it later.
139
+ escapes = True
140
+ if (
141
+ isinstance(tgt, ast.Attribute)
142
+ and isinstance(tgt.value, ast.Name)
143
+ and tgt.value.id == name
144
+ and tgt.attr == "daemon"
145
+ and isinstance(n.value, ast.Constant)
146
+ and n.value.value is True
147
+ ):
148
+ escapes = True # daemon set post-construction
149
+ elif isinstance(n, ast.Return) and isinstance(n.value, ast.Name) and n.value.id == name:
150
+ escapes = True
151
+
152
+ if not started or cancelled or escapes:
153
+ continue
154
+
155
+ findings.append(
156
+ Finding(
157
+ path=path,
158
+ line=node.lineno,
159
+ col=node.col_offset,
160
+ code=self.code,
161
+ message=(
162
+ f"`{name} = threading.Timer(...)` in `{fn.name}` is started but never "
163
+ f"cancelled or handed off; nothing can stop it from firing later. Call "
164
+ f"`{name}.cancel()` when it's no longer needed, return/store it so the "
165
+ f"caller can, or pass `daemon=True` if that's intentional."
166
+ ),
167
+ )
168
+ )
169
+ return findings
@@ -0,0 +1,80 @@
1
+ """CH025 - ``is``/``is not`` comparing against a str/bytes/int/float literal.
2
+
3
+ `is` checks object *identity*, not equality - `x is 5` only happens to
4
+ work because CPython caches small integers (-5 to 256) and sometimes
5
+ interns short string literals, neither of which is a language guarantee.
6
+ `x is 1000` can be `False` even when `x == 1000` is `True`, and whether a
7
+ given string literal gets interned depends on the compiler and the
8
+ string's exact contents. Code that "works" this way is one PyPy run, one
9
+ larger number, or one differently-compiled string away from silently
10
+ returning the wrong answer - this is pyflakes' own F632, included here
11
+ because a bug this easy to introduce by typo (`is` for `==`) and this
12
+ quiet when it happens to pass by accident earns a place in a tool whose
13
+ whole premise is "subtle correctness bugs," not just style.
14
+
15
+ `is None` / `is True` / `is False` / `is ...` are the actual idiomatic
16
+ uses of `is` and are never flagged.
17
+
18
+ A chained comparison (`a in b is not None`) has every operand and every
19
+ op in one `ast.Compare` node, but they aren't a free-for-all - each op
20
+ only applies to the two operands adjacent to it. `"usage" in response_obj
21
+ is not None` is `("usage" in response_obj) and (response_obj is not
22
+ None)`: the string literal is the left side of `in`, not of `is not`,
23
+ which is actually comparing `response_obj` against the *allowed*
24
+ singleton `None`. Real false positive found in litellm: checking "is
25
+ there a literal anywhere in the chain" and "is there an Is/IsNot op
26
+ anywhere in the chain" independently, without pairing each op with its
27
+ own adjacent operands, matched this for the wrong reason. Fixed by
28
+ walking the chain as adjacent (left, op, right) triples.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import ast
34
+
35
+ from codehound.core import Check, Finding
36
+
37
+ _ALLOWED_SINGLETONS = (None, True, False, Ellipsis)
38
+
39
+
40
+ def _is_flagged_literal(node: ast.expr) -> bool:
41
+ if not isinstance(node, ast.Constant):
42
+ return False
43
+ value = node.value
44
+ if any(value is s for s in _ALLOWED_SINGLETONS):
45
+ return False
46
+ return isinstance(value, (str, bytes, int, float, complex))
47
+
48
+
49
+ class IsLiteralComparison(Check):
50
+ code = "CH025"
51
+ name = "is-literal-comparison"
52
+ description = "`is`/`is not` compares identity, not equality; use ==/!= for str/bytes/int/float literals."
53
+
54
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
55
+ findings: list[Finding] = []
56
+ for node in ast.walk(tree):
57
+ if not isinstance(node, ast.Compare):
58
+ continue
59
+ operands = [node.left, *node.comparators]
60
+ for left, op, right in zip(operands, node.ops, operands[1:]):
61
+ if not isinstance(op, (ast.Is, ast.IsNot)):
62
+ continue
63
+ if not (_is_flagged_literal(left) or _is_flagged_literal(right)):
64
+ continue
65
+ op_text = "is not" if isinstance(op, ast.IsNot) else "is"
66
+ findings.append(
67
+ Finding(
68
+ path=path,
69
+ line=node.lineno,
70
+ col=node.col_offset,
71
+ code=self.code,
72
+ message=(
73
+ f"`{op_text}` compares identity, not equality - comparing against a "
74
+ f"literal here relies on CPython implementation details (small-int "
75
+ f"caching, string interning) that aren't guaranteed; use "
76
+ f"`{'!=' if op_text == 'is not' else '=='}` instead."
77
+ ),
78
+ )
79
+ )
80
+ return findings