codecortex 0.7.0__tar.gz → 0.8.1__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.
Files changed (66) hide show
  1. {codecortex-0.7.0/src/codecortex.egg-info → codecortex-0.8.1}/PKG-INFO +2 -1
  2. {codecortex-0.7.0 → codecortex-0.8.1}/pyproject.toml +6 -1
  3. {codecortex-0.7.0 → codecortex-0.8.1/src/codecortex.egg-info}/PKG-INFO +2 -1
  4. {codecortex-0.7.0 → codecortex-0.8.1}/src/codecortex.egg-info/SOURCES.txt +2 -1
  5. {codecortex-0.7.0 → codecortex-0.8.1}/src/codecortex.egg-info/requires.txt +1 -0
  6. codecortex-0.8.1/src/codeintel/__init__.py +1 -0
  7. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/__main__.py +6 -2
  8. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/indexer.py +148 -8
  9. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/mapper.py +46 -4
  10. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/server.py +5 -3
  11. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_chunking.py +13 -6
  12. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_mapper.py +57 -3
  13. codecortex-0.8.1/tests/test_treesitter.py +185 -0
  14. codecortex-0.7.0/src/codeintel/__init__.py +0 -1
  15. {codecortex-0.7.0 → codecortex-0.8.1}/LICENSE +0 -0
  16. {codecortex-0.7.0 → codecortex-0.8.1}/README.md +0 -0
  17. {codecortex-0.7.0 → codecortex-0.8.1}/setup.cfg +0 -0
  18. {codecortex-0.7.0 → codecortex-0.8.1}/src/codecortex.egg-info/dependency_links.txt +0 -0
  19. {codecortex-0.7.0 → codecortex-0.8.1}/src/codecortex.egg-info/entry_points.txt +0 -0
  20. {codecortex-0.7.0 → codecortex-0.8.1}/src/codecortex.egg-info/top_level.txt +0 -0
  21. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/auth.py +0 -0
  22. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/cache.py +0 -0
  23. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/config.py +0 -0
  24. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/doctor.py +0 -0
  25. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/gateway.py +0 -0
  26. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/http_server.py +0 -0
  27. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/injector.py +0 -0
  28. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/installer.py +0 -0
  29. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/logconfig.py +0 -0
  30. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/metrics.py +0 -0
  31. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/onboarding.py +0 -0
  32. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/policy.py +0 -0
  33. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/provider.py +0 -0
  34. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/providers/__init__.py +0 -0
  35. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/providers/graph.py +0 -0
  36. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/providers/lsp.py +0 -0
  37. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/providers/none.py +0 -0
  38. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/providers/semantic.py +0 -0
  39. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/reindexer.py +0 -0
  40. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/reset.py +0 -0
  41. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/searcher.py +0 -0
  42. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/semantic_db.py +0 -0
  43. {codecortex-0.7.0 → codecortex-0.8.1}/src/codeintel/term.py +0 -0
  44. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_cache.py +0 -0
  45. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_config.py +0 -0
  46. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_doctor.py +0 -0
  47. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_e2e.py +0 -0
  48. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_enterprise.py +0 -0
  49. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_gateway.py +0 -0
  50. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_graph_provider.py +0 -0
  51. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_graph_real.py +0 -0
  52. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_graph_stdin.py +0 -0
  53. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_hardening.py +0 -0
  54. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_http_auth.py +0 -0
  55. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_http_server.py +0 -0
  56. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_integration.py +0 -0
  57. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_lsp_provider.py +0 -0
  58. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_lsp_real.py +0 -0
  59. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_never_raise.py +0 -0
  60. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_onboarding.py +0 -0
  61. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_rbac.py +0 -0
  62. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_reindexer.py +0 -0
  63. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_rerank.py +0 -0
  64. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_reset.py +0 -0
  65. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_semantic_provider.py +0 -0
  66. {codecortex-0.7.0 → codecortex-0.8.1}/tests/test_term.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.7.0
3
+ Version: 0.8.1
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.7.0
3
+ Version: 0.8.1
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"
@@ -59,4 +59,5 @@ tests/test_reindexer.py
59
59
  tests/test_rerank.py
60
60
  tests/test_reset.py
61
61
  tests/test_semantic_provider.py
