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/__init__.py
ADDED
repograph/__main__.py
ADDED
repograph/agent_setup.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Install RepoGraph into coding agents.
|
|
2
|
+
|
|
3
|
+
The point of this module is not to copy a config file. It is to make the agent
|
|
4
|
+
*know when to call the tools* — the measured failure mode is silence, not
|
|
5
|
+
connectivity. So setup writes two things: the MCP server registration and a
|
|
6
|
+
short instruction file the client loads automatically.
|
|
7
|
+
|
|
8
|
+
Everything here is a local file write: no repo code runs, every file is backed
|
|
9
|
+
up before the first change, and re-running is a no-op.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import difflib
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
SKILL_NAME = "repograph"
|
|
23
|
+
|
|
24
|
+
SKILL_BODY = """\
|
|
25
|
+
# RepoGraph
|
|
26
|
+
|
|
27
|
+
A local index of this repository lives in `.repograph/`. Query it instead of
|
|
28
|
+
guessing which files a change reaches. It reads a SQLite index and never runs
|
|
29
|
+
repository code.
|
|
30
|
+
|
|
31
|
+
## When to call
|
|
32
|
+
|
|
33
|
+
Call `analyze_change_impact` before a change that crosses a file boundary:
|
|
34
|
+
renaming or moving a symbol, changing a signature, editing a shared module, a
|
|
35
|
+
route handler, or a database model.
|
|
36
|
+
|
|
37
|
+
Skip it for edits fully contained in one file with no importers, for prose or
|
|
38
|
+
dependency bumps, and for runtime-behaviour questions — the graph is static.
|
|
39
|
+
|
|
40
|
+
## Order
|
|
41
|
+
|
|
42
|
+
1. `analyze_change_impact` with `paths`, `symbol`, or `diff: true`.
|
|
43
|
+
2. Read `blind_spots` before acting. An empty list is not proof of safety.
|
|
44
|
+
3. `find_related_tests` when only the test set is needed.
|
|
45
|
+
4. `find_references` before a rename; `get_dependency_graph` to walk deeper
|
|
46
|
+
than depth 2; `get_architecture_summary` for orientation.
|
|
47
|
+
|
|
48
|
+
## Rules
|
|
49
|
+
|
|
50
|
+
- Cite `file:line` from the result. Do not invent edges.
|
|
51
|
+
- `confidence` is a ceiling: `confirmed` may justify an edit, `inferred` and
|
|
52
|
+
`heuristic` require reading the file, `unresolved` means unknown.
|
|
53
|
+
- After edits run `repograph index .`. A stale index gives stale answers.
|
|
54
|
+
- On a "no index" error, run `repograph index .` and retry once. Do not fall
|
|
55
|
+
back to guessing without saying so.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
SKILL_DESCRIPTION = (
|
|
59
|
+
"Answer what a change affects from a local RepoGraph index: before editing a "
|
|
60
|
+
"symbol, before a multi-file change, or when reviewing a diff. Returns "
|
|
61
|
+
"dependents, related tests, routes, models, and the blind spots static "
|
|
62
|
+
"analysis could not resolve."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Change:
|
|
68
|
+
path: Path
|
|
69
|
+
before: str
|
|
70
|
+
after: str
|
|
71
|
+
note: str = ""
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def kind(self) -> str:
|
|
75
|
+
if not self.before:
|
|
76
|
+
return "create"
|
|
77
|
+
return "unchanged" if self.before == self.after else "update"
|
|
78
|
+
|
|
79
|
+
def diff(self) -> str:
|
|
80
|
+
if self.kind == "unchanged":
|
|
81
|
+
return ""
|
|
82
|
+
old = self.before.splitlines(keepends=True)
|
|
83
|
+
new = self.after.splitlines(keepends=True)
|
|
84
|
+
label = "/dev/null" if self.kind == "create" else str(self.path)
|
|
85
|
+
return "".join(
|
|
86
|
+
difflib.unified_diff(old, new, fromfile=label, tofile=str(self.path))
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def server_entry(root: Path) -> dict[str, object]:
|
|
91
|
+
"""Launch command that does not depend on the client's working directory."""
|
|
92
|
+
args = ["serve", "--root", str(root)]
|
|
93
|
+
exe = shutil.which("repograph")
|
|
94
|
+
if exe:
|
|
95
|
+
return {"command": exe, "args": args}
|
|
96
|
+
return {"command": sys.executable, "args": ["-m", "repograph", *args]}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _merge_json(path: Path, key: str, entry: dict[str, object], note: str) -> Change:
|
|
100
|
+
before = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
101
|
+
try:
|
|
102
|
+
data = json.loads(before) if before.strip() else {}
|
|
103
|
+
except ValueError as exc:
|
|
104
|
+
raise ValueError(f"{path} is not valid JSON; fix it before running setup") from exc
|
|
105
|
+
if not isinstance(data, dict):
|
|
106
|
+
raise ValueError(f"{path} must contain a JSON object")
|
|
107
|
+
servers = data.get(key)
|
|
108
|
+
if servers is not None and not isinstance(servers, dict):
|
|
109
|
+
raise ValueError(f"{path}: {key!r} must be an object")
|
|
110
|
+
data.setdefault(key, {})
|
|
111
|
+
data[key][SKILL_NAME] = entry
|
|
112
|
+
after = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
113
|
+
return Change(path=path, before=before, after=after, note=note)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _codex_block(entry: dict[str, object]) -> str:
|
|
117
|
+
command = json.dumps(entry["command"])
|
|
118
|
+
args = ", ".join(json.dumps(a) for a in entry["args"]) # type: ignore[arg-type]
|
|
119
|
+
return (
|
|
120
|
+
"# >>> repograph (managed by `repograph setup`)\n"
|
|
121
|
+
"[mcp_servers.repograph]\n"
|
|
122
|
+
f"command = {command}\n"
|
|
123
|
+
f"args = [{args}]\n"
|
|
124
|
+
"# <<< repograph\n"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _codex_config(home: Path, entry: dict[str, object]) -> Change:
|
|
129
|
+
path = home / ".codex" / "config.toml"
|
|
130
|
+
before = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
131
|
+
block = _codex_block(entry)
|
|
132
|
+
pattern = re.compile(r"# >>> repograph.*?# <<< repograph\n", re.DOTALL)
|
|
133
|
+
if pattern.search(before):
|
|
134
|
+
after = pattern.sub(block, before, count=1)
|
|
135
|
+
else:
|
|
136
|
+
after = before + ("\n" if before and not before.endswith("\n") else "") + block
|
|
137
|
+
return Change(path=path, before=before, after=after, note="user-level Codex config")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _skill_file(root: Path) -> Change:
|
|
141
|
+
path = root / ".claude" / "skills" / SKILL_NAME / "SKILL.md"
|
|
142
|
+
before = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
143
|
+
after = (
|
|
144
|
+
"---\n"
|
|
145
|
+
f"name: {SKILL_NAME}\n"
|
|
146
|
+
f"description: {SKILL_DESCRIPTION}\n"
|
|
147
|
+
"---\n\n"
|
|
148
|
+
f"{SKILL_BODY}"
|
|
149
|
+
)
|
|
150
|
+
return Change(path=path, before=before, after=after, note="Claude Code skill")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _cursor_rule(root: Path) -> Change:
|
|
154
|
+
path = root / ".cursor" / "rules" / f"{SKILL_NAME}.mdc"
|
|
155
|
+
before = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
156
|
+
after = (
|
|
157
|
+
"---\n"
|
|
158
|
+
f"description: {SKILL_DESCRIPTION}\n"
|
|
159
|
+
"alwaysApply: true\n"
|
|
160
|
+
"---\n\n"
|
|
161
|
+
f"{SKILL_BODY}"
|
|
162
|
+
)
|
|
163
|
+
return Change(path=path, before=before, after=after, note="Cursor rule")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def detect_clients(root: Path, home: Path) -> list[str]:
|
|
167
|
+
"""Project-scoped clients only.
|
|
168
|
+
|
|
169
|
+
Codex is excluded on purpose: its config is user-level, so writing it is an
|
|
170
|
+
explicit `--client codex` decision rather than a side effect of running
|
|
171
|
+
setup in a repository.
|
|
172
|
+
"""
|
|
173
|
+
found: list[str] = []
|
|
174
|
+
if (root / ".cursor").is_dir():
|
|
175
|
+
found.append("cursor")
|
|
176
|
+
# Claude Code is the default: it is the client that reads project `.mcp.json`
|
|
177
|
+
# and the skill directory, and it is where the invocation gap was measured.
|
|
178
|
+
found.append("claude")
|
|
179
|
+
return found
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def plan(
|
|
183
|
+
root: Path,
|
|
184
|
+
clients: list[str],
|
|
185
|
+
include_skill: bool = True,
|
|
186
|
+
home: Path | None = None,
|
|
187
|
+
) -> list[Change]:
|
|
188
|
+
root = root.expanduser().resolve()
|
|
189
|
+
home = (home or Path.home()).expanduser().resolve()
|
|
190
|
+
entry = server_entry(root)
|
|
191
|
+
changes: list[Change] = []
|
|
192
|
+
unknown = set(clients) - {"claude", "cursor", "codex"}
|
|
193
|
+
if unknown:
|
|
194
|
+
raise ValueError(f"unknown client(s): {', '.join(sorted(unknown))}")
|
|
195
|
+
|
|
196
|
+
if "claude" in clients:
|
|
197
|
+
changes.append(_merge_json(root / ".mcp.json", "mcpServers", entry, "project MCP config"))
|
|
198
|
+
if include_skill:
|
|
199
|
+
changes.append(_skill_file(root))
|
|
200
|
+
if "cursor" in clients:
|
|
201
|
+
changes.append(
|
|
202
|
+
_merge_json(root / ".cursor" / "mcp.json", "mcpServers", entry, "Cursor MCP config")
|
|
203
|
+
)
|
|
204
|
+
if include_skill:
|
|
205
|
+
changes.append(_cursor_rule(root))
|
|
206
|
+
if "codex" in clients:
|
|
207
|
+
changes.append(_codex_config(home, entry))
|
|
208
|
+
return changes
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def apply(changes: list[Change]) -> list[Path]:
|
|
212
|
+
written: list[Path] = []
|
|
213
|
+
for change in changes:
|
|
214
|
+
if change.kind == "unchanged":
|
|
215
|
+
continue
|
|
216
|
+
change.path.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
backup = change.path.with_name(change.path.name + ".bak")
|
|
218
|
+
if change.kind == "update" and not backup.exists():
|
|
219
|
+
backup.write_text(change.before, encoding="utf-8")
|
|
220
|
+
change.path.write_text(change.after, encoding="utf-8")
|
|
221
|
+
if change.path.suffix == ".json":
|
|
222
|
+
json.loads(change.path.read_text(encoding="utf-8"))
|
|
223
|
+
written.append(change.path)
|
|
224
|
+
return written
|