codecortex 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of codecortex might be problematic. Click here for more details.
- codecortex-0.2.0.dist-info/METADATA +206 -0
- codecortex-0.2.0.dist-info/RECORD +31 -0
- codecortex-0.2.0.dist-info/WHEEL +5 -0
- codecortex-0.2.0.dist-info/entry_points.txt +2 -0
- codecortex-0.2.0.dist-info/licenses/LICENSE +21 -0
- codecortex-0.2.0.dist-info/top_level.txt +1 -0
- codeintel/__init__.py +1 -0
- codeintel/__main__.py +361 -0
- codeintel/cache.py +66 -0
- codeintel/config.py +42 -0
- codeintel/doctor.py +161 -0
- codeintel/gateway.py +228 -0
- codeintel/http_server.py +93 -0
- codeintel/indexer.py +250 -0
- codeintel/injector.py +81 -0
- codeintel/installer.py +103 -0
- codeintel/mapper.py +192 -0
- codeintel/onboarding.py +197 -0
- codeintel/policy.py +30 -0
- codeintel/provider.py +52 -0
- codeintel/providers/__init__.py +0 -0
- codeintel/providers/graph.py +415 -0
- codeintel/providers/lsp.py +407 -0
- codeintel/providers/none.py +30 -0
- codeintel/providers/semantic.py +139 -0
- codeintel/reindexer.py +112 -0
- codeintel/reset.py +100 -0
- codeintel/searcher.py +143 -0
- codeintel/semantic_db.py +78 -0
- codeintel/server.py +216 -0
- codeintel/term.py +162 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from codeintel.provider import Result, safe_null_result
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _cypher_literal(s: Any) -> str:
|
|
13
|
+
"""Escape a value for a double-quoted Cypher string literal — defense against a
|
|
14
|
+
``target`` containing quotes/backslashes (e.g. content an agent echoed from a repo)."""
|
|
15
|
+
return str(s).replace("\\", "\\\\").replace('"', '\\"')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GraphProvider:
|
|
19
|
+
"""Wraps the codebase-memory-mcp CLI. Never raises.
|
|
20
|
+
|
|
21
|
+
Backend contract (verified against codebase-memory-mcp 0.9.0 by dogfooding, not assumed):
|
|
22
|
+
* ``list_projects`` → ``{"projects": [{name, root_path, ...}]}``
|
|
23
|
+
* ``query_graph`` → ``{"columns": [...], "rows": [[...], ...], "total": N}`` — rows are
|
|
24
|
+
value-arrays aligned to ``columns``, NOT a list of dicts.
|
|
25
|
+
* ``trace_path`` → ``{function, callees: [{name, qualified_name, hop}], callers: [...]}``
|
|
26
|
+
or ``{"status": "ambiguous", "suggestions": [...]}``.
|
|
27
|
+
* ``search_code`` → ``{"results": [{node, qualified_name, label, file, match_lines}]}``.
|
|
28
|
+
* ``get_architecture`` → ``{project, total_nodes, total_edges, node_labels, edge_types, languages}``.
|
|
29
|
+
|
|
30
|
+
Call graph: module-level function calls are recorded as ``USAGE`` edges from the calling
|
|
31
|
+
``Module`` node; method/function-to-method calls are ``CALLS`` edges. "Who calls X" therefore
|
|
32
|
+
needs BOTH edge types (``[:CALLS|USAGE]``) — ``CALLS`` alone misses every module-level callee
|
|
33
|
+
(that is why the old ``(caller)-[:CALLS]->(fn)`` query returned zero rows for real symbols).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self._project_cache: dict[str, Optional[str]] = {}
|
|
38
|
+
self._detect_backend()
|
|
39
|
+
|
|
40
|
+
def _detect_backend(self) -> None:
|
|
41
|
+
path = shutil.which("codebase-memory-mcp")
|
|
42
|
+
if path:
|
|
43
|
+
self.available = True
|
|
44
|
+
self._cmd: Optional[str] = path
|
|
45
|
+
else:
|
|
46
|
+
self.available = False
|
|
47
|
+
self._cmd = None
|
|
48
|
+
|
|
49
|
+
# Sentinel: distinguishes "the subprocess call failed" from "it succeeded and returned JSON
|
|
50
|
+
# null". Overloading None for both would make a legit null result wrongly trigger the fallback.
|
|
51
|
+
_FAIL = object()
|
|
52
|
+
|
|
53
|
+
def _run(self, method: str, payload: dict, timeout_ms: int) -> Optional[Any]:
|
|
54
|
+
# Prefer PIPED STDIN — the stable, non-deprecated form the backend documents
|
|
55
|
+
# (`echo '<json>' | codebase-memory-mcp cli <method>`; no deprecation warning). Fall back
|
|
56
|
+
# to the deprecated raw-JSON positional arg for one release so an older backend still
|
|
57
|
+
# works. The two attempts SHARE one deadline (the caller's timeout_ms) so total wall time
|
|
58
|
+
# can't double. Never raises. `_run` stays the single seam existing tests patch.
|
|
59
|
+
body = json.dumps(payload)
|
|
60
|
+
deadline = time.monotonic() + max(0.0, timeout_ms / 1000)
|
|
61
|
+
out = self._run_stdin(method, body, timeout_ms)
|
|
62
|
+
if out is not self._FAIL:
|
|
63
|
+
return out # success (including a legit null) → no fallback
|
|
64
|
+
remaining_ms = int((deadline - time.monotonic()) * 1000)
|
|
65
|
+
if remaining_ms <= 0:
|
|
66
|
+
return None
|
|
67
|
+
out = self._run_rawjson(method, body, remaining_ms)
|
|
68
|
+
return None if out is self._FAIL else out
|
|
69
|
+
|
|
70
|
+
def _run_stdin(self, method: str, body: str, timeout_ms: int) -> Any:
|
|
71
|
+
try:
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
[self._cmd, "cli", method],
|
|
74
|
+
input=body.encode(),
|
|
75
|
+
capture_output=True,
|
|
76
|
+
timeout=timeout_ms / 1000,
|
|
77
|
+
)
|
|
78
|
+
if result.returncode != 0:
|
|
79
|
+
return self._FAIL # unsupported / error → let the raw-JSON fallback try
|
|
80
|
+
return json.loads(result.stdout)
|
|
81
|
+
except Exception:
|
|
82
|
+
return self._FAIL
|
|
83
|
+
|
|
84
|
+
def _run_rawjson(self, method: str, body: str, timeout_ms: int) -> Any:
|
|
85
|
+
# Deprecated-but-working bridge for older backends; remove once the live stdin test
|
|
86
|
+
# (tests/test_graph_stdin.py::test_live_stdin_list_projects) is green in CI.
|
|
87
|
+
try:
|
|
88
|
+
result = subprocess.run(
|
|
89
|
+
[self._cmd, "cli", method, body],
|
|
90
|
+
capture_output=True,
|
|
91
|
+
timeout=timeout_ms / 1000,
|
|
92
|
+
)
|
|
93
|
+
if result.returncode != 0:
|
|
94
|
+
return self._FAIL
|
|
95
|
+
return json.loads(result.stdout)
|
|
96
|
+
except Exception:
|
|
97
|
+
return self._FAIL
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _match_project(raw: Any, project_root: str) -> Optional[str]:
|
|
101
|
+
"""Resolve a list_projects response to the project name for ``project_root``.
|
|
102
|
+
|
|
103
|
+
The real codebase-memory-mcp returns ``{"projects": [...]}``; a bare list is the
|
|
104
|
+
older/mocked shape — accept both. Prefer an exact ``root_path`` match; otherwise the
|
|
105
|
+
LONGEST prefix match (so ``.../project/codeintel`` resolves to codeintel, not its
|
|
106
|
+
parent ``.../project``). Pure + static so ``_resolve_project`` and ``probe`` share it."""
|
|
107
|
+
entries = raw.get("projects", []) if isinstance(raw, dict) else raw
|
|
108
|
+
if not isinstance(entries, list):
|
|
109
|
+
return None
|
|
110
|
+
exact: Optional[str] = None
|
|
111
|
+
best_prefix_len = -1
|
|
112
|
+
best_prefix_name: Optional[str] = None
|
|
113
|
+
for entry in entries:
|
|
114
|
+
if not isinstance(entry, dict):
|
|
115
|
+
continue
|
|
116
|
+
rp = entry.get("root_path", "")
|
|
117
|
+
if not rp:
|
|
118
|
+
continue
|
|
119
|
+
if rp == project_root:
|
|
120
|
+
exact = entry.get("name")
|
|
121
|
+
break
|
|
122
|
+
if project_root.startswith(rp.rstrip("/") + "/") and len(rp) > best_prefix_len:
|
|
123
|
+
best_prefix_len = len(rp)
|
|
124
|
+
best_prefix_name = entry.get("name")
|
|
125
|
+
return exact if exact is not None else best_prefix_name
|
|
126
|
+
|
|
127
|
+
def _resolve_project(self, project_root: str) -> Optional[str]:
|
|
128
|
+
if project_root in self._project_cache:
|
|
129
|
+
return self._project_cache[project_root]
|
|
130
|
+
raw = self._run("list_projects", {}, 3000)
|
|
131
|
+
name = self._match_project(raw, project_root)
|
|
132
|
+
self._project_cache[project_root] = name
|
|
133
|
+
return name
|
|
134
|
+
|
|
135
|
+
def probe(self, project_root: str, timeout_ms: int = 3000) -> dict:
|
|
136
|
+
"""Cheap, never-raise, single-subprocess health check for the doctor.
|
|
137
|
+
|
|
138
|
+
Returns ``{installed, runnable, repo_indexed, project, detail, remediation}`` — one
|
|
139
|
+
``list_projects`` call, bounded by ``timeout_ms`` (``_run`` returns None on timeout)."""
|
|
140
|
+
if not self.available:
|
|
141
|
+
return {
|
|
142
|
+
"installed": False, "runnable": False, "repo_indexed": False, "project": None,
|
|
143
|
+
"detail": "codebase-memory-mcp not found on PATH",
|
|
144
|
+
"remediation": "install codebase-memory-mcp (the graph backend)",
|
|
145
|
+
}
|
|
146
|
+
raw = self._run("list_projects", {}, timeout_ms)
|
|
147
|
+
if raw is None:
|
|
148
|
+
return {
|
|
149
|
+
"installed": True, "runnable": False, "repo_indexed": False, "project": None,
|
|
150
|
+
"detail": "codebase-memory-mcp is installed but list_projects failed/timed out",
|
|
151
|
+
"remediation": "check `codebase-memory-mcp cli list_projects '{}'` works",
|
|
152
|
+
}
|
|
153
|
+
project = self._match_project(raw, project_root)
|
|
154
|
+
if project is None:
|
|
155
|
+
return {
|
|
156
|
+
"installed": True, "runnable": True, "repo_indexed": False, "project": None,
|
|
157
|
+
"detail": "backend OK but this repo is not indexed in the graph",
|
|
158
|
+
"remediation": f"codeintel index {project_root}",
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
"installed": True, "runnable": True, "repo_indexed": True, "project": project,
|
|
162
|
+
"detail": f"resolved project '{project}' in codebase-memory-mcp",
|
|
163
|
+
"remediation": None,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------ helpers
|
|
167
|
+
|
|
168
|
+
def _query_rows(self, cypher: str, project: str, timeout_ms: int) -> list[dict]:
|
|
169
|
+
"""Run a Cypher query and return rows as column→value dicts.
|
|
170
|
+
|
|
171
|
+
The real backend returns ``{"columns": [...], "rows": [[v, ...], ...]}`` where each row is
|
|
172
|
+
a value-array aligned to ``columns``. Tolerates the legacy/mocked list-of-dicts shape and
|
|
173
|
+
any malformed response by returning ``[]`` (never raises)."""
|
|
174
|
+
raw = self._run("query_graph", {"project": project, "query": cypher}, timeout_ms)
|
|
175
|
+
if isinstance(raw, list):
|
|
176
|
+
# Legacy/mocked shape: already a list of dicts.
|
|
177
|
+
return [r for r in raw if isinstance(r, dict)]
|
|
178
|
+
if not isinstance(raw, dict):
|
|
179
|
+
return []
|
|
180
|
+
cols = raw.get("columns")
|
|
181
|
+
rows = raw.get("rows")
|
|
182
|
+
if not isinstance(cols, list) or not isinstance(rows, list):
|
|
183
|
+
return []
|
|
184
|
+
out: list[dict] = []
|
|
185
|
+
for row in rows:
|
|
186
|
+
if isinstance(row, list):
|
|
187
|
+
out.append({str(cols[i]): row[i] for i in range(min(len(cols), len(row)))})
|
|
188
|
+
elif isinstance(row, dict):
|
|
189
|
+
out.append(row)
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def _display(row: dict, name_key: str, qn_key: str, file_key: str) -> str:
|
|
194
|
+
name = str(row.get(name_key) or "?")
|
|
195
|
+
qn = str(row.get(qn_key) or "")
|
|
196
|
+
file = str(row.get(file_key) or "")
|
|
197
|
+
edge = str(row.get("type(c)") or "").strip()
|
|
198
|
+
label = qn or name
|
|
199
|
+
tail = f" ({file})" if file and file != qn else ""
|
|
200
|
+
badge = f" [{edge}]" if edge else ""
|
|
201
|
+
return f"- {label}{badge}{tail}"
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------------ ops
|
|
204
|
+
|
|
205
|
+
def _op_callers(self, target: str, project: str, timeout_ms: int) -> Optional[str]:
|
|
206
|
+
cypher = (
|
|
207
|
+
f'MATCH (a)-[c:CALLS|USAGE]->(b) WHERE b.name="{_cypher_literal(target)}" '
|
|
208
|
+
"RETURN a.name, a.qualified_name, a.file_path, type(c) LIMIT 50"
|
|
209
|
+
)
|
|
210
|
+
rows = self._query_rows(cypher, project, timeout_ms)
|
|
211
|
+
if not rows:
|
|
212
|
+
return None
|
|
213
|
+
lines = [self._display(r, "a.name", "a.qualified_name", "a.file_path") for r in rows]
|
|
214
|
+
return f"## Callers of {target} ({len(lines)})\n" + "\n".join(lines)
|
|
215
|
+
|
|
216
|
+
def _op_callees(self, target: str, project: str, timeout_ms: int) -> Optional[str]:
|
|
217
|
+
cypher = (
|
|
218
|
+
f'MATCH (a)-[c:CALLS|USAGE]->(b) WHERE a.name="{_cypher_literal(target)}" '
|
|
219
|
+
"RETURN b.name, b.qualified_name, b.file_path, type(c) LIMIT 50"
|
|
220
|
+
)
|
|
221
|
+
rows = self._query_rows(cypher, project, timeout_ms)
|
|
222
|
+
if not rows:
|
|
223
|
+
return None
|
|
224
|
+
lines = [self._display(r, "b.name", "b.qualified_name", "b.file_path") for r in rows]
|
|
225
|
+
return f"## Callees of {target} ({len(lines)})\n" + "\n".join(lines)
|
|
226
|
+
|
|
227
|
+
def _op_impact(self, target: str, project: str, timeout_ms: int) -> Optional[str]:
|
|
228
|
+
callers = self._op_callers(target, project, timeout_ms)
|
|
229
|
+
callees = self._op_callees(target, project, timeout_ms)
|
|
230
|
+
if callers is None and callees is None:
|
|
231
|
+
return None
|
|
232
|
+
# callers/callees already carry their own "## Callers of X (N)" header — don't wrap them
|
|
233
|
+
# in a second "### Callers" header (that produced a redundant double heading).
|
|
234
|
+
parts = [f"## Impact of {target}"]
|
|
235
|
+
parts.append(callers or f"## Callers of {target} (0)\n(none found)")
|
|
236
|
+
parts.append(callees or f"## Callees of {target} (0)\n(none found)")
|
|
237
|
+
return "\n".join(parts)
|
|
238
|
+
|
|
239
|
+
def _op_chain(self, target: str, project: str, timeout_ms: int) -> Optional[str]:
|
|
240
|
+
# Accept an "A->B" form (trace from the source symbol) or a bare symbol.
|
|
241
|
+
src = target.split("->")[0].strip() if "->" in target else target.strip()
|
|
242
|
+
if not src:
|
|
243
|
+
return None
|
|
244
|
+
raw = self._run(
|
|
245
|
+
"trace_path",
|
|
246
|
+
{"project": project, "function_name": src, "mode": "calls"},
|
|
247
|
+
timeout_ms,
|
|
248
|
+
)
|
|
249
|
+
if not isinstance(raw, dict):
|
|
250
|
+
return None
|
|
251
|
+
if raw.get("status") == "ambiguous":
|
|
252
|
+
sugg = raw.get("suggestions") or []
|
|
253
|
+
names = [str(s.get("qualified_name") or s.get("name") or "?") for s in sugg if isinstance(s, dict)]
|
|
254
|
+
if not names:
|
|
255
|
+
return None
|
|
256
|
+
body = "\n".join(f"- {n}" for n in names)
|
|
257
|
+
return f"## Ambiguous symbol '{src}' — candidates\n{body}"
|
|
258
|
+
|
|
259
|
+
def _fmt(items: Any) -> list[str]:
|
|
260
|
+
out = []
|
|
261
|
+
if isinstance(items, list):
|
|
262
|
+
for it in items:
|
|
263
|
+
if not isinstance(it, dict):
|
|
264
|
+
continue
|
|
265
|
+
nm = str(it.get("name") or "?")
|
|
266
|
+
qn = str(it.get("qualified_name") or "")
|
|
267
|
+
hop = it.get("hop")
|
|
268
|
+
label = qn or nm
|
|
269
|
+
hop_s = f" [hop {hop}]" if hop is not None else ""
|
|
270
|
+
out.append(f"- {label}{hop_s}")
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
callees = _fmt(raw.get("callees"))
|
|
274
|
+
callers = _fmt(raw.get("callers"))
|
|
275
|
+
if not callees and not callers:
|
|
276
|
+
return None
|
|
277
|
+
parts = [f"## Call chain for {src}"]
|
|
278
|
+
parts.append("### Callees (downstream)")
|
|
279
|
+
parts.extend(callees or ["(none)"])
|
|
280
|
+
parts.append("### Callers (upstream)")
|
|
281
|
+
parts.extend(callers or ["(none)"])
|
|
282
|
+
return "\n".join(parts)
|
|
283
|
+
|
|
284
|
+
def _op_pattern(self, target: str, project: str, timeout_ms: int) -> Optional[str]:
|
|
285
|
+
try:
|
|
286
|
+
raw = self._run("search_code", {"project": project, "pattern": target}, timeout_ms)
|
|
287
|
+
results = raw.get("results") if isinstance(raw, dict) else raw
|
|
288
|
+
if not isinstance(results, list) or not results:
|
|
289
|
+
return f'## Pattern matches for "{target}"\n(no matches)'
|
|
290
|
+
lines = []
|
|
291
|
+
for r in results:
|
|
292
|
+
if not isinstance(r, dict):
|
|
293
|
+
continue
|
|
294
|
+
node = str(r.get("node") or r.get("qualified_name") or "?")
|
|
295
|
+
label = str(r.get("label") or "")
|
|
296
|
+
file = str(r.get("file") or "")
|
|
297
|
+
start = r.get("start_line")
|
|
298
|
+
loc = f"{file}:{start}" if file and start is not None else file
|
|
299
|
+
ml = r.get("match_lines")
|
|
300
|
+
ml_s = f" (lines {', '.join(str(x) for x in ml)})" if isinstance(ml, list) and ml else ""
|
|
301
|
+
badge = f" [{label}]" if label else ""
|
|
302
|
+
lines.append(f"- {node}{badge} {loc}{ml_s}".rstrip())
|
|
303
|
+
if not lines:
|
|
304
|
+
return f'## Pattern matches for "{target}"\n(no matches)'
|
|
305
|
+
return f'## Pattern matches for "{target}" ({len(lines)})\n' + "\n".join(lines)
|
|
306
|
+
except Exception:
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
def _op_overview(self, target: str, project: str, timeout_ms: int) -> Optional[str]:
|
|
310
|
+
try:
|
|
311
|
+
raw = self._run("get_architecture", {"project": project}, timeout_ms)
|
|
312
|
+
if not isinstance(raw, dict):
|
|
313
|
+
return None
|
|
314
|
+
name = str(raw.get("project") or project)
|
|
315
|
+
parts = [f"## Architecture: {name}"]
|
|
316
|
+
tn, te = raw.get("total_nodes"), raw.get("total_edges")
|
|
317
|
+
if tn is not None or te is not None:
|
|
318
|
+
parts.append(f"{tn or 0} nodes, {te or 0} edges")
|
|
319
|
+
|
|
320
|
+
def _counts(items: Any, key: str, ckey: str = "count") -> list[str]:
|
|
321
|
+
out = []
|
|
322
|
+
if isinstance(items, list):
|
|
323
|
+
for it in items:
|
|
324
|
+
if isinstance(it, dict) and it.get(key) is not None:
|
|
325
|
+
out.append(f"- {it.get(key)}: {it.get(ckey)}")
|
|
326
|
+
return out
|
|
327
|
+
|
|
328
|
+
node_labels = _counts(raw.get("node_labels"), "label")
|
|
329
|
+
edge_types = _counts(raw.get("edge_types"), "type")
|
|
330
|
+
if node_labels:
|
|
331
|
+
parts.append("### Node types")
|
|
332
|
+
parts.extend(node_labels)
|
|
333
|
+
if edge_types:
|
|
334
|
+
parts.append("### Edge types")
|
|
335
|
+
parts.extend(edge_types)
|
|
336
|
+
|
|
337
|
+
langs = raw.get("languages")
|
|
338
|
+
if isinstance(langs, list) and langs:
|
|
339
|
+
lang_lines = []
|
|
340
|
+
for it in langs:
|
|
341
|
+
if isinstance(it, dict):
|
|
342
|
+
lang_lines.append("- " + ", ".join(f"{k}: {v}" for k, v in it.items()))
|
|
343
|
+
else:
|
|
344
|
+
lang_lines.append(f"- {it}")
|
|
345
|
+
if lang_lines:
|
|
346
|
+
parts.append("### Languages")
|
|
347
|
+
parts.extend(lang_lines)
|
|
348
|
+
|
|
349
|
+
if len(parts) == 1: # nothing but the title — treat as no data
|
|
350
|
+
return None
|
|
351
|
+
return "\n".join(parts)
|
|
352
|
+
except Exception:
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
def build_result(
|
|
356
|
+
self,
|
|
357
|
+
op: Any,
|
|
358
|
+
target: Any,
|
|
359
|
+
files: Any,
|
|
360
|
+
budget: Any,
|
|
361
|
+
project_root: Any,
|
|
362
|
+
) -> Result:
|
|
363
|
+
try:
|
|
364
|
+
op_str = str(op or "")
|
|
365
|
+
target_str = str(target or "")
|
|
366
|
+
root_str = str(project_root or "")
|
|
367
|
+
|
|
368
|
+
if not self.available:
|
|
369
|
+
return safe_null_result(op_str, target_str, engine="graph", reason="engine-unavailable")
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
budget_ms = int(budget) if budget else 0
|
|
373
|
+
except Exception:
|
|
374
|
+
budget_ms = 0
|
|
375
|
+
timeout_ms = budget_ms if budget_ms > 0 else 5000
|
|
376
|
+
|
|
377
|
+
project = self._resolve_project(root_str)
|
|
378
|
+
if project is None:
|
|
379
|
+
return safe_null_result(
|
|
380
|
+
op_str, target_str, engine="graph", reason="project-not-indexed",
|
|
381
|
+
hint=f"run: codeintel index {root_str} (or: codeintel doctor)",
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
result_text = self._dispatch(op_str, target_str, project, timeout_ms)
|
|
385
|
+
if result_text is None:
|
|
386
|
+
return safe_null_result(op_str, target_str, engine="graph", reason="unsupported-op")
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
"ok": True,
|
|
390
|
+
"op": op_str,
|
|
391
|
+
"target": target_str,
|
|
392
|
+
"result": result_text,
|
|
393
|
+
"engine": "graph",
|
|
394
|
+
"cached": False,
|
|
395
|
+
}
|
|
396
|
+
except Exception:
|
|
397
|
+
return safe_null_result(op, target, engine="graph", reason="error")
|
|
398
|
+
|
|
399
|
+
def _dispatch(
|
|
400
|
+
self, op: str, target: str, project: str, timeout_ms: int
|
|
401
|
+
) -> Optional[str]:
|
|
402
|
+
if op == "impact" or op == "context":
|
|
403
|
+
# `context` (fan-out op) → the graph's richest single-symbol view: callers + callees.
|
|
404
|
+
return self._op_impact(target, project, timeout_ms)
|
|
405
|
+
if op == "callers":
|
|
406
|
+
return self._op_callers(target, project, timeout_ms)
|
|
407
|
+
if op == "callees":
|
|
408
|
+
return self._op_callees(target, project, timeout_ms)
|
|
409
|
+
if op == "chain":
|
|
410
|
+
return self._op_chain(target, project, timeout_ms)
|
|
411
|
+
if op == "pattern":
|
|
412
|
+
return self._op_pattern(target, project, timeout_ms)
|
|
413
|
+
if op == "overview":
|
|
414
|
+
return self._op_overview(target, project, timeout_ms)
|
|
415
|
+
return None
|