refactor: Simplify to entry-point + call graph tracing
Completely reworked the explore approach: - Claude (or keyword search) finds ONE entry point, not a list of symbols - getCallGraph(entry, depth=3) traces the actual call chain deterministically - No more AI-guessed symbol lists, bridge passes, or relevance filtering - Search result clicks also trace the full call graph from that point The graph data was always accurate — the problem was AI trying to guess the whole flow. Now AI just finds the starting point, graph does the rest. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
3d2b38918e
commit
dcd4fa397e
@@ -948,7 +948,7 @@
|
|||||||
embeddingsStatus: () => api.get('embeddings/status'),
|
embeddingsStatus: () => api.get('embeddings/status'),
|
||||||
status: () => api.get('status'),
|
status: () => api.get('status'),
|
||||||
search: (q, kind, limit) => api.get(`search?q=${encodeURIComponent(q)}${kind ? '&kind='+kind : ''}&limit=${limit||30}`),
|
search: (q, kind, limit) => api.get(`search?q=${encodeURIComponent(q)}${kind ? '&kind='+kind : ''}&limit=${limit||30}`),
|
||||||
explore: (q, maxNodes) => api.get(`explore?q=${encodeURIComponent(q)}&maxNodes=${maxNodes||30}`),
|
explore: (q) => api.get(`explore?q=${encodeURIComponent(q)}`),
|
||||||
overview: (limit) => api.get(`overview?limit=${limit||60}`),
|
overview: (limit) => api.get(`overview?limit=${limit||60}`),
|
||||||
files: () => api.get('files'),
|
files: () => api.get('files'),
|
||||||
fileNodes: (p) => api.get(`file-nodes?path=${encodeURIComponent(p)}`),
|
fileNodes: (p) => api.get(`file-nodes?path=${encodeURIComponent(p)}`),
|
||||||
@@ -1403,32 +1403,24 @@
|
|||||||
hideOverlay();
|
hideOverlay();
|
||||||
document.getElementById('search-input').value = question;
|
document.getElementById('search-input').value = question;
|
||||||
|
|
||||||
showToast('Asking Claude...');
|
showToast('Finding entry point...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.explore(question, 30);
|
const data = await api.explore(question);
|
||||||
if (data.nodes.length === 0) {
|
if (data.nodes.length === 0) {
|
||||||
showToast('No relevant code found. Try different keywords.');
|
showToast('No relevant code found. Try searching for a specific symbol.');
|
||||||
hideOverlay(false);
|
hideOverlay(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addSubgraph(data.nodes, data.edges);
|
addSubgraph(data.nodes, data.edges);
|
||||||
|
|
||||||
// Highlight root nodes, with entry point getting special treatment
|
|
||||||
if (data.roots && data.roots.length > 0) {
|
|
||||||
for (const rootId of data.roots) {
|
|
||||||
const ele = cy.getElementById(rootId);
|
|
||||||
if (ele.length > 0) ele.addClass('highlighted');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runLayout();
|
runLayout();
|
||||||
|
|
||||||
// Center on entry point if available
|
// Center on entry point
|
||||||
if (data.entryPoint) {
|
if (data.entryPoint) {
|
||||||
const entryEle = cy.getElementById(data.entryPoint);
|
const entryEle = cy.getElementById(data.entryPoint);
|
||||||
if (entryEle.length > 0) {
|
if (entryEle.length > 0) {
|
||||||
entryEle.select();
|
entryEle.select();
|
||||||
|
entryEle.addClass('highlighted');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
cy.animate({ center: { eles: entryEle } }, { duration: 400 });
|
cy.animate({ center: { eles: entryEle } }, { duration: 400 });
|
||||||
showNodeDetails(data.entryPoint);
|
showNodeDetails(data.entryPoint);
|
||||||
@@ -1437,7 +1429,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const source = data.usedClaude ? ' (via Claude)' : '';
|
const source = data.usedClaude ? ' (via Claude)' : '';
|
||||||
showToast(`Found ${data.nodes.length} related symbols${source}`);
|
showToast(`Traced ${data.nodes.length} symbols from entry point${source}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast('Error: ' + err.message);
|
showToast('Error: ' + err.message);
|
||||||
}
|
}
|
||||||
@@ -1499,37 +1491,36 @@
|
|||||||
hideSearchDropdown();
|
hideSearchDropdown();
|
||||||
document.getElementById('search-input').value = '';
|
document.getElementById('search-input').value = '';
|
||||||
hideOverlay();
|
hideOverlay();
|
||||||
|
clearGraph();
|
||||||
|
hideOverlay();
|
||||||
|
|
||||||
|
showToast('Tracing call chain...');
|
||||||
|
|
||||||
// Add to graph if not present
|
|
||||||
try {
|
try {
|
||||||
const data = await api.node(nodeId);
|
// Load the call graph from this entry point (depth 3 forward)
|
||||||
if (data.node) {
|
const data = await api.callgraph(nodeId, 3);
|
||||||
addNodeToGraph(data.node);
|
if (data.nodes.length === 0) {
|
||||||
// Also load its immediate relations
|
// Fallback: just show the node
|
||||||
const [callersData, calleesData] = await Promise.all([
|
const nodeData = await api.node(nodeId);
|
||||||
api.callers(nodeId, 1),
|
if (nodeData.node) addNodeToGraph(nodeData.node);
|
||||||
api.callees(nodeId, 1),
|
} else {
|
||||||
]);
|
addSubgraph(data.nodes, data.edges);
|
||||||
for (const item of callersData.items) {
|
|
||||||
addNodeToGraph(item.node);
|
|
||||||
addEdgeToGraph(item.edge);
|
|
||||||
}
|
|
||||||
for (const item of calleesData.items) {
|
|
||||||
addNodeToGraph(item.node);
|
|
||||||
addEdgeToGraph(item.edge);
|
|
||||||
}
|
|
||||||
expandedSets.callers.add(nodeId);
|
|
||||||
expandedSets.callees.add(nodeId);
|
|
||||||
runLayout();
|
|
||||||
// Select and focus
|
|
||||||
const ele = cy.getElementById(nodeId);
|
|
||||||
if (ele.length > 0) {
|
|
||||||
cy.nodes().unselect();
|
|
||||||
ele.select();
|
|
||||||
cy.animate({ center: { eles: ele }, zoom: 1.5 }, { duration: 300 });
|
|
||||||
}
|
|
||||||
showNodeDetails(nodeId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runLayout();
|
||||||
|
|
||||||
|
// Select and center on the entry point
|
||||||
|
const ele = cy.getElementById(nodeId);
|
||||||
|
if (ele.length > 0) {
|
||||||
|
ele.select();
|
||||||
|
ele.addClass('highlighted');
|
||||||
|
setTimeout(() => {
|
||||||
|
cy.animate({ center: { eles: ele } }, { duration: 300 });
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
|
||||||
|
showNodeDetails(nodeId);
|
||||||
|
showToast(`Traced ${data.nodes.length} symbols from entry point`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast('Error: ' + err.message);
|
showToast('Error: ' + err.message);
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-144
@@ -80,17 +80,15 @@ export class VisualizerServer {
|
|||||||
|
|
||||||
const symbolIndex = this.buildSymbolIndex();
|
const symbolIndex = this.buildSymbolIndex();
|
||||||
|
|
||||||
const prompt = `You are tracing a code flow through a codebase. Given the question and symbol index below, identify the EXACT execution path.
|
const prompt = `Given the question and codebase symbol index below, identify the single best ENTRY POINT symbol — the one function, component, or route handler where this flow starts.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Return ONLY 5-8 symbols that are DIRECTLY in the execution path
|
- Pick ONE symbol that is the starting point a user or request would hit first
|
||||||
- Start from the user-facing entry point (page, button handler, route)
|
- Prefer page components, route handlers, or top-level functions
|
||||||
- Follow the call chain: what calls what, in order
|
- Do NOT pick utility functions, helpers, or middleware
|
||||||
- Do NOT include tangentially related symbols, utilities, or unrelated features
|
|
||||||
- Every symbol should call or be called by the next one in the flow
|
|
||||||
|
|
||||||
Return ONLY this JSON format, nothing else:
|
Return ONLY this JSON, nothing else:
|
||||||
{"entry": "entrySymbol", "flow": ["step1", "step2", "step3", "step4", "step5"]}
|
{"entry": "symbolName"}
|
||||||
|
|
||||||
Question: "${question}"
|
Question: "${question}"
|
||||||
|
|
||||||
@@ -311,170 +309,70 @@ ${symbolIndex}`;
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/explore?q=...&maxNodes=...
|
// GET /api/explore?q=...
|
||||||
// Natural language question → semantic or keyword-based subgraph
|
// Find the best entry point, then return its call graph
|
||||||
if (pathname === '/api/explore') {
|
if (pathname === '/api/explore') {
|
||||||
const q = query.q || '';
|
const q = query.q || '';
|
||||||
const maxNodes = parseInt(query.maxNodes || '30', 10);
|
|
||||||
if (!q) {
|
if (!q) {
|
||||||
json({ nodes: [], edges: [], roots: [] });
|
json({ nodes: [], edges: [], roots: [], entryPoint: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract keywords and stems for relevance scoring (used by all paths)
|
|
||||||
const stopWords = new Set(['how', 'does', 'what', 'the', 'is', 'a', 'an', 'and', 'or', 'in', 'to', 'for', 'of', 'with', 'when', 'do', 'it', 'my', 'work', 'works', 'about']);
|
|
||||||
const keywords = q.toLowerCase()
|
|
||||||
.split(/\s+/)
|
|
||||||
.map(w => w.replace(/[^a-z0-9]/g, ''))
|
|
||||||
.filter(w => w.length >= 2 && !stopWords.has(w));
|
|
||||||
|
|
||||||
const stems = keywords.map(kw => kw.length > 5 ? kw.slice(0, Math.max(4, Math.ceil(kw.length * 0.5))) : kw);
|
|
||||||
const uniqueStems = [...new Set(stems)];
|
|
||||||
|
|
||||||
const _isRelevant = (node: Node): boolean => {
|
|
||||||
const haystack = `${node.name} ${node.filePath} ${node.qualifiedName}`.toLowerCase();
|
|
||||||
return uniqueStems.some(stem => haystack.includes(stem));
|
|
||||||
};
|
|
||||||
void _isRelevant; // Used by keyword fallback when Claude is unavailable
|
|
||||||
|
|
||||||
// Step 1: Find seed nodes
|
|
||||||
const seedMap = new Map<string, Node>();
|
|
||||||
const validKinds: NodeKind[] = ['function', 'method', 'class', 'interface', 'component', 'route'];
|
|
||||||
let usedClaude = false;
|
|
||||||
|
|
||||||
// Try Claude CLI first for intelligent query interpretation
|
|
||||||
let entryNodeId: string | null = null;
|
let entryNodeId: string | null = null;
|
||||||
|
let usedClaude = false;
|
||||||
|
const validKinds: NodeKind[] = ['function', 'method', 'class', 'interface', 'component', 'route'];
|
||||||
|
|
||||||
|
// Try Claude CLI to find the best entry point
|
||||||
const claudeNames = await this.askClaude(q);
|
const claudeNames = await this.askClaude(q);
|
||||||
if (claudeNames && claudeNames.length > 0) {
|
if (claudeNames && claudeNames.length > 0) {
|
||||||
usedClaude = true;
|
usedClaude = true;
|
||||||
|
// Find the entry point in the graph
|
||||||
for (const name of claudeNames) {
|
for (const name of claudeNames) {
|
||||||
|
if (entryNodeId) break;
|
||||||
const results = this.cg.searchNodes(name, { kinds: validKinds, limit: 3 });
|
const results = this.cg.searchNodes(name, { kinds: validKinds, limit: 3 });
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
// Only add if the name is a close match
|
if (r.node.name.toLowerCase() === name.toLowerCase() ||
|
||||||
if (r.node.name.toLowerCase().includes(name.toLowerCase()) ||
|
r.node.name.toLowerCase().includes(name.toLowerCase()) ||
|
||||||
name.toLowerCase().includes(r.node.name.toLowerCase())) {
|
name.toLowerCase().includes(r.node.name.toLowerCase())) {
|
||||||
seedMap.set(r.node.id, r.node);
|
entryNodeId = r.node.id;
|
||||||
// First match of first name = entry point
|
break;
|
||||||
if (!entryNodeId && name === claudeNames[0]) {
|
|
||||||
entryNodeId = r.node.id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyword fallback if Claude unavailable or returned nothing useful
|
// Keyword fallback: find best match from query keywords
|
||||||
if (seedMap.size < 3) {
|
if (!entryNodeId) {
|
||||||
|
const stopWords = new Set(['how', 'does', 'what', 'the', 'is', 'a', 'an', 'and', 'or', 'in', 'to', 'for', 'of', 'with', 'when', 'do', 'it', 'my', 'work', 'works', 'about', 'show', 'me']);
|
||||||
|
const keywords = q.toLowerCase().split(/\s+/)
|
||||||
|
.map(w => w.replace(/[^a-z0-9]/g, ''))
|
||||||
|
.filter(w => w.length >= 2 && !stopWords.has(w));
|
||||||
|
|
||||||
for (const kw of keywords) {
|
for (const kw of keywords) {
|
||||||
const kwResults = this.cg.searchNodes(kw, { kinds: validKinds, limit: 10 });
|
if (entryNodeId) break;
|
||||||
for (const r of kwResults) {
|
const results = this.cg.searchNodes(kw, { kinds: validKinds, limit: 5 });
|
||||||
seedMap.set(r.node.id, r.node);
|
if (results.length > 0) {
|
||||||
|
entryNodeId = results[0]!.node.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const fullResults = this.cg.searchNodes(q, { kinds: validKinds, limit: 10 });
|
|
||||||
for (const r of fullResults) {
|
|
||||||
seedMap.set(r.node.id, r.node);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seedMap.size === 0) {
|
if (!entryNodeId) {
|
||||||
const broad = this.cg.searchNodes(q, { limit: 10 });
|
json({ nodes: [], edges: [], roots: [], entryPoint: null });
|
||||||
for (const r of broad) seedMap.set(r.node.id, r.node);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seedMap.size === 0) {
|
|
||||||
json({ nodes: [], edges: [], roots: [] });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootIds = Array.from(seedMap.keys());
|
// Get the call graph from this entry point (depth 3)
|
||||||
const nodeMap = new Map<string, Node>(seedMap);
|
const callGraph = this.cg.getCallGraph(entryNodeId, 3);
|
||||||
const edgeList: Edge[] = [];
|
const result = serializeSubgraph(callGraph);
|
||||||
const edgeSet = new Set<string>();
|
|
||||||
|
|
||||||
const addEdge = (edge: Edge) => {
|
json({
|
||||||
const ek = `${edge.source}-${edge.kind}-${edge.target}`;
|
nodes: result.nodes,
|
||||||
if (!edgeSet.has(ek)) { edgeSet.add(ek); edgeList.push(edge); }
|
edges: result.edges,
|
||||||
};
|
roots: [entryNodeId],
|
||||||
|
entryPoint: entryNodeId,
|
||||||
// Step 2: Find edges between seeds (trust Claude's picks)
|
usedClaude,
|
||||||
// Only add non-seed nodes if they bridge two seeds
|
});
|
||||||
for (const [seedId] of seedMap) {
|
|
||||||
// Check if this seed directly connects to another seed
|
|
||||||
const callees = this.cg.getCallees(seedId, 1);
|
|
||||||
const callers = this.cg.getCallers(seedId, 1);
|
|
||||||
for (const item of [...callees, ...callers]) {
|
|
||||||
if (seedMap.has(item.node.id)) {
|
|
||||||
addEdge(item.edge);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Bridge pass — for isolated seeds, find shared callees
|
|
||||||
// that connect them to other seeds or to each other
|
|
||||||
const connectedAfterDirect = new Set<string>();
|
|
||||||
for (const e of edgeList) {
|
|
||||||
connectedAfterDirect.add(e.source);
|
|
||||||
connectedAfterDirect.add(e.target);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isolatedSeeds = Array.from(seedMap.keys()).filter(id => !connectedAfterDirect.has(id));
|
|
||||||
|
|
||||||
// Collect all callees/callers of isolated seeds to find bridges
|
|
||||||
const bridgeCandidates = new Map<string, { node: Node; connectedSeeds: Set<string>; edges: Edge[] }>();
|
|
||||||
for (const seedId of isolatedSeeds) {
|
|
||||||
const callees = this.cg.getCallees(seedId, 1);
|
|
||||||
const callers = this.cg.getCallers(seedId, 1);
|
|
||||||
for (const item of [...callees, ...callers]) {
|
|
||||||
const candidate = bridgeCandidates.get(item.node.id);
|
|
||||||
if (candidate) {
|
|
||||||
candidate.connectedSeeds.add(seedId);
|
|
||||||
candidate.edges.push(item.edge);
|
|
||||||
} else {
|
|
||||||
bridgeCandidates.set(item.node.id, {
|
|
||||||
node: item.node,
|
|
||||||
connectedSeeds: new Set([seedId]),
|
|
||||||
edges: [item.edge],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add bridges that connect 2+ seeds, or connect an isolated seed to a connected one
|
|
||||||
for (const [bridgeId, { node: bridgeNode, connectedSeeds, edges }] of bridgeCandidates) {
|
|
||||||
const connectsToGraph = connectedAfterDirect.has(bridgeId) || seedMap.has(bridgeId);
|
|
||||||
const connectsMultiple = connectedSeeds.size >= 2;
|
|
||||||
|
|
||||||
if ((connectsMultiple || connectsToGraph) && nodeMap.size < maxNodes) {
|
|
||||||
nodeMap.set(bridgeId, bridgeNode);
|
|
||||||
for (const edge of edges) addEdge(edge);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Cross-connection pass — find edges between all result nodes
|
|
||||||
for (const [nodeId] of nodeMap) {
|
|
||||||
const callers = this.cg.getCallers(nodeId, 1);
|
|
||||||
const callees = this.cg.getCallees(nodeId, 1);
|
|
||||||
for (const item of [...callers, ...callees]) {
|
|
||||||
if (nodeMap.has(item.node.id)) {
|
|
||||||
addEdge(item.edge);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Filter and clean up
|
|
||||||
const finalEdges = edgeList.filter(e => nodeMap.has(e.source) && nodeMap.has(e.target));
|
|
||||||
|
|
||||||
const connectedIds = new Set<string>();
|
|
||||||
for (const e of finalEdges) {
|
|
||||||
connectedIds.add(e.source);
|
|
||||||
connectedIds.add(e.target);
|
|
||||||
}
|
|
||||||
for (const id of rootIds) connectedIds.add(id);
|
|
||||||
|
|
||||||
const finalNodes = Array.from(nodeMap.values()).filter(n => connectedIds.has(n.id));
|
|
||||||
|
|
||||||
json({ nodes: finalNodes, edges: finalEdges, roots: rootIds, entryPoint: entryNodeId, usedClaude });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user