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.
- codexa-0.4.0.dist-info/METADATA +650 -0
- codexa-0.4.0.dist-info/RECORD +189 -0
- codexa-0.4.0.dist-info/WHEEL +5 -0
- codexa-0.4.0.dist-info/entry_points.txt +2 -0
- codexa-0.4.0.dist-info/licenses/LICENSE +21 -0
- codexa-0.4.0.dist-info/top_level.txt +1 -0
- semantic_code_intelligence/__init__.py +5 -0
- semantic_code_intelligence/analysis/__init__.py +21 -0
- semantic_code_intelligence/analysis/ai_features.py +351 -0
- semantic_code_intelligence/bridge/__init__.py +28 -0
- semantic_code_intelligence/bridge/context_provider.py +245 -0
- semantic_code_intelligence/bridge/protocol.py +167 -0
- semantic_code_intelligence/bridge/server.py +348 -0
- semantic_code_intelligence/bridge/vscode.py +271 -0
- semantic_code_intelligence/ci/__init__.py +13 -0
- semantic_code_intelligence/ci/hooks.py +98 -0
- semantic_code_intelligence/ci/hotspots.py +272 -0
- semantic_code_intelligence/ci/impact.py +246 -0
- semantic_code_intelligence/ci/metrics.py +591 -0
- semantic_code_intelligence/ci/pr.py +412 -0
- semantic_code_intelligence/ci/quality.py +557 -0
- semantic_code_intelligence/ci/templates.py +164 -0
- semantic_code_intelligence/ci/trace.py +224 -0
- semantic_code_intelligence/cli/__init__.py +0 -0
- semantic_code_intelligence/cli/commands/__init__.py +0 -0
- semantic_code_intelligence/cli/commands/ask_cmd.py +153 -0
- semantic_code_intelligence/cli/commands/benchmark_cmd.py +303 -0
- semantic_code_intelligence/cli/commands/chat_cmd.py +252 -0
- semantic_code_intelligence/cli/commands/ci_gen_cmd.py +74 -0
- semantic_code_intelligence/cli/commands/context_cmd.py +120 -0
- semantic_code_intelligence/cli/commands/cross_refactor_cmd.py +113 -0
- semantic_code_intelligence/cli/commands/deps_cmd.py +91 -0
- semantic_code_intelligence/cli/commands/docs_cmd.py +101 -0
- semantic_code_intelligence/cli/commands/doctor_cmd.py +147 -0
- semantic_code_intelligence/cli/commands/evolve_cmd.py +171 -0
- semantic_code_intelligence/cli/commands/explain_cmd.py +112 -0
- semantic_code_intelligence/cli/commands/gate_cmd.py +135 -0
- semantic_code_intelligence/cli/commands/grep_cmd.py +234 -0
- semantic_code_intelligence/cli/commands/hotspots_cmd.py +119 -0
- semantic_code_intelligence/cli/commands/impact_cmd.py +131 -0
- semantic_code_intelligence/cli/commands/index_cmd.py +138 -0
- semantic_code_intelligence/cli/commands/init_cmd.py +152 -0
- semantic_code_intelligence/cli/commands/investigate_cmd.py +163 -0
- semantic_code_intelligence/cli/commands/languages_cmd.py +101 -0
- semantic_code_intelligence/cli/commands/lsp_cmd.py +49 -0
- semantic_code_intelligence/cli/commands/mcp_cmd.py +50 -0
- semantic_code_intelligence/cli/commands/metrics_cmd.py +264 -0
- semantic_code_intelligence/cli/commands/models_cmd.py +157 -0
- semantic_code_intelligence/cli/commands/plugin_cmd.py +275 -0
- semantic_code_intelligence/cli/commands/pr_summary_cmd.py +178 -0
- semantic_code_intelligence/cli/commands/quality_cmd.py +208 -0
- semantic_code_intelligence/cli/commands/refactor_cmd.py +103 -0
- semantic_code_intelligence/cli/commands/review_cmd.py +88 -0
- semantic_code_intelligence/cli/commands/search_cmd.py +236 -0
- semantic_code_intelligence/cli/commands/serve_cmd.py +117 -0
- semantic_code_intelligence/cli/commands/suggest_cmd.py +100 -0
- semantic_code_intelligence/cli/commands/summary_cmd.py +78 -0
- semantic_code_intelligence/cli/commands/tool_cmd.py +282 -0
- semantic_code_intelligence/cli/commands/trace_cmd.py +123 -0
- semantic_code_intelligence/cli/commands/tui_cmd.py +58 -0
- semantic_code_intelligence/cli/commands/viz_cmd.py +127 -0
- semantic_code_intelligence/cli/commands/watch_cmd.py +72 -0
- semantic_code_intelligence/cli/commands/web_cmd.py +61 -0
- semantic_code_intelligence/cli/commands/workspace_cmd.py +250 -0
- semantic_code_intelligence/cli/main.py +65 -0
- semantic_code_intelligence/cli/router.py +92 -0
- semantic_code_intelligence/config/__init__.py +0 -0
- semantic_code_intelligence/config/settings.py +260 -0
- semantic_code_intelligence/context/__init__.py +19 -0
- semantic_code_intelligence/context/engine.py +429 -0
- semantic_code_intelligence/context/memory.py +253 -0
- semantic_code_intelligence/daemon/__init__.py +1 -0
- semantic_code_intelligence/daemon/watcher.py +515 -0
- semantic_code_intelligence/docs/__init__.py +1080 -0
- semantic_code_intelligence/embeddings/__init__.py +0 -0
- semantic_code_intelligence/embeddings/enhanced.py +131 -0
- semantic_code_intelligence/embeddings/generator.py +149 -0
- semantic_code_intelligence/embeddings/model_registry.py +100 -0
- semantic_code_intelligence/evolution/__init__.py +1 -0
- semantic_code_intelligence/evolution/budget_guard.py +111 -0
- semantic_code_intelligence/evolution/commit_manager.py +88 -0
- semantic_code_intelligence/evolution/context_builder.py +131 -0
- semantic_code_intelligence/evolution/engine.py +249 -0
- semantic_code_intelligence/evolution/patch_generator.py +229 -0
- semantic_code_intelligence/evolution/task_selector.py +214 -0
- semantic_code_intelligence/evolution/test_runner.py +111 -0
- semantic_code_intelligence/indexing/__init__.py +0 -0
- semantic_code_intelligence/indexing/chunker.py +174 -0
- semantic_code_intelligence/indexing/parallel.py +86 -0
- semantic_code_intelligence/indexing/scanner.py +146 -0
- semantic_code_intelligence/indexing/semantic_chunker.py +337 -0
- semantic_code_intelligence/llm/__init__.py +62 -0
- semantic_code_intelligence/llm/cache.py +219 -0
- semantic_code_intelligence/llm/cached_provider.py +145 -0
- semantic_code_intelligence/llm/conversation.py +190 -0
- semantic_code_intelligence/llm/cross_refactor.py +272 -0
- semantic_code_intelligence/llm/investigation.py +274 -0
- semantic_code_intelligence/llm/mock_provider.py +77 -0
- semantic_code_intelligence/llm/ollama_provider.py +122 -0
- semantic_code_intelligence/llm/openai_provider.py +100 -0
- semantic_code_intelligence/llm/provider.py +92 -0
- semantic_code_intelligence/llm/rate_limiter.py +164 -0
- semantic_code_intelligence/llm/reasoning.py +438 -0
- semantic_code_intelligence/llm/safety.py +110 -0
- semantic_code_intelligence/llm/streaming.py +251 -0
- semantic_code_intelligence/lsp/__init__.py +609 -0
- semantic_code_intelligence/mcp/__init__.py +393 -0
- semantic_code_intelligence/parsing/__init__.py +19 -0
- semantic_code_intelligence/parsing/parser.py +375 -0
- semantic_code_intelligence/plugins/__init__.py +255 -0
- semantic_code_intelligence/plugins/examples/__init__.py +1 -0
- semantic_code_intelligence/plugins/examples/code_quality.py +73 -0
- semantic_code_intelligence/plugins/examples/search_annotator.py +56 -0
- semantic_code_intelligence/scalability/__init__.py +205 -0
- semantic_code_intelligence/search/__init__.py +0 -0
- semantic_code_intelligence/search/formatter.py +123 -0
- semantic_code_intelligence/search/grep.py +361 -0
- semantic_code_intelligence/search/hybrid_search.py +170 -0
- semantic_code_intelligence/search/keyword_search.py +311 -0
- semantic_code_intelligence/search/section_expander.py +103 -0
- semantic_code_intelligence/services/__init__.py +0 -0
- semantic_code_intelligence/services/indexing_service.py +630 -0
- semantic_code_intelligence/services/search_service.py +269 -0
- semantic_code_intelligence/storage/__init__.py +0 -0
- semantic_code_intelligence/storage/chunk_hash_store.py +86 -0
- semantic_code_intelligence/storage/hash_store.py +66 -0
- semantic_code_intelligence/storage/index_manifest.py +85 -0
- semantic_code_intelligence/storage/index_stats.py +138 -0
- semantic_code_intelligence/storage/query_history.py +160 -0
- semantic_code_intelligence/storage/symbol_registry.py +209 -0
- semantic_code_intelligence/storage/vector_store.py +297 -0
- semantic_code_intelligence/tests/__init__.py +0 -0
- semantic_code_intelligence/tests/test_ai_features.py +351 -0
- semantic_code_intelligence/tests/test_chunker.py +119 -0
- semantic_code_intelligence/tests/test_cli.py +188 -0
- semantic_code_intelligence/tests/test_config.py +154 -0
- semantic_code_intelligence/tests/test_context.py +381 -0
- semantic_code_intelligence/tests/test_embeddings.py +73 -0
- semantic_code_intelligence/tests/test_endtoend.py +1142 -0
- semantic_code_intelligence/tests/test_enhanced_embeddings.py +92 -0
- semantic_code_intelligence/tests/test_hash_store.py +79 -0
- semantic_code_intelligence/tests/test_logging.py +55 -0
- semantic_code_intelligence/tests/test_new_cli.py +138 -0
- semantic_code_intelligence/tests/test_parser.py +495 -0
- semantic_code_intelligence/tests/test_phase10.py +355 -0
- semantic_code_intelligence/tests/test_phase11.py +593 -0
- semantic_code_intelligence/tests/test_phase12.py +375 -0
- semantic_code_intelligence/tests/test_phase13.py +663 -0
- semantic_code_intelligence/tests/test_phase14.py +568 -0
- semantic_code_intelligence/tests/test_phase15.py +814 -0
- semantic_code_intelligence/tests/test_phase16.py +792 -0
- semantic_code_intelligence/tests/test_phase17.py +815 -0
- semantic_code_intelligence/tests/test_phase18.py +934 -0
- semantic_code_intelligence/tests/test_phase19.py +986 -0
- semantic_code_intelligence/tests/test_phase20.py +2753 -0
- semantic_code_intelligence/tests/test_phase20b.py +2058 -0
- semantic_code_intelligence/tests/test_phase20c.py +962 -0
- semantic_code_intelligence/tests/test_phase21.py +428 -0
- semantic_code_intelligence/tests/test_phase22.py +799 -0
- semantic_code_intelligence/tests/test_phase23.py +783 -0
- semantic_code_intelligence/tests/test_phase24.py +715 -0
- semantic_code_intelligence/tests/test_phase25.py +496 -0
- semantic_code_intelligence/tests/test_phase26.py +251 -0
- semantic_code_intelligence/tests/test_phase27.py +531 -0
- semantic_code_intelligence/tests/test_phase8.py +592 -0
- semantic_code_intelligence/tests/test_phase9.py +643 -0
- semantic_code_intelligence/tests/test_plugins.py +293 -0
- semantic_code_intelligence/tests/test_priority_features.py +727 -0
- semantic_code_intelligence/tests/test_router.py +41 -0
- semantic_code_intelligence/tests/test_scalability.py +138 -0
- semantic_code_intelligence/tests/test_scanner.py +125 -0
- semantic_code_intelligence/tests/test_search.py +160 -0
- semantic_code_intelligence/tests/test_semantic_chunker.py +255 -0
- semantic_code_intelligence/tests/test_tools.py +182 -0
- semantic_code_intelligence/tests/test_vector_store.py +151 -0
- semantic_code_intelligence/tests/test_watcher.py +211 -0
- semantic_code_intelligence/tools/__init__.py +442 -0
- semantic_code_intelligence/tools/executor.py +232 -0
- semantic_code_intelligence/tools/protocol.py +200 -0
- semantic_code_intelligence/tui/__init__.py +454 -0
- semantic_code_intelligence/utils/__init__.py +0 -0
- semantic_code_intelligence/utils/logging.py +112 -0
- semantic_code_intelligence/version.py +3 -0
- semantic_code_intelligence/web/__init__.py +11 -0
- semantic_code_intelligence/web/api.py +289 -0
- semantic_code_intelligence/web/server.py +397 -0
- semantic_code_intelligence/web/ui.py +659 -0
- semantic_code_intelligence/web/visualize.py +226 -0
- 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
|
+
}
|