codegraph-network 0.1.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (49) hide show
  1. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/PKG-INFO +34 -1
  2. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/README.md +32 -0
  3. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/pyproject.toml +3 -2
  4. codegraph_network-0.3.0/src/codegraph/__init__.py +1 -0
  5. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/cli.py +7 -0
  6. codegraph_network-0.3.0/src/codegraph/graphalgo.py +272 -0
  7. codegraph_network-0.3.0/src/codegraph/ui.html +531 -0
  8. codegraph_network-0.3.0/src/codegraph/ui.py +532 -0
  9. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph_network.egg-info/PKG-INFO +34 -1
  10. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph_network.egg-info/SOURCES.txt +6 -1
  11. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph_network.egg-info/requires.txt +1 -0
  12. codegraph_network-0.3.0/tests/test_graphalgo.py +83 -0
  13. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/tests/test_server_cli.py +1 -1
  14. codegraph_network-0.3.0/tests/test_ui.py +104 -0
  15. codegraph_network-0.1.0/src/codegraph/__init__.py +0 -1
  16. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/LICENSE +0 -0
  17. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/setup.cfg +0 -0
  18. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/bench.py +0 -0
  19. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/community.py +0 -0
  20. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/docs.py +0 -0
  21. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/embed.py +0 -0
  22. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/indexer.py +0 -0
  23. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/install.py +0 -0
  24. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/keys.py +0 -0
  25. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/links.py +0 -0
  26. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/parser.py +0 -0
  27. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/py.typed +0 -0
  28. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/queries.py +0 -0
  29. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/rank.py +0 -0
  30. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/report.py +0 -0
  31. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/review.py +0 -0
  32. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/server.py +0 -0
  33. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/store.py +0 -0
  34. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph/tools.py +0 -0
  35. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph_network.egg-info/dependency_links.txt +0 -0
  36. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph_network.egg-info/entry_points.txt +0 -0
  37. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/src/codegraph_network.egg-info/top_level.txt +0 -0
  38. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/tests/test_edges.py +0 -0
  39. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/tests/test_imports.py +0 -0
  40. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/tests/test_incremental.py +0 -0
  41. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/tests/test_keys.py +0 -0
  42. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/tests/test_links.py +0 -0
  43. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/tests/test_mcp_e2e.py +0 -0
  44. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/tests/test_parity.py +0 -0
  45. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/tests/test_parser.py +0 -0
  46. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/tests/test_queries.py +0 -0
  47. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/tests/test_rank.py +0 -0
  48. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/tests/test_semantic.py +0 -0
  49. {codegraph_network-0.1.0 → codegraph_network-0.3.0}/tests/test_store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codegraph-network
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Graph-based code understanding for LLM agents: fewer tokens, better answers
5
5
  Author: Oviing
6
6
  License-Expression: MIT
@@ -25,6 +25,7 @@ Requires-Dist: mcp>=1.2
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: pytest>=8; extra == "dev"
27
27
  Requires-Dist: ruff>=0.6; extra == "dev"
28
+ Requires-Dist: numpy>=1.24; extra == "dev"
28
29
  Provides-Extra: embed
29
30
  Requires-Dist: fastembed>=0.4; extra == "embed"
30
31
  Requires-Dist: numpy>=1.24; extra == "embed"
@@ -62,6 +63,38 @@ Or in any MCP client config:
62
63
  The index lives in `<repo>/.codegraph/graph.db` and updates incrementally on every server start;
63
64
  `reindex` re-scans after edits made outside the session. `.gitignore` it.
64
65
 