62
- 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.1"
@@ -166,8 +166,12 @@ def main() -> None:
166
166
  gen = MapGenerator(provider)
167
167
  try:
168
168
  content = gen.generate(project_root, budget_bytes=args.budget)
169
- path = gen.write(project_root, content)
170
- print(f"Wrote {path} ({len(content.encode())} bytes)")
169
+ path, wrote = gen.write(project_root, content)
170
+ if wrote:
171
+ print(f"Wrote {path} ({len(content.encode())} bytes)")
172
+ else:
173
+ print(f"Kept existing {path} — new map was a stub (graph empty/unindexed; "
174
+ f"run `codeintel index` first)")
171
175
  if args.inject:
172
176
  inj_path, inj_action = Injector().inject(project_root)
173
177
  if inj_path:
@@ -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 -------------------------------------------------------------------
@@ -1,11 +1,30 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import os
4
5
  from typing import Optional, TYPE_CHECKING
5
6
 
6
7
  if TYPE_CHECKING:
7
8
  from codeintel.providers.graph import GraphProvider
8
9
 
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # A map with any of these sections carries real, indexed orientation content. Anything without
13
+ # them is a degraded stub: `_minimal_map` (graph unavailable / repo unindexed / generation failed),
14
+ # or a render over an empty graph. Note the ranked marker includes "(by caller count)" — the
15
+ # empty-graph render emits a differently-worded "## Ranked Symbols" heading that is NOT this.
16
+ _POPULATED_MARKERS = (
17
+ "## Architecture Overview",
18
+ "## Ranked Symbols (by caller count)",
19
+ "## Entry Points",
20
+ )
21
+
22
+
23
+ def _is_populated_map(text: str) -> bool:
24
+ """True when the map has real content (architecture overview, a ranked-symbol table, or entry
25
+ points) rather than a degraded stub — used to avoid overwriting a good CODE_INTEL.md."""
26
+ return any(marker in text for marker in _POPULATED_MARKERS)
27
+
9
28
  # Real codebase-memory-mcp contract (verified live): query_graph returns {columns, rows}
10
29
  # value-arrays, module-level calls are USAGE (not only CALLS), and nodes carry `is_entry_point`
11
30
  # (there is no `fn.type` property — that filter matched nothing, so the old queries were dead).
@@ -82,15 +101,38 @@ class MapGenerator:
82
101
  except Exception as exc:
83
102
  return _minimal_map(project_root, note=f"map generation failed: {exc}")
84
103
 
85
- def write(self, project_root: str, content: str) -> str:
104
+ def write(self, project_root: str, content: str) -> tuple[str, bool]:
105
+ """Write CODE_INTEL.md; return ``(path, wrote)``. ``wrote`` is False when an existing
106
+ populated map was preserved rather than clobbered by a stub (or the write failed), so
107
+ callers can report the outcome honestly.
108
+
109
+ Don't clobber a good map with a degraded stub: `generate` returns a stub when the graph
110
+ backend is unavailable or hasn't indexed this repo yet — a common transient during
111
+ `codeintel index`'s best-effort refresh. Overwriting a previously-populated CODE_INTEL.md
112
+ with that stub silently destroys real orientation content, so when the new content is a
113
+ stub and an existing map is populated, preserve the existing file."""
86
114
  path = os.path.join(project_root, "CODE_INTEL.md")
115
+ if not _is_populated_map(content):
116
+ try:
117
+ if os.path.isfile(path):
118
+ with open(path, encoding="utf-8") as f:
119
+ existing = f.read()
120
+ if _is_populated_map(existing):
121
+ logger.warning(
122
+ "CODE_INTEL.md: new map is a stub (graph empty/unavailable) — keeping "
123
+ "the existing populated map at %s (run `codeintel index` to refresh)",
124
+ path,
125
+ )
126
+ return path, False
127
+ except Exception:
128
+ pass # can't read the existing file — fall through and write (best-effort)
87
129
  try:
88
130
  with open(path, "w", encoding="utf-8") as f:
89
131
  f.write(content)
132
+ return path, True
90
133
  except Exception as exc:
