codecortex 0.8.2__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.

Potentially problematic release.


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

Files changed (68) hide show
  1. {codecortex-0.8.2/src/codecortex.egg-info → codecortex-0.8.3}/PKG-INFO +1 -1
  2. {codecortex-0.8.2 → codecortex-0.8.3/src/codecortex.egg-info}/PKG-INFO +1 -1
  3. {codecortex-0.8.2 → codecortex-0.8.3}/src/codecortex.egg-info/SOURCES.txt +1 -0
  4. codecortex-0.8.3/src/codeintel/__init__.py +1 -0
  5. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/__main__.py +27 -22
  6. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/indexer.py +5 -0
  7. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/onboarding.py +1 -1
  8. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/providers/semantic.py +16 -10
  9. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/reindexer.py +3 -3
  10. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/reset.py +37 -9
  11. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/semantic_db.py +84 -14
  12. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/server.py +9 -1
  13. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_doctor.py +3 -3
  14. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_e2e.py +1 -2
  15. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_hardening.py +1 -1
  16. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_integration.py +2 -2
  17. codecortex-0.8.3/tests/test_model_dimension.py +148 -0
  18. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_onboarding.py +1 -1
  19. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_semantic_provider.py +4 -4
  20. codecortex-0.8.2/src/codeintel/__init__.py +0 -1
  21. {codecortex-0.8.2 → codecortex-0.8.3}/LICENSE +0 -0
  22. {codecortex-0.8.2 → codecortex-0.8.3}/README.md +0 -0
  23. {codecortex-0.8.2 → codecortex-0.8.3}/pyproject.toml +0 -0
  24. {codecortex-0.8.2 → codecortex-0.8.3}/setup.cfg +0 -0
  25. {codecortex-0.8.2 → codecortex-0.8.3}/src/codecortex.egg-info/dependency_links.txt +0 -0
  26. {codecortex-0.8.2 → codecortex-0.8.3}/src/codecortex.egg-info/entry_points.txt +0 -0
  27. {codecortex-0.8.2 → codecortex-0.8.3}/src/codecortex.egg-info/requires.txt +0 -0
  28. {codecortex-0.8.2 → codecortex-0.8.3}/src/codecortex.egg-info/top_level.txt +0 -0
  29. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/auth.py +0 -0
  30. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/cache.py +0 -0
  31. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/config.py +0 -0
  32. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/doctor.py +0 -0
  33. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/gateway.py +0 -0
  34. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/http_server.py +0 -0
  35. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/injector.py +0 -0
  36. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/installer.py +0 -0
  37. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/logconfig.py +0 -0
  38. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/mapper.py +0 -0
  39. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/metrics.py +0 -0
  40. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/policy.py +0 -0
  41. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/provider.py +0 -0
  42. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/providers/__init__.py +0 -0
  43. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/providers/graph.py +0 -0
  44. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/providers/lsp.py +0 -0
  45. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/providers/none.py +0 -0
  46. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/searcher.py +0 -0
  47. {codecortex-0.8.2 → codecortex-0.8.3}/src/codeintel/term.py +0 -0
  48. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_cache.py +0 -0
  49. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_chunking.py +0 -0
  50. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_config.py +0 -0
  51. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_enterprise.py +0 -0
  52. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_gateway.py +0 -0
  53. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_graph_provider.py +0 -0
  54. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_graph_real.py +0 -0
  55. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_graph_stdin.py +0 -0
  56. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_http_auth.py +0 -0
  57. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_http_server.py +0 -0
  58. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_installer.py +0 -0
  59. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_lsp_provider.py +0 -0
  60. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_lsp_real.py +0 -0
  61. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_mapper.py +0 -0
  62. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_never_raise.py +0 -0
  63. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_rbac.py +0 -0
  64. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_reindexer.py +0 -0
  65. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_rerank.py +0 -0
  66. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_reset.py +0 -0
  67. {codecortex-0.8.2 → codecortex-0.8.3}/tests/test_term.py +0 -0
  68. {codecortex-0.8.2 → 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.2
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.2
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
@@ -53,6 +53,7 @@ tests/test_integration.py
53
53
  tests/test_lsp_provider.py
54
54
  tests/test_lsp_real.py
55
55
  tests/test_mapper.py
56
+ tests/test_model_dimension.py
56
57
  tests/test_never_raise.py
57
58
  tests/test_onboarding.py
58
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
- cfg = load_config(project_root)
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
- db.init()
120
- count = Indexer(
121
- db,
122
- model_name=str(cfg.get("model") or "BAAI/bge-small-en-v1.5"),
123
- window=int(cfg.get("window", 20)),
124
- stride=int(cfg.get("stride", 10)),
125
- max_chunks=int(cfg.get("max_chunks", 500)),
126
- max_total_chunks=int(cfg.get("max_total_chunks", 100000)),
127
- chunk_strategy=str(cfg.get("chunk_strategy", "syntax")),
128
- ).index(project_root)
129
- if count > 0:
130
- print(f"Indexed {count} chunks")
131
- else:
132
- print("Nothing new to index")
133
- finally:
134
- db.close()
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
@@ -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:
@@ -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 and MODEL-FREE: it opens the db
27
- read-only and counts this repo's chunks — it must NOT call SemanticDb.init() (a schema
28
- write) or load fastembed. ``repo_indexed`` is project-scoped (mirrors Searcher.has_index)."""
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
- db_path = default_db_path()
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.db present but unreadable ({type(exc).__name__})",
63
- "remediation": "rm ~/.codeintel/semantic.db && codeintel index <root>",
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
- _DB_PATH.parent.mkdir(parents=True, exist_ok=True)
102
- db = SemanticDb(str(_DB_PATH))
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-machine cache the SemanticProvider reads — index and search must never diverge
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, and chunk ceilings).
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
- conn.execute(
41
- "DELETE FROM code_embeddings WHERE chunk_id IN "
42
- "(SELECT chunk_id FROM chunk_hashes WHERE project_root=?)", (real,))
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
- cache db file plus its -wal/-shm siblings). ``apply=False`` is a dry-run: count only,
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
- path = db_path if db_path is not None else default_db_path()
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
- return _reset_all(path, apply)
98
- return _reset_scoped(project_root, path, apply)
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 default_db_path() -> str:
15
- """The single, per-machine semantic index cache. Every entry point (the
16
- SemanticProvider, the Reindexer, and the CLI) MUST resolve to this one path — rows
17
- are partitioned by ``project_root`` inside it — so ``index`` and ``search`` can never
18
- diverge onto different files for the same repo.
19
- """
20
- return str(pathlib.Path.home() / ".codeintel" / "semantic.db")
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
- dimension: int = 384
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
- c.executescript(f"""
72
- CREATE VIRTUAL TABLE IF NOT EXISTS code_embeddings USING vec0(
73
- chunk_id TEXT PRIMARY KEY,
74
- embedding FLOAT[{self.dimension}]
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
- indexed = os.path.exists(default_db_path())
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
- db_path = tmp_path / "e2e_semantic.db"
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):
@@ -24,7 +24,7 @@ def _patch_semantic(monkeypatch, tmp_path, has_index_value):
24
24
  from codeintel.indexer import Indexer