66
+ ## Explore visually
67
+
68
+ codegraph -r /path/to/repo ui
69
+
70
+ Opens an interactive map of the repo in your browser (served from `127.0.0.1` only, nothing leaves your machine).
71
+ Everything is computed from the existing index with the standard library; no extra dependencies.
72
+
73
+ **Start from communities, zoom to code.** The first view is the community map: groups of symbols that are tightly
74
+ linked to each other, found with Louvain modularity clustering (the *clusters* slider merges or splits them). Double-click
75
+ a community to open it into files, a file into its symbols, a symbol to pull in what it calls and what calls it.
76
+ Drag nodes to rearrange and pin them; scroll to zoom. Click a community for cohesion, key symbols and its most
77
+ linked neighbours. The legend isolates a community with one click.
78
+
79
+ **Lenses** colour the graph by a graph-theory metric:
80
+
81
+ | Lens | Algorithm | Shows |
82
+ |---|---|---|
83
+ | importance | PageRank | what many others lean on |
84
+ | bottlenecks | betweenness centrality (sampled on big graphs) | symbols most paths run through |
85
+ | cycles | strongly connected components | circular dependencies between symbols and files |
86
+ | layers | condensation + longest path | callers on top, callees below |
87
+ | coupling / misplaced | cross-community link ratio | symbols that belong with another community |
88
+ | core vs periphery | k-core decomposition | the dense kernel vs leaf code |
89
+ | dead code | zero in-degree + no unresolved references | candidates nothing calls |
90
+ | hotspots | git co-change x importance | central code that also changes a lot |
91
+
92
+ **Layouts:** force, grouped by community, layered (callers on top), radial around the selection.
93
+ **Filters:** minimum link weight, certain-only links, hops from the selection, tests, kind, folder.
94
+ **Tools:** *Show impact* (transitive callers), *Path* between two symbols (shortest call chain), *Surprises*
95
+ (certain links between different communities and folders), *Save / Compare snapshot* (which symbols changed
96
+ community since you saved), and export to PNG, SVG or JSON. Press `/` to search.
97
+
65
98
  ## Tools
66
99
 
67
100
  | Tool | Use it to |
@@ -26,6 +26,38 @@ Or in any MCP client config:
26
26
  The index lives in `<repo>/.codegraph/graph.db` and updates incrementally on every server start;
27
27
  `reindex` re-scans after edits made outside the session. `.gitignore` it.
28
28
 
29
+ ## Explore visually
30
+
31
+ codegraph -r /path/to/repo ui
32
+
33
+ Opens an interactive map of the repo in your browser (served from `127.0.0.1` only, nothing leaves your machine).
34
+ Everything is computed from the existing index with the standard library; no extra dependencies.
35
+
36
+ **Start from communities, zoom to code.** The first view is the community map: groups of symbols that are tightly
37
+ linked to each other, found with Louvain modularity clustering (the *clusters* slider merges or splits them). Double-click
38
+ a community to open it into files, a file into its symbols, a symbol to pull in what it calls and what calls it.
39
+ Drag nodes to rearrange and pin them; scroll to zoom. Click a community for cohesion, key symbols and its most
40
+ linked neighbours. The legend isolates a community with one click.
41
+
42
+ **Lenses** colour the graph by a graph-theory metric:
43
+
44
+ | Lens | Algorithm | Shows |
45
+ |---|---|---|
46
+ | importance | PageRank | what many others lean on |
47
+ | bottlenecks | betweenness centrality (sampled on big graphs) | symbols most paths run through |
48
+ | cycles | strongly connected components | circular dependencies between symbols and files |
49
+ | layers | condensation + longest path | callers on top, callees below |
50
+ | coupling / misplaced | cross-community link ratio | symbols that belong with another community |
51
+ | core vs periphery | k-core decomposition | the dense kernel vs leaf code |
52
+ | dead code | zero in-degree + no unresolved references | candidates nothing calls |
53
+ | hotspots | git co-change x importance | central code that also changes a lot |
54
+
55
+ **Layouts:** force, grouped by community, layered (callers on top), radial around the selection.
56
+ **Filters:** minimum link weight, certain-only links, hops from the selection, tests, kind, folder.
57
+ **Tools:** *Show impact* (transitive callers), *Path* between two symbols (shortest call chain), *Surprises*
58
+ (certain links between different communities and folders), *Save / Compare snapshot* (which symbols changed
59
+ community since you saved), and export to PNG, SVG or JSON. Press `/` to search.
60
+
29
61
  ## Tools
30
62
 
31
63
  | Tool | Use it to |
@@ -31,7 +31,8 @@ dependencies = [
31
31
  ]
32
32
 
33
33
  [project.optional-dependencies]
34
- dev = ["pytest>=8", "ruff>=0.6"]
34
+ # numpy: the semantic tests run on a fake embedder (tests/fake_embed.py) that needs it
35
+ dev = ["pytest>=8", "ruff>=0.6", "numpy>=1.24"]
35
36
  # semantic search: local embeddings (fastembed runs a small ONNX model, no network at query time)
36
37
  embed = ["fastembed>=0.4", "numpy>=1.24"]
37
38
  bench = ["pyyaml>=6"]