91
- import logging
92
- logging.getLogger(__name__).warning("CODE_INTEL.md write failed: %s", exc)
93
- return path
134
+ logger.warning("CODE_INTEL.md write failed: %s", exc)
135
+ return path, False
94
136
 
95
137
 
96
138
  def _query_ranked_symbols(provider: GraphProvider, project: str) -> list[dict]:
@@ -195,15 +195,17 @@ def code_map_handler(args: dict) -> dict:
195
195
 
196
196
  gen = MapGenerator(provider)
197
197
  content = gen.generate(project_root, budget_bytes=budget)
198
- path = gen.write(project_root, content)
199
- size = len(content.encode("utf-8"))
198
+ path, wrote = gen.write(project_root, content)
199
+ # size_bytes = bytes actually written; 0 when an existing populated map was preserved
200
+ # (the new content was a stub) — `wrote` disambiguates for the caller.
201
+ size = len(content.encode("utf-8")) if wrote else 0
200
202
 
201
203
  inject_result = None
202
204
  if inject:
203
205
  inj_path, inj_action = Injector().inject(project_root)
204
206
  inject_result = {"path": inj_path, "action": inj_action}
205
207
 
206
- return {"ok": True, "path": path, "size_bytes": size, "inject": inject_result}
208
+ return {"ok": True, "path": path, "size_bytes": size, "wrote": wrote, "inject": inject_result}
207
209
  except Exception:
208
210
  return {"ok": True, "path": None, "size_bytes": 0, "note": "map-error"}
209
211
 
@@ -195,12 +195,19 @@ def test_nul_byte_source_falls_back_without_raising():
195
195
  assert spans == idx._window_spans(0, len(lines))
196
196
 
197
197
 
198
- def test_non_python_file_always_windows():
199
- src = "".join(f"col{i},val{i}\n" for i in range(30))
200
- lines = src.splitlines(keepends=True)
201
- idx = Indexer(_mem_db()) # syntax strategy, but a .md/.ts/.csv is never AST-parsed
202
- for name in ("data.md", "app.ts", "notes.txt"):
203
- assert idx._spans_for_file(Path(name), lines, name) == idx._window_spans(0, len(lines))
198
+ def test_non_code_exts_window_but_treesitter_langs_do_not():
199
+ # .md / unmapped exts are never parsed → line windows (as of 0.8.0 tree-sitter handles the
200
+ # code langs; see tests/test_treesitter.py for the def-aligned behavior)
201
+ csv = "".join(f"col{i},val{i}\n" for i in range(30))
202
+ csv_lines = csv.splitlines(keepends=True)
203
+ idx = Indexer(_mem_db())
204
+ for name in ("data.md", "notes.txt"):
205
+ assert idx._spans_for_file(Path(name), csv_lines, name) == idx._window_spans(0, len(csv_lines))
206
+ # a real .ts file with definitions is def-aligned, NOT windowed
207
+ ts = "function a() {\n return 1;\n}\nfunction b() {\n return 2;\n}\n".splitlines(keepends=True)
208
+ ts_spans = idx._spans_for_file(Path("app.ts"), ts, "app.ts")
209
+ assert ts_spans != idx._window_spans(0, len(ts))
210
+ assert (0, 3) in ts_spans and (3, 6) in ts_spans # each function its own chunk
204
211
 
205
212
 
206
213
  # --------------------------------------------------------------------------- 5: "lines" parity
@@ -6,9 +6,15 @@ from unittest.mock import MagicMock
6
6
 
7
7
  import pytest
8
8
 
9
- from codeintel.mapper import MapGenerator
9
+ from codeintel.mapper import MapGenerator, _minimal_map, _is_populated_map
10
10
  from codeintel.injector import Injector
11
11
 
12
+ _POPULATED = (
13
+ "# CODE_INTEL.md — repo\n\n"
14
+ "## Ranked Symbols (by caller count)\n"
15
+ "| Symbol | File | Callers |\n|--------|------|---------|\n| `foo` | a.py | 9 |\n"
16
+ )
17
+
12
18
 
13
19
  def _make_provider(ranked=None, entry=None, arch=None):
14
20
  """Return a mocked GraphProvider with configurable return values."""
@@ -117,13 +123,61 @@ def test_generate_reads_columns_rows_shape():
117
123
  def test_write_creates_file():
