codexa 0.4.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 (189) hide show
  1. codexa-0.4.0.dist-info/METADATA +650 -0
  2. codexa-0.4.0.dist-info/RECORD +189 -0
  3. codexa-0.4.0.dist-info/WHEEL +5 -0
  4. codexa-0.4.0.dist-info/entry_points.txt +2 -0
  5. codexa-0.4.0.dist-info/licenses/LICENSE +21 -0
  6. codexa-0.4.0.dist-info/top_level.txt +1 -0
  7. semantic_code_intelligence/__init__.py +5 -0
  8. semantic_code_intelligence/analysis/__init__.py +21 -0
  9. semantic_code_intelligence/analysis/ai_features.py +351 -0
  10. semantic_code_intelligence/bridge/__init__.py +28 -0
  11. semantic_code_intelligence/bridge/context_provider.py +245 -0
  12. semantic_code_intelligence/bridge/protocol.py +167 -0
  13. semantic_code_intelligence/bridge/server.py +348 -0
  14. semantic_code_intelligence/bridge/vscode.py +271 -0
  15. semantic_code_intelligence/ci/__init__.py +13 -0
  16. semantic_code_intelligence/ci/hooks.py +98 -0
  17. semantic_code_intelligence/ci/hotspots.py +272 -0
  18. semantic_code_intelligence/ci/impact.py +246 -0
  19. semantic_code_intelligence/ci/metrics.py +591 -0
  20. semantic_code_intelligence/ci/pr.py +412 -0
  21. semantic_code_intelligence/ci/quality.py +557 -0
  22. semantic_code_intelligence/ci/templates.py +164 -0
  23. semantic_code_intelligence/ci/trace.py +224 -0
  24. semantic_code_intelligence/cli/__init__.py +0 -0
  25. semantic_code_intelligence/cli/commands/__init__.py +0 -0
  26. semantic_code_intelligence/cli/commands/ask_cmd.py +153 -0
  27. semantic_code_intelligence/cli/commands/benchmark_cmd.py +303 -0
  28. semantic_code_intelligence/cli/commands/chat_cmd.py +252 -0
  29. semantic_code_intelligence/cli/commands/ci_gen_cmd.py +74 -0
  30. semantic_code_intelligence/cli/commands/context_cmd.py +120 -0
  31. semantic_code_intelligence/cli/commands/cross_refactor_cmd.py +113 -0
  32. semantic_code_intelligence/cli/commands/deps_cmd.py +91 -0
  33. semantic_code_intelligence/cli/commands/docs_cmd.py +101 -0
  34. semantic_code_intelligence/cli/commands/doctor_cmd.py +147 -0
  35. semantic_code_intelligence/cli/commands/evolve_cmd.py +171 -0
  36. semantic_code_intelligence/cli/commands/explain_cmd.py +112 -0
  37. semantic_code_intelligence/cli/commands/gate_cmd.py +135 -0
  38. semantic_code_intelligence/cli/commands/grep_cmd.py +234 -0
  39. semantic_code_intelligence/cli/commands/hotspots_cmd.py +119 -0
  40. semantic_code_intelligence/cli/commands/impact_cmd.py +131 -0
  41. semantic_code_intelligence/cli/commands/index_cmd.py +138 -0
  42. semantic_code_intelligence/cli/commands/init_cmd.py +152 -0
  43. semantic_code_intelligence/cli/commands/investigate_cmd.py +163 -0
  44. semantic_code_intelligence/cli/commands/languages_cmd.py +101 -0
  45. semantic_code_intelligence/cli/commands/lsp_cmd.py +49 -0
  46. semantic_code_intelligence/cli/commands/mcp_cmd.py +50 -0
  47. semantic_code_intelligence/cli/commands/metrics_cmd.py +264 -0
  48. semantic_code_intelligence/cli/commands/models_cmd.py +157 -0
  49. semantic_code_intelligence/cli/commands/plugin_cmd.py +275 -0
  50. semantic_code_intelligence/cli/commands/pr_summary_cmd.py +178 -0
  51. semantic_code_intelligence/cli/commands/quality_cmd.py +208 -0
  52. semantic_code_intelligence/cli/commands/refactor_cmd.py +103 -0
  53. semantic_code_intelligence/cli/commands/review_cmd.py +88 -0
  54. semantic_code_intelligence/cli/commands/search_cmd.py +236 -0
  55. semantic_code_intelligence/cli/commands/serve_cmd.py +117 -0
  56. semantic_code_intelligence/cli/commands/suggest_cmd.py +100 -0
  57. semantic_code_intelligence/cli/commands/summary_cmd.py +78 -0
  58. semantic_code_intelligence/cli/commands/tool_cmd.py +282 -0
  59. semantic_code_intelligence/cli/commands/trace_cmd.py +123 -0
  60. semantic_code_intelligence/cli/commands/tui_cmd.py +58 -0
  61. semantic_code_intelligence/cli/commands/viz_cmd.py +127 -0
  62. semantic_code_intelligence/cli/commands/watch_cmd.py +72 -0
  63. semantic_code_intelligence/cli/commands/web_cmd.py +61 -0
  64. semantic_code_intelligence/cli/commands/workspace_cmd.py +250 -0
  65. semantic_code_intelligence/cli/main.py +65 -0
  66. semantic_code_intelligence/cli/router.py +92 -0
  67. semantic_code_intelligence/config/__init__.py +0 -0
  68. semantic_code_intelligence/config/settings.py +260 -0
  69. semantic_code_intelligence/context/__init__.py +19 -0
  70. semantic_code_intelligence/context/engine.py +429 -0
  71. semantic_code_intelligence/context/memory.py +253 -0
  72. semantic_code_intelligence/daemon/__init__.py +1 -0
  73. semantic_code_intelligence/daemon/watcher.py +515 -0
  74. semantic_code_intelligence/docs/__init__.py +1080 -0
  75. semantic_code_intelligence/embeddings/__init__.py +0 -0
  76. semantic_code_intelligence/embeddings/enhanced.py +131 -0
  77. semantic_code_intelligence/embeddings/generator.py +149 -0
  78. semantic_code_intelligence/embeddings/model_registry.py +100 -0
  79. semantic_code_intelligence/evolution/__init__.py +1 -0
  80. semantic_code_intelligence/evolution/budget_guard.py +111 -0
  81. semantic_code_intelligence/evolution/commit_manager.py +88 -0
  82. semantic_code_intelligence/evolution/context_builder.py +131 -0
  83. semantic_code_intelligence/evolution/engine.py +249 -0
  84. semantic_code_intelligence/evolution/patch_generator.py +229 -0
  85. semantic_code_intelligence/evolution/task_selector.py +214 -0
  86. semantic_code_intelligence/evolution/test_runner.py +111 -0
  87. semantic_code_intelligence/indexing/__init__.py +0 -0
  88. semantic_code_intelligence/indexing/chunker.py +174 -0
  89. semantic_code_intelligence/indexing/parallel.py +86 -0
  90. semantic_code_intelligence/indexing/scanner.py +146 -0
  91. semantic_code_intelligence/indexing/semantic_chunker.py +337 -0
  92. semantic_code_intelligence/llm/__init__.py +62 -0
  93. semantic_code_intelligence/llm/cache.py +219 -0
  94. semantic_code_intelligence/llm/cached_provider.py +145 -0
  95. semantic_code_intelligence/llm/conversation.py +190 -0
  96. semantic_code_intelligence/llm/cross_refactor.py +272 -0
  97. semantic_code_intelligence/llm/investigation.py +274 -0
  98. semantic_code_intelligence/llm/mock_provider.py +77 -0
  99. semantic_code_intelligence/llm/ollama_provider.py +122 -0
  100. semantic_code_intelligence/llm/openai_provider.py +100 -0
  101. semantic_code_intelligence/llm/provider.py +92 -0
  102. semantic_code_intelligence/llm/rate_limiter.py +164 -0
  103. semantic_code_intelligence/llm/reasoning.py +438 -0
  104. semantic_code_intelligence/llm/safety.py +110 -0
  105. semantic_code_intelligence/llm/streaming.py +251 -0
  106. semantic_code_intelligence/lsp/__init__.py +609 -0
  107. semantic_code_intelligence/mcp/__init__.py +393 -0
  108. semantic_code_intelligence/parsing/__init__.py +19 -0
  109. semantic_code_intelligence/parsing/parser.py +375 -0
  110. semantic_code_intelligence/plugins/__init__.py +255 -0
  111. semantic_code_intelligence/plugins/examples/__init__.py +1 -0
  112. semantic_code_intelligence/plugins/examples/code_quality.py +73 -0
  113. semantic_code_intelligence/plugins/examples/search_annotator.py +56 -0
  114. semantic_code_intelligence/scalability/__init__.py +205 -0
  115. semantic_code_intelligence/search/__init__.py +0 -0
  116. semantic_code_intelligence/search/formatter.py +123 -0
  117. semantic_code_intelligence/search/grep.py +361 -0
  118. semantic_code_intelligence/search/hybrid_search.py +170 -0
  119. semantic_code_intelligence/search/keyword_search.py +311 -0
  120. semantic_code_intelligence/search/section_expander.py +103 -0
  121. semantic_code_intelligence/services/__init__.py +0 -0
  122. semantic_code_intelligence/services/indexing_service.py +630 -0
  123. semantic_code_intelligence/services/search_service.py +269 -0
  124. semantic_code_intelligence/storage/__init__.py +0 -0
  125. semantic_code_intelligence/storage/chunk_hash_store.py +86 -0
  126. semantic_code_intelligence/storage/hash_store.py +66 -0
  127. semantic_code_intelligence/storage/index_manifest.py +85 -0
  128. semantic_code_intelligence/storage/index_stats.py +138 -0
  129. semantic_code_intelligence/storage/query_history.py +160 -0
  130. semantic_code_intelligence/storage/symbol_registry.py +209 -0
  131. semantic_code_intelligence/storage/vector_store.py +297 -0
  132. semantic_code_intelligence/tests/__init__.py +0 -0
  133. semantic_code_intelligence/tests/test_ai_features.py +351 -0
  134. semantic_code_intelligence/tests/test_chunker.py +119 -0
  135. semantic_code_intelligence/tests/test_cli.py +188 -0
  136. semantic_code_intelligence/tests/test_config.py +154 -0
  137. semantic_code_intelligence/tests/test_context.py +381 -0
  138. semantic_code_intelligence/tests/test_embeddings.py +73 -0
  139. semantic_code_intelligence/tests/test_endtoend.py +1142 -0
  140. semantic_code_intelligence/tests/test_enhanced_embeddings.py +92 -0
  141. semantic_code_intelligence/tests/test_hash_store.py +79 -0
  142. semantic_code_intelligence/tests/test_logging.py +55 -0
  143. semantic_code_intelligence/tests/test_new_cli.py +138 -0
  144. semantic_code_intelligence/tests/test_parser.py +495 -0
  145. semantic_code_intelligence/tests/test_phase10.py +355 -0
  146. semantic_code_intelligence/tests/test_phase11.py +593 -0
  147. semantic_code_intelligence/tests/test_phase12.py +375 -0
  148. semantic_code_intelligence/tests/test_phase13.py +663 -0
  149. semantic_code_intelligence/tests/test_phase14.py +568 -0
  150. semantic_code_intelligence/tests/test_phase15.py +814 -0
  151. semantic_code_intelligence/tests/test_phase16.py +792 -0
  152. semantic_code_intelligence/tests/test_phase17.py +815 -0
  153. semantic_code_intelligence/tests/test_phase18.py +934 -0
  154. semantic_code_intelligence/tests/test_phase19.py +986 -0
  155. semantic_code_intelligence/tests/test_phase20.py +2753 -0
  156. semantic_code_intelligence/tests/test_phase20b.py +2058 -0
  157. semantic_code_intelligence/tests/test_phase20c.py +962 -0
  158. semantic_code_intelligence/tests/test_phase21.py +428 -0
  159. semantic_code_intelligence/tests/test_phase22.py +799 -0
  160. semantic_code_intelligence/tests/test_phase23.py +783 -0
  161. semantic_code_intelligence/tests/test_phase24.py +715 -0
  162. semantic_code_intelligence/tests/test_phase25.py +496 -0
  163. semantic_code_intelligence/tests/test_phase26.py +251 -0
  164. semantic_code_intelligence/tests/test_phase27.py +531 -0
  165. semantic_code_intelligence/tests/test_phase8.py +592 -0
  166. semantic_code_intelligence/tests/test_phase9.py +643 -0
  167. semantic_code_intelligence/tests/test_plugins.py +293 -0
  168. semantic_code_intelligence/tests/test_priority_features.py +727 -0
  169. semantic_code_intelligence/tests/test_router.py +41 -0
  170. semantic_code_intelligence/tests/test_scalability.py +138 -0
  171. semantic_code_intelligence/tests/test_scanner.py +125 -0
  172. semantic_code_intelligence/tests/test_search.py +160 -0
  173. semantic_code_intelligence/tests/test_semantic_chunker.py +255 -0
  174. semantic_code_intelligence/tests/test_tools.py +182 -0
  175. semantic_code_intelligence/tests/test_vector_store.py +151 -0
  176. semantic_code_intelligence/tests/test_watcher.py +211 -0
  177. semantic_code_intelligence/tools/__init__.py +442 -0
  178. semantic_code_intelligence/tools/executor.py +232 -0
  179. semantic_code_intelligence/tools/protocol.py +200 -0
  180. semantic_code_intelligence/tui/__init__.py +454 -0
  181. semantic_code_intelligence/utils/__init__.py +0 -0
  182. semantic_code_intelligence/utils/logging.py +112 -0
  183. semantic_code_intelligence/version.py +3 -0
  184. semantic_code_intelligence/web/__init__.py +11 -0
  185. semantic_code_intelligence/web/api.py +289 -0
  186. semantic_code_intelligence/web/server.py +397 -0
  187. semantic_code_intelligence/web/ui.py +659 -0
  188. semantic_code_intelligence/web/visualize.py +226 -0
  189. semantic_code_intelligence/workspace/__init__.py +427 -0
