codecortex 0.2.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.

Potentially problematic release.


This version of codecortex might be problematic. Click here for more details.

@@ -0,0 +1,407 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import enum
5
+ import json
6
+ import re
7
+ import shutil
8
+ import threading
9
+ import time
10
+ from typing import Any, Optional
11
+
12
+ from mcp import ClientSession
13
+ from mcp.client.stdio import stdio_client
14
+
15
+ from codeintel.provider import Result, safe_null_result
16
+
17
+ _COOLDOWN_SECONDS = 60
18
+ _DEFAULT_TIMEOUT_S = 5.0
19
+
20
+ # Serena ships as the `serena-agent` package (executable name `serena`); `uvx serena` does NOT
21
+ # work ("Package `serena` does not provide any executables"). The working invocation — verified
22
+ # against the installed serena and the machine's own serena MCP config — pulls it straight from
23
+ # the upstream repo and starts the stdio MCP server, binding the project via `--project`.
24
+ _SERENA_GIT = "git+https://github.com/oraios/serena"
25
+
26
+
27
+ def _serena_launch_args(cmd: str, project_root: str) -> list[str]:
28
+ """Build the real serena start-mcp-server argv. Kept pure + module-level so the exact
29
+ contract (the thing that had drifted) can be asserted without launching a subprocess."""
30
+ common = [
31
+ "start-mcp-server",
32
+ "--context", "ide-assistant", # tool set tuned for a coding agent, no chat scaffolding
33
+ "--enable-web-dashboard", "false", # headless: don't pop a browser from a background thread
34
+ "--project", project_root, # bind the project at launch (tools take no project arg)
35
+ ]
36
+ if cmd == "uvx":
37
+ return ["uvx", "--from", _SERENA_GIT, "serena", *common]
38
+ # A directly-installed `serena` (or serena-mcp-server shim) on PATH.
39
+ return [cmd, *common]
40
+
41
+
42
+ class _State(enum.Enum):
43
+ WARMING = "WARMING"
44
+ READY = "READY"
45
+ FAILED = "FAILED"
46
+
47
+
48
+ class _LspSession:
49
+ def __init__(self, project_root: str, cmd: str) -> None:
50
+ self.state = _State.WARMING
51
+ self.cooldown_until: float = 0.0
52
+ self._lock = threading.Lock()
53
+ self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop()
54
+ self._mcp_session: Optional[ClientSession] = None
55
+ self._thread = threading.Thread(
56
+ target=self._run,
57
+ args=(project_root, cmd),
58
+ daemon=True,
59
+ )
60
+ self._thread.start()
61
+
62
+ def _run(self, project_root: str, cmd: str) -> None:
63
+ try:
64
+ self._loop.run_until_complete(self._warmup(project_root, cmd))
65
+ except Exception:
66
+ with self._lock:
67
+ self.state = _State.FAILED
68
+ self.cooldown_until = time.monotonic() + _COOLDOWN_SECONDS
69
+ finally:
70
+ try:
71
+ self._loop.close()
72
+ except Exception:
73
+ pass
74
+
75
+ async def _warmup(self, project_root: str, cmd: str) -> None:
76
+ from mcp import StdioServerParameters
77
+
78
+ launch_args = _serena_launch_args(cmd, project_root)
79
+ async with stdio_client(
80
+ StdioServerParameters(command=launch_args[0], args=launch_args[1:])
81
+ ) as (read, write):
82
+ async with ClientSession(read, write) as session:
83
+ await session.initialize()
84
+ with self._lock:
85
+ self._mcp_session = session
86
+ self.state = _State.READY
87
+ # Keep the loop (and the subprocess/session it owns) alive so _call_tool can
88
+ # schedule coroutines onto it. Resolves only when the thread/loop is torn down.
89
+ await asyncio.get_running_loop().create_future()
90
+
91
+
92
+ class LspProvider:
93
+ """Wraps serena's LSP-over-MCP bridge. Never raises.
94
+
95
+ Serena tool contract (verified live, not assumed):
96
+ * ``find_symbol`` — arg ``name_path_pattern``; returns a JSON list of
97
+ ``{name_path, kind, relative_path, body_location, body?}``.
98
+ * ``find_referencing_symbols`` — args ``name_path`` AND ``relative_path`` (both required);
99
+ returns ``{file: {kind: [{name_path, content_around_reference}]}}``.
100
+ * ``get_symbols_overview`` — arg ``relative_path``; returns ``{kind: [names]}``.
101
+ No tool takes a ``project_root`` — the project is bound once at launch via ``--project``.
102
+ Finding references therefore needs two steps: locate the symbol, then query with its path.
103
+ """
104
+
105
+ def __init__(self) -> None:
106
+ self._sessions: dict[str, _LspSession] = {}
107
+ self._sessions_lock = threading.Lock()
108
+ self._detect_backend()
109
+
110
+ def _detect_backend(self) -> None:
111
+ # Prefer a directly-installed serena; otherwise drive it through uvx.
112
+ if shutil.which("serena"):
113
+ self.available = True
114
+ self._cmd: Optional[str] = "serena"
115
+ elif shutil.which("uvx"):
116
+ self.available = True
117
+ self._cmd = "uvx"
118
+ else:
119
+ self.available = False
120
+ self._cmd = None
121
+
122
+ def _get_or_create_session(self, root: str) -> _LspSession:
123
+ with self._sessions_lock:
124
+ existing = self._sessions.get(root)
125
+ if existing is not None:
126
+ with existing._lock:
127
+ if existing.state == _State.FAILED:
128
+ if time.monotonic() > existing.cooldown_until:
129
+ del self._sessions[root] # cooldown elapsed → allow one respawn
130
+ else:
131
+ return existing # still cooling down — no per-request respawn
132
+ else:
133
+ return existing
134
+ session = _LspSession(root, self._cmd) # type: ignore[arg-type]
135
+ self._sessions[root] = session
136
+ return session
137
+
138
+ def probe(self, project_root: str, deep: bool = False, timeout_s: float = 20.0) -> dict:
139
+ """Never-raise health check for the doctor. Shallow (default) is FREE — PATH presence
140
+ plus any existing session's live state. Deep boots serena and polls until READY/FAILED,
141
+ bounded by ``timeout_s`` (first boot pulls serena via uvx and is slow). ``repo_indexed``
142
+ is always None: serena keeps no persistent index, it warms per-root on demand."""
143
+ if not self.available:
144
+ return {
145
+ "installed": False, "runnable": False, "repo_indexed": None,
146
+ "detail": "neither `serena` nor `uvx` found on PATH",
147
+ "remediation": "install uv (provides uvx) — serena is fetched on first use",
148
+ }
149
+ cmd = self._cmd
150
+ if not deep:
151
+ existing = self._sessions.get(project_root)
152
+ if existing is None:
153
+ return {
154
+ "installed": True, "runnable": None, "repo_indexed": None,
155
+ "detail": f"serena via `{cmd}`; boot not verified (warms on 1st query; --deep to check now)",
156
+ "remediation": None,
157
+ }
158
+ with existing._lock:
159
+ st = existing.state
160
+ if st == _State.READY:
161
+ return {"installed": True, "runnable": True, "repo_indexed": None,
162
+ "detail": "serena session is READY for this repo", "remediation": None}
163
+ if st == _State.FAILED:
164
+ return {"installed": True, "runnable": False, "repo_indexed": None,
165
+ "detail": "serena session failed to boot for this repo",
166
+ "remediation": "re-run `codeintel doctor --deep` to see the boot error"}
167
+ return {"installed": True, "runnable": None, "repo_indexed": None,
168
+ "detail": "serena session is warming for this repo", "remediation": None}
169
+
170
+ # deep: boot (or reuse) a session and poll to a hard deadline — never hangs.
171
+ session = self._get_or_create_session(project_root)
172
+ deadline = time.monotonic() + timeout_s
173
+ while time.monotonic() < deadline:
174
+ with session._lock:
175
+ st = session.state
176
+ if st == _State.READY:
177
+ return {"installed": True, "runnable": True, "repo_indexed": None,
178
+ "detail": f"serena booted via `{cmd}` and reached READY", "remediation": None}
179
+ if st == _State.FAILED:
180
+ return {"installed": True, "runnable": False, "repo_indexed": None,
181
+ "detail": "serena failed to boot",
182
+ "remediation": "check uvx + network: `uvx --from git+https://github.com/oraios/serena serena start-mcp-server`"}
183
+ time.sleep(0.5)
184
+ return {"installed": True, "runnable": None, "repo_indexed": None,
185
+ "detail": f"serena did not reach READY within {int(timeout_s)}s (still warming)",
186
+ "remediation": "retry — first boot pulls serena via uvx and can be slow"}
187
+
188
+ def build_result(
189
+ self,
190
+ op: Any,
191
+ target: Any,
192
+ files: Any,
193
+ budget: Any,
194
+ project_root: Any,
195
+ ) -> Result:
196
+ try:
197
+ op_str = str(op or "")
198
+ target_str = str(target or "")
199
+ root_str = str(project_root or "")
200
+
201
+ if not self.available:
202
+ return safe_null_result(op_str, target_str, engine="lsp", reason="engine-unavailable")
203
+
204
+ try:
205
+ budget_ms = int(budget) if budget else 0
206
+ except Exception:
207
+ budget_ms = 0
208
+ timeout_s = (budget_ms / 1000) if budget_ms > 0 else _DEFAULT_TIMEOUT_S
209
+
210
+ session = self._get_or_create_session(root_str)
211
+
212
+ with session._lock:
213
+ state = session.state
214
+
215
+ if state == _State.WARMING:
216
+ return safe_null_result(op_str, target_str, engine="lsp", reason="warming")
217
+
218
+ if state == _State.FAILED:
219
+ return safe_null_result(op_str, target_str, engine="lsp", reason="boot-failed")
220
+
221
+ # READY
222
+ result_text = self._dispatch(session, op_str, target_str, root_str, timeout_s)
223
+ if result_text is None:
224
+ return safe_null_result(op_str, target_str, engine="lsp", reason="unsupported-op")
225
+
226
+ return {
227
+ "ok": True,
228
+ "op": op_str,
229
+ "target": target_str,
230
+ "result": result_text,
231
+ "engine": "lsp",
232
+ "cached": False,
233
+ }
234
+ except Exception:
235
+ return safe_null_result(op, target, engine="lsp", reason="error")
236
+
237
+ def _dispatch(
238
+ self,
239
+ session: _LspSession,
240
+ op: str,
241
+ target: str,
242
+ root: str,
243
+ timeout_s: float,
244
+ ) -> Optional[str]:
245
+ if op == "symbol" or op == "context":
246
+ # `context` (fan-out op) → the LSP's richest single-symbol view: definition + refs.
247
+ return self._op_symbol(session, target, root, timeout_s)
248
+ if op == "overview":
249
+ return self._op_overview(session, target, root, timeout_s)
250
+ return None
251
+
252
+ def _call_tool(
253
+ self,
254
+ session: _LspSession,
255
+ tool: str,
256
+ args: dict,
257
+ timeout_s: float,
258
+ ) -> Optional[Any]:
259
+ try:
260
+ mcp_session = session._mcp_session
261
+ if mcp_session is None:
262
+ return None
263
+ coro = mcp_session.call_tool(tool, args)
264
+ future = asyncio.run_coroutine_threadsafe(coro, session._loop)
265
+ return future.result(timeout=timeout_s)
266
+ except Exception:
267
+ return None
268
+
269
+ def _extract_text(self, raw: Any) -> Optional[str]:
270
+ if raw is None:
271
+ return None
272
+ if isinstance(raw, str):
273
+ return raw
274
+ # mcp CallToolResult has a .content list of TextContent
275
+ try:
276
+ parts = []
277
+ for item in raw.content:
278
+ if hasattr(item, "text"):
279
+ parts.append(item.text)
280
+ return "\n".join(parts) if parts else None
281
+ except Exception:
282
+ return None
283
+
284
+ @staticmethod
285
+ def _loads(text: Optional[str]) -> Any:
286
+ if not text:
287
+ return None
288
+ try:
289
+ return json.loads(text)
290
+ except Exception:
291
+ return None
292
+
293
+ @staticmethod
294
+ def _ref_line(content: Any) -> Optional[str]:
295
+ """Pull the referenced line number out of serena's `content_around_reference` blob,
296
+ which marks the reference line with a leading `>` (e.g. ` > 7:from ...`)."""
297
+ if not isinstance(content, str):
298
+ return None
299
+ m = re.search(r">\s*(\d+):", content)
300
+ return m.group(1) if m else None
301
+
302
+ def _format_matches(self, target: str, matches: list) -> tuple[str, Optional[dict]]:
303
+ parts = [f"## Symbol: {target}"]
304
+ first: Optional[dict] = None
305
+ for m in matches:
306
+ if not isinstance(m, dict):
307
+ continue
308
+ if first is None:
309
+ first = m
310
+ kind = m.get("kind") or "symbol"
311
+ rel = m.get("relative_path") or "?"
312
+ loc = m.get("body_location") if isinstance(m.get("body_location"), dict) else {}
313
+ s, e = loc.get("start_line"), loc.get("end_line")
314
+ span = f":{s}-{e}" if s is not None else ""
315
+ parts.append(f"**{kind}** — {rel}{span}")
316
+ body = m.get("body")
317
+ if body:
318
+ parts.append(f"```\n{body}\n```")
319
+ return "\n".join(parts), first
320
+
321
+ def _format_refs(self, data: Any) -> list[str]:
322
+ lines: list[str] = []
323
+ if not isinstance(data, dict):
324
+ return lines
325
+ for file, kinds in data.items():
326
+ if not isinstance(kinds, dict):
327
+ continue
328
+ for _kind, entries in kinds.items():
329
+ if not isinstance(entries, list):
330
+ continue
331
+ for ent in entries:
332
+ if not isinstance(ent, dict):
333
+ continue
334
+ np = str(ent.get("name_path") or "").strip()
335
+ line = self._ref_line(ent.get("content_around_reference"))
336
+ loc = f"{file}:{line}" if line else str(file)
337
+ suffix = f" ({np})" if np else ""
338
+ lines.append(f"- {loc}{suffix}")
339
+ if len(lines) >= 50:
340
+ return lines
341
+ return lines
342
+
343
+ def _op_symbol(
344
+ self, session: _LspSession, target: str, root: str, timeout_s: float
345
+ ) -> Optional[str]:
346
+ try:
347
+ def_raw = self._call_tool(
348
+ session,
349
+ "find_symbol",
350
+ {"name_path_pattern": target, "include_body": True, "max_matches": 5},
351
+ timeout_s,
352
+ )
353
+ def_text = self._extract_text(def_raw)
354
+ matches = self._loads(def_text)
355
+
356
+ first: Optional[dict] = None
357
+ if isinstance(matches, list) and matches:
358
+ def_section, first = self._format_matches(target, matches)
359
+ else:
360
+ # Non-JSON / degenerate response — surface whatever serena returned.
361
+ def_section = f"## Symbol: {target}\n{def_text or '(not found)'}"
362
+
363
+ # References require the located symbol's own path (two-step contract).
364
+ ref_section = "## References\n(none)"
365
+ if first and first.get("relative_path"):
366
+ ref_raw = self._call_tool(
367
+ session,
368
+ "find_referencing_symbols",
369
+ {
370
+ "name_path": first.get("name_path") or target,
371
+ "relative_path": first.get("relative_path"),
372
+ },
373
+ timeout_s,
374
+ )
375
+ ref_lines = self._format_refs(self._loads(self._extract_text(ref_raw)))
376
+ if ref_lines:
377
+ ref_section = f"## References ({len(ref_lines)})\n" + "\n".join(ref_lines)
378
+
379
+ return f"{def_section}\n\n{ref_section}"
380
+ except Exception:
381
+ return None
382
+
383
+ def _op_overview(
384
+ self, session: _LspSession, target: str, root: str, timeout_s: float
385
+ ) -> Optional[str]:
386
+ try:
387
+ raw = self._call_tool(
388
+ session,
389
+ "get_symbols_overview",
390
+ {"relative_path": target or ""},
391
+ timeout_s,
392
+ )
393
+ text = self._extract_text(raw)
394
+ if not text:
395
+ return None
396
+ parsed = self._loads(text)
397
+ if isinstance(parsed, dict):
398
+ parts = [f"## Overview: {target}"]
399
+ for kind, names in parsed.items():
400
+ if isinstance(names, list):
401
+ parts.append(f"**{kind}**: " + ", ".join(str(n) for n in names))
402
+ else:
403
+ parts.append(f"**{kind}**: {names}")
404
+ return "\n".join(parts)
405
+ return text
406
+ except Exception:
407
+ return None
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from codeintel.provider import Result, safe_null_result
4
+
5
+
6
+ class NoneProvider:
7
+ """Always returns a safe-null Result. Never raises."""
8
+
9
+ def build_result(
10
+ self,
11
+ op,
12
+ target,
13
+ files,
14
+ budget,
15
+ project_root,
16
+ ) -> Result:
17
+ try:
18
+ op = str(op or "")
19
+ target = str(target or "")
20
+ return safe_null_result(op, target, engine="none", reason="no-engine")
21
+ except Exception:
22
+ return {
23
+ "ok": True,
24
+ "op": str(op or ""),
25
+ "target": str(target or ""),
26
+ "result": None,
27
+ "engine": "none",
28
+ "cached": False,
29
+ "reason": "no-engine",
30
+ }
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+
5
+ from codeintel.provider import Result, safe_null_result
6
+
7
+ try:
8
+ import fastembed # noqa: F401
9
+ import sqlite_vec # noqa: F401
10
+ _DEPS_OK = True
11
+ except ImportError:
12
+ _DEPS_OK = False
13
+
14
+ _DB_PATH = pathlib.Path.home() / ".codeintel" / "semantic.db"
15
+
16
+
17
+ class SemanticProvider:
18
+ """Real semantic search provider backed by SemanticDb and Searcher."""
19
+
20
+ @property
21
+ def available(self) -> bool:
22
+ return _DEPS_OK
23
+
24
+ def probe(self, project_root: str) -> dict:
25
+ """Never-raise health check for the doctor. READ-ONLY and MODEL-FREE: it opens the db
26
+ read-only and counts this repo's chunks — it must NOT call SemanticDb.init() (a schema
27
+ write) or load fastembed. ``repo_indexed`` is project-scoped (mirrors Searcher.has_index)."""
28
+ if not self.available:
29
+ return {
30
+ "installed": False, "runnable": False, "repo_indexed": False,
31
+ "detail": "fastembed / sqlite-vec not importable",
32
+ "remediation": "pip install fastembed sqlite-vec (or: pip install -e .)",
33
+ }
34
+ import os
35
+ import sqlite3
36
+
37
+ try:
38
+ from codeintel.semantic_db import default_db_path
39
+ db_path = default_db_path()
40
+ except Exception:
41
+ db_path = ""
42
+ if not db_path or not os.path.exists(db_path):
43
+ return {
44
+ "installed": True, "runnable": True, "repo_indexed": False,
45
+ "detail": "no semantic index database yet",
46
+ "remediation": f"codeintel index {project_root}",
47
+ }
48
+ try:
49
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
50
+ try:
51
+ real = os.path.realpath(project_root) if project_root else ""
52
+ row = conn.execute(
53
+ "SELECT COUNT(*) FROM chunk_hashes WHERE project_root = ?", (real,)
54
+ ).fetchone()
55
+ finally:
56
+ conn.close()
57
+ count = int(row[0]) if row else 0
58
+ except Exception as exc:
59
+ return {
60
+ "installed": True, "runnable": False, "repo_indexed": False,
61
+ "detail": f"semantic.db present but unreadable ({type(exc).__name__})",
62
+ "remediation": "rm ~/.codeintel/semantic.db && codeintel index <root>",
63
+ }
64
+ if count > 0:
65
+ return {
66
+ "installed": True, "runnable": True, "repo_indexed": True,
67
+ "detail": f"{count} indexed chunks for this repo", "remediation": None,
68
+ }
69
+ return {
70
+ "installed": True, "runnable": True, "repo_indexed": False,
71
+ "detail": "semantic.db present but 0 chunks for this repo",
72
+ "remediation": f"codeintel index {project_root}",
73
+ }
74
+
75
+ def build_result(
76
+ self,
77
+ op: str,
78
+ target: str,
79
+ files: list[str],
80
+ budget: int,
81
+ project_root: str,
82
+ ) -> Result:
83
+ # `context` (fan-out op) → semantic's contribution is a similarity search on the target.
84
+ if op not in ("search", "context"):
85
+ return safe_null_result(op, target, engine="semantic", reason="op-not-supported")
86
+ if not self.available:
87
+ return safe_null_result(op, target, engine="semantic", reason="engine-unavailable")
88
+ if not project_root:
89
+ return safe_null_result(op, target, engine="semantic", reason="no-project-root")
90
+
91
+ try:
92
+ from codeintel.config import load_config
93
+ from codeintel.semantic_db import SemanticDb
94
+ from codeintel.indexer import Indexer
95
+ from codeintel.searcher import Searcher
96
+
97
+ cfg = load_config(project_root)
98
+ model = str(cfg.get("model") or "BAAI/bge-small-en-v1.5")
99
+
100
+ _DB_PATH.parent.mkdir(parents=True, exist_ok=True)
101
+ db = SemanticDb(str(_DB_PATH))
102
+ db.init()
103
+
104
+ Indexer(
105
+ db,
106
+ model_name=model,
107
+ window=int(cfg.get("window", 20)),
108
+ stride=int(cfg.get("stride", 10)),
109
+ max_chunks=int(cfg.get("max_chunks", 500)),
110
+ ).index(project_root)
111
+
112
+ searcher = Searcher(db, model_name=model)
113
+ if not searcher.has_index(project_root):
114
+ return safe_null_result(
115
+ op, target, engine="semantic", reason="no-index",
116
+ hint=f"run: codeintel index {project_root} (or: codeintel doctor)",
117
+ )
118
+
119
+ matches = searcher.search(
120
+ target, project_root, cosine_floor=float(cfg.get("cosine_floor", 0.25))
121
+ )
122
+ if not matches:
123
+ return safe_null_result(op, target, engine="semantic", reason="below-floor")
124
+
125
+ lines = [
126
+ f"{m['path']}:{m['line']} | {m['snippet'].splitlines()[0] if m['snippet'].splitlines() else m['snippet']}"
127
+ for m in matches
128
+ ]
129
+ result: Result = {
130
+ "ok": True,
131
+ "op": op,
132
+ "target": target,
133
+ "result": "\n".join(lines),
134
+ "engine": "semantic",
135
+ "cached": False,
136
+ }
137
+ return result
138
+ except Exception:
139
+ return safe_null_result(op, target, engine="semantic", reason="provider-error")
codeintel/reindexer.py ADDED
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import threading
6
+ import time
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class _DaemonPool:
12
+ """Runs background work on DAEMON threads so it can never block interpreter shutdown.
13
+
14
+ A one-shot ``codeintel query`` must return immediately — with a non-daemon pool, Python's
15
+ shutdown joins the workers, so a first query on a large repo hung for minutes while the
16
+ repo-wide reindex finished (and, with buffered/piped stdout, the result never even flushed
17
+ until the join completed). Daemon threads exit with the process; on the persistent server
18
+ they run to completion normally. Rate is bounded by the Reindexer's debounce, so a
19
+ thread-per-task is fine. ``shutdown(wait=)`` is kept for deterministic test draining."""
20
+
21
+ def __init__(self, max_workers: int = 2) -> None:
22
+ self._max_workers = max_workers # advisory; the Reindexer debounce is the real rate limit
23
+ self._threads: list[threading.Thread] = []
24
+ self._lock = threading.Lock()
25
+
26
+ def submit(self, fn, *args) -> None:
27
+ t = threading.Thread(target=fn, args=args, daemon=True)
28
+ with self._lock:
29
+ self._threads = [x for x in self._threads if x.is_alive()] # drop finished
30
+ self._threads.append(t)
31
+ t.start()
32
+
33
+ def shutdown(self, wait: bool = True) -> None:
34
+ if not wait:
35
+ return
36
+ with self._lock:
37
+ threads = list(self._threads)
38
+ for t in threads:
39
+ t.join()
40
+
41
+
42
+ class Reindexer:
43
+ def __init__(self, debounce_seconds: float = 30, enabled: bool = True) -> None:
44
+ self._debounce_seconds = debounce_seconds
45
+ self._enabled = (
46
+ os.environ.get("CODEINTEL_REINDEX", "on").strip().lower() != "off"
47
+ and enabled
48
+ )
49
+ self._lock = threading.Lock()
50
+ self._last_fired: dict[str, float] = {}
51
+ # Per-project index generation — bumped when a reindex completes. The gateway
52
+ # folds it into the cache key so a structural answer is invalidated once the
53
+ # index actually moves (a symbol/free-text target has no file content to hash).
54
+ self._generation: dict[str, int] = {}
55
+ self._executor = _DaemonPool(max_workers=2)
56
+
57
+ def generation(self, project_root: str) -> int:
58
+ with self._lock:
59
+ return self._generation.get(project_root, 0)
60
+
61
+ def maybe_reindex(self, project_root: str) -> None:
62
+ if not self._enabled:
63
+ return
64
+ if not project_root:
65
+ return
66
+
67
+ now = time.monotonic()
68
+ with self._lock:
69
+ last = self._last_fired.get(project_root, 0.0)
70
+ if now - last < self._debounce_seconds:
71
+ return
72
+ self._last_fired[project_root] = now
73
+
74
+ self._executor.submit(self._do_reindex, project_root)
75
+
76
+ def _do_reindex(self, project_root: str) -> None:
77
+ try:
78
+ self._semantic_reindex(project_root)
79
+ self._graph_reindex(project_root)
80
+ with self._lock:
81
+ self._generation[project_root] = self._generation.get(project_root, 0) + 1
82
+ except Exception as exc:
83
+ logger.warning("Reindexer._do_reindex failed for %s: %s", project_root, exc)
84
+
85
+ def _semantic_reindex(self, project_root: str) -> None:
86
+ import pathlib
87
+
88
+ from codeintel.semantic_db import SemanticDb, default_db_path
89
+ from codeintel.indexer import Indexer
90
+
91
+ # Same per-machine cache the SemanticProvider reads — index and search must
92
+ # never diverge onto different files.
93
+ db_path = default_db_path()
94
+ pathlib.Path(db_path).parent.mkdir(parents=True, exist_ok=True)
95
+ db = SemanticDb(db_path)
96
+ try:
97
+ db.init()
98
+ Indexer(db).index(project_root)
99
+ finally:
100
+ db.close()
101
+
102
+ def _graph_reindex(self, project_root: str) -> None:
103
+ # Route through the graph provider's single subprocess/JSON seam (piped stdin, with a
104
+ # deprecated raw-JSON fallback) rather than duplicating the deprecated raw-JSON call here.
105
+ try:
106
+ from codeintel.providers.graph import GraphProvider
107
+ gp = GraphProvider()
108
+ if not gp.available:
109
+ return
110
+ gp._run("detect_changes", {"project_root": project_root}, 120_000)
111
+ except Exception as exc:
112
+ logger.warning("graph detect_changes failed: %s", exc)