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 ADDED
@@ -0,0 +1,3 @@
1
+ """RepoGraph: evidence-backed codebase intelligence for AI coding agents."""
2
+
3
+ __version__ = "0.1.0"
repograph/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -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