repograph-codeintel 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.
- repograph/__init__.py +3 -0
- repograph/__main__.py +3 -0
- repograph/agent_setup.py +224 -0
- repograph/cli.py +476 -0
- repograph/config.py +145 -0
- repograph/extract.py +616 -0
- repograph/impact.py +558 -0
- repograph/indexer.py +184 -0
- repograph/mcp_server.py +130 -0
- repograph/query.py +485 -0
- repograph/resolve.py +755 -0
- repograph/scanner.py +107 -0
- repograph/store.py +287 -0
- repograph/test_gate.py +169 -0
- repograph_codeintel-0.1.0.dist-info/METADATA +295 -0
- repograph_codeintel-0.1.0.dist-info/RECORD +20 -0
- repograph_codeintel-0.1.0.dist-info/WHEEL +5 -0
- repograph_codeintel-0.1.0.dist-info/entry_points.txt +2 -0
- repograph_codeintel-0.1.0.dist-info/licenses/LICENSE +202 -0
- repograph_codeintel-0.1.0.dist-info/top_level.txt +1 -0
repograph/indexer.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Incremental indexing pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import sqlite3
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .config import Settings
|
|
13
|
+
from .extract import extract
|
|
14
|
+
from .resolve import Resolver
|
|
15
|
+
from .scanner import read_source, scan
|
|
16
|
+
from .store import Store
|
|
17
|
+
|
|
18
|
+
TEST_DIRS = {"test", "tests", "__tests__", "spec", "specs", "e2e", "cypress"}
|
|
19
|
+
TEST_FILE_RE = (
|
|
20
|
+
"test_", "_test.", ".test.", ".spec.", "conftest.py",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_test_path(path: str) -> bool:
|
|
25
|
+
parts = path.lower().split("/")
|
|
26
|
+
base = parts[-1]
|
|
27
|
+
if any(p in TEST_DIRS for p in parts[:-1]):
|
|
28
|
+
return True
|
|
29
|
+
return any(marker in base for marker in TEST_FILE_RE) or base.startswith("test_")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class IndexStats:
|
|
34
|
+
files_scanned: int = 0
|
|
35
|
+
added: int = 0
|
|
36
|
+
updated: int = 0
|
|
37
|
+
unchanged: int = 0
|
|
38
|
+
deleted: int = 0
|
|
39
|
+
parse_errors: int = 0
|
|
40
|
+
duration_s: float = 0.0
|
|
41
|
+
truncated: bool = False
|
|
42
|
+
skipped_large: int = 0
|
|
43
|
+
skipped_symlink: int = 0
|
|
44
|
+
languages: dict[str, int] = field(default_factory=dict)
|
|
45
|
+
db_bytes: int = 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _sha(data: bytes) -> str:
|
|
49
|
+
return hashlib.sha256(data).hexdigest()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def index_repository(root: Path, *, force: bool = False, reset: bool = False,
|
|
53
|
+
settings: Settings | None = None) -> IndexStats:
|
|
54
|
+
from .config import load_settings
|
|
55
|
+
|
|
56
|
+
started = time.perf_counter()
|
|
57
|
+
root = Path(root).resolve()
|
|
58
|
+
settings = settings or load_settings(root)
|
|
59
|
+
scan_result = scan(root, settings)
|
|
60
|
+
entries = scan_result.files
|
|
61
|
+
stats = IndexStats(
|
|
62
|
+
files_scanned=len(entries),
|
|
63
|
+
truncated=scan_result.truncated,
|
|
64
|
+
skipped_large=scan_result.skipped_large,
|
|
65
|
+
skipped_symlink=scan_result.skipped_symlink,
|
|
66
|
+
)
|
|
67
|
+
for e in entries:
|
|
68
|
+
stats.languages[e.lang] = stats.languages.get(e.lang, 0) + 1
|
|
69
|
+
|
|
70
|
+
with Store(settings) as store:
|
|
71
|
+
store.init_schema(reset=reset)
|
|
72
|
+
if reset:
|
|
73
|
+
store.conn.execute("DELETE FROM files")
|
|
74
|
+
store.conn.commit()
|
|
75
|
+
store.set_meta("root", str(root))
|
|
76
|
+
existing = store.existing_files()
|
|
77
|
+
scanned = {e.path: e for e in entries}
|
|
78
|
+
|
|
79
|
+
for path in sorted(set(existing) - set(scanned)):
|
|
80
|
+
store.delete_file(path)
|
|
81
|
+
stats.deleted += 1
|
|
82
|
+
|
|
83
|
+
pending: list[tuple] = []
|
|
84
|
+
for e in entries:
|
|
85
|
+
old = existing.get(e.path)
|
|
86
|
+
if old and not force and old.size == e.size and old.mtime == e.mtime:
|
|
87
|
+
stats.unchanged += 1
|
|
88
|
+
continue
|
|
89
|
+
data = read_source(root, e.path, settings)
|
|
90
|
+
if data is None:
|
|
91
|
+
stats.unchanged += 1
|
|
92
|
+
continue
|
|
93
|
+
sha = _sha(data)
|
|
94
|
+
if old and old.sha256 == sha and not force:
|
|
95
|
+
stats.unchanged += 1
|
|
96
|
+
continue
|
|
97
|
+
pending.append((e, data, sha, old))
|
|
98
|
+
|
|
99
|
+
# Register every pending file first so import targets can be linked
|
|
100
|
+
# even when target and source are indexed in the same run.
|
|
101
|
+
pending_ids: dict[str, int] = {}
|
|
102
|
+
for e, data, sha, old in pending:
|
|
103
|
+
with store.tx():
|
|
104
|
+
fid = store.upsert_file(e.path, e.lang, sha, e.size, e.mtime, None,
|
|
105
|
+
is_test_path(e.path), _now())
|
|
106
|
+
store.clear_children(fid)
|
|
107
|
+
pending_ids[e.path] = fid
|
|
108
|
+
|
|
109
|
+
resolver = Resolver(root, [(e.path, e.lang) for e in entries])
|
|
110
|
+
|
|
111
|
+
for e, data, sha, old in pending:
|
|
112
|
+
fid = pending_ids[e.path]
|
|
113
|
+
result = None
|
|
114
|
+
error = None
|
|
115
|
+
try:
|
|
116
|
+
result = extract(e.path, e.lang, data)
|
|
117
|
+
error = "; ".join(result.errors) if result.errors else None
|
|
118
|
+
except Exception as exc: # keep indexing; one bad file is not fatal
|
|
119
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
120
|
+
result = None
|
|
121
|
+
|
|
122
|
+
with store.tx():
|
|
123
|
+
store.clear_children(fid)
|
|
124
|
+
if error:
|
|
125
|
+
stats.parse_errors += 1
|
|
126
|
+
if result is not None:
|
|
127
|
+
for sym in result.symbols:
|
|
128
|
+
store.insert_symbol(fid, sym)
|
|
129
|
+
for route in result.routes:
|
|
130
|
+
store.insert_route(fid, route)
|
|
131
|
+
resolved = resolver.resolve(e.path, result.imports)
|
|
132
|
+
binding: dict[str, tuple[int | None, str | None, str]] = {}
|
|
133
|
+
for r in resolved:
|
|
134
|
+
target_id = store.file_id(r.target) if r.target else None
|
|
135
|
+
conf = r.confidence
|
|
136
|
+
method = r.method
|
|
137
|
+
if r.kind == "external":
|
|
138
|
+
target_id = None
|
|
139
|
+
store.insert_import(fid, r, target_id, conf, method, r.kind, r.note)
|
|
140
|
+
if target_id is None:
|
|
141
|
+
continue
|
|
142
|
+
for imported, local in r.names:
|
|
143
|
+
if not local or local == "*":
|
|
144
|
+
continue
|
|
145
|
+
target_name = None if imported in ("*", "default", "") else imported
|
|
146
|
+
binding.setdefault(local, (target_id, target_name, conf))
|
|
147
|
+
|
|
148
|
+
same_file = {}
|
|
149
|
+
for sym in result.symbols:
|
|
150
|
+
same_file.setdefault(sym.name, sym)
|
|
151
|
+
for ref in result.refs:
|
|
152
|
+
if ref.name in same_file:
|
|
153
|
+
confirmed = ref.kind == "call"
|
|
154
|
+
store.insert_ref(fid, ref, "confirmed" if confirmed else "inferred",
|
|
155
|
+
"same-file-symbol" if confirmed else "same-file-name",
|
|
156
|
+
fid, ref.name)
|
|
157
|
+
elif ref.name in binding:
|
|
158
|
+
target_id, target_name, conf = binding[ref.name]
|
|
159
|
+
store.insert_ref(fid, ref, conf, "import-binding", target_id,
|
|
160
|
+
target_name or ref.name)
|
|
161
|
+
elif ref.receiver and ref.receiver in binding:
|
|
162
|
+
target_id, _, _ = binding[ref.receiver]
|
|
163
|
+
store.insert_ref(fid, ref, "inferred", "module-or-instance-attribute",
|
|
164
|
+
target_id, ref.name)
|
|
165
|
+
else:
|
|
166
|
+
store.insert_ref(fid, ref, "unresolved", "unresolved-name", None, None)
|
|
167
|
+
store.upsert_file(e.path, e.lang, sha, e.size, e.mtime, error,
|
|
168
|
+
is_test_path(e.path), _now())
|
|
169
|
+
if old is None:
|
|
170
|
+
stats.added += 1
|
|
171
|
+
else:
|
|
172
|
+
stats.updated += 1
|
|
173
|
+
|
|
174
|
+
with store.tx():
|
|
175
|
+
store.set_meta("indexed_at", _now())
|
|
176
|
+
store.conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
177
|
+
stats.duration_s = round(time.perf_counter() - started, 3)
|
|
178
|
+
db = Path(settings.db_path)
|
|
179
|
+
stats.db_bytes = sum(p.stat().st_size for p in (db, Path(str(db) + "-wal")) if p.exists())
|
|
180
|
+
return stats
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _now() -> str:
|
|
184
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
repograph/mcp_server.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""MCP server exposing RepoGraph's read-only intelligence tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from mcp.server.mcpserver import MCPServer
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .config import load_settings
|
|
12
|
+
from .impact import analyze_impact
|
|
13
|
+
from .query import (
|
|
14
|
+
architecture_summary,
|
|
15
|
+
dependency_graph,
|
|
16
|
+
find_references as query_references,
|
|
17
|
+
search_symbols as query_symbols,
|
|
18
|
+
)
|
|
19
|
+
from .store import Store
|
|
20
|
+
|
|
21
|
+
INSTRUCTIONS = """RepoGraph answers repository-structure questions from a local index.
|
|
22
|
+
Every relationship carries a source location, extraction method and confidence.
|
|
23
|
+
Results are partial by construction: absence of a dependency or test is not
|
|
24
|
+
evidence that a change is safe. Call analyze_change_impact before proposing
|
|
25
|
+
edits, and read the blind_spots field."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _store(root: Path) -> Store:
|
|
29
|
+
settings = load_settings(root)
|
|
30
|
+
if not settings.db_path.exists():
|
|
31
|
+
raise FileNotFoundError(
|
|
32
|
+
f"no index at {settings.db_path}; run `repograph index {root}` first"
|
|
33
|
+
)
|
|
34
|
+
store = Store(settings)
|
|
35
|
+
store.init_schema()
|
|
36
|
+
return store
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_server(root: str | Path) -> MCPServer:
|
|
40
|
+
root = Path(root).expanduser().resolve()
|
|
41
|
+
_store(root).close() # fail fast with a clear error if unindexed
|
|
42
|
+
|
|
43
|
+
server = MCPServer("repograph", instructions=INSTRUCTIONS, version=__version__)
|
|
44
|
+
|
|
45
|
+
@server.tool(description="Search indexed symbols by name or qualified name. Returns definitions with file:line evidence.")
|
|
46
|
+
def search_symbols(query: str, kind: Optional[str] = None, limit: int = 20) -> dict[str, Any]:
|
|
47
|
+
limit = max(1, min(int(limit), 200))
|
|
48
|
+
try:
|
|
49
|
+
with _store(root) as store:
|
|
50
|
+
hits = query_symbols(store, query, kind=kind, limit=limit)
|
|
51
|
+
return {"query": query, "symbols": hits,
|
|
52
|
+
"note": "Matches are lexical over symbol names, not semantic embeddings."}
|
|
53
|
+
except Exception as exc: # MCP tools must return actionable errors
|
|
54
|
+
return {"error": f"{type(exc).__name__}: {exc}"}
|
|
55
|
+
|
|
56
|
+
@server.tool(description="Find definitions and statically resolved reference sites for a symbol.")
|
|
57
|
+
def find_references(name: str, limit: int = 100) -> dict[str, Any]:
|
|
58
|
+
limit = max(1, min(int(limit), 500))
|
|
59
|
+
try:
|
|
60
|
+
with _store(root) as store:
|
|
61
|
+
return query_references(store, name, limit=limit)
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
return {"error": f"{type(exc).__name__}: {exc}"}
|
|
64
|
+
|
|
65
|
+
@server.tool(description="Get the file-level dependency graph around a path (direction: in, out, both).")
|
|
66
|
+
def get_dependency_graph(path: Optional[str] = None, direction: str = "both",
|
|
67
|
+
depth: int = 1) -> dict[str, Any]:
|
|
68
|
+
if direction not in ("in", "out", "both"):
|
|
69
|
+
return {"error": "direction must be one of: in, out, both"}
|
|
70
|
+
try:
|
|
71
|
+
with _store(root) as store:
|
|
72
|
+
return dependency_graph(store, path=path, direction=direction,
|
|
73
|
+
depth=max(1, min(int(depth), 5)))
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
return {"error": f"{type(exc).__name__}: {exc}"}
|
|
76
|
+
|
|
77
|
+
@server.tool(description=(
|
|
78
|
+
"Analyze the impact of changed files, a symbol, or the current git diff. "
|
|
79
|
+
"Returns direct and transitive dependents, related tests, routes, models, "
|
|
80
|
+
"architecture notes, and explicit blind spots."
|
|
81
|
+
))
|
|
82
|
+
def analyze_change_impact(paths: Optional[list[str]] = None,
|
|
83
|
+
symbol: Optional[str] = None, diff: bool = False,
|
|
84
|
+
depth: int = 2, include_tests: bool = True) -> dict[str, Any]:
|
|
85
|
+
if not paths and not symbol and not diff:
|
|
86
|
+
return {"error": "provide paths, symbol, or diff=true"}
|
|
87
|
+
try:
|
|
88
|
+
with _store(root) as store:
|
|
89
|
+
return analyze_impact(store, root, paths=paths, symbol=symbol, diff=diff,
|
|
90
|
+
depth=max(1, min(int(depth), 5)),
|
|
91
|
+
include_tests=bool(include_tests))
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
return {"error": f"{type(exc).__name__}: {exc}"}
|
|
94
|
+
|
|
95
|
+
@server.tool(description="Find tests related to changed files via import edges and naming conventions.")
|
|
96
|
+
def find_related_tests(paths: list[str], depth: int = 1) -> dict[str, Any]:
|
|
97
|
+
try:
|
|
98
|
+
with _store(root) as store:
|
|
99
|
+
result = analyze_impact(store, root, paths=paths, depth=max(1, min(int(depth), 3)))
|
|
100
|
+
if "error" in result:
|
|
101
|
+
return result
|
|
102
|
+
return {"tests": result["related_tests"],
|
|
103
|
+
"blind_spots": [b for b in result["blind_spots"] if b["kind"] in
|
|
104
|
+
("parse-errors", "no-static-dependents",
|
|
105
|
+
"test-selection-reachability")],
|
|
106
|
+
"note": ("Test selection is heuristic; the suite is the ground truth. "
|
|
107
|
+
"Test files in test-selection-reachability can also be affected.")}
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
return {"error": f"{type(exc).__name__}: {exc}"}
|
|
110
|
+
|
|
111
|
+
@server.tool(description="Summarize repository architecture: languages, layers, hubs, entry points, routes, models.")
|
|
112
|
+
def get_architecture_summary() -> dict[str, Any]:
|
|
113
|
+
try:
|
|
114
|
+
with _store(root) as store:
|
|
115
|
+
return architecture_summary(store, root)
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
return {"error": f"{type(exc).__name__}: {exc}"}
|
|
118
|
+
|
|
119
|
+
return server
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main() -> None:
|
|
123
|
+
import os
|
|
124
|
+
|
|
125
|
+
root = os.environ.get("REPOGRAPH_ROOT", os.getcwd())
|
|
126
|
+
build_server(root).run("stdio")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
main()
|