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/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")
@@ -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()