cicada-mcp 0.1.4__py3-none-any.whl
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.
Potentially problematic release.
This version of cicada-mcp might be problematic. Click here for more details.
- cicada/__init__.py +30 -0
- cicada/clean.py +297 -0
- cicada/command_logger.py +293 -0
- cicada/dead_code_analyzer.py +282 -0
- cicada/extractors/__init__.py +36 -0
- cicada/extractors/base.py +66 -0
- cicada/extractors/call.py +176 -0
- cicada/extractors/dependency.py +361 -0
- cicada/extractors/doc.py +179 -0
- cicada/extractors/function.py +246 -0
- cicada/extractors/module.py +123 -0
- cicada/extractors/spec.py +151 -0
- cicada/find_dead_code.py +270 -0
- cicada/formatter.py +918 -0
- cicada/git_helper.py +646 -0
- cicada/indexer.py +629 -0
- cicada/install.py +724 -0
- cicada/keyword_extractor.py +364 -0
- cicada/keyword_search.py +553 -0
- cicada/lightweight_keyword_extractor.py +298 -0
- cicada/mcp_server.py +1559 -0
- cicada/mcp_tools.py +291 -0
- cicada/parser.py +124 -0
- cicada/pr_finder.py +435 -0
- cicada/pr_indexer/__init__.py +20 -0
- cicada/pr_indexer/cli.py +62 -0
- cicada/pr_indexer/github_api_client.py +431 -0
- cicada/pr_indexer/indexer.py +297 -0
- cicada/pr_indexer/line_mapper.py +209 -0
- cicada/pr_indexer/pr_index_builder.py +253 -0
- cicada/setup.py +339 -0
- cicada/utils/__init__.py +52 -0
- cicada/utils/call_site_formatter.py +95 -0
- cicada/utils/function_grouper.py +57 -0
- cicada/utils/hash_utils.py +173 -0
- cicada/utils/index_utils.py +290 -0
- cicada/utils/path_utils.py +240 -0
- cicada/utils/signature_builder.py +106 -0
- cicada/utils/storage.py +111 -0
- cicada/utils/subprocess_runner.py +182 -0
- cicada/utils/text_utils.py +90 -0
- cicada/version_check.py +116 -0
- cicada_mcp-0.1.4.dist-info/METADATA +619 -0
- cicada_mcp-0.1.4.dist-info/RECORD +48 -0
- cicada_mcp-0.1.4.dist-info/WHEEL +5 -0
- cicada_mcp-0.1.4.dist-info/entry_points.txt +8 -0
- cicada_mcp-0.1.4.dist-info/licenses/LICENSE +21 -0
- cicada_mcp-0.1.4.dist-info/top_level.txt +1 -0
cicada/mcp_server.py
ADDED
|
@@ -0,0 +1,1559 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""
|
|
3
|
+
Cicada MCP Server - Elixir Module Search.
|
|
4
|
+
|
|
5
|
+
Provides an MCP tool to search for Elixir modules and their functions.
|
|
6
|
+
|
|
7
|
+
Author: Cursor(Auto)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, cast
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from mcp.server import Server
|
|
18
|
+
from mcp.server.stdio import stdio_server
|
|
19
|
+
from mcp.types import Tool, TextContent
|
|
20
|
+
|
|
21
|
+
from cicada.formatter import ModuleFormatter
|
|
22
|
+
from cicada.pr_finder import PRFinder
|
|
23
|
+
from cicada.git_helper import GitHelper
|
|
24
|
+
from cicada.utils import load_index, get_config_path, get_pr_index_path
|
|
25
|
+
from cicada.mcp_tools import get_tool_definitions
|
|
26
|
+
from cicada.command_logger import get_logger
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CicadaServer:
|
|
30
|
+
"""MCP server for Elixir module search."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, config_path: str | None = None):
|
|
33
|
+
"""
|
|
34
|
+
Initialize the server with configuration.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
config_path: Path to config file. If None, uses environment variables
|
|
38
|
+
or default path.
|
|
39
|
+
"""
|
|
40
|
+
if config_path is None:
|
|
41
|
+
config_path = self._get_config_path()
|
|
42
|
+
|
|
43
|
+
self.config = self._load_config(config_path)
|
|
44
|
+
self.index = self._load_index()
|
|
45
|
+
self._pr_index: dict | None = None # Lazy load PR index only when needed
|
|
46
|
+
self.server = Server("cicada")
|
|
47
|
+
|
|
48
|
+
# Cache keyword availability check
|
|
49
|
+
self._has_keywords = self._check_keywords_available()
|
|
50
|
+
|
|
51
|
+
# Initialize git helper
|
|
52
|
+
repo_path = self.config.get("repository", {}).get("path", ".")
|
|
53
|
+
self.git_helper: GitHelper | None = None
|
|
54
|
+
try:
|
|
55
|
+
self.git_helper = GitHelper(repo_path)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
# If git initialization fails, set to None
|
|
58
|
+
# (e.g., not a git repository)
|
|
59
|
+
print(f"Warning: Git helper not available: {e}", file=sys.stderr)
|
|
60
|
+
|
|
61
|
+
# Initialize command logger
|
|
62
|
+
self.logger = get_logger()
|
|
63
|
+
|
|
64
|
+
# Register handlers
|
|
65
|
+
_ = self.server.list_tools()(self.list_tools)
|
|
66
|
+
_ = self.server.call_tool()(self.call_tool_with_logging)
|
|
67
|
+
|
|
68
|
+
def _get_config_path(self) -> str:
|
|
69
|
+
"""
|
|
70
|
+
Determine the config file path from environment or defaults.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Path to the config file
|
|
74
|
+
"""
|
|
75
|
+
# Check if CICADA_CONFIG_DIR is set (new temp directory approach)
|
|
76
|
+
config_dir = os.environ.get("CICADA_CONFIG_DIR")
|
|
77
|
+
if config_dir:
|
|
78
|
+
return str(Path(config_dir) / "config.yaml")
|
|
79
|
+
|
|
80
|
+
# Determine repository path from environment or current directory
|
|
81
|
+
repo_path = os.environ.get("CICADA_REPO_PATH")
|
|
82
|
+
|
|
83
|
+
# Check if WORKSPACE_FOLDER_PATHS is available (Cursor-specific)
|
|
84
|
+
if not repo_path:
|
|
85
|
+
workspace_paths = os.environ.get("WORKSPACE_FOLDER_PATHS")
|
|
86
|
+
if workspace_paths:
|
|
87
|
+
# WORKSPACE_FOLDER_PATHS might be a single path or multiple paths
|
|
88
|
+
# Take the first one if multiple
|
|
89
|
+
# Use os.pathsep for platform-aware splitting (';' on Windows, ':' on Unix)
|
|
90
|
+
repo_path = (
|
|
91
|
+
workspace_paths.split(os.pathsep)[0]
|
|
92
|
+
if os.pathsep in workspace_paths
|
|
93
|
+
else workspace_paths
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Fall back to current working directory
|
|
97
|
+
if not repo_path:
|
|
98
|
+
repo_path = str(Path.cwd().resolve())
|
|
99
|
+
|
|
100
|
+
# Try new storage structure first
|
|
101
|
+
try:
|
|
102
|
+
config_path = get_config_path(repo_path)
|
|
103
|
+
if config_path.exists():
|
|
104
|
+
return str(config_path)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
print(
|
|
107
|
+
f"Warning: Could not load from new storage structure: {e}",
|
|
108
|
+
file=sys.stderr,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Fall back to old structure for backward compatibility
|
|
112
|
+
old_path = Path(repo_path) / ".cicada" / "config.yaml"
|
|
113
|
+
if old_path.exists():
|
|
114
|
+
return str(old_path)
|
|
115
|
+
|
|
116
|
+
# If neither exists, return the new storage path
|
|
117
|
+
# (will trigger helpful error message in _load_config)
|
|
118
|
+
return str(get_config_path(repo_path))
|
|
119
|
+
|
|
120
|
+
def _load_config(self, config_path: str) -> dict:
|
|
121
|
+
"""Load configuration from YAML file."""
|
|
122
|
+
config_file = Path(config_path)
|
|
123
|
+
if not config_file.exists():
|
|
124
|
+
raise FileNotFoundError(
|
|
125
|
+
f"Config file not found: {config_path}\n\n"
|
|
126
|
+
f"Please run setup first:\n"
|
|
127
|
+
f" cicada cursor # For Cursor\n"
|
|
128
|
+
f" cicada claude # For Claude Code\n"
|
|
129
|
+
f" cicada vs # For VS Code"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
with open(config_file, "r") as f:
|
|
133
|
+
data = yaml.safe_load(f)
|
|
134
|
+
return data if isinstance(data, dict) else {}
|
|
135
|
+
|
|
136
|
+
def _load_index(self) -> dict[str, Any]:
|
|
137
|
+
"""Load the index from JSON file."""
|
|
138
|
+
import json
|
|
139
|
+
|
|
140
|
+
index_path = Path(self.config["storage"]["index_path"])
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
result = load_index(index_path, raise_on_error=True)
|
|
144
|
+
if result is None:
|
|
145
|
+
raise FileNotFoundError(
|
|
146
|
+
f"Index file not found: {index_path}\n\n"
|
|
147
|
+
f"Please run setup first:\n"
|
|
148
|
+
f" cicada cursor # For Cursor\n"
|
|
149
|
+
f" cicada claude # For Claude Code\n"
|
|
150
|
+
f" cicada vs # For VS Code"
|
|
151
|
+
)
|
|
152
|
+
return result
|
|
153
|
+
except json.JSONDecodeError as e:
|
|
154
|
+
# Index file is corrupted - provide helpful message
|
|
155
|
+
repo_path = self.config.get("repository", {}).get("path", ".")
|
|
156
|
+
raise RuntimeError(
|
|
157
|
+
f"Index file is corrupted: {index_path}\n"
|
|
158
|
+
f"Error: {e}\n\n"
|
|
159
|
+
f"To rebuild the index, run:\n"
|
|
160
|
+
f" cd {repo_path}\n"
|
|
161
|
+
f" cicada-clean -f # Safer cleanup\n"
|
|
162
|
+
f" cicada cursor # or: cicada claude, cicada vs\n"
|
|
163
|
+
)
|
|
164
|
+
except FileNotFoundError:
|
|
165
|
+
raise FileNotFoundError(
|
|
166
|
+
f"Index file not found: {index_path}\n\n"
|
|
167
|
+
f"Please run setup first:\n"
|
|
168
|
+
f" cicada cursor # For Cursor\n"
|
|
169
|
+
f" cicada claude # For Claude Code\n"
|
|
170
|
+
f" cicada vs # For VS Code"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def pr_index(self) -> dict[str, Any] | None:
|
|
175
|
+
"""Lazy load the PR index from JSON file."""
|
|
176
|
+
if self._pr_index is None:
|
|
177
|
+
# Get repo path from config
|
|
178
|
+
repo_path = Path(self.config.get("repository", {}).get("path", "."))
|
|
179
|
+
|
|
180
|
+
# Try new storage structure first
|
|
181
|
+
try:
|
|
182
|
+
pr_index_path = get_pr_index_path(repo_path)
|
|
183
|
+
if pr_index_path.exists():
|
|
184
|
+
self._pr_index = load_index(
|
|
185
|
+
pr_index_path, verbose=True, raise_on_error=False
|
|
186
|
+
)
|
|
187
|
+
return self._pr_index
|
|
188
|
+
except Exception as e:
|
|
189
|
+
print(
|
|
190
|
+
f"Warning: Could not load PR index from new storage structure: {e}",
|
|
191
|
+
file=sys.stderr,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Fall back to old structure for backward compatibility
|
|
195
|
+
pr_index_path = repo_path / ".cicada" / "pr_index.json"
|
|
196
|
+
self._pr_index = load_index(
|
|
197
|
+
pr_index_path, verbose=True, raise_on_error=False
|
|
198
|
+
)
|
|
199
|
+
return self._pr_index
|
|
200
|
+
|
|
201
|
+
def _load_pr_index(self) -> dict[str, Any] | None:
|
|
202
|
+
"""Load the PR index from JSON file."""
|
|
203
|
+
# Get repo path from config
|
|
204
|
+
repo_path = Path(self.config.get("repository", {}).get("path", "."))
|
|
205
|
+
|
|
206
|
+
# Try new storage structure first
|
|
207
|
+
try:
|
|
208
|
+
pr_index_path = get_pr_index_path(repo_path)
|
|
209
|
+
if pr_index_path.exists():
|
|
210
|
+
return load_index(pr_index_path, verbose=True, raise_on_error=False)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
print(
|
|
213
|
+
f"Warning: Could not load PR index from new storage structure: {e}",
|
|
214
|
+
file=sys.stderr,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Fall back to old structure for backward compatibility
|
|
218
|
+
pr_index_path = repo_path / ".cicada" / "pr_index.json"
|
|
219
|
+
return load_index(pr_index_path, verbose=True, raise_on_error=False)
|
|
220
|
+
|
|
221
|
+
def _check_keywords_available(self) -> bool:
|
|
222
|
+
"""
|
|
223
|
+
Check if any keywords are available in the index.
|
|
224
|
+
|
|
225
|
+
This is cached at initialization to avoid repeated checks.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
True if keywords are available in the index
|
|
229
|
+
"""
|
|
230
|
+
for module_data in self.index.get("modules", {}).values():
|
|
231
|
+
if module_data.get("keywords"):
|
|
232
|
+
return True
|
|
233
|
+
for func in module_data.get("functions", []):
|
|
234
|
+
if func.get("keywords"):
|
|
235
|
+
return True
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
async def list_tools(self) -> list[Tool]:
|
|
239
|
+
"""List available MCP tools."""
|
|
240
|
+
return get_tool_definitions()
|
|
241
|
+
|
|
242
|
+
async def call_tool_with_logging(
|
|
243
|
+
self, name: str, arguments: dict
|
|
244
|
+
) -> list[TextContent]:
|
|
245
|
+
"""Wrapper for call_tool that logs execution details."""
|
|
246
|
+
from datetime import datetime
|
|
247
|
+
|
|
248
|
+
# Record start time
|
|
249
|
+
start_time = time.perf_counter()
|
|
250
|
+
timestamp = datetime.now()
|
|
251
|
+
error_msg = None
|
|
252
|
+
response = None
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
# Call the actual tool handler
|
|
256
|
+
response = await self.call_tool(name, arguments)
|
|
257
|
+
return response
|
|
258
|
+
except Exception as e:
|
|
259
|
+
# Capture error if tool execution fails
|
|
260
|
+
error_msg = str(e)
|
|
261
|
+
raise
|
|
262
|
+
finally:
|
|
263
|
+
# Calculate execution time in milliseconds
|
|
264
|
+
end_time = time.perf_counter()
|
|
265
|
+
execution_time_ms = (end_time - start_time) * 1000
|
|
266
|
+
|
|
267
|
+
# Log the command execution (async to prevent event loop blocking)
|
|
268
|
+
await self.logger.log_command_async(
|
|
269
|
+
tool_name=name,
|
|
270
|
+
arguments=arguments,
|
|
271
|
+
response=response,
|
|
272
|
+
execution_time_ms=execution_time_ms,
|
|
273
|
+
timestamp=timestamp,
|
|
274
|
+
error=error_msg,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
async def call_tool(self, name: str, arguments: dict) -> list[TextContent]:
|
|
278
|
+
"""Handle tool calls."""
|
|
279
|
+
if name == "search_module":
|
|
280
|
+
module_name = arguments.get("module_name")
|
|
281
|
+
file_path = arguments.get("file_path")
|
|
282
|
+
output_format = arguments.get("format", "markdown")
|
|
283
|
+
private_functions = arguments.get("private_functions", "exclude")
|
|
284
|
+
|
|
285
|
+
# Validate that at least one is provided
|
|
286
|
+
if not module_name and not file_path:
|
|
287
|
+
error_msg = "Either 'module_name' or 'file_path' must be provided"
|
|
288
|
+
return [TextContent(type="text", text=error_msg)]
|
|
289
|
+
|
|
290
|
+
# If file_path is provided, resolve it to module_name
|
|
291
|
+
if file_path:
|
|
292
|
+
resolved_module = self._resolve_file_to_module(file_path)
|
|
293
|
+
if not resolved_module:
|
|
294
|
+
error_msg = f"Could not find module in file: {file_path}"
|
|
295
|
+
return [TextContent(type="text", text=error_msg)]
|
|
296
|
+
module_name = resolved_module
|
|
297
|
+
|
|
298
|
+
assert module_name is not None, "module_name must be provided"
|
|
299
|
+
return await self._search_module(
|
|
300
|
+
module_name, output_format, private_functions
|
|
301
|
+
)
|
|
302
|
+
elif name == "search_function":
|
|
303
|
+
function_name = arguments.get("function_name")
|
|
304
|
+
output_format = arguments.get("format", "markdown")
|
|
305
|
+
include_usage_examples = arguments.get("include_usage_examples", False)
|
|
306
|
+
max_examples = arguments.get("max_examples", 5)
|
|
307
|
+
test_files_only = arguments.get("test_files_only", False)
|
|
308
|
+
|
|
309
|
+
if not function_name:
|
|
310
|
+
error_msg = "'function_name' is required"
|
|
311
|
+
return [TextContent(type="text", text=error_msg)]
|
|
312
|
+
|
|
313
|
+
return await self._search_function(
|
|
314
|
+
function_name,
|
|
315
|
+
output_format,
|
|
316
|
+
include_usage_examples,
|
|
317
|
+
max_examples,
|
|
318
|
+
test_files_only,
|
|
319
|
+
)
|
|
320
|
+
elif name == "search_module_usage":
|
|
321
|
+
module_name = arguments.get("module_name")
|
|
322
|
+
output_format = arguments.get("format", "markdown")
|
|
323
|
+
|
|
324
|
+
if not module_name:
|
|
325
|
+
error_msg = "'module_name' is required"
|
|
326
|
+
return [TextContent(type="text", text=error_msg)]
|
|
327
|
+
|
|
328
|
+
return await self._search_module_usage(module_name, output_format)
|
|
329
|
+
elif name == "find_pr_for_line":
|
|
330
|
+
file_path = arguments.get("file_path")
|
|
331
|
+
line_number = arguments.get("line_number")
|
|
332
|
+
output_format = arguments.get("format", "text")
|
|
333
|
+
|
|
334
|
+
if not file_path:
|
|
335
|
+
error_msg = "'file_path' is required"
|
|
336
|
+
return [TextContent(type="text", text=error_msg)]
|
|
337
|
+
|
|
338
|
+
if not line_number:
|
|
339
|
+
error_msg = "'line_number' is required"
|
|
340
|
+
return [TextContent(type="text", text=error_msg)]
|
|
341
|
+
|
|
342
|
+
return await self._find_pr_for_line(file_path, line_number, output_format)
|
|
343
|
+
elif name == "get_commit_history":
|
|
344
|
+
file_path = arguments.get("file_path")
|
|
345
|
+
function_name = arguments.get("function_name")
|
|
346
|
+
start_line = arguments.get("start_line")
|
|
347
|
+
end_line = arguments.get("end_line")
|
|
348
|
+
precise_tracking = arguments.get("precise_tracking", False)
|
|
349
|
+
show_evolution = arguments.get("show_evolution", False)
|
|
350
|
+
max_commits = arguments.get("max_commits", 10)
|
|
351
|
+
|
|
352
|
+
if not file_path:
|
|
353
|
+
error_msg = "'file_path' is required"
|
|
354
|
+
return [TextContent(type="text", text=error_msg)]
|
|
355
|
+
|
|
356
|
+
# Validate line range parameters
|
|
357
|
+
if precise_tracking or show_evolution:
|
|
358
|
+
if not start_line or not end_line:
|
|
359
|
+
error_msg = "Both 'start_line' and 'end_line' are required for precise_tracking or show_evolution"
|
|
360
|
+
return [TextContent(type="text", text=error_msg)]
|
|
361
|
+
|
|
362
|
+
return await self._get_file_history(
|
|
363
|
+
file_path,
|
|
364
|
+
function_name,
|
|
365
|
+
start_line,
|
|
366
|
+
end_line,
|
|
367
|
+
precise_tracking,
|
|
368
|
+
show_evolution,
|
|
369
|
+
max_commits,
|
|
370
|
+
)
|
|
371
|
+
elif name == "get_blame":
|
|
372
|
+
file_path = arguments.get("file_path")
|
|
373
|
+
start_line = arguments.get("start_line")
|
|
374
|
+
end_line = arguments.get("end_line")
|
|
375
|
+
|
|
376
|
+
if not file_path:
|
|
377
|
+
error_msg = "'file_path' is required"
|
|
378
|
+
return [TextContent(type="text", text=error_msg)]
|
|
379
|
+
|
|
380
|
+
if not start_line or not end_line:
|
|
381
|
+
error_msg = "Both 'start_line' and 'end_line' are required"
|
|
382
|
+
return [TextContent(type="text", text=error_msg)]
|
|
383
|
+
|
|
384
|
+
return await self._get_function_history(file_path, start_line, end_line)
|
|
385
|
+
elif name == "get_file_pr_history":
|
|
386
|
+
file_path = arguments.get("file_path")
|
|
387
|
+
|
|
388
|
+
if not file_path:
|
|
389
|
+
error_msg = "'file_path' is required"
|
|
390
|
+
return [TextContent(type="text", text=error_msg)]
|
|
391
|
+
|
|
392
|
+
return await self._get_file_pr_history(file_path)
|
|
393
|
+
elif name == "search_by_keywords":
|
|
394
|
+
keywords = arguments.get("keywords")
|
|
395
|
+
|
|
396
|
+
if not keywords:
|
|
397
|
+
error_msg = "'keywords' is required"
|
|
398
|
+
return [TextContent(type="text", text=error_msg)]
|
|
399
|
+
|
|
400
|
+
if not isinstance(keywords, list):
|
|
401
|
+
error_msg = "'keywords' must be a list of strings"
|
|
402
|
+
return [TextContent(type="text", text=error_msg)]
|
|
403
|
+
|
|
404
|
+
return await self._search_by_keywords(keywords)
|
|
405
|
+
elif name == "find_dead_code":
|
|
406
|
+
min_confidence = arguments.get("min_confidence", "high")
|
|
407
|
+
output_format = arguments.get("format", "markdown")
|
|
408
|
+
|
|
409
|
+
return await self._find_dead_code(min_confidence, output_format)
|
|
410
|
+
else:
|
|
411
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
412
|
+
|
|
413
|
+
def _resolve_file_to_module(self, file_path: str) -> str | None:
|
|
414
|
+
"""Resolve a file path to a module name by searching the index."""
|
|
415
|
+
# Normalize the file path (remove leading ./ and trailing whitespace)
|
|
416
|
+
normalized_path = file_path.strip().lstrip("./")
|
|
417
|
+
|
|
418
|
+
# Search through all modules to find one matching this file path
|
|
419
|
+
for module_name, module_data in self.index["modules"].items():
|
|
420
|
+
module_file = module_data["file"]
|
|
421
|
+
|
|
422
|
+
# Check for exact match
|
|
423
|
+
if module_file == normalized_path:
|
|
424
|
+
return module_name
|
|
425
|
+
|
|
426
|
+
# Also check if the provided path ends with the module file
|
|
427
|
+
# (handles cases where user provides absolute path)
|
|
428
|
+
if normalized_path.endswith(module_file):
|
|
429
|
+
return module_name
|
|
430
|
+
|
|
431
|
+
# Check if the module file ends with the provided path
|
|
432
|
+
# (handles cases where user provides just filename or partial path)
|
|
433
|
+
if module_file.endswith(normalized_path):
|
|
434
|
+
return module_name
|
|
435
|
+
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
async def _search_module(
|
|
439
|
+
self,
|
|
440
|
+
module_name: str,
|
|
441
|
+
output_format: str = "markdown",
|
|
442
|
+
private_functions: str = "exclude",
|
|
443
|
+
) -> list[TextContent]:
|
|
444
|
+
"""Search for a module and return its information."""
|
|
445
|
+
# Exact match lookup
|
|
446
|
+
if module_name in self.index["modules"]:
|
|
447
|
+
data = self.index["modules"][module_name]
|
|
448
|
+
|
|
449
|
+
if output_format == "json":
|
|
450
|
+
result = ModuleFormatter.format_module_json(
|
|
451
|
+
module_name, data, private_functions
|
|
452
|
+
)
|
|
453
|
+
else:
|
|
454
|
+
result = ModuleFormatter.format_module_markdown(
|
|
455
|
+
module_name, data, private_functions
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
return [TextContent(type="text", text=result)]
|
|
459
|
+
|
|
460
|
+
# Module not found
|
|
461
|
+
total_modules = self.index["metadata"]["total_modules"]
|
|
462
|
+
|
|
463
|
+
if output_format == "json":
|
|
464
|
+
error_result = ModuleFormatter.format_error_json(module_name, total_modules)
|
|
465
|
+
else:
|
|
466
|
+
error_result = ModuleFormatter.format_error_markdown(
|
|
467
|
+
module_name, total_modules
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
return [TextContent(type="text", text=error_result)]
|
|
471
|
+
|
|
472
|
+
async def _search_function(
|
|
473
|
+
self,
|
|
474
|
+
function_name: str,
|
|
475
|
+
output_format: str = "markdown",
|
|
476
|
+
include_usage_examples: bool = False,
|
|
477
|
+
max_examples: int = 5,
|
|
478
|
+
test_files_only: bool = False,
|
|
479
|
+
) -> list[TextContent]:
|
|
480
|
+
"""Search for a function across all modules and return matches with call sites."""
|
|
481
|
+
# Parse the function name - supports multiple formats:
|
|
482
|
+
# - "func_name" or "func_name/arity" (search all modules)
|
|
483
|
+
# - "Module.func_name" or "Module.func_name/arity" (search specific module)
|
|
484
|
+
target_module = None
|
|
485
|
+
target_name = function_name
|
|
486
|
+
target_arity = None
|
|
487
|
+
|
|
488
|
+
# Check for Module.function format
|
|
489
|
+
if "." in function_name:
|
|
490
|
+
# Split on last dot to separate module from function
|
|
491
|
+
parts = function_name.rsplit(".", 1)
|
|
492
|
+
if len(parts) == 2:
|
|
493
|
+
target_module = parts[0]
|
|
494
|
+
target_name = parts[1]
|
|
495
|
+
|
|
496
|
+
# Check for arity
|
|
497
|
+
if "/" in target_name:
|
|
498
|
+
parts = target_name.split("/")
|
|
499
|
+
target_name = parts[0]
|
|
500
|
+
try:
|
|
501
|
+
target_arity = int(parts[1])
|
|
502
|
+
except (ValueError, IndexError):
|
|
503
|
+
pass
|
|
504
|
+
|
|
505
|
+
# Search across all modules for function definitions
|
|
506
|
+
results = []
|
|
507
|
+
for module_name, module_data in self.index["modules"].items():
|
|
508
|
+
# If target_module is specified, only search in that module
|
|
509
|
+
if target_module and module_name != target_module:
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
for func in module_data["functions"]:
|
|
513
|
+
# Match by name and optionally arity
|
|
514
|
+
if func["name"] == target_name:
|
|
515
|
+
if target_arity is None or func["arity"] == target_arity:
|
|
516
|
+
# Find call sites for this function
|
|
517
|
+
call_sites = self._find_call_sites(
|
|
518
|
+
target_module=module_name,
|
|
519
|
+
target_function=target_name,
|
|
520
|
+
target_arity=func["arity"],
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
# Filter for test files only if requested
|
|
524
|
+
if test_files_only:
|
|
525
|
+
call_sites = self._filter_test_call_sites(call_sites)
|
|
526
|
+
|
|
527
|
+
# Optionally include usage examples (actual code lines)
|
|
528
|
+
call_sites_with_examples = []
|
|
529
|
+
if include_usage_examples and call_sites:
|
|
530
|
+
# Consolidate call sites by calling module (one example per module)
|
|
531
|
+
consolidated_sites = self._consolidate_call_sites_by_module(
|
|
532
|
+
call_sites
|
|
533
|
+
)
|
|
534
|
+
# Limit the number of examples
|
|
535
|
+
call_sites_with_examples = consolidated_sites[:max_examples]
|
|
536
|
+
# Extract code lines for each call site
|
|
537
|
+
self._add_code_examples(call_sites_with_examples)
|
|
538
|
+
|
|
539
|
+
results.append(
|
|
540
|
+
{
|
|
541
|
+
"module": module_name,
|
|
542
|
+
"moduledoc": module_data.get("moduledoc"),
|
|
543
|
+
"function": func,
|
|
544
|
+
"file": module_data["file"],
|
|
545
|
+
"call_sites": call_sites,
|
|
546
|
+
"call_sites_with_examples": call_sites_with_examples,
|
|
547
|
+
}
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
# Format results
|
|
551
|
+
if output_format == "json":
|
|
552
|
+
result = ModuleFormatter.format_function_results_json(
|
|
553
|
+
function_name, results
|
|
554
|
+
)
|
|
555
|
+
else:
|
|
556
|
+
result = ModuleFormatter.format_function_results_markdown(
|
|
557
|
+
function_name, results
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
return [TextContent(type="text", text=result)]
|
|
561
|
+
|
|
562
|
+
async def _search_module_usage(
|
|
563
|
+
self, module_name: str, output_format: str = "markdown"
|
|
564
|
+
) -> list[TextContent]:
|
|
565
|
+
"""
|
|
566
|
+
Search for all locations where a module is used (aliased/imported and called).
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
module_name: The module to search for (e.g., "MyApp.User")
|
|
570
|
+
output_format: Output format ('markdown' or 'json')
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
TextContent with usage information
|
|
574
|
+
"""
|
|
575
|
+
# Check if the module exists in the index
|
|
576
|
+
if module_name not in self.index["modules"]:
|
|
577
|
+
error_msg = f"Module '{module_name}' not found in index."
|
|
578
|
+
return [TextContent(type="text", text=error_msg)]
|
|
579
|
+
|
|
580
|
+
usage_results = {
|
|
581
|
+
"aliases": [], # Modules that alias the target module
|
|
582
|
+
"imports": [], # Modules that import the target module
|
|
583
|
+
"requires": [], # Modules that require the target module
|
|
584
|
+
"uses": [], # Modules that use the target module
|
|
585
|
+
"value_mentions": [], # Modules that mention the target as a value
|
|
586
|
+
"function_calls": [], # Direct function calls to the target module
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
# Search through all modules to find usage
|
|
590
|
+
for caller_module, module_data in self.index["modules"].items():
|
|
591
|
+
# Skip the module itself
|
|
592
|
+
if caller_module == module_name:
|
|
593
|
+
continue
|
|
594
|
+
|
|
595
|
+
# Check aliases
|
|
596
|
+
aliases = module_data.get("aliases", {})
|
|
597
|
+
for alias_name, full_module in aliases.items():
|
|
598
|
+
if full_module == module_name:
|
|
599
|
+
usage_results["aliases"].append(
|
|
600
|
+
{
|
|
601
|
+
"importing_module": caller_module,
|
|
602
|
+
"alias_name": alias_name,
|
|
603
|
+
"full_module": full_module,
|
|
604
|
+
"file": module_data["file"],
|
|
605
|
+
}
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
# Check imports
|
|
609
|
+
imports = module_data.get("imports", [])
|
|
610
|
+
if module_name in imports:
|
|
611
|
+
usage_results["imports"].append(
|
|
612
|
+
{
|
|
613
|
+
"importing_module": caller_module,
|
|
614
|
+
"file": module_data["file"],
|
|
615
|
+
}
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
# Check requires
|
|
619
|
+
requires = module_data.get("requires", [])
|
|
620
|
+
if module_name in requires:
|
|
621
|
+
usage_results["requires"].append(
|
|
622
|
+
{
|
|
623
|
+
"importing_module": caller_module,
|
|
624
|
+
"file": module_data["file"],
|
|
625
|
+
}
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
# Check uses
|
|
629
|
+
uses = module_data.get("uses", [])
|
|
630
|
+
if module_name in uses:
|
|
631
|
+
usage_results["uses"].append(
|
|
632
|
+
{
|
|
633
|
+
"importing_module": caller_module,
|
|
634
|
+
"file": module_data["file"],
|
|
635
|
+
}
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
# Check value mentions
|
|
639
|
+
value_mentions = module_data.get("value_mentions", [])
|
|
640
|
+
if module_name in value_mentions:
|
|
641
|
+
usage_results["value_mentions"].append(
|
|
642
|
+
{
|
|
643
|
+
"importing_module": caller_module,
|
|
644
|
+
"file": module_data["file"],
|
|
645
|
+
}
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
# Check function calls
|
|
649
|
+
calls = module_data.get("calls", [])
|
|
650
|
+
module_calls = {} # Track calls grouped by function
|
|
651
|
+
|
|
652
|
+
for call in calls:
|
|
653
|
+
call_module = call.get("module")
|
|
654
|
+
|
|
655
|
+
# Resolve the call's module name using aliases
|
|
656
|
+
if call_module:
|
|
657
|
+
resolved_module = aliases.get(call_module, call_module)
|
|
658
|
+
|
|
659
|
+
if resolved_module == module_name:
|
|
660
|
+
# Track which function is being called
|
|
661
|
+
func_key = f"{call['function']}/{call['arity']}"
|
|
662
|
+
|
|
663
|
+
if func_key not in module_calls:
|
|
664
|
+
module_calls[func_key] = {
|
|
665
|
+
"function": call["function"],
|
|
666
|
+
"arity": call["arity"],
|
|
667
|
+
"lines": [],
|
|
668
|
+
"alias_used": (
|
|
669
|
+
call_module
|
|
670
|
+
if call_module != resolved_module
|
|
671
|
+
else None
|
|
672
|
+
),
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
module_calls[func_key]["lines"].append(call["line"])
|
|
676
|
+
|
|
677
|
+
# Add call information if there are any calls
|
|
678
|
+
if module_calls:
|
|
679
|
+
usage_results["function_calls"].append(
|
|
680
|
+
{
|
|
681
|
+
"calling_module": caller_module,
|
|
682
|
+
"file": module_data["file"],
|
|
683
|
+
"calls": list(module_calls.values()),
|
|
684
|
+
}
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
# Format results
|
|
688
|
+
if output_format == "json":
|
|
689
|
+
result = ModuleFormatter.format_module_usage_json(
|
|
690
|
+
module_name, usage_results
|
|
691
|
+
)
|
|
692
|
+
else:
|
|
693
|
+
result = ModuleFormatter.format_module_usage_markdown(
|
|
694
|
+
module_name, usage_results
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
return [TextContent(type="text", text=result)]
|
|
698
|
+
|
|
699
|
+
def _add_code_examples(self, call_sites: list):
|
|
700
|
+
"""
|
|
701
|
+
Add actual code lines to call sites.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
call_sites: List of call site dictionaries to enhance with code examples
|
|
705
|
+
|
|
706
|
+
Modifies call_sites in-place by adding 'code_line' key with the actual source code.
|
|
707
|
+
Extracts complete function calls from opening '(' to closing ')'.
|
|
708
|
+
"""
|
|
709
|
+
# Get the repo path from the index metadata (fallback to config if not available)
|
|
710
|
+
repo_path_str = self.index.get("metadata", {}).get("repo_path")
|
|
711
|
+
if not repo_path_str:
|
|
712
|
+
# Fallback to config if available
|
|
713
|
+
repo_path_str = self.config.get("repository", {}).get("path")
|
|
714
|
+
|
|
715
|
+
if not repo_path_str:
|
|
716
|
+
# Can't add examples without repo path
|
|
717
|
+
return
|
|
718
|
+
|
|
719
|
+
repo_path = Path(repo_path_str)
|
|
720
|
+
|
|
721
|
+
for site in call_sites:
|
|
722
|
+
file_path = repo_path / site["file"]
|
|
723
|
+
line_number = site["line"]
|
|
724
|
+
|
|
725
|
+
try:
|
|
726
|
+
# Read all lines from the file
|
|
727
|
+
with open(file_path, "r") as f:
|
|
728
|
+
lines = f.readlines()
|
|
729
|
+
|
|
730
|
+
# Extract complete function call
|
|
731
|
+
code_lines = self._extract_complete_call(lines, line_number)
|
|
732
|
+
if code_lines:
|
|
733
|
+
site["code_line"] = code_lines
|
|
734
|
+
except (FileNotFoundError, IOError, IndexError):
|
|
735
|
+
# If we can't read the file/line, just skip adding the code example
|
|
736
|
+
pass
|
|
737
|
+
|
|
738
|
+
def _extract_complete_call(self, lines: list[str], start_line: int) -> str | None:
|
|
739
|
+
"""
|
|
740
|
+
Extract code with ±2 lines of context around the call line.
|
|
741
|
+
|
|
742
|
+
Args:
|
|
743
|
+
lines: All lines from the file
|
|
744
|
+
start_line: Line number where the call starts (1-indexed)
|
|
745
|
+
|
|
746
|
+
Returns:
|
|
747
|
+
Code snippet with context, dedented to remove common leading whitespace
|
|
748
|
+
"""
|
|
749
|
+
if start_line < 1 or start_line > len(lines):
|
|
750
|
+
return None
|
|
751
|
+
|
|
752
|
+
# Convert to 0-indexed
|
|
753
|
+
call_idx = start_line - 1
|
|
754
|
+
|
|
755
|
+
# Calculate context range (±2 lines)
|
|
756
|
+
context_lines = 2
|
|
757
|
+
start_idx = max(0, call_idx - context_lines)
|
|
758
|
+
end_idx = min(len(lines), call_idx + context_lines + 1)
|
|
759
|
+
|
|
760
|
+
# Extract the lines with context
|
|
761
|
+
extracted_lines = []
|
|
762
|
+
for i in range(start_idx, end_idx):
|
|
763
|
+
extracted_lines.append(lines[i].rstrip("\n"))
|
|
764
|
+
|
|
765
|
+
# Dedent: strip common leading whitespace
|
|
766
|
+
if extracted_lines:
|
|
767
|
+
# Find minimum indentation (excluding empty/whitespace-only lines)
|
|
768
|
+
min_indent: int | float = float("inf")
|
|
769
|
+
for line in extracted_lines:
|
|
770
|
+
if line.strip(): # Skip empty/whitespace-only lines
|
|
771
|
+
leading_spaces = len(line) - len(line.lstrip())
|
|
772
|
+
min_indent = min(min_indent, leading_spaces)
|
|
773
|
+
|
|
774
|
+
# Strip the common indentation from all lines
|
|
775
|
+
if min_indent != float("inf") and min_indent > 0:
|
|
776
|
+
dedented_lines = []
|
|
777
|
+
min_indent_int = int(min_indent)
|
|
778
|
+
for line in extracted_lines:
|
|
779
|
+
if len(line) >= min_indent_int:
|
|
780
|
+
dedented_lines.append(line[min_indent_int:])
|
|
781
|
+
else:
|
|
782
|
+
dedented_lines.append(line)
|
|
783
|
+
extracted_lines = dedented_lines
|
|
784
|
+
|
|
785
|
+
return "\n".join(extracted_lines) if extracted_lines else None
|
|
786
|
+
|
|
787
|
+
def _find_call_sites(
|
|
788
|
+
self, target_module: str, target_function: str, target_arity: int
|
|
789
|
+
) -> list:
|
|
790
|
+
"""
|
|
791
|
+
Find all locations where a function is called.
|
|
792
|
+
|
|
793
|
+
Args:
|
|
794
|
+
target_module: The module containing the function (e.g., "MyApp.User")
|
|
795
|
+
target_function: The function name (e.g., "create_user")
|
|
796
|
+
target_arity: The function arity
|
|
797
|
+
|
|
798
|
+
Returns:
|
|
799
|
+
List of call sites with resolved module names
|
|
800
|
+
"""
|
|
801
|
+
call_sites = []
|
|
802
|
+
|
|
803
|
+
# Find the function definition line to filter out @spec/@doc
|
|
804
|
+
function_def_line = None
|
|
805
|
+
if target_module in self.index["modules"]:
|
|
806
|
+
for func in self.index["modules"][target_module]["functions"]:
|
|
807
|
+
if func["name"] == target_function and func["arity"] == target_arity:
|
|
808
|
+
function_def_line = func["line"]
|
|
809
|
+
break
|
|
810
|
+
|
|
811
|
+
for caller_module, module_data in self.index["modules"].items():
|
|
812
|
+
# Get aliases for this module to resolve calls
|
|
813
|
+
aliases = module_data.get("aliases", {})
|
|
814
|
+
|
|
815
|
+
# Check all calls in this module
|
|
816
|
+
for call in module_data.get("calls", []):
|
|
817
|
+
if call["function"] != target_function:
|
|
818
|
+
continue
|
|
819
|
+
|
|
820
|
+
if call["arity"] != target_arity:
|
|
821
|
+
continue
|
|
822
|
+
|
|
823
|
+
# Resolve the call's module name using aliases
|
|
824
|
+
call_module = call.get("module")
|
|
825
|
+
|
|
826
|
+
if call_module is None:
|
|
827
|
+
# Local call - check if it's in the same module
|
|
828
|
+
if caller_module == target_module:
|
|
829
|
+
# Filter out calls that are part of the function definition
|
|
830
|
+
# (@spec, @doc appear 1-5 lines before the def)
|
|
831
|
+
if (
|
|
832
|
+
function_def_line
|
|
833
|
+
and abs(call["line"] - function_def_line) <= 5
|
|
834
|
+
):
|
|
835
|
+
continue
|
|
836
|
+
|
|
837
|
+
# Find the calling function
|
|
838
|
+
calling_function = self._find_function_at_line(
|
|
839
|
+
caller_module, call["line"]
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
call_sites.append(
|
|
843
|
+
{
|
|
844
|
+
"calling_module": caller_module,
|
|
845
|
+
"calling_function": calling_function,
|
|
846
|
+
"file": module_data["file"],
|
|
847
|
+
"line": call["line"],
|
|
848
|
+
"call_type": "local",
|
|
849
|
+
}
|
|
850
|
+
)
|
|
851
|
+
else:
|
|
852
|
+
# Qualified call - resolve the module name
|
|
853
|
+
resolved_module = aliases.get(call_module, call_module)
|
|
854
|
+
|
|
855
|
+
# Check if this resolves to our target module
|
|
856
|
+
if resolved_module == target_module:
|
|
857
|
+
# Find the calling function
|
|
858
|
+
calling_function = self._find_function_at_line(
|
|
859
|
+
caller_module, call["line"]
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
call_sites.append(
|
|
863
|
+
{
|
|
864
|
+
"calling_module": caller_module,
|
|
865
|
+
"calling_function": calling_function,
|
|
866
|
+
"file": module_data["file"],
|
|
867
|
+
"line": call["line"],
|
|
868
|
+
"call_type": "qualified",
|
|
869
|
+
"alias_used": (
|
|
870
|
+
call_module
|
|
871
|
+
if call_module != resolved_module
|
|
872
|
+
else None
|
|
873
|
+
),
|
|
874
|
+
}
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
return call_sites
|
|
878
|
+
|
|
879
|
+
def _find_function_at_line(self, module_name: str, line: int) -> dict | None:
|
|
880
|
+
"""
|
|
881
|
+
Find the function that contains a specific line number.
|
|
882
|
+
|
|
883
|
+
Args:
|
|
884
|
+
module_name: The module to search in
|
|
885
|
+
line: The line number
|
|
886
|
+
|
|
887
|
+
Returns:
|
|
888
|
+
Dictionary with 'name' and 'arity', or None if not found
|
|
889
|
+
"""
|
|
890
|
+
if module_name not in self.index["modules"]:
|
|
891
|
+
return None
|
|
892
|
+
|
|
893
|
+
module_data = cast(dict[str, Any], self.index["modules"][module_name])
|
|
894
|
+
functions: list[Any] = module_data.get("functions", [])
|
|
895
|
+
|
|
896
|
+
# Find the function whose definition line is closest before the target line
|
|
897
|
+
best_match: dict[str, Any] | None = None
|
|
898
|
+
for func in functions:
|
|
899
|
+
func_line = func["line"]
|
|
900
|
+
# The function must be defined before or at the line
|
|
901
|
+
if func_line <= line:
|
|
902
|
+
# Keep the closest one
|
|
903
|
+
if best_match is None or func_line > best_match["line"]:
|
|
904
|
+
best_match = {
|
|
905
|
+
"name": func["name"],
|
|
906
|
+
"arity": func["arity"],
|
|
907
|
+
"line": func_line,
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
return best_match
|
|
911
|
+
|
|
912
|
+
def _consolidate_call_sites_by_module(self, call_sites: list) -> list:
|
|
913
|
+
"""
|
|
914
|
+
Consolidate call sites by calling module, keeping only one example per module.
|
|
915
|
+
Prioritizes keeping test files separate from regular code files.
|
|
916
|
+
|
|
917
|
+
Args:
|
|
918
|
+
call_sites: List of call site dictionaries
|
|
919
|
+
|
|
920
|
+
Returns:
|
|
921
|
+
Consolidated list with one call site per unique calling module
|
|
922
|
+
"""
|
|
923
|
+
seen_modules = {}
|
|
924
|
+
consolidated = []
|
|
925
|
+
|
|
926
|
+
for site in call_sites:
|
|
927
|
+
module = site["calling_module"]
|
|
928
|
+
|
|
929
|
+
# If we haven't seen this module yet, add it
|
|
930
|
+
if module not in seen_modules:
|
|
931
|
+
seen_modules[module] = site
|
|
932
|
+
consolidated.append(site)
|
|
933
|
+
|
|
934
|
+
return consolidated
|
|
935
|
+
|
|
936
|
+
def _filter_test_call_sites(self, call_sites: list) -> list:
|
|
937
|
+
"""
|
|
938
|
+
Filter call sites to only include calls from test files.
|
|
939
|
+
|
|
940
|
+
A file is considered a test file if 'test' appears anywhere in its path.
|
|
941
|
+
|
|
942
|
+
Args:
|
|
943
|
+
call_sites: List of call site dictionaries
|
|
944
|
+
|
|
945
|
+
Returns:
|
|
946
|
+
Filtered list containing only call sites from test files
|
|
947
|
+
"""
|
|
948
|
+
return [site for site in call_sites if "test" in site["file"].lower()]
|
|
949
|
+
|
|
950
|
+
async def _find_pr_for_line(
|
|
951
|
+
self, file_path: str, line_number: int, output_format: str = "text"
|
|
952
|
+
) -> list[TextContent]:
|
|
953
|
+
"""
|
|
954
|
+
Find the PR that introduced a specific line of code.
|
|
955
|
+
|
|
956
|
+
Args:
|
|
957
|
+
file_path: Path to the file
|
|
958
|
+
line_number: Line number (1-indexed)
|
|
959
|
+
output_format: Output format ('text', 'json', or 'markdown')
|
|
960
|
+
|
|
961
|
+
Returns:
|
|
962
|
+
TextContent with PR information
|
|
963
|
+
"""
|
|
964
|
+
try:
|
|
965
|
+
# Get repo path from config
|
|
966
|
+
repo_path = self.config.get("repository", {}).get("path", ".")
|
|
967
|
+
index_path = Path(repo_path) / ".cicada" / "pr_index.json"
|
|
968
|
+
|
|
969
|
+
# Check if index exists
|
|
970
|
+
if not index_path.exists():
|
|
971
|
+
error_msg = (
|
|
972
|
+
"PR index not found. Please run:\n"
|
|
973
|
+
" cicada-index-pr\n\n"
|
|
974
|
+
"This will create the PR index at .cicada/pr_index.json"
|
|
975
|
+
)
|
|
976
|
+
return [TextContent(type="text", text=error_msg)]
|
|
977
|
+
|
|
978
|
+
# Initialize PRFinder with index enabled
|
|
979
|
+
pr_finder = PRFinder(
|
|
980
|
+
repo_path=repo_path,
|
|
981
|
+
use_index=True,
|
|
982
|
+
index_path=".cicada/pr_index.json",
|
|
983
|
+
verbose=False,
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
# Find PR for the line using index
|
|
987
|
+
result = pr_finder.find_pr_for_line(file_path, line_number)
|
|
988
|
+
|
|
989
|
+
# If no PR found in index, check if it exists via network
|
|
990
|
+
if result.get("pr") is None and result.get("commit"):
|
|
991
|
+
# Try network lookup to see if PR actually exists
|
|
992
|
+
pr_finder_network = PRFinder(
|
|
993
|
+
repo_path=repo_path,
|
|
994
|
+
use_index=False,
|
|
995
|
+
verbose=False,
|
|
996
|
+
)
|
|
997
|
+
network_result = pr_finder_network.find_pr_for_line(
|
|
998
|
+
file_path, line_number
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
if network_result.get("pr") is not None:
|
|
1002
|
+
# PR exists but not in index - suggest update
|
|
1003
|
+
error_msg = (
|
|
1004
|
+
"PR index is incomplete. Please run:\n"
|
|
1005
|
+
" cicada-index-pr\n\n"
|
|
1006
|
+
"This will update the index with recent PRs (incremental by default)."
|
|
1007
|
+
)
|
|
1008
|
+
return [TextContent(type="text", text=error_msg)]
|
|
1009
|
+
else:
|
|
1010
|
+
# No PR associated with this commit
|
|
1011
|
+
result["pr"] = None # Ensure it's explicitly None
|
|
1012
|
+
result["note"] = "No PR associated with this line"
|
|
1013
|
+
|
|
1014
|
+
# Format the result
|
|
1015
|
+
formatted_result = pr_finder.format_result(result, output_format)
|
|
1016
|
+
|
|
1017
|
+
return [TextContent(type="text", text=formatted_result)]
|
|
1018
|
+
|
|
1019
|
+
except Exception as e:
|
|
1020
|
+
error_msg = f"Error finding PR: {str(e)}"
|
|
1021
|
+
return [TextContent(type="text", text=error_msg)]
|
|
1022
|
+
|
|
1023
|
+
async def _get_file_history(
|
|
1024
|
+
self,
|
|
1025
|
+
file_path: str,
|
|
1026
|
+
function_name: str | None = None,
|
|
1027
|
+
start_line: int | None = None,
|
|
1028
|
+
end_line: int | None = None,
|
|
1029
|
+
_precise_tracking: bool = False,
|
|
1030
|
+
show_evolution: bool = False,
|
|
1031
|
+
max_commits: int = 10,
|
|
1032
|
+
) -> list[TextContent]:
|
|
1033
|
+
"""
|
|
1034
|
+
Get git commit history for a file or function.
|
|
1035
|
+
|
|
1036
|
+
Args:
|
|
1037
|
+
file_path: Path to the file
|
|
1038
|
+
function_name: Optional function name for function tracking (git log -L :funcname:file)
|
|
1039
|
+
start_line: Optional starting line for fallback line-based tracking
|
|
1040
|
+
end_line: Optional ending line for fallback line-based tracking
|
|
1041
|
+
precise_tracking: Deprecated (function tracking is always used when function_name provided)
|
|
1042
|
+
show_evolution: Include function evolution metadata
|
|
1043
|
+
max_commits: Maximum number of commits to return
|
|
1044
|
+
|
|
1045
|
+
Returns:
|
|
1046
|
+
TextContent with formatted commit history
|
|
1047
|
+
|
|
1048
|
+
Note:
|
|
1049
|
+
- If function_name is provided, uses git's function tracking
|
|
1050
|
+
- Function tracking works even as the function moves in the file
|
|
1051
|
+
- Line numbers are used as fallback if function tracking fails
|
|
1052
|
+
- Requires .gitattributes with "*.ex diff=elixir" for function tracking
|
|
1053
|
+
"""
|
|
1054
|
+
if not self.git_helper:
|
|
1055
|
+
error_msg = (
|
|
1056
|
+
"Git history is not available (repository may not be a git repo)"
|
|
1057
|
+
)
|
|
1058
|
+
return [TextContent(type="text", text=error_msg)]
|
|
1059
|
+
|
|
1060
|
+
try:
|
|
1061
|
+
evolution = None
|
|
1062
|
+
tracking_method = "file"
|
|
1063
|
+
|
|
1064
|
+
# Determine which tracking method to use
|
|
1065
|
+
# Priority: function name > line numbers > file level
|
|
1066
|
+
if function_name:
|
|
1067
|
+
# Use function-based tracking (git log -L :funcname:file)
|
|
1068
|
+
commits = self.git_helper.get_function_history_precise(
|
|
1069
|
+
file_path,
|
|
1070
|
+
start_line=start_line,
|
|
1071
|
+
end_line=end_line,
|
|
1072
|
+
function_name=function_name,
|
|
1073
|
+
max_commits=max_commits,
|
|
1074
|
+
)
|
|
1075
|
+
title = f"Git History for {function_name} in {file_path}"
|
|
1076
|
+
tracking_method = "function"
|
|
1077
|
+
|
|
1078
|
+
# Get evolution metadata if requested
|
|
1079
|
+
if show_evolution:
|
|
1080
|
+
evolution = self.git_helper.get_function_evolution(
|
|
1081
|
+
file_path,
|
|
1082
|
+
start_line=start_line,
|
|
1083
|
+
end_line=end_line,
|
|
1084
|
+
function_name=function_name,
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
elif start_line and end_line:
|
|
1088
|
+
# Use line-based tracking (git log -L start,end:file)
|
|
1089
|
+
commits = self.git_helper.get_function_history_precise(
|
|
1090
|
+
file_path,
|
|
1091
|
+
start_line=start_line,
|
|
1092
|
+
end_line=end_line,
|
|
1093
|
+
max_commits=max_commits,
|
|
1094
|
+
)
|
|
1095
|
+
title = f"Git History for {file_path} (lines {start_line}-{end_line})"
|
|
1096
|
+
tracking_method = "line"
|
|
1097
|
+
|
|
1098
|
+
if show_evolution:
|
|
1099
|
+
evolution = self.git_helper.get_function_evolution(
|
|
1100
|
+
file_path, start_line=start_line, end_line=end_line
|
|
1101
|
+
)
|
|
1102
|
+
else:
|
|
1103
|
+
# File-level history
|
|
1104
|
+
commits = self.git_helper.get_file_history(file_path, max_commits)
|
|
1105
|
+
title = f"Git History for {file_path}"
|
|
1106
|
+
|
|
1107
|
+
if not commits:
|
|
1108
|
+
result = f"No commit history found for {file_path}"
|
|
1109
|
+
return [TextContent(type="text", text=result)]
|
|
1110
|
+
|
|
1111
|
+
# Format the results as markdown
|
|
1112
|
+
lines = [f"# {title}\n"]
|
|
1113
|
+
|
|
1114
|
+
# Add tracking method info
|
|
1115
|
+
if tracking_method == "function":
|
|
1116
|
+
lines.append(
|
|
1117
|
+
"*Using function tracking (git log -L :funcname:file) - tracks function even as it moves*\n"
|
|
1118
|
+
)
|
|
1119
|
+
elif tracking_method == "line":
|
|
1120
|
+
lines.append(
|
|
1121
|
+
"*Using line-based tracking (git log -L start,end:file)*\n"
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
# Add evolution metadata if available
|
|
1125
|
+
if evolution:
|
|
1126
|
+
lines.append("## Function Evolution\n")
|
|
1127
|
+
created = evolution["created_at"]
|
|
1128
|
+
modified = evolution["last_modified"]
|
|
1129
|
+
|
|
1130
|
+
lines.append(
|
|
1131
|
+
f"- **Created:** {created['date'][:10]} by {created['author']} (commit `{created['sha']}`)"
|
|
1132
|
+
)
|
|
1133
|
+
lines.append(
|
|
1134
|
+
f"- **Last Modified:** {modified['date'][:10]} by {modified['author']} (commit `{modified['sha']}`)"
|
|
1135
|
+
)
|
|
1136
|
+
lines.append(
|
|
1137
|
+
f"- **Total Modifications:** {evolution['total_modifications']} commit(s)"
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
if evolution.get("modification_frequency"):
|
|
1141
|
+
freq = evolution["modification_frequency"]
|
|
1142
|
+
lines.append(
|
|
1143
|
+
f"- **Modification Frequency:** {freq:.2f} commits/month"
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
lines.append("") # Empty line
|
|
1147
|
+
|
|
1148
|
+
lines.append(f"Found {len(commits)} commit(s)\n")
|
|
1149
|
+
|
|
1150
|
+
for i, commit in enumerate(commits, 1):
|
|
1151
|
+
lines.append(f"## {i}. {commit['summary']}")
|
|
1152
|
+
lines.append(f"- **Commit:** `{commit['sha']}`")
|
|
1153
|
+
lines.append(
|
|
1154
|
+
f"- **Author:** {commit['author']} ({commit['author_email']})"
|
|
1155
|
+
)
|
|
1156
|
+
lines.append(f"- **Date:** {commit['date']}")
|
|
1157
|
+
|
|
1158
|
+
# Add relevance indicator for function searches
|
|
1159
|
+
if "relevance" in commit:
|
|
1160
|
+
relevance_emoji = (
|
|
1161
|
+
"🎯" if commit["relevance"] == "mentioned" else "📝"
|
|
1162
|
+
)
|
|
1163
|
+
relevance_text = (
|
|
1164
|
+
"Function mentioned"
|
|
1165
|
+
if commit["relevance"] == "mentioned"
|
|
1166
|
+
else "File changed"
|
|
1167
|
+
)
|
|
1168
|
+
lines.append(f"- **Relevance:** {relevance_emoji} {relevance_text}")
|
|
1169
|
+
|
|
1170
|
+
# Add full commit message if it's different from summary
|
|
1171
|
+
if commit["message"] != commit["summary"]:
|
|
1172
|
+
lines.append(f"\n**Full message:**\n```\n{commit['message']}\n```")
|
|
1173
|
+
|
|
1174
|
+
lines.append("") # Empty line between commits
|
|
1175
|
+
|
|
1176
|
+
result = "\n".join(lines)
|
|
1177
|
+
return [TextContent(type="text", text=result)]
|
|
1178
|
+
|
|
1179
|
+
except Exception as e:
|
|
1180
|
+
error_msg = f"Error getting file history: {str(e)}"
|
|
1181
|
+
return [TextContent(type="text", text=error_msg)]
|
|
1182
|
+
|
|
1183
|
+
async def _get_function_history(
|
|
1184
|
+
self, file_path: str, start_line: int, end_line: int
|
|
1185
|
+
) -> list[TextContent]:
|
|
1186
|
+
"""
|
|
1187
|
+
Get line-by-line authorship for a code section using git blame.
|
|
1188
|
+
|
|
1189
|
+
Args:
|
|
1190
|
+
file_path: Path to the file
|
|
1191
|
+
start_line: Starting line number
|
|
1192
|
+
end_line: Ending line number
|
|
1193
|
+
|
|
1194
|
+
Returns:
|
|
1195
|
+
TextContent with formatted blame information
|
|
1196
|
+
"""
|
|
1197
|
+
if not self.git_helper:
|
|
1198
|
+
error_msg = "Git blame is not available (repository may not be a git repo)"
|
|
1199
|
+
return [TextContent(type="text", text=error_msg)]
|
|
1200
|
+
|
|
1201
|
+
try:
|
|
1202
|
+
blame_groups = self.git_helper.get_function_history(
|
|
1203
|
+
file_path, start_line, end_line
|
|
1204
|
+
)
|
|
1205
|
+
|
|
1206
|
+
if not blame_groups:
|
|
1207
|
+
result = f"No blame information found for {file_path} lines {start_line}-{end_line}"
|
|
1208
|
+
return [TextContent(type="text", text=result)]
|
|
1209
|
+
|
|
1210
|
+
# Format the results as markdown
|
|
1211
|
+
lines = [f"# Git Blame for {file_path} (lines {start_line}-{end_line})\n"]
|
|
1212
|
+
lines.append(f"Found {len(blame_groups)} authorship group(s)\n")
|
|
1213
|
+
|
|
1214
|
+
for i, group in enumerate(blame_groups, 1):
|
|
1215
|
+
# Group header
|
|
1216
|
+
line_range = (
|
|
1217
|
+
f"lines {group['line_start']}-{group['line_end']}"
|
|
1218
|
+
if group["line_start"] != group["line_end"]
|
|
1219
|
+
else f"line {group['line_start']}"
|
|
1220
|
+
)
|
|
1221
|
+
lines.append(f"## Group {i}: {group['author']} ({line_range})")
|
|
1222
|
+
|
|
1223
|
+
lines.append(
|
|
1224
|
+
f"- **Author:** {group['author']} ({group['author_email']})"
|
|
1225
|
+
)
|
|
1226
|
+
lines.append(f"- **Commit:** `{group['sha']}`")
|
|
1227
|
+
lines.append(f"- **Date:** {group['date'][:10]}")
|
|
1228
|
+
lines.append(f"- **Lines:** {group['line_count']}\n")
|
|
1229
|
+
|
|
1230
|
+
# Show code lines
|
|
1231
|
+
lines.append("**Code:**")
|
|
1232
|
+
lines.append("```elixir")
|
|
1233
|
+
for line_info in group["lines"]:
|
|
1234
|
+
# Show line number and content
|
|
1235
|
+
lines.append(f"{line_info['content']}")
|
|
1236
|
+
lines.append("```\n")
|
|
1237
|
+
|
|
1238
|
+
result = "\n".join(lines)
|
|
1239
|
+
return [TextContent(type="text", text=result)]
|
|
1240
|
+
|
|
1241
|
+
except Exception as e:
|
|
1242
|
+
error_msg = f"Error getting blame information: {str(e)}"
|
|
1243
|
+
return [TextContent(type="text", text=error_msg)]
|
|
1244
|
+
|
|
1245
|
+
async def _get_file_pr_history(self, file_path: str) -> list[TextContent]:
|
|
1246
|
+
"""
|
|
1247
|
+
Get all PRs that modified a specific file with descriptions and comments.
|
|
1248
|
+
|
|
1249
|
+
Args:
|
|
1250
|
+
file_path: Path to the file (relative to repo root or absolute)
|
|
1251
|
+
|
|
1252
|
+
Returns:
|
|
1253
|
+
TextContent with formatted PR history
|
|
1254
|
+
"""
|
|
1255
|
+
if not self.pr_index:
|
|
1256
|
+
error_msg = (
|
|
1257
|
+
"PR index not available. Please run:\n"
|
|
1258
|
+
" python cicada/pr_indexer.py\n\n"
|
|
1259
|
+
"This will create the PR index at .cicada/pr_index.json"
|
|
1260
|
+
)
|
|
1261
|
+
return [TextContent(type="text", text=error_msg)]
|
|
1262
|
+
|
|
1263
|
+
# Normalize file path
|
|
1264
|
+
repo_path = Path(self.config.get("repository", {}).get("path", "."))
|
|
1265
|
+
file_path_obj = Path(file_path)
|
|
1266
|
+
|
|
1267
|
+
if file_path_obj.is_absolute():
|
|
1268
|
+
try:
|
|
1269
|
+
file_path_obj = file_path_obj.relative_to(repo_path)
|
|
1270
|
+
except ValueError:
|
|
1271
|
+
error_msg = (
|
|
1272
|
+
f"File path {file_path} is not within repository {repo_path}"
|
|
1273
|
+
)
|
|
1274
|
+
return [TextContent(type="text", text=error_msg)]
|
|
1275
|
+
|
|
1276
|
+
file_path_str = str(file_path_obj)
|
|
1277
|
+
|
|
1278
|
+
# Look up PRs that touched this file
|
|
1279
|
+
file_to_prs = self.pr_index.get("file_to_prs", {})
|
|
1280
|
+
pr_numbers = file_to_prs.get(file_path_str, [])
|
|
1281
|
+
|
|
1282
|
+
if not pr_numbers:
|
|
1283
|
+
result = f"No pull requests found that modified: {file_path_str}"
|
|
1284
|
+
return [TextContent(type="text", text=result)]
|
|
1285
|
+
|
|
1286
|
+
# Get PR details
|
|
1287
|
+
prs_data = self.pr_index.get("prs", {})
|
|
1288
|
+
|
|
1289
|
+
# Format results as markdown
|
|
1290
|
+
lines = [f"# Pull Request History for {file_path_str}\n"]
|
|
1291
|
+
lines.append(f"Found {len(pr_numbers)} pull request(s)\n")
|
|
1292
|
+
|
|
1293
|
+
for pr_num in pr_numbers:
|
|
1294
|
+
pr = prs_data.get(str(pr_num))
|
|
1295
|
+
if not pr:
|
|
1296
|
+
continue
|
|
1297
|
+
|
|
1298
|
+
# PR Header
|
|
1299
|
+
status = "merged" if pr.get("merged") else pr.get("state", "unknown")
|
|
1300
|
+
lines.append(f"## PR #{pr['number']}: {pr['title']}")
|
|
1301
|
+
lines.append(f"- **Author:** @{pr['author']}")
|
|
1302
|
+
lines.append(f"- **Status:** {status}")
|
|
1303
|
+
lines.append(f"- **URL:** {pr['url']}\n")
|
|
1304
|
+
|
|
1305
|
+
# PR Description (trimmed to first 10 lines)
|
|
1306
|
+
description = pr.get("description", "").strip()
|
|
1307
|
+
if description:
|
|
1308
|
+
lines.append("### Description")
|
|
1309
|
+
desc_lines = description.split("\n")
|
|
1310
|
+
if len(desc_lines) > 10:
|
|
1311
|
+
trimmed_desc = "\n".join(desc_lines[:10])
|
|
1312
|
+
lines.append(f"{trimmed_desc}")
|
|
1313
|
+
lines.append(
|
|
1314
|
+
f"\n*... (trimmed, {len(desc_lines) - 10} more lines)*\n"
|
|
1315
|
+
)
|
|
1316
|
+
else:
|
|
1317
|
+
lines.append(f"{description}\n")
|
|
1318
|
+
|
|
1319
|
+
# Review Comments for this file only
|
|
1320
|
+
comments = pr.get("comments", [])
|
|
1321
|
+
file_comments = [c for c in comments if c.get("path") == file_path_str]
|
|
1322
|
+
|
|
1323
|
+
if file_comments:
|
|
1324
|
+
lines.append(f"### Review Comments ({len(file_comments)})")
|
|
1325
|
+
|
|
1326
|
+
for comment in file_comments:
|
|
1327
|
+
author = comment.get("author", "unknown")
|
|
1328
|
+
body = comment.get("body", "").strip()
|
|
1329
|
+
line_num = comment.get("line")
|
|
1330
|
+
original_line = comment.get("original_line")
|
|
1331
|
+
resolved = comment.get("resolved", False)
|
|
1332
|
+
|
|
1333
|
+
# Comment header with line info
|
|
1334
|
+
if line_num:
|
|
1335
|
+
line_info = f"Line {line_num}"
|
|
1336
|
+
elif original_line:
|
|
1337
|
+
line_info = f"Original line {original_line} (unmapped)"
|
|
1338
|
+
else:
|
|
1339
|
+
line_info = "No line info"
|
|
1340
|
+
|
|
1341
|
+
resolved_marker = " ✓ Resolved" if resolved else ""
|
|
1342
|
+
lines.append(f"\n**@{author}** ({line_info}){resolved_marker}:")
|
|
1343
|
+
|
|
1344
|
+
# Indent comment body
|
|
1345
|
+
for line in body.split("\n"):
|
|
1346
|
+
lines.append(f"> {line}")
|
|
1347
|
+
|
|
1348
|
+
lines.append("") # Empty line after comments
|
|
1349
|
+
|
|
1350
|
+
lines.append("---\n") # Separator between PRs
|
|
1351
|
+
|
|
1352
|
+
result = "\n".join(lines)
|
|
1353
|
+
return [TextContent(type="text", text=result)]
|
|
1354
|
+
|
|
1355
|
+
async def _search_by_keywords(self, keywords: list[str]) -> list[TextContent]:
|
|
1356
|
+
"""
|
|
1357
|
+
Search for modules and functions by keywords.
|
|
1358
|
+
|
|
1359
|
+
Args:
|
|
1360
|
+
keywords: List of keywords to search for
|
|
1361
|
+
|
|
1362
|
+
Returns:
|
|
1363
|
+
TextContent with formatted search results
|
|
1364
|
+
"""
|
|
1365
|
+
from cicada.keyword_search import KeywordSearcher
|
|
1366
|
+
|
|
1367
|
+
# Check if keywords are available (cached at initialization)
|
|
1368
|
+
if not self._has_keywords:
|
|
1369
|
+
error_msg = (
|
|
1370
|
+
"No keywords found in index. Please rebuild the index with keyword extraction:\n\n"
|
|
1371
|
+
" cicada-index --extract-keywords\n\n"
|
|
1372
|
+
"This will extract keywords from documentation using NLP."
|
|
1373
|
+
)
|
|
1374
|
+
return [TextContent(type="text", text=error_msg)]
|
|
1375
|
+
|
|
1376
|
+
# Perform the search
|
|
1377
|
+
searcher = KeywordSearcher(self.index)
|
|
1378
|
+
results = searcher.search(keywords, top_n=5)
|
|
1379
|
+
|
|
1380
|
+
if not results:
|
|
1381
|
+
result = f"No results found for keywords: {', '.join(keywords)}"
|
|
1382
|
+
return [TextContent(type="text", text=result)]
|
|
1383
|
+
|
|
1384
|
+
# Format results
|
|
1385
|
+
from cicada.formatter import ModuleFormatter
|
|
1386
|
+
|
|
1387
|
+
formatted_result = ModuleFormatter.format_keyword_search_results_markdown(
|
|
1388
|
+
keywords, results
|
|
1389
|
+
)
|
|
1390
|
+
|
|
1391
|
+
return [TextContent(type="text", text=formatted_result)]
|
|
1392
|
+
|
|
1393
|
+
async def _find_dead_code(
|
|
1394
|
+
self, min_confidence: str, output_format: str
|
|
1395
|
+
) -> list[TextContent]:
|
|
1396
|
+
"""
|
|
1397
|
+
Find potentially unused public functions.
|
|
1398
|
+
|
|
1399
|
+
Args:
|
|
1400
|
+
min_confidence: Minimum confidence level ('high', 'medium', or 'low')
|
|
1401
|
+
output_format: Output format ('markdown' or 'json')
|
|
1402
|
+
|
|
1403
|
+
Returns:
|
|
1404
|
+
TextContent with formatted dead code analysis
|
|
1405
|
+
"""
|
|
1406
|
+
from cicada.dead_code_analyzer import DeadCodeAnalyzer
|
|
1407
|
+
from cicada.find_dead_code import (
|
|
1408
|
+
filter_by_confidence,
|
|
1409
|
+
format_markdown,
|
|
1410
|
+
format_json,
|
|
1411
|
+
)
|
|
1412
|
+
|
|
1413
|
+
# Run analysis
|
|
1414
|
+
analyzer = DeadCodeAnalyzer(self.index)
|
|
1415
|
+
results = analyzer.analyze()
|
|
1416
|
+
|
|
1417
|
+
# Filter by confidence
|
|
1418
|
+
results = filter_by_confidence(results, min_confidence)
|
|
1419
|
+
|
|
1420
|
+
# Format output
|
|
1421
|
+
if output_format == "json":
|
|
1422
|
+
output = format_json(results)
|
|
1423
|
+
else:
|
|
1424
|
+
output = format_markdown(results)
|
|
1425
|
+
|
|
1426
|
+
return [TextContent(type="text", text=output)]
|
|
1427
|
+
|
|
1428
|
+
async def run(self):
|
|
1429
|
+
"""Run the MCP server."""
|
|
1430
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
1431
|
+
await self.server.run(
|
|
1432
|
+
read_stream, write_stream, self.server.create_initialization_options()
|
|
1433
|
+
)
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
async def async_main():
|
|
1437
|
+
"""Async main entry point."""
|
|
1438
|
+
try:
|
|
1439
|
+
# Check if setup is needed before starting server
|
|
1440
|
+
# Redirect stdout to stderr during setup to avoid polluting MCP protocol
|
|
1441
|
+
original_stdout = sys.stdout
|
|
1442
|
+
try:
|
|
1443
|
+
sys.stdout = sys.stderr
|
|
1444
|
+
_auto_setup_if_needed()
|
|
1445
|
+
finally:
|
|
1446
|
+
sys.stdout = original_stdout
|
|
1447
|
+
|
|
1448
|
+
server = CicadaServer()
|
|
1449
|
+
await server.run()
|
|
1450
|
+
except Exception as e:
|
|
1451
|
+
print(f"Error starting server: {e}", file=sys.stderr)
|
|
1452
|
+
sys.exit(1)
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def _auto_setup_if_needed():
|
|
1456
|
+
"""
|
|
1457
|
+
Automatically run setup if the repository hasn't been indexed yet.
|
|
1458
|
+
|
|
1459
|
+
This enables zero-config MCP usage - just point the MCP config to cicada-server
|
|
1460
|
+
and it will index the repository on first run.
|
|
1461
|
+
"""
|
|
1462
|
+
from cicada.utils import (
|
|
1463
|
+
get_config_path,
|
|
1464
|
+
get_index_path,
|
|
1465
|
+
create_storage_dir,
|
|
1466
|
+
get_storage_dir,
|
|
1467
|
+
)
|
|
1468
|
+
from cicada.setup import index_repository, create_config_yaml
|
|
1469
|
+
|
|
1470
|
+
# Determine repository path from environment or current directory
|
|
1471
|
+
repo_path_str = os.environ.get("CICADA_REPO_PATH")
|
|
1472
|
+
|
|
1473
|
+
# Check if WORKSPACE_FOLDER_PATHS is available (Cursor-specific)
|
|
1474
|
+
if not repo_path_str:
|
|
1475
|
+
workspace_paths = os.environ.get("WORKSPACE_FOLDER_PATHS")
|
|
1476
|
+
if workspace_paths:
|
|
1477
|
+
# WORKSPACE_FOLDER_PATHS might be a single path or multiple paths
|
|
1478
|
+
# Take the first one if multiple
|
|
1479
|
+
# Use os.pathsep for platform-aware splitting (';' on Windows, ':' on Unix)
|
|
1480
|
+
repo_path_str = (
|
|
1481
|
+
workspace_paths.split(os.pathsep)[0]
|
|
1482
|
+
if os.pathsep in workspace_paths
|
|
1483
|
+
else workspace_paths
|
|
1484
|
+
)
|
|
1485
|
+
|
|
1486
|
+
if repo_path_str:
|
|
1487
|
+
repo_path = Path(repo_path_str).resolve()
|
|
1488
|
+
else:
|
|
1489
|
+
repo_path = Path.cwd().resolve()
|
|
1490
|
+
|
|
1491
|
+
# Check if config and index already exist
|
|
1492
|
+
config_path = get_config_path(repo_path)
|
|
1493
|
+
index_path = get_index_path(repo_path)
|
|
1494
|
+
|
|
1495
|
+
if config_path.exists() and index_path.exists():
|
|
1496
|
+
# Already set up, nothing to do
|
|
1497
|
+
return
|
|
1498
|
+
|
|
1499
|
+
# Setup needed - create storage and index
|
|
1500
|
+
print("=" * 60, file=sys.stderr)
|
|
1501
|
+
print("Cicada: First-time setup detected", file=sys.stderr)
|
|
1502
|
+
print("=" * 60, file=sys.stderr)
|
|
1503
|
+
print(file=sys.stderr)
|
|
1504
|
+
|
|
1505
|
+
# Validate it's an Elixir project
|
|
1506
|
+
if not (repo_path / "mix.exs").exists():
|
|
1507
|
+
print(
|
|
1508
|
+
f"Error: {repo_path} does not appear to be an Elixir project",
|
|
1509
|
+
file=sys.stderr,
|
|
1510
|
+
)
|
|
1511
|
+
print("(mix.exs not found)", file=sys.stderr)
|
|
1512
|
+
sys.exit(1)
|
|
1513
|
+
|
|
1514
|
+
try:
|
|
1515
|
+
# Create storage directory
|
|
1516
|
+
storage_dir = create_storage_dir(repo_path)
|
|
1517
|
+
print(f"Repository: {repo_path}", file=sys.stderr)
|
|
1518
|
+
print(f"Storage: {storage_dir}", file=sys.stderr)
|
|
1519
|
+
print(file=sys.stderr)
|
|
1520
|
+
|
|
1521
|
+
# Index repository
|
|
1522
|
+
index_repository(repo_path)
|
|
1523
|
+
print(file=sys.stderr)
|
|
1524
|
+
|
|
1525
|
+
# Create config.yaml
|
|
1526
|
+
create_config_yaml(repo_path, storage_dir)
|
|
1527
|
+
print(file=sys.stderr)
|
|
1528
|
+
|
|
1529
|
+
print("=" * 60, file=sys.stderr)
|
|
1530
|
+
print("✓ Setup Complete! Starting server...", file=sys.stderr)
|
|
1531
|
+
print("=" * 60, file=sys.stderr)
|
|
1532
|
+
print(file=sys.stderr)
|
|
1533
|
+
|
|
1534
|
+
except Exception as e:
|
|
1535
|
+
print(f"Error during auto-setup: {e}", file=sys.stderr)
|
|
1536
|
+
sys.exit(1)
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
def main():
|
|
1540
|
+
"""Synchronous entry point for use with setuptools console_scripts."""
|
|
1541
|
+
import asyncio
|
|
1542
|
+
import sys
|
|
1543
|
+
|
|
1544
|
+
# Accept optional positional argument for repo path
|
|
1545
|
+
# Usage: cicada-server [repo_path]
|
|
1546
|
+
if len(sys.argv) > 1:
|
|
1547
|
+
repo_path = sys.argv[1]
|
|
1548
|
+
# Convert to absolute path
|
|
1549
|
+
from pathlib import Path
|
|
1550
|
+
|
|
1551
|
+
abs_path = Path(repo_path).resolve()
|
|
1552
|
+
# Set environment variable to override default
|
|
1553
|
+
os.environ["CICADA_REPO_PATH"] = str(abs_path)
|
|
1554
|
+
|
|
1555
|
+
asyncio.run(async_main())
|
|
1556
|
+
|
|
1557
|
+
|
|
1558
|
+
if __name__ == "__main__":
|
|
1559
|
+
main()
|