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/__init__.py +1 -0
- codegraph/bench.py +93 -0
- codegraph/cli.py +128 -0
- codegraph/community.py +77 -0
- codegraph/docs.py +141 -0
- codegraph/embed.py +221 -0
- codegraph/indexer.py +190 -0
- codegraph/install.py +219 -0
- codegraph/keys.py +94 -0
- codegraph/links.py +220 -0
- codegraph/parser.py +459 -0
- codegraph/py.typed +0 -0
- codegraph/queries.py +657 -0
- codegraph/rank.py +121 -0
- codegraph/report.py +199 -0
- codegraph/review.py +145 -0
- codegraph/server.py +81 -0
- codegraph/store.py +638 -0
- codegraph/tools.py +99 -0
- codegraph_network-0.1.0.dist-info/METADATA +220 -0
- codegraph_network-0.1.0.dist-info/RECORD +25 -0
- codegraph_network-0.1.0.dist-info/WHEEL +5 -0
- codegraph_network-0.1.0.dist-info/entry_points.txt +2 -0
- codegraph_network-0.1.0.dist-info/licenses/LICENSE +21 -0
- codegraph_network-0.1.0.dist-info/top_level.txt +1 -0
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()
|