@@ -51,7 +52,7 @@ version = { attr = "codegraph.__version__" }
51
52
  where = ["src"]
52
53
 
53
54
  [tool.setuptools.package-data]
54
- codegraph = ["py.typed"]
55
+ codegraph = ["py.typed", "ui.html"]
55
56
 
56
57
  [tool.pytest.ini_options]
57
58
  testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -44,6 +44,9 @@ def build_parser() -> argparse.ArgumentParser:
44
44
  ap.add_argument("-r", "--root", default=".", help="repository root (default: cwd)")
45
45
  sub = ap.add_subparsers(dest="cmd", required=True)
46
46
  sub.add_parser("serve", help="run the MCP server over stdio")
47
+ ui = sub.add_parser("ui", help="open an interactive graph explorer in the browser (local only)")
48
+ ui.add_argument("--port", type=int, default=0, help="port to listen on (default: any free port)")
49
+ ui.add_argument("--no-open", action="store_true", help="print the URL instead of opening a browser")
47
50
  bench = sub.add_parser("bench", help="measure tokens and recall (lexical vs hybrid) on a tasks file")
48
51
  bench.add_argument("tasks", help="YAML/JSON list of {task: ..., expect: [symbols]}")
49
52
  bench.add_argument("-k", type=int, default=10, help="results per search (default 10)")
@@ -94,6 +97,10 @@ def main(argv=None):
94
97
  from .server import main as serve
95
98
  return serve(args.root)
96
99
 
100
+ if args.cmd == "ui":
101
+ from .ui import serve as ui_serve
102
+ return ui_serve(args.root, args.port, not args.no_open)
103
+
97
104
  if args.cmd == "bench":
98
105
  from .bench import run as bench
99
106
  print(bench(args.root, args.tasks, args.k))