118
124
  gen = MapGenerator(None)
119
125
  with tempfile.TemporaryDirectory() as d:
120
- path = gen.write(d, "# content")
126
+ path, wrote = gen.write(d, "# content")
121
127
  expected = os.path.join(d, "CODE_INTEL.md")
122
- assert path == expected
128
+ assert path == expected and wrote is True
123
129
  assert os.path.exists(expected)
124
130
  assert open(expected).read() == "# content"
125
131
 
126
132
 
133
+ def test_write_preserves_populated_map_when_new_is_a_stub():
134
+ # the dogfooding bug: `codeintel index`'s best-effort refresh produced a stub (graph empty/
135
+ # unindexed at that moment) and clobbered a rich, populated CODE_INTEL.md. It must NOT.
136
+ gen = MapGenerator(None)
137
+ with tempfile.TemporaryDirectory() as d:
138
+ path = os.path.join(d, "CODE_INTEL.md")
139
+ open(path, "w").write(_POPULATED)
140
+ stub = _minimal_map(d, note="project not yet indexed")
141
+ assert not _is_populated_map(stub) and _is_populated_map(_POPULATED) # sanity
142
+ p, wrote = gen.write(d, stub)
143
+ assert p == path and wrote is False # preserved → reported as not written
144
+ assert open(path).read() == _POPULATED # preserved, not stubbed
145
+
146
+
147
+ def test_write_stub_ok_when_no_existing_map():
148
+ # first-ever generation of a stub is fine — there's nothing to preserve
149
+ gen = MapGenerator(None)
150
+ with tempfile.TemporaryDirectory() as d:
151
+ _, wrote = gen.write(d, _minimal_map(d, note="x"))
152
+ assert wrote is True
153
+ assert "Auto-generated" in open(os.path.join(d, "CODE_INTEL.md")).read()
154
+
155
+
156
+ def test_write_populated_replaces_a_stub():
157
+ # a real map SHOULD replace a prior stub — the normal refresh-improves case still works
158
+ gen = MapGenerator(None)
159
+ with tempfile.TemporaryDirectory() as d:
160
+ path = os.path.join(d, "CODE_INTEL.md")
161
+ open(path, "w").write(_minimal_map(d, note="stale"))
162
+ _, wrote = gen.write(d, _POPULATED)
163
+ assert wrote is True
164
+ assert _is_populated_map(open(path).read())
165
+
166
+
167
+ def test_entry_points_only_map_counts_as_populated():
168
+ # a sparse-but-real render (entry points, no arch/ranked table) is NOT a stub — it must refresh,
169
+ # not be misclassified and skipped (regression for the review's false-negative finding)
170
+ entry_only = ("# CODE_INTEL.md — repo\n\n## Ranked Symbols\n_(no symbols found)_\n\n"
171
+ "## Entry Points\n- `main` (app.py)\n")
172
+ assert _is_populated_map(entry_only)
173
+ gen = MapGenerator(None)
174
+ with tempfile.TemporaryDirectory() as d:
175
+ path = os.path.join(d, "CODE_INTEL.md")
176
+ open(path, "w").write(_POPULATED)
177
+ _, wrote = gen.write(d, entry_only) # real content replaces the old map, not skipped
178
+ assert wrote is True and "Entry Points" in open(path).read()
179
+
180
+
127
181
  # ---------------------------------------------------------------------------
128
182
  # Injector tests
129
183
  # ---------------------------------------------------------------------------
