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,240 @@
1
+ """
2
+ Path manipulation utilities.
3
+
4
+ This module provides centralized path normalization and resolution
5
+ functions used throughout the codebase.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from typing import Optional, Union
10
+
11
+
12
+ def normalize_file_path(
13
+ file_path: Union[str, Path],
14
+ strip_leading_dot: bool = True,
15
+ strip_trailing_whitespace: bool = True,
16
+ ) -> str:
17
+ """
18
+ Normalize a file path for consistent comparisons.
19
+
20
+ Args:
21
+ file_path: Path to normalize
22
+ strip_leading_dot: Remove leading './' if present
23
+ strip_trailing_whitespace: Remove trailing whitespace
24
+
25
+ Returns:
26
+ Normalized path string
27
+
28
+ Example:
29
+ normalize_file_path('./lib/user.ex') -> 'lib/user.ex'
30
+ normalize_file_path(' lib/user.ex ') -> 'lib/user.ex'
31
+ """
32
+ path_str = str(file_path)
33
+
34
+ if strip_trailing_whitespace:
35
+ path_str = path_str.strip()
36
+
37
+ if strip_leading_dot:
38
+ # Remove leading './' prefix (not individual '.' or '/' characters)
39
+ while path_str.startswith("./"):
40
+ path_str = path_str[2:]
41
+
42
+ return path_str
43
+
44
+
45
+ def resolve_to_repo_root(
46
+ file_path: Union[str, Path],
47
+ repo_root: Union[str, Path],
48
+ ) -> Path:
49
+ """
50
+ Resolve a file path relative to repository root.
51
+
52
+ Handles both absolute and relative paths, converting absolute paths
53
+ to be relative to the repository root.
54
+
55
+ Args:
56
+ file_path: Path to resolve (can be absolute or relative)
57
+ repo_root: Repository root directory
58
+
59
+ Returns:
60
+ Path relative to repo root
61
+
62
+ Raises:
63
+ ValueError: If absolute path is not within repo root
64
+
65
+ Example:
66
+ resolve_to_repo_root('/repo/lib/user.ex', '/repo') -> Path('lib/user.ex')
67
+ resolve_to_repo_root('lib/user.ex', '/repo') -> Path('lib/user.ex')
68
+ """
69
+ file_path_obj = Path(file_path)
70
+ repo_root_obj = Path(repo_root).resolve()
71
+
72
+ # If already relative, return as-is
73
+ if not file_path_obj.is_absolute():
74
+ return file_path_obj
75
+
76
+ # Convert absolute to relative
77
+ try:
78
+ return file_path_obj.relative_to(repo_root_obj)
79
+ except ValueError:
80
+ raise ValueError(f"File path {file_path} is not within repository {repo_root}")
81
+
82
+
83
+ def match_file_path(
84
+ candidate: Union[str, Path],
85
+ target: Union[str, Path],
86
+ normalize: bool = True,
87
+ ) -> bool:
88
+ """
89
+ Check if two file paths match, with flexible matching rules.
90
+
91
+ Supports:
92
+ - Exact match
93
+ - Candidate ends with target
94
+ - Target ends with candidate
95
+
96
+ Args:
97
+ candidate: File path to check
98
+ target: Target file path
99
+ normalize: Whether to normalize paths before comparison
100
+
101
+ Returns:
102
+ True if paths match
103
+
104
+ Example:
105
+ match_file_path('lib/user.ex', 'lib/user.ex') -> True
106
+ match_file_path('/repo/lib/user.ex', 'lib/user.ex') -> True
107
+ match_file_path('user.ex', 'lib/user.ex') -> True
108
+ """
109
+ if normalize:
110
+ candidate_str = normalize_file_path(candidate)
111
+ target_str = normalize_file_path(target)
112
+ else:
113
+ candidate_str = str(candidate)
114
+ target_str = str(target)
115
+
116
+ # Exact match
117
+ if candidate_str == target_str:
118
+ return True
119
+
120
+ # Candidate ends with target (absolute path provided, target is relative)
121
+ if candidate_str.endswith(target_str):
122
+ return True
123
+
124
+ # Target ends with candidate (partial path provided)
125
+ if target_str.endswith(candidate_str):
126
+ return True
127
+
128
+ return False
129
+
130
+
131
+ def find_repo_root(start_path: Optional[Union[str, Path]] = None) -> Optional[Path]:
132
+ """
133
+ Find the git repository root starting from a given path.
134
+
135
+ Args:
136
+ start_path: Path to start searching from (default: current directory)
137
+
138
+ Returns:
139
+ Path to repository root, or None if not in a git repo
140
+
141
+ Example:
142
+ find_repo_root('/repo/lib/user') -> Path('/repo')
143
+ find_repo_root('/not/a/repo') -> None
144
+ """
145
+ if start_path is None:
146
+ current = Path.cwd()
147
+ else:
148
+ current = Path(start_path).resolve()
149
+
150
+ # Walk up the directory tree looking for .git
151
+ for parent in [current] + list(current.parents):
152
+ if (parent / ".git").exists():
153
+ return parent
154
+
155
+ return None
156
+
157
+
158
+ def ensure_relative_to_repo(
159
+ file_path: Union[str, Path],
160
+ repo_root: Union[str, Path],
161
+ ) -> str:
162
+ """
163
+ Ensure a file path is relative to the repository root.
164
+
165
+ This is a convenience function that combines normalization and
166
+ resolution. If the path is already relative, it's normalized.
167
+ If it's absolute, it's converted to relative.
168
+
169
+ Args:
170
+ file_path: File path to process
171
+ repo_root: Repository root directory
172
+
173
+ Returns:
174
+ Normalized path string relative to repo root
175
+
176
+ Example:
177
+ ensure_relative_to_repo('/repo/./lib/user.ex', '/repo') -> 'lib/user.ex'
178
+ ensure_relative_to_repo('lib/user.ex', '/repo') -> 'lib/user.ex'
179
+ """
180
+ resolved = resolve_to_repo_root(file_path, repo_root)
181
+ return normalize_file_path(resolved)
182
+
183
+
184
+ def ensure_gitignore_has_cicada(repo_root: Union[str, Path]) -> bool:
185
+ """
186
+ Ensure .gitignore contains .cicada/ directory entry.
187
+
188
+ If .gitignore exists and doesn't already contain .cicada/, adds it.
189
+ If .gitignore doesn't exist, this function does nothing.
190
+
191
+ Args:
192
+ repo_root: Repository root directory
193
+
194
+ Returns:
195
+ True if .cicada/ was added to .gitignore, False otherwise
196
+
197
+ Example:
198
+ ensure_gitignore_has_cicada('/repo') -> True (if added)
199
+ ensure_gitignore_has_cicada('/repo') -> False (if already present or no .gitignore)
200
+ """
201
+ repo_root_path = Path(repo_root).resolve()
202
+ gitignore_path = repo_root_path / ".gitignore"
203
+
204
+ # Do nothing if .gitignore doesn't exist
205
+ if not gitignore_path.exists():
206
+ return False
207
+
208
+ try:
209
+ # Read existing .gitignore
210
+ with open(gitignore_path, "r") as f:
211
+ content = f.read()
212
+
213
+ # Check if .cicada/ is already present in actual gitignore patterns
214
+ # (ignore comment lines starting with #)
215
+ for line in content.splitlines():
216
+ # Strip whitespace and skip empty lines and comments
217
+ stripped = line.strip()
218
+ if stripped and not stripped.startswith("#"):
219
+ # Check if this line contains .cicada as a gitignore pattern
220
+ # Valid patterns: .cicada, .cicada/, /.cicada, /.cicada/, **/.cicada/, etc.
221
+ if (
222
+ stripped in (".cicada", ".cicada/")
223
+ or stripped.endswith("/.cicada")
224
+ or stripped.endswith("/.cicada/")
225
+ ):
226
+ return False
227
+
228
+ # Add .cicada/ to .gitignore
229
+ with open(gitignore_path, "a") as f:
230
+ # Add newline if file doesn't end with one
231
+ if content and not content.endswith("\n"):
232
+ f.write("\n")
233
+
234
+ f.write(".cicada/\n")
235
+
236
+ return True
237
+
238
+ except (IOError, OSError):
239
+ # Fail silently if we can't read/write the file
240
+ return False
@@ -0,0 +1,106 @@
1
+ """
2
+ Function signature building utilities.
3
+
4
+ This module provides utilities for formatting function signatures,
5
+ eliminating duplication across the formatter module.
6
+ """
7
+
8
+ from typing import Dict, List, Any
9
+
10
+
11
+ class SignatureBuilder:
12
+ """
13
+ Builds formatted function signatures from function data.
14
+
15
+ This class consolidates signature formatting logic that appears
16
+ in multiple places in the formatter module.
17
+ """
18
+
19
+ @staticmethod
20
+ def build(func: Dict[str, Any]) -> str:
21
+ """
22
+ Build a formatted function signature.
23
+
24
+ Creates signatures like:
25
+ - "func_name(arg1: type1, arg2: type2) :: return_type"
26
+ - "func_name(arg1, arg2)"
27
+ - "func_name/2"
28
+
29
+ Args:
30
+ func: Function dictionary with keys:
31
+ - name: Function name
32
+ - arity: Function arity
33
+ - args: Optional list of argument names
34
+ - args_with_types: Optional list of {name, type} dicts
35
+ - return_type: Optional return type string
36
+
37
+ Returns:
38
+ Formatted signature string
39
+
40
+ Example:
41
+ func = {
42
+ 'name': 'create_user',
43
+ 'arity': 2,
44
+ 'args_with_types': [
45
+ {'name': 'attrs', 'type': 'map'},
46
+ {'name': 'opts', 'type': 'keyword'}
47
+ ],
48
+ 'return_type': '{:ok, User.t()} | {:error, Ecto.Changeset.t()}'
49
+ }
50
+ sig = SignatureBuilder.build(func)
51
+ # Returns: "create_user(attrs: map, opts: keyword) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}"
52
+ """
53
+ func_name = func["name"]
54
+ signature = ""
55
+
56
+ # If we have args_with_types, use that for rich signatures
57
+ if "args_with_types" in func and func["args_with_types"]:
58
+ args_str = SignatureBuilder._format_args_with_types(func["args_with_types"])
59
+ signature = f"{func_name}({args_str})"
60
+
61
+ # Otherwise, fallback to args without types
62
+ elif "args" in func and func["args"]:
63
+ args_str = ", ".join(func["args"])
64
+ signature = f"{func_name}({args_str})"
65
+
66
+ # No args, just show function name with empty parens or /0
67
+ elif func["arity"] == 0:
68
+ signature = f"{func_name}()"
69
+
70
+ # Fallback to name/arity notation
71
+ else:
72
+ signature = f"{func_name}/{func['arity']}"
73
+
74
+ # Append return type if available
75
+ if "return_type" in func and func["return_type"]:
76
+ signature += f" :: {func['return_type']}"
77
+
78
+ return signature
79
+
80
+ @staticmethod
81
+ def _format_args_with_types(args_with_types: List[Dict[str, str]]) -> str:
82
+ """
83
+ Format arguments with type annotations.
84
+
85
+ Args:
86
+ args_with_types: List of dicts with 'name' and 'type' keys
87
+
88
+ Returns:
89
+ Comma-separated string of "name: type" pairs
90
+
91
+ Example:
92
+ args = [
93
+ {'name': 'attrs', 'type': 'map'},
94
+ {'name': 'opts', 'type': 'keyword'}
95
+ ]
96
+ formatted = SignatureBuilder._format_args_with_types(args)
97
+ # Returns: "attrs: map, opts: keyword"
98
+ """
99
+ formatted_args: list[str] = []
100
+ for arg in args_with_types:
101
+ if arg.get("type"):
102
+ formatted_args.append(f"{arg['name']}: {arg['type']}")
103
+ else:
104
+ formatted_args.append(arg["name"])
105
+
106
+ return ", ".join(formatted_args)
@@ -0,0 +1,111 @@
1
+ """
2
+ Storage management utilities for Cicada.
3
+
4
+ Handles creation and management of storage directories for index files.
5
+ """
6
+
7
+ import hashlib
8
+ from pathlib import Path
9
+
10
+
11
+ def get_repo_hash(repo_path: str | Path) -> str:
12
+ """
13
+ Generate a unique hash for a repository path.
14
+
15
+ Args:
16
+ repo_path: Path to the repository
17
+
18
+ Returns:
19
+ Hex string hash of the repository path
20
+ """
21
+ repo_path_str = str(Path(repo_path).resolve())
22
+ return hashlib.sha256(repo_path_str.encode()).hexdigest()[:16]
23
+
24
+
25
+ def get_storage_dir(repo_path: str | Path) -> Path:
26
+ """
27
+ Get the storage directory for a repository.
28
+
29
+ Storage structure:
30
+ ~/.cicada/projects/<repo_hash>/
31
+
32
+ Args:
33
+ repo_path: Path to the repository
34
+
35
+ Returns:
36
+ Path to the storage directory for this repository
37
+ """
38
+ repo_hash = get_repo_hash(repo_path)
39
+ storage_dir = Path.home() / ".cicada" / "projects" / repo_hash
40
+ return storage_dir
41
+
42
+
43
+ def create_storage_dir(repo_path: str | Path) -> Path:
44
+ """
45
+ Create the storage directory for a repository if it doesn't exist.
46
+
47
+ Args:
48
+ repo_path: Path to the repository
49
+
50
+ Returns:
51
+ Path to the created storage directory
52
+ """
53
+ storage_dir = get_storage_dir(repo_path)
54
+ storage_dir.mkdir(parents=True, exist_ok=True)
55
+ return storage_dir
56
+
57
+
58
+ def get_index_path(repo_path: str | Path) -> Path:
59
+ """
60
+ Get the path to the index file for a repository.
61
+
62
+ Args:
63
+ repo_path: Path to the repository
64
+
65
+ Returns:
66
+ Path to the index.json file
67
+ """
68
+ storage_dir = get_storage_dir(repo_path)
69
+ return storage_dir / "index.json"
70
+
71
+
72
+ def get_config_path(repo_path: str | Path) -> Path:
73
+ """
74
+ Get the path to the config file for a repository.
75
+
76
+ Args:
77
+ repo_path: Path to the repository
78
+
79
+ Returns:
80
+ Path to the config.yaml file
81
+ """
82
+ storage_dir = get_storage_dir(repo_path)
83
+ return storage_dir / "config.yaml"
84
+
85
+
86
+ def get_hashes_path(repo_path: str | Path) -> Path:
87
+ """
88
+ Get the path to the hashes file for a repository.
89
+
90
+ Args:
91
+ repo_path: Path to the repository
92
+
93
+ Returns:
94
+ Path to the hashes.json file
95
+ """
96
+ storage_dir = get_storage_dir(repo_path)
97
+ return storage_dir / "hashes.json"
98
+
99
+
100
+ def get_pr_index_path(repo_path: str | Path) -> Path:
101
+ """
102
+ Get the path to the PR index file for a repository.
103
+
104
+ Args:
105
+ repo_path: Path to the repository
106
+
107
+ Returns:
108
+ Path to the pr_index.json file
109
+ """
110
+ storage_dir = get_storage_dir(repo_path)
111
+ return storage_dir / "pr_index.json"
@@ -0,0 +1,182 @@
1
+ """
2
+ Subprocess execution utilities.
3
+
4
+ This module provides centralized subprocess execution with consistent
5
+ error handling and logging patterns.
6
+ """
7
+
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Optional, List, Union
12
+
13
+
14
+ class SubprocessRunner:
15
+ """
16
+ Centralized subprocess execution with error handling.
17
+
18
+ This class provides consistent subprocess execution patterns used
19
+ throughout the codebase, reducing duplication and ensuring uniform
20
+ error handling.
21
+ """
22
+
23
+ def __init__(self, cwd: Optional[Union[str, Path]] = None, verbose: bool = False):
24
+ """
25
+ Initialize the subprocess runner.
26
+
27
+ Args:
28
+ cwd: Working directory for commands (default: current directory)
29
+ verbose: If True, print command output to stderr
30
+ """
31
+ self.cwd = Path(cwd) if cwd else None
32
+ self.verbose = verbose
33
+
34
+ def run(
35
+ self,
36
+ cmd: Union[str, List[str]],
37
+ capture_output: bool = True,
38
+ text: bool = True,
39
+ check: bool = True,
40
+ timeout: Optional[int] = None,
41
+ ) -> subprocess.CompletedProcess:
42
+ """
43
+ Run a subprocess command with error handling.
44
+
45
+ Args:
46
+ cmd: Command to execute (string or list)
47
+ capture_output: Whether to capture stdout/stderr
48
+ text: Whether to return output as text (vs bytes)
49
+ check: Whether to raise exception on non-zero exit
50
+ timeout: Optional timeout in seconds
51
+
52
+ Returns:
53
+ CompletedProcess instance
54
+
55
+ Raises:
56
+ subprocess.CalledProcessError: If command fails and check=True
57
+ subprocess.TimeoutExpired: If timeout is reached
58
+ """
59
+ try:
60
+ result = subprocess.run(
61
+ cmd,
62
+ cwd=self.cwd,
63
+ capture_output=capture_output,
64
+ text=text,
65
+ check=check,
66
+ timeout=timeout,
67
+ )
68
+
69
+ if self.verbose and result.stdout:
70
+ print(result.stdout, file=sys.stderr)
71
+
72
+ return result
73
+
74
+ except subprocess.CalledProcessError as e:
75
+ if self.verbose:
76
+ print(f"Command failed: {cmd}", file=sys.stderr)
77
+ if e.stderr:
78
+ print(f"Error: {e.stderr}", file=sys.stderr)
79
+ raise
80
+ except subprocess.TimeoutExpired as e:
81
+ if self.verbose:
82
+ print(f"Command timed out: {cmd}", file=sys.stderr)
83
+ raise
84
+
85
+ def run_git_command(
86
+ self,
87
+ args: Union[str, List[str]],
88
+ check: bool = True,
89
+ ) -> subprocess.CompletedProcess:
90
+ """
91
+ Run a git command with consistent error handling.
92
+
93
+ Args:
94
+ args: Git command arguments (without 'git')
95
+ check: Whether to raise exception on non-zero exit
96
+
97
+ Returns:
98
+ CompletedProcess instance
99
+
100
+ Example:
101
+ runner.run_git_command(['status', '--short'])
102
+ runner.run_git_command('log --oneline -n 5')
103
+ """
104
+ if isinstance(args, str):
105
+ cmd = f"git {args}"
106
+ else:
107
+ cmd = ["git"] + args
108
+
109
+ return self.run(cmd, check=check)
110
+
111
+ def run_gh_command(
112
+ self,
113
+ args: Union[str, List[str]],
114
+ check: bool = True,
115
+ ) -> subprocess.CompletedProcess:
116
+ """
117
+ Run a GitHub CLI command with consistent error handling.
118
+
119
+ Args:
120
+ args: gh command arguments (without 'gh')
121
+ check: Whether to raise exception on non-zero exit
122
+
123
+ Returns:
124
+ CompletedProcess instance
125
+
126
+ Example:
127
+ runner.run_gh_command(['pr', 'list'])
128
+ runner.run_gh_command('api repos/owner/repo/pulls')
129
+ """
130
+ if isinstance(args, str):
131
+ cmd = f"gh {args}"
132
+ else:
133
+ cmd = ["gh"] + args
134
+
135
+ return self.run(cmd, check=check)
136
+
137
+
138
+ # Convenience functions for simple use cases
139
+
140
+
141
+ def run_git_command(
142
+ args: Union[str, List[str]],
143
+ cwd: Optional[Union[str, Path]] = None,
144
+ check: bool = True,
145
+ verbose: bool = False,
146
+ ) -> subprocess.CompletedProcess:
147
+ """
148
+ Run a git command (convenience function).
149
+
150
+ Args:
151
+ args: Git command arguments (without 'git')
152
+ cwd: Working directory
153
+ check: Whether to raise exception on non-zero exit
154
+ verbose: Whether to print output
155
+
156
+ Returns:
157
+ CompletedProcess instance
158
+ """
159
+ runner = SubprocessRunner(cwd=cwd, verbose=verbose)
160
+ return runner.run_git_command(args, check=check)
161
+
162
+
163
+ def run_gh_command(
164
+ args: Union[str, List[str]],
165
+ cwd: Optional[Union[str, Path]] = None,
166
+ check: bool = True,
167
+ verbose: bool = False,
168
+ ) -> subprocess.CompletedProcess:
169
+ """
170
+ Run a GitHub CLI command (convenience function).
171
+
172
+ Args:
173
+ args: gh command arguments (without 'gh')
174
+ cwd: Working directory
175
+ check: Whether to raise exception on non-zero exit
176
+ verbose: Whether to print output
177
+
178
+ Returns:
179
+ CompletedProcess instance
180
+ """
181
+ runner = SubprocessRunner(cwd=cwd, verbose=verbose)
182
+ return runner.run_gh_command(args, check=check)