feat(graph-explorer): implement interactive code relationship visualization with Cytoscape.js

- Added main render function to initialize the graph explorer view.
- Implemented data loading functions for graph nodes, edges, and search process data.
- Created UI layout with tabs for graph view and search process view.
- Developed filtering options for nodes and edges with corresponding UI elements.
- Integrated Cytoscape.js for graph visualization, including node and edge styling.
- Added functionality for node selection and details display.
- Implemented impact analysis feature with modal display for results.
- Included utility functions for refreshing data and managing UI states.
This commit is contained in:
catlog22
2025-12-15 19:35:18 +08:00
parent 97640a517a
commit 894b93e08d
9 changed files with 2438 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
# Graph Routes API Documentation
## Overview
The Graph Routes module provides REST API endpoints for querying and visualizing code structure data from CodexLens indices. It exposes symbols and their relationships as graph nodes and edges.
## Endpoints
### GET /api/graph/nodes
Query all symbols from the CodexLens SQLite database and return them as graph nodes.
**Query Parameters:**
- `path` (optional): Project path. Defaults to server's initial path.
**Response:**
```json
{
"nodes": [
{
"id": "src/file.ts:functionName:10",
"name": "functionName",
"type": "FUNCTION",
"file": "src/file.ts",
"line": 10,
"docstring": "function_type",
"tokenCount": 45
}
]
}
```
**Node Types:**
- `FUNCTION`: Functions and procedures
- `CLASS`: Classes, interfaces, and type definitions
- `METHOD`: Class methods
- `VARIABLE`: Variables and constants
- `MODULE`: Module-level definitions
---
### GET /api/graph/edges
Query all code relationships from the CodexLens SQLite database and return them as graph edges.
**Query Parameters:**
- `path` (optional): Project path. Defaults to server's initial path.
**Response:**
```json
{
"edges": [
{
"source": "src/file.ts:caller:10",
"target": "module.callee",
"type": "CALLS",
"sourceLine": 10,
"sourceFile": "src/file.ts"
}
]
}
```
**Edge Types:**
- `CALLS`: Function/method invocations
- `IMPORTS`: Import/require relationships
- `INHERITS`: Class inheritance
---
### GET /api/graph/impact
Get impact analysis for a symbol - determines what code is affected if this symbol changes.
**Query Parameters:**
- `path` (optional): Project path. Defaults to server's initial path.
- `symbol` (required): Symbol ID in format `file:name:line`
**Response:**
```json
{
"directDependents": [
"src/other.ts:caller:20",
"src/another.ts:user:35"
],
"affectedFiles": [
"src/other.ts",
"src/another.ts"
]
}
```
---
## Implementation Details
### PathMapper
Maps source code paths to CodexLens index database paths following the storage structure:
- Windows: `D:\path\to\project``~/.codexlens/indexes/D/path/to/project/_index.db`
- Unix: `/home/user/project``~/.codexlens/indexes/home/user/project/_index.db`
### Database Schema
Queries two main tables:
1. **symbols** - Code symbol definitions
- `id`, `file_id`, `name`, `kind`, `start_line`, `end_line`, `token_count`, `symbol_type`
2. **code_relationships** - Inter-symbol dependencies
- `id`, `source_symbol_id`, `target_qualified_name`, `relationship_type`, `source_line`, `target_file`
### Error Handling
- Returns empty arrays (`[]`) if index database doesn't exist
- Returns 500 with error message on database query failures
- Returns 400 if required parameters are missing
### Type Mappings
**Symbol Kinds → Node Types:**
```typescript
{
'function' 'FUNCTION',
'class' 'CLASS',
'method' 'METHOD',
'variable' 'VARIABLE',
'module' 'MODULE',
'interface' 'CLASS',
'type' 'CLASS'
}
```
**Relationship Types → Edge Types:**
```typescript
{
'call' 'CALLS',
'import' 'IMPORTS',
'inherits' 'INHERITS',
'uses' 'CALLS'
}
```
---
## Usage Examples
### Fetch All Nodes
```bash
curl "http://localhost:3000/api/graph/nodes?path=/path/to/project"
```
### Fetch All Edges
```bash
curl "http://localhost:3000/api/graph/edges?path=/path/to/project"
```
### Analyze Impact
```bash
curl "http://localhost:3000/api/graph/impact?path=/path/to/project&symbol=src/file.ts:functionName:10"
```
---
## Integration with CodexLens
This module requires:
1. CodexLens to be installed and initialized (`/api/codexlens/init`)
2. Project to be indexed (creates `_index.db` files)
3. better-sqlite3 package for direct database access
## Dependencies
- `better-sqlite3`: Direct SQLite database access
- `codex-lens`: Python package providing the indexing infrastructure
- `path`, `fs`, `os`: Node.js built-in modules for file system operations

