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.
- {codecortex-0.8.4/src/codecortex.egg-info → codecortex-0.9.0}/PKG-INFO +1 -1
- {codecortex-0.8.4 → codecortex-0.9.0/src/codecortex.egg-info}/PKG-INFO +1 -1
- codecortex-0.9.0/src/codeintel/__init__.py +1 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/doctor.py +15 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/gateway.py +28 -5
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/indexer.py +27 -4
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/providers/graph.py +191 -2
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/server.py +5 -1
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_doctor.py +19 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_gateway.py +30 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_graph_real.py +220 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_treesitter.py +23 -0
- codecortex-0.8.4/src/codeintel/__init__.py +0 -1
- {codecortex-0.8.4 → codecortex-0.9.0}/LICENSE +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/README.md +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/pyproject.toml +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/setup.cfg +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codecortex.egg-info/SOURCES.txt +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codecortex.egg-info/entry_points.txt +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codecortex.egg-info/requires.txt +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codecortex.egg-info/top_level.txt +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/__main__.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/auth.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/cache.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/config.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/http_server.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/injector.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/installer.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/logconfig.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/mapper.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/metrics.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/onboarding.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/policy.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/provider.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/providers/__init__.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/providers/lsp.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/providers/none.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/providers/semantic.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/reindexer.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/reset.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/searcher.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/semantic_db.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/src/codeintel/term.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_cache.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_chunking.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_config.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_e2e.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_enterprise.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_graph_provider.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_graph_stdin.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_hardening.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_http_auth.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_http_server.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_installer.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_integration.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_lsp_provider.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_lsp_real.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_mapper.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_mcp_server.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_model_dimension.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_never_raise.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_onboarding.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_rbac.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_reindexer.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_rerank.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_reset.py +0 -0
- {codecortex-0.8.4 → codecortex-0.9.0}/tests/test_semantic_provider.py +0 -0
- {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.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
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
|
-
|
|
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),
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|