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
|
@@ -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)
|