Skip to content

Commit a97a1d8

Browse files
committed
feat: symbol-level views with edge validity and API gap fixes
- Add Symbols and Types views (symbol graph, call edges, type filtering) - Fix edge validity: filter node_modules edges, auto-create symbolNodes for non-exported callers (<module> scope, internal functions) - Expand graph builder to extract interface/type/enum symbol nodes - Add symbol module derivation for module cloud grouping - Fix API gaps: groups wrapper, search suggestions, MCP hotspot defaults - Remove dead exports (ForceThresholds, un-export internal-only symbols) - Update tests for new API response shapes
1 parent f0ea416 commit a97a1d8

File tree

22 files changed

+650
-37
lines changed

22 files changed

+650
-37
lines changed

app/api/file/[...path]/route.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,17 @@ export function GET(
2525
path: filePath,
2626
loc: node?.loc ?? 0,
2727
module: node?.module ?? "",
28-
functions: functions.map((f) => ({ name: f.label, loc: f.loc })),
28+
functions: functions.map((f) => {
29+
const symId = `${filePath}::${f.label}`;
30+
const symMetrics = graph.symbolMetrics.get(symId);
31+
return {
32+
name: f.label,
33+
loc: f.loc,
34+
fanIn: symMetrics?.fanIn ?? 0,
35+
fanOut: symMetrics?.fanOut ?? 0,
36+
pageRank: symMetrics?.pageRank ?? 0,
37+
};
38+
}),
2939
imports: imports.map((e) => ({ from: e.target, symbols: e.symbols })),
3040
dependents: dependents.map((e) => ({
3141
path: e.source,

app/api/groups/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@ import { getGraph } from "@/src/server/graph-store";
33

44
export function GET(): NextResponse {
55
const graph = getGraph();
6-
return NextResponse.json(graph.groups);
6+
return NextResponse.json({ groups: graph.groups });
77
}

app/api/mcp/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ function runTool(graph: CodebaseGraph, tool: string, params: Record<string, unkn
125125
}
126126

127127
case "find_hotspots": {
128-
const metric = params.metric as string;
128+
const metric = (params.metric as string | undefined) ?? "coupling";
129129
const limit = (params.limit as number | undefined) ?? 10;
130130
type ScoredFile = { path: string; score: number; reason: string };
131131
const scored: ScoredFile[] = [];

app/api/search/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,5 @@ export function GET(request: Request): NextResponse {
3232
})),
3333
}));
3434

35-
return NextResponse.json({ query, results: mapped });
35+
return NextResponse.json({ query, results: mapped, suggestions: [] });
3636
}

app/api/symbol-graph/route.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { NextResponse } from "next/server";
2+
import { getGraph } from "@/src/server/graph-store";
3+
import type { SymbolGraphResponse } from "@/lib/types";
4+
5+
export function GET(): NextResponse<SymbolGraphResponse> {
6+
const graph = getGraph();
7+
8+
const symbolNodes = graph.symbolNodes.map((s) => {
9+
const metrics = graph.symbolMetrics.get(s.id);
10+
return {
11+
id: s.id,
12+
name: s.name,
13+
type: s.type,
14+
file: s.file,
15+
loc: s.loc,
16+
isDefault: s.isDefault,
17+
fanIn: metrics?.fanIn ?? 0,
18+
fanOut: metrics?.fanOut ?? 0,
19+
pageRank: metrics?.pageRank ?? 0,
20+
betweenness: metrics?.betweenness ?? 0,
21+
};
22+
});
23+
24+
const callEdges = graph.callEdges.map((e) => ({
25+
source: e.source,
26+
target: e.target,
27+
callerSymbol: e.callerSymbol,
28+
calleeSymbol: e.calleeSymbol,
29+
confidence: e.confidence,
30+
}));
31+
32+
const symbolMetrics = [...graph.symbolMetrics.values()].map((m) => ({
33+
symbolId: m.symbolId,
34+
name: m.name,
35+
file: m.file,
36+
fanIn: m.fanIn,
37+
fanOut: m.fanOut,
38+
pageRank: m.pageRank,
39+
betweenness: m.betweenness,
40+
}));
41+
42+
return NextResponse.json({ symbolNodes, callEdges, symbolMetrics });
43+
}

