codegraph-network 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/rank.py ADDED
@@ -0,0 +1,121 @@
1
+ """Graph ranking: global PageRank (stored at index time) and local personalized PageRank (per query)."""
2
+ from __future__ import annotations
3
+
4
+ from collections import defaultdict, deque
5
+ from collections.abc import Callable, Iterable
6
+
7
+
8
+ def pagerank(nodes: Iterable[int], edges: Iterable[tuple[int, int]],
9
+ damping: float = 0.85, iters: int = 60, tol: float = 1e-7,
10
+ stats: dict | None = None, init: dict[int, float] | None = None) -> dict[int, float]:
11
+ """Classic directed PageRank. Importance flows src -> dst, so widely depended-upon code ranks high.
12
+
13
+ `tol` is the L1 change per iteration. Power iteration with dangling nodes sheds mass at only
14
+ ~`damping` per step, so a tolerance far below that needs hundreds of passes for little gain;
15
+ 1e-7 is the point where the ranking stops visibly changing. Pass a dict as `stats` to find out
16
+ whether the run converged inside the budget rather than assuming it.
17
+ """
18
+ nodes = list(nodes)
19
+ n = len(nodes)
20
+ if n == 0:
21
+ if stats is not None:
22
+ stats.update(iterations=0, converged=True, delta=0.0, nodes=0)
23
+ return {}
24
+ pos = {v: i for i, v in enumerate(nodes)}
25
+ out: list[list[int]] = [[] for _ in range(n)]
26
+ for s, d in edges: # ignore edges pointing at symbols that are no longer in the graph
27
+ si, di = pos.get(s), pos.get(d)
28
+ if si is not None and di is not None:
29
+ out[si].append(di)
30
+ # dense lists beat dicts here: this loop runs iters x edges times and is the one full pass
31
+ sources = [(i, o, damping / len(o)) for i, o in enumerate(out) if o]
32
+ dangling = [i for i, o in enumerate(out) if not o]
33
+ rank = [1.0 / n] * n
34
+ if init:
35
+ # warm start from the previous run: an incremental reindex then converges in a few passes.
36
+ # Nodes without a prior score (new symbols) start at the uniform value.
37
+ rank = [init.get(v) or 1.0 / n for v in nodes]
38
+ total = sum(rank)
39
+ rank = [r / total for r in rank]
40
+ delta, it = 0.0, 0
41
+ for _step in range(1, iters + 1):
42
+ it = _step
43
+ base = (1 - damping) / n + damping * sum(rank[i] for i in dangling) / n
44
+ new = [base] * n
45
+ for i, ds, w in sources:
46
+ c = rank[i] * w
47
+ for d in ds:
48
+ new[d] += c
49
+ delta = sum(map(abs, map(float.__sub__, new, rank)))
50
+ rank = new
51
+ if delta < tol:
52
+ break
53
+ if stats is not None:
54
+ stats.update(iterations=it, converged=delta < tol, delta=delta, nodes=n)
55
+ return dict(zip(nodes, rank, strict=True))
56
+
57
+
58
+ def personalized_pagerank(nbrs: Callable[[int], list[tuple[int, float]]], seeds: dict[int, float],
59
+ alpha: float = 0.15, eps: float = 1e-5,
60
+ max_nodes: int = 20_000) -> dict[int, float]:
61
+ """Local push approximation of random-walk-with-restart from `seeds`.
62
+
63
+ Only nodes near the seeds are visited, so cost does not grow with repo size -- except for hub
64
+ seeds, which is why the walk is hard-capped. `nbrs(u)` returns [(neighbour, weight)]; weights
65
+ let callers down-weight hub nodes.
66
+
67
+ Mass handling: a node whose residual falls below the threshold keeps it and stops. When the
68
+ `max_nodes` cap is reached the walk stops expanding and the leftover is banked onto the nodes
69
+ it belonged to, so scores stay comparable; any residue from nodes past the cap is added to the
70
+ highest-scoring node rather than dropped, so total mass is preserved.
71
+ """
72
+ p: dict[int, float] = defaultdict(float)
73
+ r: dict[int, float] = defaultdict(float)
74
+ for s, m in seeds.items():
75
+ r[s] += m
76
+ queue = deque(seeds)
77
+ queued = set(seeds)
78
+ seen = set(seeds)
79
+ overflow = 0.0
80
+
81
+ def reachable(u: int) -> bool:
82
+ """True while the result may still grow by one node."""
83
+ return len(seen) < max_nodes
84
+
85
+ while queue:
86
+ u = queue.popleft()
87
+ queued.discard(u)
88
+ ru = r[u]
89
+ nb = nbrs(u)
90
+ if not nb:
91
+ p[u] += ru
92
+ r[u] = 0.0
93
+ continue
94
+ if ru < eps * len(nb):
95
+ p[u] += ru # below threshold: stop here, but keep the mass
96
+ r[u] = 0.0
97
+ continue
98
+ total_w = sum(w for _, w in nb)
99
+ p[u] += alpha * ru
100
+ spread = (1 - alpha) * ru
101
+ r[u] = 0.0
102
+ for v, w in nb:
103
+ r[v] += spread * w / total_w
104
+ if v in queued or r[v] < eps * max(1, len(nbrs(v))):
105
+ continue
106
+ if not reachable(v):
107
+ overflow += r[v]
108
+ r[v] = 0.0
109
+ continue
110
+ seen.add(v)
111
+ queue.append(v)
112
+ queued.add(v)
113
+ for v, rv in r.items(): # anything still pending when the queue drains
114
+ if rv:
115
+ if len(seen) >= max_nodes:
116
+ overflow += rv
117
+ else:
118
+ p[v] += rv
119
+ if overflow and p:
120
+ p[max(p, key=p.get)] += overflow
121
+ return p
codegraph/report.py ADDED
@@ -0,0 +1,199 @@
1
+ """Human-readable views of the whole graph: GRAPH_REPORT.md and a self-contained graph.html.
2
+
3
+ Both are written next to the index (`.codegraph/`) and need nothing but a browser or a markdown viewer.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import posixpath
9
+ from collections import Counter, defaultdict
10
+ from pathlib import Path
11
+
12
+ HTML_NODES = 400 # a force layout stays readable and smooth up to roughly this many nodes
13
+ GOD_NODES = 10
14
+ COMMUNITIES = 12
15
+ SURPRISES = 8
16
+ NON_TARGET = ("module", "impl", "namespace")
17
+
18
+
19
+ def _area(file: str) -> str:
20
+ parts = file.split("/")
21
+ return "/".join(parts[:2]) if len(parts) > 2 else parts[0]
22
+
23
+
24
+ def _gather(db):
25
+ syms = {r["id"]: r for r in db.execute(
26
+ f"SELECT id, file, qualname, kind, rank, community FROM symbols WHERE kind NOT IN {NON_TARGET!r}")}
27
+ edges = [(r["src"], r["dst"], r["kind"], r["conf"]) for r in db.execute("SELECT src, dst, kind, conf FROM edges")
28
+ if r["src"] in syms and r["dst"] in syms]
29
+ deg: Counter = Counter()
30
+ for s, d, _, _ in edges:
31
+ deg[s] += 1
32
+ deg[d] += 1
33
+ return syms, edges, deg
34
+
35
+
36
+ def _communities(syms, edges, deg):
37
+ members: dict[int, list[int]] = defaultdict(list)
38
+ for i, r in syms.items():
39
+ if r["community"] >= 0:
40
+ members[r["community"]].append(i)
41
+ inside: Counter = Counter()
42
+ touching: Counter = Counter()
43
+ for s, d, _, _ in edges:
44
+ cs, cd = syms[s]["community"], syms[d]["community"]
45
+ touching[cs] += 1
46
+ if cs != cd:
47
+ touching[cd] += 1
48
+ else:
49
+ inside[cs] += 1
50
+ out = []
51
+ for c, ids in sorted(members.items(), key=lambda kv: (-len(kv[1]), kv[0])):
52
+ ids.sort(key=lambda i: -syms[i]["rank"])
53
+ areas = Counter(_area(syms[i]["file"]) for i in ids)
54
+ out.append({"id": c, "size": len(ids), "area": areas.most_common(1)[0][0],
55
+ "files": len({syms[i]["file"] for i in ids}),
56
+ "top": [syms[i]["qualname"] for i in ids[:4]],
57
+ "cohesion": inside[c] / touching[c] if touching[c] else 1.0})
58
+ return out
59
+
60
+
61
+ def _surprises(syms, edges, deg):
62
+ """Certain edges that join different communities *and* different areas of the tree, between
63
+ symbols that are not hubs: the links a reader would not have guessed from the file layout."""
64
+ scored = []
65
+ for s, d, kind, conf in edges:
66
+ a, b = syms[s], syms[d]
67
+ if conf != "extracted" or a["community"] == b["community"] or _area(a["file"]) == _area(b["file"]):
68
+ continue
69
+ if a["community"] < 0 or b["community"] < 0:
70
+ continue
71
+ scored.append((deg[s] + deg[d], a["qualname"], b["qualname"], kind, a["file"], b["file"]))
72
+ scored.sort()
73
+ return scored[:SURPRISES]
74
+
75
+
76
+ def build_report(db) -> tuple[str, dict]:
77
+ syms, edges, deg = _gather(db)
78
+ conf = Counter(c for *_, c in edges)
79
+ comms = _communities(syms, edges, deg)
80
+ files = db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
81
+ kinds = Counter(r["kind"] for r in syms.values())
82
+ out = ["# Codegraph report", "",
83
+ f"{files} files, {len(syms)} symbols, {len(edges)} edges "
84
+ f"({conf['extracted']} extracted, {conf['inferred']} inferred, {conf['ambiguous']} ambiguous), "
85
+ f"{len(comms)} communities.", ""]
86
+ docs = [k for k in ("section", "table", "view", "procedure") if kinds.get(k)]
87
+ if docs:
88
+ out += ["Beyond code: " + ", ".join(f"{kinds[k]} {k}s" for k in docs) + ".", ""]
89
+
90
+ out += ["## Key symbols", "", "Highest PageRank: the code everything else leans on.", ""]
91
+ top = sorted(syms, key=lambda i: -syms[i]["rank"])[:GOD_NODES]
92
+ for i in top:
93
+ r = syms[i]
94
+ out.append(f"- `{r['qualname']}` ({r['kind']}, {r['file']}) rank {r['rank']:.4f}, {deg[i]} links")
95
+
96
+ out += ["", "## Communities", "", "Groups of symbols that call each other more than they call out. "
97
+ "Cohesion is the share of their edges that stay inside.", ""]
98
+ for c in comms[:COMMUNITIES]:
99
+ out.append(f"- **#{c['id']} {c['area']}** — {c['size']} symbols in {c['files']} files, "
100
+ f"cohesion {c['cohesion']:.0%}: " + ", ".join(f"`{q}`" for q in c["top"]))
101
+ if len(comms) > COMMUNITIES:
102
+ out.append(f"- ... and {len(comms) - COMMUNITIES} smaller")
103
+
104
+ sur = _surprises(syms, edges, deg)
105
+ if sur:
106
+ out += ["", "## Surprising connections", "",
107
+ "Certain links between different communities *and* different directories, between "
108
+ "non-hub symbols.", ""]
109
+ out += [f"- `{a}` ({fa}) -{k}-> `{b}` ({fb})" for _, a, b, k, fa, fb in sur]
110
+
111
+ out += ["", "## Questions worth asking", ""]
112
+ if top:
113
+ q0 = syms[top[0]]["qualname"]
114
+ out += [f"- What breaks if `{q0}` changes? -> `codegraph impact {q0}`",
115
+ f"- What is `{q0}` closest to? -> `codegraph related {q0}`"]
116
+ if conf["ambiguous"]:
117
+ out.append(f"- {conf['ambiguous']} edges are ambiguous (several plausible targets). "
118
+ "Treat their callers/impact as upper bounds.")
119
+ if comms:
120
+ out.append(f"- Why is the biggest area ({comms[0]['area']}, {comms[0]['size']} symbols) one community?")
121
+ meta = {"symbols": len(syms), "edges": len(edges), "communities": len(comms),
122
+ "confidence": dict(conf)}
123
+ return "\n".join(out) + "\n", meta
124
+
125
+
126
+ def build_html(db) -> str:
127
+ syms, edges, deg = _gather(db)
128
+ keep = sorted(syms, key=lambda i: -syms[i]["rank"])[:HTML_NODES]
129
+ ix = {sid: n for n, sid in enumerate(keep)}
130
+ nodes = [{"n": syms[i]["qualname"], "f": syms[i]["file"], "c": syms[i]["community"],
131
+ "r": syms[i]["rank"], "k": syms[i]["kind"]} for i in keep]
132
+ links = [{"s": ix[s], "t": ix[d], "q": c} for s, d, _, c in edges if s in ix and d in ix]
133
+ data = json.dumps({"nodes": nodes, "links": links}).replace("</", "<\\/")
134
+ return _HTML.replace("__DATA__", data).replace("__TOTAL__", str(len(syms))).replace("__SHOWN__", str(len(keep)))
135
+
136
+
137
+ def write_all(db, root: Path) -> tuple[str, list[Path]]:
138
+ out_dir = root / ".codegraph"
139
+ out_dir.mkdir(exist_ok=True)
140
+ text, _ = build_report(db)
141
+ md, html = out_dir / "GRAPH_REPORT.md", out_dir / "graph.html"
142
+ md.write_text(text)
143
+ html.write_text(build_html(db))
144
+ return text, [md, html]
145
+
146
+
147
+ def short(path: Path, root: Path) -> str:
148
+ return posixpath.normpath(path.relative_to(root).as_posix())
149
+
150
+
151
+ _HTML = """<!doctype html>
152
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
153
+ <title>Codegraph</title>
154
+ <style>
155
+ :root{--bg:#fafafa;--fg:#1a1a1a;--mute:#6b7280;--edge:#9ca3af}
156
+ @media (prefers-color-scheme:dark){:root{--bg:#111318;--fg:#e5e7eb;--mute:#9ca3af;--edge:#4b5563}}
157
+ html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);font:14px system-ui,sans-serif}
158
+ canvas{display:block;width:100%;height:100%}
159
+ #tip{position:fixed;pointer-events:none;background:var(--bg);border:1px solid var(--edge);padding:4px 8px;
160
+ border-radius:4px;display:none;max-width:60ch;word-break:break-all}
161
+ #hud{position:fixed;left:12px;top:10px;color:var(--mute)}
162
+ </style></head><body>
163
+ <canvas id="c"></canvas><div id="tip"></div>
164
+ <div id="hud">Top __SHOWN__ of __TOTAL__ symbols by PageRank. Colour = community. Dashed = inferred/ambiguous link. Hover to read, click to pin a neighbourhood.</div>
165
+ <script>
166
+ const D=__DATA__, N=D.nodes, L=D.links, cv=document.getElementById('c'), cx=cv.getContext('2d'), tip=document.getElementById('tip');
167
+ const dpr=devicePixelRatio||1; let W,H;
168
+ function size(){W=innerWidth;H=innerHeight;cv.width=W*dpr;cv.height=H*dpr;cx.setTransform(dpr,0,0,dpr,0,0)}
169
+ addEventListener('resize',size);size();
170
+ const col=c=>c<0?'#9ca3af':`hsl(${(c*137.5)%360},65%,52%)`;
171
+ N.forEach((n,i)=>{n.x=W/2+Math.cos(i)*W/4*Math.random();n.y=H/2+Math.sin(i)*H/4*Math.random();n.vx=n.vy=0;n.d=0;n.rad=3+Math.sqrt(n.r*4000)});
172
+ L.forEach(l=>{N[l.s].d++;N[l.t].d++});
173
+ let alpha=1, pin=null, hov=null;
174
+ function step(){
175
+ for(let i=0;i<N.length;i++){const a=N[i];
176
+ for(let j=i+1;j<N.length;j++){const b=N[j];let dx=a.x-b.x,dy=a.y-b.y,d2=dx*dx+dy*dy+.01;
177
+ if(d2<40000){const f=900/d2;dx*=f;dy*=f;a.vx+=dx;a.vy+=dy;b.vx-=dx;b.vy-=dy}}
178
+ a.vx+=(W/2-a.x)*.002;a.vy+=(H/2-a.y)*.002}
179
+ for(const l of L){const a=N[l.s],b=N[l.t];let dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;
180
+ const same=a.c===b.c,f=(d-(same?40:90))*.012*(l.q==='extracted'?1:.5);dx*=f/d;dy*=f/d;a.vx+=dx;a.vy+=dy;b.vx-=dx;b.vy-=dy}
181
+ for(const n of N){n.vx*=.82;n.vy*=.82;n.x+=n.vx*alpha;n.y+=n.vy*alpha}
182
+ alpha=Math.max(.02,alpha*.992)}
183
+ const near=i=>{const s=new Set([i]);L.forEach(l=>{if(l.s===i)s.add(l.t);if(l.t===i)s.add(l.s)});return s};
184
+ function draw(){
185
+ cx.clearRect(0,0,W,H);const focus=pin!==null?near(pin):null;
186
+ cx.lineWidth=1;
187
+ for(const l of L){const a=N[l.s],b=N[l.t],on=!focus||(focus.has(l.s)&&focus.has(l.t));
188
+ cx.globalAlpha=on?.55:.06;cx.strokeStyle=getComputedStyle(document.documentElement).getPropertyValue('--edge');
189
+ cx.setLineDash(l.q==='extracted'?[]:[3,3]);cx.beginPath();cx.moveTo(a.x,a.y);cx.lineTo(b.x,b.y);cx.stroke()}
190
+ cx.setLineDash([]);
191
+ N.forEach((n,i)=>{cx.globalAlpha=!focus||focus.has(i)?1:.12;cx.fillStyle=col(n.c);cx.beginPath();cx.arc(n.x,n.y,n.rad,0,6.3);cx.fill()});
192
+ cx.globalAlpha=1}
193
+ (function loop(){step();draw();requestAnimationFrame(loop)})();
194
+ function pick(e){let best=null,bd=1e9;N.forEach((n,i)=>{const d=Math.hypot(n.x-e.clientX,n.y-e.clientY);if(d<n.rad+4&&d<bd){bd=d;best=i}});return best}
195
+ cv.addEventListener('mousemove',e=>{hov=pick(e);if(hov===null){tip.style.display='none';return}
196
+ const n=N[hov];tip.textContent=`${n.k} ${n.n} ${n.f} (${n.d} links)`;tip.style.display='block';tip.style.left=e.clientX+12+'px';tip.style.top=e.clientY+12+'px'});
197
+ cv.addEventListener('click',e=>{const p=pick(e);pin=p===pin?null:p;alpha=.3});
198
+ </script></body></html>
199
+ """
codegraph/review.py ADDED
@@ -0,0 +1,145 @@
1
+ """`review` / `prs`: what does this change touch, and what depends on it?
2
+
3
+ Maps a git diff onto graph symbols, then reports each changed symbol's blast radius, whether any test is
4
+ linked to it, and files that usually change together with the diff but are missing from it.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import subprocess
10
+ from collections import defaultdict
11
+ from pathlib import Path
12
+
13
+ from .links import is_test_path
14
+
15
+ HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
16
+ MAIN_BRANCHES = ("origin/main", "origin/master", "main", "master")
17
+ COCHANGE_MIN = 0.3 # a partner file below this weight is coincidence, not a habit
18
+ MAX_SYMBOLS = 15
19
+
20
+
21
+ def _git(root: Path, *args: str) -> str | None:
22
+ try:
23
+ p = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, timeout=30)
24
+ except (OSError, subprocess.SubprocessError):
25
+ return None
26
+ return p.stdout if p.returncode == 0 else None
27
+
28
+
29
+ def default_base(root: Path) -> str:
30
+ """The point the change started from: merge-base with the main branch, or HEAD (uncommitted work)
31
+ when already on it."""
32
+ for name in MAIN_BRANCHES:
33
+ out = _git(root, "merge-base", "HEAD", name)
34
+ if out and out.strip():
35
+ base = out.strip()
36
+ head = (_git(root, "rev-parse", "HEAD") or "").strip()
37
+ return base if base != head else "HEAD"
38
+ return "HEAD"
39
+
40
+
41
+ def parse_diff(diff: str) -> tuple[dict[str, list[tuple[int, int]]], list[str]]:
42
+ """Unified diff (-U0) -> ({file: [(first, last) new-side line ranges]}, [deleted files])."""
43
+ changed: dict[str, list[tuple[int, int]]] = defaultdict(list)
44
+ deleted: list[str] = []
45
+ old = cur = None
46
+ for line in diff.splitlines():
47
+ if line.startswith("--- "):
48
+ old = line[6:] if line.startswith("--- a/") else None
49
+ elif line.startswith("+++ "):
50
+ cur = line[6:] if line.startswith("+++ b/") else None
51
+ if cur is None and old:
52
+ deleted.append(old)
53
+ elif cur and (m := HUNK.match(line)):
54
+ start, n = int(m.group(1)), int(m.group(2) if m.group(2) is not None else 1)
55
+ changed[cur].append((start, start + max(n, 1) - 1)) # n == 0: pure deletion at `start`
56
+ return dict(changed), deleted
57
+
58
+
59
+ def _innermost(rows, ranges):
60
+ """Symbols overlapping any range, minus containers whose own child also overlaps."""
61
+ hit = [r for r in rows if any(r["line_start"] <= b and a <= r["line_end"] for a, b in ranges)]
62
+ ids = {r["parent"] for r in hit}
63
+ return [r for r in hit if r["id"] not in ids]
64
+
65
+
66
+ def review(graph, base: str = "", max_tokens: int = 1500) -> str:
67
+ root = graph.root
68
+ if _git(root, "rev-parse", "--git-dir") is None:
69
+ return "Not a git repository: nothing to diff."
70
+ base = base.strip() or default_base(root)
71
+ diff = _git(root, "diff", "-U0", "--no-color", base, "--")
72
+ if diff is None:
73
+ return f"git could not diff against '{base}'. Pass a branch, tag or commit."
74
+ changed, deleted = parse_diff(diff)
75
+ if not changed and not deleted:
76
+ return f"No changes against {base}."
77
+
78
+ db = graph.db
79
+ rows_by_file = defaultdict(list)
80
+ for r in db.execute("SELECT * FROM symbols WHERE kind NOT IN ('module','impl','namespace')"):
81
+ if r["file"] in changed:
82
+ rows_by_file[r["file"]].append(r)
83
+ touched, unindexed = [], []
84
+ for f, ranges in sorted(changed.items()):
85
+ rows = rows_by_file.get(f)
86
+ if rows is None:
87
+ unindexed.append(f)
88
+ continue
89
+ touched += _innermost(rows, ranges)
90
+
91
+ report = []
92
+ for r in touched:
93
+ levels = graph._bfs(r, "in", 3, 10_000)
94
+ deps = [n for ns in levels.values() for n in ns]
95
+ report.append((r, len(deps), len({n["file"] for n in deps}),
96
+ sum(1 for n in deps if n["conf"] != "extracted"), _tests_for(graph, r["id"])))
97
+ report.sort(key=lambda t: (-t[1], t[0]["qualname"]))
98
+
99
+ files = sorted(changed)
100
+ out = [f"review vs {base}: {len(files)} file(s), {len(touched)} symbol(s) changed"
101
+ + (f", {len(deleted)} file(s) deleted" if deleted else "")]
102
+ if report:
103
+ out.append("highest blast radius first:")
104
+ for r, n, nf, soft, tests in report[:MAX_SYMBOLS]:
105
+ what = f"{n} dependents in {nf} file(s)" + (f", {soft} via inferred/ambiguous" if soft else "") if n else "no dependents"
106
+ verdict = "" if is_test_path(r["file"]) else (" tests: " + (", ".join(tests[:2]) if tests else "NONE linked"))
107
+ out.append(f" {r['qualname']} {r['file']}:{r['line_start']} -> {what}{verdict}")
108
+ if len(report) > MAX_SYMBOLS:
109
+ out.append(f" ... and {len(report) - MAX_SYMBOLS} more")
110
+ untested = [r["qualname"] for r, _, _, _, t in report if not t and not is_test_path(r["file"])]
111
+ if untested:
112
+ out.append("changed without a linked test: " + ", ".join(untested[:8])
113
+ + (f" (+{len(untested) - 8})" if len(untested) > 8 else ""))
114
+ missing = _cochange_missing(graph, set(files))
115
+ if missing:
116
+ out.append("usually changes together with this diff, but untouched: "
117
+ + ", ".join(f"{m} ({w:.2f})" for m, w in missing[:5]))
118
+ if deleted:
119
+ out.append("deleted: " + ", ".join(deleted[:8]))
120
+ if unindexed:
121
+ out.append(f"not indexed (unsupported type or ignored): {', '.join(unindexed[:6])}"
122
+ + (f" (+{len(unindexed) - 6})" if len(unindexed) > 6 else ""))
123
+ text = "\n".join(out)
124
+ cap = max_tokens * 4
125
+ return text if len(text) <= cap else text[:cap].rsplit("\n", 1)[0] + "\n ... truncated"
126
+
127
+
128
+ def _tests_for(graph, sid: int) -> list[str]:
129
+ """Test symbols linked to `sid` (links of kind 'tests', in either direction)."""
130
+ names = []
131
+ for other, kind, _w, _note, _fwd in graph._lnk.get(sid, ()):
132
+ if kind == "tests":
133
+ row = graph.db.execute("SELECT qualname FROM symbols WHERE id=?", (other,)).fetchone()
134
+ if row:
135
+ names.append(row[0])
136
+ return names
137
+
138
+
139
+ def _cochange_missing(graph, files: set[str]) -> list[tuple[str, float]]:
140
+ best: dict[str, float] = {}
141
+ for f in files:
142
+ for other, w in graph._fl.get(f, ()):
143
+ if other not in files and w >= COCHANGE_MIN:
144
+ best[other] = max(best.get(other, 0.0), w)
145
+ return sorted(best.items(), key=lambda kv: (-kv[1], kv[0]))
codegraph/server.py ADDED
@@ -0,0 +1,81 @@
1
+ """MCP server exposing the code graph to LLM agents."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from mcp.server.mcpserver import MCPServer
9
+
10
+ from .indexer import index_repo
11
+ from .queries import Graph
12
+ from .tools import TOOLS
13
+
14
+ try: # mcp 1.x compatibility: FastMCP is the same object under its old name
15
+ from mcp.server.fastmcp import FastMCP
16
+ except ImportError: # pragma: no cover - mcp 2.x
17
+ FastMCP = MCPServer
18
+
19
+ INSTRUCTIONS = (
20
+ "Code graph for this repository. Prefer these tools over reading whole files: "
21
+ "start with repo_map, find code with search, then call context(symbol) to get a symbol's source, "
22
+ "the signatures it uses and who uses it in one compact response. Use impact(symbol) before changing "
23
+ "a function, and similar(symbol) to find code that does the same thing under different names. "
24
+ "Fall back to reading files only for exact surrounding code."
25
+ )
26
+
27
+
28
+ def _log(msg: str):
29
+ """stderr only: stdout is the MCP transport."""
30
+ print(f"codegraph: {msg}", file=sys.stderr, flush=True)
31
+
32
+
33
+ def build(root: str | Path, index: bool = True) -> MCPServer:
34
+ root = Path(root).resolve()
35
+ mcp = MCPServer("codegraph", instructions=INSTRUCTIONS)
36
+ if index:
37
+ _log(f"indexing {root} ...")
38
+ stats = index_repo(root, progress=lambda m: _log(m))
39
+ _log(f"indexed {stats['files']} files, {stats['symbols']} symbols, {stats['edges']} edges")
40
+ graph = Graph(root) # one long-lived graph: adjacency lists stay in memory
41
+
42
+ for tool in TOOLS:
43
+ _register(mcp, tool, graph, root)
44
+
45
+ return mcp
46
+
47
+
48
+ def _register(mcp, tool, graph: Graph, root: Path):
49
+ """Expose one Tool spec. The signature is generated from the spec, annotations included,
50
+ so the advertised schema cannot drift from what the tool actually accepts."""
51
+ if tool.name == "reindex":
52
+ def reindex(force: bool = False) -> str:
53
+ r = index_repo(root, force)
54
+ graph.reload()
55
+ return (f"{r['files']} files, {r['changed']} re-parsed, "
56
+ f"{r['symbols']} symbols, {r['edges']} edges")
57
+ reindex.__doc__ = tool.summary
58
+ mcp.tool(name=tool.name)(reindex)
59
+ return
60
+
61
+ def dispatch(**kwargs):
62
+ return getattr(graph, tool.name)(**kwargs)
63
+
64
+ params = tool.params
65
+ args = []
66
+ for p in params:
67
+ args.append(f"{p.name}: {p.type}" if p.default is None
68
+ else f"{p.name}: {p.type} = {p.default!r}")
69
+ # keyword forwarding: the MCP runtime calls tools with keyword arguments
70
+ src = (f"def _{tool.name}({', '.join(args)}) -> str:\n"
71
+ f" return _dispatch({', '.join(f'{n}={n}' for n in [p.name for p in params])})")
72
+ ns: dict = {"_dispatch": dispatch}
73
+ exec(compile(src, f"<tool {tool.name}>", "exec"), ns)
74
+ fn = ns[f"_{tool.name}"]
75
+ fn.__name__ = tool.name # the MCP tool name must not inherit the underscore
76
+ fn.__doc__ = tool.summary
77
+ mcp.tool(name=tool.name)(fn)
78
+
79
+
80
+ def main(root: str | None = None):
81
+ build(root or os.environ.get("CODEGRAPH_ROOT") or os.getcwd()).run()