codegraph-voyage 0.1.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.
- codegraph_voyage/__init__.py +8 -0
- codegraph_voyage/__main__.py +5 -0
- codegraph_voyage/cli.py +691 -0
- codegraph_voyage/document.py +238 -0
- codegraph_voyage/explore.py +148 -0
- codegraph_voyage/mcp_server.py +78 -0
- codegraph_voyage/providers.py +275 -0
- codegraph_voyage/ranking.py +448 -0
- codegraph_voyage/sanitize.py +116 -0
- codegraph_voyage/sidecar.py +325 -0
- codegraph_voyage/tests/__init__.py +1 -0
- codegraph_voyage/tests/benchmark.py +278 -0
- codegraph_voyage/tests/test_all.py +1114 -0
- codegraph_voyage-0.1.0.dist-info/METADATA +196 -0
- codegraph_voyage-0.1.0.dist-info/RECORD +17 -0
- codegraph_voyage-0.1.0.dist-info/WHEEL +4 -0
- codegraph_voyage-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Document construction from CodeGraph node metadata.
|
|
2
|
+
|
|
3
|
+
Builds symbol-level documents from indexed node metadata and source line
|
|
4
|
+
ranges read from the CodeGraph DB.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import sqlite3
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DocumentConstructionError(Exception):
|
|
14
|
+
"""Raised when document construction fails."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _read_source_lines(root: Path, file_path: str, start_line: int, end_line: int) -> str:
|
|
18
|
+
"""Read source lines from a file within the project root.
|
|
19
|
+
|
|
20
|
+
Returns empty string if the file cannot be read or is out of range.
|
|
21
|
+
"""
|
|
22
|
+
# Resolve both paths so traversal (../) and symlink escapes cannot read
|
|
23
|
+
# arbitrary host files into an embedding document.
|
|
24
|
+
root_resolved = root.resolve()
|
|
25
|
+
try:
|
|
26
|
+
full = (root_resolved / file_path).resolve()
|
|
27
|
+
full.relative_to(root_resolved)
|
|
28
|
+
text = full.read_text(encoding="utf-8", errors="replace")
|
|
29
|
+
except (FileNotFoundError, IsADirectoryError, OSError, ValueError):
|
|
30
|
+
return ""
|
|
31
|
+
lines = text.splitlines()
|
|
32
|
+
# start_line/end_line are 1-indexed from CodeGraph
|
|
33
|
+
start = max(0, start_line - 1)
|
|
34
|
+
end = min(len(lines), end_line)
|
|
35
|
+
if start >= end:
|
|
36
|
+
return ""
|
|
37
|
+
return "\n".join(lines[start:end])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_document(
|
|
41
|
+
node: dict[str, Any],
|
|
42
|
+
root: Path,
|
|
43
|
+
*,
|
|
44
|
+
include_source: bool = True,
|
|
45
|
+
max_source_lines: int = 200,
|
|
46
|
+
) -> str:
|
|
47
|
+
"""Build a single symbol-level document from a CodeGraph node.
|
|
48
|
+
|
|
49
|
+
The document is a structured text block that combines metadata, docstring,
|
|
50
|
+
signature, and (optionally) source lines.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
node: A dict with keys matching the CodeGraph nodes table columns.
|
|
54
|
+
root: Absolute project root path for reading source files.
|
|
55
|
+
include_source: Whether to include source lines in the document.
|
|
56
|
+
max_source_lines: Maximum number of source lines to include (prevents
|
|
57
|
+
giant documents from bloating embeddings).
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
A plain-text document string suitable for embedding.
|
|
61
|
+
"""
|
|
62
|
+
parts: list[str] = []
|
|
63
|
+
|
|
64
|
+
name = node.get("name", "").strip()
|
|
65
|
+
qualified_name = node.get("qualified_name", "").strip()
|
|
66
|
+
kind = node.get("kind", "").strip()
|
|
67
|
+
file_path = node.get("file_path", "").strip()
|
|
68
|
+
language = node.get("language", "").strip()
|
|
69
|
+
docstring = (node.get("docstring") or "").strip()
|
|
70
|
+
signature = (node.get("signature") or "").strip()
|
|
71
|
+
visibility = (node.get("visibility") or "").strip()
|
|
72
|
+
return_type = (node.get("return_type") or "").strip()
|
|
73
|
+
start_line = node.get("start_line")
|
|
74
|
+
end_line = node.get("end_line")
|
|
75
|
+
|
|
76
|
+
# --- Header ---
|
|
77
|
+
parts.append(f"Symbol: {name}")
|
|
78
|
+
if qualified_name and qualified_name != name:
|
|
79
|
+
parts.append(f"Qualified Name: {qualified_name}")
|
|
80
|
+
parts.append(f"Kind: {kind}")
|
|
81
|
+
parts.append(f"File: {file_path}")
|
|
82
|
+
if language:
|
|
83
|
+
parts.append(f"Language: {language}")
|
|
84
|
+
if start_line and end_line:
|
|
85
|
+
parts.append(f"Lines: {start_line}-{end_line}")
|
|
86
|
+
if visibility:
|
|
87
|
+
parts.append(f"Visibility: {visibility}")
|
|
88
|
+
if return_type:
|
|
89
|
+
parts.append(f"Return Type: {return_type}")
|
|
90
|
+
|
|
91
|
+
# --- Signature ---
|
|
92
|
+
if signature:
|
|
93
|
+
parts.append(f"\nSignature:\n{signature}")
|
|
94
|
+
|
|
95
|
+
# --- Docstring ---
|
|
96
|
+
if docstring:
|
|
97
|
+
parts.append(f"\nDocstring:\n{docstring}")
|
|
98
|
+
|
|
99
|
+
# --- Source lines ---
|
|
100
|
+
if include_source and start_line and end_line and file_path:
|
|
101
|
+
source = _read_source_lines(root, file_path, start_line, end_line)
|
|
102
|
+
if source:
|
|
103
|
+
src_lines = source.splitlines()
|
|
104
|
+
if len(src_lines) > max_source_lines:
|
|
105
|
+
src_lines = src_lines[:max_source_lines]
|
|
106
|
+
src_lines.append(
|
|
107
|
+
f"# ... truncated at {max_source_lines} lines for embedding"
|
|
108
|
+
)
|
|
109
|
+
parts.append("\nSource:\n" + "\n".join(src_lines))
|
|
110
|
+
|
|
111
|
+
return "\n".join(parts)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def compute_content_hash(document: str) -> str:
|
|
115
|
+
"""Return SHA-256 hex digest of the document text."""
|
|
116
|
+
return hashlib.sha256(document.encode("utf-8")).hexdigest()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_documents_from_db(
|
|
120
|
+
codegraph_db: Path,
|
|
121
|
+
root: Path,
|
|
122
|
+
*,
|
|
123
|
+
include_source: bool = True,
|
|
124
|
+
max_source_lines: int = 200,
|
|
125
|
+
node_kinds: tuple[str, ...] | None = None,
|
|
126
|
+
file_filter: str | None = None,
|
|
127
|
+
) -> list[dict[str, Any]]:
|
|
128
|
+
"""Build documents for all (or filtered) nodes in the CodeGraph DB.
|
|
129
|
+
|
|
130
|
+
Returns a list of dicts with keys:
|
|
131
|
+
node_id, node_kind, name, qualified_name, file_path, document,
|
|
132
|
+
content_hash, language, start_line, end_line
|
|
133
|
+
"""
|
|
134
|
+
if not codegraph_db.is_file():
|
|
135
|
+
raise DocumentConstructionError(
|
|
136
|
+
f"CodeGraph DB not found: {codegraph_db}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
conn = sqlite3.connect(f"file://{codegraph_db.resolve()}?mode=ro", uri=True)
|
|
140
|
+
try:
|
|
141
|
+
query = """
|
|
142
|
+
SELECT id, kind, name, qualified_name, file_path, language,
|
|
143
|
+
start_line, end_line, docstring, signature, visibility,
|
|
144
|
+
return_type
|
|
145
|
+
FROM nodes
|
|
146
|
+
WHERE 1=1
|
|
147
|
+
"""
|
|
148
|
+
params: list[Any] = []
|
|
149
|
+
if node_kinds:
|
|
150
|
+
placeholders = ",".join("?" for _ in node_kinds)
|
|
151
|
+
query += f" AND kind IN ({placeholders})"
|
|
152
|
+
params.extend(node_kinds)
|
|
153
|
+
if file_filter:
|
|
154
|
+
query += " AND file_path LIKE ?"
|
|
155
|
+
params.append(f"%{file_filter}%")
|
|
156
|
+
|
|
157
|
+
rows = conn.execute(query, params).fetchall()
|
|
158
|
+
columns = [
|
|
159
|
+
"id", "kind", "name", "qualified_name", "file_path", "language",
|
|
160
|
+
"start_line", "end_line", "docstring", "signature", "visibility",
|
|
161
|
+
"return_type",
|
|
162
|
+
]
|
|
163
|
+
finally:
|
|
164
|
+
conn.close()
|
|
165
|
+
|
|
166
|
+
results: list[dict[str, Any]] = []
|
|
167
|
+
for row in rows:
|
|
168
|
+
node = dict(zip(columns, row))
|
|
169
|
+
if not node.get("name") and not node.get("qualified_name"):
|
|
170
|
+
continue
|
|
171
|
+
doc = build_document(
|
|
172
|
+
node, root,
|
|
173
|
+
include_source=include_source,
|
|
174
|
+
max_source_lines=max_source_lines,
|
|
175
|
+
)
|
|
176
|
+
results.append({
|
|
177
|
+
"node_id": node["id"],
|
|
178
|
+
"node_kind": node["kind"],
|
|
179
|
+
"name": node["name"],
|
|
180
|
+
"qualified_name": node["qualified_name"],
|
|
181
|
+
"file_path": node["file_path"],
|
|
182
|
+
"language": node["language"],
|
|
183
|
+
"start_line": node["start_line"],
|
|
184
|
+
"end_line": node["end_line"],
|
|
185
|
+
"document": doc,
|
|
186
|
+
"content_hash": compute_content_hash(doc),
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
return results
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def build_document_for_node_id(
|
|
193
|
+
codegraph_db: Path,
|
|
194
|
+
root: Path,
|
|
195
|
+
node_id: str,
|
|
196
|
+
*,
|
|
197
|
+
include_source: bool = True,
|
|
198
|
+
max_source_lines: int = 200,
|
|
199
|
+
) -> dict[str, Any] | None:
|
|
200
|
+
"""Build a document for a single node by its ID."""
|
|
201
|
+
conn = sqlite3.connect(f"file://{codegraph_db.resolve()}?mode=ro", uri=True)
|
|
202
|
+
try:
|
|
203
|
+
row = conn.execute(
|
|
204
|
+
"""SELECT id, kind, name, qualified_name, file_path, language,
|
|
205
|
+
start_line, end_line, docstring, signature, visibility,
|
|
206
|
+
return_type
|
|
207
|
+
FROM nodes WHERE id = ?""",
|
|
208
|
+
(node_id,),
|
|
209
|
+
).fetchone()
|
|
210
|
+
finally:
|
|
211
|
+
conn.close()
|
|
212
|
+
|
|
213
|
+
if row is None:
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
columns = [
|
|
217
|
+
"id", "kind", "name", "qualified_name", "file_path", "language",
|
|
218
|
+
"start_line", "end_line", "docstring", "signature", "visibility",
|
|
219
|
+
"return_type",
|
|
220
|
+
]
|
|
221
|
+
node = dict(zip(columns, row))
|
|
222
|
+
doc = build_document(
|
|
223
|
+
node, root,
|
|
224
|
+
include_source=include_source,
|
|
225
|
+
max_source_lines=max_source_lines,
|
|
226
|
+
)
|
|
227
|
+
return {
|
|
228
|
+
"node_id": node["id"],
|
|
229
|
+
"node_kind": node["kind"],
|
|
230
|
+
"name": node["name"],
|
|
231
|
+
"qualified_name": node["qualified_name"],
|
|
232
|
+
"file_path": node["file_path"],
|
|
233
|
+
"language": node["language"],
|
|
234
|
+
"start_line": node["start_line"],
|
|
235
|
+
"end_line": node["end_line"],
|
|
236
|
+
"document": doc,
|
|
237
|
+
"content_hash": compute_content_hash(doc),
|
|
238
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Integration with `codegraph explore` — invokes the codegraph CLI with semantic
|
|
2
|
+
candidates as the query input.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .ranking import RankingResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def codegraph_explore(
|
|
18
|
+
candidates: list[RankingResult],
|
|
19
|
+
project_path: str | Path | None = None,
|
|
20
|
+
max_files: int = 12,
|
|
21
|
+
codegraph_bin: str = "codegraph",
|
|
22
|
+
*,
|
|
23
|
+
dry_run: bool = False,
|
|
24
|
+
timeout: int = 60,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
"""Invoke `codegraph explore` with semantic candidate symbols.
|
|
27
|
+
|
|
28
|
+
Builds a query string from the top-ranked candidate names and qualified
|
|
29
|
+
names, then runs `codegraph explore <query>`.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
candidates: Ranked results from hybrid search.
|
|
33
|
+
project_path: Project root path (for -p flag).
|
|
34
|
+
max_files: Max files for codegraph explore.
|
|
35
|
+
codegraph_bin: Path to the codegraph binary.
|
|
36
|
+
dry_run: If True, return the command string without running.
|
|
37
|
+
timeout: Timeout in seconds for the subprocess call.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Dict with keys: command, stdout, stderr, returncode, error.
|
|
41
|
+
"""
|
|
42
|
+
# Build query from top candidates — use names and qualified names
|
|
43
|
+
query_parts: list[str] = []
|
|
44
|
+
seen: set[str] = set()
|
|
45
|
+
for c in candidates:
|
|
46
|
+
for part in [c.qualified_name, c.name]:
|
|
47
|
+
if part and part not in seen:
|
|
48
|
+
query_parts.append(part)
|
|
49
|
+
seen.add(part)
|
|
50
|
+
if len(query_parts) >= 20:
|
|
51
|
+
break
|
|
52
|
+
if len(query_parts) >= 20:
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
if not query_parts:
|
|
56
|
+
return {
|
|
57
|
+
"command": "",
|
|
58
|
+
"stdout": "",
|
|
59
|
+
"stderr": "No candidate symbols to explore.",
|
|
60
|
+
"returncode": 1,
|
|
61
|
+
"error": "No candidates",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
query_str = " ".join(query_parts)
|
|
65
|
+
|
|
66
|
+
cmd = [codegraph_bin, "explore"]
|
|
67
|
+
if project_path:
|
|
68
|
+
cmd.extend(["-p", str(project_path)])
|
|
69
|
+
cmd.append(query_str)
|
|
70
|
+
if max_files:
|
|
71
|
+
cmd.extend(["--max-files", str(max_files)])
|
|
72
|
+
|
|
73
|
+
if dry_run:
|
|
74
|
+
return {
|
|
75
|
+
"command": " ".join(cmd),
|
|
76
|
+
"stdout": "",
|
|
77
|
+
"stderr": "",
|
|
78
|
+
"returncode": 0,
|
|
79
|
+
"error": None,
|
|
80
|
+
"query_symbols": query_parts,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
result = subprocess.run(
|
|
85
|
+
cmd,
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True,
|
|
88
|
+
timeout=timeout,
|
|
89
|
+
)
|
|
90
|
+
return {
|
|
91
|
+
"command": " ".join(cmd),
|
|
92
|
+
"stdout": result.stdout,
|
|
93
|
+
"stderr": result.stderr,
|
|
94
|
+
"returncode": result.returncode,
|
|
95
|
+
"error": None if result.returncode == 0 else result.stderr.strip(),
|
|
96
|
+
"query_symbols": query_parts,
|
|
97
|
+
}
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
return {
|
|
100
|
+
"command": " ".join(cmd),
|
|
101
|
+
"stdout": "",
|
|
102
|
+
"stderr": f"codegraph binary not found: {codegraph_bin}",
|
|
103
|
+
"returncode": -1,
|
|
104
|
+
"error": f"Binary not found: {codegraph_bin}",
|
|
105
|
+
}
|
|
106
|
+
except subprocess.TimeoutExpired:
|
|
107
|
+
return {
|
|
108
|
+
"command": " ".join(cmd),
|
|
109
|
+
"stdout": "",
|
|
110
|
+
"stderr": f"codegraph explore timed out after {timeout}s",
|
|
111
|
+
"returncode": -1,
|
|
112
|
+
"error": "Timeout",
|
|
113
|
+
}
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
return {
|
|
116
|
+
"command": " ".join(cmd),
|
|
117
|
+
"stdout": "",
|
|
118
|
+
"stderr": str(exc),
|
|
119
|
+
"returncode": -1,
|
|
120
|
+
"error": str(exc),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_explore_query(
|
|
125
|
+
candidates: list[RankingResult],
|
|
126
|
+
max_symbols: int = 20,
|
|
127
|
+
) -> str:
|
|
128
|
+
"""Build a query string from ranked candidates for codegraph explore.
|
|
129
|
+
|
|
130
|
+
Uses the top-ranked candidates, preferring qualified names over simple
|
|
131
|
+
names, and deduplicating.
|
|
132
|
+
"""
|
|
133
|
+
parts: list[str] = []
|
|
134
|
+
seen: set[str] = set()
|
|
135
|
+
for c in candidates:
|
|
136
|
+
for field in [c.qualified_name, c.name, c.file_path]:
|
|
137
|
+
if not field:
|
|
138
|
+
continue
|
|
139
|
+
# Transform paths first, then deduplicate the transformed value.
|
|
140
|
+
value = Path(field).stem if field == c.file_path else field
|
|
141
|
+
if value not in seen:
|
|
142
|
+
parts.append(value)
|
|
143
|
+
seen.add(value)
|
|
144
|
+
if len(parts) >= max_symbols:
|
|
145
|
+
break
|
|
146
|
+
if len(parts) >= max_symbols:
|
|
147
|
+
break
|
|
148
|
+
return " ".join(parts[:max_symbols])
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
from mcp.server.mcpserver import MCPServer
|
|
8
|
+
|
|
9
|
+
# Initialize MCPServer server
|
|
10
|
+
mcp = MCPServer("codegraph-voyage")
|
|
11
|
+
|
|
12
|
+
def _run_cli(*args: str) -> subprocess.CompletedProcess:
|
|
13
|
+
"""Helper to run the codegraph_voyage CLI as a subprocess."""
|
|
14
|
+
cmd = ["codegraph-voyage"] + list(args)
|
|
15
|
+
return subprocess.run(
|
|
16
|
+
cmd,
|
|
17
|
+
capture_output=True,
|
|
18
|
+
text=True,
|
|
19
|
+
check=False
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
@mcp.tool()
|
|
23
|
+
def search_codebase(query: str, top_k: int = 10) -> str:
|
|
24
|
+
"""
|
|
25
|
+
Perform a hybrid semantic search to find symbols in the codebase related to the query.
|
|
26
|
+
This is extremely useful to pinpoint where specific features, concepts, or terms are implemented.
|
|
27
|
+
Returns a JSON string of ranked results.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
# Default to voyage provider instead of fake
|
|
31
|
+
result = _run_cli("search", query, "--top-k", str(top_k), "--json", "--provider", "voyage")
|
|
32
|
+
if result.returncode != 0:
|
|
33
|
+
return json.dumps({"error": result.stderr.strip()})
|
|
34
|
+
return result.stdout.strip()
|
|
35
|
+
except Exception as e:
|
|
36
|
+
return json.dumps({"error": str(e)})
|
|
37
|
+
|
|
38
|
+
@mcp.tool()
|
|
39
|
+
def explore_codebase(query: str, max_files: int = 12) -> str:
|
|
40
|
+
"""
|
|
41
|
+
Perform a semantic search to find symbols related to the query, and then walk the dependency graph
|
|
42
|
+
(using `codegraph explore`) to gather the full codebase context around those symbols.
|
|
43
|
+
Use this to get 'wide-sprawl' context for complex features or dependencies.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
# The explore command outputs text (not json currently) which is perfectly readable context
|
|
47
|
+
result = _run_cli("explore", query, "--max-files", str(max_files), "--provider", "voyage")
|
|
48
|
+
if result.returncode != 0:
|
|
49
|
+
return json.dumps({"error": result.stderr.strip()})
|
|
50
|
+
return result.stdout.strip()
|
|
51
|
+
except Exception as e:
|
|
52
|
+
return json.dumps({"error": str(e)})
|
|
53
|
+
|
|
54
|
+
@mcp.tool()
|
|
55
|
+
def index_codebase(kind: str = "function,class", file_filter: str = "") -> str:
|
|
56
|
+
"""
|
|
57
|
+
Rebuild the codebase embedding index.
|
|
58
|
+
Call this if the codebase has changed significantly and you need fresh embeddings.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
args = ["index", "--provider", "voyage"]
|
|
62
|
+
if kind:
|
|
63
|
+
args.extend(["--kind", kind])
|
|
64
|
+
if file_filter:
|
|
65
|
+
args.extend(["--file-filter", file_filter])
|
|
66
|
+
|
|
67
|
+
result = _run_cli(*args)
|
|
68
|
+
if result.returncode != 0:
|
|
69
|
+
return json.dumps({"error": result.stderr.strip()})
|
|
70
|
+
return result.stdout.strip()
|
|
71
|
+
except Exception as e:
|
|
72
|
+
return json.dumps({"error": str(e)})
|
|
73
|
+
|
|
74
|
+
def main():
|
|
75
|
+
mcp.run()
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
main()
|