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.

Files changed (48) hide show
  1. cicada/__init__.py +30 -0
  2. cicada/clean.py +297 -0
  3. cicada/command_logger.py +293 -0
  4. cicada/dead_code_analyzer.py +282 -0
  5. cicada/extractors/__init__.py +36 -0
  6. cicada/extractors/base.py +66 -0
  7. cicada/extractors/call.py +176 -0
  8. cicada/extractors/dependency.py +361 -0
  9. cicada/extractors/doc.py +179 -0
  10. cicada/extractors/function.py +246 -0
  11. cicada/extractors/module.py +123 -0
  12. cicada/extractors/spec.py +151 -0
  13. cicada/find_dead_code.py +270 -0
  14. cicada/formatter.py +918 -0
  15. cicada/git_helper.py +646 -0
  16. cicada/indexer.py +629 -0
  17. cicada/install.py +724 -0
  18. cicada/keyword_extractor.py +364 -0
  19. cicada/keyword_search.py +553 -0
  20. cicada/lightweight_keyword_extractor.py +298 -0
  21. cicada/mcp_server.py +1559 -0
  22. cicada/mcp_tools.py +291 -0
  23. cicada/parser.py +124 -0
  24. cicada/pr_finder.py +435 -0
  25. cicada/pr_indexer/__init__.py +20 -0
  26. cicada/pr_indexer/cli.py +62 -0
  27. cicada/pr_indexer/github_api_client.py +431 -0
  28. cicada/pr_indexer/indexer.py +297 -0
  29. cicada/pr_indexer/line_mapper.py +209 -0
  30. cicada/pr_indexer/pr_index_builder.py +253 -0
  31. cicada/setup.py +339 -0
  32. cicada/utils/__init__.py +52 -0
  33. cicada/utils/call_site_formatter.py +95 -0
  34. cicada/utils/function_grouper.py +57 -0
  35. cicada/utils/hash_utils.py +173 -0
  36. cicada/utils/index_utils.py +290 -0
  37. cicada/utils/path_utils.py +240 -0
  38. cicada/utils/signature_builder.py +106 -0
  39. cicada/utils/storage.py +111 -0
  40. cicada/utils/subprocess_runner.py +182 -0
  41. cicada/utils/text_utils.py +90 -0
  42. cicada/version_check.py +116 -0
  43. cicada_mcp-0.1.4.dist-info/METADATA +619 -0
  44. cicada_mcp-0.1.4.dist-info/RECORD +48 -0
  45. cicada_mcp-0.1.4.dist-info/WHEEL +5 -0
  46. cicada_mcp-0.1.4.dist-info/entry_points.txt +8 -0
  47. cicada_mcp-0.1.4.dist-info/licenses/LICENSE +21 -0
  48. 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()