app/api/symbols/[name]/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export function GET(
4646
}
4747

4848
const sym = deduped[0];
49+
const symbolNode = graph.symbolNodes.find((s) => s.id === sym.symbolId);
4950

5051
const callers = graph.callEdges
5152
.filter((e) => e.calleeSymbol === symbolName || e.target === sym.symbolId)
@@ -58,8 +59,12 @@ export function GET(
5859
return NextResponse.json({
5960
name: sym.name,
6061
file: sym.file,
62+
loc: symbolNode?.loc ?? 0,
63+
type: symbolNode?.type ?? "function",
6164
fanIn: sym.fanIn,
6265
fanOut: sym.fanOut,
66+
pageRank: sym.pageRank,
67+
betweenness: sym.betweenness,
6368
callers,
6469
callees,
6570
nextSteps: getHints("symbol_context"),

app/page.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ProjectBar } from "@/components/project-bar";
77
import { ViewTabs } from "@/components/view-tabs";
88
import { SearchInput } from "@/components/search-input";
99
import { DetailPanel } from "@/components/detail-panel";
10+
import { SymbolDetailPanel } from "@/components/symbol-detail";
1011
import { SettingsPanel } from "@/components/settings-panel";
1112
import { Legend } from "@/components/legend";
1213
import { FileTree } from "@/components/file-tree";
@@ -17,6 +18,7 @@ function App(): React.ReactElement | null {
1718
graphData,
1819
forceData,
1920
groupData,
21+
symbolData,
2022
projectName,
2123
staleness,
2224
isLoading,
@@ -27,6 +29,8 @@ function App(): React.ReactElement | null {
2729
setCurrentView,
2830
selectedNode,
2931
setSelectedNode,
32+
selectedSymbol,
33+
setSelectedSymbol,
3034
focusNodeId,
3135
handleNodeClick,
3236
handleNavigate,
@@ -74,7 +78,9 @@ function App(): React.ReactElement | null {
7478
focusNodeId={focusNodeId}
7579
forceData={forceData}
7680
circularDeps={graphData.stats.circularDeps}
81+
symbolData={symbolData}
7782
onNodeClick={handleNodeClick}
83+
onSymbolClick={setSelectedSymbol}
7884
/>
7985
<DetailPanel
8086
node={selectedNode}
@@ -83,6 +89,11 @@ function App(): React.ReactElement | null {
8389
onNavigate={handleNavigate}
8490
onFocus={handleFocus}
8591
/>
92+
<SymbolDetailPanel
93+
symbol={selectedSymbol}
94+
callEdges={symbolData?.callEdges ?? []}
95+
onClose={() => { setSelectedSymbol(null); }}
96+
/>
8697
<Legend view={currentView} groups={groupData} showClouds={config.showModuleBoxes} />
8798
<SettingsPanel config={config} onChange={setConfig} />
8899
</>

components/detail-panel.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ export function DetailPanel({
8484
{node.functions.map((f) => (
8585
<div key={f.name} className="py-0.5">
8686
{f.name} ({f.loc} LOC)
87+
{f.fanIn !== undefined && (
88+
<span className="text-[#666] ml-1">
89+
in:{f.fanIn} out:{f.fanOut ?? 0} PR:{(f.pageRank ?? 0).toFixed(3)}
90+
</span>
91+
)}
8792
</div>
8893
))}
8994
</div>

components/graph-canvas.tsx

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import type {
1616
GraphConfig,
1717
ForceApiResponse,
1818
ViewType,
19+
SymbolGraphResponse,
20+
SymbolApiNode,
1921
} from "@/lib/types";
2022
import {
2123
galaxyView,
@@ -26,6 +28,8 @@ import {
2628
forcesView,
2729
churnView,
2830
coverageView,
31+
symbolView,
32+
typesView,
2933
getModuleColor,
3034
} from "@/lib/views";
3135
import { cloudGroup } from "@/src/cloud-group";
@@ -55,7 +59,9 @@ export function GraphCanvas({
5559
focusNodeId,
5660
forceData,
5761
circularDeps,
62+
symbolData,
5863
onNodeClick,
64+
onSymbolClick,
5965
}: {
6066
nodes: GraphApiNode[];
6167
edges: GraphApiEdge[];
@@ -64,7 +70,9 @@ export function GraphCanvas({
6470
focusNodeId: string | null;
6571
forceData: ForceApiResponse | undefined;
6672
circularDeps: string[][];
73+
symbolData: SymbolGraphResponse | undefined;
6774
onNodeClick: (node: GraphApiNode) => void;
75+
onSymbolClick: (symbol: SymbolApiNode) => void;
6876
}): React.ReactElement {
6977
const fgRef = useRef<ForceGraph3DInstance | undefined>(undefined);
7078
const cloudsRef = useRef(new Map<string, CloudEntry>());
@@ -99,10 +107,18 @@ export function GraphCanvas({
99107
return churnView(nodes, edges, config);
100108
case "coverage":
101109
return coverageView(nodes, edges, config);
110+
case "symbols":
111+
return symbolData
112+
? symbolView(symbolData.symbolNodes, symbolData.callEdges, config)
113+
: { nodes: [], links: [] };
114+
case "types":
115+
return symbolData
116+
? typesView(symbolData.symbolNodes, symbolData.callEdges, config)
117+
: { nodes: [], links: [] };
102118
default:
103119
return galaxyView(nodes, edges, config);
104120
}
105-
}, [nodes, edges, config, currentView, focusNodeId, forceData, circularDeps, nodeById]);
121+
}, [nodes, edges, config, currentView, focusNodeId, forceData, circularDeps, nodeById, symbolData]);
106122

107123
// Build stable node/link objects for ForceGraph3D — store refs for tick handler access
108124
const fgGraphData = useMemo(() => {
@@ -347,20 +363,48 @@ export function GraphCanvas({
347363
return () => { window.removeEventListener("search-fly", handleSearchFly); };
348364
}, []);
349365

366+
const symbolById = useMemo(
367+
() => new Map(symbolData?.symbolNodes.map((s) => [s.id, s]) ?? []),
368+
[symbolData],
369+
);
370+
371+
const isSymbolView = currentView === "symbols" || currentView === "types";
372+
350373
const handleNodeClick = useCallback(
351374
(node: Record<string, unknown>) => {
352-
const apiNode = nodeById.get(node.id as string);
353-
if (apiNode) onNodeClick(apiNode);
375+
const id = node.id as string;
376+
if (isSymbolView) {
377+
const sym = symbolById.get(id);
378+
if (sym) onSymbolClick(sym);
379+
} else {
380+
const apiNode = nodeById.get(id);
381+
if (apiNode) onNodeClick(apiNode);
382+
}
354383
},
355-
[nodeById, onNodeClick],
384+
[nodeById, symbolById, onNodeClick, onSymbolClick, isSymbolView],
356385
);
357386

387+
const showEmptyPlaceholder =
388+
isSymbolView && symbolData?.symbolNodes.length === 0;
389+
390+
if (showEmptyPlaceholder) {
391+
return (
392+
<div className="w-screen h-screen flex items-center justify-center bg-[#0a0a0f]">
393+
<div className="text-[#888] text-lg">No symbols found</div>
394+
</div>
395+
);
396+
}
397+
358398
return (
359399
<div ref={containerRef} className="w-screen h-screen" data-cloud-count="0">
360400
<ForceGraph3D
361401
ref={fgRef}
362402
graphData={fgGraphData}
363-
nodeLabel={(n: Record<string, unknown>) => `${n.path as string} (${n.loc as number} LOC)`}
403+
nodeLabel={(n: Record<string, unknown>) =>
404+
isSymbolView
405+
? `${n.label as string} (${n.path as string})`
406+
: `${n.path as string} (${n.loc as number} LOC)`
407+
}
364408
nodeColor={(n: Record<string, unknown>) => n.color as string}
365409
nodeVal={(n: Record<string, unknown>) => n.size as number}
366410
nodeOpacity={config.nodeOpacity}

components/graph-provider.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
import { createContext, useContext, useState, useCallback, useEffect } from "react";
44
import { useGraphData } from "@/hooks/use-graph-data";
5+
import { useSymbolData } from "@/hooks/use-symbol-data";
56
import { useGraphConfig } from "@/hooks/use-graph-config";
6-
import type { GraphApiNode, GraphApiResponse, ForceApiResponse, GroupMetrics, GraphConfig, ViewType } from "@/lib/types";
7+
import type { GraphApiNode, GraphApiResponse, ForceApiResponse, GroupMetrics, GraphConfig, ViewType, SymbolGraphResponse, SymbolApiNode } from "@/lib/types";
78

89
interface StalenessInfo {
910
stale: boolean;
@@ -14,6 +15,7 @@ interface GraphContextValue {
1415
graphData: GraphApiResponse | undefined;
1516
forceData: ForceApiResponse | undefined;
1617
groupData: GroupMetrics[] | undefined;
18+
symbolData: SymbolGraphResponse | undefined;
1719
projectName: string;
1820
staleness: StalenessInfo | undefined;
1921
isLoading: boolean;
@@ -24,6 +26,8 @@ interface GraphContextValue {
2426
setCurrentView: (view: ViewType) => void;
2527
selectedNode: GraphApiNode | null;
2628
setSelectedNode: (node: GraphApiNode | null) => void;
29+
selectedSymbol: SymbolApiNode | null;
30+
setSelectedSymbol: (symbol: SymbolApiNode | null) => void;
2731
focusNodeId: string | null;
2832
setFocusNodeId: (id: string | null) => void;
2933
handleNodeClick: (node: GraphApiNode) => void;
@@ -45,7 +49,15 @@ export function GraphProvider({ children }: { children: React.ReactNode }) {
4549
const { config, setConfig } = useGraphConfig();
4650
const [currentView, setCurrentView] = useState<ViewType>("galaxy");
4751
const [selectedNode, setSelectedNode] = useState<GraphApiNode | null>(null);
52+
const [selectedSymbol, setSelectedSymbol] = useState<SymbolApiNode | null>(null);
4853
const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
54+
const isSymbolView = currentView === "symbols" || currentView === "types";
55+
const { symbolData } = useSymbolData(isSymbolView);
56+
57+
useEffect(() => {
58+
if (!isSymbolView) setSelectedSymbol(null);
59+
if (isSymbolView) setSelectedNode(null);
60+
}, [isSymbolView]);
4961

5062
useEffect(() => {
5163
if (projectName) {
@@ -85,6 +97,7 @@ export function GraphProvider({ children }: { children: React.ReactNode }) {
8597
graphData,
8698
forceData,
8799
groupData,
100+
symbolData,
88101
projectName,
89102
staleness,
90103
isLoading,
@@ -95,6 +108,8 @@ export function GraphProvider({ children }: { children: React.ReactNode }) {
95108
setCurrentView,
96109
selectedNode,
97110
setSelectedNode,
111+
selectedSymbol,
112+
setSelectedSymbol,
98113
focusNodeId,
99114
setFocusNodeId,
100115
handleNodeClick,

0 commit comments

Comments
 (0)