25
25
  from codeintel.searcher import Searcher
26
26
 
27
- monkeypatch.setattr(sem, "_DB_PATH", tmp_path / "sem.db")
27
+ monkeypatch.setattr("codeintel.semantic_db._base_dir", lambda: tmp_path)
28
28
  calls = {"index": 0}
29
29
 
30
30
  def _fake_index(self, root):
@@ -134,7 +134,7 @@ def test_overview_auto_falls_back_to_lsp():
134
134
  def test_empty_project_reports_no_index(tmp_path, monkeypatch):
135
135
  import codeintel.providers.semantic as sem
136
136
  empty = tmp_path / "empty"; empty.mkdir()
137
- monkeypatch.setattr(sem, "_DB_PATH", tmp_path / "s.db")
137
+ monkeypatch.setattr("codeintel.semantic_db._base_dir", lambda: tmp_path)
138
138
  monkeypatch.setattr(sem, "_DEPS_OK", True)
139
139
  with patch("fastembed.TextEmbedding", _FakeTextEmbedding):
140
140
  r = SemanticProvider().build_result("search", "x", [], 0, str(empty))
@@ -150,7 +150,7 @@ def test_config_cosine_floor_reaches_searcher(tmp_path, monkeypatch):
150
150
  'cosine_floor = 0.99\nrerank = "off"\nrerank_candidates = 7\n'
