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
@@ -0,0 +1,282 @@
1
+ """
2
+ Dead Code Analyzer for Elixir codebases.
3
+
4
+ Identifies potentially unused public functions using the indexed codebase data.
5
+
6
+ Author: Cursor(Auto)
7
+ """
8
+
9
+ from typing import Dict, List, Optional
10
+
11
+
12
+ class DeadCodeAnalyzer:
13
+ """Analyzes Elixir code index to find potentially unused public functions."""
14
+
15
+ def __init__(self, index: dict):
16
+ """
17
+ Initialize analyzer with code index.
18
+
19
+ Args:
20
+ index: The indexed codebase data containing modules and their metadata
21
+ """
22
+ self.index = index
23
+ self.modules = index.get("modules", {})
24
+
25
+ def analyze(self) -> dict:
26
+ """
27
+ Analyze the index to find dead code candidates.
28
+
29
+ Returns:
30
+ Dict with analysis results:
31
+ {
32
+ "summary": {
33
+ "total_public_functions": int,
34
+ "analyzed_functions": int,
35
+ "skipped_impl_functions": int,
36
+ "skipped_test_functions": int,
37
+ "total_candidates": int
38
+ },
39
+ "candidates": {
40
+ "high": [...],
41
+ "medium": [...],
42
+ "low": [...]
43
+ }
44
+ }
45
+ """
46
+ # Track statistics
47
+ total_public = 0
48
+ skipped_impl = 0
49
+ skipped_files = 0 # test files and .exs files
50
+ analyzed = 0
51
+
52
+ # Collect candidates by confidence level
53
+ candidates = {"high": [], "medium": [], "low": []}
54
+
55
+ # Analyze each module
56
+ for module_name, module_data in self.modules.items():
57
+ # Skip test files and .exs files entirely
58
+ if self._is_test_file(module_data["file"]):
59
+ skipped_files += sum(
60
+ 1 for f in module_data["functions"] if f["type"] == "def"
61
+ )
62
+ continue
63
+
64
+ # Analyze each function in the module
65
+ for function in module_data["functions"]:
66
+ # Only analyze public functions
67
+ if function["type"] != "def":
68
+ continue
69
+
70
+ total_public += 1
71
+
72
+ # Skip functions with @impl (they're called by behaviors)
73
+ if function.get("impl"):
74
+ skipped_impl += 1
75
+ continue
76
+
77
+ analyzed += 1
78
+
79
+ # Find usages of this function
80
+ usage_count = self._find_usages(
81
+ module_name, function["name"], function["arity"]
82
+ )
83
+
84
+ # If function is used, skip it
85
+ if usage_count > 0:
86
+ continue
87
+
88
+ # Function has zero usages - determine confidence level
89
+ confidence = self._calculate_confidence(module_name, module_data)
90
+
91
+ # Create candidate entry
92
+ candidate = {
93
+ "module": module_name,
94
+ "function": function["name"],
95
+ "arity": function["arity"],
96
+ "line": function["line"],
97
+ "file": module_data["file"],
98
+ "signature": function.get(
99
+ "signature", f"{function['type']} {function['name']}"
100
+ ),
101
+ }
102
+
103
+ # Add context for low/medium confidence
104
+ if confidence == "low":
105
+ # Module is used as value somewhere
106
+ value_mentioners = self._find_value_mentioners(module_name)
107
+ candidate["reason"] = "module_passed_as_value"
108
+ candidate["mentioned_in"] = value_mentioners
109
+ elif confidence == "medium":
110
+ # Module has behaviors or uses
111
+ candidate["reason"] = "module_has_behaviors_or_uses"
112
+ candidate["uses"] = module_data.get("uses", [])
113
+ candidate["behaviours"] = module_data.get("behaviours", [])
114
+ else:
115
+ candidate["reason"] = "no_usage_found"
116
+
117
+ candidates[confidence].append(candidate)
118
+
119
+ # Build summary
120
+ total_candidates = sum(len(candidates[level]) for level in candidates)
121
+
122
+ return {
123
+ "summary": {
124
+ "total_public_functions": total_public,
125
+ "analyzed": analyzed,
126
+ "skipped_impl": skipped_impl,
127
+ "skipped_files": skipped_files,
128
+ "total_candidates": total_candidates,
129
+ },
130
+ "candidates": candidates,
131
+ }
132
+
133
+ def _is_test_file(self, file_path: str) -> bool:
134
+ """
135
+ Check if a file should be skipped from dead code analysis.
136
+
137
+ Files are skipped if they are:
138
+ - Test files (in 'test/' directory or '_test.ex' suffix)
139
+ - Script files (.exs extension)
140
+
141
+ Args:
142
+ file_path: Path to the file
143
+
144
+ Returns:
145
+ True if the file should be skipped
146
+ """
147
+ file_lower = file_path.lower()
148
+ return (
149
+ # Test files
150
+ "/test/" in file_lower
151
+ or file_lower.startswith("test/")
152
+ or file_lower.endswith("_test.ex")
153
+ # All .exs files (scripts, config files, etc.)
154
+ or file_lower.endswith(".exs")
155
+ )
156
+
157
+ def _find_usages(
158
+ self, target_module: str, target_function: str, target_arity: int
159
+ ) -> int:
160
+ """
161
+ Find the number of times a function is called across the codebase.
162
+
163
+ Uses the same logic as mcp_server._find_call_sites to resolve aliases
164
+ and match function calls.
165
+
166
+ Args:
167
+ target_module: Module containing the function
168
+ target_function: Function name
169
+ target_arity: Function arity
170
+
171
+ Returns:
172
+ Number of call sites found
173
+ """
174
+ call_count = 0
175
+
176
+ # Get the function definition line to filter out @spec/@doc
177
+ function_def_line = None
178
+ if target_module in self.modules:
179
+ for func in self.modules[target_module]["functions"]:
180
+ if func["name"] == target_function and func["arity"] == target_arity:
181
+ function_def_line = func["line"]
182
+ break
183
+
184
+ # Search through all modules for calls
185
+ for caller_module, module_data in self.modules.items():
186
+ # Get aliases for resolving calls
187
+ aliases = module_data.get("aliases", {})
188
+
189
+ # Check all calls in this module
190
+ for call in module_data.get("calls", []):
191
+ if call["function"] != target_function:
192
+ continue
193
+
194
+ if call["arity"] != target_arity:
195
+ continue
196
+
197
+ # Resolve the call's module name using aliases
198
+ call_module = call.get("module")
199
+
200
+ if call_module is None:
201
+ # Local call - check if it's in the same module
202
+ if caller_module == target_module:
203
+ # Filter out calls that are BEFORE the function definition
204
+ # (@spec, @doc annotations appear 1-5 lines before the def)
205
+ # Only filter if call is before def and within 5 lines
206
+ if function_def_line and call["line"] < function_def_line:
207
+ if (function_def_line - call["line"]) <= 5:
208
+ continue
209
+ call_count += 1
210
+ else:
211
+ # Qualified call - resolve the module name
212
+ resolved_module = aliases.get(call_module, call_module)
213
+
214
+ # Check if this resolves to our target module
215
+ if resolved_module == target_module:
216
+ call_count += 1
217
+
218
+ return call_count
219
+
220
+ def _calculate_confidence(self, module_name: str, module_data: dict) -> str:
221
+ """
222
+ Calculate confidence level for a dead code candidate.
223
+
224
+ Confidence levels:
225
+ - high: No usage, no dynamic call indicators, no behaviors/uses
226
+ - medium: No usage, but module has behaviors or uses (possible callbacks)
227
+ - low: No usage, but module passed as value (possible dynamic calls)
228
+
229
+ Args:
230
+ module_name: Name of the module
231
+ module_data: Module metadata
232
+
233
+ Returns:
234
+ Confidence level: "high", "medium", or "low"
235
+ """
236
+ # Check if module is used as a value (lowest confidence)
237
+ if self._is_module_used_as_value(module_name):
238
+ return "low"
239
+
240
+ # Check if module has behaviors or uses (medium confidence)
241
+ has_behaviour = len(module_data.get("behaviours", [])) > 0
242
+ has_use = len(module_data.get("uses", [])) > 0
243
+
244
+ if has_behaviour or has_use:
245
+ return "medium"
246
+
247
+ # No dynamic indicators - high confidence
248
+ return "high"
249
+
250
+ def _is_module_used_as_value(self, module_name: str) -> bool:
251
+ """
252
+ Check if a module is mentioned as a value in any other module.
253
+
254
+ When a module is passed as a value, its functions might be called
255
+ dynamically, so we can't be certain they're unused.
256
+
257
+ Args:
258
+ module_name: Module to check
259
+
260
+ Returns:
261
+ True if module appears in value_mentions of any other module
262
+ """
263
+ for other_module, module_data in self.modules.items():
264
+ if module_name in module_data.get("value_mentions", []):
265
+ return True
266
+ return False
267
+
268
+ def _find_value_mentioners(self, module_name: str) -> List[dict]:
269
+ """
270
+ Find all modules that mention this module as a value.
271
+
272
+ Args:
273
+ module_name: Module to search for
274
+
275
+ Returns:
276
+ List of dicts with {"module": str, "file": str}
277
+ """
278
+ mentioners = []
279
+ for other_module, module_data in self.modules.items():
280
+ if module_name in module_data.get("value_mentions", []):
281
+ mentioners.append({"module": other_module, "file": module_data["file"]})
282
+ return mentioners
@@ -0,0 +1,36 @@
1
+ """
2
+ Extractors for parsing Elixir source code.
3
+
4
+ This package contains specialized extractors for different parts of Elixir modules.
5
+
6
+ Author: Cursor(Auto)
7
+ """
8
+
9
+ from .module import extract_modules
10
+ from .function import extract_functions
11
+ from .spec import extract_specs, match_specs_to_functions
12
+ from .doc import extract_docs, match_docs_to_functions
13
+ from .dependency import (
14
+ extract_aliases,
15
+ extract_imports,
16
+ extract_requires,
17
+ extract_uses,
18
+ extract_behaviours,
19
+ )
20
+ from .call import extract_function_calls, extract_value_mentions
21
+
22
+ __all__ = [
23
+ "extract_modules",
24
+ "extract_functions",
25
+ "extract_specs",
26
+ "match_specs_to_functions",
27
+ "extract_docs",
28
+ "match_docs_to_functions",
29
+ "extract_aliases",
30
+ "extract_imports",
31
+ "extract_requires",
32
+ "extract_uses",
33
+ "extract_behaviours",
34
+ "extract_function_calls",
35
+ "extract_value_mentions",
36
+ ]
@@ -0,0 +1,66 @@
1
+ """
2
+ Shared utilities for extractors.
3
+ """
4
+
5
+
6
+ def extract_string_from_arguments(arguments_node, source_code: bytes) -> str | None:
7
+ """Extract string value from function arguments."""
8
+ for child in arguments_node.children:
9
+ # Handle string literals
10
+ if child.type == "string":
11
+ # Get the string content (without quotes)
12
+ string_content = []
13
+ for string_child in child.children:
14
+ if string_child.type == "quoted_content":
15
+ content = source_code[
16
+ string_child.start_byte : string_child.end_byte
17
+ ].decode("utf-8")
18
+ string_content.append(content)
19
+
20
+ if string_content:
21
+ return "".join(string_content)
22
+
23
+ # Handle false (for @moduledoc false)
24
+ elif child.type == "boolean" or child.type == "atom":
25
+ value = source_code[child.start_byte : child.end_byte].decode("utf-8")
26
+ if value == "false":
27
+ return None
28
+
29
+ return None
30
+
31
+
32
+ def get_param_name(node, source_code: bytes) -> str | None:
33
+ """Get parameter name from a parameter node."""
34
+ # Handle simple identifier: my_arg
35
+ if node.type == "identifier":
36
+ return source_code[node.start_byte : node.end_byte].decode("utf-8")
37
+
38
+ # Handle pattern match with default: my_arg \\ default_value
39
+ elif node.type == "binary_operator":
40
+ for child in node.children:
41
+ if child.type == "identifier":
42
+ return source_code[child.start_byte : child.end_byte].decode("utf-8")
43
+
44
+ # Handle destructuring: {key, value} or [head | tail]
45
+ elif node.type in ["tuple", "list", "map"]:
46
+ # For complex patterns, return the whole pattern as string
47
+ return source_code[node.start_byte : node.end_byte].decode("utf-8")
48
+
49
+ # Handle call patterns (e.g., %Struct{} = arg)
50
+ elif node.type == "call":
51
+ # Try to find the actual variable name
52
+ for child in node.children:
53
+ if child.type == "identifier":
54
+ return source_code[child.start_byte : child.end_byte].decode("utf-8")
55
+
56
+ # Fallback: return the whole node as string
57
+ return source_code[node.start_byte : node.end_byte].decode("utf-8")
58
+
59
+
60
+ def count_arguments(arguments_node) -> int:
61
+ """Count the number of arguments in a function call."""
62
+ count = 0
63
+ for child in arguments_node.children:
64
+ if child.type not in [",", "(", ")"]:
65
+ count += 1
66
+ return count
@@ -0,0 +1,176 @@
1
+ """
2
+ Function call and value mention extraction logic.
3
+ """
4
+
5
+ from .base import count_arguments
6
+
7
+
8
+ def extract_function_calls(node, source_code: bytes) -> list:
9
+ """Extract all function calls from a module body."""
10
+ calls = []
11
+ _find_function_calls_recursive(node, source_code, calls)
12
+ return calls
13
+
14
+
15
+ def _find_function_calls_recursive(node, source_code: bytes, calls: list):
16
+ """Recursively find function calls."""
17
+ if node.type == "call":
18
+ # Check if this is a function definition (def/defp)
19
+ is_function_def = False
20
+ for child in node.children:
21
+ if child.type == "identifier":
22
+ func_text = source_code[child.start_byte : child.end_byte].decode(
23
+ "utf-8"
24
+ )
25
+ if func_text in ["def", "defp", "defmodule"]:
26
+ is_function_def = True
27
+ break
28
+
29
+ if is_function_def:
30
+ # Skip the arguments (which contain the function signature)
31
+ # but still process the do_block to find calls within the function body
32
+ for child in node.children:
33
+ if child.type == "do_block":
34
+ _find_function_calls_recursive(child, source_code, calls)
35
+ return # Don't process other children
36
+
37
+ # Try to extract the function call information
38
+ call_info = _parse_function_call(node, source_code)
39
+ if call_info:
40
+ calls.append(call_info)
41
+
42
+ # Recursively search all children
43
+ for child in node.children:
44
+ _find_function_calls_recursive(child, source_code, calls)
45
+
46
+
47
+ def _parse_function_call(call_node, source_code: bytes) -> dict | None:
48
+ """
49
+ Parse a function call to extract the module, function name, arity, and location.
50
+
51
+ Handles:
52
+ - Local calls: func(arg1, arg2)
53
+ - Module calls: MyModule.func(arg1, arg2)
54
+ - Aliased calls: User.create(name, email)
55
+ """
56
+ line = call_node.start_point[0] + 1
57
+
58
+ # Check for dot notation (Module.function)
59
+ has_dot = False
60
+ module_name = None
61
+ function_name = None
62
+ arguments_node = None
63
+
64
+ for child in call_node.children:
65
+ if child.type == "dot":
66
+ has_dot = True
67
+ # Extract module and function from dot
68
+ for dot_child in child.children:
69
+ if dot_child.type == "alias":
70
+ module_name = source_code[
71
+ dot_child.start_byte : dot_child.end_byte
72
+ ].decode("utf-8")
73
+ elif dot_child.type == "identifier":
74
+ function_name = source_code[
75
+ dot_child.start_byte : dot_child.end_byte
76
+ ].decode("utf-8")
77
+ elif child.type == "identifier" and not has_dot:
78
+ # Local function call
79
+ function_name = source_code[child.start_byte : child.end_byte].decode(
80
+ "utf-8"
81
+ )
82
+ elif child.type == "arguments":
83
+ arguments_node = child
84
+
85
+ # Skip certain special forms and macros
86
+ if function_name in [
87
+ "alias",
88
+ "import",
89
+ "require",
90
+ "use",
91
+ "def",
92
+ "defp",
93
+ "defmodule",
94
+ "if",
95
+ "unless",
96
+ "case",
97
+ "cond",
98
+ "with",
99
+ "for",
100
+ "try",
101
+ "receive",
102
+ ]:
103
+ return None
104
+
105
+ # Calculate arity
106
+ arity = 0
107
+ if arguments_node:
108
+ arity = count_arguments(arguments_node)
109
+
110
+ if function_name:
111
+ return {
112
+ "module": module_name, # None for local calls
113
+ "function": function_name,
114
+ "arity": arity,
115
+ "line": line,
116
+ }
117
+
118
+ return None
119
+
120
+
121
+ def extract_value_mentions(node, source_code: bytes) -> list:
122
+ """Extract all module mentions as values (e.g., module passed as argument)."""
123
+ value_mentions = []
124
+ _find_value_mentions_recursive(node, source_code, value_mentions)
125
+ # Return unique module names
126
+ return list(set(value_mentions))
127
+
128
+
129
+ def _find_value_mentions_recursive(node, source_code: bytes, value_mentions: list):
130
+ """Recursively find module value mentions."""
131
+ # Look for alias nodes that are NOT part of alias/import/require/use declarations
132
+ # and are NOT part of module function calls (which are already tracked in calls)
133
+
134
+ if node.type == "alias":
135
+ # Check if this is a standalone alias (value mention)
136
+ # Skip if parent is a specific call type
137
+
138
+ # Get the module name
139
+ module_name = source_code[node.start_byte : node.end_byte].decode("utf-8")
140
+
141
+ # We need to check if this alias is part of a call with dot notation
142
+ # If it has a dot parent, it's a module function call, not a value mention
143
+ is_in_call = False
144
+ current = node
145
+
146
+ # Check ancestors to see if we're in a special context
147
+ for _ in range(3): # Check up to 3 levels up
148
+ if current.parent:
149
+ current = current.parent
150
+ if current.type == "call":
151
+ # Check if this is alias/import/require/use/defmodule
152
+ for child in current.children:
153
+ if child.type == "identifier":
154
+ func_text = source_code[
155
+ child.start_byte : child.end_byte
156
+ ].decode("utf-8")
157
+ if func_text in [
158
+ "alias",
159
+ "import",
160
+ "require",
161
+ "use",
162
+ "defmodule",
163
+ ]:
164
+ is_in_call = True
165
+ break
166
+ elif current.type == "dot":
167
+ # This alias is part of a Module.function call
168
+ is_in_call = True
169
+ break
170
+
171
+ if not is_in_call:
172
+ value_mentions.append(module_name)
173
+
174
+ # Recursively search all children
175
+ for child in node.children:
176
+ _find_value_mentions_recursive(child, source_code, value_mentions)