cicada-mcp 0.2.0__py3-none-any.whl → 0.3.0__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.
Files changed (62) hide show
  1. cicada/_version_hash.py +4 -0
  2. cicada/cli.py +6 -748
  3. cicada/commands.py +1255 -0
  4. cicada/dead_code/__init__.py +1 -0
  5. cicada/{find_dead_code.py → dead_code/finder.py} +2 -1
  6. cicada/dependency_analyzer.py +147 -0
  7. cicada/entry_utils.py +92 -0
  8. cicada/extractors/base.py +9 -9
  9. cicada/extractors/call.py +17 -20
  10. cicada/extractors/common.py +64 -0
  11. cicada/extractors/dependency.py +117 -235
  12. cicada/extractors/doc.py +2 -49
  13. cicada/extractors/function.py +10 -14
  14. cicada/extractors/keybert.py +228 -0
  15. cicada/extractors/keyword.py +191 -0
  16. cicada/extractors/module.py +6 -10
  17. cicada/extractors/spec.py +8 -56
  18. cicada/format/__init__.py +20 -0
  19. cicada/{ascii_art.py → format/ascii_art.py} +1 -1
  20. cicada/format/formatter.py +1145 -0
  21. cicada/git_helper.py +134 -7
  22. cicada/indexer.py +322 -89
  23. cicada/interactive_setup.py +251 -323
  24. cicada/interactive_setup_helpers.py +302 -0
  25. cicada/keyword_expander.py +437 -0
  26. cicada/keyword_search.py +208 -422
  27. cicada/keyword_test.py +383 -16
  28. cicada/mcp/__init__.py +10 -0
  29. cicada/mcp/entry.py +17 -0
  30. cicada/mcp/filter_utils.py +107 -0
  31. cicada/mcp/pattern_utils.py +118 -0
  32. cicada/{mcp_server.py → mcp/server.py} +819 -73
  33. cicada/mcp/tools.py +473 -0
  34. cicada/pr_finder.py +2 -3
  35. cicada/pr_indexer/indexer.py +3 -2
  36. cicada/setup.py +167 -35
  37. cicada/tier.py +225 -0
  38. cicada/utils/__init__.py +9 -2
  39. cicada/utils/fuzzy_match.py +54 -0
  40. cicada/utils/index_utils.py +9 -0
  41. cicada/utils/path_utils.py +18 -0
  42. cicada/utils/text_utils.py +52 -1
  43. cicada/utils/tree_utils.py +47 -0
  44. cicada/version_check.py +99 -0
  45. cicada/watch_manager.py +320 -0
  46. cicada/watcher.py +431 -0
  47. cicada_mcp-0.3.0.dist-info/METADATA +541 -0
  48. cicada_mcp-0.3.0.dist-info/RECORD +70 -0
  49. cicada_mcp-0.3.0.dist-info/entry_points.txt +4 -0
  50. cicada/formatter.py +0 -864
  51. cicada/keybert_extractor.py +0 -286
  52. cicada/lightweight_keyword_extractor.py +0 -290
  53. cicada/mcp_entry.py +0 -683
  54. cicada/mcp_tools.py +0 -291
  55. cicada_mcp-0.2.0.dist-info/METADATA +0 -735
  56. cicada_mcp-0.2.0.dist-info/RECORD +0 -53
  57. cicada_mcp-0.2.0.dist-info/entry_points.txt +0 -4
  58. /cicada/{dead_code_analyzer.py → dead_code/analyzer.py} +0 -0
  59. /cicada/{colors.py → format/colors.py} +0 -0
  60. {cicada_mcp-0.2.0.dist-info → cicada_mcp-0.3.0.dist-info}/WHEEL +0 -0
  61. {cicada_mcp-0.2.0.dist-info → cicada_mcp-0.3.0.dist-info}/licenses/LICENSE +0 -0
  62. {cicada_mcp-0.2.0.dist-info → cicada_mcp-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1 @@
1
+ """Dead code analysis module - finds and analyzes unused code in Elixir projects."""
@@ -11,9 +11,10 @@ import argparse
11
11
  import json
12
12
  import sys
13
13
 
14
- from cicada.dead_code_analyzer import DeadCodeAnalyzer
15
14
  from cicada.utils import get_index_path, load_index
16
15
 
16
+ from .analyzer import DeadCodeAnalyzer
17
+
17
18
 
18
19
  def format_markdown(results: dict) -> str:
19
20
  """
@@ -0,0 +1,147 @@
1
+ """
2
+ Dependency analysis for Elixir modules and functions.
3
+
4
+ This module processes already-extracted AST data (aliases, imports, uses, calls)
5
+ to produce clean dependency information.
6
+ """
7
+
8
+
9
+ def _resolve_module_alias(module_name: str, aliases: dict) -> str:
10
+ """
11
+ Resolve a module name using the alias mapping.
12
+
13
+ Args:
14
+ module_name: Short or full module name
15
+ aliases: Dict mapping short names to full names
16
+
17
+ Returns:
18
+ Full module name (resolved if aliased, otherwise unchanged)
19
+ """
20
+ return aliases.get(module_name, module_name)
21
+
22
+
23
+ def extract_module_dependencies(module_data: dict) -> dict:
24
+ """
25
+ Extract module-level dependencies from parsed module data.
26
+
27
+ Args:
28
+ module_data: Dictionary containing module information with:
29
+ - aliases: Dict mapping short names to full module names
30
+ - imports: List of imported module names
31
+ - uses: List of used module names
32
+ - requires: List of required module names (optional)
33
+ - behaviours: List of behaviour module names (optional)
34
+ - calls: List of function calls with module, function, arity, line
35
+
36
+ Returns:
37
+ Dictionary with:
38
+ - modules: Set of module names this module depends on
39
+ - has_dynamic_calls: Boolean indicating if there are unresolved calls
40
+ """
41
+ dependencies = set()
42
+ aliases = module_data.get("aliases", {})
43
+
44
+ # Add dependencies from various sources
45
+ # Note: we use aliases.values() to get full names, not short names
46
+ for _source_key, extract_values in [
47
+ ("aliases", lambda: aliases.values()),
48
+ ("imports", lambda: module_data.get("imports", [])),
49
+ ("uses", lambda: module_data.get("uses", [])),
50
+ ("requires", lambda: module_data.get("requires", [])),
51
+ ("behaviours", lambda: module_data.get("behaviours", [])),
52
+ ]:
53
+ dependencies.update(extract_values())
54
+
55
+ # Add dependencies from function calls (with alias resolution)
56
+ for call in module_data.get("calls", []):
57
+ module_name = call.get("module")
58
+ if module_name:
59
+ resolved_module = _resolve_module_alias(module_name, aliases)
60
+ # Exclude Kernel module (too noisy)
61
+ if resolved_module != "Kernel":
62
+ dependencies.add(resolved_module)
63
+
64
+ return {
65
+ "modules": sorted(dependencies),
66
+ "has_dynamic_calls": False, # Could be enhanced to detect apply() etc.
67
+ }
68
+
69
+
70
+ def extract_function_dependencies(
71
+ module_data: dict,
72
+ function_data: dict,
73
+ all_module_calls: list,
74
+ function_end_line: int,
75
+ ) -> list:
76
+ """
77
+ Extract function-level dependencies from function calls.
78
+
79
+ Args:
80
+ module_data: Dictionary containing module information (for alias resolution)
81
+ function_data: Dictionary containing function information (name, arity, line)
82
+ all_module_calls: List of ALL calls in the module
83
+ function_end_line: The line where the function ends
84
+
85
+ Returns:
86
+ List of dictionaries, each containing:
87
+ - module: Module name (resolved from aliases)
88
+ - function: Function name
89
+ - arity: Function arity
90
+ - line: Line number where called
91
+ """
92
+ module_name = module_data.get("module")
93
+ aliases = module_data.get("aliases", {})
94
+ function_start_line = function_data.get("line")
95
+
96
+ # Filter calls to only those within this function's line range
97
+ function_calls = [
98
+ call
99
+ for call in all_module_calls
100
+ if function_start_line is not None
101
+ and call.get("line") is not None
102
+ and function_start_line <= call["line"] <= function_end_line
103
+ ]
104
+
105
+ dependencies = []
106
+ for call in function_calls:
107
+ # Resolve module name (external calls use aliases, local calls use current module)
108
+ call_module = call.get("module")
109
+ resolved_module = (
110
+ _resolve_module_alias(call_module, aliases) if call_module else module_name
111
+ )
112
+
113
+ dependencies.append(
114
+ {
115
+ "module": resolved_module,
116
+ "function": call.get("function"),
117
+ "arity": call.get("arity"),
118
+ "line": call.get("line"),
119
+ }
120
+ )
121
+
122
+ return dependencies
123
+
124
+
125
+ def calculate_function_end_line(function_data: dict, next_function_line: int | None) -> int:
126
+ """
127
+ Calculate the end line of a function.
128
+
129
+ Args:
130
+ function_data: Dictionary containing function information
131
+ next_function_line: Line number of the next function, or None if this is the last function
132
+
133
+ Returns:
134
+ Estimated end line of the function
135
+ """
136
+ function_line = function_data.get("line")
137
+
138
+ if next_function_line:
139
+ # Function ends just before the next function
140
+ return next_function_line - 1
141
+ elif function_line is not None:
142
+ # Last function - use a large number as end line
143
+ # This is a heuristic; ideally we'd get the actual end line from the AST
144
+ return function_line + 10000
145
+ else:
146
+ # If no line info, return a large number
147
+ return 99999999
cicada/entry_utils.py ADDED
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from collections.abc import Callable, Sequence
5
+
6
+ from cicada import commands as _commands_module
7
+
8
+ KNOWN_SUBCOMMANDS_SET = getattr(_commands_module, "KNOWN_SUBCOMMANDS_SET", frozenset())
9
+ get_argument_parser = _commands_module.get_argument_parser
10
+ handle_command = _commands_module.handle_command
11
+
12
+ DefaultResolver = Callable[[], str | None] | str | None
13
+
14
+
15
+ def prepare_argv(
16
+ argv: Sequence[str],
17
+ *,
18
+ default_on_unknown: str | None,
19
+ default_on_none: DefaultResolver,
20
+ default_on_unknown_args: Sequence[str] | None = None,
21
+ default_on_none_args: Sequence[str] | None = None,
22
+ ) -> list[str]:
23
+ """
24
+ Normalize argv so both entry points share identical subcommand routing.
25
+
26
+ - If the first argument is an unknown token (and not a flag), inject the default subcommand and any associated args.
27
+ - If no arguments are provided, append the default-on-none subcommand (with optional extra args).
28
+ """
29
+ normalized = list(argv)
30
+
31
+ if len(normalized) > 1:
32
+ first_arg = normalized[1]
33
+ if (
34
+ default_on_unknown
35
+ and first_arg not in KNOWN_SUBCOMMANDS_SET
36
+ and not first_arg.startswith("-")
37
+ ):
38
+ extras = list(default_on_unknown_args or ())
39
+ normalized[1:1] = [default_on_unknown, *extras]
40
+ elif len(normalized) == 1:
41
+ default_command = _resolve_default(default_on_none)
42
+ if default_command:
43
+ extras = list(default_on_none_args or ())
44
+ normalized.append(default_command)
45
+ if extras:
46
+ normalized.extend(extras)
47
+
48
+ return normalized
49
+
50
+
51
+ def run_cli(
52
+ *,
53
+ prog_name: str,
54
+ version_prog_name: str,
55
+ default_on_unknown: str | None,
56
+ default_on_none: DefaultResolver,
57
+ default_on_unknown_args: Sequence[str] | None = None,
58
+ default_on_none_args: Sequence[str] | None = None,
59
+ ) -> None:
60
+ """Shared entry-point runner for cicada and cicada-mcp."""
61
+ argv = list(sys.argv)
62
+ _maybe_print_version(argv, version_prog_name)
63
+
64
+ normalized = prepare_argv(
65
+ argv,
66
+ default_on_unknown=default_on_unknown,
67
+ default_on_none=default_on_none,
68
+ default_on_unknown_args=default_on_unknown_args,
69
+ default_on_none_args=default_on_none_args,
70
+ )
71
+
72
+ parser = get_argument_parser()
73
+ parser.prog = prog_name
74
+ args = parser.parse_args(normalized[1:])
75
+
76
+ if not handle_command(args):
77
+ parser.print_help()
78
+ sys.exit(1)
79
+
80
+
81
+ def _resolve_default(spec: DefaultResolver) -> str | None:
82
+ if callable(spec):
83
+ return spec()
84
+ return spec
85
+
86
+
87
+ def _maybe_print_version(argv: Sequence[str], prog_name: str) -> None:
88
+ if len(argv) > 1 and argv[1] in ("--version", "-v"):
89
+ from cicada.version_check import get_version_string
90
+
91
+ print(f"{prog_name} {get_version_string()}")
92
+ sys.exit(0)
cicada/extractors/base.py CHANGED
@@ -2,6 +2,8 @@
2
2
  Shared utilities for extractors.
3
3
  """
4
4
 
5
+ from cicada.utils import extract_text_from_node
6
+
5
7
 
6
8
  def extract_string_from_arguments(arguments_node, source_code: bytes) -> str | None:
7
9
  """Extract string value from function arguments."""
@@ -12,9 +14,7 @@ def extract_string_from_arguments(arguments_node, source_code: bytes) -> str | N
12
14
  string_content = []
13
15
  for string_child in child.children:
14
16
  if string_child.type == "quoted_content":
15
- content = source_code[string_child.start_byte : string_child.end_byte].decode(
16
- "utf-8"
17
- )
17
+ content = extract_text_from_node(string_child, source_code)
18
18
  string_content.append(content)
19
19
 
20
20
  if string_content:
@@ -22,7 +22,7 @@ def extract_string_from_arguments(arguments_node, source_code: bytes) -> str | N
22
22
 
23
23
  # Handle false (for @moduledoc false)
24
24
  elif child.type == "boolean" or child.type == "atom":
25
- value = source_code[child.start_byte : child.end_byte].decode("utf-8")
25
+ value = extract_text_from_node(child, source_code)
26
26
  if value == "false":
27
27
  return None
28
28
 
@@ -33,28 +33,28 @@ def get_param_name(node, source_code: bytes) -> str | None:
33
33
  """Get parameter name from a parameter node."""
34
34
  # Handle simple identifier: my_arg
35
35
  if node.type == "identifier":
36
- return source_code[node.start_byte : node.end_byte].decode("utf-8")
36
+ return extract_text_from_node(node, source_code)
37
37
 
38
38
  # Handle pattern match with default: my_arg \\ default_value
39
39
  elif node.type == "binary_operator":
40
40
  for child in node.children:
41
41
  if child.type == "identifier":
42
- return source_code[child.start_byte : child.end_byte].decode("utf-8")
42
+ return extract_text_from_node(child, source_code)
43
43
 
44
44
  # Handle destructuring: {key, value} or [head | tail]
45
45
  elif node.type in ["tuple", "list", "map"]:
46
46
  # For complex patterns, return the whole pattern as string
47
- return source_code[node.start_byte : node.end_byte].decode("utf-8")
47
+ return extract_text_from_node(node, source_code)
48
48
 
49
49
  # Handle call patterns (e.g., %Struct{} = arg)
50
50
  elif node.type == "call":
51
51
  # Try to find the actual variable name
52
52
  for child in node.children:
53
53
  if child.type == "identifier":
54
- return source_code[child.start_byte : child.end_byte].decode("utf-8")
54
+ return extract_text_from_node(child, source_code)
55
55
 
56
56
  # Fallback: return the whole node as string
57
- return source_code[node.start_byte : node.end_byte].decode("utf-8")
57
+ return extract_text_from_node(node, source_code)
58
58
 
59
59
 
60
60
  def count_arguments(arguments_node) -> int:
cicada/extractors/call.py CHANGED
@@ -2,6 +2,8 @@
2
2
  Function call and value mention extraction logic.
3
3
  """
4
4
 
5
+ from cicada.utils import extract_text_from_node, is_function_definition_call
6
+
5
7
  from .base import count_arguments
6
8
 
7
9
 
@@ -14,17 +16,18 @@ def extract_function_calls(node, source_code: bytes) -> list:
14
16
 
15
17
  def _find_function_calls_recursive(node, source_code: bytes, calls: list):
16
18
  """Recursively find function calls."""
17
- if node.type == "call":
18
- # Check if this is a function definition (def/defp)
19
- is_function_def = False
19
+ # Skip module attributes (@spec, @doc, @moduledoc, @type, etc.)
20
+ # These are wrapped in unary_operator nodes with @ token
21
+ if node.type == "unary_operator":
22
+ # Check if this is a module attribute (starts with @)
20
23
  for child in node.children:
21
- if child.type == "identifier":
22
- func_text = source_code[child.start_byte : child.end_byte].decode("utf-8")
23
- if func_text in ["def", "defp", "defmodule"]:
24
- is_function_def = True
25
- break
24
+ if child.type == "@":
25
+ # This is a module attribute, skip the entire subtree
26
+ return
26
27
 
27
- if is_function_def:
28
+ if node.type == "call":
29
+ # Check if this is a function definition (def/defp)
30
+ if is_function_definition_call(node, source_code):
28
31
  # Skip the arguments (which contain the function signature)
29
32
  # but still process the do_block to find calls within the function body
30
33
  for child in node.children:
@@ -65,16 +68,12 @@ def _parse_function_call(call_node, source_code: bytes) -> dict | None:
65
68
  # Extract module and function from dot
66
69
  for dot_child in child.children:
67
70
  if dot_child.type == "alias":
68
- module_name = source_code[dot_child.start_byte : dot_child.end_byte].decode(
69
- "utf-8"
70
- )
71
+ module_name = extract_text_from_node(dot_child, source_code)
71
72
  elif dot_child.type == "identifier":
72
- function_name = source_code[dot_child.start_byte : dot_child.end_byte].decode(
73
- "utf-8"
74
- )
73
+ function_name = extract_text_from_node(dot_child, source_code)
75
74
  elif child.type == "identifier" and not has_dot:
76
75
  # Local function call
77
- function_name = source_code[child.start_byte : child.end_byte].decode("utf-8")
76
+ function_name = extract_text_from_node(child, source_code)
78
77
  elif child.type == "arguments":
79
78
  arguments_node = child
80
79
 
@@ -132,7 +131,7 @@ def _find_value_mentions_recursive(node, source_code: bytes, value_mentions: lis
132
131
  # Skip if parent is a specific call type
133
132
 
134
133
  # Get the module name
135
- module_name = source_code[node.start_byte : node.end_byte].decode("utf-8")
134
+ module_name = extract_text_from_node(node, source_code)
136
135
 
137
136
  # We need to check if this alias is part of a call with dot notation
138
137
  # If it has a dot parent, it's a module function call, not a value mention
@@ -147,9 +146,7 @@ def _find_value_mentions_recursive(node, source_code: bytes, value_mentions: lis
147
146
  # Check if this is alias/import/require/use/defmodule
148
147
  for child in current.children:
149
148
  if child.type == "identifier":
150
- func_text = source_code[child.start_byte : child.end_byte].decode(
151
- "utf-8"
152
- )
149
+ func_text = extract_text_from_node(child, source_code)
153
150
  if func_text in [
154
151
  "alias",
155
152
  "import",
@@ -0,0 +1,64 @@
1
+ from cicada.utils import extract_text_from_node, is_function_definition_call
2
+
3
+
4
+ def _find_nodes_recursive(node, source_code: bytes, results: list, node_type: str, parse_function):
5
+ """Recursively find nodes of a specific type and parse them."""
6
+ if node.type == node_type:
7
+ result = parse_function(node, source_code)
8
+ if result:
9
+ if isinstance(result, list):
10
+ results.extend(result)
11
+ else:
12
+ results.append(result)
13
+
14
+ # Recursively search children, but skip function bodies
15
+ for child in node.children:
16
+ if child.type == "call" and is_function_definition_call(child, source_code):
17
+ continue
18
+
19
+ _find_nodes_recursive(child, source_code, results, node_type, parse_function)
20
+
21
+
22
+ def _find_attribute_recursive(
23
+ node, source_code: bytes, attributes: dict, attribute_name: str, parse_function
24
+ ):
25
+ """Recursively find attribute declarations."""
26
+ # Look for unary_operator nodes (which represent @ attributes)
27
+ if node.type == "unary_operator":
28
+ operator = None
29
+ operand = None
30
+
31
+ for child in node.children:
32
+ if child.type == "@":
33
+ operator = child
34
+ elif child.type == "call":
35
+ operand = child
36
+
37
+ if operator and operand:
38
+ # Check if this is a doc attribute
39
+ for call_child in operand.children:
40
+ if call_child.type == "identifier":
41
+ attr_name = extract_text_from_node(call_child, source_code)
42
+
43
+ if attr_name == attribute_name:
44
+ # Extract the doc definition
45
+ if attribute_name == "spec":
46
+ attribute_info = parse_function(operand, source_code)
47
+ else:
48
+ attribute_info = parse_function(
49
+ operand, source_code, node.start_point[0] + 1
50
+ )
51
+ if attribute_info:
52
+ if attribute_name == "doc":
53
+ attributes[attribute_info["line"]] = attribute_info
54
+ elif attribute_name == "spec":
55
+ key = f"{attribute_info['name']}/{attribute_info['arity']}"
56
+ attributes[key] = attribute_info
57
+
58
+ # Recursively search children
59
+ for child in node.children:
60
+ # Don't recurse into nested defmodule or function definitions
61
+ if child.type == "call" and is_function_definition_call(child, source_code):
62
+ continue
63
+
64
+ _find_attribute_recursive(child, source_code, attributes, attribute_name, parse_function)