151
151
  )
152
152
  (tmp_path / "code.py").write_text("def f():\n return 1\n")
153
- monkeypatch.setattr(sem, "_DB_PATH", tmp_path / "s.db")
153
+ monkeypatch.setattr("codeintel.semantic_db._base_dir", lambda: tmp_path)
154
154
  monkeypatch.setattr(sem, "_DEPS_OK", True)
155
155
 
156
156
  captured = {}
@@ -0,0 +1,148 @@
1
+ """Embedding-model / vector-dimension handling (0.8.3).
2
+
3
+ Different embedding models produce incompatible vectors (often different lengths), and a
4
+ sqlite-vec vec0 table is single-dimension. The design isolates them PHYSICALLY: each model gets
5
+ its own cache file (``semantic-<hash>.db``), and the vec0 table self-dimensions from the first
6
+ real vector. Test #1 is the regression that caught the reverted global-wipe fix.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import glob
11
+ import os
12
+ from unittest.mock import patch
13
+
14
+ import numpy as np
15
+
16
+ from codeintel.indexer import Indexer
17
+ from codeintel.reset import run_reset
18
+ from codeintel.searcher import Searcher
19
+ from codeintel.semantic_db import DEFAULT_MODEL, SemanticDb, default_db_path
20
+
21
+
22
+ def _fake(dim):
23
+ class _F:
24
+ def __init__(self, model_name=None):
25
+ pass
26
+
27
+ def embed(self, texts):
28
+ return [np.full(dim, 0.1, dtype=np.float32) for _ in list(texts)]
29
+ return _F
30
+
31
+
32
+ def _seed(path, project_root_real, cid="c1"):
33
+ db = SemanticDb(path)
34
+ db.init()
35
+ db.conn().execute(
36
+ "INSERT INTO chunk_hashes(chunk_id, project_root, file_path, chunk_start, content_hash)"
37
+ " VALUES (?,?,?,?,?)", (cid, project_root_real, "f.py", 0, "h"))
38
+ db.conn().commit()
39
+ db.close()
40
+
41
+
42
+ # --------------------------------------------------------------------------- #1 THE regression
43
+
44
+ def test_different_model_repos_do_not_wipe_each_other(tmp_path, monkeypatch):
45
+ # The reverted fix wiped the whole shared cache on a model mismatch; with two repos on
46
+ # different models that meant they perpetually destroyed each other. Per-model files make it
47
+ # physically impossible.
48
+ monkeypatch.setattr("codeintel.semantic_db._base_dir", lambda: tmp_path)
49
+ (tmp_path / "a").mkdir(); (tmp_path / "a" / "a.py").write_text("def a():\n return 1\n")
50
+ (tmp_path / "b").mkdir(); (tmp_path / "b" / "b.py").write_text("def b():\n return 2\n")
51
+
52
+ def index(repo, model, dim):
53
+ db = SemanticDb(default_db_path(model)); db.init()
54
+ with patch("fastembed.TextEmbedding", _fake(dim)):
55
+ Indexer(db, model_name=model).index(str(tmp_path / repo))
56
+ return db
57
+
58
+ # ping-pong: A(default,384) → B(other,768) → A → B, asserting each survives the other
59
+ for _ in range(2):
60
+ db_a = index("a", DEFAULT_MODEL, 384)
61
+ db_b = index("b", "other-model-768", 768)
62
+ assert Searcher(db_a, model_name=DEFAULT_MODEL).has_index(str(tmp_path / "a")), \
63
+ "repo A must survive repo B indexing under a different model"
64
+ assert Searcher(db_b, model_name="other-model-768").has_index(str(tmp_path / "b"))
65
+
66
+ # they live in DIFFERENT files
67
+ assert default_db_path(DEFAULT_MODEL) != default_db_path("other-model-768")
68
+ assert os.path.exists(tmp_path / "semantic.db") # A → default file
69
+ assert len(glob.glob(str(tmp_path / "semantic-*.db"))) == 1 # B → its own file
70
+
71
+
72
+ # --------------------------------------------------------------------------- self-dimensioning
73
+
74
+ def test_table_self_dimensions_from_first_vector(tmp_path):
75
+ db = SemanticDb(str(tmp_path / "y.db")); db.init()
76
+ assert db._table_dim() is None # init does NOT create code_embeddings
77
+ assert db.ensure_embeddings_table(768) == 768
78
+ assert db._table_dim() == 768 # created at the real vector size, not 384
79
+
80
+
81
+ def test_ensure_embeddings_table_skips_on_dimension_mismatch(tmp_path):
82
+ # the only in-file mismatch case (a DEFAULT_MODEL size bump): skip the write, never wipe
83
+ db = SemanticDb(str(tmp_path / "x.db")); db.init()
84
+ assert db.ensure_embeddings_table(384) == 384
85
+ assert db.ensure_embeddings_table(768) is None # refuses
86
+ assert db._table_dim() == 384 # table left intact, not recreated/wiped
87
+
88
+
89
+ # --------------------------------------------------------------------------- path invariants
90
+
91
+ def test_default_db_path_invariants(tmp_path, monkeypatch):
92
+ monkeypatch.setattr("codeintel.semantic_db._base_dir", lambda: tmp_path)
93
+ d = default_db_path
94
+ legacy = str(tmp_path / "semantic.db")
95
+ assert d() == d(None) == d("") == d(" ") == d(DEFAULT_MODEL) == legacy # default → legacy file
96
+ assert d("other") != legacy and d("other").startswith(str(tmp_path / "semantic-"))
97
+ assert d("a") != d("b") and d("a") == d("a") # distinct models distinct, same stable
98
+ assert d("wéird/model:v2") .endswith(".db") # total: any string yields a filename
99
+
100
+
101
+ # --------------------------------------------------------------------------- reset across files
102
+
103
+ def test_scoped_reset_sweeps_all_model_files_but_spares_other_projects(tmp_path, monkeypatch):
104
+ monkeypatch.setattr("codeintel.semantic_db._base_dir", lambda: tmp_path)
105
+ real_a = os.path.realpath(str(tmp_path / "a"))
106
+ real_b = os.path.realpath(str(tmp_path / "b"))
107
+ # project A has rows in TWO model files; project B has rows in the default file
108
+ _seed(default_db_path(DEFAULT_MODEL), real_a, "a-default")
109
+ _seed(default_db_path("other"), real_a, "a-other")
110
+ db_b = SemanticDb(default_db_path(DEFAULT_MODEL)) # same default file as A's default rows
111
+ db_b.conn().execute(
112
+ "INSERT INTO chunk_hashes(chunk_id, project_root, file_path, chunk_start, content_hash)"
113
+ " VALUES (?,?,?,?,?)", ("b1", real_b, "g.py", 0, "h"))
114
+ db_b.conn().commit(); db_b.close()
115
+
116
+ res = run_reset(str(tmp_path / "a"), apply=True) # no db_path → sweep every model file
117
+ assert res["count"] == 2 # A's rows removed from BOTH files
118
+
119
+ surviving = SemanticDb(default_db_path(DEFAULT_MODEL)).conn().execute(
120
+ "SELECT COUNT(*) FROM chunk_hashes WHERE project_root=?", (real_b,)).fetchone()[0]
121
+ assert surviving == 1, "a scoped reset of A must leave project B untouched"
122
+
123
+
124
+ def test_probe_and_build_result_resolve_the_same_file(tmp_path, monkeypatch):
125
+ # regression for the divergence the CHANGELOG says it closed (build_result used a module
126
+ # _DB_PATH, probe used default_db_path()): with a NON-default model both must hit the same file,
127
+ # or probe reports "not indexed" for a repo build_result just indexed.
128
+ monkeypatch.setattr("codeintel.semantic_db._base_dir", lambda: tmp_path)
129
+ monkeypatch.setattr("codeintel.providers.semantic._DEPS_OK", True)
130
+ from codeintel.providers.semantic import SemanticProvider
131
+ repo = tmp_path / "r"; repo.mkdir()
132
+ (repo / ".codeintel.toml").write_text('model = "custom-384-model"\n')
133
+ (repo / "m.py").write_text("def f():\n return 1\n")
134
+
135
+ with patch("fastembed.TextEmbedding", _fake(384)):
136
+ SemanticProvider().build_result("search", "f", [], 0, str(repo)) # writes the model's file
137
+ assert os.path.exists(default_db_path("custom-384-model")) # not the default file
138
+ assert SemanticProvider().probe(str(repo))["repo_indexed"] is True # probe found the SAME file
139
+
140
+
141
+ def test_reset_tolerates_absent_code_embeddings(tmp_path):
142
+ # code_embeddings is created lazily, so a seed-only db has none — reset must not choke on it
143
+ p = str(tmp_path / "x.db")
144
+ real = os.path.realpath(str(tmp_path))
145
+ _seed(p, real)
146
+ assert SemanticDb(p)._table_dim() is None # confirms no code_embeddings table
147
+ res = run_reset(str(tmp_path), apply=True, db_path=p)
148
+ assert res["count"] == 1 and "removed" in res["detail"]
@@ -59,7 +59,7 @@ def test_setup_index_real_db(tmp_path, monkeypatch):
59
59
  (repo / "sample.py").write_text("def greet():\n return 'hello'\n")
60
60
 
61
61
  db_path = tmp_path / "semantic.db"
62
- monkeypatch.setattr("codeintel.semantic_db.default_db_path", lambda: str(db_path))
62
+ monkeypatch.setattr("codeintel.semantic_db.default_db_path", lambda *a, **k: str(db_path))
63
63
 
64
64
  report = onboarding.run_setup(str(repo), do_index=True, out=io.StringIO())
65
65
  assert report["ok"] is True
@@ -97,7 +97,7 @@ def test_available_when_deps_present(monkeypatch):
97
97
  def test_search_returns_matches(tmp_path, monkeypatch):
98
98
  (tmp_path / "sample.py").write_text("def greet():\n return 'hello'\n")
99
99
  db_path = tmp_path / "semantic.db"
100
- monkeypatch.setattr("codeintel.providers.semantic._DB_PATH", db_path)
100
+ monkeypatch.setattr("codeintel.semantic_db._base_dir", lambda: tmp_path)
101
101
  monkeypatch.setattr("codeintel.providers.semantic._DEPS_OK", True)
102
102
 
103
103
  with patch("fastembed.TextEmbedding", _FakeTextEmbedding):
@@ -135,7 +135,7 @@ def test_empty_index_safe_null(tmp_path, monkeypatch):
135
135
  empty_dir = tmp_path / "empty"
136
136
  empty_dir.mkdir()
137
137
  db_path = tmp_path / "semantic.db"
138
- monkeypatch.setattr("codeintel.providers.semantic._DB_PATH", db_path)
138
+ monkeypatch.setattr("codeintel.semantic_db._base_dir", lambda: tmp_path)
139
139
  monkeypatch.setattr("codeintel.providers.semantic._DEPS_OK", True)
140
140
 
141
141
  with patch("fastembed.TextEmbedding", _FakeTextEmbedding):
@@ -155,7 +155,7 @@ def test_empty_index_safe_null(tmp_path, monkeypatch):
155
155
  def test_below_floor_returns_none(tmp_path, monkeypatch):
156
156
  (tmp_path / "code.py").write_text("x = 1\n")
157
157
  db_path = tmp_path / "semantic.db"
158
- monkeypatch.setattr("codeintel.providers.semantic._DB_PATH", db_path)
158
+ monkeypatch.setattr("codeintel.semantic_db._base_dir", lambda: tmp_path)
159
159
  monkeypatch.setattr("codeintel.providers.semantic._DEPS_OK", True)
160
160
 
161
161
  with patch("fastembed.TextEmbedding", _FakeTextEmbedding), \
@@ -195,7 +195,7 @@ def test_context_op_is_accepted(monkeypatch):
195
195
 
196
196
  def test_provider_never_raises(tmp_path, monkeypatch):
197
197
  db_path = tmp_path / "semantic.db"
198
- monkeypatch.setattr("codeintel.providers.semantic._DB_PATH", db_path)
198
+ monkeypatch.setattr("codeintel.semantic_db._base_dir", lambda: tmp_path)
199
199
  monkeypatch.setattr("codeintel.providers.semantic._DEPS_OK", True)
200
200
 
201
201
  with patch("codeintel.semantic_db.SemanticDb.init", side_effect=RuntimeError("injected")):
@@ -1 +0,0 @@
1
- __version__ = "0.8.2"
File without changes
File without changes
File without changes
File without changes