Skip to content

Commit 1b3bb6d

Browse files
author
wryyyds7
committed
[#139] find_callers_by_method_name: find all call sites of a method
Implement MCP tool to find all callers of a Java method by its fully qualified name. Three-step architecture: 1. Index building: method_index (method FQN → info) + class_index (class FQN → extends/implements/all_parents) 2. Call site extraction: regex search for receiver.methodName( and Receiver::methodName patterns, skip method declarations and comments 3. Type resolution: infer receiver type from field declarations, method parameters, local variables, import map, and class index Supports: - Instance calls (obj.method()) - Static calls (Cls.method()) - Method references (Foo::method) - include_inherited_callers (expand to parent/child class methods) - include_test_code filter - max_results truncation Reuses #134/#137/#138 core components. Test data: java-design-patterns (~1900 files, < 10s)
1 parent 3115234 commit 1b3bb6d

3 files changed

Lines changed: 1221 additions & 0 deletions

File tree

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
"""
2+
测试 find_callers_by_method_name tool
3+
4+
覆盖验收标准:
5+
- [x] 正确解析: 普通实例调用、静态调用、链式调用、方法引用
6+
- [x] 跳过注释、字符串内的伪命中
7+
- [x] 单元测试覆盖:静态/实例/方法引用/链式/匿名类内调用
8+
- [x] 中等仓库响应 < 10s
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import os
14+
import time
15+
16+
import pytest
17+
from fastmcp import FastMCP
18+
19+
from yasa_mcp.tools.find_callers_by_method_name import find_callers_by_method_name
20+
from yasa_mcp.registry import auto_discover_tools
21+
22+
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
23+
_JDP_REPO = os.path.join(_REPO_ROOT, "test-repos", "java-design-patterns")
24+
25+
26+
def _create_file(root: str, relpath: str, content: str) -> str:
27+
abspath = os.path.join(root, relpath)
28+
os.makedirs(os.path.dirname(abspath), exist_ok=True)
29+
with open(abspath, "w", encoding="utf-8") as f:
30+
f.write(content)
31+
return abspath
32+
33+
34+
# 测试数据
35+
_DAO_CODE = """\
36+
package com.example.dao;
37+
38+
public class UserDao {
39+
public User findById(Long id) {
40+
return null;
41+
}
42+
43+
public static UserDao getInstance() {
44+
return new UserDao();
45+
}
46+
}
47+
"""
48+
49+
_SERVICE_CODE = """\
50+
package com.example.service;
51+
52+
import com.example.dao.UserDao;
53+
54+
public class UserService {
55+
private UserDao userDao;
56+
57+
// userDao.findById(id) in comment should not match
58+
59+
public void getUser(Long id) {
60+
User user = userDao.findById(id);
61+
System.out.println(user);
62+
}
63+
64+
public void staticCall() {
65+
UserDao.getInstance();
66+
}
67+
68+
public void methodRef() {
69+
Function<Long, User> func = userDao::findById;
70+
}
71+
}
72+
"""
73+
74+
# 测试数据: 注释中的伪命中
75+
_COMMENT_CODE = """\
76+
package com.example.service;
77+
78+
import com.example.dao.UserDao;
79+
80+
public class CommentService {
81+
private UserDao userDao;
82+
83+
public void process() {
84+
// userDao.findById(1);
85+
/* userDao.findById(2); */
86+
String s = "userDao.findById(3)";
87+
userDao.findById(4);
88+
}
89+
}
90+
"""
91+
92+
93+
# ---------------------------------------------------------------------------
94+
# 基本调用点查找
95+
# ---------------------------------------------------------------------------
96+
97+
class TestBasicCallers:
98+
"""测试基本调用点查找"""
99+
100+
@pytest.mark.asyncio
101+
async def test_instance_call(self, tmp_path):
102+
"""普通实例调用"""
103+
_create_file(tmp_path, "src/main/java/com/example/dao/UserDao.java", _DAO_CODE)
104+
_create_file(tmp_path, "src/main/java/com/example/service/UserService.java", _SERVICE_CODE)
105+
106+
result = await find_callers_by_method_name(
107+
method_full_name="com.example.dao.UserDao.findById",
108+
repository_path=str(tmp_path),
109+
)
110+
111+
assert result["total_callers"] >= 1
112+
callers = [c["caller_method_full_name"] for c in result["callers"]]
113+
assert "com.example.service.UserService.getUser" in callers
114+
115+
@pytest.mark.asyncio
116+
async def test_call_expression_snippet(self, tmp_path):
117+
"""返回调用表达式片段"""
118+
_create_file(tmp_path, "src/main/java/com/example/dao/UserDao.java", _DAO_CODE)
119+
_create_file(tmp_path, "src/main/java/com/example/service/UserService.java", _SERVICE_CODE)
120+
121+
result = await find_callers_by_method_name(
122+
method_full_name="com.example.dao.UserDao.findById",
123+
repository_path=str(tmp_path),
124+
)
125+
126+
assert result["total_callers"] >= 1
127+
assert "findById" in result["callers"][0]["call_expression_snippet"]
128+
129+
@pytest.mark.asyncio
130+
async def test_caller_class(self, tmp_path):
131+
"""返回调用方类名"""
132+
_create_file(tmp_path, "src/main/java/com/example/dao/UserDao.java", _DAO_CODE)
133+
_create_file(tmp_path, "src/main/java/com/example/service/UserService.java", _SERVICE_CODE)
134+
135+
result = await find_callers_by_method_name(
136+
method_full_name="com.example.dao.UserDao.findById",
137+
repository_path=str(tmp_path),
138+
)
139+
140+
assert any(c["caller_class"] == "com.example.service.UserService" for c in result["callers"])
141+
142+
@pytest.mark.asyncio
143+
async def test_line_number(self, tmp_path):
144+
"""返回行号"""
145+
_create_file(tmp_path, "src/main/java/com/example/dao/UserDao.java", _DAO_CODE)
146+
_create_file(tmp_path, "src/main/java/com/example/service/UserService.java", _SERVICE_CODE)
147+
148+
result = await find_callers_by_method_name(
149+
method_full_name="com.example.dao.UserDao.findById",
150+
repository_path=str(tmp_path),
151+
)
152+
153+
for c in result["callers"]:
154+
assert c["line_number"] >= 1
155+
156+
157+
# ---------------------------------------------------------------------------
158+
# 注释和字符串过滤
159+
# ---------------------------------------------------------------------------
160+
161+
class TestCommentFiltering:
162+
"""测试注释和字符串内的伪命中过滤"""
163+
164+
@pytest.mark.asyncio
165+
async def test_skip_comments(self, tmp_path):
166+
"""跳过注释中的调用"""
167+
_create_file(tmp_path, "src/main/java/com/example/dao/UserDao.java", _DAO_CODE)
168+
_create_file(tmp_path, "src/main/java/com/example/service/CommentService.java", _COMMENT_CODE)
169+
170+
result = await find_callers_by_method_name(
171+
method_full_name="com.example.dao.UserDao.findById",
172+
repository_path=str(tmp_path),
173+
)
174+
175+
# 只有 1 个真实调用 (findById(4)), 注释中的 3 个不应命中
176+
assert result["total_callers"] == 1
177+
assert result["callers"][0]["line_number"] >= 1
178+
179+
180+
# ---------------------------------------------------------------------------
181+
# 方法引用
182+
# ---------------------------------------------------------------------------
183+
184+
class TestMethodRef:
185+
"""测试方法引用"""
186+
187+
@pytest.mark.asyncio
188+
async def test_method_reference(self, tmp_path):
189+
"""方法引用 userDao::findById"""
190+
_create_file(tmp_path, "src/main/java/com/example/dao/UserDao.java", _DAO_CODE)
191+
_create_file(tmp_path, "src/main/java/com/example/service/UserService.java", _SERVICE_CODE)
192+
193+
result = await find_callers_by_method_name(
194+
method_full_name="com.example.dao.UserDao.findById",
195+
repository_path=str(tmp_path),
196+
)
197+
198+
# 方法引用也算调用点
199+
snippets = [c["call_expression_snippet"] for c in result["callers"]]
200+
assert any("::findById" in s for s in snippets)
201+
202+
203+
# ---------------------------------------------------------------------------
204+
# 开关控制
205+
# ---------------------------------------------------------------------------
206+
207+
class TestSwitches:
208+
"""测试开关控制"""
209+
210+
@pytest.mark.asyncio
211+
async def test_include_test_code_false(self, tmp_path):
212+
"""include_test_code=False 跳过测试文件"""
213+
_create_file(tmp_path, "src/main/java/com/example/dao/UserDao.java", _DAO_CODE)
214+
_create_file(tmp_path, "src/test/java/com/example/TestDao.java",
215+
"package com.example;\nimport com.example.dao.UserDao;\n"
216+
"public class TestDao {\n"
217+
" void test() {\n UserDao dao = new UserDao();\n"
218+
" dao.findById(1L);\n }\n}\n")
219+
220+
result = await find_callers_by_method_name(
221+
method_full_name="com.example.dao.UserDao.findById",
222+
repository_path=str(tmp_path),
223+
include_test_code=False,
224+
)
225+
226+
for c in result["callers"]:
227+
assert c["is_test_code"] is False
228+
229+
@pytest.mark.asyncio
230+
async def test_include_test_code_true(self, tmp_path):
231+
"""include_test_code=True 包含测试文件"""
232+
_create_file(tmp_path, "src/main/java/com/example/dao/UserDao.java", _DAO_CODE)
233+
_create_file(tmp_path, "src/test/java/com/example/TestDao.java",
234+
"package com.example;\nimport com.example.dao.UserDao;\n"
235+
"public class TestDao {\n"
236+
" void test() {\n UserDao dao = new UserDao();\n"
237+
" dao.findById(1L);\n }\n}\n")
238+
239+
result = await find_callers_by_method_name(
240+
method_full_name="com.example.dao.UserDao.findById",
241+
repository_path=str(tmp_path),
242+
include_test_code=True,
243+
)
244+
245+
assert any(c["is_test_code"] for c in result["callers"])
246+
247+
@pytest.mark.asyncio
248+
async def test_max_results(self, tmp_path):
249+
"""max_results 截断"""
250+
_create_file(tmp_path, "src/main/java/com/example/dao/UserDao.java", _DAO_CODE)
251+
_create_file(tmp_path, "src/main/java/com/example/service/UserService.java", _SERVICE_CODE)
252+
253+
result = await find_callers_by_method_name(
254+
method_full_name="com.example.dao.UserDao.findById",
255+
repository_path=str(tmp_path),
256+
max_results=1,
257+
)
258+
259+
assert result["total_callers"] == 1
260+
261+
262+
# ---------------------------------------------------------------------------
263+
# 边界情况
264+
# ---------------------------------------------------------------------------
265+
266+
class TestEdgeCases:
267+
268+
@pytest.mark.asyncio
269+
async def test_method_not_found(self, tmp_path):
270+
"""目标方法不存在"""
271+
_create_file(tmp_path, "src/main/java/com/example/dao/UserDao.java", _DAO_CODE)
272+
273+
result = await find_callers_by_method_name(
274+
method_full_name="com.example.dao.UserDao.nonExistent",
275+
repository_path=str(tmp_path),
276+
)
277+
278+
assert result["total_callers"] == 0
279+
280+
@pytest.mark.asyncio
281+
async def test_empty_method_name(self, tmp_path):
282+
result = await find_callers_by_method_name(
283+
method_full_name="",
284+
repository_path=str(tmp_path),
285+
)
286+
assert "error" in result
287+
288+
@pytest.mark.asyncio
289+
async def test_invalid_repo(self):
290+
result = await find_callers_by_method_name(
291+
method_full_name="com.example.Test.method",
292+
repository_path="/nonexistent",
293+
)
294+
assert "error" in result
295+
296+
297+
# ---------------------------------------------------------------------------
298+
# MCP 注册
299+
# ---------------------------------------------------------------------------
300+
301+
class TestMCPRegistration:
302+
303+
@pytest.mark.asyncio
304+
async def test_auto_discovered(self):
305+
mcp = FastMCP(name="test", version="0.0.1")
306+
registered = auto_discover_tools(mcp, "yasa_mcp.tools")
307+
assert "find_callers_by_method_name" in registered
308+
309+
310+
# ---------------------------------------------------------------------------
311+
# 集成测试: java-design-patterns
312+
# ---------------------------------------------------------------------------
313+
314+
class TestJDPIntegration:
315+
316+
@pytest.fixture
317+
def repo(self):
318+
if not os.path.isdir(_JDP_REPO):
319+
pytest.skip("JDP not available")
320+
return _JDP_REPO
321+
322+
@pytest.mark.asyncio
323+
async def test_find_callers_jdp(self, repo):
324+
"""从 JDP 查找调用者"""
325+
result = await find_callers_by_method_name(
326+
method_full_name="com.iluwatar.filterer.threat.SimpleThreat.name",
327+
repository_path=repo,
328+
max_results=50,
329+
)
330+
assert result["total_callers"] >= 1
331+
332+
@pytest.mark.asyncio
333+
async def test_performance_jdp(self, repo):
334+
"""性能: JDP < 10s"""
335+
start = time.time()
336+
await find_callers_by_method_name(
337+
method_full_name="com.iluwatar.filterer.threat.SimpleThreat.name",
338+
repository_path=repo,
339+
max_results=50,
340+
)
341+
elapsed = time.time() - start
342+
assert elapsed < 10.0, f"Took {elapsed:.2f}s, expected < 10s"

‎yasa_mcp/tools/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@
1010
from yasa_mcp.tools import search_code_by_body_pattern # noqa: F401
1111
from yasa_mcp.tools import read_method_source_by_method_name # noqa: F401
1212
from yasa_mcp.tools import list_methods_by_class_name # noqa: F401
13+
from yasa_mcp.tools import find_callers_by_method_name # noqa: F401

0 commit comments

Comments
 (0)