@@ -0,0 +1,272 @@
1
+ """Graph-theory algorithms behind the UI lenses. Pure stdlib, deterministic, no I/O.
2
+
3
+ Nodes are any sortable hashables (symbol ids). Graphs are plain dicts so callers can build them from SQL
4
+ rows without an adapter: `edges` is a list of (a, b, weight) for the undirected algorithms and `out`
5
+ is {node: iterable of successors} for the directed ones.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import heapq
10
+ from collections import defaultdict, deque
11
+
12
+ EPS = 1e-12
13
+
14
+
15
+ def _renumber(label: dict, by_size: bool = True) -> dict:
16
+ """Relabel communities 0..k-1, biggest first, so ids are stable and the largest is always 0."""
17
+ size: dict = defaultdict(int)
18
+ for lb in label.values():
19
+ size[lb] += 1
20
+ order = sorted(size, key=lambda lb: (-size[lb], lb) if by_size else (lb,))
21
+ ix = {lb: i for i, lb in enumerate(order)}
22
+ return {n: ix[lb] for n, lb in label.items()}
23
+
24
+
25
+ def louvain(nodes, edges, resolution: float = 1.0, max_levels: int = 10) -> list[dict]:
26
+ """Louvain modularity optimisation (Blondel et al. 2008) with a resolution parameter.
27
+
28
+ Returns one partition per level, finest first: [{node: community}, ...]. Every level maps the
29
+ *original* nodes, so levels[-1] is the coarsest. Nodes without edges are left out. Resolution > 1
30
+ favours smaller communities, < 1 larger ones. Visiting order is sorted, so the result is reproducible.
31
+ """
32
+ adj: dict = defaultdict(lambda: defaultdict(float))
33
+ loops: dict = defaultdict(float)
34
+ for a, b, w in edges:
35
+ if a == b:
36
+ loops[a] += w
37
+ else:
38
+ adj[a][b] += w
39
+ adj[b][a] += w
40
+ cur = sorted(n for n in set(nodes) if adj.get(n) or loops.get(n))
41
+ member = {n: n for n in cur} # original node -> node of the current (aggregated) graph
42
+ levels: list[dict] = []
43
+ for _ in range(max_levels):
44
+ deg = {i: sum(adj[i].values()) + 2 * loops.get(i, 0.0) for i in cur}
45
+ m2 = sum(deg.values())
46
+ if m2 <= 0:
47
+ break
48
+ comm = {i: i for i in cur}
49
+ tot = dict(deg)
50
+ improved = False
51
+ for _sweep in range(50):
52
+ moved = 0
53
+ for i in cur:
54
+ ci, ki = comm[i], deg[i]
55
+ wc: dict = defaultdict(float)
56
+ for j, w in adj[i].items():
57
+ wc[comm[j]] += w
58
+ tot[ci] -= ki
59
+ best = ci
60
+ best_gain = wc.get(ci, 0.0) - resolution * tot[ci] * ki / m2
61
+ for c in sorted(wc):
62
+ g = wc[c] - resolution * tot[c] * ki / m2
63
+ if g > best_gain + EPS:
64
+ best, best_gain = c, g
65
+ tot[best] += ki
66
+ if best != ci:
67
+ comm[i] = best
68
+ moved += 1
69
+ improved = True
70
+ if not moved:
71
+ break
72
+ if not improved:
73
+ break
74
+ comm = _renumber(comm)
75
+ member = {o: comm[member[o]] for o in member}
76
+ levels.append(dict(member))
77
+ k = len(set(comm.values()))
78
+ if k == len(cur):
79
+ break
80
+ nadj: dict = defaultdict(lambda: defaultdict(float))
81
+ nloops: dict = defaultdict(float)
82
+ for i in cur:
83
+ ci = comm[i]
84
+ nloops[ci] += loops.get(i, 0.0)
85
+ for j, w in adj[i].items():
86
+ cj = comm[j]
87
+ if ci == cj:
88
+ nloops[ci] += w / 2 # an internal edge is seen from both ends
89
+ else:
90
+ nadj[ci][cj] += w
91
+ adj, loops, cur = nadj, nloops, list(range(k))
92
+ return levels
93
+
94
+
95
+ def modularity(part: dict, edges, resolution: float = 1.0) -> float:
96
+ """Newman modularity of a partition; used to compare runs and in tests."""
97
+ deg: dict = defaultdict(float)
98
+ inside = 0.0
99
+ total = 0.0
100
+ for a, b, w in edges:
101
+ total += w
102
+ deg[a] += w
103
+ deg[b] += w
104
+ if part.get(a) is not None and part.get(a) == part.get(b):
105
+ inside += w
106
+ if total <= 0:
107
+ return 0.0
108
+ tot: dict = defaultdict(float)
109
+ for n, d in deg.items():
110
+ if n in part:
111
+ tot[part[n]] += d
112
+ return inside / total - resolution * sum(t * t for t in tot.values()) / (4 * total * total)
113
+
114
+
115
+ def fold_small(part: dict, edges, min_size: int = 3) -> dict:
116
+ """Merge communities smaller than `min_size` into the neighbour they are most connected to."""
117
+ adj: dict = defaultdict(lambda: defaultdict(float))
118
+ for a, b, w in edges:
119
+ if a != b:
120
+ adj[a][b] += w
121
+ adj[b][a] += w
122
+ part = dict(part)
123
+ size: dict = defaultdict(int)
124
+ for c in part.values():
125
+ size[c] += 1
126
+ for n in sorted(part):
127
+ if size[part[n]] >= min_size:
128
+ continue
129
+ votes: dict = defaultdict(float)
130
+ for m, w in adj.get(n, {}).items():
131
+ if m in part and size[part[m]] >= min_size:
132
+ votes[part[m]] += w
133
+ if votes:
134
+ size[part[n]] -= 1
135
+ part[n] = min(votes, key=lambda c: (-votes[c], c))
136
+ size[part[n]] += 1
137
+ return _renumber(part)
138
+
139
+
140
+ def sccs(nodes, out) -> list[list]:
141
+ """Strongly connected components (iterative Tarjan). `out` maps node -> iterable of successors."""
142
+ index: dict = {}
143
+ low: dict = {}
144
+ on: set = set()
145
+ stack: list = []
146
+ comps: list = []
147
+ n = 0
148
+ for root in sorted(nodes):
149
+ if root in index:
150
+ continue
151
+ index[root] = low[root] = n
152
+ n += 1
153
+ stack.append(root)
154
+ on.add(root)
155
+ work = [(root, iter(sorted(out.get(root, ()))))]
156
+ while work:
157
+ v, it = work[-1]
158
+ for w in it:
159
+ if w not in index:
160
+ index[w] = low[w] = n
161
+ n += 1
162
+ stack.append(w)
163
+ on.add(w)
164
+ work.append((w, iter(sorted(out.get(w, ())))))
165
+ break
166
+ if w in on:
167
+ low[v] = min(low[v], index[w])
168
+ else:
169
+ work.pop()
170
+ if work:
171
+ u = work[-1][0]
172
+ low[u] = min(low[u], low[v])
173
+ if low[v] == index[v]:
174
+ comp = []
175
+ while True:
176
+ w = stack.pop()
177
+ on.discard(w)
178
+ comp.append(w)
179
+ if w == v:
180
+ break
181
+ comps.append(comp)
182
+ return comps
183
+
184
+
185
+ def betweenness(adj, pivots: int | None = None) -> dict:
186
+ """Betweenness centrality (Brandes) on an unweighted undirected graph {node: iterable of neighbours}.
187
+
188
+ With `pivots` set, only that many evenly spaced sources are used and the sum is scaled up: the
189
+ standard sampling estimate, which keeps big graphs affordable while ranking hubs the same way.
190
+ """
191
+ nodes = sorted(adj)
192
+ nbr = {v: sorted(set(adj[v]) - {v}) for v in nodes}
193
+ sources = nodes
194
+ if pivots and pivots < len(nodes):
195
+ step = len(nodes) / pivots
196
+ sources = [nodes[int(i * step)] for i in range(pivots)]
197
+ bc = dict.fromkeys(nodes, 0.0)
198
+ for s in sources:
199
+ order = []
200
+ preds: dict = defaultdict(list)
201
+ sigma = dict.fromkeys(nodes, 0)
202
+ dist = dict.fromkeys(nodes, -1)
203
+ sigma[s], dist[s] = 1, 0
204
+ q = deque([s])
205
+ while q:
206
+ v = q.popleft()
207
+ order.append(v)
208
+ for w in nbr[v]:
209
+ if dist[w] < 0:
210
+ dist[w] = dist[v] + 1
211
+ q.append(w)
212
+ if dist[w] == dist[v] + 1:
213
+ sigma[w] += sigma[v]
214
+ preds[w].append(v)
215
+ delta = dict.fromkeys(nodes, 0.0)
216
+ for w in reversed(order):
217
+ for v in preds[w]:
218
+ delta[v] += sigma[v] / sigma[w] * (1 + delta[w])
219
+ if w != s:
220
+ bc[w] += delta[w]
221
+ scale = len(nodes) / len(sources) if sources else 1.0
222
+ return {v: b * scale / 2 for v, b in bc.items()} # undirected: each pair was counted twice
223
+
224
+
225
+ def kcore(adj) -> dict:
226
+ """Core number of every node (Batagelj-Zaversnik, heap variant): the largest k such that the node
227
+ survives repeatedly peeling off nodes with fewer than k neighbours."""
228
+ nbr = {v: set(ns) - {v} for v, ns in adj.items()}
229
+ deg = {v: len(ns) for v, ns in nbr.items()}
230
+ heap = [(d, v) for v, d in deg.items()]
231
+ heapq.heapify(heap)
232
+ core: dict = {}
233
+ k = 0
234
+ while heap:
235
+ d, v = heapq.heappop(heap)
236
+ if v in core or d != deg[v]:
237
+ continue
238
+ k = max(k, d)
239
+ core[v] = k
240
+ for u in nbr[v]:
241
+ if u not in core:
242
+ deg[u] -= 1
243
+ heapq.heappush(heap, (deg[u], u))
244
+ return core
245
+
246
+
247
+ def shortest_path(out, inn, a, b, directed_first: bool = True) -> list:
248
+ """Fewest-hop path a -> b. Follows edge direction first (a calls ... calls b) and only if that fails
249
+ treats the graph as undirected. Returns [] when b is unreachable."""
250
+ def bfs(step):
251
+ prev = {a: None}
252
+ q = deque([a])
253
+ while q:
254
+ v = q.popleft()
255
+ if v == b:
256
+ path = []
257
+ while v is not None:
258
+ path.append(v)
259
+ v = prev[v]
260
+ return path[::-1]
261
+ for w in step(v):
262
+ if w not in prev:
263
+ prev[w] = v
264
+ q.append(w)
265
+ return []
266
+ if a == b:
267
+ return [a]
268
+ if directed_first:
269
+ p = bfs(lambda v: sorted(out.get(v, ())))
270
+ if p:
271
+ return p
272
+ return bfs(lambda v: sorted(set(out.get(v, ())) | set(inn.get(v, ()))))