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.

@@ -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