View File

@@ -0,0 +1,315 @@
// @ts-nocheck
/**
* Graph Routes Module
* Handles graph visualization API endpoints for codex-lens data
*/
import type { IncomingMessage, ServerResponse } from 'http';
import { executeCodexLens } from '../../tools/codex-lens.js';
import { homedir } from 'os';
import { join } from 'path';
import { existsSync } from 'fs';
import Database from 'better-sqlite3';
export interface RouteContext {
pathname: string;
url: URL;
req: IncomingMessage;
res: ServerResponse;
initialPath: string;
handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise<any>) => void;
broadcastToClients: (data: unknown) => void;
}
/**
* PathMapper utility class (simplified from codex-lens Python implementation)
* Maps source paths to index database paths
*/
class PathMapper {
private indexRoot: string;
constructor(indexRoot?: string) {
this.indexRoot = indexRoot || join(homedir(), '.codexlens', 'indexes');
}
/**
* Normalize path to cross-platform storage format
* Windows: D:\path\to\dir → D/path/to/dir
* Unix: /home/user/path → home/user/path
*/
normalizePath(sourcePath: string): string {
const resolved = sourcePath.replace(/\\/g, '/');
// Handle Windows paths with drive letters
if (process.platform === 'win32' && /^[A-Za-z]:/.test(resolved)) {
const drive = resolved[0]; // D
const rest = resolved.slice(2); // /path/to/dir
return `${drive}${rest}`.replace(/^\//, '');
}
// Handle Unix paths - remove leading slash
return resolved.replace(/^\//, '');
}
/**
* Convert source path to index database path
*/
sourceToIndexDb(sourcePath: string): string {
const normalized = this.normalizePath(sourcePath);
return join(this.indexRoot, normalized, '_index.db');
}
}
interface GraphNode {
id: string;
name: string;
type: string;
file: string;
line: number;
docstring?: string;
tokenCount?: number;
}
interface GraphEdge {
source: string;
target: string;
type: string;
sourceLine: number;
sourceFile: string;
}
interface ImpactAnalysis {
directDependents: string[];
affectedFiles: string[];
}
/**
* Map codex-lens symbol kinds to graph node types
*/
function mapSymbolKind(kind: string): string {
const kindMap: Record<string, string> = {
'function': 'FUNCTION',
'class': 'CLASS',
'method': 'METHOD',
'variable': 'VARIABLE',
'module': 'MODULE',
'interface': 'CLASS', // TypeScript interfaces as CLASS
'type': 'CLASS', // Type aliases as CLASS
};
return kindMap[kind.toLowerCase()] || 'VARIABLE';
}
/**
* Map codex-lens relationship types to graph edge types
*/
function mapRelationType(relType: string): string {
const typeMap: Record<string, string> = {
'call': 'CALLS',
'import': 'IMPORTS',
'inherits': 'INHERITS',
'uses': 'CALLS', // Fallback uses → CALLS
};
return typeMap[relType.toLowerCase()] || 'CALLS';
}
/**
* Query symbols from codex-lens database
*/
async function querySymbols(projectPath: string): Promise<GraphNode[]> {
const mapper = new PathMapper();
const dbPath = mapper.sourceToIndexDb(projectPath);
if (!existsSync(dbPath)) {
return [];
}
try {
const db = Database(dbPath, { readonly: true });
const rows = db.prepare(`
SELECT
s.id,
s.name,
s.kind,
s.start_line,
s.token_count,
s.symbol_type,
f.path as file
FROM symbols s
JOIN files f ON s.file_id = f.id
ORDER BY f.path, s.start_line
`).all();
db.close();
return rows.map((row: any) => ({
id: `${row.file}:${row.name}:${row.start_line}`,
name: row.name,
type: mapSymbolKind(row.kind),
file: row.file,
line: row.start_line,
docstring: row.symbol_type || undefined,
tokenCount: row.token_count || undefined,
}));
} catch (err) {
console.error(`[Graph] Failed to query symbols: ${err.message}`);
return [];
}
}
/**
* Query code relationships from codex-lens database
*/
async function queryRelationships(projectPath: string): Promise<GraphEdge[]> {
const mapper = new PathMapper();
const dbPath = mapper.sourceToIndexDb(projectPath);
if (!existsSync(dbPath)) {
return [];
}
try {
const db = Database(dbPath, { readonly: true });
const rows = db.prepare(`
SELECT
s.name as source_name,
s.start_line as source_line,
f.path as source_file,
r.target_qualified_name,
r.relationship_type,
r.target_file
FROM code_relationships r
JOIN symbols s ON r.source_symbol_id = s.id
JOIN files f ON s.file_id = f.id
ORDER BY f.path, s.start_line
`).all();
db.close();
return rows.map((row: any) => ({
source: `${row.source_file}:${row.source_name}:${row.source_line}`,
target: row.target_qualified_name,
type: mapRelationType(row.relationship_type),
sourceLine: row.source_line,
sourceFile: row.source_file,
}));
} catch (err) {
console.error(`[Graph] Failed to query relationships: ${err.message}`);
return [];
}
}
/**
* Perform impact analysis for a symbol
* Find all symbols that depend on this symbol (direct and transitive)
*/
async function analyzeImpact(projectPath: string, symbolId: string): Promise<ImpactAnalysis> {
const mapper = new PathMapper();
const dbPath = mapper.sourceToIndexDb(projectPath);
if (!existsSync(dbPath)) {
return { directDependents: [], affectedFiles: [] };
}
try {
const db = Database(dbPath, { readonly: true });
// Parse symbolId to extract symbol name
const parts = symbolId.split(':');
const symbolName = parts.length >= 2 ? parts[1] : symbolId;
// Find all symbols that reference this symbol
const rows = db.prepare(`
SELECT DISTINCT
s.name as dependent_name,
f.path as dependent_file,
s.start_line as dependent_line
FROM code_relationships r
JOIN symbols s ON r.source_symbol_id = s.id
JOIN files f ON s.file_id = f.id
WHERE r.target_qualified_name LIKE ?
`).all(`%${symbolName}%`);
db.close();
const directDependents = rows.map((row: any) =>
`${row.dependent_file}:${row.dependent_name}:${row.dependent_line}`
);
const affectedFiles = [...new Set(rows.map((row: any) => row.dependent_file))];
return {
directDependents,
affectedFiles,
};
} catch (err) {
console.error(`[Graph] Failed to analyze impact: ${err.message}`);
return { directDependents: [], affectedFiles: [] };
}
}
/**
* Handle Graph routes
* @returns true if route was handled, false otherwise
*/
export async function handleGraphRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath } = ctx;
// API: Graph Nodes - Get all symbols as graph nodes
if (pathname === '/api/graph/nodes') {
const projectPath = url.searchParams.get('path') || initialPath;
try {
const nodes = await querySymbols(projectPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ nodes }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message, nodes: [] }));
}
return true;
}
// API: Graph Edges - Get all relationships as graph edges
if (pathname === '/api/graph/edges') {
const projectPath = url.searchParams.get('path') || initialPath;
try {
const edges = await queryRelationships(projectPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ edges }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message, edges: [] }));
}
return true;
}
// API: Impact Analysis - Get impact analysis for a symbol
if (pathname === '/api/graph/impact') {
const projectPath = url.searchParams.get('path') || initialPath;
const symbolId = url.searchParams.get('symbol');
if (!symbolId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'symbol parameter is required' }));
return true;
}
try {
const impact = await analyzeImpact(projectPath, symbolId);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(impact));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: err.message,
directDependents: [],
affectedFiles: []
}));
}
return true;
}
return false;
}

View File

@@ -11,6 +11,7 @@ import { handleMemoryRoutes } from './routes/memory-routes.js';
import { handleMcpRoutes } from './routes/mcp-routes.js';
import { handleHooksRoutes } from './routes/hooks-routes.js';
import { handleCodexLensRoutes } from './routes/codexlens-routes.js';
import { handleGraphRoutes } from './routes/graph-routes.js';
import { handleSystemRoutes } from './routes/system-routes.js';
import { handleFilesRoutes } from './routes/files-routes.js';
import { handleSkillsRoutes } from './routes/skills-routes.js';
@@ -270,6 +271,11 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleCodexLensRoutes(routeContext)) return;
}
// Graph routes (/api/graph/*)
if (pathname.startsWith('/api/graph/')) {
if (await handleGraphRoutes(routeContext)) return;
}
// CCW routes (/api/ccw/*)
if (pathname.startsWith('/api/ccw/')) {
if (await handleCcwRoutes(routeContext)) return;