codebase-index 1.6.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 (64) hide show
  1. codebase_index/__init__.py +7 -0
  2. codebase_index/__main__.py +3 -0
  3. codebase_index/cli.py +916 -0
  4. codebase_index/config.py +110 -0
  5. codebase_index/discovery/__init__.py +10 -0
  6. codebase_index/discovery/classify.py +151 -0
  7. codebase_index/discovery/ignore.py +58 -0
  8. codebase_index/discovery/walker.py +75 -0
  9. codebase_index/doctor.py +138 -0
  10. codebase_index/embeddings/__init__.py +2 -0
  11. codebase_index/embeddings/backend.py +67 -0
  12. codebase_index/embeddings/external.py +56 -0
  13. codebase_index/embeddings/local.py +41 -0
  14. codebase_index/embeddings/noop.py +15 -0
  15. codebase_index/graph/__init__.py +8 -0
  16. codebase_index/graph/analysis.py +468 -0
  17. codebase_index/graph/builder.py +160 -0
  18. codebase_index/graph/expand.py +136 -0
  19. codebase_index/graph/export.py +381 -0
  20. codebase_index/graph/navigate.py +201 -0
  21. codebase_index/indexer/__init__.py +8 -0
  22. codebase_index/indexer/doc_chunks.py +202 -0
  23. codebase_index/indexer/freshness.py +109 -0
  24. codebase_index/indexer/pipeline.py +423 -0
  25. codebase_index/mcp/__init__.py +2 -0
  26. codebase_index/mcp/server.py +354 -0
  27. codebase_index/models.py +145 -0
  28. codebase_index/output/__init__.py +6 -0
  29. codebase_index/output/json.py +13 -0
  30. codebase_index/output/markdown.py +316 -0
  31. codebase_index/output/redact.py +31 -0
  32. codebase_index/parsers/__init__.py +9 -0
  33. codebase_index/parsers/base.py +47 -0
  34. codebase_index/parsers/languages.py +290 -0
  35. codebase_index/parsers/line_chunker.py +39 -0
  36. codebase_index/parsers/symbol_chunks.py +62 -0
  37. codebase_index/parsers/treesitter.py +439 -0
  38. codebase_index/retrieval/__init__.py +9 -0
  39. codebase_index/retrieval/budget.py +82 -0
  40. codebase_index/retrieval/fusion.py +62 -0
  41. codebase_index/retrieval/intent.py +56 -0
  42. codebase_index/retrieval/pipeline.py +207 -0
  43. codebase_index/retrieval/rerank.py +69 -0
  44. codebase_index/retrieval/searchers.py +291 -0
  45. codebase_index/retrieval/skeleton.py +251 -0
  46. codebase_index/retrieval/types.py +79 -0
  47. codebase_index/scaffold.py +399 -0
  48. codebase_index/service.py +158 -0
  49. codebase_index/skill_template/SKILL.md +198 -0
  50. codebase_index/skill_template/examples/hooks/settings.json +16 -0
  51. codebase_index/skill_template/scripts/cbx +25 -0
  52. codebase_index/skill_template/scripts/cbx.ps1 +25 -0
  53. codebase_index/skill_update.py +150 -0
  54. codebase_index/storage/__init__.py +8 -0
  55. codebase_index/storage/db.py +116 -0
  56. codebase_index/storage/repo.py +701 -0
  57. codebase_index/storage/schema.sql +125 -0
  58. codebase_index/watch/__init__.py +5 -0
  59. codebase_index/watch/watcher.py +93 -0
  60. codebase_index-1.6.0.dist-info/METADATA +748 -0
  61. codebase_index-1.6.0.dist-info/RECORD +64 -0
  62. codebase_index-1.6.0.dist-info/WHEEL +4 -0
  63. codebase_index-1.6.0.dist-info/entry_points.txt +4 -0
  64. codebase_index-1.6.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,399 @@