@@ -0,0 +1,515 @@
1
+ """Background intelligence subsystem — file watching and async indexing.
2
+
3
+ Provides:
4
+ - NativeFileWatcher: uses ``watchfiles`` (Rust-backed) for instant change detection
5
+ - FileWatcher: legacy polling fallback for environments without watchfiles
6
+ - IndexingDaemon: runs incremental indexing in background
7
+ - AsyncIndexer: queue-based async indexing pipeline
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import threading
14
+ import time
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any, Callable
18
+
19
+ try:
20
+ from watchfiles import watch, Change
21
+ _HAS_WATCHFILES = True
22
+ except ImportError:
23
+ _HAS_WATCHFILES = False
24
+
25
+ from semantic_code_intelligence.config.settings import AppConfig, load_config
26
+ from semantic_code_intelligence.indexing.scanner import compute_file_hash, scan_repository
27
+ from semantic_code_intelligence.storage.hash_store import HashStore
28
+ from semantic_code_intelligence.utils.logging import get_logger
29
+
30
+ logger = get_logger("daemon")
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # File Change Events
35
+ # ---------------------------------------------------------------------------
36
+
37
+ @dataclass
38
+ class FileChangeEvent:
39
+ """Represents a detected file change."""
40
+
41
+ path: Path
42
+ relative_path: str
43
+ change_type: str # "created", "modified", "deleted"
44
+ timestamp: float = 0.0
45
+
46
+ def to_dict(self) -> dict[str, Any]:
47
+ """Serialize the file change event to a plain dictionary."""
48
+ return {
49
+ "path": str(self.path),
50
+ "relative_path": self.relative_path,
51
+ "change_type": self.change_type,
52
+ "timestamp": self.timestamp,
53
+ }
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Native File Watcher (watchfiles / Rust-backed — instant OS notifications)
58
+ # ---------------------------------------------------------------------------
59
+
60
+ class NativeFileWatcher:
61
+ """Rust-backed file watcher using ``watchfiles`` for instant change detection.
62
+
63
+ Uses OS-native APIs (inotify/FSEvents/ReadDirectoryChanges) instead of
64
+ polling. Falls back to polling automatically if watchfiles is unavailable.
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ project_root: Path,
70
+ debounce: float = 0.5,
71
+ ) -> None:
72
+ self._root = project_root.resolve()
73
+ self._debounce = int(debounce * 1000) # watchfiles uses ms
74
+ self._config = load_config(self._root)
75
+ self._running = False
76
+ self._thread: threading.Thread | None = None
77
+ self._callbacks: list[Callable[[list[FileChangeEvent]], None]] = []
78
+ # Build set of supported extensions for filtering
79
+ self._extensions = set(self._config.index.extensions)
80
+
81
+ @property
82
+ def is_running(self) -> bool:
83
+ return self._running
84
+
85
+ def on_change(self, callback: Callable[[list[FileChangeEvent]], None]) -> None:
86
+ self._callbacks.append(callback)
87
+
88
+ def _should_watch(self, path: Path) -> bool:
89
+ """Filter out files that don't match indexed extensions."""
90
+ return path.suffix in self._extensions
91
+
92
+ def _watch_loop(self) -> None:
93
+ """Main watching loop using watchfiles."""
94
+ logger.info("Native file watcher started for %s (Rust-backed)", self._root)
95
+ try:
96
+ for changes in watch(
97
+ self._root,
98
+ debounce=self._debounce,
99
+ step=100,
100
+ stop_event=threading.Event() if not self._running else None,
101
+ recursive=True,
102
+ rust_timeout=5000,
103
+ ):
104
+ if not self._running:
105
+ break
106
+
107
+ events: list[FileChangeEvent] = []
108
+ now = time.time()
109
+
110
+ for change_type, path_str in changes:
111
+ path = Path(path_str)
112
+
113
+ # Skip non-indexed files
114
+ if not self._should_watch(path):
115
+ continue
116
+
117
+ # Skip hidden/ignored directories
118
+ try:
119
+ rel = str(path.relative_to(self._root))
120
+ except ValueError:
121
+ continue
122
+ if any(part.startswith(".") for part in Path(rel).parts[:-1]):
123
+ continue
124
+
125
+ if change_type == Change.added:
126
+ ct = "created"
127
+ elif change_type == Change.modified:
128
+ ct = "modified"
129
+ elif change_type == Change.deleted:
130
+ ct = "deleted"
131
+ else:
132
+ continue
133
+
134
+ events.append(FileChangeEvent(
135
+ path=path,
136
+ relative_path=rel,
137
+ change_type=ct,
138
+ timestamp=now,
139
+ ))
140
+
141
+ if events:
142
+ logger.info("Native watcher detected %d change(s)", len(events))
143
+ for cb in self._callbacks:
144
+ try:
145
+ cb(events)
146
+ except Exception:
147
+ logger.exception("Error in native watcher callback")
148
+ except Exception:
149
+ if self._running:
150
+ logger.exception("Error in native file watcher")
151
+
152
+ def start(self) -> None:
153
+ if self._running:
154
+ return
155
+ self._running = True
156
+ self._thread = threading.Thread(
157
+ target=self._watch_loop, daemon=True, name="codexa-native-watcher",
158
+ )
159
+ self._thread.start()
160
+
161
+ def stop(self) -> None:
162
+ self._running = False
163
+ if self._thread is not None:
164
+ self._thread.join(timeout=3.0)
165
+ self._thread = None
166
+ logger.info("Native file watcher stopped.")
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # File Watcher (polling-based, no external deps)
171
+ # ---------------------------------------------------------------------------
172
+
173
+ class FileWatcher:
174
+ """Polls the filesystem for changes using hash-based detection.
175
+
176
+ Uses the existing HashStore infrastructure rather than OS-specific
177
+ watchers, making it portable and consistent with the indexing pipeline.
178
+ """
179
+
180
+ def __init__(
181
+ self,
182
+ project_root: Path,
183
+ poll_interval: float = 2.0,
184
+ debounce: float = 0.5,
185
+ ) -> None:
186
+ self._root = project_root.resolve()
187
+ self._poll_interval = poll_interval
188
+ self._debounce = debounce
189
+ self._config = load_config(self._root)
190
+ self._hash_store = HashStore()
191
+ self._running = False
192
+ self._thread: threading.Thread | None = None
193
+ self._callbacks: list[Callable[[list[FileChangeEvent]], None]] = []
194
+ self._lock = threading.Lock()
195
+ self._last_scan_hashes: dict[str, str] = {}
196
+
197
+ @property
198
+ def is_running(self) -> bool:
199
+ """Whether the file watcher is currently polling."""
200
+ return self._running
201
+
202
+ def on_change(self, callback: Callable[[list[FileChangeEvent]], None]) -> None:
203
+ """Register a callback for file change events.
204
+
205
+ The callback receives a list of FileChangeEvent objects.
206
+ """
207
+ self._callbacks.append(callback)
208
+
209
+ def _initial_scan(self) -> None:
210
+ """Perform initial scan to populate baseline hashes."""
211
+ scanned = scan_repository(self._root, self._config.index)
212
+ with self._lock:
213
+ for sf in scanned:
214
+ self._last_scan_hashes[sf.relative_path] = sf.content_hash
215
+
216
+ def _detect_changes(self) -> list[FileChangeEvent]:
217
+ """Scan repository and detect changes since last poll."""
218
+ scanned = scan_repository(self._root, self._config.index)
219
+ current_hashes: dict[str, str] = {}
220
+ events: list[FileChangeEvent] = []
221
+ now = time.time()
222
+
223
+ for sf in scanned:
224
+ current_hashes[sf.relative_path] = sf.content_hash
225
+
226
+ with self._lock:
227
+ # Check for new or modified files
228
+ for rel_path, new_hash in current_hashes.items():
229
+ old_hash = self._last_scan_hashes.get(rel_path)
230
+ if old_hash is None:
231
+ events.append(FileChangeEvent(
232
+ path=self._root / rel_path,
233
+ relative_path=rel_path,
234
+ change_type="created",
235
+ timestamp=now,
236
+ ))
237
+ elif old_hash != new_hash:
238
+ events.append(FileChangeEvent(
239
+ path=self._root / rel_path,
240
+ relative_path=rel_path,
241
+ change_type="modified",
242
+ timestamp=now,
243
+ ))
244
+
245
+ # Check for deleted files
246
+ for rel_path in self._last_scan_hashes:
247
+ if rel_path not in current_hashes:
248
+ events.append(FileChangeEvent(
249
+ path=self._root / rel_path,
250
+ relative_path=rel_path,
251
+ change_type="deleted",
252
+ timestamp=now,
253
+ ))
254
+
255
+ self._last_scan_hashes = current_hashes
256
+
257
+ return events
258
+
259
+ def _poll_loop(self) -> None:
260
+ """Main polling loop (runs in background thread)."""
261
+ self._initial_scan()
262
+ logger.info("File watcher started for %s (poll=%.1fs)", self._root, self._poll_interval)
263
+
264
+ while self._running:
265
+ time.sleep(self._poll_interval)
266
+ if not self._running:
267
+ break
268
+
269
+ try:
270
+ events = self._detect_changes()
271
+ if events:
272
+ # Debounce: wait a bit for rapid successive changes
273
+ time.sleep(self._debounce)
274
+ if not self._running:
275
+ break
276
+ # Re-check to merge rapid changes
277
+ more_events = self._detect_changes()
278
+ events.extend(more_events)
279
+
280
+ logger.info("Detected %d file change(s)", len(events))
281
+ for cb in self._callbacks:
282
+ try:
283
+ cb(events)
284
+ except Exception:
285
+ logger.exception("Error in file watcher callback")
286
+ except Exception:
287
+ logger.exception("Error in file watcher poll loop")
288
+
289
+ def start(self) -> None:
290
+ """Start watching in a background thread."""
291
+ if self._running:
292
+ return
293
+ self._running = True
294
+ self._thread = threading.Thread(target=self._poll_loop, daemon=True, name="codexa-watcher")
295
+ self._thread.start()
296
+
297
+ def stop(self) -> None:
298
+ """Stop the watcher and wait for the thread to exit."""
299
+ self._running = False
300
+ if self._thread is not None:
301
+ self._thread.join(timeout=self._poll_interval * 2)
302
+ self._thread = None
303
+ logger.info("File watcher stopped.")
304
+
305
+ def scan_once(self) -> list[FileChangeEvent]:
306
+ """Perform a single scan without starting continuous watching.
307
+
308
+ Useful for manual/CLI-driven change detection.
309
+ """
310
+ if not self._last_scan_hashes:
311
+ self._initial_scan()
312
+ return [] # First scan is the baseline
313
+ return self._detect_changes()
314
+
315
+
316
+ # ---------------------------------------------------------------------------
317
+ # Async Indexing Queue
318
+ # ---------------------------------------------------------------------------
319
+
320
+ @dataclass
321
+ class IndexingTask:
322
+ """A queued indexing task."""
323
+
324
+ file_paths: list[str]
325
+ deleted_paths: list[str] = field(default_factory=list)
326
+ force: bool = False
327
+ timestamp: float = 0.0
328
+
329
+
330
+ class AsyncIndexer:
331
+ """Queue-based asynchronous indexing processor.
332
+
333
+ Accepts indexing tasks and processes them in a background thread.
334
+ Provides callbacks for task completion and error handling.
335
+ """
336
+
337
+ def __init__(self, project_root: Path) -> None:
338
+ self._root = project_root.resolve()
339
+ self._queue: list[IndexingTask] = []
340
+ self._lock = threading.Lock()
341
+ self._running = False
342
+ self._thread: threading.Thread | None = None
343
+ self._on_complete: Callable[[int], None] | None = None
344
+ self._on_error: Callable[[Exception], None] | None = None
345
+ self._tasks_processed: int = 0
346
+
347
+ @property
348
+ def pending_count(self) -> int:
349
+ """Number of indexing tasks waiting in the queue."""
350
+ with self._lock:
351
+ return len(self._queue)
352
+
353
+ @property
354
+ def tasks_processed(self) -> int:
355
+ """Total number of indexing tasks completed so far."""
356
+ return self._tasks_processed
357
+
358
+ def set_callbacks(
359
+ self,
360
+ on_complete: Callable[[int], None] | None = None,
361
+ on_error: Callable[[Exception], None] | None = None,
362
+ ) -> None:
363
+ """Set completion and error callbacks."""
364
+ self._on_complete = on_complete
365
+ self._on_error = on_error
366
+
367
+ def enqueue(
368
+ self,
369
+ file_paths: list[str],
370
+ deleted_paths: list[str] | None = None,
371
+ force: bool = False,
372
+ ) -> None:
373
+ """Add an indexing task to the queue."""
374
+ task = IndexingTask(
375
+ file_paths=file_paths,
376
+ deleted_paths=deleted_paths or [],
377
+ force=force,
378
+ timestamp=time.time(),
379
+ )
380
+ with self._lock:
381
+ self._queue.append(task)
382
+ logger.debug("Enqueued indexing task for %d files (%d deleted)",
383
+ len(file_paths), len(task.deleted_paths))
384
+
385
+ def _process_loop(self) -> None:
386
+ """Main processing loop."""
387
+ while self._running:
388
+ task: IndexingTask | None = None
389
+ with self._lock:
390
+ if self._queue:
391
+ task = self._queue.pop(0)
392
+
393
+ if task is None:
394
+ time.sleep(0.1)
395
+ continue
396
+
397
+ try:
398
+ if task.force or not task.file_paths:
399
+ # Full re-index when forced or no specific files
400
+ from semantic_code_intelligence.services.indexing_service import run_indexing
401
+ result = run_indexing(self._root, force=task.force)
402
+ else:
403
+ # Per-file incremental indexing (Phase 27)
404
+ from semantic_code_intelligence.services.indexing_service import run_incremental_indexing
405
+ result = run_incremental_indexing(
406
+ self._root,
407
+ changed_files=task.file_paths,
408
+ deleted_files=task.deleted_paths,
409
+ )
410
+ self._tasks_processed += 1
411
+ logger.info("Async indexing complete: %s", result)
412
+ if self._on_complete:
413
+ self._on_complete(result.files_indexed)
414
+ except Exception as e:
415
+ logger.exception("Error processing indexing task")
416
+ if self._on_error:
417
+ self._on_error(e)
418
+
419
+ def start(self) -> None:
420
+ """Start the async indexing processor."""
421
+ if self._running:
422
+ return
423
+ self._running = True
424
+ self._thread = threading.Thread(
425
+ target=self._process_loop, daemon=True, name="codexa-indexer"
426
+ )
427
+ self._thread.start()
428
+ logger.info("Async indexer started.")
429
+
430
+ def stop(self) -> None:
431
+ """Stop the async indexer."""
432
+ self._running = False
433
+ if self._thread is not None:
434
+ self._thread.join(timeout=5.0)
435
+ self._thread = None
436
+ logger.info("Async indexer stopped.")
437
+
438
+
439
+ # ---------------------------------------------------------------------------
440
+ # Indexing Daemon (combines watcher + async indexer)
441
+ # ---------------------------------------------------------------------------
442
+
443
+ class IndexingDaemon:
444
+ """High-level daemon that watches for file changes and triggers indexing.
445
+
446
+ Combines FileWatcher + AsyncIndexer into a single start/stop API.
447
+ Automatically uses the Rust-backed NativeFileWatcher when ``watchfiles``
448
+ is installed, falling back to polling otherwise.
449
+ """
450
+
451
+ def __init__(
452
+ self,
453
+ project_root: Path,
454
+ poll_interval: float = 2.0,
455
+ debounce: float = 0.5,
456
+ ) -> None:
457
+ self._root = project_root.resolve()
458
+ if _HAS_WATCHFILES:
459
+ logger.info("Using Rust-backed native file watcher (watchfiles)")
460
+ self._watcher: FileWatcher | NativeFileWatcher = NativeFileWatcher(
461
+ project_root, debounce,
462
+ )
463
+ else:
464
+ logger.info("watchfiles not installed, using polling watcher (%.1fs)", poll_interval)
465
+ self._watcher = FileWatcher(project_root, poll_interval, debounce)
466
+ self._indexer = AsyncIndexer(project_root)
467
+ self._watcher.on_change(self._on_file_changes)
468
+ self._event_log: list[FileChangeEvent] = []
469
+ self._lock = threading.Lock()
470
+
471
+ @property
472
+ def is_running(self) -> bool:
473
+ """Whether the daemon (watcher + indexer) is active."""
474
+ return self._watcher.is_running
475
+
476
+ @property
477
+ def event_log(self) -> list[FileChangeEvent]:
478
+ """Return a snapshot of recent file-change events."""
479
+ with self._lock:
480
+ return list(self._event_log)
481
+
482
+ def _on_file_changes(self, events: list[FileChangeEvent]) -> None:
483
+ """Handle detected file changes."""
484
+ with self._lock:
485
+ self._event_log.extend(events)
486
+ # Keep only last 1000 events
487
+ if len(self._event_log) > 1000:
488
+ self._event_log = self._event_log[-1000:]
489
+
490
+ changed_paths = [str(e.path) for e in events if e.change_type != "deleted"]
491
+ deleted_paths = [str(e.path) for e in events if e.change_type == "deleted"]
492
+ if changed_paths or deleted_paths:
493
+ self._indexer.enqueue(changed_paths, deleted_paths=deleted_paths)
494
+
495
+ def start(self) -> None:
496
+ """Start the daemon (watcher + indexer)."""
497
+ logger.info("Starting indexing daemon for %s", self._root)
498
+ self._indexer.start()
499
+ self._watcher.start()
500
+
501
+ def stop(self) -> None:
502
+ """Stop the daemon."""
503
+ self._watcher.stop()
504
+ self._indexer.stop()
505
+ logger.info("Indexing daemon stopped.")
506
+
507
+ def get_status(self) -> dict[str, Any]:
508
+ """Get daemon status."""
509
+ return {
510
+ "running": self.is_running,
511
+ "project_root": str(self._root),
512
+ "events_recorded": len(self._event_log),
513
+ "pending_tasks": self._indexer.pending_count,
514
+ "tasks_completed": self._indexer.tasks_processed,
515
+ }