codehound 0.1.0__tar.gz → 1.1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codehound
3
- Version: 0.1.0
3
+ Version: 1.1.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
@@ -20,9 +20,10 @@ Description-Content-Type: text/markdown
20
20
 
21
21
  # 🐕 codehound
22
22
 
23
- **An AST-based static analyzer that hunts *real* bugs in large Python codebases — every rule is backed by a bug that was actually found and merged into a major open-source AI framework.**
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.**
24
24
 
25
25
  [![CI](https://github.com/kratos0718/codehound/actions/workflows/ci.yml/badge.svg)](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
26
+ [![PyPI](https://img.shields.io/pypi/v/codehound.svg)](https://pypi.org/project/codehound/)
26
27
  ![Python](https://img.shields.io/badge/python-3.9%2B-blue)
27
28
  ![License](https://img.shields.io/badge/license-MIT-green)
28
29
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21851079.svg)](https://doi.org/10.5281/zenodo.21851079)
@@ -63,13 +64,23 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
63
64
  ## Install
64
65
 
65
66
  ```bash
66
- # from a clone (modern pip)
67
+ pip install codehound
68
+ ```
69
+
70
+ Zero dependencies — it's ~750 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
71
+
72
+ <details>
73
+ <summary>From a clone instead (for development)</summary>
74
+
75
+ ```bash
67
76
  pip install -e .
68
77
 
69
78
  # or run straight from source, no install needed
70
79
  PYTHONPATH=src python -m codehound.cli scan path/to/project
71
80
  ```
72
81
 
82
+ </details>
83
+
73
84
  ## Usage
74
85
 
75
86
  ```bash
@@ -105,9 +116,26 @@ codehound list
105
116
  | **CH004** | `deprecated-get-event-loop` | `asyncio.get_event_loop()` outside a running loop — deprecated since 3.10. | crewAI structured-tool / Snowflake search tool |
106
117
  | **CH005** | `unclosed-file-handle` | `f = open(...)` with no `with` and no matching `.close()` — leaks descriptors until `RLIMIT_NOFILE` is exhausted. | agno `OpenAITools.transcribe_audio` |
107
118
  | **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
+ | **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 |
108
120
 
109
121
  `codehound list` prints this from the source of truth.
110
122
 
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).
138
+
111
139
  ---
112
140
 
113
141
  ## How it works
@@ -124,12 +152,13 @@ codehound/
124
152
  ├── datetime_utcnow.py (CH003)
125
153
  ├── get_event_loop.py (CH004)
126
154
  ├── resource_leak.py (CH005)
127
- └── floating_task.py (CH006)
155
+ ├── floating_task.py (CH006)
156
+ └── unawaited_coroutine.py (CH007)
128
157
  ```
129
158
 
130
159
  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.
131
160
 
132
- **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`. The test suite asserts both "bad code is flagged" and "correct code is not."
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."
133
162
 
134
163
  ---
135
164
 
@@ -146,10 +175,12 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
146
175
 
147
176
  ## Roadmap
148
177
 
149
- - [ ] `await` on a non-awaited coroutine (missing-await detection)
178
+ - [x] `await` on a non-awaited coroutine (missing-await detection) — CH007
179
+ - [x] PyPI release — `pip install codehound`
180
+ - [ ] Cross-module resolution for CH007 (currently same-file only)
150
181
  - [ ] Sync HTTP clients constructed inside async request handlers
151
182
  - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
152
- - [ ] Pre-commit hook + PyPI release
183
+ - [ ] Pre-commit hook
153
184
 
154
185
  ---
155
186
 
@@ -1,8 +1,9 @@
1
1
  # 🐕 codehound
2
2
 
3
- **An AST-based static analyzer that hunts *real* bugs in large Python codebases — every rule is backed by a bug that was actually found and merged into a major open-source AI framework.**
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.**
4
4
 
5
5
  [![CI](https://github.com/kratos0718/codehound/actions/workflows/ci.yml/badge.svg)](https://github.com/kratos0718/codehound/actions/workflows/ci.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/codehound.svg)](https://pypi.org/project/codehound/)
6
7
  ![Python](https://img.shields.io/badge/python-3.9%2B-blue)
7
8
  ![License](https://img.shields.io/badge/license-MIT-green)
8
9
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21851079.svg)](https://doi.org/10.5281/zenodo.21851079)
@@ -43,13 +44,23 @@ I was contributing bug fixes to large AI frameworks and noticed the same handful
43
44
  ## Install
44
45
 
45
46
  ```bash
46
- # from a clone (modern pip)
47
+ pip install codehound
48
+ ```
49
+
50
+ Zero dependencies — it's ~750 lines on top of the standard-library `ast` module, so this installs instantly and runs fully offline, no API key or network call involved.
51
+
52
+ <details>
53
+ <summary>From a clone instead (for development)</summary>
54
+
55
+ ```bash
47
56
  pip install -e .
48
57
 
49
58
  # or run straight from source, no install needed
50
59
  PYTHONPATH=src python -m codehound.cli scan path/to/project
51
60
  ```
52
61
 
62
+ </details>
63
+
53
64
  ## Usage
54
65
 
55
66
  ```bash
@@ -85,9 +96,26 @@ codehound list
85
96
  | **CH004** | `deprecated-get-event-loop` | `asyncio.get_event_loop()` outside a running loop — deprecated since 3.10. | crewAI structured-tool / Snowflake search tool |
86
97
  | **CH005** | `unclosed-file-handle` | `f = open(...)` with no `with` and no matching `.close()` — leaks descriptors until `RLIMIT_NOFILE` is exhausted. | agno `OpenAITools.transcribe_audio` |
87
98
  | **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
+ | **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 |
88
100
 
89
101
  `codehound list` prints this from the source of truth.
90
102
 
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).
118
+
91
119
  ---
92
120
 
93
121
  ## How it works
@@ -104,12 +132,13 @@ codehound/
104
132
  ├── datetime_utcnow.py (CH003)
105
133
  ├── get_event_loop.py (CH004)
106
134
  ├── resource_leak.py (CH005)
107
- └── floating_task.py (CH006)
135
+ ├── floating_task.py (CH006)
136
+ └── unawaited_coroutine.py (CH007)
108
137
  ```
109
138
 
110
139
  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.
111
140
 
112
- **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`. The test suite asserts both "bad code is flagged" and "correct code is not."
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."
113
142
 
114
143
  ---
115
144
 
@@ -126,10 +155,12 @@ Every check has paired tests: the buggy pattern *is* flagged, and the idiomatic
126
155
 
127
156
  ## Roadmap
128
157
 
129
- - [ ] `await` on a non-awaited coroutine (missing-await detection)
158
+ - [x] `await` on a non-awaited coroutine (missing-await detection) — CH007
159
+ - [x] PyPI release — `pip install codehound`
160
+ - [ ] Cross-module resolution for CH007 (currently same-file only)
130
161
  - [ ] Sync HTTP clients constructed inside async request handlers
131
162
  - [ ] `--fix` for the mechanical rules (CH002, CH003, CH004)
132
- - [ ] Pre-commit hook + PyPI release
163
+ - [ ] Pre-commit hook
133
164
 
134
165
  ---
135
166
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codehound"
7
- version = "0.1.0"
7
+ dynamic = ["version"]
8
8
  description = "An AST-based static analyzer that hunts real correctness and async-safety bugs in Python code."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -30,6 +30,9 @@ codehound = "codehound.cli:main"
30
30
  [project.optional-dependencies]
31
31
  dev = ["pytest>=7"]
32
32
 
33
+ [tool.hatch.version]
34
+ path = "src/codehound/__init__.py"
35
+
33
36
  [tool.hatch.build.targets.wheel]
34
37
  packages = ["src/codehound"]
35
38
 
@@ -1,7 +1,9 @@
1
1
  """codehound - an AST-based static analyzer that hunts real bugs in Python code.
2
2
 
3
- Six checks, each backed by a bug that was actually found and fixed in a popular
4
- open-source AI framework (agno, crewAI, mem0, huggingface_hub).
3
+ Seven checks. Six are each backed by a bug that was actually found and
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
7
  """
6
8
 
7
9
  from __future__ import annotations
@@ -9,7 +11,7 @@ from __future__ import annotations
9
11
  from codehound.checks import ALL_CHECKS, get_checks
10
12
  from codehound.core import Check, Finding, scan_file, scan_path
11
13
 
12
- __version__ = "0.1.0"
14
+ __version__ = "1.1.0"
13
15
 
14
16
  __all__ = [
15
17
  "ALL_CHECKS",
@@ -8,6 +8,7 @@ from codehound.checks.floating_task import FloatingTask
8
8
  from codehound.checks.get_event_loop import DeprecatedGetEventLoop
9
9
  from codehound.checks.mutable_defaults import MutableDefaultArgument
10
10
  from codehound.checks.resource_leak import UnclosedFileHandle
11
+ from codehound.checks.unawaited_coroutine import UnawaitedCoroutineCall
11
12
  from codehound.core import Check
12
13
 
13
14
  ALL_CHECKS: list[type[Check]] = [
@@ -17,6 +18,7 @@ ALL_CHECKS: list[type[Check]] = [
17
18
  DeprecatedGetEventLoop,
18
19
  UnclosedFileHandle,
19
20
  FloatingTask,
21
+ UnawaitedCoroutineCall,
20
22
  ]
21
23
 
22
24
 
@@ -0,0 +1,113 @@
1
+ """CH007 - Coroutine called without ``await`` (or scheduling).
2
+
3
+ Calling an ``async def`` function produces a coroutine object; it does not run
4
+ any of the function's body until something ``await``s it, wraps it in
5
+ ``asyncio.create_task``/``asyncio.gather``, or otherwise drives it. A bare
6
+ ``foo()`` where ``foo`` is a coroutine function, used as a standalone
7
+ statement, creates the coroutine and immediately discards it - the work
8
+ inside never executes at all, and Python emits a
9
+ ``RuntimeWarning: coroutine 'foo' was never awaited`` (easy to miss if
10
+ warnings aren't surfaced in CI).
11
+
12
+ This is a stricter cousin of CH006 (floating-task): CH006's task is at least
13
+ *scheduled* and can still fail mid-run; here nothing ever starts.
14
+
15
+ Precision matters a lot here specifically because the sync/async "twin
16
+ method" convention (a `def foo` and an `async def foo` on two different
17
+ classes, e.g. `Toolkit` vs `AsyncToolkit`) is common in exactly the
18
+ codebases this tool targets - a naive whole-file name match flags every
19
+ `self.foo()` call against *any* same-named async method anywhere in the
20
+ file, sync twin included. Two real false positives found this way while
21
+ building this check (agno's `ZepTools`/`ZepAsyncTools.initialize`, and a
22
+ `write` call parameter shadowed by an unrelated same-named async closure
23
+ hundreds of lines away) are why the matching below is scoped:
24
+
25
+ - `foo()` (bare name): only matches a module-level `async def foo`, and
26
+ only if `foo` isn't also a parameter of the enclosing function (a
27
+ parameter shadows any outer name with the same spelling).
28
+ - `self.foo()` / `cls.foo()`: only matches an `async def foo` defined
29
+ directly in the *same* enclosing class as the call site - not a
30
+ same-named method on an unrelated class elsewhere in the file.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import ast
36
+
37
+ from codehound.core import Check, Finding, enclosing_class, enclosing_function
38
+
39
+
40
+ class UnawaitedCoroutineCall(Check):
41
+ code = "CH007"
42
+ name = "unawaited-coroutine-call"
43
+ description = "async function called without await/create_task; the coroutine never runs."
44
+
45
+ def run(self, tree: ast.AST, parents: dict, path: str) -> list[Finding]:
46
+ module_level_async: set[str] = set()
47
+ class_async_methods: dict[int, set[str]] = {}
48
+
49
+ for node in ast.walk(tree):
50
+ if not isinstance(node, ast.AsyncFunctionDef):
51
+ continue
52
+ parent = parents.get(id(node))
53
+ if isinstance(parent, ast.Module):
54
+ module_level_async.add(node.name)
55
+ elif isinstance(parent, ast.ClassDef):
56
+ class_async_methods.setdefault(id(parent), set()).add(node.name)
57
+
58
+ if not module_level_async and not class_async_methods:
59
+ return []
60
+
61
+ findings: list[Finding] = []
62
+ for node in ast.walk(tree):
63
+ if not isinstance(node, ast.Expr) or not isinstance(node.value, ast.Call):
64
+ continue
65
+ call = node.value
66
+ func = call.func
67
+ called_name = None
68
+
69
+ if isinstance(func, ast.Name):
70
+ if func.id in module_level_async and not self._is_shadowed_param(
71
+ func.id, node, parents
72
+ ):
73
+ called_name = func.id
74
+ elif isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
75
+ if func.value.id in ("self", "cls"):
76
+ owning_class = enclosing_class(node, parents)
77
+ if owning_class is not None and func.attr in class_async_methods.get(
78
+ id(owning_class), set()
79
+ ):
80
+ called_name = func.attr
81
+
82
+ if called_name is None:
83
+ continue
84
+
85
+ findings.append(
86
+ Finding(
87
+ path=path,
88
+ line=node.lineno,
89
+ col=node.col_offset,
90
+ code=self.code,
91
+ message=(
92
+ f"`{called_name}(...)` is an async function called without `await` "
93
+ f"or scheduling; the coroutine is created and immediately discarded, "
94
+ f"so its body never runs."
95
+ ),
96
+ )
97
+ )
98
+ return findings
99
+
100
+ @staticmethod
101
+ def _is_shadowed_param(name: str, node: ast.AST, parents: dict) -> bool:
102
+ fn = enclosing_function(node, parents)
103
+ if fn is None:
104
+ return False
105
+ args = fn.args
106
+ all_params = (
107
+ args.posonlyargs
108
+ + args.args
109
+ + args.kwonlyargs
110
+ + ([args.vararg] if args.vararg else [])
111
+ + ([args.kwarg] if args.kwarg else [])
112
+ )
113
+ return any(a.arg == name for a in all_params)
@@ -110,6 +110,25 @@ def enclosing_function(node: ast.AST, parents: dict):
110
110
  return None
111
111
 
112
112
 
113
+ def enclosing_class(node: ast.AST, parents: dict) -> ast.ClassDef | None:
114
+ """Return the nearest enclosing ClassDef, or None.
115
+
116
+ Stops at the first FunctionDef/AsyncFunctionDef boundary that isn't
117
+ itself inside the class body being searched for - i.e. this walks up
118
+ through nested functions too, so a method's inner helper still resolves
119
+ to the class it's defined in.
120
+ """
121
+ cur = node
122
+ while cur is not None:
123
+ p = parents.get(id(cur))
124
+ if p is None:
125
+ return None
126
+ if isinstance(p, ast.ClassDef):
127
+ return p
128
+ cur = p
129
+ return None
130
+
131
+
113
132
  def is_awaited(node: ast.AST, parents: dict) -> bool:
114
133
  """True if the call ``node`` is the direct operand of an ``await``.
115
134
 
@@ -170,3 +170,113 @@ def test_ch006_ignores_taskgroup_create_task():
170
170
  " tg.create_task(coro)\n"
171
171
  )
172
172
  assert _run(code, ["CH006"]) == []
173
+
174
+
175
+ # --- CH007 unawaited-coroutine-call ------------------------------------------------
176
+
177
+
178
+ def test_ch007_flags_bare_call_to_async_function():
179
+ code = (
180
+ "async def fetch():\n"
181
+ " ...\n"
182
+ "async def f():\n"
183
+ " fetch()\n"
184
+ )
185
+ findings = _run(code, ["CH007"])
186
+ assert len(findings) == 1
187
+ assert findings[0].code == "CH007"
188
+
189
+
190
+ def test_ch007_ignores_awaited_call():
191
+ code = (
192
+ "async def fetch():\n"
193
+ " ...\n"
194
+ "async def f():\n"
195
+ " await fetch()\n"
196
+ )
197
+ assert _run(code, ["CH007"]) == []
198
+
199
+
200
+ def test_ch007_ignores_call_wrapped_in_create_task():
201
+ code = (
202
+ "import asyncio\n"
203
+ "async def fetch():\n"
204
+ " ...\n"
205
+ "async def f():\n"
206
+ " asyncio.create_task(fetch())\n"
207
+ )
208
+ assert _run(code, ["CH007"]) == []
209
+
210
+
211
+ def test_ch007_ignores_assigned_result():
212
+ code = (
213
+ "async def fetch():\n"
214
+ " ...\n"
215
+ "async def f():\n"
216
+ " coro = fetch()\n"
217
+ " await coro\n"
218
+ )
219
+ assert _run(code, ["CH007"]) == []
220
+
221
+
222
+ def test_ch007_ignores_call_to_sync_function():
223
+ code = (
224
+ "def fetch():\n"
225
+ " ...\n"
226
+ "async def f():\n"
227
+ " fetch()\n"
228
+ )
229
+ assert _run(code, ["CH007"]) == []
230
+
231
+
232
+ def test_ch007_flags_bare_call_to_async_method_via_self():
233
+ code = (
234
+ "class C:\n"
235
+ " async def fetch(self):\n"
236
+ " ...\n"
237
+ " async def f(self):\n"
238
+ " self.fetch()\n"
239
+ )
240
+ findings = _run(code, ["CH007"])
241
+ assert len(findings) == 1
242
+ assert findings[0].code == "CH007"
243
+
244
+
245
+ def test_ch007_ignores_returned_coroutine():
246
+ code = (
247
+ "async def fetch():\n"
248
+ " ...\n"
249
+ "def f():\n"
250
+ " return fetch()\n"
251
+ )
252
+ assert _run(code, ["CH007"]) == []
253
+
254
+
255
+ def test_ch007_ignores_sync_twin_method_on_different_class():
256
+ # Real false positive found in agno: ZepTools.initialize is sync,
257
+ # ZepAsyncTools.initialize (a different class) is async - calling
258
+ # self.initialize() from ZepTools must not match the unrelated twin.
259
+ code = (
260
+ "class ZepTools:\n"
261
+ " def __init__(self):\n"
262
+ " self.initialize()\n"
263
+ " def initialize(self):\n"
264
+ " ...\n"
265
+ "class ZepAsyncTools:\n"
266
+ " async def initialize(self):\n"
267
+ " ...\n"
268
+ )
269
+ assert _run(code, ["CH007"]) == []
270
+
271
+
272
+ def test_ch007_ignores_name_shadowed_by_parameter():
273
+ # Real false positive found in agno: an unrelated `async def write`
274
+ # exists elsewhere in the file, but `write` here is a plain callable
275
+ # parameter, not a reference to that function.
276
+ code = (
277
+ "def run_with_retry(write):\n"
278
+ " write()\n"
279
+ "async def write():\n"
280
+ " ...\n"
281
+ )
282
+ assert _run(code, ["CH007"]) == []
File without changes
File without changes