1
+ """Materialize the bundled skill template into project CLI trees.
2
+
3
+ Pure filesystem helpers used by the `init` CLI command. The template is read from
4
+ the wheel via importlib.resources, so it works in editable and zip installs alike.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import shutil
12
+ import stat
13
+ import sys
14
+ from importlib import resources
15
+ from importlib.resources.abc import Traversable
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from .config import Config
20
+
21
+ CLI_TARGETS = ("claude", "codex", "opencode")
22
+
23
+ # MCP clients that receive a JSON config entry (no skill files needed).
24
+ MCP_TARGETS = ("cursor", "claude-desktop", "zed", "vscode", "windsurf")
25
+
26
+ ALL_TARGETS = CLI_TARGETS + MCP_TARGETS
27
+
28
+ CLAUDE_SKILL_REL = Path(".claude") / "skills" / "codebase-index"
29
+ CODEX_SKILL_REL = Path(".codex") / "skills" / "codebase-index"
30
+ OPENCODE_SKILL_REL = Path(".opencode") / "skills" / "codebase-index"
31
+ OPENCODE_COMMAND_REL = Path(".opencode") / "commands" / "codebase-index.md"
32
+ OPENCODE_AGENT_REL = Path(".opencode") / "agents" / "codebase-index.md"
33
+
34
+ SKILL_REL = CLAUDE_SKILL_REL
35
+ CACHE_REL = Path(".claude") / "cache" / "codebase-index"
36
+ _CACHE_IGNORE_LINE = ".claude/cache/codebase-index/"
37
+ _GITIGNORE_BLOCK = (
38
+ "\n# codebase-index cache (machine-local; do not commit)\n"
39
+ f"{_CACHE_IGNORE_LINE}\n"
40
+ )
41
+ _MANAGED_START = "<!-- >>> codebase-index managed >>> -->"
42
+ _MANAGED_END = "<!-- <<< codebase-index managed <<< -->"
43
+
44
+
45
+ def _template_root() -> Traversable:
46
+ return resources.files("codebase_index") / "skill_template"
47
+
48
+
49
+ def _iter_template(node: Traversable, prefix: str = "") -> "list[tuple[str, Traversable]]":
50
+ """Depth-first list of (relative-posix-path, file) under a template dir."""
51
+ out: list[tuple[str, Traversable]] = []
52
+ for child in node.iterdir():
53
+ rel = f"{prefix}{child.name}"
54
+ if child.is_dir():
55
+ out.extend(_iter_template(child, prefix=f"{rel}/"))
56
+ else:
57
+ out.append((rel, child))
58
+ return out
59
+
60
+
61
+ def skill_rel_for_target(target: str) -> Path:
62
+ if target == "claude":
63
+ return CLAUDE_SKILL_REL
64
+ if target == "codex":
65
+ return CODEX_SKILL_REL
66
+ if target == "opencode":
67
+ return OPENCODE_SKILL_REL
68
+ raise ValueError(f"unknown CLI target: {target}")
69
+
70
+
71
+ def detect_cli_targets(root: Path) -> list[str]:
72
+ """Detect usable local CLI targets for a project install."""
73
+ home = Path.home()
74
+ checks = (
75
+ ("claude", "claude", root / ".claude", home / ".claude"),
76
+ ("codex", "codex", root / ".codex", home / ".codex"),
77
+ ("opencode", "opencode", root / ".opencode", home / ".config" / "opencode"),
78
+ )
79
+ return [
80
+ target
81
+ for target, command, project_marker, home_marker in checks
82
+ if project_marker.exists() or shutil.which(command) or home_marker.exists()
83
+ ]
84
+
85
+
86
+ def detect_mcp_targets(root: Path) -> list[str]:
87
+ """Detect MCP-capable clients present on this machine or in this project."""
88
+ home = Path.home()
89
+ found: list[str] = []
90
+
91
+ checks: list[tuple[str, list[Optional[Path]]]] = [
92
+ ("cursor", [root / ".cursor", home / ".cursor"]),
93
+ ("windsurf", [root / ".windsurf", home / ".windsurf"]),
94
+ ("vscode", [root / ".vscode"]),
95
+ ("zed", [root / ".zed", home / ".config" / "zed"]),
96
+ ("claude-desktop", [_claude_desktop_config_path()]),
97
+ ]
98
+ exe_checks = {
99
+ "cursor": ["cursor"],
100
+ "windsurf": ["windsurf"],
101
+ "vscode": ["code", "code-insiders"],
102
+ "zed": ["zed"],
103
+ }
104
+ for target, markers in checks:
105
+ if any(m is not None and m.exists() for m in markers):
106
+ found.append(target)
107
+ continue
108
+ for exe in exe_checks.get(target, []):
109
+ if shutil.which(exe):
110
+ found.append(target)
111
+ break
112
+ return found
113
+
114
+
115
+ def materialize_skill(root: Path, *, force: bool, target: str = "claude") -> list[Path]:
116
+ """Copy the whole skill template to the target's project resource directory."""
117
+ dest = root / skill_rel_for_target(target)
118
+ if dest.exists() and not force:
119
+ raise FileExistsError(dest)
120
+
121
+ written: list[Path] = []
122
+ for rel, node in _iter_template(_template_root()):
123
+ dst = dest / Path(rel)
124
+ dst.parent.mkdir(parents=True, exist_ok=True)
125
+ dst.write_bytes(node.read_bytes())
126
+ if rel == "scripts/cbx":
127
+ dst.chmod(dst.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
128
+ written.append(dst)
129
+
130
+ # Write version stamp so auto-update can detect when the skill is outdated.
131
+ try:
132
+ from importlib.metadata import version as _pkg_version
133
+ pkg_ver = _pkg_version("codebase-index")
134
+ except Exception:
135
+ pkg_ver = "unknown"
136
+ stamp = dest / ".skill_version"
137
+ stamp.write_text(pkg_ver + "\n", encoding="utf-8")
138
+ written.append(stamp)
139
+
140
+ return written
141
+
142
+
143
+ def _managed_block(content: str) -> str:
144
+ return f"{_MANAGED_START}\n{content.rstrip()}\n{_MANAGED_END}\n"
145
+
146
+
147
+ def _upsert_managed_block(path: Path, content: str) -> Path:
148
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
149
+ block = _managed_block(content)
150
+ if _MANAGED_START in existing and _MANAGED_END in existing:
151
+ before, rest = existing.split(_MANAGED_START, 1)
152
+ _, after = rest.split(_MANAGED_END, 1)
153
+ new_text = before.rstrip() + "\n\n" + block + after.lstrip()
154
+ else:
155
+ sep = "" if existing in ("", "\n") else "\n\n"
156
+ new_text = existing.rstrip() + sep + block
157
+ path.write_text(new_text, encoding="utf-8")
158
+ return path
159
+
160
+
161
+ def write_codex_agents(root: Path) -> Path:
162
+ rel = CODEX_SKILL_REL / "SKILL.md"
163
+ content = f"""# codebase-index
164
+
165
+ Use the local codebase index before scanning repository files.
166
+
167
+ Skill resources: `{rel.as_posix()}`
168
+
169
+ Run `codebase-index search "<query>" --json` for general questions, or use
170
+ `symbol`, `refs`, `impact`, and `graph` for symbol lookup, references, change
171
+ impact, and HTML graph export. Search/read commands auto-build the index when
172
+ it is missing; run `codebase-index update` when responses report stale data.
173
+ """
174
+ return _upsert_managed_block(root / "AGENTS.md", content)
175
+
176
+
177
+ def write_opencode_files(root: Path) -> list[Path]:
178
+ command = root / OPENCODE_COMMAND_REL
179
+ agent = root / OPENCODE_AGENT_REL
180
+ command.parent.mkdir(parents=True, exist_ok=True)
181
+ agent.parent.mkdir(parents=True, exist_ok=True)
182
+ command.write_text(
183
+ """---
184
+ description: Search this repository with codebase-index before reading files.
185
+ ---
186
+
187
+ Run:
188
+
189
+ ```bash
190
+ codebase-index search "$ARGUMENTS" --json
191
+ ```
192
+
193
+ Use `symbol <name>`, `refs <name>`, or `impact <file|symbol>` when those match
194
+ the request. If the index is missing, run `codebase-index index` first.
195
+ """,
196
+ encoding="utf-8",
197
+ )
198
+ src = _template_root() / "SKILL.md"
199
+ agent.write_bytes(src.read_bytes())
200
+ return [command, agent]
201
+
202
+
203
+ def install_target(root: Path, target: str, *, force: bool) -> list[Path]:
204
+ written = materialize_skill(root, force=force, target=target)
205
+ if target == "codex":
206
+ written.append(write_codex_agents(root))
207
+ elif target == "opencode":
208
+ written.extend(write_opencode_files(root))
209
+ return written
210
+
211
+
212
+ def write_config(root: Path, *, force: bool) -> Path:
213
+ """Write resolved defaults to `<root>/.claude/cache/codebase-index/config.json`."""
214
+ path = root / CACHE_REL / "config.json"
215
+ path.parent.mkdir(parents=True, exist_ok=True)
216
+ if path.exists() and not force:
217
+ return path
218
+ cfg = Config()
219
+ path.write_text(cfg.model_dump_json(indent=2) + "\n", encoding="utf-8")
220
+ return path
221
+
222
+
223
+ def merge_gitignore(root: Path) -> bool:
224
+ """Append the cache-ignore block to `<root>/.gitignore` if absent. Returns True if changed."""
225
+ path = root / ".gitignore"
226
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
227
+ if _CACHE_IGNORE_LINE in existing:
228
+ return False
229
+ sep = "" if existing.endswith("\n") or existing == "" else "\n"
230
+ path.write_text(existing + sep + _GITIGNORE_BLOCK, encoding="utf-8")
231
+ return True
232
+
233
+
234
+ def write_hooks_example(root: Path) -> Path:
235
+ """Copy the hooks example next to the installed skill (for manual `--with-hooks` review)."""
236
+ src = _template_root() / "examples" / "hooks" / "settings.json"
237
+ path = root / SKILL_REL / "examples" / "hooks" / "settings.json"
238
+ path.parent.mkdir(parents=True, exist_ok=True)
239
+ path.write_bytes(src.read_bytes())
240
+ return path
241
+
242
+
243
+ SETTINGS_REL = Path(".claude") / "settings.json"
244
+ _HOOK_MARKER = "codebase-index update"
245
+
246
+
247
+ def _template_hook_entries() -> "list[dict]":
248
+ src = _template_root() / "examples" / "hooks" / "settings.json"
249
+ data = json.loads(src.read_text(encoding="utf-8"))
250
+ return data["hooks"]["PostToolUse"]
251
+
252
+
253
+ def _has_our_hook(settings: dict) -> bool:
254
+ for entry in settings.get("hooks", {}).get("PostToolUse", []):
255
+ for hk in entry.get("hooks", []):
256
+ if _HOOK_MARKER in hk.get("command", ""):
257
+ return True
258
+ return False
259
+
260
+
261
+ def merge_hook_settings(root: Path) -> bool:
262
+ path = root / SETTINGS_REL
263
+ settings: dict = {}
264
+ if path.exists():
265
+ settings = json.loads(path.read_text(encoding="utf-8"))
266
+ if _has_our_hook(settings):
267
+ return False
268
+
269
+ hooks = settings.setdefault("hooks", {})
270
+ post = hooks.setdefault("PostToolUse", [])
271
+ post.extend(_template_hook_entries())
272
+
273
+ path.parent.mkdir(parents=True, exist_ok=True)
274
+ path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
275
+ return True
276
+
277
+
278
+ # ── MCP client config helpers ──────────────────────────────────────────────────────────────────
279
+
280
+ _MCP_SERVER_NAME = "codebase-index"
281
+ _MCP_ENTRY_STDIO = {"command": "codebase-index", "args": ["mcp"]}
282
+
283
+
284
+ def _claude_desktop_config_path() -> Optional[Path]:
285
+ """Platform-specific path to Claude Desktop's config file."""
286
+ if sys.platform == "win32":
287
+ appdata = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
288
+ return appdata / "Claude" / "claude_desktop_config.json"
289
+ if sys.platform == "darwin":
290
+ return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
291
+ return Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
292
+
293
+
294
+ def _load_json_file(path: Path) -> dict:
295
+ if path.exists():
296
+ try:
297
+ return json.loads(path.read_text(encoding="utf-8"))
298
+ except (OSError, json.JSONDecodeError):
299
+ return {}
300
+ return {}
301
+
302
+
303
+ def _write_json_file(path: Path, data: dict) -> None:
304
+ path.parent.mkdir(parents=True, exist_ok=True)
305
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
306
+
307
+
308
+ def _merge_mcp_servers(path: Path, entry: dict, *, force: bool) -> bool:
309
+ """Merge {"mcpServers": {"codebase-index": entry}} into a JSON config file.
310
+
311
+ Returns True if the file was written (new or updated), False if already present.
312
+ """
313
+ data = _load_json_file(path)
314
+ servers: dict = data.setdefault("mcpServers", {})
315
+ if _MCP_SERVER_NAME in servers and not force:
316
+ return False
317
+ servers[_MCP_SERVER_NAME] = entry
318
+ _write_json_file(path, data)
319
+ return True
320
+
321
+
322
+ def _merge_vscode_mcp(path: Path, *, force: bool) -> bool:
323
+ """VS Code uses {"servers": {"name": {"type": "stdio", ...}}} in .vscode/mcp.json."""
324
+ data = _load_json_file(path)
325
+ servers: dict = data.setdefault("servers", {})
326
+ if _MCP_SERVER_NAME in servers and not force:
327
+ return False
328
+ servers[_MCP_SERVER_NAME] = {"type": "stdio", **_MCP_ENTRY_STDIO}
329
+ _write_json_file(path, data)
330
+ return True
331
+
332
+
333
+ def _merge_zed_settings(path: Path, *, force: bool) -> bool:
334
+ """Zed uses context_servers with a nested command object in settings.json."""
335
+ data = _load_json_file(path)
336
+ ctx: dict = data.setdefault("context_servers", {})
337
+ if _MCP_SERVER_NAME in ctx and not force:
338
+ return False
339
+ ctx[_MCP_SERVER_NAME] = {
340
+ "command": {
341
+ "path": "codebase-index",
342
+ "args": ["mcp"],
343
+ }
344
+ }
345
+ _write_json_file(path, data)
346
+ return True
347
+
348
+
349
+ def install_mcp_target(root: Path, target: str, *, force: bool = False) -> tuple[Path, bool]:
350
+ """Write or merge the MCP server entry for `target`.
351
+
352
+ Returns (config_path, written) where written=False means it was already present.
353
+ Raises ValueError for unknown targets.
354
+ """
355
+ if target == "cursor":
356
+ path = root / ".cursor" / "mcp.json"
357
+ written = _merge_mcp_servers(path, _MCP_ENTRY_STDIO, force=force)
358
+ return path, written
359
+
360
+ if target == "windsurf":
361
+ path = root / ".windsurf" / "mcp.json"
362
+ written = _merge_mcp_servers(path, _MCP_ENTRY_STDIO, force=force)
363
+ return path, written
364
+
365
+ if target == "vscode":
366
+ path = root / ".vscode" / "mcp.json"
367
+ written = _merge_vscode_mcp(path, force=force)
368
+ return path, written
369
+
370
+ if target == "zed":
371
+ # prefer project-local; Zed picks it up automatically
372
+ path = root / ".zed" / "settings.json"
373
+ written = _merge_zed_settings(path, force=force)
374
+ return path, written
375
+
376
+ if target == "claude-desktop":
377
+ maybe_path = _claude_desktop_config_path()
378
+ if maybe_path is None:
379
+ raise RuntimeError("Cannot determine Claude Desktop config path on this platform.")
380
+ written = _merge_mcp_servers(maybe_path, _MCP_ENTRY_STDIO, force=force)
381
+ return maybe_path, written
382
+
383
+ raise ValueError(f"unknown MCP target: {target!r}. Valid: {MCP_TARGETS}")
384
+
385
+
386
+ def enabled_hooks(root: Path) -> list[str]:
387
+ path = root / SETTINGS_REL
388
+ if not path.exists():
389
+ return []
390
+ try:
391
+ settings = json.loads(path.read_text(encoding="utf-8"))
392
+ except (OSError, json.JSONDecodeError):
393
+ return []
394
+ return [
395
+ hk.get("command", "")
396
+ for entry in settings.get("hooks", {}).get("PostToolUse", [])
397
+ for hk in entry.get("hooks", [])
398
+ if _HOOK_MARKER in hk.get("command", "")
399
+ ]
@@ -0,0 +1,158 @@
1
+ """Shared service layer for the CLI and the MCP server.
2
+
3
+ Both surfaces drive the same retrieval/storage code; this module owns the
4
+ pieces that used to be duplicated and drift apart: the cache-path formula,
5
+ db/config resolution, the explain query rewrite, vector-aware search
6
+ sessions, and the stats payload (including the per-language graph tier the
7
+ skill keys on).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sqlite3
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any, Callable, Optional, Union
16
+
17
+ if TYPE_CHECKING:
18
+ from .config import Config
19
+
20
+ _EXPLAIN_HINTS = ("how", "architecture", "overview")
21
+
22
+
23
+ def cache_dir_for(cfg: "Config") -> Path:
24
+ """Per-project cache directory (index DB, graph exports, skill backups)."""
25
+ return Path(cfg.root) / ".claude" / "cache" / "codebase-index"
26
+
27
+
28
+ def db_path_for(cfg: "Config") -> Path:
29
+ """Index location for a resolved config; the CBX_DB_PATH env var overrides."""
30
+ override = os.environ.get("CBX_DB_PATH")
31
+ if override:
32
+ return Path(override)
33
+ return cache_dir_for(cfg) / "index.sqlite"
34
+
35
+
36
+ def resolve_db(root: Optional[Union[Path, str]] = None) -> tuple[Path, "Config"]:
37
+ """Resolve (db_path, config) the same way on every surface.
38
+
39
+ The config loads from *root* (CLI --root, MCP CBX_ROOT, else upward
40
+ discovery from cwd); CBX_DB_PATH overrides only the index location.
41
+ """
42
+ from .config import load
43
+
44
+ cfg = load(Path(root) if root is not None else None)
45
+ return db_path_for(cfg), cfg
46
+
47
+
48
+ def search_backend(cfg: "Config", warn: Callable[[str], None]) -> Any:
49
+ """Embedding backend for query-time vector search.
50
+
51
+ Returns a NoopBackend (enabled=False) when embeddings are off, so callers
52
+ can branch on `backend.enabled`. Network/external gating is enforced by
53
+ resolve_backend (SECURITY.md §4).
54
+ """
55
+ from .embeddings.backend import resolve_backend
56
+
57
+ return resolve_backend(cfg, warn=warn)
58
+
59
+
60
+ def normalize_explain_query(query: str) -> str:
61
+ """Rewrite a bare topic into a how-does-X-work question for intent detection."""
62
+ if any(w in query.lower() for w in _EXPLAIN_HINTS):
63
+ return query
64
+ return f"how does {query} work"
65
+
66
+
67
+ def search_payload(
68
+ db_path: Path,
69
+ cfg: "Config",
70
+ query: str,
71
+ *,
72
+ mode: str = "hybrid",
73
+ limit: int = 10,
74
+ offset: int = 0,
75
+ token_budget: int = 1500,
76
+ no_fallback: bool = False,
77
+ backend: Any = None,
78
+ raw: bool = False,
79
+ ) -> dict:
80
+ """One search session: open the DB (vector-enabled when the backend is
81
+ live), run retrieval, return the payload dict both surfaces serialize.
82
+
83
+ ``raw`` forces full snippets; otherwise snippets are skeletonized when
84
+ ``cfg.retrieval.compact_snippets`` is on (the default)."""
85
+ from .retrieval.pipeline import search as run_search
86
+ from .storage.db import Database
87
+
88
+ compact = cfg.retrieval.compact_snippets and not raw
89
+ with Database(db_path) as db:
90
+ if backend is not None and getattr(backend, "enabled", False):
91
+ db.enable_vectors()
92
+ return run_search(
93
+ db.conn,
94
+ query,
95
+ mode=mode,
96
+ limit=limit,
97
+ offset=offset,
98
+ token_budget=token_budget,
99
+ no_fallback=no_fallback,
100
+ backend=backend,
101
+ root=Path(cfg.root),
102
+ config=cfg,
103
+ compact=compact,
104
+ compact_min_reduction=cfg.retrieval.compact_min_reduction,
105
+ )
106
+
107
+
108
+ def architecture_payload(db_path: Path, cfg: "Config") -> dict[str, Any]:
109
+ """The cached architecture analytics (communities / god nodes / surprising /
110
+ questions) plus index freshness — the payload both CLI and MCP serialize.
111
+
112
+ Returns ``available: False`` when no analysis is cached (an index built before
113
+ this feature, or an empty graph); the caller tells the user to reindex.
114
+ """
115
+ from .graph import analysis
116
+ from .indexer.freshness import compute_freshness
117
+ from .storage.db import Database
118
+
119
+ with Database(db_path) as db:
120
+ fresh = compute_freshness(db.conn, Path(cfg.root), cfg)
121
+ summary = analysis.load_analysis(db.conn)
122
+ if summary is None:
123
+ return {
124
+ "exists": True,
125
+ "available": False,
126
+ "reason": (
127
+ "No architecture analysis cached. Rebuild the index "
128
+ "(`codebase-index index`) to compute it."
129
+ ),
130
+ "index": fresh.model_dump(),
131
+ }
132
+ return {"exists": True, "available": True, "index": fresh.model_dump(), **summary}
133
+
134
+
135
+ def stats_payload(conn: sqlite3.Connection) -> dict[str, Any]:
136
+ """Index size, freshness, and per-language coverage with the graph tier."""
137
+ from .parsers.languages import has_full_graph
138
+ from .storage import repo
139
+
140
+ coverage = [
141
+ {
142
+ "lang": r["lang"],
143
+ "files": r["files"],
144
+ "symbols": r["symbols"],
145
+ # Tier-A languages get import/inheritance edges; Tier-B is
146
+ # symbols-only, so refs/impact are partial for them.
147
+ "graph": "full" if has_full_graph(r["lang"]) else "partial",
148
+ }
149
+ for r in repo.treesitter_coverage(conn)
150
+ ]
151
+ return {
152
+ "files": repo.count_files(conn),
153
+ "symbols": repo.count_symbols(conn),
154
+ "built_at": repo.get_meta(conn, "built_at"),
155
+ "head_commit": repo.get_meta(conn, "head_commit"),
156
+ "treesitter_coverage": coverage,
157
+ "exists": True,
158
+ }