diff --git a/src/visualizer/public/index.html b/src/visualizer/public/index.html index f961eef..05d9532 100644 --- a/src/visualizer/public/index.html +++ b/src/visualizer/public/index.html @@ -948,7 +948,7 @@ embeddingsStatus: () => api.get('embeddings/status'), status: () => api.get('status'), 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}`), files: () => api.get('files'), fileNodes: (p) => api.get(`file-nodes?path=${encodeURIComponent(p)}`), @@ -1403,32 +1403,24 @@ hideOverlay(); document.getElementById('search-input').value = question; - showToast('Asking Claude...'); + showToast('Finding entry point...'); try { - const data = await api.explore(question, 30); + const data = await api.explore(question); 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); return; } 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(); - // Center on entry point if available + // Center on entry point if (data.entryPoint) { const entryEle = cy.getElementById(data.entryPoint); if (entryEle.length > 0) { entryEle.select(); + entryEle.addClass('highlighted'); setTimeout(() => { cy.animate({ center: { eles: entryEle } }, { duration: 400 }); showNodeDetails(data.entryPoint); @@ -1437,7 +1429,7 @@ } 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) { showToast('Error: ' + err.message); } @@ -1499,37 +1491,36 @@ hideSearchDropdown(); document.getElementById('search-input').value = ''; hideOverlay(); + clearGraph(); + hideOverlay(); + + showToast('Tracing call chain...'); - // Add to graph if not present try { - const data = await api.node(nodeId); - if (data.node) { - addNodeToGraph(data.node); - // Also load its immediate relations - const [callersData, calleesData] = await Promise.all([ - api.callers(nodeId, 1), - api.callees(nodeId, 1), - ]); - 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); + // Load the call graph from this entry point (depth 3 forward) + const data = await api.callgraph(nodeId, 3); + if (data.nodes.length === 0) { + // Fallback: just show the node + const nodeData = await api.node(nodeId); + if (nodeData.node) addNodeToGraph(nodeData.node); + } else { + addSubgraph(data.nodes, data.edges); } + + 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) { showToast('Error: ' + err.message); } diff --git a/src/visualizer/server.ts b/src/visualizer/server.ts index 1121403..f5c51b7 100644 --- a/src/visualizer/server.ts +++ b/src/visualizer/server.ts @@ -80,17 +80,15 @@ export class VisualizerServer { 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: -- Return ONLY 5-8 symbols that are DIRECTLY in the execution path -- Start from the user-facing entry point (page, button handler, route) -- Follow the call chain: what calls what, in order -- Do NOT include tangentially related symbols, utilities, or unrelated features -- Every symbol should call or be called by the next one in the flow +- Pick ONE symbol that is the starting point a user or request would hit first +- Prefer page components, route handlers, or top-level functions +- Do NOT pick utility functions, helpers, or middleware -Return ONLY this JSON format, nothing else: -{"entry": "entrySymbol", "flow": ["step1", "step2", "step3", "step4", "step5"]} +Return ONLY this JSON, nothing else: +{"entry": "symbolName"} Question: "${question}" @@ -311,170 +309,70 @@ ${symbolIndex}`; return; } - // GET /api/explore?q=...&maxNodes=... - // Natural language question → semantic or keyword-based subgraph + // GET /api/explore?q=... + // Find the best entry point, then return its call graph if (pathname === '/api/explore') { const q = query.q || ''; - const maxNodes = parseInt(query.maxNodes || '30', 10); if (!q) { - json({ nodes: [], edges: [], roots: [] }); + json({ nodes: [], edges: [], roots: [], entryPoint: null }); 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(); - 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 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); if (claudeNames && claudeNames.length > 0) { usedClaude = true; + // Find the entry point in the graph for (const name of claudeNames) { + if (entryNodeId) break; const results = this.cg.searchNodes(name, { kinds: validKinds, limit: 3 }); for (const r of results) { - // Only add if the name is a close match - if (r.node.name.toLowerCase().includes(name.toLowerCase()) || + if (r.node.name.toLowerCase() === name.toLowerCase() || + r.node.name.toLowerCase().includes(name.toLowerCase()) || name.toLowerCase().includes(r.node.name.toLowerCase())) { - seedMap.set(r.node.id, r.node); - // First match of first name = entry point - if (!entryNodeId && name === claudeNames[0]) { - entryNodeId = r.node.id; - } + entryNodeId = r.node.id; + break; } } } } - // Keyword fallback if Claude unavailable or returned nothing useful - if (seedMap.size < 3) { + // Keyword fallback: find best match from query keywords + 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) { - const kwResults = this.cg.searchNodes(kw, { kinds: validKinds, limit: 10 }); - for (const r of kwResults) { - seedMap.set(r.node.id, r.node); + if (entryNodeId) break; + const results = this.cg.searchNodes(kw, { kinds: validKinds, limit: 5 }); + 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) { - const broad = this.cg.searchNodes(q, { limit: 10 }); - for (const r of broad) seedMap.set(r.node.id, r.node); - } - - if (seedMap.size === 0) { - json({ nodes: [], edges: [], roots: [] }); + if (!entryNodeId) { + json({ nodes: [], edges: [], roots: [], entryPoint: null }); return; } - const rootIds = Array.from(seedMap.keys()); - const nodeMap = new Map(seedMap); - const edgeList: Edge[] = []; - const edgeSet = new Set(); + // Get the call graph from this entry point (depth 3) + const callGraph = this.cg.getCallGraph(entryNodeId, 3); + const result = serializeSubgraph(callGraph); - const addEdge = (edge: Edge) => { - const ek = `${edge.source}-${edge.kind}-${edge.target}`; - if (!edgeSet.has(ek)) { edgeSet.add(ek); edgeList.push(edge); } - }; - - // Step 2: Find edges between seeds (trust Claude's picks) - // 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(); - 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; 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(); - 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 }); + json({ + nodes: result.nodes, + edges: result.edges, + roots: [entryNodeId], + entryPoint: entryNodeId, + usedClaude, + }); return; }