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
cicada/watcher.py ADDED
@@ -0,0 +1,431 @@
1
+ """
2
+ File system watcher for automatic Elixir code reindexing.
3
+
4
+ This module provides the FileWatcher class which monitors Elixir source files
5
+ for changes and automatically triggers incremental reindexing.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import signal
11
+ import sys
12
+ import threading
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
17
+ from watchdog.observers import Observer
18
+
19
+ from cicada.indexer import ElixirIndexer
20
+ from cicada.utils.storage import create_storage_dir
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class ElixirFileEventHandler(FileSystemEventHandler):
26
+ """
27
+ Event handler for file system changes.
28
+
29
+ Filters events to only process Elixir source files (.ex, .exs)
30
+ and ignores changes in excluded directories.
31
+ """
32
+
33
+ def __init__(self, watcher: "FileWatcher"):
34
+ """
35
+ Initialize the event handler.
36
+
37
+ Args:
38
+ watcher: The FileWatcher instance to notify of changes
39
+ """
40
+ super().__init__()
41
+ self.watcher = watcher
42
+ # Reuse excluded_dirs from ElixirIndexer to avoid duplication
43
+ self.excluded_dirs = ElixirIndexer(verbose=False).excluded_dirs
44
+
45
+ def _is_elixir_file(self, path: str) -> bool:
46
+ """Check if the path is an Elixir source file."""
47
+ return path.endswith((".ex", ".exs"))
48
+
49
+ def _is_excluded_path(self, path: str) -> bool:
50
+ """Check if the path is in an excluded directory."""
51
+ path_parts = Path(path).parts
52
+ return any(excluded in path_parts for excluded in self.excluded_dirs)
53
+
54
+ def on_any_event(self, event: FileSystemEvent) -> None:
55
+ """
56
+ Handle file system events.
57
+
58
+ Filters events to only process Elixir files not in excluded directories,
59
+ then notifies the watcher to trigger reindexing.
60
+
61
+ Args:
62
+ event: The file system event
63
+ """
64
+ # Ignore directory events
65
+ if event.is_directory:
66
+ return
67
+
68
+ # Convert src_path to string (it can be bytes or str)
69
+ src_path = str(event.src_path)
70
+
71
+ # Only process Elixir source files
72
+ if not self._is_elixir_file(src_path):
73
+ return
74
+
75
+ # Skip excluded directories
76
+ if self._is_excluded_path(src_path):
77
+ return
78
+
79
+ # Notify watcher of the change
80
+ self.watcher._on_file_change(event)
81
+
82
+
83
+ class FileWatcher:
84
+ """
85
+ Watches Elixir source files and triggers automatic reindexing on changes.
86
+
87
+ The watcher monitors .ex and .exs files in a repository, excluding
88
+ standard directories like deps, _build, and node_modules. When changes
89
+ are detected, it debounces the events and triggers incremental reindexing.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ repo_path: str,
95
+ debounce_seconds: float = 2.0,
96
+ verbose: bool = True,
97
+ tier: str = "regular",
98
+ register_signal_handlers: bool = True,
99
+ ):
100
+ """
101
+ Initialize the file watcher.
102
+
103
+ Args:
104
+ repo_path: Path to the repository to watch
105
+ debounce_seconds: Seconds to wait before triggering reindex after changes
106
+ verbose: Whether to show detailed indexing progress
107
+ tier: Indexing tier (fast, regular, or max)
108
+ register_signal_handlers: Whether to register SIGINT/SIGTERM handlers (disable for testing)
109
+ """
110
+ self.repo_path = Path(repo_path).resolve()
111
+ self.debounce_seconds = debounce_seconds
112
+ self.verbose = verbose
113
+ self.tier = tier
114
+
115
+ self.observer: Observer | None = None # type: ignore[valid-type]
116
+ self.indexer: ElixirIndexer | None = None
117
+ self.debounce_timer: threading.Timer | None = None
118
+ self.timer_lock = threading.Lock()
119
+ self.running = False
120
+ self.shutdown_event = threading.Event()
121
+ self._consecutive_failures = 0 # Track consecutive reindex failures
122
+
123
+ # Set up signal handlers for graceful shutdown (unless disabled for testing)
124
+ if register_signal_handlers:
125
+ signal.signal(signal.SIGINT, self._signal_handler)
126
+ signal.signal(signal.SIGTERM, self._signal_handler)
127
+
128
+ def _signal_handler(self, signum: int, _frame) -> None:
129
+ """Handle shutdown signals (SIGINT, SIGTERM)."""
130
+ signal_name = signal.Signals(signum).name
131
+ logger.info(f"Received {signal_name} signal")
132
+ print(f"\n\nReceived {signal_name} signal. Stopping watcher...")
133
+
134
+ self.shutdown_event.set()
135
+
136
+ try:
137
+ self.stop_watching()
138
+ except Exception as e:
139
+ logger.exception(f"Error during signal handler cleanup: {e}")
140
+ print(f"Warning: Error during cleanup: {e}", file=sys.stderr)
141
+ # Still exit even if cleanup fails
142
+ finally:
143
+ sys.exit(0)
144
+
145
+ def start_watching(self) -> None:
146
+ """
147
+ Start watching for file changes.
148
+
149
+ This method:
150
+ 1. Creates storage directory if needed
151
+ 2. Runs an initial index to ensure up-to-date state
152
+ 3. Starts the file system observer
153
+ 4. Blocks until interrupted (Ctrl-C)
154
+ """
155
+ if self.running:
156
+ logger.warning("Watcher is already running")
157
+ return
158
+
159
+ print(f"Initializing watch mode for {self.repo_path}")
160
+ print(f"Debounce interval: {self.debounce_seconds}s")
161
+ print()
162
+
163
+ # Ensure storage directory exists
164
+ create_storage_dir(self.repo_path)
165
+
166
+ # Get index path
167
+ from cicada.utils.storage import get_index_path
168
+
169
+ index_path = get_index_path(self.repo_path)
170
+
171
+ # Create indexer instance
172
+ self.indexer = ElixirIndexer(verbose=self.verbose)
173
+
174
+ # Run initial index
175
+ print("Running initial index...")
176
+ try:
177
+ self.indexer.incremental_index_repository(
178
+ repo_path=str(self.repo_path),
179
+ output_path=str(index_path),
180
+ extract_keywords=True,
181
+ force_full=False,
182
+ )
183
+ print("\nInitial indexing complete!")
184
+ print()
185
+ except KeyboardInterrupt:
186
+ print("\n\nInitial indexing interrupted. Exiting...")
187
+ return
188
+ except (MemoryError, OSError) as e:
189
+ # System-level failures - don't continue regardless of existing index
190
+ print("\n" + "=" * 70)
191
+ print("CRITICAL: System error during initial indexing!")
192
+ print("=" * 70)
193
+ print(f"Error: {e}")
194
+ print("\nWatch mode cannot start due to system-level failure.")
195
+ if isinstance(e, MemoryError):
196
+ print("Your system is out of memory. Close other applications and try again.")
197
+ elif isinstance(e, OSError):
198
+ print(f"File system error: {e}")
199
+ print("Check disk space, permissions, and filesystem health.")
200
+ print("=" * 70)
201
+ logger.critical(f"System error during initial indexing: {e}", exc_info=True)
202
+ sys.exit(1)
203
+ except Exception as e:
204
+ # Other failures - check if we can use existing index
205
+ logger.exception("Initial indexing failed")
206
+ self._handle_initial_index_failure(e, index_path)
207
+
208
+ # Set up file system observer
209
+ event_handler = ElixirFileEventHandler(self)
210
+ self.observer = Observer()
211
+ self.observer.schedule(event_handler, str(self.repo_path), recursive=True)
212
+
213
+ # Start observing
214
+ self.observer.start()
215
+ self.running = True
216
+
217
+ print("=" * 70)
218
+ print("Watching for changes to Elixir files (.ex, .exs)")
219
+ print("=" * 70)
220
+ print("Press Ctrl-C to stop")
221
+ print()
222
+
223
+ # Block until interrupted
224
+ try:
225
+ while not self.shutdown_event.is_set():
226
+ time.sleep(1)
227
+ except KeyboardInterrupt:
228
+ print("\n\nStopping watcher...")
229
+ finally:
230
+ self.stop_watching()
231
+
232
+ def stop_watching(self) -> None:
233
+ """Stop watching for file changes and clean up resources."""
234
+ if not self.running:
235
+ return
236
+
237
+ self.running = False
238
+
239
+ # Cancel pending debounce timer
240
+ with self.timer_lock:
241
+ if self.debounce_timer is not None:
242
+ try:
243
+ self.debounce_timer.cancel()
244
+ # Note: cancel() only prevents future execution,
245
+ # callback might still be running
246
+ logger.debug("Cancelled pending debounce timer")
247
+ except Exception as e:
248
+ logger.warning(f"Error cancelling timer: {e}")
249
+ finally:
250
+ self.debounce_timer = None
251
+
252
+ # Stop observer
253
+ if self.observer is not None:
254
+ self.observer.stop()
255
+ self.observer.join(timeout=5)
256
+
257
+ if self.observer.is_alive():
258
+ logger.warning("Observer thread did not stop within timeout")
259
+ print("Warning: File watcher thread did not stop cleanly", file=sys.stderr)
260
+ # Still clear reference to allow GC, but log the issue
261
+
262
+ self.observer = None
263
+
264
+ print("Watcher stopped.")
265
+
266
+ def _handle_initial_index_failure(self, error: Exception, index_path: Path) -> None:
267
+ """Handle failure during initial indexing.
268
+
269
+ Args:
270
+ error: The exception that occurred
271
+ index_path: Path to the index file
272
+
273
+ Raises:
274
+ SystemExit: If watch mode cannot continue
275
+ """
276
+ if not index_path.exists():
277
+ # No existing index - must exit
278
+ print("\n" + "=" * 70)
279
+ print("ERROR: Initial indexing failed and no existing index found!")
280
+ print("=" * 70)
281
+ print(f"Error: {error}")
282
+ print("\nWatch mode cannot start without an index.")
283
+ print("Please fix the error and try again, or run:")
284
+ print(f" cicada index {self.repo_path}")
285
+ print("=" * 70)
286
+ sys.exit(1)
287
+
288
+ # Try to load existing index to verify it's usable
289
+ try:
290
+ with open(index_path) as f:
291
+ index = json.load(f)
292
+ if not index or not index.get("modules"):
293
+ raise ValueError("Existing index is empty or corrupted")
294
+
295
+ # Existing index appears valid
296
+ print("\n" + "=" * 70)
297
+ print("WARNING: Initial indexing failed!")
298
+ print("=" * 70)
299
+ print(f"Error: {error}")
300
+ print("\nAn existing index was found and verified as usable.")
301
+ print("Watch mode will continue, but the index may be outdated.")
302
+ print("\nTo fix this issue, run:")
303
+ print(f" cicada clean && cicada index {self.repo_path}")
304
+ print("=" * 70)
305
+ print()
306
+ except Exception as load_error:
307
+ # Existing index is corrupted or unusable
308
+ print("\n" + "=" * 70)
309
+ print("ERROR: Initial indexing failed and existing index is corrupted!")
310
+ print("=" * 70)
311
+ print(f"Indexing error: {error}")
312
+ print(f"Index validation error: {load_error}")
313
+ print("\nCannot start watch mode. Please fix the issue:")
314
+ print(f" cicada clean && cicada index {self.repo_path}")
315
+ print("=" * 70)
316
+ logger.error(f"Existing index corrupted: {load_error}")
317
+ sys.exit(1)
318
+
319
+ def _on_file_change(self, event: FileSystemEvent) -> None:
320
+ """
321
+ Handle file change events with debouncing.
322
+
323
+ When a file changes, this method cancels any pending reindex timer
324
+ and starts a new one. This ensures that rapid successive changes
325
+ only trigger a single reindex operation.
326
+
327
+ Args:
328
+ event: The file system event
329
+ """
330
+ with self.timer_lock:
331
+ self._cancel_pending_timer()
332
+ self._start_new_timer()
333
+
334
+ def _cancel_pending_timer(self) -> None:
335
+ """Cancel any pending debounce timer."""
336
+ if self.debounce_timer is None:
337
+ return
338
+
339
+ try:
340
+ self.debounce_timer.cancel()
341
+ logger.debug("Cancelled previous debounce timer due to new file change")
342
+ except Exception as e:
343
+ logger.warning(f"Error cancelling previous timer: {e}")
344
+
345
+ def _start_new_timer(self) -> None:
346
+ """Start a new debounce timer."""
347
+ self.debounce_timer = threading.Timer(
348
+ self.debounce_seconds,
349
+ self._trigger_reindex,
350
+ )
351
+ self.debounce_timer.daemon = True
352
+ self.debounce_timer.start()
353
+
354
+ def _trigger_reindex(self) -> None:
355
+ """
356
+ Trigger incremental reindexing.
357
+
358
+ This method is called after the debounce period has elapsed.
359
+ It runs the incremental indexer and handles any errors gracefully.
360
+ """
361
+ with self.timer_lock:
362
+ self.debounce_timer = None
363
+
364
+ print("\n" + "=" * 70)
365
+ print("File changes detected - reindexing...")
366
+ print("=" * 70)
367
+ print()
368
+
369
+ try:
370
+ if self.indexer is not None:
371
+ from cicada.utils.storage import get_index_path
372
+
373
+ index_path = get_index_path(self.repo_path)
374
+ self.indexer.incremental_index_repository(
375
+ repo_path=str(self.repo_path),
376
+ output_path=str(index_path),
377
+ extract_keywords=True,
378
+ force_full=False,
379
+ )
380
+ print()
381
+ print("=" * 70)
382
+ print("Reindexing complete!")
383
+ print("=" * 70)
384
+ print()
385
+
386
+ # Reset failure counter on success
387
+ self._consecutive_failures = 0
388
+
389
+ except KeyboardInterrupt:
390
+ # Don't catch interrupts - let them propagate
391
+ print("\n\nReindexing interrupted.")
392
+ raise
393
+
394
+ except (MemoryError, OSError) as e:
395
+ # System-level errors - warn but continue (might be transient)
396
+ print()
397
+ print("=" * 70)
398
+ print(f"SYSTEM ERROR during reindexing: {e}")
399
+ print("=" * 70)
400
+ if isinstance(e, MemoryError):
401
+ print("Your system is out of memory.")
402
+ elif isinstance(e, OSError):
403
+ print(f"File system error: {e}")
404
+ print("\nWatcher will continue, but next reindex may also fail.")
405
+ print("If this persists, stop the watcher and check system resources.")
406
+ print("=" * 70)
407
+ print()
408
+ logger.error(f"System error during reindex: {e}", exc_info=True)
409
+
410
+ except Exception as e:
411
+ # Unexpected errors - track consecutive failures
412
+ print()
413
+ print("=" * 70)
414
+ print(f"ERROR during reindexing: {e}")
415
+ print("=" * 70)
416
+ print("Continuing to watch for changes...")
417
+ print()
418
+ logger.exception("Reindexing failed")
419
+
420
+ # Track consecutive failures
421
+ self._consecutive_failures += 1
422
+
423
+ if self._consecutive_failures >= 3:
424
+ print("=" * 70)
425
+ print("WARNING: Reindexing has failed 3 consecutive times!")
426
+ print("=" * 70)
427
+ print("The watcher may be broken. Consider stopping it and investigating.")
428
+ print("Check logs for details. You may need to run:")
429
+ print(f" cicada clean && cicada index {self.repo_path}")
430
+ print("=" * 70)
431
+ logger.error("Multiple consecutive reindex failures detected")