@@ -0,0 +1,185 @@
1
+ """Tree-sitter chunking (0.8.0, Phase 3 of docs/roadmap-semantic.md).
2
+
3
+ Extends 0.6.0 syntax-aware chunking to non-Python languages (TS/JS/Go/Rust/Java/C/C++) via
4
+ tree-sitter, behind the same `_primary_spans → _cover` pipeline. `tree-sitter-language-pack` is a
5
+ normal dependency; these tests exercise real grammars, plus the graceful fallback to line-windowing
6
+ when a parser is unavailable or a file fails to yield defs.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from unittest.mock import patch
12
+
13
+ import pytest
14
+
15
+ from codeintel.indexer import Indexer, _INDEXED_EXTS, _TS_LANG_BY_EXT
16
+ from codeintel.semantic_db import SemanticDb
17
+
18
+ # A normal dependency — but skip cleanly rather than error if a platform lacks a wheel.
19
+ pytest.importorskip("tree_sitter_language_pack")
20
+
21
+
22
+ def _idx(**kw) -> Indexer:
23
+ db = SemanticDb(":memory:")
24
+ db.init()
25
+ return Indexer(db, **kw)
26
+
27
+
28
+ def _spans(name: str, src: str, **kw):
29
+ lines = src.splitlines(keepends=True)
30
+ return _idx(**kw)._spans_for_file(Path(name), lines, name), len(lines)
31
+
32
+
33
+ def _assert_gapless_cover(spans, n):
34
+ assert spans and spans[0][0] == 0, f"must start at 0: {spans}"
35
+ assert len({s for s, _ in spans}) == len(spans), "duplicate start → chunk_id collision"
36
+ frontier = 0
37
+ for s, e in sorted(spans):
38
+ assert s < e, f"empty/negative span {(s, e)}"
39
+ assert s <= frontier, f"gap before {(s, e)} (covered to {frontier})"
40
+ frontier = max(frontier, e)
41
+ assert frontier == n, f"must cover to {n}, reached {frontier}"
42
+
43
+
44
+ # --------------------------------------------------------------------------- per-language def-alignment
45
+
46
+ def test_typescript_class_and_function_are_def_aligned():
47
+ src = (
48
+ "export class Widget {\n" # 0 class header
49
+ " greet(name: string) {\n" # 1 method
50
+ " return name;\n" # 2
51
+ " }\n" # 3
52
+ "}\n" # 4
53
+ "function top() {\n" # 5 top-level function
54
+ " return 1;\n" # 6
55
+ "}\n" # 7
56
+ )
57
+ spans, n = _spans("a.ts", src)
58
+ _assert_gapless_cover(spans, n)
59
+ starts = {s for s, _ in spans}
60
+ assert 0 in starts # class header chunk begins on the class line
61
+ assert 1 in starts # the method is its own chunk (not shadowed by a whole-class chunk)
62
+ assert 5 in starts # the top-level function is its own chunk
63
+
64
+
65
+ def test_go_functions_and_methods():
66
+ src = ("package p\n\n"
67
+ "func Free() int {\n\treturn 1\n}\n\n"
68
+ "func (t T) Method() int {\n\treturn 2\n}\n")
69
+ spans, n = _spans("b.go", src)
70
+ _assert_gapless_cover(spans, n)
71
+ starts = {s for s, _ in spans}
72
+ assert 2 in starts # func Free at line 2 (0-based)
73
+ assert 6 in starts # method at line 6
74
+
75
+
76
+ def test_rust_impl_method_is_its_own_chunk():
77
+ src = ("struct S { x: i32 }\n\n"
78
+ "impl S {\n"
79
+ " fn method(&self) -> i32 {\n"
80
+ " self.x\n"
81
+ " }\n"
82
+ "}\n")
83
+ spans, n = _spans("c.rs", src)
84
+ _assert_gapless_cover(spans, n)
85
+ assert 3 in {s for s, _ in spans} # fn method at line 3, carved out of the impl block
86
+
87
+
88
+ def test_java_class_method():
89
+ src = ("class A {\n"
90
+ " void m() {\n"
91
+ " return;\n"
92
+ " }\n"
93
+ "}\n")
94
+ spans, n = _spans("d.java", src)
95
+ _assert_gapless_cover(spans, n)
96
+ starts = {s for s, _ in spans}
97
+ assert 0 in starts and 1 in starts # class header + method m
98
+
99
+
100
+ def test_cpp_class_and_free_function():
101
+ src = ("class C {\npublic:\n int m() { return 1; }\n};\nint free_fn() { return 2; }\n")
102
+ spans, n = _spans("e.cpp", src)
103
+ _assert_gapless_cover(spans, n)
104
+
105
+
106
+ def test_multiple_functions_are_separate_chunks():
107
+ src = "function a() {\n return 1;\n}\nfunction b() {\n return 2;\n}\n"
108
+ spans, n = _spans("m.ts", src)
109
+ _assert_gapless_cover(spans, n)
110
+ assert (0, 3) in spans and (3, 6) in spans # each function whole and distinct
111
+
112
+
113
+ # --------------------------------------------------------------------------- fallbacks / never-raise
114
+
115
+ def test_error_tolerant_source_still_covers_and_never_raises():
116
+ spans, n = _spans("bad.js", "this is @#$ not valid javascript !!!\n")
117
+ _assert_gapless_cover(spans, n) # tree-sitter is error-tolerant → partial tree, no raise
118
+
119
+
120
+ def test_lines_strategy_bypasses_treesitter():
121
+ src = "function foo() {\n return 1;\n}\n"
122
+ lines = src.splitlines(keepends=True)
123
+ idx = _idx(chunk_strategy="lines")
124
+ assert idx._spans_for_file(Path("x.ts"), lines, "x.ts") == idx._window_spans(0, len(lines))
125
+
126
+
127
+ def test_unmapped_exts_window():
128
+ src = "".join(f"line {i}\n" for i in range(25))
129
+ lines = src.splitlines(keepends=True)
130
+ idx = _idx()
131
+ for name in ("readme.md", "notes.txt"):
132
+ assert idx._spans_for_file(Path(name), lines, name) == idx._window_spans(0, len(lines))
133
+
134
+
135
+ def test_falls_back_to_windowing_when_parser_unavailable():
136
+ src = "function foo() {\n return 1;\n}\n"
137
+ lines = src.splitlines(keepends=True)
138
+ idx = _idx()
139
+ with patch.object(idx, "_get_ts_parser", return_value=None): # simulate the dep not installed
140
+ assert idx._spans_for_file(Path("x.ts"), lines, "x.ts") == idx._window_spans(0, len(lines))
141
+
142
+
143
+ def test_never_raises_on_parser_exception():
144
+ src = "function foo() {\n return 1;\n}\n"
145
+ lines = src.splitlines(keepends=True)
146
+ idx = _idx()
147
+
148
+ class _Boom:
149
+ def parse(self, *a, **k):
150
+ raise RuntimeError("grammar exploded")
151
+
152
+ with patch.object(idx, "_get_ts_parser", return_value=_Boom()):
153
+ assert idx._spans_for_file(Path("x.ts"), lines, "x.ts") == idx._window_spans(0, len(lines))
154
+
155
+
156
+ def test_parser_cache_loads_each_language_once():
157
+ idx = _idx()
158
+ with patch("tree_sitter_language_pack.get_parser") as gp:
159
+ gp.return_value = object()
160
+ idx._get_ts_parser("go")
161
+ idx._get_ts_parser("go")
162
+ idx._get_ts_parser("rust")
163
+ assert gp.call_count == 2 # one load per distinct language, then cached
164
+
165
+
166
+ def test_typescript_interface_decomposes_into_members():
167
+ # interface method members are `method_signature` (distinct from abstract-class members); the
168
+ # interface must decompose into a header + per-method chunks, not one whole-interface chunk
169
+ src = ("interface Repo {\n" # 0 header
170
+ " get(id: string): Item;\n" # 1 method_signature
171
+ " put(item: Item): void;\n" # 2 method_signature
172
+ "}\n") # 3
173
+ spans, n = _spans("r.ts", src)
174
+ _assert_gapless_cover(spans, n)
175
+ starts = {s for s, _ in spans}
176
+ assert 1 in starts and 2 in starts, f"interface methods must be their own chunks: {spans}"
177
+
178
+
179
+ def test_lang_map_and_indexed_exts_agree_both_ways():
180
+ code_exts = _INDEXED_EXTS - {".py", ".md"}
181
+ # every walked code ext maps to a tree-sitter language...
182
+ assert code_exts <= set(_TS_LANG_BY_EXT), f"walked but unmapped: {code_exts - set(_TS_LANG_BY_EXT)}"
183
+ # ...AND every mapped ext is actually walked (else it's indexed by nothing — silent omission)
184
+ assert set(_TS_LANG_BY_EXT) <= _INDEXED_EXTS, \
185
+ f"mapped but never walked: {set(_TS_LANG_BY_EXT) - _INDEXED_EXTS}"
@@ -1 +0,0 @@
1
- __version__ = "0.7.0"
File without changes
File without changes
File without changes
File without changes