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'); + } + } +}