codegraph-network 0.1.0__tar.gz → 0.2.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 (47) hide show
  1. {codegraph_network-0.1.0/src/codegraph_network.egg-info → codegraph_network-0.2.0}/PKG-INFO +13 -1
  2. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/README.md +11 -0
  3. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/pyproject.toml +3 -2
  4. codegraph_network-0.2.0/src/codegraph/__init__.py +1 -0
  5. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/cli.py +7 -0
  6. codegraph_network-0.2.0/src/codegraph/ui.html +262 -0
  7. codegraph_network-0.2.0/src/codegraph/ui.py +196 -0
  8. {codegraph_network-0.1.0 → codegraph_network-0.2.0/src/codegraph_network.egg-info}/PKG-INFO +13 -1
  9. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph_network.egg-info/SOURCES.txt +4 -1
  10. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph_network.egg-info/requires.txt +1 -0
  11. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/tests/test_server_cli.py +1 -1
  12. codegraph_network-0.2.0/tests/test_ui.py +60 -0
  13. codegraph_network-0.1.0/src/codegraph/__init__.py +0 -1
  14. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/LICENSE +0 -0
  15. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/setup.cfg +0 -0
  16. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/bench.py +0 -0
  17. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/community.py +0 -0
  18. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/docs.py +0 -0
  19. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/embed.py +0 -0
  20. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/indexer.py +0 -0
  21. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/install.py +0 -0
  22. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/keys.py +0 -0
  23. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/links.py +0 -0
  24. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/parser.py +0 -0
  25. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/py.typed +0 -0
  26. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/queries.py +0 -0
  27. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/rank.py +0 -0
  28. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/report.py +0 -0
  29. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/review.py +0 -0
  30. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/server.py +0 -0
  31. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/store.py +0 -0
  32. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph/tools.py +0 -0
  33. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph_network.egg-info/dependency_links.txt +0 -0
  34. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph_network.egg-info/entry_points.txt +0 -0
  35. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/src/codegraph_network.egg-info/top_level.txt +0 -0
  36. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/tests/test_edges.py +0 -0
  37. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/tests/test_imports.py +0 -0
  38. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/tests/test_incremental.py +0 -0
  39. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/tests/test_keys.py +0 -0
  40. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/tests/test_links.py +0 -0
  41. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/tests/test_mcp_e2e.py +0 -0
  42. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/tests/test_parity.py +0 -0
  43. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/tests/test_parser.py +0 -0
  44. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/tests/test_queries.py +0 -0
  45. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/tests/test_rank.py +0 -0
  46. {codegraph_network-0.1.0 → codegraph_network-0.2.0}/tests/test_semantic.py +0 -0
  47. {codegraph_network-0.1.0 → codegraph_network-0.2.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.2.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,17 @@ 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
+ You start from the file-level network: lines are calls and imports between files, colour is community (or folder).
72
+ Drag nodes to rearrange and pin them, scroll to zoom, drag the background to pan. Double-click a file to open it
73
+ into its functions and classes; double-click a symbol to pull in everything it calls and everything that calls it.
74
+ The side panel shows the source, callers and callees (click to jump), and **Show impact** highlights the transitive
75
+ callers, i.e. what breaks if the symbol changes. Press `/` to search.
76
+
65
77
  ## Tools
66
78
 
67
79
  | Tool | Use it to |
@@ -26,6 +26,17 @@ 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
+ You start from the file-level network: lines are calls and imports between files, colour is community (or folder).
35
+ Drag nodes to rearrange and pin them, scroll to zoom, drag the background to pan. Double-click a file to open it
36
+ into its functions and classes; double-click a symbol to pull in everything it calls and everything that calls it.
37
+ The side panel shows the source, callers and callees (click to jump), and **Show impact** highlights the transitive
38
+ callers, i.e. what breaks if the symbol changes. Press `/` to search.
39
+
29
40
  ## Tools
30
41
 
31
42
  | 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.2.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,262 @@
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
3
+ <title>Codegraph Explorer</title>
4
+ <style>
5
+ :root{--bg:#fafafa;--panel:#fff;--fg:#1a1a1a;--mute:#6b7280;--line:#d1d5db;--edge:#9ca3af;--acc:#2563eb;--hot:#dc2626;--code:#f3f4f6}
6
+ @media (prefers-color-scheme:dark){:root{--bg:#0f1115;--panel:#171a21;--fg:#e5e7eb;--mute:#9ca3af;--line:#2d333d;--edge:#6b7280;--acc:#60a5fa;--hot:#f87171;--code:#0b0d11}}
7
+ *{box-sizing:border-box}
8
+ html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);font:13px/1.4 system-ui,sans-serif;overflow:hidden}
9
+ #top{position:fixed;left:0;right:0;top:0;height:44px;display:flex;gap:8px;align-items:center;padding:0 12px;background:var(--panel);border-bottom:1px solid var(--line);z-index:5}
10
+ #top b{margin-right:4px}
11
+ input,select,button{font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:5px 9px}
12
+ button{cursor:pointer}button:hover{border-color:var(--acc)}
13
+ button.on{border-color:var(--acc);color:var(--acc)}
14
+ #q{width:260px}
15
+ #res{position:fixed;top:44px;left:96px;width:380px;max-height:60vh;overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:6px;display:none;z-index:6}
16
+ #res div{padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--line)}#res div:hover{background:var(--code)}
17
+ #res small,.mute{color:var(--mute)}
18
+ #wrap{position:fixed;left:0;top:44px;bottom:0;right:380px}
19
+ canvas{display:block;width:100%;height:100%;cursor:grab}
20
+ canvas.drag{cursor:grabbing}
21
+ #side{position:fixed;top:44px;right:0;bottom:0;width:380px;background:var(--panel);border-left:1px solid var(--line);overflow:auto;padding:14px}
22
+ #side h3{margin:0 0 2px;word-break:break-all}
23
+ #side pre{background:var(--code);padding:10px;border-radius:6px;overflow:auto;max-height:340px;font:12px/1.45 ui-monospace,Menlo,monospace}
24
+ #side .btns{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0}
25
+ #side .nb{padding:3px 0;cursor:pointer;word-break:break-all}#side .nb:hover{color:var(--acc)}
26
+ #side h4{margin:14px 0 4px;color:var(--mute);font-weight:600;text-transform:uppercase;font-size:11px;letter-spacing:.04em}
27
+ #hud{position:fixed;left:12px;bottom:10px;color:var(--mute);background:var(--bg);padding:4px 8px;border-radius:6px;opacity:.92;pointer-events:none}
28
+ #tip{position:fixed;pointer-events:none;background:var(--panel);border:1px solid var(--line);padding:3px 8px;border-radius:5px;display:none;z-index:7;max-width:50ch;word-break:break-all}
29
+ @media (max-width:800px){#side{width:100%;top:auto;height:45%}#wrap{right:0;bottom:45%}}
30
+ </style></head><body>
31
+ <div id="top"><b>Codegraph</b>
32
+ <input id="q" placeholder="Search a symbol and add it to the graph" autocomplete="off">
33
+ <button id="fit" title="Zoom to fit everything">Fit</button>
34
+ <button id="unpin" title="Release all dragged nodes">Unpin all</button>
35
+ <button id="reset" title="Back to the file overview">Reset</button>
36
+ <select id="color" title="Colour by"><option value="c">colour: community</option><option value="d">colour: folder</option></select>
37
+ <button id="lbl" class="on" title="Show labels">Labels</button>
38
+ </div>
39
+ <div id="res"></div>
40
+ <div id="wrap"><canvas id="c"></canvas></div>
41
+ <div id="side"></div>
42
+ <div id="hud"></div><div id="tip"></div>
43
+ <script>
44
+ "use strict";
45
+ const $=id=>document.getElementById(id), cv=$('c'), cx=cv.getContext('2d'), side=$('side'), tip=$('tip'), hud=$('hud');
46
+ const api=(p,q={})=>fetch('/api/'+p+'?'+new URLSearchParams(q)).then(r=>r.json());
47
+ const el=(t,props={},...kids)=>{const e=document.createElement(t);Object.assign(e,props);kids.forEach(k=>e.append(k));return e};
48
+
49
+ // ---- state ------------------------------------------------------------------------------------
50
+ let nodes=new Map(), links=[], fileLinks=[], symEdges=new Map(), symInfo=new Map(), total=0;
51
+ let sel=null, hov=null, impact=new Set(), alpha=1, ticks=0, auto=true, showLabels=true, colorBy='c', dirty=true;
52
+ const cam={x:0,y:0,k:1}; let W=0,H=0;
53
+ const dpr=devicePixelRatio||1;
54
+ let css={};
55
+ function readCss(){const s=getComputedStyle(document.documentElement);['bg','fg','mute','edge','acc','hot','line'].forEach(k=>css[k]=s.getPropertyValue('--'+k).trim())}
56
+ matchMedia('(prefers-color-scheme:dark)').addEventListener('change',()=>{readCss();dirty=true});readCss();
57
+
58
+ function resize(){const r=cv.getBoundingClientRect();W=r.width;H=r.height;cv.width=W*dpr;cv.height=H*dpr;dirty=true}
59
+ addEventListener('resize',resize);resize();
60
+
61
+ // ---- colours ----------------------------------------------------------------------------------
62
+ const hue=n=>`hsl(${(n*137.5)%360},62%,52%)`;
63
+ function hash(s){let h=0;for(let i=0;i<s.length;i++)h=(h*31+s.charCodeAt(i))|0;return Math.abs(h)}
64
+ const dir=f=>f.split('/').slice(0,-1).slice(0,2).join('/')||'.';
65
+ function colorOf(n){
66
+ if(colorBy==='d')return hue(hash(dir(n.file)));
67
+ return n.c<0?'#9ca3af':hue(n.c)}
68
+
69
+ // ---- graph building ---------------------------------------------------------------------------
70
+ const rnd=()=>(Math.random()-.5);
71
+ function addFile(f){
72
+ nodes.set('f:'+f.id,{key:'f:'+f.id,t:'f',label:f.id.split('/').pop(),file:f.id,c:f.c,n:f.n,loc:f.loc,rank:f.rank,
73
+ r:7+Math.sqrt(f.n)*1.7,x:rnd()*W*.8,y:rnd()*H*.8,vx:0,vy:0})}
74
+ function symNode(s,ax,ay){
75
+ const k='s:'+s.id;symInfo.set(s.id,s);
76
+ if(nodes.has(k))return nodes.get(k);
77
+ const n={key:k,t:'s',id:s.id,label:s.name,file:s.file,c:s.c,kind:s.kind,rank:s.rank,
78
+ r:3.5+Math.min(14,Math.sqrt(s.rank*2500)),x:ax+rnd()*40,y:ay+rnd()*40,vx:0,vy:0};
79
+ nodes.set(k,n);return n}
80
+ const touched=f=>{for(const n of nodes.values())if(n.t==='s'&&n.file===f)return true;return false};
81
+ function entity(id){
82
+ const k='s:'+id;if(nodes.has(k))return nodes.get(k);
83
+ const s=symInfo.get(id);return s&&nodes.get('f:'+s.file)||null}
84
+ function rebuild(){
85
+ const m=new Map(), tf=new Set();
86
+ for(const n of nodes.values())if(n.t==='s')tf.add(n.file);
87
+ const add=(a,b,w,k,q)=>{if(!a||!b||a===b)return;const key=a.key+'>'+b.key,o=m.get(key);
88
+ if(o){o.w+=w;if(q==='extracted')o.q=q}else m.set(key,{a,b,w,k,q})};
89
+ for(const l of fileLinks)if(!tf.has(l.s)&&!tf.has(l.t))add(nodes.get('f:'+l.s),nodes.get('f:'+l.t),l.w,l.k,l.k==='import'?'import':'extracted');
90
+ for(const e of symEdges.values())add(entity(e.s),entity(e.t),1,e.k,e.q);
91
+ links=[...m.values()];dirty=true}
92
+ function reheat(a=.5){alpha=Math.max(alpha,a);dirty=true}
93
+
94
+ async function loadOverview(){
95
+ nodes=new Map();symEdges=new Map();symInfo=new Map();sel=null;impact=new Set();auto=true;ticks=0;
96
+ hud.textContent='loading...';
97
+ const d=await api('overview');
98
+ total=d.total;fileLinks=d.links;d.files.forEach(f=>{fileMeta.set(f.id,f);addFile(f)});rebuild();reheat(1);panel();status()}
99
+ function status(){
100
+ const ns=[...nodes.values()].filter(n=>n.t==='s').length, nf=nodes.size-ns;
101
+ hud.textContent=`${nf} files, ${ns} symbols shown · ${total} files indexed · drag a node to pin it · double-click to expand · wheel to zoom · drag background to pan`}
102
+
103
+ async function expandFile(path){
104
+ const d=await api('file',{path});const fn=nodes.get('f:'+path);
105
+ const ax=fn?fn.x:0, ay=fn?fn.y:0;
106
+ d.symbols.forEach(s=>symNode(s,ax,ay));d.far.forEach(s=>symInfo.set(s.id,s));
107
+ d.edges.forEach(e=>symEdges.set(e.s+'>'+e.t+e.k,e));
108
+ nodes.delete('f:'+path);rebuild();reheat(.8);status();return d}
109
+ function collapseFile(path){
110
+ let x=0,y=0,c=0;
111
+ for(const [k,n] of nodes)if(n.t==='s'&&n.file===path){x+=n.x;y+=n.y;c++;nodes.delete(k)}
112
+ if(!c)return;
113
+ const info=fileMeta.get(path)||{id:path,n:c,loc:0,rank:0,c:-1};
114
+ addFile(info);const n=nodes.get('f:'+path);n.x=x/c;n.y=y/c;
115
+ if(sel&&!nodes.has(sel))sel='f:'+path;
116
+ rebuild();reheat(.5);status();panel()}
117
+ const fileMeta=new Map();
118
+ async function expandSymbol(id){
119
+ const d=await api('symbol',{id});if(d.error)return;
120
+ const me=symNode(d,W/2,H/2);
121
+ d.far.sort((a,b)=>b.rank-a.rank).slice(0,40).forEach(s=>symNode(s,me.x,me.y));
122
+ d.edges.forEach(e=>symEdges.set(e.s+'>'+e.t+e.k,e));
123
+ rebuild();reheat(.7);status();return d}
124
+ async function showImpact(id){
125
+ const d=await api('impact',{id});impact=new Set([id]);
126
+ d.symbols.forEach(s=>{symNode(s,nodes.get('s:'+id).x,nodes.get('s:'+id).y);impact.add(s.id)});
127
+ d.edges.forEach(e=>symEdges.set(e.s+'>'+e.t+e.k,e));
128
+ rebuild();reheat(.7);status();panel()}
129
+
130
+ // ---- force layout -----------------------------------------------------------------------------
131
+ function step(){
132
+ const arr=[...nodes.values()], n=arr.length;
133
+ const cen=new Map(); // symbols of one file are pulled together so an expanded file reads as a cluster
134
+ for(const a of arr)if(a.t==='s'){const c=cen.get(a.file)||(cen.set(a.file,{x:0,y:0,n:0}),cen.get(a.file));c.x+=a.x;c.y+=a.y;c.n++}
135
+ for(let i=0;i<n;i++){const a=arr[i];
136
+ for(let j=i+1;j<n;j++){const b=arr[j];let dx=a.x-b.x,dy=a.y-b.y,d2=dx*dx+dy*dy+.01;
137
+ if(d2<90000){const f=(1400+(a.r+b.r)*30)/d2;dx*=f;dy*=f;a.vx+=dx;a.vy+=dy;b.vx-=dx;b.vy-=dy}}
138
+ a.vx-=a.x*.004;a.vy-=a.y*.004;
139
+ if(a.t==='s'){const c=cen.get(a.file);if(c.n>1){a.vx+=(c.x/c.n-a.x)*.01;a.vy+=(c.y/c.n-a.y)*.01}}}
140
+ for(const l of links){const a=l.a,b=l.b;let dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;
141
+ const rest=a.r+b.r+(a.t==='s'&&a.file===b.file?22:70),f=(d-rest)*.012*(l.q==='extracted'?1:.6);
142
+ dx*=f/d;dy*=f/d;a.vx+=dx;a.vy+=dy;b.vx-=dx;b.vy-=dy}
143
+ for(const a of arr){
144
+ if(a.pin){a.vx=a.vy=0;continue}
145
+ a.vx*=.8;a.vy*=.8;a.x+=a.vx*alpha;a.y+=a.vy*alpha}
146
+ alpha=Math.max(.0,alpha*.985);ticks++;
147
+ if(auto&&nodes.size&&(ticks===60||alpha<.013)){fit()}}
148
+
149
+ // ---- drawing ----------------------------------------------------------------------------------
150
+ const toS=(x,y)=>[(x-cam.x)*cam.k+W/2,(y-cam.y)*cam.k+H/2], toW=(x,y)=>[(x-W/2)/cam.k+cam.x,(y-H/2)/cam.k+cam.y];
151
+ function fit(){
152
+ if(!nodes.size)return;let x0=1e9,y0=1e9,x1=-1e9,y1=-1e9;
153
+ for(const n of nodes.values()){x0=Math.min(x0,n.x-n.r);x1=Math.max(x1,n.x+n.r);y0=Math.min(y0,n.y-n.r);y1=Math.max(y1,n.y+n.r)}
154
+ cam.k=Math.min(2.5,.9*Math.min(W/(x1-x0||1),H/(y1-y0||1)));cam.x=(x0+x1)/2;cam.y=(y0+y1)/2;dirty=true}
155
+ function neighbours(key){const s=new Set([key]);for(const l of links){if(l.a.key===key)s.add(l.b.key);if(l.b.key===key)s.add(l.a.key)}return s}
156
+ function arrow(x,y,ang,sz){cx.beginPath();cx.moveTo(x,y);cx.lineTo(x-sz*Math.cos(ang-.45),y-sz*Math.sin(ang-.45));
157
+ cx.lineTo(x-sz*Math.cos(ang+.45),y-sz*Math.sin(ang+.45));cx.closePath();cx.fill()}
158
+ function draw(){
159
+ cx.setTransform(dpr,0,0,dpr,0,0);cx.clearRect(0,0,W,H);
160
+ const focus=sel&&nodes.has(sel)?neighbours(sel):null, k=cam.k;
161
+ // soft hulls around expanded files
162
+ const groups=new Map();
163
+ for(const n of nodes.values())if(n.t==='s'){const g=groups.get(n.file)||(groups.set(n.file,{x:0,y:0,n:0,m:[]}),groups.get(n.file));g.x+=n.x;g.y+=n.y;g.n++;g.m.push(n)}
164
+ for(const [f,g] of groups){const mx=g.x/g.n,my=g.y/g.n;let r=0;g.m.forEach(m=>r+=Math.hypot(m.x-mx,m.y-my));r=r/g.n*1.5+10;
165
+ const [sx,sy]=toS(mx,my);cx.globalAlpha=.07;cx.fillStyle=colorOf(g.m[0]);cx.beginPath();cx.arc(sx,sy,(r+14)*k,0,6.3);cx.fill();
166
+ cx.globalAlpha=.7;cx.fillStyle=css.mute;cx.font='11px system-ui';cx.textAlign='center';cx.fillText(f.split('/').pop(),sx,sy-(r+18)*k)}
167
+ for(const l of links){
168
+ const on=!focus||(focus.has(l.a.key)&&focus.has(l.b.key)), imp=impact.size&&l.a.id!==undefined&&impact.has(l.a.id)&&impact.has(l.b.id);
169
+ const [ax,ay]=toS(l.a.x,l.a.y),[bx,by]=toS(l.b.x,l.b.y);
170
+ cx.globalAlpha=on?(imp?.9:focus?.6:.3):.04;cx.strokeStyle=cx.fillStyle=imp?css.hot:css.edge;
171
+ cx.lineWidth=Math.min(5,1+Math.log2(l.w))*(imp?1.6:1);
172
+ cx.setLineDash(l.k==='import'?[1,4]:l.q==='extracted'?[]:[5,4]);
173
+ cx.beginPath();cx.moveTo(ax,ay);cx.lineTo(bx,by);cx.stroke();
174
+ if(l.k!=='import'&&k>.35){cx.setLineDash([]);const ang=Math.atan2(by-ay,bx-ax),rr=l.b.r*k+1;arrow(bx-Math.cos(ang)*rr,by-Math.sin(ang)*rr,ang,6)}}
175
+ cx.setLineDash([]);
176
+ for(const n of nodes.values()){
177
+ const [sx,sy]=toS(n.x,n.y), r=n.r*k, dim=focus&&!focus.has(n.key);
178
+ cx.globalAlpha=dim?.12:1;cx.fillStyle=colorOf(n);
179
+ cx.beginPath();
180
+ if(n.t==='f'){const s=Math.max(r*1.6,4);cx.roundRect(sx-s/2,sy-s/2,s,s,s*.22)}else cx.arc(sx,sy,Math.max(r,1.5),0,6.3);
181
+ cx.fill();
182
+ if(n.pin){cx.strokeStyle=css.fg;cx.lineWidth=1.2;cx.stroke()}
183
+ if(n.key===sel||n.key===hov||(n.t==='s'&&impact.has(n.id))){cx.strokeStyle=n.key===sel?css.acc:css.hot;cx.lineWidth=2.5;cx.beginPath();
184
+ cx.arc(sx,sy,Math.max(r,3)+(n.t==='f'?r*.4:2)+2,0,6.3);cx.stroke()}
185
+ const show=showLabels&&!dim&&(n.key===sel||n.key===hov||(focus&&focus.has(n.key))||k>1.5||(n.t==='f'&&(n.r>13||nodes.size<80))||(n.t==='s'&&k>.9&&n.r>7));
186
+ if(show){cx.globalAlpha=1;cx.fillStyle=css.fg;cx.font=(n.key===sel?'bold ':'')+'12px system-ui';cx.textAlign='left';cx.fillText(n.label,sx+Math.max(r,3)+4,sy+4)}}
187
+ cx.globalAlpha=1}
188
+ (function loop(){if(alpha>.012){step();dirty=true}if(dirty){draw();dirty=false}requestAnimationFrame(loop)})();
189
+
190
+ // ---- interaction ------------------------------------------------------------------------------
191
+ function pick(ev){const r=cv.getBoundingClientRect(),[wx,wy]=toW(ev.clientX-r.left,ev.clientY-r.top);let best=null,bd=1e9;
192
+ for(const n of nodes.values()){const d=Math.hypot(n.x-wx,n.y-wy),rr=Math.max(n.r,4/cam.k)+3/cam.k;if(d<rr&&d<bd){bd=d;best=n}}return best}
193
+ let drag=null, pan=null, moved=false;
194
+ cv.addEventListener('pointerdown',e=>{cv.setPointerCapture(e.pointerId);moved=false;const n=pick(e);
195
+ if(n){drag=n;cv.classList.add('drag')}else{pan={x:e.clientX,y:e.clientY,cx:cam.x,cy:cam.y};cv.classList.add('drag')}});
196
+ cv.addEventListener('pointermove',e=>{const r=cv.getBoundingClientRect();
197
+ if(drag){moved=true;auto=false;const [wx,wy]=toW(e.clientX-r.left,e.clientY-r.top);drag.x=wx;drag.y=wy;drag.pin=true;reheat(.25);return}
198
+ if(pan){moved=true;auto=false;cam.x=pan.cx-(e.clientX-pan.x)/cam.k;cam.y=pan.cy-(e.clientY-pan.y)/cam.k;dirty=true;return}
199
+ const n=pick(e);if(n&&n.key!==hov||!n&&hov){hov=n?n.key:null;dirty=true}
200
+ if(n){tip.textContent=n.t==='f'?`${n.file} (${n.n} symbols, ${n.loc} loc)`:`${n.kind} ${symInfo.get(n.id).qn} ${n.file}`;
201
+ tip.style.display='block';tip.style.left=e.clientX+14+'px';tip.style.top=e.clientY+14+'px'}else tip.style.display='none'});
202
+ cv.addEventListener('pointerup',e=>{cv.classList.remove('drag');
203
+ if(drag&&!moved){select(drag.key)}else if(!drag&&pan&&!moved){select(null)}
204
+ drag=pan=null});
205
+ cv.addEventListener('dblclick',async e=>{const n=pick(e);if(!n)return;n.pin=false;
206
+ if(n.t==='f')await expandFile(n.file);else await expandSymbol(n.id);select(n.t==='f'?null:n.key)});
207
+ cv.addEventListener('wheel',e=>{e.preventDefault();auto=false;const r=cv.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top;
208
+ const [wx,wy]=toW(mx,my);cam.k=Math.min(8,Math.max(.05,cam.k*Math.exp(-e.deltaY*.0015)));
209
+ cam.x=wx-(mx-W/2)/cam.k;cam.y=wy-(my-H/2)/cam.k;dirty=true},{passive:false});
210
+ $('fit').onclick=()=>{auto=true;fit()};
211
+ $('unpin').onclick=()=>{nodes.forEach(n=>n.pin=false);reheat(.6)};
212
+ $('reset').onclick=()=>loadOverview();
213
+ $('color').onchange=e=>{colorBy=e.target.value;dirty=true};
214
+ $('lbl').onclick=e=>{showLabels=!showLabels;e.target.classList.toggle('on',showLabels);dirty=true};
215
+
216
+ // ---- side panel -------------------------------------------------------------------------------
217
+ function select(key){sel=key;dirty=true;panel()}
218
+ function btn(text,fn,title=''){return el('button',{textContent:text,title,onclick:fn})}
219
+ async function focusSymbol(id,s){ // bring any symbol into the graph and select it
220
+ if(!nodes.has('s:'+id)){if(s)symInfo.set(id,s);await expandSymbol(id)}
221
+ const n=nodes.get('s:'+id);if(n){cam.x=n.x;cam.y=n.y;cam.k=Math.max(cam.k,1);select(n.key)}}
222
+ async function panel(){
223
+ side.replaceChildren();const n=sel&&nodes.get(sel);
224
+ if(!n){side.append(el('h3',{textContent:'Explore'}),el('p',{className:'mute',textContent:
225
+ 'Each square is a file, sized by symbol count; lines are calls and imports between files. Double-click a file to open it into its functions and classes, double-click a function to pull in everything it calls and everything that calls it. Drag nodes to rearrange and pin them. Search to jump straight to a symbol.'}));return}
226
+ if(n.t==='f'){
227
+ side.append(el('h3',{textContent:n.file}),el('div',{className:'mute',textContent:`${n.n} symbols · ${n.loc} lines`}),
228
+ el('div',{className:'btns'},btn('Expand into symbols',async()=>{await expandFile(n.file);select(null)}),
229
+ btn(n.pin?'Unpin':'Pin',()=>{n.pin=!n.pin;reheat(.1);panel()})));
230
+ const near=[];links.forEach(l=>{if(l.a===n)near.push([l.b,'→',l.w]);else if(l.b===n)near.push([l.a,'←',l.w])});
231
+ if(near.length){side.append(el('h4',{textContent:'Connected files'}));
232
+ near.sort((a,b)=>b[2]-a[2]).slice(0,25).forEach(([o,d,w])=>side.append(el('div',{className:'nb',textContent:`${d} ${o.file||o.label} ×${w}`,onclick:()=>{if(o.t==='f'||o.t==='s')select(o.key)}})))}
233
+ return}
234
+ const d=await api('symbol',{id:n.id});if(sel!==n.key)return;
235
+ side.append(el('h3',{textContent:d.qn}),el('div',{className:'mute',textContent:`${d.kind} · ${d.file}:${d.l1}-${d.l2}`}));
236
+ if(d.sig)side.append(el('div',{style:'margin-top:6px;font-family:ui-monospace,Menlo,monospace;word-break:break-all',textContent:d.sig}));
237
+ if(d.doc)side.append(el('p',{className:'mute',textContent:d.doc.slice(0,400)}));
238
+ side.append(el('div',{className:'btns'},
239
+ btn('Expand neighbours',async()=>{await expandSymbol(n.id);panel()},'pull in callers and callees'),
240
+ btn(impact.has(n.id)&&impact.size>1?'Hide impact':'Show impact',async()=>{if(impact.size>1){impact=new Set();dirty=true;panel()}else await showImpact(n.id)},'transitive callers: what breaks if this changes'),
241
+ btn('Collapse file',()=>collapseFile(n.file),'fold this file back into one node'),
242
+ btn(n.pin?'Unpin':'Pin',()=>{n.pin=!n.pin;reheat(.1);panel()})));
243
+ const info=id=>symInfo.get(id)||d.far.find(s=>s.id===id);
244
+ const callers=d.edges.filter(e=>e.t===n.id).map(e=>[info(e.s),e]), callees=d.edges.filter(e=>e.s===n.id).map(e=>[info(e.t),e]);
245
+ const list=(title,rows)=>{if(!rows.length)return;side.append(el('h4',{textContent:`${title} (${rows.length})`}));
246
+ rows.slice(0,40).forEach(([s,e])=>s&&side.append(el('div',{className:'nb',onclick:()=>focusSymbol(s.id,s),
247
+ textContent:`${s.qn}${e.q&&e.q!=='extracted'?' ?':''} ${s.file}`})))};
248
+ list('Called by',callers);list('Calls / uses',callees);
249
+ side.append(el('h4',{textContent:'Source'}),el('pre',{textContent:d.source||'(no source)'}))}
250
+
251
+ // ---- search -----------------------------------------------------------------------------------
252
+ let tm;const res=$('res');
253
+ $('q').addEventListener('input',e=>{clearTimeout(tm);const v=e.target.value.trim();
254
+ if(!v){res.style.display='none';return}
255
+ tm=setTimeout(async()=>{const d=await api('search',{q:v});res.replaceChildren();
256
+ d.symbols.forEach(s=>res.append(el('div',{onclick:()=>{res.style.display='none';focusSymbol(s.id,s)}},
257
+ el('span',{textContent:s.qn}),el('small',{textContent:` ${s.kind} · ${s.file}`}))));
258
+ if(!d.symbols.length)res.append(el('div',{className:'mute',textContent:'no matches'}));res.style.display='block'},180)});
259
+ addEventListener('keydown',e=>{if(e.key==='Escape'){res.style.display='none';select(null)}if(e.key==='/'&&document.activeElement!==$('q')){e.preventDefault();$('q').focus()}});
260
+
261
+ loadOverview();
262
+ </script></body></html>
@@ -0,0 +1,196 @@
1
+ """`codegraph ui`: an interactive, local-only graph explorer served from the existing index.
2
+
3
+ Stdlib only. The page (ui.html) starts from the file-level network and lets you drag nodes, expand a
4
+ file into its symbols, pull in a symbol's neighbours and read source, all through the JSON API below.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sys
10
+ import threading
11
+ import webbrowser
12
+ from collections import Counter, defaultdict, deque
13
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
+ from importlib import resources
15
+ from urllib.parse import parse_qs, urlparse
16
+
17
+ from .indexer import index_repo
18
+ from .queries import Graph
19
+
20
+ OVERVIEW_FILES = 500 # a force layout in a browser canvas stays smooth up to roughly this many nodes
21
+ IMPACT_LIMIT = 80
22
+ HIDDEN = ("module", "impl", "namespace")
23
+
24
+
25
+ def _sym(r) -> dict:
26
+ return {"id": r["id"], "name": r["name"], "qn": r["qualname"], "kind": r["kind"], "file": r["file"],
27
+ "l1": r["line_start"], "l2": r["line_end"], "rank": r["rank"], "c": r["community"]}
28
+
29
+
30
+ class Api:
31
+ """Read-only queries over one Graph. Every method returns JSON-serialisable data."""
32
+
33
+ def __init__(self, graph: Graph):
34
+ self.g = graph
35
+
36
+ def overview(self, limit: int = OVERVIEW_FILES) -> dict:
37
+ with self.g._lock:
38
+ db = self.g.db
39
+ files = {}
40
+ for r in db.execute(
41
+ f"SELECT file, COUNT(*) n, SUM(rank) rank FROM symbols WHERE kind NOT IN {HIDDEN!r} GROUP BY file"):
42
+ files[r["file"]] = {"id": r["file"], "n": r["n"], "rank": r["rank"] or 0.0, "loc": 0, "c": -1}
43
+ for r in db.execute("SELECT path, loc, lang FROM files"):
44
+ if r["path"] in files:
45
+ files[r["path"]].update(loc=r["loc"] or 0, lang=r["lang"])
46
+ votes: dict[str, Counter] = defaultdict(Counter)
47
+ for r in db.execute(f"SELECT file, community FROM symbols WHERE community>=0 AND kind NOT IN {HIDDEN!r}"):
48
+ votes[r["file"]][r["community"]] += 1
49
+ for f, v in votes.items():
50
+ if f in files:
51
+ files[f]["c"] = v.most_common(1)[0][0]
52
+ keep = {f["id"] for f in sorted(files.values(), key=lambda f: -f["rank"])[:limit]}
53
+ pair: Counter = Counter()
54
+ for r in db.execute("SELECT a.file fa, b.file fb FROM edges e JOIN symbols a ON a.id=e.src "
55
+ "JOIN symbols b ON b.id=e.dst WHERE a.file != b.file"):
56
+ if r["fa"] in keep and r["fb"] in keep:
57
+ pair[(r["fa"], r["fb"])] += 1
58
+ links = [{"s": a, "t": b, "w": w, "k": "call"} for (a, b), w in pair.items()]
59
+ seen = set(pair)
60
+ for r in db.execute("SELECT file, dst FROM imports"):
61
+ k = (r["file"], r["dst"])
62
+ if r["file"] in keep and r["dst"] in keep and k not in seen and r["file"] != r["dst"]:
63
+ seen.add(k)
64
+ links.append({"s": k[0], "t": k[1], "w": 1, "k": "import"})
65
+ return {"files": [files[f] for f in keep], "links": links, "total": len(files)}
66
+
67
+ def file(self, path: str) -> dict:
68
+ """Symbols of one file plus every edge that touches them (the far end is described too)."""
69
+ with self.g._lock:
70
+ db = self.g.db
71
+ syms = [_sym(r) for r in db.execute(
72
+ f"SELECT * FROM symbols WHERE file=? AND kind NOT IN {HIDDEN!r} ORDER BY line_start", (path,))]
73
+ ids = [s["id"] for s in syms]
74
+ return {"symbols": syms, **self._edges_of(ids)}
75
+
76
+ def _edges_of(self, ids) -> dict:
77
+ db = self.g.db
78
+ edges, far = [], {}
79
+ if not ids:
80
+ return {"edges": edges, "far": []}
81
+ mark = ",".join("?" * len(ids))
82
+ mine = set(ids)
83
+ for r in db.execute(f"SELECT src, dst, kind, conf FROM edges WHERE src IN ({mark}) OR dst IN ({mark})",
84
+ (*ids, *ids)):
85
+ edges.append({"s": r["src"], "t": r["dst"], "k": r["kind"], "q": r["conf"]})
86
+ for o in (r["src"], r["dst"]):
87
+ if o not in mine:
88
+ far[o] = None
89
+ if far:
90
+ m2 = ",".join("?" * len(far))
91
+ far_rows = db.execute(f"SELECT * FROM symbols WHERE id IN ({m2}) AND kind NOT IN {HIDDEN!r}",
92
+ tuple(far)).fetchall()
93
+ ok = {r["id"] for r in far_rows}
94
+ edges = [e for e in edges if (e["s"] in mine or e["s"] in ok) and (e["t"] in mine or e["t"] in ok)]
95
+ return {"edges": edges, "far": [_sym(r) for r in far_rows]}
96
+ return {"edges": edges, "far": []}
97
+
98
+ def symbol(self, sid: int) -> dict:
99
+ with self.g._lock:
100
+ db = self.g.db
101
+ r = db.execute("SELECT * FROM symbols WHERE id=?", (sid,)).fetchone()
102
+ if r is None:
103
+ return {"error": "unknown symbol"}
104
+ out = _sym(r)
105
+ out.update(sig=r["sig"] or "", doc=r["doc"] or "", source=self.g._read(r)[:12000])
106
+ nb = self._edges_of([sid])
107
+ out["edges"], out["far"] = nb["edges"], nb["far"]
108
+ return out
109
+
110
+ def impact(self, sid: int, depth: int = 3) -> dict:
111
+ """Transitive callers (what breaks if this changes), nearest first."""
112
+ with self.g._lock:
113
+ seen, q, order = {sid}, deque([(sid, 0)]), []
114
+ while q and len(order) < IMPACT_LIMIT:
115
+ u, d = q.popleft()
116
+ if d >= depth:
117
+ continue
118
+ for v in self.g._in.get(u, ()):
119
+ if v not in seen:
120
+ seen.add(v)
121
+ order.append(v)
122
+ q.append((v, d + 1))
123
+ order = order[:IMPACT_LIMIT]
124
+ if not order:
125
+ return {"symbols": [], "edges": []}
126
+ mark = ",".join("?" * len(order))
127
+ rows = self.g.db.execute(f"SELECT * FROM symbols WHERE id IN ({mark}) AND kind NOT IN {HIDDEN!r}",
128
+ tuple(order)).fetchall()
129
+ ids = {r["id"] for r in rows} | {sid}
130
+ edges = [{"s": u, "t": v, "k": "call", "q": "inferred"} for u in ids for v in self.g._out.get(u, ())
131
+ if v in ids]
132
+ return {"symbols": [_sym(r) for r in rows], "edges": edges}
133
+
134
+ def search(self, q: str) -> dict:
135
+ with self.g._lock:
136
+ rows = self.g._search_rows(q, 25) if q.strip() else []
137
+ return {"symbols": [_sym(r) for r in rows if r["kind"] not in HIDDEN]}
138
+
139
+
140
+ class Handler(BaseHTTPRequestHandler):
141
+ api: Api
142
+ port: int
143
+ page: bytes
144
+
145
+ def log_message(self, *a): # keep the terminal quiet
146
+ pass
147
+
148
+ def _send(self, body: bytes, ctype: str, code: int = 200):
149
+ self.send_response(code)
150
+ self.send_header("Content-Type", ctype)
151
+ self.send_header("Content-Length", str(len(body)))
152
+ self.send_header("Cache-Control", "no-store")
153
+ self.end_headers()
154
+ self.wfile.write(body)
155
+
156
+ def do_GET(self):
157
+ # DNS-rebinding guard: the source of your repo must only reach pages served from this host:port
158
+ if self.headers.get("Host", "").split(":")[0] not in ("127.0.0.1", "localhost"):
159
+ return self._send(b"forbidden", "text/plain", 403)
160
+ u = urlparse(self.path)
161
+ a = {k: v[0] for k, v in parse_qs(u.query).items()}
162
+ try:
163
+ if u.path == "/":
164
+ return self._send(self.page, "text/html; charset=utf-8")
165
+ routes = {
166
+ "/api/overview": lambda: self.api.overview(int(a.get("limit", OVERVIEW_FILES))),
167
+ "/api/file": lambda: self.api.file(a["path"]),
168
+ "/api/symbol": lambda: self.api.symbol(int(a["id"])),
169
+ "/api/impact": lambda: self.api.impact(int(a["id"]), int(a.get("depth", 3))),
170
+ "/api/search": lambda: self.api.search(a.get("q", "")),
171
+ }
172
+ if u.path not in routes:
173
+ return self._send(b"not found", "text/plain", 404)
174
+ return self._send(json.dumps(routes[u.path]()).encode(), "application/json")
175
+ except (KeyError, ValueError):
176
+ return self._send(b'{"error":"bad request"}', "application/json", 400)
177
+
178
+
179
+ def serve(root: str, port: int = 0, open_browser: bool = True) -> None:
180
+ print(f"codegraph: indexing {root} ...", file=sys.stderr, flush=True)
181
+ index_repo(root)
182
+ graph = Graph(root)
183
+ Handler.api = Api(graph)
184
+ Handler.page = resources.files("codegraph").joinpath("ui.html").read_bytes()
185
+ server = ThreadingHTTPServer(("127.0.0.1", port), Handler) # loopback only: never expose source on the LAN
186
+ url = f"http://127.0.0.1:{server.server_address[1]}/"
187
+ print(f"codegraph ui: {url} (Ctrl+C to stop)", flush=True)
188
+ if open_browser:
189
+ threading.Timer(0.3, webbrowser.open, (url,)).start()
190
+ try:
191
+ server.serve_forever()
192
+ except KeyboardInterrupt:
193
+ pass
194
+ finally:
195
+ server.server_close()
196
+ graph.close()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codegraph-network
3
- Version: 0.1.0
3
+ Version: 0.2.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,17 @@ 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
+ You start from the file-level network: lines are calls and imports between files, colour is community (or folder).
72
+ Drag nodes to rearrange and pin them, scroll to zoom, drag the background to pan. Double-click a file to open it
73
+ into its functions and classes; double-click a symbol to pull in everything it calls and everything that calls it.
74
+ The side panel shows the source, callers and callees (click to jump), and **Show impact** highlights the transitive
75
+ callers, i.e. what breaks if the symbol changes. Press `/` to search.
76
+
65
77
  ## Tools
66
78
 
67
79
  | Tool | Use it to |
@@ -20,6 +20,8 @@ src/codegraph/review.py
20
20
  src/codegraph/server.py
21
21
  src/codegraph/store.py
22
22
  src/codegraph/tools.py
23
+ src/codegraph/ui.html
24
+ src/codegraph/ui.py
23
25
  src/codegraph_network.egg-info/PKG-INFO
24
26
  src/codegraph_network.egg-info/SOURCES.txt
25
27
  src/codegraph_network.egg-info/dependency_links.txt
@@ -38,4 +40,5 @@ tests/test_queries.py
38
40
  tests/test_rank.py
39
41
  tests/test_semantic.py
40
42
  tests/test_server_cli.py
41
- tests/test_store.py
43
+ tests/test_store.py
44
+ tests/test_ui.py
@@ -8,6 +8,7 @@ pyyaml>=6
8
8
  [dev]
9
9
  pytest>=8
10
10
  ruff>=0.6
11
+ numpy>=1.24
11
12
 
12
13
  [embed]
13
14
  fastembed>=0.4
@@ -90,7 +90,7 @@ def test_cli_exposes_every_tool():
90
90
  assert subactions, "no subcommands registered"
91
91
  names = set(subactions[0].choices)
92
92
  from codegraph.cli import CLI_SPELLINGS
93
- expected = {"serve", "bench", "install", "hook"} | {spelling for spellings in CLI_SPELLINGS.values() for spelling in spellings}
93
+ expected = {"serve", "ui", "bench", "install", "hook"} | {spelling for spellings in CLI_SPELLINGS.values() for spelling in spellings}
94
94
  assert names == expected
95
95
  # documented aliases must keep working
96
96
  assert {"map"} <= names
@@ -0,0 +1,60 @@
1
+ import json
2
+ import threading
3
+ import urllib.error
4
+ import urllib.request
5
+ from http.server import ThreadingHTTPServer
6
+
7
+ import pytest
8
+
9
+ from codegraph import ui
10
+
11
+ from .conftest import FIXTURES, build_graph
12
+
13
+
14
+ @pytest.fixture(scope="module")
15
+ def base():
16
+ graph = build_graph(FIXTURES / "py")
17
+ ui.Handler.api = ui.Api(graph)
18
+ ui.Handler.page = b"<html>page</html>"
19
+ server = ThreadingHTTPServer(("127.0.0.1", 0), ui.Handler)
20
+ threading.Thread(target=server.serve_forever, daemon=True).start()
21
+ yield f"http://127.0.0.1:{server.server_address[1]}"
22
+ server.shutdown()
23
+ graph.close()
24
+
25
+
26
+ def get(base, path, host=None):
27
+ req = urllib.request.Request(base + path, headers={"Host": host} if host else {})
28
+ with urllib.request.urlopen(req) as r:
29
+ return r.read()
30
+
31
+
32
+ def test_page_and_overview(base):
33
+ assert b"page" in get(base, "/")
34
+ d = json.loads(get(base, "/api/overview"))
35
+ assert d["files"] and d["total"] >= len(d["files"])
36
+ assert any(f["id"].endswith("service.py") for f in d["files"])
37
+ assert d["links"]
38
+
39
+
40
+ def test_file_symbol_impact_search(base):
41
+ path = next(f["id"] for f in json.loads(get(base, "/api/overview"))["files"] if f["id"].endswith("core.py"))
42
+ d = json.loads(get(base, f"/api/file?path={path}"))
43
+ assert d["symbols"]
44
+ ids = {s["id"] for s in d["symbols"]} | {s["id"] for s in d["far"]}
45
+ assert all(e["s"] in ids and e["t"] in ids for e in d["edges"])
46
+ sid = d["symbols"][0]["id"]
47
+ sym = json.loads(get(base, f"/api/symbol?id={sid}"))
48
+ assert sym["qn"] and "source" in sym
49
+ assert "symbols" in json.loads(get(base, f"/api/impact?id={sid}"))
50
+ hits = json.loads(get(base, f"/api/search?q={d['symbols'][0]['name']}"))["symbols"]
51
+ assert hits
52
+
53
+
54
+ def test_rejects_foreign_host_and_bad_input(base):
55
+ with pytest.raises(urllib.error.HTTPError) as e:
56
+ get(base, "/", host="evil.example")
57
+ assert e.value.code == 403
58
+ with pytest.raises(urllib.error.HTTPError) as e:
59
+ get(base, "/api/symbol?id=abc")
60
+ assert e.value.code == 400
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"