From 43ea0a40bacb71dbb07445e17c287e4740b97d53 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 22 Mar 2026 16:43:57 -0500 Subject: [PATCH 1/8] feat: Add interactive graph visualization with Claude-powered exploration Adds `codegraph visualize` command that launches a localhost web UI for visually exploring code relationships. Users can ask natural language questions like "how does authentication work?" and see the relevant code flow rendered as an interactive graph. Key components: - Visualizer HTTP server (src/visualizer/server.ts) with REST API - Single-page frontend with Cytoscape.js graph + highlight.js code preview - Claude CLI integration for intelligent query interpretation - Dark theme, right-click context menus, keyboard shortcuts - Detail panel with source code, callers, callees, hierarchy Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- src/bin/codegraph.ts | 64 + src/visualizer/public/index.html | 1960 ++++++++++++++++++++++++++++++ src/visualizer/server.ts | 582 +++++++++ 4 files changed, 2607 insertions(+), 1 deletion(-) create mode 100644 src/visualizer/public/index.html create mode 100644 src/visualizer/server.ts diff --git a/package.json b/package.json index 4d6a9b5..076f6e3 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "build": "tsc && npm run copy-assets", "postinstall": "node scripts/postinstall.js", "preuninstall": "node dist/bin/uninstall.js", - "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"", + "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f));fs.mkdirSync('dist/visualizer/public',{recursive:true});fs.copyFileSync('src/visualizer/public/index.html','dist/visualizer/public/index.html')\"", "dev": "tsc --watch", "cli": "npm run build && node dist/bin/codegraph.js", "test": "vitest run", diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 8418d37..9b45971 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -955,6 +955,70 @@ program } }); +/** + * codegraph visualize [path] + */ +program + .command('visualize [path]') + .description('Open interactive graph visualization in your browser') + .option('-p, --port ', 'Port to listen on (default: auto)', parseInt) + .option('--no-open', 'Do not open browser automatically') + .action(async (pathArg: string | undefined, options: { port?: number; open?: boolean }) => { + const projectPath = resolveProjectPath(pathArg); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph not initialized in ${projectPath}`); + info('Run "codegraph init -i" first'); + process.exit(1); + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + const stats = cg.getStats(); + + console.log(chalk.bold('\n CodeGraph Explorer\n')); + info(`Project: ${projectPath}`); + info(`Indexed: ${formatNumber(stats.nodeCount)} nodes, ${formatNumber(stats.edgeCount)} edges, ${formatNumber(stats.fileCount)} files\n`); + + const { VisualizerServer } = await import('../visualizer/server'); + const server = new VisualizerServer(cg); + const { url } = await server.start({ port: options.port, openBrowser: options.open !== false }); + + success(`Visualizer running at ${chalk.cyan(url)}`); + console.log(chalk.dim(' Press Ctrl+C to stop\n')); + + // Open browser + if (options.open !== false) { + const openCmd = process.platform === 'darwin' ? 'open' : + process.platform === 'win32' ? 'start' : 'xdg-open'; + spawn(openCmd, [url], { detached: true, stdio: 'ignore' }).unref(); + } + + // Handle shutdown — force exit on second Ctrl+C + let shuttingDown = false; + const shutdown = () => { + if (shuttingDown) { + process.exit(1); + } + shuttingDown = true; + console.log(chalk.dim('\n Shutting down...')); + server.stop().then(() => { + cg.close(); + process.exit(0); + }).catch(() => process.exit(1)); + // Force exit after 2s if graceful shutdown hangs + setTimeout(() => process.exit(1), 2000).unref(); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + } catch (err) { + captureException(err); + error(`Failed to start visualizer: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + /** * codegraph mark-dirty [path] * diff --git a/src/visualizer/public/index.html b/src/visualizer/public/index.html new file mode 100644 index 0000000..e07f820 --- /dev/null +++ b/src/visualizer/public/index.html @@ -0,0 +1,1960 @@ + + + + + + CodeGraph Explorer + + + + + + + + + + + + + +
+ + + + +
+ + + + +
+
+
+
🔮
+
Ask about your code
+
Try: "How does authentication work?" or "What happens when a user signs in?"
+
+ + + + +
+
+ +
+ + + +
+
+ + +
+
+
+
-
+
+ +
+
+
+
+
+ + +
+
+
🧠
+
Enable Semantic Search
+
+ CodeGraph Explorer uses semantic embeddings to understand your code by meaning, not just keywords. + This lets you ask questions like "how does authentication work?" and get accurate results. +

+ This is a one-time setup that generates a local embedding model for this project. No data leaves your machine. +
+
+
+
+
+
+ Preparing... + 0% +
+
+
+ + +
+
+
+ + +
+
Expand Callees
+
Expand Callers
+
+
🌐 Full Call Graph
+
💥 Impact Analysis
+
+
📂 Show Children
+
📋 View Details
+
+
Remove from Graph
+
+ + +
+ + +
+ + + + diff --git a/src/visualizer/server.ts b/src/visualizer/server.ts new file mode 100644 index 0000000..033a45c --- /dev/null +++ b/src/visualizer/server.ts @@ -0,0 +1,582 @@ +/** + * CodeGraph Visualizer Server + * + * Lightweight HTTP server that serves the graph visualization UI + * and exposes REST API endpoints for querying the CodeGraph database. + */ + +import * as http from 'http'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as url from 'url'; +import { execFile } from 'child_process'; +import type CodeGraph from '../index'; +import type { Node, Edge, NodeKind } from '../types'; + +export interface VisualizerOptions { + /** Port to listen on (0 = auto-assign) */ + port?: number; + /** Whether to open browser automatically */ + openBrowser?: boolean; + /** Host to bind to */ + host?: string; +} + +/** + * Serialize a Subgraph (which uses Map) to plain JSON + */ +function serializeSubgraph(subgraph: { nodes: Map; edges: Edge[]; roots: string[] }) { + return { + nodes: Array.from(subgraph.nodes.values()), + edges: subgraph.edges, + roots: subgraph.roots, + }; +} + +export class VisualizerServer { + private cg: CodeGraph; + private server: http.Server | null = null; + private projectRoot: string; + private symbolIndexCache: string | null = null; + private claudeAvailable: boolean | null = null; + + constructor(cg: CodeGraph) { + this.cg = cg; + this.projectRoot = cg.getProjectRoot(); + } + + /** + * Build a compact symbol index string for Claude prompts + */ + private buildSymbolIndex(): string { + if (this.symbolIndexCache) return this.symbolIndexCache; + + const validKinds: NodeKind[] = ['function', 'method', 'class', 'interface', 'component', 'route', 'enum', 'type_alias']; + const byFile = new Map(); + + for (const kind of validKinds) { + for (const node of this.cg.getNodesByKind(kind)) { + const symbols = byFile.get(node.filePath) || []; + symbols.push(`${node.kind}:${node.name}`); + byFile.set(node.filePath, symbols); + } + } + + const lines: string[] = []; + for (const [file, symbols] of byFile) { + lines.push(`${file}: ${symbols.join(', ')}`); + } + + this.symbolIndexCache = lines.join('\n'); + return this.symbolIndexCache; + } + + /** + * Ask Claude CLI to interpret a natural language question into relevant symbol names + */ + private async askClaude(question: string): Promise { + // Check if claude is available (cache result) + if (this.claudeAvailable === false) return null; + + const symbolIndex = this.buildSymbolIndex(); + + const prompt = `You are analyzing a codebase to help a developer understand it visually. Given the question and symbol index below, identify the 8-12 most relevant symbols that would help answer the question. + +IMPORTANT: Return ONLY a JSON array of symbol names. No explanation, no markdown, no code fences. Just the array. +Example: ["requireAuth", "LoginPage", "getSession", "UserService"] + +Question: "${question}" + +Symbol index (format: file: kind:name, ...): +${symbolIndex}`; + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(null); + }, 30000); + + execFile('claude', ['-p', prompt, '--output-format', 'text'], { + timeout: 30000, + maxBuffer: 1024 * 1024, + }, (err, stdout) => { + clearTimeout(timeout); + + if (err) { + this.claudeAvailable = false; + resolve(null); + return; + } + + this.claudeAvailable = true; + + // Parse the JSON array from Claude's response + try { + const text = stdout.trim(); + // Try to extract JSON array from response (Claude might wrap it) + const jsonMatch = text.match(/\[[\s\S]*\]/); + if (jsonMatch) { + const names = JSON.parse(jsonMatch[0]) as string[]; + if (Array.isArray(names) && names.length > 0) { + resolve(names.map(String)); + return; + } + } + } catch { + // Parse failed + } + + resolve(null); + }); + }); + } + + /** + * Start the visualizer server + */ + async start(options: VisualizerOptions = {}): Promise<{ port: number; url: string }> { + const host = options.host || '127.0.0.1'; + const port = options.port || 0; + + this.server = http.createServer((req, res) => { + this.handleRequest(req, res).catch((err) => { + console.error('[Visualizer] Request error:', err); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal server error' })); + }); + }); + + return new Promise((resolve, reject) => { + this.server!.listen(port, host, () => { + const addr = this.server!.address(); + if (!addr || typeof addr === 'string') { + reject(new Error('Failed to get server address')); + return; + } + const serverUrl = `http://${host}:${addr.port}`; + resolve({ port: addr.port, url: serverUrl }); + }); + + this.server!.on('error', reject); + }); + } + + /** + * Stop the server + */ + stop(): Promise { + return new Promise((resolve) => { + if (this.server) { + this.server.close(() => resolve()); + } else { + resolve(); + } + }); + } + + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const parsedUrl = url.parse(req.url || '/', true); + const pathname = parsedUrl.pathname || '/'; + + // CORS headers for local development + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + // API routes + if (pathname.startsWith('/api/')) { + return this.handleAPI(pathname, parsedUrl.query as Record, res); + } + + // Static file serving + return this.serveStatic(pathname, res); + } + + private async handleAPI( + pathname: string, + query: Record, + res: http.ServerResponse + ): Promise { + const json = (data: unknown, status = 200) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); + }; + + try { + // GET /api/status + if (pathname === '/api/status') { + const stats = this.cg.getStats(); + json({ stats, projectRoot: this.projectRoot, projectName: path.basename(this.projectRoot) }); + return; + } + + // GET /api/embeddings/status + if (pathname === '/api/embeddings/status') { + const config = this.cg.getConfig(); + const embeddingStats = this.cg.getEmbeddingStats(); + const isEnabled = config.enableEmbeddings === true; + const isInitialized = this.cg.isEmbeddingsInitialized(); + const totalVectors = embeddingStats?.totalVectors ?? 0; + const stats = this.cg.getStats(); + // Consider ready if we have vectors for at least half the eligible nodes + const eligibleNodes = stats.nodeCount - (stats.nodesByKind.file ?? 0) - (stats.nodesByKind.import ?? 0); + const isReady = isEnabled && totalVectors > 0 && totalVectors >= eligibleNodes * 0.5; + json({ isEnabled, isInitialized, isReady, totalVectors, eligibleNodes }); + return; + } + + // GET /api/embeddings/generate — SSE stream that enables, initializes, and generates embeddings + if (pathname === '/api/embeddings/generate') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + + const send = (event: string, data: unknown) => { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + }; + + try { + // Step 1: Enable embeddings in config + send('status', { phase: 'config', message: 'Enabling embeddings...' }); + const config = this.cg.getConfig(); + if (!config.enableEmbeddings) { + this.cg.updateConfig({ enableEmbeddings: true }); + } + + // Step 2: Initialize embedding model (downloads on first use) + send('status', { phase: 'model', message: 'Loading embedding model (first time may download ~30MB)...' }); + await this.cg.initializeEmbeddings(); + send('status', { phase: 'model', message: 'Embedding model ready' }); + + // Step 3: Generate embeddings with progress + send('status', { phase: 'embedding', message: 'Generating embeddings...' }); + const count = await this.cg.generateEmbeddings((progress) => { + send('progress', { + current: progress.current, + total: progress.total, + nodeName: progress.nodeName, + percent: progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0, + }); + }); + + send('complete', { totalEmbedded: count, message: `Generated ${count} embeddings` }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + send('error', { message }); + } + + res.end(); + return; + } + + // GET /api/search?q=...&kind=...&limit=... + if (pathname === '/api/search') { + const q = query.q || ''; + const kind = query.kind as NodeKind | undefined; + const limit = parseInt(query.limit || '30', 10); + if (!q) { + json({ results: [] }); + return; + } + const results = this.cg.searchNodes(q, { kinds: kind ? [kind] : undefined, limit }); + json({ results }); + return; + } + + // GET /api/explore?q=...&maxNodes=... + // Natural language question → semantic or keyword-based subgraph + if (pathname === '/api/explore') { + const q = query.q || ''; + const maxNodes = parseInt(query.maxNodes || '30', 10); + if (!q) { + json({ nodes: [], edges: [], roots: [] }); + 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)); + }; + + // 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 + const claudeNames = await this.askClaude(q); + if (claudeNames && claudeNames.length > 0) { + usedClaude = true; + for (const name of claudeNames) { + 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()) || + name.toLowerCase().includes(r.node.name.toLowerCase())) { + seedMap.set(r.node.id, r.node); + } + } + } + } + + // Keyword fallback if Claude unavailable or returned nothing useful + if (seedMap.size < 3) { + 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); + } + } + 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: [] }); + return; + } + + const rootIds = Array.from(seedMap.keys()); + const nodeMap = new Map(seedMap); + const edgeList: Edge[] = []; + const edgeSet = new Set(); + + const addEdge = (edge: Edge) => { + const ek = `${edge.source}-${edge.kind}-${edge.target}`; + if (!edgeSet.has(ek)) { edgeSet.add(ek); edgeList.push(edge); } + }; + + // Step 2: For each seed, get callers/callees (depth 1) + // Only keep neighbors that are relevant or connect to other seeds + // Fall back to top-3 non-relevant only if seed has NO relevant neighbors + for (const [seedId] of seedMap) { + if (nodeMap.size >= maxNodes) break; + const callers = this.cg.getCallers(seedId, 1); + const callees = this.cg.getCallees(seedId, 1); + const neighbors = [...callers, ...callees]; + + const relevant: typeof neighbors = []; + const irrelevant: typeof neighbors = []; + + for (const item of neighbors) { + if (seedMap.has(item.node.id) || isRelevant(item.node)) { + relevant.push(item); + } else { + irrelevant.push(item); + } + } + + // Always add relevant neighbors + for (const item of relevant) { + if (nodeMap.size >= maxNodes && !nodeMap.has(item.node.id)) continue; + nodeMap.set(item.node.id, item.node); + addEdge(item.edge); + } + + // Seeds with no relevant neighbors stay isolated — user can + // right-click → expand to explore manually. No noise added. + } + + // Step 3: 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 4: Filter edges and remove isolated non-root nodes + 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, usedClaude }); + return; + } + + // GET /api/overview?limit=... + if (pathname === '/api/overview') { + const limit = parseInt(query.limit || '50', 10); + // Get top-level exported classes, functions, components + const kinds: NodeKind[] = ['class', 'function', 'interface', 'component', 'enum', 'type_alias']; + const nodes: Node[] = []; + for (const kind of kinds) { + const kindNodes = this.cg.getNodesByKind(kind); + for (const n of kindNodes) { + if (n.isExported || n.kind === 'class' || n.kind === 'component') { + nodes.push(n); + } + if (nodes.length >= limit) break; + } + if (nodes.length >= limit) break; + } + json({ nodes }); + return; + } + + // GET /api/files + if (pathname === '/api/files') { + const files = this.cg.getFiles(); + json({ files }); + return; + } + + // Routes with node ID: /api/node//... + const nodeMatch = pathname.match(/^\/api\/node\/([^/]+)(\/.*)?$/); + if (nodeMatch) { + const nodeId = decodeURIComponent(nodeMatch[1]!); + const sub = nodeMatch[2] || ''; + + // GET /api/node/ + if (!sub || sub === '/') { + const node = this.cg.getNode(nodeId); + if (!node) { + json({ error: 'Node not found' }, 404); + return; + } + const code = await this.cg.getCode(nodeId); + const ancestors = this.cg.getAncestors(nodeId); + json({ node, code, ancestors }); + return; + } + + // GET /api/node//callers?depth=... + if (sub === '/callers') { + const depth = parseInt(query.depth || '1', 10); + const items = this.cg.getCallers(nodeId, depth); + json({ items }); + return; + } + + // GET /api/node//callees?depth=... + if (sub === '/callees') { + const depth = parseInt(query.depth || '1', 10); + const items = this.cg.getCallees(nodeId, depth); + json({ items }); + return; + } + + // GET /api/node//children + if (sub === '/children') { + const children = this.cg.getChildren(nodeId); + json({ children }); + return; + } + + // GET /api/node//impact?depth=... + if (sub === '/impact') { + const depth = parseInt(query.depth || '2', 10); + const subgraph = this.cg.getImpactRadius(nodeId, depth); + json(serializeSubgraph(subgraph)); + return; + } + + // GET /api/node//callgraph?depth=... + if (sub === '/callgraph') { + const depth = parseInt(query.depth || '2', 10); + const subgraph = this.cg.getCallGraph(nodeId, depth); + json(serializeSubgraph(subgraph)); + return; + } + + // GET /api/node//context + if (sub === '/context') { + const context = this.cg.getContext(nodeId); + json({ context }); + return; + } + + json({ error: 'Unknown endpoint' }, 404); + return; + } + + // GET /api/file-nodes?path=... + if (pathname === '/api/file-nodes') { + const filePath = query.path || ''; + if (!filePath) { + json({ error: 'path parameter required' }, 400); + return; + } + const nodes = this.cg.getNodesInFile(filePath); + json({ nodes }); + return; + } + + json({ error: 'Unknown API endpoint' }, 404); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + json({ error: message }, 500); + } + } + + private serveStatic(pathname: string, res: http.ServerResponse): void { + if (pathname === '/' || pathname === '/index.html') { + pathname = '/index.html'; + } + + // Resolve from the public directory next to this file + const publicDir = path.join(__dirname, 'public'); + const filePath = path.join(publicDir, pathname); + + // Security: prevent directory traversal + if (!filePath.startsWith(publicDir)) { + res.writeHead(403); + res.end('Forbidden'); + return; + } + + const ext = path.extname(filePath).toLowerCase(); + const mimeTypes: Record = { + '.html': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.json': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + }; + + try { + const content = fs.readFileSync(filePath); + res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' }); + res.end(content); + } catch { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not found'); + } + } +} From ba30c74461c82bc28f8fa0dfc33b59afd0ad8a8e Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 22 Mar 2026 16:46:09 -0500 Subject: [PATCH 2/8] feat: Improve visualizer graph quality and UI - Bridge pass: connect isolated seed nodes that share callees - Kind labels on nodes (fn, class, comp, etc.) for quick identification - Quick action buttons in detail panel (Expand Callees, Callers, Call Graph, Impact) - Wider detail panel (460px) for better code readability - Multiline node labels showing name + kind Co-Authored-By: Claude Opus 4.6 (1M context) --- src/visualizer/public/index.html | 26 +++++++++++++--- src/visualizer/server.ts | 52 +++++++++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/visualizer/public/index.html b/src/visualizer/public/index.html index e07f820..bc736b7 100644 --- a/src/visualizer/public/index.html +++ b/src/visualizer/public/index.html @@ -42,7 +42,7 @@ --font-mono: 'SF Mono', 'Fira Code', 'JetBrains Mono', Consolas, monospace; --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; --sidebar-width: 320px; - --panel-width: 420px; + --panel-width: 460px; --header-height: 52px; --radius: 8px; --radius-sm: 6px; @@ -991,8 +991,8 @@ 'height': 'label', 'padding': '12px', 'shape': 'data(shape)', - 'text-wrap': 'none', - 'text-max-width': '160px', + 'text-wrap': 'wrap', + 'text-max-width': '180px', 'transition-property': 'background-opacity, border-color, border-opacity, opacity, text-opacity', 'transition-duration': '0.2s', } @@ -1130,16 +1130,24 @@ // ==================================================================== // Graph Operations // ==================================================================== + const kindLabels = { + 'function': 'fn', 'method': 'method', 'class': 'class', 'interface': 'iface', + 'component': 'comp', 'route': 'route', 'enum': 'enum', 'type_alias': 'type', + 'struct': 'struct', 'trait': 'trait', 'variable': 'var', 'constant': 'const', + 'property': 'prop', 'field': 'field', 'file': 'file', 'module': 'mod', + }; + function addNodeToGraph(node) { if (cy.getElementById(node.id).length > 0) return; const color = kindColors[node.kind] || '#8b949e'; const shape = kindShapes[node.kind] || 'round-rectangle'; + const kindLabel = kindLabels[node.kind] || node.kind; cy.add({ group: 'nodes', data: { id: node.id, nodeId: node.id, - label: node.name, + label: `${node.name}\n${kindLabel}`, color: color, shape: shape, kind: node.kind, @@ -1541,6 +1549,16 @@ let html = ''; + // Quick actions + html += `
+
+ + + + +
+
`; + // Meta info html += `
Info
diff --git a/src/visualizer/server.ts b/src/visualizer/server.ts index 033a45c..3e4582f 100644 --- a/src/visualizer/server.ts +++ b/src/visualizer/server.ts @@ -397,11 +397,55 @@ ${symbolIndex}`; addEdge(item.edge); } - // Seeds with no relevant neighbors stay isolated — user can - // right-click → expand to explore manually. No noise added. + // For seeds with no relevant neighbors, add top-3 callees + // so they're not completely floating + if (relevant.length === 0 && irrelevant.length > 0) { + for (const item of irrelevant.slice(0, 3)) { + if (nodeMap.size >= maxNodes) break; + // Only add if it connects to another node already in the graph + if (nodeMap.has(item.node.id)) { + addEdge(item.edge); + } + } + } } - // Step 3: Cross-connection pass — find edges between all result nodes + // Step 3: Bridge pass — find shared callees between isolated seeds + // If two seeds both call the same function, add it as a bridge node + const isolatedSeeds = Array.from(seedMap.keys()).filter(id => { + return !edgeList.some(e => e.source === id || e.target === id); + }); + + if (isolatedSeeds.length > 1) { + // Collect callees for each isolated seed + const seedCallees = new Map(); + for (const seedId of isolatedSeeds) { + const callees = this.cg.getCallees(seedId, 1); + for (const item of callees) { + const existing = seedCallees.get(item.node.id); + if (existing) { + existing.seeds.push(seedId); + } else { + seedCallees.set(item.node.id, { node: item.node, seeds: [seedId] }); + } + } + } + // Add bridge nodes that connect 2+ isolated seeds + for (const [bridgeId, { node: bridgeNode, seeds }] of seedCallees) { + if (seeds.length >= 2 && nodeMap.size < maxNodes) { + nodeMap.set(bridgeId, bridgeNode); + // Add edges from each seed to the bridge + for (const seedId of seeds) { + const callees = this.cg.getCallees(seedId, 1); + for (const item of callees) { + if (item.node.id === bridgeId) addEdge(item.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); @@ -412,7 +456,7 @@ ${symbolIndex}`; } } - // Step 4: Filter edges and remove isolated non-root nodes + // Step 5: Filter edges and remove isolated non-root nodes const finalEdges = edgeList.filter(e => nodeMap.has(e.source) && nodeMap.has(e.target)); const connectedIds = new Set(); From c433e7d1a0473a4865473f4f96ba2b53dad82a9a Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 22 Mar 2026 16:48:55 -0500 Subject: [PATCH 3/8] refactor: Trust Claude's seed picks, only add bridge nodes Instead of expanding all callers/callees of seeds (which pulls in noise from hub nodes like getSession), now: 1. Find direct edges between Claude's seeds 2. Only add non-seed nodes if they bridge 2+ isolated seeds 3. Cross-connection pass discovers hidden edges between result nodes 4. No more unrelated callers of hub nodes polluting the graph Co-Authored-By: Claude Opus 4.6 (1M context) --- src/visualizer/server.ts | 110 ++++++++++++++++----------------------- 1 file changed, 45 insertions(+), 65 deletions(-) diff --git a/src/visualizer/server.ts b/src/visualizer/server.ts index 3e4582f..0fc1a9b 100644 --- a/src/visualizer/server.ts +++ b/src/visualizer/server.ts @@ -310,10 +310,11 @@ ${symbolIndex}`; 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 _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(); @@ -370,78 +371,57 @@ ${symbolIndex}`; if (!edgeSet.has(ek)) { edgeSet.add(ek); edgeList.push(edge); } }; - // Step 2: For each seed, get callers/callees (depth 1) - // Only keep neighbors that are relevant or connect to other seeds - // Fall back to top-3 non-relevant only if seed has NO relevant neighbors + // 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) { - if (nodeMap.size >= maxNodes) break; - const callers = this.cg.getCallers(seedId, 1); + // Check if this seed directly connects to another seed const callees = this.cg.getCallees(seedId, 1); - const neighbors = [...callers, ...callees]; - - const relevant: typeof neighbors = []; - const irrelevant: typeof neighbors = []; - - for (const item of neighbors) { - if (seedMap.has(item.node.id) || isRelevant(item.node)) { - relevant.push(item); - } else { - irrelevant.push(item); - } - } - - // Always add relevant neighbors - for (const item of relevant) { - if (nodeMap.size >= maxNodes && !nodeMap.has(item.node.id)) continue; - nodeMap.set(item.node.id, item.node); - addEdge(item.edge); - } - - // For seeds with no relevant neighbors, add top-3 callees - // so they're not completely floating - if (relevant.length === 0 && irrelevant.length > 0) { - for (const item of irrelevant.slice(0, 3)) { - if (nodeMap.size >= maxNodes) break; - // Only add if it connects to another node already in the graph - if (nodeMap.has(item.node.id)) { - addEdge(item.edge); - } + 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 — find shared callees between isolated seeds - // If two seeds both call the same function, add it as a bridge node - const isolatedSeeds = Array.from(seedMap.keys()).filter(id => { - return !edgeList.some(e => e.source === id || e.target === id); - }); + // 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); + } - if (isolatedSeeds.length > 1) { - // Collect callees for each isolated seed - const seedCallees = new Map(); - for (const seedId of isolatedSeeds) { - const callees = this.cg.getCallees(seedId, 1); - for (const item of callees) { - const existing = seedCallees.get(item.node.id); - if (existing) { - existing.seeds.push(seedId); - } else { - seedCallees.set(item.node.id, { node: item.node, seeds: [seedId] }); - } + 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 bridge nodes that connect 2+ isolated seeds - for (const [bridgeId, { node: bridgeNode, seeds }] of seedCallees) { - if (seeds.length >= 2 && nodeMap.size < maxNodes) { - nodeMap.set(bridgeId, bridgeNode); - // Add edges from each seed to the bridge - for (const seedId of seeds) { - const callees = this.cg.getCallees(seedId, 1); - for (const item of callees) { - if (item.node.id === bridgeId) addEdge(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); } } @@ -456,7 +436,7 @@ ${symbolIndex}`; } } - // Step 5: Filter edges and remove isolated non-root nodes + // Step 5: Filter and clean up const finalEdges = edgeList.filter(e => nodeMap.has(e.source) && nodeMap.has(e.target)); const connectedIds = new Set(); From 2278d3fd6f5b4754bc44c76b0e7386a6cadd4e98 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 22 Mar 2026 16:52:10 -0500 Subject: [PATCH 4/8] feat: Flow-oriented exploration with entry point identification Update Claude prompt to identify the entry point and return symbols in execution order. The graph now centers on the entry point and auto-opens its detail panel, giving users a clear starting point to trace the flow. - Claude returns {entry, flow} instead of flat array - Entry point is auto-selected and centered on load - Detail panel opens immediately for the entry point - Prompt asks for max 8-10 symbols in execution order Co-Authored-By: Claude Opus 4.6 (1M context) --- src/visualizer/public/index.html | 15 ++++++++++- src/visualizer/server.ts | 43 +++++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/visualizer/public/index.html b/src/visualizer/public/index.html index bc736b7..f961eef 100644 --- a/src/visualizer/public/index.html +++ b/src/visualizer/public/index.html @@ -1414,7 +1414,7 @@ } addSubgraph(data.nodes, data.edges); - // Highlight root/entry-point nodes + // 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); @@ -1423,6 +1423,19 @@ } runLayout(); + + // Center on entry point if available + if (data.entryPoint) { + const entryEle = cy.getElementById(data.entryPoint); + if (entryEle.length > 0) { + entryEle.select(); + setTimeout(() => { + cy.animate({ center: { eles: entryEle } }, { duration: 400 }); + showNodeDetails(data.entryPoint); + }, 350); + } + } + const source = data.usedClaude ? ' (via Claude)' : ''; showToast(`Found ${data.nodes.length} related symbols${source}`); } catch (err) { diff --git a/src/visualizer/server.ts b/src/visualizer/server.ts index 0fc1a9b..e4c934e 100644 --- a/src/visualizer/server.ts +++ b/src/visualizer/server.ts @@ -80,10 +80,16 @@ export class VisualizerServer { const symbolIndex = this.buildSymbolIndex(); - const prompt = `You are analyzing a codebase to help a developer understand it visually. Given the question and symbol index below, identify the 8-12 most relevant symbols that would help answer the question. + const prompt = `You are analyzing a codebase to help a developer visually trace a code flow. Given the question and symbol index below, identify the entry point and the key symbols in the flow. -IMPORTANT: Return ONLY a JSON array of symbol names. No explanation, no markdown, no code fences. Just the array. -Example: ["requireAuth", "LoginPage", "getSession", "UserService"] +IMPORTANT: Return ONLY a JSON object with this exact format. No explanation, no markdown, no code fences. +{"entry": "symbolName", "flow": ["symbol1", "symbol2", "symbol3", ...]} + +- "entry" is THE single starting point the user would trigger (e.g., a page component, route handler, button click handler) +- "flow" is the symbols in rough execution order, starting from the entry point (max 8-10 symbols) +- Include the entry point in the flow array too + +Example: {"entry": "LoginPage", "flow": ["LoginPage", "handleSubmit", "authenticateUser", "createSession", "redirect"]} Question: "${question}" @@ -109,13 +115,27 @@ ${symbolIndex}`; this.claudeAvailable = true; - // Parse the JSON array from Claude's response + // Parse Claude's response — try object format first, then array fallback try { const text = stdout.trim(); - // Try to extract JSON array from response (Claude might wrap it) - const jsonMatch = text.match(/\[[\s\S]*\]/); - if (jsonMatch) { - const names = JSON.parse(jsonMatch[0]) as string[]; + // Try to extract JSON object {"entry": ..., "flow": [...]} + const objMatch = text.match(/\{[\s\S]*\}/); + if (objMatch) { + const parsed = JSON.parse(objMatch[0]) as { entry?: string; flow?: string[] }; + if (parsed.flow && Array.isArray(parsed.flow) && parsed.flow.length > 0) { + // Return flow with entry first + const names = parsed.flow.map(String); + if (parsed.entry && !names.includes(parsed.entry)) { + names.unshift(String(parsed.entry)); + } + resolve(names); + return; + } + } + // Fallback: try JSON array + const arrMatch = text.match(/\[[\s\S]*\]/); + if (arrMatch) { + const names = JSON.parse(arrMatch[0]) as string[]; if (Array.isArray(names) && names.length > 0) { resolve(names.map(String)); return; @@ -322,6 +342,7 @@ ${symbolIndex}`; let usedClaude = false; // Try Claude CLI first for intelligent query interpretation + let entryNodeId: string | null = null; const claudeNames = await this.askClaude(q); if (claudeNames && claudeNames.length > 0) { usedClaude = true; @@ -332,6 +353,10 @@ ${symbolIndex}`; if (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; + } } } } @@ -448,7 +473,7 @@ ${symbolIndex}`; const finalNodes = Array.from(nodeMap.values()).filter(n => connectedIds.has(n.id)); - json({ nodes: finalNodes, edges: finalEdges, roots: rootIds, usedClaude }); + json({ nodes: finalNodes, edges: finalEdges, roots: rootIds, entryPoint: entryNodeId, usedClaude }); return; } From 3d2b38918e5ef73f932ff9c4a67fb647daeba0cd Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 22 Mar 2026 16:55:52 -0500 Subject: [PATCH 5/8] refine: Tighten Claude prompt for focused 5-8 node execution paths Stricter prompt rules: only symbols directly in the execution path, every symbol must call or be called by the next, no tangential features. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/visualizer/server.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/visualizer/server.ts b/src/visualizer/server.ts index e4c934e..1121403 100644 --- a/src/visualizer/server.ts +++ b/src/visualizer/server.ts @@ -80,20 +80,21 @@ export class VisualizerServer { const symbolIndex = this.buildSymbolIndex(); - const prompt = `You are analyzing a codebase to help a developer visually trace a code flow. Given the question and symbol index below, identify the entry point and the key symbols in the flow. + const prompt = `You are tracing a code flow through a codebase. Given the question and symbol index below, identify the EXACT execution path. -IMPORTANT: Return ONLY a JSON object with this exact format. No explanation, no markdown, no code fences. -{"entry": "symbolName", "flow": ["symbol1", "symbol2", "symbol3", ...]} +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 -- "entry" is THE single starting point the user would trigger (e.g., a page component, route handler, button click handler) -- "flow" is the symbols in rough execution order, starting from the entry point (max 8-10 symbols) -- Include the entry point in the flow array too - -Example: {"entry": "LoginPage", "flow": ["LoginPage", "handleSubmit", "authenticateUser", "createSession", "redirect"]} +Return ONLY this JSON format, nothing else: +{"entry": "entrySymbol", "flow": ["step1", "step2", "step3", "step4", "step5"]} Question: "${question}" -Symbol index (format: file: kind:name, ...): +Symbol index: ${symbolIndex}`; return new Promise((resolve) => { From dcd4fa397ec1ac4e37653b114bc12da31bb326a3 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 22 Mar 2026 17:19:23 -0500 Subject: [PATCH 6/8] refactor: Simplify to entry-point + call graph tracing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/visualizer/public/index.html | 77 ++++++------- src/visualizer/server.ts | 186 +++++++------------------------ 2 files changed, 76 insertions(+), 187 deletions(-) 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; } From 8f5f88b813f563251436a73bd0dea59c02b104ba Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 22 Mar 2026 17:22:35 -0500 Subject: [PATCH 7/8] refactor: Remove AI entry point guessing, use search-driven flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI picking the entry point was unreliable. Now: - Type a symbol name → dropdown shows matches - Click a result (or Enter to pick first) → traces its call graph depth 3 - The user picks the starting point, the graph does the rest deterministically No more AI guesswork. Search + graph traversal = reliable flows. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/visualizer/public/index.html | 36 +++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/visualizer/public/index.html b/src/visualizer/public/index.html index 05d9532..741e518 100644 --- a/src/visualizer/public/index.html +++ b/src/visualizer/public/index.html @@ -744,7 +744,7 @@
🔍 - +
@@ -796,14 +796,8 @@
🔮
-
Ask about your code
-
Try: "How does authentication work?" or "What happens when a user signs in?"
-
- - - - -
+
Search for a starting point
+
Type a symbol name, pick it, and trace its call chain
@@ -1459,9 +1453,27 @@ if (e.key === 'Enter') { e.preventDefault(); const query = e.target.value.trim(); - if (query) { - hideSearchDropdown(); - exploreQuery(query); + if (!query) return; + + // If dropdown is visible and has results, select the first one + const dropdown = document.getElementById('search-results-dropdown'); + const firstItem = dropdown.querySelector('.search-result-item'); + if (dropdown.classList.contains('visible') && firstItem) { + firstItem.click(); + } else { + // Trigger a search and auto-select first result + (async () => { + try { + const data = await api.search(query, null, 10); + if (data.results.length > 0) { + selectSearchResult(data.results[0].node.id); + } else { + showToast('No symbols found. Try a different search.'); + } + } catch (err) { + showToast('Search error: ' + err.message); + } + })(); } } } From 0756636bde2388c75ebb0de976d90716a11b3334 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 1 Apr 2026 13:50:12 -0500 Subject: [PATCH 8/8] feat: Improve search tokenization with camelCase splitting and code-aware stop words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractSearchTerms now splits camelCase, PascalCase, snake_case, and dot.notation into individual tokens (e.g. "getUserName" → ["user", "name"]). Stop words expanded with code-specific noise words (code, file, function, method, class, type, etc.) to improve search precision. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/search/query-utils.ts | 40 ++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/src/search/query-utils.ts b/src/search/query-utils.ts index 8f16340..0e89f20 100644 --- a/src/search/query-utils.ts +++ b/src/search/query-utils.ts @@ -8,9 +8,11 @@ import * as path from 'path'; import { Node } from '../types'; /** - * Common stop words to filter from search queries + * Common stop words to filter from search queries. + * Includes generic English + code-specific noise words. */ export const STOP_WORDS = new Set([ + // English 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'is', 'it', 'that', 'this', 'are', 'was', 'be', 'has', 'had', 'have', 'do', 'does', 'did', 'will', 'would', 'could', @@ -18,17 +20,41 @@ export const STOP_WORDS = new Set([ 'every', 'how', 'what', 'where', 'when', 'who', 'which', 'why', 'i', 'me', 'my', 'we', 'our', 'you', 'your', 'he', 'she', 'they', 'find', 'show', 'get', 'list', 'give', 'tell', + 'been', 'done', 'made', 'used', 'using', 'work', 'works', 'found', + 'also', 'into', 'then', 'than', 'just', 'more', 'some', 'such', + 'over', 'only', 'new', 'out', 'its', 'so', 'up', 'as', 'if', + // Code-specific noise + 'code', 'file', 'files', 'function', 'method', 'class', 'type', + 'build', 'run', 'test', 'fix', 'bug', 'call', 'called', 'set', 'add', ]); /** - * Extract meaningful search terms from a natural language query + * Extract meaningful search terms from a natural language query. + * Splits camelCase, PascalCase, snake_case, SCREAMING_SNAKE, and dot.notation + * into individual tokens before filtering. */ export function extractSearchTerms(query: string): string[] { - return query - .toLowerCase() - .replace(/[^\w\s-]/g, ' ') - .split(/\s+/) - .filter(term => term.length > 1 && !STOP_WORDS.has(term)); + const tokens = new Set(); + + // Split camelCase / PascalCase: "getUserName" → "get User Name" + const camelSplit = query + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2'); + + // Replace underscores and dots with spaces (snake_case, dot.notation) + const normalised = camelSplit.replace(/[_.]+/g, ' '); + + // Split on any non-alphanumeric character + const words = normalised.split(/[^a-zA-Z0-9]+/).filter(Boolean); + + for (const word of words) { + const lower = word.toLowerCase(); + if (lower.length < 3) continue; + if (STOP_WORDS.has(lower)) continue; + tokens.add(lower); + } + + return [...tokens]; } /**