codecortex 0.8.4__tar.gz → 0.9.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.
Files changed (69) hide show
  1. {codecortex-0.8.4/src/codecortex.egg-info → codecortex-0.9.0}/PKG-INFO +1 -1
  2. {codecortex-0.8.4 → codecortex-0.9.0/src/codecortex.egg-info}/PKG-INFO +1 -1
  3. codecortex-0.9.0/src/codeintel/__init__.py +1 -0
  4. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/doctor.py +15 -0
  5. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/gateway.py +28 -5
  6. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/indexer.py +27 -4
  7. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/providers/graph.py +191 -2
  8. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/server.py +5 -1
  9. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_doctor.py +19 -0
  10. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_gateway.py +30 -0
  11. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_graph_real.py +220 -0
  12. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_treesitter.py +23 -0
  13. codecortex-0.8.4/src/codeintel/__init__.py +0 -1
  14. {codecortex-0.8.4 → codecortex-0.9.0}/LICENSE +0 -0
  15. {codecortex-0.8.4 → codecortex-0.9.0}/README.md +0 -0
  16. {codecortex-0.8.4 → codecortex-0.9.0}/pyproject.toml +0 -0
  17. {codecortex-0.8.4 → codecortex-0.9.0}/setup.cfg +0 -0
  18. {codecortex-0.8.4 → codecortex-0.9.0}/src/codecortex.egg-info/SOURCES.txt +0 -0
  19. {codecortex-0.8.4 → codecortex-0.9.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
  20. {codecortex-0.8.4 → codecortex-0.9.0}/src/codecortex.egg-info/entry_points.txt +0 -0
  21. {codecortex-0.8.4 → codecortex-0.9.0}/src/codecortex.egg-info/requires.txt +0 -0
  22. {codecortex-0.8.4 → codecortex-0.9.0}/src/codecortex.egg-info/top_level.txt +0 -0
  23. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/__main__.py +0 -0
  24. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/auth.py +0 -0
  25. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/cache.py +0 -0
  26. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/config.py +0 -0
  27. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/http_server.py +0 -0
  28. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/injector.py +0 -0
  29. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/installer.py +0 -0
  30. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/logconfig.py +0 -0
  31. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/mapper.py +0 -0
  32. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/metrics.py +0 -0
  33. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/onboarding.py +0 -0
  34. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/policy.py +0 -0
  35. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/provider.py +0 -0
  36. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/providers/__init__.py +0 -0
  37. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/providers/lsp.py +0 -0
  38. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/providers/none.py +0 -0
  39. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/providers/semantic.py +0 -0
  40. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/reindexer.py +0 -0
  41. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/reset.py +0 -0
  42. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/searcher.py +0 -0
  43. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/semantic_db.py +0 -0
  44. {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/term.py +0 -0
  45. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_cache.py +0 -0
  46. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_chunking.py +0 -0
  47. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_config.py +0 -0
  48. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_e2e.py +0 -0
  49. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_enterprise.py +0 -0
  50. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_graph_provider.py +0 -0
  51. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_graph_stdin.py +0 -0
  52. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_hardening.py +0 -0
  53. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_http_auth.py +0 -0
  54. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_http_server.py +0 -0
  55. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_installer.py +0 -0
  56. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_integration.py +0 -0
  57. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_lsp_provider.py +0 -0
  58. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_lsp_real.py +0 -0
  59. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_mapper.py +0 -0
  60. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_mcp_server.py +0 -0
  61. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_model_dimension.py +0 -0
  62. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_never_raise.py +0 -0
  63. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_onboarding.py +0 -0
  64. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_rbac.py +0 -0
  65. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_reindexer.py +0 -0
  66. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_rerank.py +0 -0
  67. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_reset.py +0 -0
  68. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_semantic_provider.py +0 -0
  69. {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_term.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.8.4
3
+ Version: 0.9.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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.8.4
3
+ Version: 0.9.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
@@ -0,0 +1 @@
1
+ __version__ = "0.9.0"
@@ -102,11 +102,21 @@ def run_doctor(
102
102
  "runnable": False, "repo_indexed": None,
103
103
  "detail": "semantic provider unavailable", "remediation": None}
104
104
 
105
+ # Is def-aligned (tree-sitter) chunking active for non-Python files? The semantic engine works
106
+ # without it (line-window fallback) but SILENTLY — surface it so a broken/missing grammar pack
107
+ # doesn't degrade chunking invisibly. find_spec (not import) keeps this cheap + never-load.
108
+ try:
109
+ import importlib.util
110
+ treesitter = importlib.util.find_spec("tree_sitter_language_pack") is not None
111
+ except Exception:
112
+ treesitter = False
113
+
105
114
  ready = sum(1 for e in engines.values() if e.get("status") != "fail")
106
115
  return {
107
116
  "ok": True,
108
117
  "project_root": root,
109
118
  "deep": bool(deep),
119
+ "treesitter": treesitter,
110
120
  "summary": {"ready": ready, "total": len(engines),
111
121
  "healthy": all(e.get("status") != "fail" for e in engines.values())},
112
122
  "engines": engines,
@@ -163,4 +173,9 @@ def render_doctor_text(report: dict) -> str:
163
173
  "tip: `codeintel setup --install-uv --install-deps --index` bootstraps the "
164
174
  "pip-installable backends; each fix: line above has the per-engine command."
165
175
  ))
176
+ if report.get("treesitter") is False:
177
+ out.append(" " + c.dim(
178
+ "def-aligned chunking: OFF for non-Python — tree-sitter-language-pack not importable, "
179
+ "so TS/JS/Go/Rust/… fall back to line windows. fix: pip install tree-sitter-language-pack"
180
+ ))
166
181
  return "\n".join(out)
@@ -20,11 +20,21 @@ _AUTO_ENGINE: dict[str, str] = {
20
20
  "chain": "graph",
21
21
  "pattern": "graph",
22
22
  "overview": "graph",
23
+ "changed": "graph",
24
+ "changes": "graph",
25
+ "deadcode": "graph",
26
+ "hotspots": "graph",
23
27
  "symbol": "lsp",
24
28
  "search": "semantic",
25
29
  "context": "both", # fan-out; resolved in Phase 4
26
30
  }
27
31
 
32
+ # Ops whose answer depends on live, unhashable state (the git worktree) rather than the indexed
33
+ # content the cache key is built from — caching them would serve a stale answer within a freshness
34
+ # generation. `changed` reads uncommitted edits; `deadcode`/`hotspots` are pure functions of the
35
+ # index and stay cached (correctly keyed by the freshness generation).
36
+ _UNCACHED_OPS: frozenset[str] = frozenset({"changed", "changes"})
37
+
28
38
 
29
39
  class Gateway:
30
40
  def __init__(self, graph=None, lsp=None, semantic=None, policy: TieringPolicy | None = None, reindexer: Reindexer | None = None):
@@ -199,9 +209,17 @@ class Gateway:
199
209
  except Exception:
200
210
  freshness = 0
201
211
 
212
+ # Ops that read live, unhashable state (the git worktree) must never be served from the
213
+ # content-hash cache — it can't see uncommitted edits. Computed ONCE here so it covers
214
+ # BOTH the fan-out and the single-engine paths below (a miss on either serves a stale diff).
215
+ uncacheable = op_str in _UNCACHED_OPS
216
+
202
217
  # Fan-out: dispatch to multiple engines concurrently and merge
203
218
  if engine_str in _FANOUT_ENGINES:
204
- cached_result = self._cache.get(op_str, target_str, engine_str, root_str, freshness)
219
+ cached_result = (
220
+ None if uncacheable
221
+ else self._cache.get(op_str, target_str, engine_str, root_str, freshness)
222
+ )
205
223
  if cached_result is not None:
206
224
  return {**cached_result, "cached": True}
207
225
  if engine_str == "both":
@@ -210,11 +228,15 @@ class Gateway:
210
228
  engines = ["graph", "lsp", "semantic"]
211
229
  fan_results = self._fan_out(engines, op_str, target_str, budget, project_root)
212
230
  result = self._merge(fan_results, op_str, target_str, engine_str)
213
- self._cache.put(op_str, target_str, engine_str, root_str, result, freshness)
231
+ if not uncacheable:
232
+ self._cache.put(op_str, target_str, engine_str, root_str, result, freshness)
214
233
  return result
215
234
 
216
- # Single-engine dispatch
217
- cached_result = self._cache.get(op_str, target_str, engine_str, root_str, freshness)
235
+ # Single-engine dispatch (`uncacheable`, computed above, also guards this path).
236
+ cached_result = (
237
+ None if uncacheable
238
+ else self._cache.get(op_str, target_str, engine_str, root_str, freshness)
239
+ )
218
240
  if cached_result is not None:
219
241
  return {**cached_result, "cached": True}
220
242
  provider = self._provider_for(engine_str)
@@ -236,7 +258,8 @@ class Gateway:
236
258
  if lsp_result.get("result") is not None:
237
259
  result = lsp_result
238
260
 
239
- self._cache.put(op_str, target_str, engine_str, root_str, result, freshness)
261
+ if not uncacheable:
262
+ self._cache.put(op_str, target_str, engine_str, root_str, result, freshness)
240
263
  return result
241
264
 
242
265
  except Exception as exc:
@@ -85,6 +85,22 @@ _TS_CONTAINER_TYPES = {
85
85
  "c": set(),
86
86
  "cpp": {"class_specifier", "struct_specifier", "namespace_definition"},
87
87
  }
88
+ # JS/TS assign functions to bindings — `const C = () => {}`, `export const f = function(){}` — which
89
+ # is how React components and hooks (and most modern TS) are written. A lexical/variable declaration
90
+ # counts as a def ONLY when a declarator's value is a function, not a plain `const x = 5`.
91
+ _TS_ARROW_LANGS = frozenset({"typescript", "tsx", "javascript"})
92
+ _TS_DECL_TYPES = frozenset({"lexical_declaration", "variable_declaration"})
93
+ _TS_FUNC_VALUE_TYPES = frozenset({"arrow_function", "function_expression", "function",
94
+ "generator_function"})
95
+
96
+
97
+ def _ts_decl_is_function(node) -> bool:
98
+ """True if a lexical/variable declaration binds a function (arrow or function expression)."""
99
+ for child in node.children:
100
+ if child.type == "variable_declarator":
101
+ if any(gc.type in _TS_FUNC_VALUE_TYPES for gc in child.children):
102
+ return True
103
+ return False
88
104
 
89
105
 
90
106
  class Indexer:
@@ -330,19 +346,26 @@ class Indexer:
330
346
  func_types = _TS_FUNC_TYPES.get(lang, set())
331
347
  cont_types = _TS_CONTAINER_TYPES.get(lang, set())
332
348
  all_types = func_types | cont_types
349
+ arrow_ok = lang in _TS_ARROW_LANGS
333
350
  if not all_types:
334
351
  return [] # unknown language -> caller windows the whole file
335
352
  spans: list[tuple[int, int]] = []
336
353
 
354
+ def is_def(c) -> bool:
355
+ if c.type in all_types:
356
+ return True
357
+ # a const/let bound to an arrow/function (React components, hooks, callbacks)
358
+ return arrow_ok and c.type in _TS_DECL_TYPES and _ts_decl_is_function(c)
359
+
337
360
  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."""
361
+ """Def nodes reachable without crossing another def — top-level defs from the root, a
362
+ container's direct members from the container. Iterative, so deep nesting can't blow the
363
+ stack. Function-bound `const`/`let` declarations count as defs (is_def)."""
341
364
  found = []
342
365
  stack = [c for c in reversed(node.children) if c.is_named]
343
366
  while stack:
344
367
  c = stack.pop()
345
- if c.type in all_types:
368
+ if is_def(c):
346
369
  found.append(c)
347
370
  else:
348
371
  stack.extend(g for g in reversed(c.children) if g.is_named)
@@ -28,6 +28,13 @@ class GraphProvider:
28
28
  or ``{"status": "ambiguous", "suggestions": [...]}``.
29
29
  * ``search_code`` → ``{"results": [{node, qualified_name, label, file, match_lines}]}``.
30
30
  * ``get_architecture`` → ``{project, total_nodes, total_edges, node_labels, edge_types, languages}``.
31
+ * ``search_graph`` → ``{"total": N, "results": [{name, qualified_name, file_path, in_degree,
32
+ out_degree, complexity, cognitive, lines, is_test, is_entry_point}, ...]}``
33
+ — degree filters (max_degree/min_degree/exclude_entry_points) + metrics.
34
+ * ``detect_changes`` → ``{"changed_files": [path, ...], "impacted_symbols": [{qualified_name,
35
+ name, file_path}, ...], "changed_count": N, "depth": D}``. changed_files
36
+ come DUPLICATED (staged+unstaged); impacted_symbols interleaves bare file
37
+ markers (label == file_path) with real symbols.
31
38
 
32
39
  Call graph: module-level function calls are recorded as ``USAGE`` edges from the calling
33
40
  ``Module`` node; method/function-to-method calls are ``CALLS`` edges. "Who calls X" therefore
@@ -226,6 +233,61 @@ class GraphProvider:
226
233
  badge = f" [{edge}]" if edge else ""
227
234
  return f"- {label}{badge}{tail}"
228
235
 
236
+ def _search_symbols(self, extra: dict, project: str, timeout_ms: int) -> Optional[list[dict]]:
237
+ """``search_graph`` → parsed result dicts. ``None`` = backend failed/malformed (→ safe-null
238
+ upstream); ``[]`` = backend answered but nothing matched (→ an informative empty render).
239
+ Preserving that distinction is why the repo-scan ops return a string on empty-success but
240
+ ``None`` on can't-answer. Never raises."""
241
+ raw = self._run("search_graph", {"project": project, **extra}, timeout_ms)
242
+ if raw is None:
243
+ return None
244
+ results = raw.get("results") if isinstance(raw, dict) else raw
245
+ if not isinstance(results, list):
246
+ return None
247
+ return [r for r in results if isinstance(r, dict)]
248
+
249
+ @staticmethod
250
+ def _looks_like_test(fp: str, name: str) -> bool:
251
+ """Heuristic test detection. The backend's own ``is_test`` flag comes back False for pytest
252
+ functions (verified by dogfooding), so dead-code / hotspot scans must filter by path+name
253
+ or drown in test noise — this is the single most load-bearing renderer detail."""
254
+ f = (fp or "").lower()
255
+ if f.startswith(("tests/", "test/")) or "/tests/" in f or "/test/" in f:
256
+ return True
257
+ base = f.rsplit("/", 1)[-1]
258
+ return (base.startswith("test_") or base.endswith("_test.py")
259
+ or base == "conftest.py" or (name or "").startswith("test_"))
260
+
261
+ @staticmethod
262
+ def _is_synthetic(fp: str) -> bool:
263
+ """Builtins / generated nodes carry an empty or ``<...>`` file_path (e.g. <python-builtins>)."""
264
+ return (not fp) or fp.startswith("<")
265
+
266
+ @classmethod
267
+ def _is_noise(cls, r: dict) -> bool:
268
+ """Rows a code-quality scan should hide: builtins/generated nodes and test code (the backend's
269
+ own ``is_test`` is unreliable — see ``_looks_like_test``). Shared by deadcode + hotspots."""
270
+ fp = str(r.get("file_path") or "")
271
+ return cls._is_synthetic(fp) or cls._looks_like_test(fp, str(r.get("name") or ""))
272
+
273
+ def _render_scan(self, kept: list[dict], title: str, cap: int, meta_fn) -> str:
274
+ """Render a repo-scan op's markdown from filtered+sorted rows: ``## title (count)`` + one
275
+ ``- label (file) [meta]`` line per row (top ``cap``) + a ``+N more`` note when truncated.
276
+ ``meta_fn(row) -> list[str]`` supplies the per-op metric badge, so deadcode/hotspots share
277
+ the row format and truncation note (the drift-prone parts) and differ only in their metrics."""
278
+ lines = []
279
+ for r in kept[:cap]:
280
+ label = str(r.get("qualified_name") or r.get("name") or "?")
281
+ fp = str(r.get("file_path") or "")
282
+ meta = meta_fn(r)
283
+ badge = f" [{', '.join(meta)}]" if meta else ""
284
+ tail = f" ({fp})" if fp else ""
285
+ lines.append(f"- {label}{tail}{badge}")
286
+ body = "\n".join(lines)
287
+ if len(kept) > cap:
288
+ body += f"\n… (+{len(kept) - cap} more)"
289
+ return f"## {title} ({len(kept)})\n" + body
290
+
229
291
  # ------------------------------------------------------------------ ops
230
292
 
231
293
  def _op_callers(self, target: str, project: str, timeout_ms: int) -> Optional[str]:
@@ -269,7 +331,7 @@ class GraphProvider:
269
331
  return None
270
332
  raw = self._run(
271
333
  "trace_path",
272
- {"project": project, "function_name": src, "mode": "calls"},
334
+ {"project": project, "function_name": src, "mode": "calls", "risk_labels": True},
273
335
  timeout_ms,
274
336
  )
275
337
  if not isinstance(raw, dict):
@@ -291,9 +353,11 @@ class GraphProvider:
291
353
  nm = str(it.get("name") or "?")
292
354
  qn = str(it.get("qualified_name") or "")
293
355
  hop = it.get("hop")
356
+ risk = it.get("risk")
294
357
  label = qn or nm
295
358
  hop_s = f" [hop {hop}]" if hop is not None else ""
296
- out.append(f"- {label}{hop_s}")
359
+ risk_s = f" [risk: {risk}]" if risk else ""
360
+ out.append(f"- {label}{hop_s}{risk_s}")
297
361
  return out
298
362
 
299
363
  callees = _fmt(raw.get("callees"))
@@ -378,6 +442,125 @@ class GraphProvider:
378
442
  except Exception:
379
443
  return None
380
444
 
445
+ # -------------------------------------------------- repo-scan ops (no target)
446
+ # These key on the whole index / git worktree, not a symbol — `target` is ignored. A clean/empty
447
+ # scan is a TRUE answer ("nothing changed", "no dead code"), not a lookup miss, so they return an
448
+ # informative string; only a backend failure returns None (→ safe-null upstream).
449
+
450
+ def _op_changed(self, project: str, timeout_ms: int) -> Optional[str]:
451
+ """Impact of the working tree's UNCOMMITTED changes: changed files → impacted symbols. The
452
+ flagship pre-edit op. detect_changes drives a backend-side reindex of the changed files, so
453
+ it gets a higher timeout floor than a plain read."""
454
+ try:
455
+ raw = self._run("detect_changes", {"project": project}, max(timeout_ms, 15000))
456
+ if not isinstance(raw, dict):
457
+ return None
458
+ files_raw = raw.get("changed_files")
459
+ syms_raw = raw.get("impacted_symbols")
460
+ # Guard against a non-detect_changes dict (e.g. a backend error object): if NEITHER key
461
+ # is a list, this isn't a real response — degrade to safe-null, NOT a false "clean tree".
462
+ if not isinstance(files_raw, list) and not isinstance(syms_raw, list):
463
+ return None
464
+ # The backend returns DUPLICATE changed_files (staged + unstaged views) — dedupe,
465
+ # order-preserving (dogfooding showed 6 real files reported as 11).
466
+ files, seen_f = [], set()
467
+ for f in files_raw if isinstance(files_raw, list) else []:
468
+ if isinstance(f, str) and f not in seen_f:
469
+ seen_f.add(f)
470
+ files.append(f)
471
+ # impacted_symbols interleaves real symbols with bare file/module markers whose label IS
472
+ # its own path (name == qualified_name == file_path). Drop those structurally by comparing
473
+ # label to file_path — this catches a root-level marker (`main.py`, no "/") AND avoids
474
+ # dropping a real symbol whose qualified name legitimately contains "/" (e.g. Go's
475
+ # github.com/org/pkg.Func). Files are already listed above; dedupe the rest.
476
+ syms, seen_s = [], set()
477
+ for s in syms_raw if isinstance(syms_raw, list) else []:
478
+ if not isinstance(s, dict):
479
+ continue
480
+ label = str(s.get("qualified_name") or s.get("name") or "").strip()
481
+ fp = str(s.get("file_path") or s.get("file") or "")
482
+ if not label or label == fp:
483
+ continue
484
+ key = (label, fp)
485
+ if key in seen_s:
486
+ continue
487
+ seen_s.add(key)
488
+ syms.append((label, fp))
489
+ if not files and not syms:
490
+ return "## Changes impact\n(working tree clean — no uncommitted changes)"
491
+ parts = [f"## Changes impact ({len(files)} files → {len(syms)} symbols)"]
492
+ if files:
493
+ parts.append(f"### Changed files ({len(files)})")
494
+ parts.extend(f"- {f}" for f in files[:40])
495
+ if len(files) > 40:
496
+ parts.append(f"… (+{len(files) - 40} more)")
497
+ if syms:
498
+ parts.append(f"### Impacted symbols ({len(syms)})")
499
+ for label, fp in syms[:40]:
500
+ tail = f" ({fp})" if fp and fp != label else ""
501
+ parts.append(f"- {label}{tail}")
502
+ if len(syms) > 40:
503
+ parts.append(f"… (+{len(syms) - 40} more)")
504
+ return "\n".join(parts)
505
+ except Exception:
506
+ return None
507
+
508
+ def _op_deadcode(self, project: str, timeout_ms: int) -> Optional[str]:
509
+ """Unreferenced non-test symbols (dead-code candidates): in-degree 0 Functions, entry points
510
+ excluded server-side, tests/builtins filtered client-side, biggest first."""
511
+ try:
512
+ rows = self._search_symbols(
513
+ {"label": "Function", "max_degree": 0, "exclude_entry_points": True, "limit": 200},
514
+ project, timeout_ms,
515
+ )
516
+ if rows is None:
517
+ return None
518
+ # `is_entry_point` is deliberate belt-and-suspenders on top of the server-side
519
+ # exclude_entry_points flag: it's an external-backend flag we don't independently verify,
520
+ # and main() shown as "dead" would be a visible embarrassment — one cheap client check.
521
+ kept = [r for r in rows if not self._is_noise(r) and not r.get("is_entry_point")]
522
+ if not kept:
523
+ return "## Dead-code candidates\n(none found)"
524
+ kept.sort(key=lambda r: r.get("lines") or 0, reverse=True)
525
+
526
+ def _meta(r: dict) -> list[str]:
527
+ m = []
528
+ if r.get("out_degree") is not None:
529
+ m.append(f"out:{r.get('out_degree')}")
530
+ if r.get("lines") is not None:
531
+ m.append(f"{r.get('lines')} lines")
532
+ return m
533
+
534
+ return self._render_scan(kept, "Dead-code candidates", 30, _meta)
535
+ except Exception:
536
+ return None
537
+
538
+ def _op_hotspots(self, project: str, timeout_ms: int) -> Optional[str]:
539
+ """Highest complexity / fan-in symbols (refactor-risk hotspots). search_graph returns rows
540
+ UNSORTED (name order) and caps at ``limit``, so we over-request then sort CLIENT-SIDE by
541
+ (complexity, in_degree). Tests/builtins filtered out."""
542
+ try:
543
+ rows = self._search_symbols(
544
+ {"label": "Function", "min_degree": 1, "limit": 200}, project, timeout_ms,
545
+ )
546
+ if rows is None:
547
+ return None
548
+ kept = [r for r in rows if not self._is_noise(r)]
549
+ if not kept:
550
+ return "## Complexity / fan-in hotspots\n(none found)"
551
+ kept.sort(key=lambda r: (r.get("complexity") or 0, r.get("in_degree") or 0), reverse=True)
552
+
553
+ def _meta(r: dict) -> list[str]:
554
+ m = [f"in:{r.get('in_degree') or 0} out:{r.get('out_degree') or 0}",
555
+ f"cx:{r.get('complexity') or 0} cog:{r.get('cognitive') or 0}"]
556
+ if r.get("lines") is not None:
557
+ m.append(f"{r.get('lines')} lines")
558
+ return m
559
+
560
+ return self._render_scan(kept, "Complexity / fan-in hotspots", 25, _meta)
561
+ except Exception:
562
+ return None
563
+
381
564
  def build_result(
382
565
  self,
383
566
  op: Any,
@@ -439,4 +622,10 @@ class GraphProvider:
439
622
  return self._op_pattern(target, project, timeout_ms)
440
623
  if op == "overview":
441
624
  return self._op_overview(target, project, timeout_ms)
625
+ if op == "changed" or op == "changes":
626
+ return self._op_changed(project, timeout_ms)
627
+ if op == "deadcode":
628
+ return self._op_deadcode(project, timeout_ms)
629
+ if op == "hotspots":
630
+ return self._op_hotspots(project, timeout_ms)
442
631
  return None
@@ -230,6 +230,8 @@ _MCP_INSTRUCTIONS = (
230
230
  "(callees), impact of a change, where-a-symbol-is-defined, how a call chain flows, and "
231
231
  "natural-language 'find the code that does Y' search. It is graph-augmented and ranked, so it "
232
232
  "beats raw grep for locating and relating code.\n\n"
233
+ "Before you edit, run `changed` to see which symbols your uncommitted edits ripple into; reach "
234
+ "for `hotspots` (complexity/fan-in risk) and `deadcode` when planning a refactor.\n\n"
233
235
  "Orient on a new repo with `code.map` (ranked architecture: top symbols, entry points, routes). "
234
236
  "If results look empty, call `code.doctor` — it says exactly what to index or install. "
235
237
  "`code.status` reports engine health.\n\n"
@@ -269,7 +271,9 @@ def run() -> None:
269
271
  "Understand code across graph + LSP + semantic engines — prefer this over grep/file-read "
270
272
  "for locating and relating code. ops: `search` (natural-language or symbol semantic search), "
271
273
  "`symbol` (definition/signature), `callers`, `callees`, `impact`, `chain` (\"A->B\" call "
272
- "path), `pattern` (graph-augmented grep), `overview` (architecture), `context` (fan-out). "
274
+ "path, risk-labeled), `pattern` (graph-augmented grep), `overview` (architecture), "
275
+ "`context` (fan-out), `changed` (impact of your uncommitted git edits — no target needed), "
276
+ "`deadcode` (unreferenced non-test symbols), `hotspots` (highest fan-in/complexity symbols). "
273
277
  "`target` is the symbol/query; optional `engine` (auto|graph|lsp|semantic), `project_root`. "
274
278
  "Never raises: `ok` is always true; a null `result` + `reason` means not-found/not-indexed."
275
279
  ))
@@ -64,6 +64,25 @@ def test_setup_tip_footer_only_when_unhealthy():
64
64
  assert "codeintel setup" in unhealthy # actionable guidance only when something is missing
65
65
 
66
66
 
67
+ def test_treesitter_advisory_only_when_missing():
68
+ # the silent tree-sitter fallback (found by dogfooding) must be visible: an advisory when
69
+ # def-aligned chunking is OFF, nothing when it's on
70
+ base = {"project_root": "/repo", "deep": False,
71
+ "summary": {"ready": 3, "total": 3, "healthy": True},
72
+ "engines": {"semantic": {"engine": "semantic", "status": "ok", "installed": True,
73
+ "runnable": True, "repo_indexed": True, "detail": "ok",
74
+ "remediation": None}}}
75
+ off = doctor.render_doctor_text({**base, "treesitter": False})
76
+ on = doctor.render_doctor_text({**base, "treesitter": True})
77
+ assert "def-aligned chunking: OFF" in off and "tree-sitter-language-pack" in off
78
+ assert "def-aligned chunking" not in on # no noise when it's active
79
+
80
+
81
+ def test_run_doctor_reports_treesitter_availability():
82
+ r = doctor.run_doctor("/tmp/x", deep=False)
83
+ assert isinstance(r.get("treesitter"), bool) # always reported (bool), never raises
84
+
85
+
67
86
  # --------------------------------------------------------------------------- #
68
87
  # never-raise + no-hang orchestration
69
88
  # --------------------------------------------------------------------------- #
@@ -198,6 +198,36 @@ def test_cache_miss_after_content_change(tmp_path):
198
198
  assert r3["cached"] is False
199
199
 
200
200
 
201
+ def test_changed_op_is_never_cached_but_hotspots_is():
202
+ # `changed` reads the live git worktree — which the content-hash cache key can't see — so it must
203
+ # bypass the cache or it serves a stale diff. `hotspots` is a pure function of the index → cached.
204
+ graph = _StubProvider("graph", "graph-data")
205
+ gw = Gateway(graph=graph)
206
+
207
+ c1 = gw.query(op="changed", target="", engine="graph")
208
+ c2 = gw.query(op="changed", target="", engine="graph")
209
+ assert c1["cached"] is False and c2["cached"] is False # never a cache hit
210
+ assert graph.call_count == 2 # provider re-invoked every call
211
+
212
+ graph.call_count = 0
213
+ h1 = gw.query(op="hotspots", target="", engine="graph")
214
+ h2 = gw.query(op="hotspots", target="", engine="graph")
215
+ assert h1["cached"] is False and h2["cached"] is True # second served from cache
216
+ assert graph.call_count == 1
217
+
218
+
219
+ def test_changed_not_cached_on_fanout_path():
220
+ # Regression for the reviewer's HIGH finding: the fan-out path (engine="both"/"all") must ALSO
221
+ # bypass the cache for `changed` — else an explicit engine="both" serves a stale diff.
222
+ graph = _StubProvider("graph", "graph-data")
223
+ lsp = _StubProvider("lsp", "lsp-data")
224
+ gw = Gateway(graph=graph, lsp=lsp)
225
+ r1 = gw.query(op="changed", target="", engine="both")
226
+ r2 = gw.query(op="changed", target="", engine="both")
227
+ assert r1["cached"] is False and r2["cached"] is False # never a cache hit on fan-out either
228
+ assert graph.call_count == 2 # re-dispatched each call, not cached
229
+
230
+
201
231
  # ---------------------------------------------------------------------------
202
232
  # Test 11: tiering off — role ignored, all ops pass
203
233
  # ---------------------------------------------------------------------------
@@ -97,6 +97,58 @@ CAP_TRACE_AMBIGUOUS = {
97
97
  }
98
98
 
99
99
 
100
+ # detect_changes — real shape (verbatim from `codebase-memory-mcp cli detect_changes`).
101
+ CAP_DETECT_CHANGES = {
102
+ "changed_files": ["src/codeintel/providers/graph.py"],
103
+ "changed_count": 1, "depth": 2,
104
+ "impacted_symbols": [
105
+ {"qualified_name": "codeintel.src.codeintel.providers.graph.GraphProvider._dispatch",
106
+ "name": "_dispatch", "file_path": "src/codeintel/providers/graph.py"}],
107
+ }
108
+ CAP_DETECT_CHANGES_CLEAN = {"changed_files": [], "changed_count": 0, "impacted_symbols": [], "depth": 2}
109
+
110
+ # Real backend quirk (caught by dogfooding): changed_files come back DUPLICATED (staged + unstaged),
111
+ # and impacted_symbols interleaves bare file/module markers (label is a path) with real symbols.
112
+ CAP_DETECT_CHANGES_DUPES = {
113
+ "changed_files": ["src/codeintel/gateway.py", "src/codeintel/gateway.py", "src/codeintel/server.py"],
114
+ "impacted_symbols": [
115
+ {"name": "src/codeintel/gateway.py", "qualified_name": "src/codeintel/gateway.py",
116
+ "file_path": "src/codeintel/gateway.py"}, # module marker → dropped
117
+ {"name": "Gateway", "qualified_name": "codeintel.src.codeintel.gateway.Gateway",
118
+ "file_path": "src/codeintel/gateway.py"},
119
+ {"name": "Gateway", "qualified_name": "codeintel.src.codeintel.gateway.Gateway",
120
+ "file_path": "src/codeintel/gateway.py"}, # exact dup → deduped
121
+ ],
122
+ }
123
+
124
+ # search_graph — real envelope {"total","has_more","results":[{…rich metrics…}]}. The `is_test:false`
125
+ # on a pytest function is the real trap the client-side path filter exists to catch.
126
+ CAP_DEADCODE = {"total": 8, "has_more": False, "results": [
127
+ {"name": "test_x", "qualified_name": "codeintel.tests.test_a.test_x", "file_path": "tests/test_a.py",
128
+ "in_degree": 0, "out_degree": 0, "complexity": 0, "lines": 3, "is_test": False, "is_entry_point": False},
129
+ {"name": "unused_helper", "qualified_name": "codeintel.src.codeintel.foo.unused_helper",
130
+ "file_path": "src/codeintel/foo.py", "in_degree": 0, "out_degree": 2, "complexity": 4, "lines": 18,
131
+ "is_test": False, "is_entry_point": False},
132
+ ]}
133
+ # Deliberately NOT complexity-sorted (backend returns name order) — proves the client-side sort.
134
+ CAP_HOTSPOTS = {"total": 3, "has_more": False, "results": [
135
+ {"name": "code_status_handler", "qualified_name": "codeintel.src.codeintel.server.code_status_handler",
136
+ "file_path": "src/codeintel/server.py", "in_degree": 5, "out_degree": 5, "complexity": 15,
137
+ "cognitive": 34, "lines": 61},
138
+ {"name": "len", "qualified_name": "builtins.len", "file_path": "<python-builtins>",
139
+ "in_degree": 12, "out_degree": 0, "complexity": 0},
140
+ {"name": "main", "qualified_name": "codeintel.src.codeintel.__main__.main",
141
+ "file_path": "src/codeintel/__main__.py", "in_degree": 1, "out_degree": 41, "complexity": 33,
142
+ "cognitive": 110, "lines": 220},
143
+ ]}
144
+ CAP_TRACE_RISK = {
145
+ "function": "code_status_handler", "direction": "both",
146
+ "callees": [{"name": "str", "qualified_name": "builtins.str", "hop": 2, "risk": "HIGH"}],
147
+ "callers": [{"name": "main", "qualified_name": "codeintel.src.codeintel.__main__.main",
148
+ "hop": 1, "risk": "CRITICAL"}],
149
+ }
150
+
151
+
100
152
  def _provider(monkeypatch, responses):
101
153
  """A GraphProvider whose _run replays captured responses keyed by method."""
102
154
  monkeypatch.setattr(
@@ -253,6 +305,174 @@ def test_run_raising_is_caught(monkeypatch):
253
305
  assert r["ok"] is True # never-raise holds even when _run throws mid-query
254
306
 
255
307
 
308
+ # --------------------------------------------------------------------------- #
309
+ # 5 — repo-scan ops: changed / deadcode / hotspots + risk-labeled chain (0.9.0)
310
+ # --------------------------------------------------------------------------- #
311
+
312
+ ROOT = "/Users/x/Documents/project/codeintel"
313
+
314
+
315
+ def _capturing_provider(monkeypatch, method_response):
316
+ """Provider that records the (method, payload, timeout) of the non-list_projects call."""
317
+ seen: dict = {}
318
+ monkeypatch.setattr("codeintel.providers.graph.shutil.which", lambda x: "/fake/codebase-memory-mcp")
319
+ p = GraphProvider()
320
+
321
+ def _cap(method, payload, timeout_ms):
322
+ if method == "list_projects":
323
+ return CAP_LIST_PROJECTS
324
+ seen["method"], seen["payload"], seen["timeout"] = method, payload, timeout_ms
325
+ return method_response
326
+ monkeypatch.setattr(p, "_run", _cap)
327
+ return p, seen
328
+
329
+
330
+ def test_changed_renders_files_and_impacted_symbols(monkeypatch):
331
+ p = _provider(monkeypatch, {"list_projects": CAP_LIST_PROJECTS, "detect_changes": CAP_DETECT_CHANGES})
332
+ r = p.build_result("changed", "", [], 0, ROOT)
333
+ assert r["ok"] is True and r["result"] is not None
334
+ assert "Changes impact (1 files → 1 symbols)" in r["result"]
335
+ assert "src/codeintel/providers/graph.py" in r["result"]
336
+ assert "GraphProvider._dispatch" in r["result"]
337
+
338
+
339
+ def test_changed_clean_tree_is_informative_not_null(monkeypatch):
340
+ # A clean tree is a TRUE answer, not a lookup miss — a string, NOT safe-null (else the op looks broken).
341
+ p = _provider(monkeypatch, {"list_projects": CAP_LIST_PROJECTS, "detect_changes": CAP_DETECT_CHANGES_CLEAN})
342
+ r = p.build_result("changed", "", [], 0, ROOT)
343
+ assert r["result"] is not None and "working tree clean" in r["result"]
344
+
345
+
346
+ def test_changed_dedupes_files_and_drops_module_markers(monkeypatch):
347
+ # Regression for the dogfood finding: 3→2 files (gateway dedup'd); 3→1 symbols (path-marker
348
+ # dropped, Gateway dedup'd). The header count encodes both fixes.
349
+ p = _provider(monkeypatch, {"list_projects": CAP_LIST_PROJECTS, "detect_changes": CAP_DETECT_CHANGES_DUPES})
350
+ r = p.build_result("changed", "", [], 0, ROOT)
351
+ assert "Changes impact (2 files → 1 symbols)" in r["result"]
352
+ assert "Gateway" in r["result"]
353
+
354
+
355
+ def test_changed_backend_failure_is_safe_null(monkeypatch):
356
+ p = _provider(monkeypatch, {"list_projects": CAP_LIST_PROJECTS, "detect_changes": None})
357
+ r = p.build_result("changed", "", [], 0, ROOT)
358
+ assert r["ok"] is True and r["result"] is None
359
+
360
+
361
+ def test_changed_malformed_dict_is_safe_null(monkeypatch):
362
+ # A dict that isn't a detect_changes shape (e.g. a backend error object) must degrade to
363
+ # safe-null, NOT render a false "working tree clean". (reviewer warning)
364
+ p = _provider(monkeypatch, {"list_projects": CAP_LIST_PROJECTS, "detect_changes": {"error": "boom"}})
365
+ r = p.build_result("changed", "", [], 0, ROOT)
366
+ assert r["ok"] is True and r["result"] is None
367
+
368
+
369
+ def test_changed_drops_root_level_file_markers(monkeypatch):
370
+ # A changed file at the project ROOT yields a marker whose label has no "/". The structural
371
+ # label==file_path filter drops it (a "/"-in-label check would leak it as a fake symbol).
372
+ resp = {"changed_files": ["main.py"], "impacted_symbols": [
373
+ {"name": "main.py", "qualified_name": "main.py", "file_path": "main.py"}, # root marker → dropped
374
+ {"name": "run", "qualified_name": "main.run", "file_path": "main.py"}, # real symbol → kept
375
+ ]}
376
+ p = _provider(monkeypatch, {"list_projects": CAP_LIST_PROJECTS, "detect_changes": resp})
377
+ r = p.build_result("changed", "", [], 0, ROOT)
378
+ assert "Changes impact (1 files → 1 symbols)" in r["result"] # marker dropped, real symbol kept
379
+ assert "main.run" in r["result"]
380
+
381
+
382
+ def test_changed_keeps_symbol_with_slash_in_qualified_name(monkeypatch):
383
+ # Go-style qualified names contain "/". The old "/"-in-label filter would wrongly drop them;
384
+ # label != file_path so the structural filter keeps them. (reviewer inverse-direction finding)
385
+ resp = {"changed_files": ["pkg/svc.go"], "impacted_symbols": [
386
+ {"name": "Serve", "qualified_name": "github.com/org/repo/pkg.Serve", "file_path": "pkg/svc.go"},
387
+ ]}
388
+ p = _provider(monkeypatch, {"list_projects": CAP_LIST_PROJECTS, "detect_changes": resp})
389
+ r = p.build_result("changed", "", [], 0, ROOT)
390
+ assert "github.com/org/repo/pkg.Serve" in r["result"] # kept despite "/" in qualified name
391
+ assert "Changes impact (1 files → 1 symbols)" in r["result"]
392
+
393
+
394
+ def test_changed_payload_ignores_target_and_raises_timeout_floor(monkeypatch):
395
+ p, seen = _capturing_provider(monkeypatch, CAP_DETECT_CHANGES_CLEAN)
396
+ p.build_result("changed", "ignored-target", [], 0, ROOT)
397
+ assert seen["method"] == "detect_changes"
398
+ assert seen["payload"] == {"project": "codeintel"} # target never leaks into the payload
399
+ assert seen["timeout"] >= 15000 # higher floor: it drives a backend reindex
400
+
401
+
402
+ def test_deadcode_filters_tests_and_synthetic(monkeypatch):
403
+ p = _provider(monkeypatch, {"list_projects": CAP_LIST_PROJECTS, "search_graph": CAP_DEADCODE})
404
+ r = p.build_result("deadcode", "", [], 0, ROOT)
405
+ assert r["result"] is not None
406
+ assert "unused_helper" in r["result"] # real dead code kept
407
+ assert "test_x" not in r["result"] # pytest fn dropped despite backend is_test:false
408
+
409
+
410
+ def test_deadcode_all_filtered_is_informative(monkeypatch):
411
+ only_tests = {"total": 1, "results": [CAP_DEADCODE["results"][0]]} # just test_x
412
+ p = _provider(monkeypatch, {"list_projects": CAP_LIST_PROJECTS, "search_graph": only_tests})
413
+ r = p.build_result("deadcode", "", [], 0, ROOT)
414
+ assert r["result"] is not None and "none found" in r["result"]
415
+
416
+
417
+ def test_deadcode_malformed_is_safe_null(monkeypatch):
418
+ p = _provider(monkeypatch, {"list_projects": CAP_LIST_PROJECTS, "search_graph": {"results": "nope"}})
419
+ r = p.build_result("deadcode", "", [], 0, ROOT)
420
+ assert r["ok"] is True and r["result"] is None
421
+
422
+
423
+ def test_deadcode_payload_has_degree_filters(monkeypatch):
424
+ p, seen = _capturing_provider(monkeypatch, CAP_DEADCODE)
425
+ p.build_result("deadcode", "", [], 0, ROOT)
426
+ assert seen["payload"].get("max_degree") == 0
427
+ assert seen["payload"].get("exclude_entry_points") is True
428
+ assert seen["payload"].get("label") == "Function"
429
+
430
+
431
+ def test_hotspots_sorts_by_complexity_client_side(monkeypatch):
432
+ p = _provider(monkeypatch, {"list_projects": CAP_LIST_PROJECTS, "search_graph": CAP_HOTSPOTS})
433
+ r = p.build_result("hotspots", "", [], 0, ROOT)
434
+ assert r["result"] is not None
435
+ # main (cx33) must rank above code_status_handler (cx15) despite the backend's name order.
436
+ assert r["result"].index("__main__.main") < r["result"].index("code_status_handler")
437
+ assert "builtins.len" not in r["result"] # synthetic builtin filtered out
438
+ assert "cx:33" in r["result"]
439
+
440
+
441
+ def test_hotspots_payload_has_min_degree(monkeypatch):
442
+ p, seen = _capturing_provider(monkeypatch, CAP_HOTSPOTS)
443
+ p.build_result("hotspots", "", [], 0, ROOT)
444
+ assert seen["payload"].get("min_degree") is not None
445
+ assert seen["payload"].get("label") == "Function"
446
+
447
+
448
+ def test_chain_carries_risk_labels(monkeypatch):
449
+ p = _provider(monkeypatch, {"list_projects": CAP_LIST_PROJECTS, "trace_path": CAP_TRACE_RISK})
450
+ r = p.build_result("chain", "code_status_handler", [], 0, ROOT)
451
+ assert r["result"] is not None
452
+ assert "[risk: CRITICAL]" in r["result"] and "[risk: HIGH]" in r["result"]
453
+
454
+
455
+ def test_chain_requests_risk_labels(monkeypatch):
456
+ p, seen = _capturing_provider(monkeypatch, CAP_TRACE_RISK)
457
+ p.build_result("chain", "code_status_handler", [], 0, ROOT)
458
+ assert seen["method"] == "trace_path" and seen["payload"].get("risk_labels") is True
459
+
460
+
461
+ def test_new_ops_never_raise_when_run_throws(monkeypatch):
462
+ monkeypatch.setattr("codeintel.providers.graph.shutil.which", lambda x: "/fake/codebase-memory-mcp")
463
+ p = GraphProvider()
464
+
465
+ def _boom(method, payload, timeout_ms):
466
+ if method == "list_projects":
467
+ return CAP_LIST_PROJECTS
468
+ raise RuntimeError("backend exploded")
469
+
470
+ monkeypatch.setattr(p, "_run", _boom)
471
+ for op in ("changed", "deadcode", "hotspots"):
472
+ r = p.build_result(op, "", [], 0, ROOT)
473
+ assert r["ok"] is True, f"{op} must never raise"
474
+
475
+
256
476
  @pytest.mark.skipif(
257
477
  shutil.which("codebase-memory-mcp") is None,
258
478
  reason="codebase-memory-mcp backend not installed",
@@ -97,6 +97,29 @@ def test_java_class_method():
97
97
  assert 0 in starts and 1 in starts # class header + method m
98
98
 
99
99
 
100
+ def test_arrow_function_consts_are_def_aligned():
101
+ # React components / hooks / callbacks are `const X = () => {}`, not `function` declarations —
102
+ # they must def-align too (the common modern-TS/React case); a plain data const must NOT.
103
+ src = (
104
+ "import x from 'y';\n" # 0
105
+ "\n" # 1
106
+ "export const Header = () => {\n" # 2 arrow component
107
+ " return null;\n" # 3
108
+ "};\n" # 4
109
+ "\n" # 5
110
+ "const useThing = (id) => {\n" # 6 arrow hook
111
+ " return id;\n" # 7
112
+ "};\n" # 8
113
+ "\n" # 9
114
+ "const PLAIN = 42;\n" # 10 plain data const — NOT a def
115
+ )
116
+ spans, n = _spans("c.tsx", src)
117
+ _assert_gapless_cover(spans, n)
118
+ starts = {s for s, _ in spans}
119
+ assert 2 in starts and 6 in starts # both arrow consts are their own chunks
120
+ assert 10 not in starts # a plain `const x = 42` is not a def chunk
121
+
122
+
100
123
  def test_cpp_class_and_free_function():
101
124
  src = ("class C {\npublic:\n int m() { return 1; }\n};\nint free_fn() { return 2; }\n")
102
125
  spans, n = _spans("e.cpp", src)
@@ -1 +0,0 @@
1
- __version__ = "0.8.4"
File without changes
File without changes
File without changes
File without changes
File without changes