codecortex 0.6.0__tar.gz → 0.8.0__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 (67) hide show
  1. {codecortex-0.6.0/src/codecortex.egg-info → codecortex-0.8.0}/PKG-INFO +2 -1
  2. {codecortex-0.6.0 → codecortex-0.8.0}/pyproject.toml +6 -1
  3. {codecortex-0.6.0 → codecortex-0.8.0/src/codecortex.egg-info}/PKG-INFO +2 -1
  4. {codecortex-0.6.0 → codecortex-0.8.0}/src/codecortex.egg-info/SOURCES.txt +3 -1
  5. {codecortex-0.6.0 → codecortex-0.8.0}/src/codecortex.egg-info/requires.txt +1 -0
  6. codecortex-0.8.0/src/codeintel/__init__.py +1 -0
  7. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/config.py +4 -1
  8. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/doctor.py +5 -0
  9. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/indexer.py +158 -10
  10. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/providers/graph.py +3 -1
  11. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/providers/lsp.py +3 -1
  12. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/providers/semantic.py +4 -1
  13. codecortex-0.8.0/src/codeintel/searcher.py +311 -0
  14. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_chunking.py +13 -6
  15. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_config.py +11 -0
  16. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_doctor.py +12 -0
  17. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_integration.py +11 -5
  18. codecortex-0.8.0/tests/test_rerank.py +256 -0
  19. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_semantic_provider.py +48 -0
  20. codecortex-0.8.0/tests/test_treesitter.py +185 -0
  21. codecortex-0.6.0/src/codeintel/__init__.py +0 -1
  22. codecortex-0.6.0/src/codeintel/searcher.py +0 -143
  23. {codecortex-0.6.0 → codecortex-0.8.0}/LICENSE +0 -0
  24. {codecortex-0.6.0 → codecortex-0.8.0}/README.md +0 -0
  25. {codecortex-0.6.0 → codecortex-0.8.0}/setup.cfg +0 -0
  26. {codecortex-0.6.0 → codecortex-0.8.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
  27. {codecortex-0.6.0 → codecortex-0.8.0}/src/codecortex.egg-info/entry_points.txt +0 -0
  28. {codecortex-0.6.0 → codecortex-0.8.0}/src/codecortex.egg-info/top_level.txt +0 -0
  29. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/__main__.py +0 -0
  30. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/auth.py +0 -0
  31. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/cache.py +0 -0
  32. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/gateway.py +0 -0
  33. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/http_server.py +0 -0
  34. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/injector.py +0 -0
  35. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/installer.py +0 -0
  36. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/logconfig.py +0 -0
  37. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/mapper.py +0 -0
  38. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/metrics.py +0 -0
  39. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/onboarding.py +0 -0
  40. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/policy.py +0 -0
  41. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/provider.py +0 -0
  42. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/providers/__init__.py +0 -0
  43. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/providers/none.py +0 -0
  44. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/reindexer.py +0 -0
  45. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/reset.py +0 -0
  46. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/semantic_db.py +0 -0
  47. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/server.py +0 -0
  48. {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/term.py +0 -0
  49. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_cache.py +0 -0
  50. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_e2e.py +0 -0
  51. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_enterprise.py +0 -0
  52. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_gateway.py +0 -0
  53. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_graph_provider.py +0 -0
  54. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_graph_real.py +0 -0
  55. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_graph_stdin.py +0 -0
  56. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_hardening.py +0 -0
  57. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_http_auth.py +0 -0
  58. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_http_server.py +0 -0
  59. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_lsp_provider.py +0 -0
  60. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_lsp_real.py +0 -0
  61. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_mapper.py +0 -0
  62. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_never_raise.py +0 -0
  63. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_onboarding.py +0 -0
  64. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_rbac.py +0 -0
  65. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_reindexer.py +0 -0
  66. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_reset.py +0 -0
  67. {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_term.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.6.0
3
+ Version: 0.8.0
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
@@ -26,6 +26,7 @@ License-File: LICENSE
26
26
  Requires-Dist: mcp>=1.0
27
27
  Requires-Dist: sqlite-vec>=0.1
28
28
  Requires-Dist: fastembed>=0.3
29
+ Requires-Dist: tree-sitter-language-pack>=1.0
29
30
  Provides-Extra: dev
30
31
  Requires-Dist: pytest>=8; extra == "dev"
31
32
  Requires-Dist: numpy>=1.24; extra == "dev"
@@ -31,7 +31,12 @@ classifiers = [
31
31
  "Topic :: Software Development :: Quality Assurance",
32
32
  "Typing :: Typed",
33
33
  ]
34
- dependencies = ["mcp>=1.0", "sqlite-vec>=0.1", "fastembed>=0.3"]
34
+ dependencies = [
35
+ "mcp>=1.0",
36
+ "sqlite-vec>=0.1",
37
+ "fastembed>=0.3",
38
+ "tree-sitter-language-pack>=1.0", # def-aligned chunking for non-Python languages (P3)
39
+ ]
35
40
 
36
41
  [project.urls]
37
42
  Homepage = "https://github.com/hamilton-sky/codeintel"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.6.0
3
+ Version: 0.8.0
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
@@ -26,6 +26,7 @@ License-File: LICENSE
26
26
  Requires-Dist: mcp>=1.0
27
27
  Requires-Dist: sqlite-vec>=0.1
28
28
  Requires-Dist: fastembed>=0.3
29
+ Requires-Dist: tree-sitter-language-pack>=1.0
29
30
  Provides-Extra: dev
30
31
  Requires-Dist: pytest>=8; extra == "dev"
31
32
  Requires-Dist: numpy>=1.24; extra == "dev"
@@ -56,6 +56,8 @@ tests/test_never_raise.py
56
56
  tests/test_onboarding.py
57
57
  tests/test_rbac.py
58
58
  tests/test_reindexer.py
59
+ tests/test_rerank.py
59
60
  tests/test_reset.py
60
61
  tests/test_semantic_provider.py
61
- tests/test_term.py
62
+ tests/test_term.py
63
+ tests/test_treesitter.py
@@ -1,6 +1,7 @@
1
1
  mcp>=1.0
2
2
  sqlite-vec>=0.1
3
3
  fastembed>=0.3
4
+ tree-sitter-language-pack>=1.0
4
5
 
5
6
  [dev]
6
7
  pytest>=8
@@ -0,0 +1 @@
1
+ __version__ = "0.8.0"
@@ -25,6 +25,8 @@ _DEFAULTS: dict = {
25
25
  "cosine_floor": 0.25,
26
26
  "model": "BAAI/bge-small-en-v1.5",
27
27
  "chunk_strategy": "syntax", # syntax-aware (def/class boundaries) vs fixed line windows
28
+ "rerank": "on", # hybrid lexical+semantic rerank of search results
29
+ "rerank_candidates": 30, # cosine candidates fused/re-ranked before returning top-k
28
30
  }
29
31
 
30
32
  # Values restricted to a fixed set — anything else falls back to the default.
@@ -33,8 +35,9 @@ _ENUMS: dict = {
33
35
  "semantic": {"on", "off"},
34
36
  "reindex": {"on-demand", "never"},
35
37
  "chunk_strategy": {"syntax", "lines"},
38
+ "rerank": {"on", "off"},
36
39
  }
37
- _POSITIVE_INTS = ("window", "stride", "max_chunks", "max_total_chunks")
40
+ _POSITIVE_INTS = ("window", "stride", "max_chunks", "max_total_chunks", "rerank_candidates")
38
41
 
39
42
 
40
43
  def _read_toml(path: pathlib.Path) -> dict:
@@ -158,4 +158,9 @@ def render_doctor_text(report: dict) -> str:
158
158
  tail = "" if report.get("deep") else c.dim(" (run with --deep to boot-check serena)")
159
159
  out.append("")
160
160
  out.append(f" {count} engines ready for this repo.{tail}")
161
+ if healthy is False:
162
+ out.append(" " + c.dim(
163
+ "tip: `codeintel setup --install-uv --install-deps --index` bootstraps the "
164
+ "pip-installable backends; each fix: line above has the per-engine command."
165
+ ))
161
166
  return "\n".join(out)
@@ -14,7 +14,10 @@ if TYPE_CHECKING:
14
14
  logger = logging.getLogger(__name__)
15
15
 
16
16
  _INDEXED_EXTS = frozenset({
17
- ".py", ".ts", ".js", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".md"
17
+ ".py", ".md",
18
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", # TS/JS variants
19
+ ".go", ".rs", ".java",
20
+ ".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", # C/C++ variants
18
21
  })
19
22
  _SKIP_DIRS = frozenset({"__pycache__", ".git", "node_modules"})
20
23
  # Vendored / regenerable dirs skipped even without a .gitignore entry.
@@ -41,6 +44,49 @@ def _pos_int(val: object, default: int) -> int:
41
44
  return n if n > 0 else default
42
45
 
43
46
 
47
+ # ---- tree-sitter chunking (P3): def-aligned chunks for non-Python languages -----------------
48
+ # Extends the P1 chunker interface to TS/JS/Go/Rust/Java/C/C++ via tree-sitter, behind the same
49
+ # _primary_spans → _cover pipeline. The grammar pack is a normal dependency but never a hard
50
+ # requirement: if it (or a grammar) is unavailable, or a file's language config is missing, the
51
+ # file falls back to line windowing — exactly like a Python parse failure.
52
+
53
+ # file extension -> tree_sitter_language_pack language name. Only the *code* exts in
54
+ # _INDEXED_EXTS; .md (and anything unmapped) keeps line-windowing.
55
+ _TS_LANG_BY_EXT = {
56
+ ".ts": "typescript", ".tsx": "tsx", ".js": "javascript", ".jsx": "javascript",
57
+ ".mjs": "javascript", ".cjs": "javascript",
58
+ ".go": "go", ".rs": "rust", ".java": "java",
59
+ ".c": "c", ".h": "cpp", ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp",
60
+ ".hpp": "cpp", ".hh": "cpp",
61
+ }
62
+ # "Leaf" definition node types — each emitted as one whole chunk (like a top-level Python def).
63
+ _TS_FUNC_TYPES = {
64
+ "typescript": {"function_declaration", "generator_function_declaration", "method_definition",
65
+ "method_signature", "abstract_method_signature", "function_signature"},
66
+ "tsx": {"function_declaration", "generator_function_declaration", "method_definition",
67
+ "method_signature", "abstract_method_signature", "function_signature"},
68
+ "javascript": {"function_declaration", "generator_function_declaration", "method_definition"},
69
+ "go": {"function_declaration", "method_declaration", "type_declaration"},
70
+ "rust": {"function_item", "function_signature_item", "struct_item", "enum_item",
71
+ "union_item", "type_item", "macro_definition"},
72
+ "java": {"method_declaration", "constructor_declaration"},
73
+ "c": {"function_definition", "struct_specifier", "enum_specifier", "union_specifier"},
74
+ "cpp": {"function_definition"},
75
+ }
76
+ # "Container" definition types — emitted as a header chunk + one chunk per nested member, exactly
77
+ # like a Python class (so per-method chunks are never shadowed by a whole-class chunk).
78
+ _TS_CONTAINER_TYPES = {
79
+ "typescript": {"class_declaration", "abstract_class_declaration", "interface_declaration"},
80
+ "tsx": {"class_declaration", "abstract_class_declaration", "interface_declaration"},
81
+ "javascript": {"class_declaration"},
82
+ "go": set(),
83
+ "rust": {"impl_item", "trait_item", "mod_item"},
84
+ "java": {"class_declaration", "interface_declaration", "enum_declaration", "record_declaration"},
85
+ "c": set(),
86
+ "cpp": {"class_specifier", "struct_specifier", "namespace_definition"},
87
+ }
88
+
89
+
44
90
  class Indexer:
45
91
  def __init__(
46
92
  self,
@@ -76,6 +122,7 @@ class Indexer:
76
122
  else 2 * self.window
77
123
  )
78
124
  self._embedder = None
125
+ self._ts_parsers: dict = {} # per-instance tree-sitter parser cache (lang -> parser|None)
79
126
 
80
127
  def _get_embedder(self):
81
128
  if self._embedder is None:
@@ -249,16 +296,109 @@ class Indexer:
249
296
  n = len(lines)
250
297
  return self._cover(self._primary_spans(tree, n), n)
251
298
 
299
+ def _get_ts_parser(self, lang: str):
300
+ """Lazily load + cache (per instance) the tree-sitter parser for a language. Returns None
301
+ when ``tree-sitter-language-pack`` isn't installed or the grammar is unavailable — the
302
+ caller then windows (tree-sitter is a normal dep, never a hard requirement)."""
303
+ cache = self._ts_parsers
304
+ if lang in cache:
305
+ return cache[lang]
306
+ parser = None
307
+ try:
308
+ from tree_sitter_language_pack import get_parser
309
+ parser = get_parser(lang)
310
+ except Exception as exc:
311
+ logger.debug("tree-sitter parser for %s unavailable: %s", lang, exc)
312
+ cache[lang] = parser
313
+ return parser
314
+
315
+ @staticmethod
316
+ def _ts_end_line(node, n: int) -> int:
317
+ """0-based half-open end line of a tree-sitter node. ``end_point`` is (row, col); a col of 0
318
+ means the node ends at the start of that row (so the row isn't included). Clamped to
319
+ ``[start+1, n]``."""
320
+ row, col = node.end_point
321
+ end = row if col == 0 else row + 1
322
+ return min(n, max(node.start_point[0] + 1, end))
323
+
324
+ def _primary_spans_ts(self, root, lang: str, n: int) -> list[tuple[int, int]]:
325
+ """Def-aligned 'primary' spans from a tree-sitter tree, mirroring ``_primary_spans``: each
326
+ function/method → one span; each container (class/impl/trait/…) → a header span plus one
327
+ span per nested member. Node types the language config doesn't list are simply not treated
328
+ as defs — they fall into ``_cover``'s window-filled gaps, so an incomplete config degrades
329
+ precision, never correctness."""
330
+ func_types = _TS_FUNC_TYPES.get(lang, set())
331
+ cont_types = _TS_CONTAINER_TYPES.get(lang, set())
332
+ all_types = func_types | cont_types
333
+ if not all_types:
334
+ return [] # unknown language -> caller windows the whole file
335
+ spans: list[tuple[int, int]] = []
336
+
337
+ def nearest_defs(node):
338
+ """Def-type nodes reachable without crossing another def-type — top-level defs from the
339
+ root, a container's direct members from the container. Iterative, so deep nesting can't
340
+ blow the stack."""
341
+ found = []
342
+ stack = [c for c in reversed(node.children) if c.is_named]
343
+ while stack:
344
+ c = stack.pop()
345
+ if c.type in all_types:
346
+ found.append(c)
347
+ else:
348
+ stack.extend(g for g in reversed(c.children) if g.is_named)
349
+ return found
350
+
351
+ def emit(node):
352
+ s = node.start_point[0]
353
+ e = self._ts_end_line(node, n)
354
+ if node.type in cont_types:
355
+ members = sorted(nearest_defs(node), key=lambda m: m.start_point[0])
356
+ if members:
357
+ header_end = max(s + 1, min(members[0].start_point[0], e))
358
+ spans.append((s, header_end)) # header: class/impl line -> first member
359
+ for m in members:
360
+ emit(m)
361
+ return
362
+ spans.append((s, e))
363
+
364
+ for node in sorted(nearest_defs(root), key=lambda x: x.start_point[0]):
365
+ emit(node)
366
+ return spans
367
+
368
+ def _chunk_treesitter(self, lines: list[str], source: str, lang: str):
369
+ """Parse ``source`` with tree-sitter → a complete, def-aligned cover, or ``None`` when the
370
+ parser is unavailable (caller windows). tree-sitter is error-tolerant, so a syntactically
371
+ broken file still yields a partial tree (and thus useful spans) rather than raising."""
372
+ parser = self._get_ts_parser(lang)
373
+ if parser is None:
374
+ return None
375
+ tree = parser.parse(source.encode("utf-8", errors="replace"))
376
+ n = len(lines)
377
+ return self._cover(self._primary_spans_ts(tree.root_node, lang, n), n)
378
+
252
379
  def _spans_for_file(
253
380
  self, filepath: Path, lines: list[str], rel_path: str
254
381
  ) -> list[tuple[int, int]]:
255
- """Choose spans for one file: syntax-aware for ``.py`` under the syntax strategy (falling
256
- back to windowing on any parse failure), fixed windows for everything else."""
257
- if self.chunk_strategy == "syntax" and filepath.suffix.lower() == ".py":
258
- try:
259
- return self._chunk_python_ast(lines, "".join(lines))
260
- except Exception as exc:
261
- logger.debug("syntax chunking failed for %s (%s) — windowing", rel_path, exc)
382
+ """Choose spans for one file under the syntax strategy: ``ast`` for ``.py``, tree-sitter for
383
+ the mapped languages (TS/JS/Go/Rust/Java/C/C++), fixed windows for everything else and on
384
+ any parse/grammar failure or when tree-sitter isn't installed."""
385
+ if self.chunk_strategy == "syntax":
386
+ suffix = filepath.suffix.lower()
387
+ if suffix == ".py":
388
+ try:
389
+ return self._chunk_python_ast(lines, "".join(lines))
390
+ except Exception as exc:
391
+ logger.debug("syntax chunking failed for %s (%s) — windowing", rel_path, exc)
392
+ else:
393
+ lang = _TS_LANG_BY_EXT.get(suffix)
394
+ if lang is not None:
395
+ try:
396
+ spans = self._chunk_treesitter(lines, "".join(lines), lang)
397
+ if spans is not None:
398
+ return spans
399
+ except Exception as exc:
400
+ logger.debug("tree-sitter chunking failed for %s (%s) — windowing",
401
+ rel_path, exc)
262
402
  return self._window_spans(0, len(lines))
263
403
 
264
404
  # ---- materialisation -------------------------------------------------------------------
@@ -380,9 +520,17 @@ class Indexer:
380
520
  try:
381
521
  vec = embeddings[j]
382
522
  vec_bytes = struct.pack(f"{len(vec)}f", *vec)
523
+ # sqlite-vec's vec0 virtual table does NOT honor INSERT OR REPLACE — it raises
524
+ # UNIQUE on an existing chunk_id instead of replacing. So re-embedding a chunk
525
+ # whose content changed but whose chunk_id (start line) is stable — the common
526
+ # case under syntax chunking, where a def's chunk_id is its def line — would
527
+ # silently fail and keep the STALE vector. DELETE-then-INSERT is the supported
528
+ # upsert for vec0. (chunk_hashes below is a normal table, where REPLACE works.)
529
+ conn.execute(
530
+ "DELETE FROM code_embeddings WHERE chunk_id = ?", (chunk_id,)
531
+ )
383
532
  conn.execute(
384
- "INSERT OR REPLACE INTO code_embeddings(chunk_id, embedding)"
385
- " VALUES (?, ?)",
533
+ "INSERT INTO code_embeddings(chunk_id, embedding) VALUES (?, ?)",
386
534
  (chunk_id, vec_bytes),
387
535
  )
388
536
  conn.execute(
@@ -157,7 +157,9 @@ class GraphProvider:
157
157
  return {
158
158
  "installed": False, "runnable": False, "repo_indexed": False, "project": None,
159
159
  "detail": "codebase-memory-mcp not found on PATH",
160
- "remediation": "install codebase-memory-mcp (the graph backend)",
160
+ "remediation": "put the codebase-memory-mcp binary on PATH — it's an external "
161
+ "native backend (see docs/graph.md); once present it self-updates "
162
+ "via `codebase-memory-mcp update`",
161
163
  }
162
164
  raw = self._run("list_projects", {}, timeout_ms)
163
165
  if raw is None:
@@ -144,7 +144,9 @@ class LspProvider:
144
144
  return {
145
145
  "installed": False, "runnable": False, "repo_indexed": None,
146
146
  "detail": "neither `serena` nor `uvx` found on PATH",
147
- "remediation": "install uv (provides uvx) serena is fetched on first use",
147
+ "remediation": "install uv (provides uvx): `codeintel setup --install-uv` "
148
+ "(or `brew install uv` / `pip install uv`) — serena is then "
149
+ "fetched on first use",
148
150
  }
149
151
  cmd = self._cmd
150
152
  if not deep:
@@ -130,7 +130,10 @@ class SemanticProvider:
130
130
  )
131
131
 
132
132
  matches = searcher.search(
133
- target, project_root, cosine_floor=float(cfg.get("cosine_floor", 0.25))
133
+ target, project_root,
134
+ cosine_floor=float(cfg.get("cosine_floor", 0.25)),
135
+ rerank=str(cfg.get("rerank", "on")),
136
+ rerank_candidates=int(cfg.get("rerank_candidates", 30)),
134
137
  )
135
138
  if not matches:
136
139
  return safe_null_result(op, target, engine="semantic", reason="below-floor")
@@ -0,0 +1,311 @@
1
+ from __future__ import annotations
2
+
3
+ import itertools
4
+ import logging
5
+ import os
6
+ import re
7
+ import struct
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from codeintel.semantic_db import SemanticDb
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ _SNIPPET_LINES = 5
17
+ # Hybrid rerank (0.7.0): re-read up to this many lines per candidate for lexical scoring — a hard
18
+ # per-candidate cap so one huge file can't blow up a query. The read is further bounded at the next
19
+ # stored chunk start (see _rerank); the returned snippet still uses only the first _SNIPPET_LINES.
20
+ _RERANK_READ_LINES = 40
21
+ # Hard ceiling on the candidate set regardless of rerank_candidates, so a misconfigured value can't
22
+ # turn one query into thousands of file reads on the interactive hot path.
23
+ _RERANK_CANDIDATES_CAP = 200
24
+ _RRF_K = 60 # Reciprocal Rank Fusion constant (standard ≈ 60; damps rank differences)
25
+ _SYMBOL_BOOST = 0.1 # additive fusion bonus for an exact def/class-name match (≫ one RRF term)
26
+
27
+ # `\w` is Unicode-aware in Python 3, so a non-ASCII identifier is kept whole; the sub-splitter is
28
+ # Latin-cased on purpose (camelCase only exists there) and just adds bonus pieces for ASCII names.
29
+ _IDENT_RE = re.compile(r"\w+")
30
+ _SUBTOKEN_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+")
31
+ _SYMBOL_RE = re.compile(r"[^\W\d]\w*") # a query that is a single identifier (letter/_ then word)
32
+
33
+
34
+ def _pos_int(val: object, default: int) -> int:
35
+ """A usable positive int or the default — guards the public Searcher.search against a caller
36
+ passing a non-int/zero/negative k or rerank_candidates (mirrors indexer._pos_int)."""
37
+ try:
38
+ n = int(val) # type: ignore[arg-type]
39
+ except (TypeError, ValueError, OverflowError):
40
+ return default
41
+ return n if n > 0 else default
42
+
43
+
44
+ def _tokenize(text: str) -> set[str]:
45
+ """Lexical tokens for overlap scoring: each whole identifier plus its camel/snake sub-pieces,
46
+ lowercased. Deliberately set-based (presence, not frequency) — cheap and enough for ranking."""
47
+ toks: set[str] = set()
48
+ for ident in _IDENT_RE.findall(text):
49
+ toks.add(ident.lower())
50
+ for sub in _SUBTOKEN_RE.findall(ident):
51
+ toks.add(sub.lower())
52
+ return toks
53
+
54
+
55
+ class Searcher:
56
+ def __init__(
57
+ self,
58
+ db: SemanticDb,
59
+ model_name: str = "BAAI/bge-small-en-v1.5",
60
+ ) -> None:
61
+ self.db = db
62
+ self.model_name = model_name
63
+ self._embedder = None
64
+
65
+ def _get_embedder(self):
66
+ if self._embedder is None:
67
+ from fastembed import TextEmbedding
68
+ self._embedder = TextEmbedding(model_name=self.model_name)
69
+ return self._embedder
70
+
71
+ def _embed_query(self, query: str) -> bytes | None:
72
+ try:
73
+ embedder = self._get_embedder()
74
+ vecs = list(embedder.embed([query]))
75
+ if not vecs:
76
+ return None
77
+ vec = vecs[0]
78
+ return struct.pack(f"{len(vec)}f", *vec)
79
+ except Exception as exc:
80
+ logger.warning("query embedding failed: %s", exc)
81
+ return None
82
+
83
+ def _row_count(self, project_root_real: str) -> int:
84
+ try:
85
+ conn = self.db.conn()
86
+ row = conn.execute(
87
+ "SELECT COUNT(*) FROM chunk_hashes WHERE project_root = ?",
88
+ (project_root_real,),
89
+ ).fetchone()
90
+ return row[0] if row else 0
91
+ except Exception as exc:
92
+ logger.warning("rowcount check failed: %s", exc)
93
+ return 0
94
+
95
+ def has_index(self, project_root: str) -> bool:
96
+ """True when this project has at least one indexed chunk — lets the provider
97
+ distinguish 'nothing indexed yet' (no-index) from 'matches below floor'."""
98
+ return self._row_count(os.path.realpath(project_root)) > 0
99
+
100
+ def _read_snippet(self, file_path: Path, chunk_start: int) -> str:
101
+ try:
102
+ with open(file_path, encoding="utf-8", errors="replace") as f:
103
+ lines = f.readlines()
104
+ snippet_lines = lines[chunk_start: chunk_start + _SNIPPET_LINES]
105
+ return "".join(snippet_lines).rstrip()
106
+ except FileNotFoundError:
107
+ return "[file not found]"
108
+ except Exception as exc:
109
+ logger.debug("snippet read failed for %s:%d: %s", file_path, chunk_start, exc)
110
+ return "[file not found]"
111
+
112
+ def _read_chunk(self, file_path: Path, chunk_start: int) -> list[str] | None:
113
+ """Bounded re-read from ``chunk_start`` for rerank lexical scoring (and the snippet in one
114
+ read). Uses ``islice`` so a huge multi-line file is not fully materialised just to take a
115
+ 40-line window. ``None`` on any failure — the caller scores that candidate 0 and shows a
116
+ not-found snippet, never crashing (a missing/edited file must degrade, per never-raise)."""
117
+ try:
118
+ with open(file_path, encoding="utf-8", errors="replace") as f:
119
+ return list(itertools.islice(f, chunk_start, chunk_start + _RERANK_READ_LINES))
120
+ except Exception as exc:
121
+ logger.debug("chunk re-read failed for %s:%d: %s", file_path, chunk_start, exc)
122
+ return None
123
+
124
+ def _chunk_starts(self, project_root_real: str, file_path: str) -> list[int]:
125
+ """All stored chunk start lines for one file, sorted. Used to bound each candidate's rerank
126
+ read at the *next* chunk — the chunk's true end for tiling syntax chunks, its owned region
127
+ for overlapping line windows — so lexical/boost text never bleeds into an unrelated def.
128
+ Uses the (project_root, file_path) composite index. ``[]`` on any error → no bound applied."""
129
+ try:
130
+ rows = self.db.conn().execute(
131
+ "SELECT chunk_start FROM chunk_hashes"
132
+ " WHERE project_root = ? AND file_path = ? ORDER BY chunk_start",
133
+ (project_root_real, file_path),
134
+ ).fetchall()
135
+ return [int(r[0]) for r in rows]
136
+ except Exception as exc:
137
+ logger.debug("chunk-start lookup failed for %s: %s", file_path, exc)
138
+ return []
139
+
140
+ @staticmethod
141
+ def _lexical_score(query_tokens: set[str], chunk_text: str) -> float:
142
+ """Token overlap in [0, 1]: the fraction of query (sub)tokens present in the chunk."""
143
+ if not query_tokens:
144
+ return 0.0
145
+ return len(query_tokens & _tokenize(chunk_text)) / len(query_tokens)
146
+
147
+ @staticmethod
148
+ def _symbol_boost(query: str, chunk_text: str) -> float:
149
+ """Additive fusion bonus for the 'exact symbol' case: when the query is a single identifier
150
+ that appears in the chunk as a ``def``/``class`` name (full boost) or as a standalone word
151
+ (half). Case-insensitive, to match the lexical score. Cosine alone under-ranks these literal
152
+ matches; this is what pulls them to the top. The ``_SYMBOL_RE`` guard runs before any
153
+ ``re.escape`` interpolation, so no query character can ever reach the built pattern."""
154
+ q = (query or "").strip()
155
+ if not _SYMBOL_RE.fullmatch(q):
156
+ return 0.0 # multi-word / non-identifier query → no structural signal
157
+ if re.search(rf"\b(?:def|class)\s+{re.escape(q)}\b", chunk_text, re.IGNORECASE):
158
+ return _SYMBOL_BOOST
159
+ if re.search(rf"\b{re.escape(q)}\b", chunk_text, re.IGNORECASE):
160
+ return _SYMBOL_BOOST * 0.5
161
+ return 0.0
162
+
163
+ def _rerank(
164
+ self, query: str, root: Path, project_root_real: str, candidates: list[dict]
165
+ ) -> list[dict]:
166
+ """Reorder floor-gated candidates (given in cosine order) by Reciprocal Rank Fusion over
167
+ the semantic rank and a lexical rank, plus a symbol boost. Reads each candidate's chunk
168
+ once (bounded) — caching its 5-line snippet — so the whole rerank costs ≤ len(candidates)
169
+ reads. When no candidate has any lexical overlap the lexical rank mirrors the semantic
170
+ rank, so the cosine order is returned unchanged (rerank only *reorders* on real signal)."""
171
+ query_tokens = _tokenize(query)
172
+ n = len(candidates)
173
+ # Bound each candidate's lexical/boost text at the NEXT stored chunk start in its file — the
174
+ # chunk's true end for tiling syntax chunks, its owned region for overlapping line windows.
175
+ # A chunk's end line isn't stored, so a fixed 40-line read would otherwise (a) bleed into an
176
+ # unrelated later def, handing this chunk a symbol boost that isn't its own, and/or (b) — if
177
+ # we capped at neighbouring *candidates* — truncate a chunk below its own overlapping span.
178
+ # Bounding at the next real chunk gives each source line to exactly one chunk: the one whose
179
+ # 5-line snippet will actually show it.
180
+ starts_by_file: dict[str, list[int]] = {}
181
+ for path in {c["path"] for c in candidates}:
182
+ starts_by_file[path] = self._chunk_starts(project_root_real, path)
183
+
184
+ lex = [0.0] * n
185
+ boost = [0.0] * n
186
+ for i, c in enumerate(candidates):
187
+ lines = self._read_chunk(root / c["path"], c["line"])
188
+ if lines is None:
189
+ c["snippet"] = "[file not found]"
190
+ text = ""
191
+ else:
192
+ # snippet keeps the existing 5-line preview (bleed-tolerant, unchanged); the
193
+ # lexical/boost text is capped at the next stored chunk start in this file.
194
+ c["snippet"] = "".join(lines[:_SNIPPET_LINES]).rstrip()
195
+ nxt = next((s for s in starts_by_file.get(c["path"], ()) if s > c["line"]), None)
196
+ text = "".join(lines if nxt is None else lines[: max(1, nxt - c["line"])])
197
+ lex[i] = self._lexical_score(query_tokens, text)
198
+ boost[i] = self._symbol_boost(query, text)
199
+
200
+ # lexical rank: highest lexical score first, ties broken by the semantic rank (index i)
201
+ order_by_lex = sorted(range(n), key=lambda i: (-lex[i], i))
202
+ rank_lex = [0] * n
203
+ for pos, i in enumerate(order_by_lex):
204
+ rank_lex[i] = pos
205
+
206
+ # sem rank is the candidate's position i (they arrive in cosine order)
207
+ fused = [1.0 / (_RRF_K + i) + 1.0 / (_RRF_K + rank_lex[i]) + boost[i] for i in range(n)]
208
+ order = sorted(range(n), key=lambda i: (-fused[i], i)) # fused desc, tie → better cosine
209
+ return [candidates[i] for i in order]
210
+
211
+ def search(
212
+ self,
213
+ query: str,
214
+ project_root: str,
215
+ k: int = 10,
216
+ cosine_floor: float = 0.25,
217
+ rerank: str = "on",
218
+ rerank_candidates: int = 30,
219
+ ) -> list[dict]:
220
+ if not query or not query.strip():
221
+ return []
222
+
223
+ # Guard the public API: a direct caller passing a non-int/zero/negative k or
224
+ # rerank_candidates must degrade, not raise (never-raise). Config callers already pre-cast.
225
+ k = _pos_int(k, 10)
226
+ rerank_candidates = _pos_int(rerank_candidates, 30)
227
+ # Accept the documented "off" plus the obvious falsy spellings (incl. the Python bool
228
+ # ``False`` → ``"false"``), so a direct caller isn't silently left with rerank on.
229
+ do_rerank = str(rerank).strip().lower() not in ("off", "false", "0", "no", "none")
230
+ # With rerank on, retrieve a wider candidate set by cosine, then re-order it; still return
231
+ # the top-k. Cap only the *extra* rerank breadth (rerank_candidates) — the DoS guard — while
232
+ # always honoring k, so enabling rerank never returns fewer results than the pure-cosine
233
+ # path would for the same k (even a large k > cap).
234
+ candidate_limit = max(k, min(rerank_candidates, _RERANK_CANDIDATES_CAP)) if do_rerank else k
235
+
236
+ project_root_real = os.path.realpath(project_root)
237
+
238
+ # Scope the KNN to THIS project — a search in repo B must never surface repo A's
239
+ # chunks (wrong-file, wrong-content hits) from the shared cache.
240
+ if self._row_count(project_root_real) == 0:
241
+ return []
242
+
243
+ query_vec = self._embed_query(query)
244
+ if query_vec is None:
245
+ return []
246
+
247
+ try:
248
+ conn = self.db.conn()
249
+ rows = conn.execute(
250
+ """
251
+ SELECT
252
+ ce.chunk_id,
253
+ ch.chunk_start,
254
+ ch.file_path,
255
+ vec_distance_cosine(ce.embedding, ?) AS dist
256
+ FROM code_embeddings ce
257
+ JOIN chunk_hashes ch ON ce.chunk_id = ch.chunk_id
258
+ WHERE ch.project_root = ?
259
+ ORDER BY dist
260
+ LIMIT ?
261
+ """,
262
+ (query_vec, project_root_real, candidate_limit),
263
+ ).fetchall()
264
+ except Exception as exc:
265
+ logger.warning("KNN query failed: %s", exc)
266
+ return []
267
+
268
+ root = Path(project_root)
269
+
270
+ # Floor-gated candidate set, in cosine order. The cosine_floor stays on the *semantic*
271
+ # candidates (not the fused score), so rerank can only re-order what pure cosine already
272
+ # judged good enough — quality can't regress below the pre-0.7 path.
273
+ candidates: list[dict] = []
274
+ for row in rows:
275
+ try:
276
+ score = 1.0 - float(row["dist"])
277
+ if score < cosine_floor:
278
+ continue
279
+ candidates.append({
280
+ "path": str(row["file_path"]),
281
+ "line": int(row["chunk_start"]),
282
+ "score": round(score, 6),
283
+ })
284
+ except Exception as exc:
285
+ logger.debug("candidate row processing failed: %s", exc)
286
+ continue
287
+
288
+ if not candidates:
289
+ return []
290
+
291
+ # Rerank re-reads chunk text; if anything goes wrong, fall back to the cosine order so a
292
+ # rerank fault can never do worse than today (and never raises).
293
+ if do_rerank and len(candidates) > 1:
294
+ try:
295
+ candidates = self._rerank(query, root, project_root_real, candidates)
296
+ except Exception as exc:
297
+ logger.warning("rerank failed, using cosine order: %s", exc)
298
+
299
+ results: list[dict] = []
300
+ for c in candidates[:k]:
301
+ # _rerank caches the snippet on each candidate it read; fill it in otherwise.
302
+ snippet = c.get("snippet")
303
+ if snippet is None:
304
+ snippet = self._read_snippet(root / c["path"], c["line"])
305
+ results.append({
306
+ "path": c["path"],
307
+ "line": c["line"],
308
+ "snippet": snippet,
309
+ "score": c["score"],
310
+ })
311
+ return results