codecortex 0.8.1__tar.gz → 0.8.3__tar.gz
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.
- {codecortex-0.8.1/src/codecortex.egg-info → codecortex-0.8.3}/PKG-INFO +1 -1
- {codecortex-0.8.1 → codecortex-0.8.3/src/codecortex.egg-info}/PKG-INFO +1 -1
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codecortex.egg-info/SOURCES.txt +2 -0
- codecortex-0.8.3/src/codeintel/__init__.py +1 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/__main__.py +27 -22
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/indexer.py +5 -0
- codecortex-0.8.3/src/codeintel/installer.py +120 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/onboarding.py +1 -1
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/providers/graph.py +8 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/providers/semantic.py +16 -10
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/reindexer.py +3 -3
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/reset.py +37 -9
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/semantic_db.py +84 -14
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/server.py +9 -1
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_doctor.py +3 -3
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_e2e.py +1 -2
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_graph_provider.py +13 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_hardening.py +1 -1
- codecortex-0.8.3/tests/test_installer.py +97 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_integration.py +2 -2
- codecortex-0.8.3/tests/test_model_dimension.py +148 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_onboarding.py +1 -1
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_semantic_provider.py +4 -4
- codecortex-0.8.1/src/codeintel/__init__.py +0 -1
- codecortex-0.8.1/src/codeintel/installer.py +0 -113
- {codecortex-0.8.1 → codecortex-0.8.3}/LICENSE +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/README.md +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/pyproject.toml +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/setup.cfg +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codecortex.egg-info/dependency_links.txt +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codecortex.egg-info/entry_points.txt +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codecortex.egg-info/requires.txt +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codecortex.egg-info/top_level.txt +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/auth.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/cache.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/config.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/doctor.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/gateway.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/http_server.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/injector.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/logconfig.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/mapper.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/metrics.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/policy.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/provider.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/providers/__init__.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/providers/lsp.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/providers/none.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/searcher.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/term.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_cache.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_chunking.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_config.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_enterprise.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_gateway.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_graph_real.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_graph_stdin.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_http_auth.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_http_server.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_lsp_provider.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_lsp_real.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_mapper.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_never_raise.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_rbac.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_reindexer.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_rerank.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_reset.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_term.py +0 -0
- {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_treesitter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.3
|
|
4
4
|
Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
|
|
5
5
|
Author: Shammai Hamilton
|
|
6
6
|
License-Expression: MIT
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.3
|
|
4
4
|
Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
|
|
5
5
|
Author: Shammai Hamilton
|
|
6
6
|
License-Expression: MIT
|
|
@@ -48,10 +48,12 @@ tests/test_graph_stdin.py
|
|
|
48
48
|
tests/test_hardening.py
|
|
49
49
|
tests/test_http_auth.py
|
|
50
50
|
tests/test_http_server.py
|
|
51
|
+
tests/test_installer.py
|
|
51
52
|
tests/test_integration.py
|
|
52
53
|
tests/test_lsp_provider.py
|
|
53
54
|
tests/test_lsp_real.py
|
|
54
55
|
tests/test_mapper.py
|
|
56
|
+
tests/test_model_dimension.py
|
|
55
57
|
tests/test_never_raise.py
|
|
56
58
|
tests/test_onboarding.py
|
|
57
59
|
tests/test_rbac.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.8.3"
|
|
@@ -110,28 +110,32 @@ def main() -> None:
|
|
|
110
110
|
from codeintel.semantic_db import SemanticDb, default_db_path
|
|
111
111
|
|
|
112
112
|
project_root = args.project_root or os.getcwd()
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
db_path = default_db_path()
|
|
116
|
-
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
117
|
-
db = SemanticDb(db_path)
|
|
113
|
+
# Wrap the whole semantic pass so a setup failure (e.g. an unresolvable home dir →
|
|
114
|
+
# Path.home() raising) degrades with a message, like every other subcommand, not a traceback.
|
|
118
115
|
try:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
116
|
+
cfg = load_config(project_root)
|
|
117
|
+
db_path = default_db_path(str(cfg.get("model") or ""))
|
|
118
|
+
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
119
|
+
db = SemanticDb(db_path)
|
|
120
|
+
try:
|
|
121
|
+
db.init()
|
|
122
|
+
count = Indexer(
|
|
123
|
+
db,
|
|
124
|
+
model_name=str(cfg.get("model") or "BAAI/bge-small-en-v1.5"),
|
|
125
|
+
window=int(cfg.get("window", 20)),
|
|
126
|
+
stride=int(cfg.get("stride", 10)),
|
|
127
|
+
max_chunks=int(cfg.get("max_chunks", 500)),
|
|
128
|
+
max_total_chunks=int(cfg.get("max_total_chunks", 100000)),
|
|
129
|
+
chunk_strategy=str(cfg.get("chunk_strategy", "syntax")),
|
|
130
|
+
).index(project_root)
|
|
131
|
+
if count > 0:
|
|
132
|
+
print(f"Indexed {count} chunks")
|
|
133
|
+
else:
|
|
134
|
+
print("Nothing new to index")
|
|
135
|
+
finally:
|
|
136
|
+
db.close()
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
print(f"index failed: {exc}")
|
|
135
139
|
|
|
136
140
|
# best-effort graph reindex
|
|
137
141
|
import shutil
|
|
@@ -242,8 +246,9 @@ def main() -> None:
|
|
|
242
246
|
state = "available" if available else "unavailable"
|
|
243
247
|
print(f" {engine:<10} {state}")
|
|
244
248
|
|
|
249
|
+
from codeintel.config import load_config
|
|
245
250
|
from codeintel.semantic_db import default_db_path
|
|
246
|
-
db_path = default_db_path()
|
|
251
|
+
db_path = default_db_path(str(load_config(project_root).get("model") or ""))
|
|
247
252
|
if os.path.exists(db_path):
|
|
248
253
|
import datetime
|
|
249
254
|
mtime = os.path.getmtime(db_path)
|
|
@@ -519,6 +519,11 @@ class Indexer:
|
|
|
519
519
|
break
|
|
520
520
|
try:
|
|
521
521
|
vec = embeddings[j]
|
|
522
|
+
# Create code_embeddings lazily, sized to this vector (self-dimensioning). A
|
|
523
|
+
# returned dim != len(vec) means the file already holds a different dimension
|
|
524
|
+
# (only reachable on a DEFAULT_MODEL size bump) — skip rather than corrupt/mix.
|
|
525
|
+
if self.db.ensure_embeddings_table(len(vec)) != len(vec):
|
|
526
|
+
continue
|
|
522
527
|
vec_bytes = struct.pack(f"{len(vec)}f", *vec)
|
|
523
528
|
# sqlite-vec's vec0 virtual table does NOT honor INSERT OR REPLACE — it raises
|
|
524
529
|
# UNIQUE on an existing chunk_id instead of replacing. So re-embedding a chunk
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
|
|
7
|
+
_AGENTS = ["claude", "codex", "gemini", "zed"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _atomic_write_text(path: pathlib.Path, text: str) -> None:
|
|
11
|
+
"""Write via a sibling temp file + os.replace so an interrupted write can never truncate
|
|
12
|
+
the user's existing agent config (which holds unrelated settings) to a partial/empty file.
|
|
13
|
+
The temp lives in the same directory as ``path`` so the replace stays on one filesystem."""
|
|
14
|
+
tmp = path.with_name(path.name + ".codeintel.tmp")
|
|
15
|
+
tmp.write_text(text, encoding="utf-8")
|
|
16
|
+
os.replace(tmp, path)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Per-agent registration recipe. Most agents take a Claude-style JSON block; Codex is different —
|
|
20
|
+
# its CLI reads MCP servers from ``~/.codex/config.toml`` as ``[mcp_servers.<name>]`` TOML tables,
|
|
21
|
+
# NOT a JSON ``mcpServers`` map in config.json (writing the latter registers nothing).
|
|
22
|
+
_CONFIG: dict[str, dict] = {
|
|
23
|
+
"claude": {
|
|
24
|
+
"format": "json",
|
|
25
|
+
"path": "~/.claude/settings.json",
|
|
26
|
+
"key": ["mcpServers", "codeintel"],
|
|
27
|
+
"value": {"command": "codeintel", "args": ["serve"]},
|
|
28
|
+
},
|
|
29
|
+
"codex": {
|
|
30
|
+
"format": "toml-mcp",
|
|
31
|
+
"path": "~/.codex/config.toml",
|
|
32
|
+
"table": "[mcp_servers.codeintel]",
|
|
33
|
+
"block": '[mcp_servers.codeintel]\ncommand = "codeintel"\nargs = ["serve"]\n',
|
|
34
|
+
},
|
|
35
|
+
"gemini": {
|
|
36
|
+
"format": "json",
|
|
37
|
+
"path": "~/.gemini/settings.json",
|
|
38
|
+
"key": ["mcpServers", "codeintel"],
|
|
39
|
+
"value": {"command": "codeintel", "args": ["serve"]},
|
|
40
|
+
},
|
|
41
|
+
"zed": {
|
|
42
|
+
"format": "json",
|
|
43
|
+
"path": "~/.config/zed/settings.json",
|
|
44
|
+
"key": ["context_servers", "codeintel"],
|
|
45
|
+
"value": {"command": {"path": "codeintel", "args": ["serve"]}},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_nested(data: dict, keys: list[str]):
|
|
51
|
+
node = data
|
|
52
|
+
for k in keys:
|
|
53
|
+
if not isinstance(node, dict) or k not in node:
|
|
54
|
+
return None
|
|
55
|
+
node = node[k]
|
|
56
|
+
return node
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _set_nested(data: dict, keys: list[str], value) -> None:
|
|
60
|
+
node = data
|
|
61
|
+
for k in keys[:-1]:
|
|
62
|
+
if k not in node or not isinstance(node[k], dict):
|
|
63
|
+
node[k] = {}
|
|
64
|
+
node = node[k]
|
|
65
|
+
node[keys[-1]] = value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Installer:
|
|
69
|
+
def register(self, agent: str) -> dict:
|
|
70
|
+
spec = _CONFIG.get(agent)
|
|
71
|
+
if spec is None:
|
|
72
|
+
return self._result(agent, "", False, "failed", f"unknown agent '{agent}'")
|
|
73
|
+
config_path = pathlib.Path(spec["path"]).expanduser()
|
|
74
|
+
try:
|
|
75
|
+
if spec.get("format") == "toml-mcp":
|
|
76
|
+
return self._register_toml(agent, config_path, spec)
|
|
77
|
+
return self._register_json(agent, config_path, spec)
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
return self._result(agent, str(config_path), False, "failed", str(exc))
|
|
80
|
+
|
|
81
|
+
def _register_json(self, agent: str, config_path: pathlib.Path, spec: dict) -> dict:
|
|
82
|
+
if config_path.exists():
|
|
83
|
+
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
84
|
+
if not isinstance(data, dict):
|
|
85
|
+
data = {}
|
|
86
|
+
else:
|
|
87
|
+
data = {}
|
|
88
|
+
|
|
89
|
+
if _get_nested(data, spec["key"]) == spec["value"]:
|
|
90
|
+
return self._result(agent, str(config_path), True, "already")
|
|
91
|
+
|
|
92
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
_set_nested(data, spec["key"], spec["value"])
|
|
94
|
+
_atomic_write_text(config_path, json.dumps(data, indent=2))
|
|
95
|
+
return self._result(agent, str(config_path), True, "registered")
|
|
96
|
+
|
|
97
|
+
def _register_toml(self, agent: str, config_path: pathlib.Path, spec: dict) -> dict:
|
|
98
|
+
"""Codex: append an ``[mcp_servers.codeintel]`` table to config.toml, preserving everything
|
|
99
|
+
already there (other servers, project trust levels, hooks). Text-based on purpose — it must
|
|
100
|
+
not reformat or risk corrupting a config the user hand-edits, and it stays idempotent by
|
|
101
|
+
checking for the table header. If a codeintel entry already exists it is left untouched."""
|
|
102
|
+
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
|
|
103
|
+
if spec["table"] in existing:
|
|
104
|
+
return self._result(agent, str(config_path), True, "already")
|
|
105
|
+
|
|
106
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
if existing == "":
|
|
108
|
+
new_text = spec["block"]
|
|
109
|
+
else:
|
|
110
|
+
prefix = existing if existing.endswith("\n") else existing + "\n"
|
|
111
|
+
new_text = prefix + "\n" + spec["block"] # blank line before the new table
|
|
112
|
+
_atomic_write_text(config_path, new_text)
|
|
113
|
+
return self._result(agent, str(config_path), True, "registered")
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _result(agent: str, path: str, ok: bool, action: str, reason: str = "") -> dict:
|
|
117
|
+
return {"agent": agent, "path": path, "ok": ok, "action": action, "reason": reason}
|
|
118
|
+
|
|
119
|
+
def register_all(self) -> list[dict]:
|
|
120
|
+
return [self.register(agent) for agent in _AGENTS]
|
|
@@ -63,7 +63,7 @@ def _bounded_index(project_root: str, *, timeout_s: float, out) -> dict:
|
|
|
63
63
|
def _work() -> None:
|
|
64
64
|
try:
|
|
65
65
|
cfg = load_config(project_root)
|
|
66
|
-
db_path = default_db_path()
|
|
66
|
+
db_path = default_db_path(str(cfg.get("model") or ""))
|
|
67
67
|
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
68
68
|
db = SemanticDb(db_path)
|
|
69
69
|
try:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
import shutil
|
|
5
6
|
import subprocess
|
|
6
7
|
import threading
|
|
@@ -107,6 +108,13 @@ class GraphProvider:
|
|
|
107
108
|
older/mocked shape — accept both. Prefer an exact ``root_path`` match; otherwise the
|
|
108
109
|
LONGEST prefix match (so ``.../project/codeintel`` resolves to codeintel, not its
|
|
109
110
|
parent ``.../project``). Pure + static so ``_resolve_project`` and ``probe`` share it."""
|
|
111
|
+
# Normalize the input to an absolute realpath: the backend stores absolute root_paths, so a
|
|
112
|
+
# relative ``project_root`` (e.g. `codeintel map .` passing ".") would otherwise never match
|
|
113
|
+
# — the bug where the map/query silently reported "not indexed" from inside the repo.
|
|
114
|
+
try:
|
|
115
|
+
project_root = os.path.realpath(project_root)
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
110
118
|
entries = raw.get("projects", []) if isinstance(raw, dict) else raw
|
|
111
119
|
if not isinstance(entries, list):
|
|
112
120
|
return None
|
|
@@ -12,8 +12,6 @@ try:
|
|
|
12
12
|
except ImportError:
|
|
13
13
|
_DEPS_OK = False
|
|
14
14
|
|
|
15
|
-
_DB_PATH = pathlib.Path.home() / ".codeintel" / "semantic.db"
|
|
16
|
-
|
|
17
15
|
|
|
18
16
|
class SemanticProvider:
|
|
19
17
|
"""Real semantic search provider backed by SemanticDb and Searcher."""
|
|
@@ -23,9 +21,10 @@ class SemanticProvider:
|
|
|
23
21
|
return _DEPS_OK
|
|
24
22
|
|
|
25
23
|
def probe(self, project_root: str) -> dict:
|
|
26
|
-
"""Never-raise health check for the doctor. READ-ONLY
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
"""Never-raise health check for the doctor. READ-ONLY: it opens the db read-only and counts
|
|
25
|
+
this repo's chunks — it must NOT call SemanticDb.init() (a schema write) or LOAD fastembed.
|
|
26
|
+
It does resolve the project's ``model`` *name* (a cheap config read, no model load) to pick
|
|
27
|
+
the per-model cache file. ``repo_indexed`` is project-scoped (mirrors Searcher.has_index)."""
|
|
29
28
|
if not self.available:
|
|
30
29
|
return {
|
|
31
30
|
"installed": False, "runnable": False, "repo_indexed": False,
|
|
@@ -36,8 +35,10 @@ class SemanticProvider:
|
|
|
36
35
|
import sqlite3
|
|
37
36
|
|
|
38
37
|
try:
|
|
38
|
+
from codeintel.config import load_config
|
|
39
39
|
from codeintel.semantic_db import default_db_path
|
|
40
|
-
|
|
40
|
+
model = str(load_config(project_root).get("model") or "")
|
|
41
|
+
db_path = default_db_path(model)
|
|
41
42
|
except Exception:
|
|
42
43
|
db_path = ""
|
|
43
44
|
if not db_path or not os.path.exists(db_path):
|
|
@@ -59,8 +60,8 @@ class SemanticProvider:
|
|
|
59
60
|
except Exception as exc:
|
|
60
61
|
return {
|
|
61
62
|
"installed": True, "runnable": False, "repo_indexed": False,
|
|
62
|
-
"detail": f"semantic
|
|
63
|
-
"remediation": "
|
|
63
|
+
"detail": f"semantic cache present but unreadable ({type(exc).__name__})",
|
|
64
|
+
"remediation": f"codeintel reset {project_root} && codeintel index {project_root}",
|
|
64
65
|
}
|
|
65
66
|
if count > 0:
|
|
66
67
|
return {
|
|
@@ -95,11 +96,16 @@ class SemanticProvider:
|
|
|
95
96
|
from codeintel.indexer import Indexer
|
|
96
97
|
from codeintel.searcher import Searcher
|
|
97
98
|
|
|
99
|
+
from codeintel.semantic_db import default_db_path
|
|
100
|
+
|
|
98
101
|
cfg = load_config(project_root)
|
|
99
102
|
model = str(cfg.get("model") or "BAAI/bge-small-en-v1.5")
|
|
100
103
|
|
|
101
|
-
|
|
102
|
-
|
|
104
|
+
# Per-model cache file: index and search for this repo use the SAME model → same file,
|
|
105
|
+
# so a repo configured with a different model can never corrupt or wipe another's rows.
|
|
106
|
+
db_path = default_db_path(model)
|
|
107
|
+
pathlib.Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
db = SemanticDb(db_path)
|
|
103
109
|
db.init()
|
|
104
110
|
|
|
105
111
|
searcher = Searcher(db, model_name=model)
|
|
@@ -102,11 +102,11 @@ class Reindexer:
|
|
|
102
102
|
from codeintel.semantic_db import SemanticDb, default_db_path
|
|
103
103
|
from codeintel.indexer import Indexer
|
|
104
104
|
|
|
105
|
-
# Same per-
|
|
105
|
+
# Same per-model cache file the SemanticProvider reads — index and search must never diverge
|
|
106
106
|
# onto different files. Honor the project's config so the background pass indexes exactly
|
|
107
|
-
# like the inline and CLI paths (same model, window/stride,
|
|
107
|
+
# like the inline and CLI paths (same model → same file, plus window/stride, ceilings).
|
|
108
108
|
cfg = load_config(project_root)
|
|
109
|
-
db_path = default_db_path()
|
|
109
|
+
db_path = default_db_path(str(cfg.get("model") or ""))
|
|
110
110
|
pathlib.Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
111
111
|
db = SemanticDb(db_path)
|
|
112
112
|
try:
|
|
@@ -5,6 +5,7 @@ module is pure (dry-run by default via ``apply=False``).
|
|
|
5
5
|
"""
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import glob
|
|
8
9
|
import os
|
|
9
10
|
import sqlite3
|
|
10
11
|
|
|
@@ -13,6 +14,16 @@ import sqlite_vec
|
|
|
13
14
|
from codeintel.semantic_db import default_db_path
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
def _cache_files() -> list[str]:
|
|
18
|
+
"""Every per-model cache file (``semantic.db`` + ``semantic-<hash>.db``). Reset must sweep all
|
|
19
|
+
of them: a repo's rows can live in any model's file, and a model switch leaves orphans behind."""
|
|
20
|
+
base = os.path.dirname(default_db_path())
|
|
21
|
+
try:
|
|
22
|
+
return sorted(glob.glob(os.path.join(base, "semantic*.db")))
|
|
23
|
+
except Exception:
|
|
24
|
+
return []
|
|
25
|
+
|
|
26
|
+
|
|
16
27
|
def _reset_scoped(project_root: str, path: str, apply: bool) -> dict:
|
|
17
28
|
real = os.path.realpath(str(project_root))
|
|
18
29
|
if not os.path.exists(path):
|
|
@@ -37,9 +48,12 @@ def _reset_scoped(project_root: str, path: str, apply: bool) -> dict:
|
|
|
37
48
|
count = int(row[0]) if row else 0
|
|
38
49
|
|
|
39
50
|
if apply:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
try:
|
|
52
|
+
conn.execute(
|
|
53
|
+
"DELETE FROM code_embeddings WHERE chunk_id IN "
|
|
54
|
+
"(SELECT chunk_id FROM chunk_hashes WHERE project_root=?)", (real,))
|
|
55
|
+
except Exception:
|
|
56
|
+
pass # code_embeddings is created lazily at first embed — may not exist yet
|
|
43
57
|
conn.execute("DELETE FROM chunk_hashes WHERE project_root=?", (real,))
|
|
44
58
|
conn.commit()
|
|
45
59
|
|
|
@@ -88,13 +102,27 @@ def run_reset(
|
|
|
88
102
|
apply: bool = False,
|
|
89
103
|
db_path: str | None = None,
|
|
90
104
|
) -> dict:
|
|
91
|
-
"""Drop indexed rows for ``project_root`` (or, with ``all_projects``, remove the whole
|
|
92
|
-
|
|
93
|
-
delete nothing. Never raises.
|
|
105
|
+
"""Drop indexed rows for ``project_root`` (or, with ``all_projects``, remove the whole cache —
|
|
106
|
+
every per-model db file plus their -wal/-shm siblings). ``apply=False`` is a dry-run: count
|
|
107
|
+
only, delete nothing. Never raises.
|
|
108
|
+
|
|
109
|
+
An explicit ``db_path`` operates on exactly that one file (the test seam / a targeted reset);
|
|
110
|
+
otherwise reset sweeps EVERY per-model cache file, so a repo's rows are cleared no matter which
|
|
111
|
+
model's file they landed in, and model-switch orphans are reclaimed."""
|
|
94
112
|
try:
|
|
95
|
-
|
|
113
|
+
if db_path is not None:
|
|
114
|
+
return _reset_all(db_path, apply) if all_projects else _reset_scoped(project_root, db_path, apply)
|
|
115
|
+
|
|
116
|
+
files = _cache_files() or [default_db_path()]
|
|
96
117
|
if all_projects:
|
|
97
|
-
|
|
98
|
-
|
|
118
|
+
count = sum(_reset_all(p, apply)["count"] for p in files)
|
|
119
|
+
verb = "removed" if apply else "would remove"
|
|
120
|
+
return {"ok": True, "mode": "all", "target": "ALL", "count": count,
|
|
121
|
+
"applied": bool(apply), "detail": f"{verb} {count} index file(s) across all models"}
|
|
122
|
+
real = os.path.realpath(str(project_root))
|
|
123
|
+
count = sum(_reset_scoped(project_root, p, apply)["count"] for p in files)
|
|
124
|
+
verb = "removed" if apply else "found"
|
|
125
|
+
return {"ok": True, "mode": "scoped", "target": real, "count": count, "applied": bool(apply),
|
|
126
|
+
"detail": f"{verb} {count} indexed chunk(s) for this project"}
|
|
99
127
|
except Exception as exc:
|
|
100
128
|
return {"ok": True, "applied": apply, "detail": f"reset-error: {type(exc).__name__}: {exc}"}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import logging
|
|
4
5
|
import pathlib
|
|
6
|
+
import re
|
|
5
7
|
import sqlite3
|
|
6
8
|
|
|
7
9
|
import sqlite_vec
|
|
@@ -11,23 +13,45 @@ logger = logging.getLogger(__name__)
|
|
|
11
13
|
DEFAULT_MODEL = "BAAI/bge-small-en-v1.5"
|
|
12
14
|
|
|
13
15
|
|
|
14
|
-
def
|
|
15
|
-
"""The
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
def _base_dir() -> pathlib.Path:
|
|
17
|
+
"""The per-machine cache directory. A single seam so tests can redirect every model's db
|
|
18
|
+
file at once (patch this, not each computed path)."""
|
|
19
|
+
return pathlib.Path.home() / ".codeintel"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _model_slug(model: str) -> str:
|
|
23
|
+
# errors="replace" keeps this total (default_db_path promises it) even for a pathological
|
|
24
|
+
# model string with unpaired surrogates — unreachable via config, but the docstring says total.
|
|
25
|
+
return hashlib.sha256(model.strip().encode("utf-8", "replace")).hexdigest()[:12]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def default_db_path(model: str | None = None) -> str:
|
|
29
|
+
"""The per-machine semantic cache file for a given embedding ``model``. A sqlite-vec vec0 table
|
|
30
|
+
is single-dimension and different models' vectors are incompatible, so each model gets its OWN
|
|
31
|
+
file — different-model repos then coexist as separate files and can never corrupt or wipe each
|
|
32
|
+
other. The default model (and ``None``) map to the legacy ``semantic.db`` (zero migration); any
|
|
33
|
+
other model maps to ``semantic-<hash(model)>.db``.
|
|
34
|
+
|
|
35
|
+
Index and search for one repo MUST pass the same model → same file. Rows are still partitioned
|
|
36
|
+
by ``project_root`` WITHIN a shared-model file. Pure + total: any string yields a filename."""
|
|
37
|
+
base = _base_dir()
|
|
38
|
+
m = (model or "").strip()
|
|
39
|
+
if not m or m == DEFAULT_MODEL:
|
|
40
|
+
return str(base / "semantic.db")
|
|
41
|
+
return str(base / f"semantic-{_model_slug(m)}.db")
|
|
21
42
|
|
|
22
43
|
|
|
23
44
|
class SemanticDb:
|
|
24
45
|
"""DB layer: opens a SQLite connection, loads sqlite-vec, and owns schema creation."""
|
|
25
46
|
|
|
26
|
-
|
|
47
|
+
_DIM_RE = re.compile(r"float\s*\[\s*(\d+)\s*\]", re.IGNORECASE)
|
|
27
48
|
|
|
28
49
|
def __init__(self, db_path: str) -> None:
|
|
29
50
|
self.db_path = db_path
|
|
30
51
|
self._conn: sqlite3.Connection | None = None
|
|
52
|
+
# The vec0 embedding dimension, discovered lazily from the table / the first real vector
|
|
53
|
+
# (see ensure_embeddings_table) rather than hardcoded — so any model's size just works.
|
|
54
|
+
self.dimension: int | None = None
|
|
31
55
|
|
|
32
56
|
def conn(self) -> sqlite3.Connection:
|
|
33
57
|
if self._conn is None:
|
|
@@ -68,12 +92,10 @@ class SemanticDb:
|
|
|
68
92
|
except Exception:
|
|
69
93
|
pass
|
|
70
94
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
);
|
|
76
|
-
|
|
95
|
+
# chunk_hashes + indexes are created now; code_embeddings is created LAZILY at the first
|
|
96
|
+
# write, sized to the embedding model's real vector length (ensure_embeddings_table) — a
|
|
97
|
+
# vec0 table is single-dimension, so it can't be created before the model's size is known.
|
|
98
|
+
c.executescript("""
|
|
77
99
|
CREATE TABLE IF NOT EXISTS chunk_hashes (
|
|
78
100
|
chunk_id TEXT PRIMARY KEY,
|
|
79
101
|
project_root TEXT NOT NULL,
|
|
@@ -94,6 +116,54 @@ class SemanticDb:
|
|
|
94
116
|
""")
|
|
95
117
|
c.commit()
|
|
96
118
|
|
|
119
|
+
def _table_dim(self) -> int | None:
|
|
120
|
+
"""The existing code_embeddings vec0 dimension from the live schema (``FLOAT[N]``), or None
|
|
121
|
+
if the table is absent / unparseable."""
|
|
122
|
+
try:
|
|
123
|
+
row = self.conn().execute(
|
|
124
|
+
"SELECT sql FROM sqlite_master WHERE name = 'code_embeddings'"
|
|
125
|
+
).fetchone()
|
|
126
|
+
if not row or not row[0]:
|
|
127
|
+
return None
|
|
128
|
+
m = self._DIM_RE.search(str(row[0]))
|
|
129
|
+
return int(m.group(1)) if m else None
|
|
130
|
+
except Exception:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
def ensure_embeddings_table(self, dim: int) -> int | None:
|
|
134
|
+
"""Ensure ``code_embeddings`` exists sized to ``dim`` (the embedding's true length). Returns
|
|
135
|
+
the table dimension (== dim) on success, or ``None`` when it already exists at a DIFFERENT
|
|
136
|
+
dimension — the caller then skips the write, never mixing dimensions and never wiping data.
|
|
137
|
+
The table self-dimensions from the real vector, so any model (incl. future/unknown ones)
|
|
138
|
+
just works. Never raises.
|
|
139
|
+
|
|
140
|
+
A dimension mismatch is only reachable on the default-model file when a release bumps
|
|
141
|
+
``DEFAULT_MODEL`` to a new-sized model (a non-default file is keyed by model, so its dim is
|
|
142
|
+
fixed); that release directs the user to ``codeintel reset`` once. Non-destructive here."""
|
|
143
|
+
try:
|
|
144
|
+
dim = int(dim)
|
|
145
|
+
if self.dimension is None:
|
|
146
|
+
self.dimension = self._table_dim()
|
|
147
|
+
if self.dimension == dim:
|
|
148
|
+
return dim
|
|
149
|
+
if self.dimension is not None:
|
|
150
|
+
logger.warning(
|
|
151
|
+
"embedding dimension %d != cache dimension %d — skipping write; run "
|
|
152
|
+
"`codeintel reset` to rebuild the semantic index for the new model",
|
|
153
|
+
dim, self.dimension,
|
|
154
|
+
)
|
|
155
|
+
return None
|
|
156
|
+
self.conn().execute(
|
|
157
|
+
"CREATE VIRTUAL TABLE IF NOT EXISTS code_embeddings USING vec0("
|
|
158
|
+
f"chunk_id TEXT PRIMARY KEY, embedding FLOAT[{dim}])"
|
|
159
|
+
)
|
|
160
|
+
self.conn().commit()
|
|
161
|
+
self.dimension = dim
|
|
162
|
+
return dim
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
logger.warning("ensure_embeddings_table failed: %s", exc)
|
|
165
|
+
return None
|
|
166
|
+
|
|
97
167
|
def delete_file_orphans(
|
|
98
168
|
self, project_root: str, file_path: str, keep_ids: set[str]
|
|
99
169
|
) -> int:
|
|
@@ -122,10 +122,18 @@ def code_status_handler(args: dict) -> dict:
|
|
|
122
122
|
if semantic_available:
|
|
123
123
|
model = DEFAULT_MODEL
|
|
124
124
|
if project_root:
|
|
125
|
+
try: # report THIS repo's configured model, not the machine default
|
|
126
|
+
from codeintel.config import load_config
|
|
127
|
+
model = str(load_config(project_root).get("model") or DEFAULT_MODEL)
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
125
130
|
indexed = bool(SemanticProvider().probe(project_root).get("repo_indexed"))
|
|
126
131
|
else:
|
|
132
|
+
import glob
|
|
127
133
|
import os
|
|
128
|
-
|
|
134
|
+
# any per-model cache file (semantic.db / semantic-<hash>.db) counts as "indexed"
|
|
135
|
+
base = os.path.dirname(default_db_path())
|
|
136
|
+
indexed = bool(glob.glob(os.path.join(base, "semantic*.db")))
|
|
129
137
|
except Exception:
|
|
130
138
|
pass
|
|
131
139
|
|
|
@@ -157,7 +157,7 @@ def test_semantic_probe_real_db_indexed(tmp_path, monkeypatch):
|
|
|
157
157
|
repo = tmp_path / "repo"
|
|
158
158
|
repo.mkdir()
|
|
159
159
|
_make_db(db_path, os.path.realpath(str(repo)))
|
|
160
|
-
monkeypatch.setattr("codeintel.semantic_db.default_db_path", lambda: str(db_path))
|
|
160
|
+
monkeypatch.setattr("codeintel.semantic_db.default_db_path", lambda *a, **k: str(db_path))
|
|
161
161
|
|
|
162
162
|
r = SemanticProvider().probe(str(repo))
|
|
163
163
|
assert r["installed"] is True and r["runnable"] is True and r["repo_indexed"] is True
|
|
@@ -172,7 +172,7 @@ def test_semantic_probe_real_db_indexed(tmp_path, monkeypatch):
|
|
|
172
172
|
|
|
173
173
|
def test_semantic_probe_no_db(tmp_path, monkeypatch):
|
|
174
174
|
monkeypatch.setattr("codeintel.providers.semantic._DEPS_OK", True)
|
|
175
|
-
monkeypatch.setattr("codeintel.semantic_db.default_db_path", lambda: str(tmp_path / "missing.db"))
|
|
175
|
+
monkeypatch.setattr("codeintel.semantic_db.default_db_path", lambda *a, **k: str(tmp_path / "missing.db"))
|
|
176
176
|
r = SemanticProvider().probe(str(tmp_path))
|
|
177
177
|
assert r["runnable"] is True and r["repo_indexed"] is False
|
|
178
178
|
assert "codeintel index" in r["remediation"]
|
|
@@ -182,7 +182,7 @@ def test_semantic_probe_corrupt_db(tmp_path, monkeypatch):
|
|
|
182
182
|
monkeypatch.setattr("codeintel.providers.semantic._DEPS_OK", True)
|
|
183
183
|
db_path = tmp_path / "semantic.db"
|
|
184
184
|
db_path.write_bytes(b"this is not a sqlite database")
|
|
185
|
-
monkeypatch.setattr("codeintel.semantic_db.default_db_path", lambda: str(db_path))
|
|
185
|
+
monkeypatch.setattr("codeintel.semantic_db.default_db_path", lambda *a, **k: str(db_path))
|
|
186
186
|
r = SemanticProvider().probe(str(tmp_path))
|
|
187
187
|
assert r["installed"] is True and r["runnable"] is False # unreadable → not runnable
|
|
188
188
|
assert r["remediation"]
|
|
@@ -58,8 +58,7 @@ def fixture_repo(tmp_path):
|
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
def test_e2e_search_returns_ranked_result(fixture_repo, tmp_path, monkeypatch):
|
|
61
|
-
|
|
62
|
-
monkeypatch.setattr(_sem_mod, "_DB_PATH", db_path)
|
|
61
|
+
monkeypatch.setattr("codeintel.semantic_db._base_dir", lambda: tmp_path)
|
|
63
62
|
monkeypatch.setattr(_sem_mod, "_DEPS_OK", True)
|
|
64
63
|
|
|
65
64
|
with patch("fastembed.TextEmbedding", _FakeTextEmbedding):
|
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
"""GraphProvider tests: never-raise invariant and key behavioral guarantees."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import os
|
|
4
5
|
import subprocess
|
|
5
6
|
|
|
6
7
|
from codeintel.providers.graph import GraphProvider
|
|
7
8
|
from codeintel.server import code_status_handler
|
|
8
9
|
|
|
9
10
|
|
|
11
|
+
def test_match_project_resolves_a_relative_path(tmp_path, monkeypatch):
|
|
12
|
+
# The backend stores absolute root_paths, so a relative project_root (e.g. `codeintel map .`)
|
|
13
|
+
# must be normalized before matching — otherwise resolution fails from inside the repo and the
|
|
14
|
+
# map/graph query silently reports "not indexed" (the map-stub bug).
|
|
15
|
+
real = os.path.realpath(str(tmp_path))
|
|
16
|
+
raw = {"projects": [{"name": "myrepo", "root_path": real}]}
|
|
17
|
+
monkeypatch.chdir(tmp_path)
|
|
18
|
+
assert GraphProvider._match_project(raw, ".") == "myrepo" # relative resolves now
|
|
19
|
+
assert GraphProvider._match_project(raw, real) == "myrepo" # absolute still works
|
|
20
|
+
assert GraphProvider._match_project(raw, os.path.join(real, "src")) == "myrepo" # subdir prefix
|
|
21
|
+
|
|
22
|
+
|
|
10
23
|
# ---------------------------------------------------------------------------
|
|
11
24
|
# Group 1 — Never-raise: None args
|
|
12
25
|
# ---------------------------------------------------------------------------
|