From e542cd6b2feabe8bbdc8368afd71f1f58c6d647d Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:41:18 -0700 Subject: [PATCH 1/5] feat(export): add GraphML, GraphSON, Neo4j CSV formats and interactive HTML viewer Add three new export formats for graph database interoperability: - GraphML (XML standard) with file-level and function-level modes - GraphSON (TinkerPop v3) for Gremlin/JanusGraph compatibility - Neo4j CSV (bulk import) with separate nodes/relationships files Add interactive HTML viewer (`codegraph plot`) powered by vis-network: - Hierarchical, force, and radial layouts with physics toggle - Node coloring by kind or role, search/filter, legend panel - Configurable via .plotDotCfg JSON file Update CLI export command, MCP export_graph tool, and programmatic API to support all six formats. Impact: 12 functions changed, 6 affected --- src/cli.js | 92 +++++++++- src/export.js | 305 +++++++++++++++++++++++++++++++++ src/index.js | 13 +- src/mcp.js | 29 +++- src/viewer.js | 336 +++++++++++++++++++++++++++++++++++++ tests/graph/export.test.js | 205 +++++++++++++++++++++- tests/graph/viewer.test.js | 114 +++++++++++++ 7 files changed, 1084 insertions(+), 10 deletions(-) create mode 100644 src/viewer.js create mode 100644 tests/graph/viewer.test.js diff --git a/src/cli.js b/src/cli.js index e5b95942..06cb4c9e 100644 --- a/src/cli.js +++ b/src/cli.js @@ -16,7 +16,14 @@ import { MODELS, search, } from './embedder.js'; -import { exportDOT, exportJSON, exportMermaid } from './export.js'; +import { + exportDOT, + exportGraphML, + exportGraphSON, + exportJSON, + exportMermaid, + exportNeo4jCSV, +} from './export.js'; import { setVerbose } from './logger.js'; import { printNdjson } from './paginate.js'; import { @@ -446,9 +453,13 @@ program program .command('export') - .description('Export dependency graph as DOT (Graphviz), Mermaid, or JSON') + .description('Export dependency graph as DOT, Mermaid, JSON, GraphML, GraphSON, or Neo4j CSV') .option('-d, --db ', 'Path to graph.db') - .option('-f, --format ', 'Output format: dot, mermaid, json', 'dot') + .option( + '-f, --format ', + 'Output format: dot, mermaid, json, graphml, graphson, neo4j', + 'dot', + ) .option('--functions', 'Function-level graph instead of file-level') .option('-T, --no-tests', 'Exclude test/spec files') .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') @@ -472,6 +483,25 @@ program case 'json': output = JSON.stringify(exportJSON(db, exportOpts), null, 2); break; + case 'graphml': + output = exportGraphML(db, exportOpts); + break; + case 'graphson': + output = JSON.stringify(exportGraphSON(db, exportOpts), null, 2); + break; + case 'neo4j': { + const csv = exportNeo4jCSV(db, exportOpts); + if (opts.output) { + const base = opts.output.replace(/\.[^.]+$/, '') || opts.output; + fs.writeFileSync(`${base}-nodes.csv`, csv.nodes, 'utf-8'); + fs.writeFileSync(`${base}-relationships.csv`, csv.relationships, 'utf-8'); + db.close(); + console.log(`Exported to ${base}-nodes.csv and ${base}-relationships.csv`); + return; + } + output = `--- nodes.csv ---\n${csv.nodes}\n\n--- relationships.csv ---\n${csv.relationships}`; + break; + } default: output = exportDOT(db, exportOpts); break; @@ -487,6 +517,62 @@ program } }); +program + .command('plot') + .description('Generate an interactive HTML dependency graph viewer') + .option('-d, --db ', 'Path to graph.db') + .option('--functions', 'Function-level graph instead of file-level') + .option('-T, --no-tests', 'Exclude test/spec files') + .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') + .option('--min-confidence ', 'Minimum edge confidence threshold (default: 0.5)', '0.5') + .option('-o, --output ', 'Write HTML to file') + .option('-c, --config ', 'Path to .plotDotCfg config file') + .option('--no-open', 'Do not open in browser') + .action(async (opts) => { + const { generatePlotHTML, loadPlotConfig } = await import('./viewer.js'); + const { exec } = await import('node:child_process'); + const os = await import('node:os'); + const db = openReadonlyOrFail(opts.db); + + let plotCfg; + if (opts.config) { + try { + plotCfg = JSON.parse(fs.readFileSync(opts.config, 'utf-8')); + } catch (e) { + console.error(`Failed to load config: ${e.message}`); + db.close(); + process.exitCode = 1; + return; + } + } else { + plotCfg = loadPlotConfig(process.cwd()); + } + + const html = generatePlotHTML(db, { + fileLevel: !opts.functions, + noTests: resolveNoTests(opts), + minConfidence: parseFloat(opts.minConfidence), + config: plotCfg, + }); + db.close(); + + const outPath = opts.output || path.join(os.tmpdir(), `codegraph-plot-${Date.now()}.html`); + fs.writeFileSync(outPath, html, 'utf-8'); + console.log(`Plot written to ${outPath}`); + + if (opts.open !== false) { + const cmd = + process.platform === 'win32' + ? `start "" "${outPath}"` + : process.platform === 'darwin' + ? `open "${outPath}"` + : `xdg-open "${outPath}"`; + exec(cmd, (err) => { + if (err) console.error('Could not open browser:', err.message); + }); + } + }); + program .command('cycles') .description('Detect circular dependencies in the codebase') diff --git a/src/export.js b/src/export.js index e13ca5ef..e7687daa 100644 --- a/src/export.js +++ b/src/export.js @@ -4,6 +4,25 @@ import { isTestFile } from './queries.js'; const DEFAULT_MIN_CONFIDENCE = 0.5; +/** Escape special XML characters. */ +function escapeXml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** RFC 4180 CSV field escaping — quote fields containing commas, quotes, or newlines. */ +function escapeCsv(s) { + const str = String(s); + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + /** * Export the dependency graph in DOT (Graphviz) format. */ @@ -374,3 +393,289 @@ export function exportJSON(db, opts = {}) { const base = { nodes, edges }; return paginateResult(base, 'edges', { limit: opts.limit, offset: opts.offset }); } + +/** + * Export the dependency graph in GraphML (XML) format. + */ +export function exportGraphML(db, opts = {}) { + const fileLevel = opts.fileLevel !== false; + const noTests = opts.noTests || false; + const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE; + const edgeLimit = opts.limit; + + const lines = [ + '', + '', + ]; + + if (fileLevel) { + lines.push(' '); + lines.push(' '); + lines.push(' '); + lines.push(' '); + + let edges = db + .prepare(` + SELECT DISTINCT n1.file AS source, n2.file AS target + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls') + AND e.confidence >= ? + `) + .all(minConf); + if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target)); + if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit); + + const files = new Set(); + for (const { source, target } of edges) { + files.add(source); + files.add(target); + } + + const fileIds = new Map(); + let nIdx = 0; + for (const f of files) { + const id = `n${nIdx++}`; + fileIds.set(f, id); + lines.push(` `); + lines.push(` ${escapeXml(path.basename(f))}`); + lines.push(` ${escapeXml(f)}`); + lines.push(' '); + } + + let eIdx = 0; + for (const { source, target } of edges) { + lines.push( + ` `, + ); + lines.push(' imports'); + lines.push(' '); + } + } else { + lines.push(' '); + lines.push(' '); + lines.push(' '); + lines.push(' '); + lines.push(' '); + lines.push(' '); + lines.push(' '); + lines.push(' '); + + let edges = db + .prepare(` + SELECT n1.id AS source_id, n1.name AS source_name, n1.kind AS source_kind, + n1.file AS source_file, n1.line AS source_line, n1.role AS source_role, + n2.id AS target_id, n2.name AS target_name, n2.kind AS target_kind, + n2.file AS target_file, n2.line AS target_line, n2.role AS target_role, + e.kind AS edge_kind, e.confidence + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module') + AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module') + AND e.kind = 'calls' + AND e.confidence >= ? + `) + .all(minConf); + if (noTests) + edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file)); + if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit); + + const emittedNodes = new Set(); + function emitNode(id, name, kind, file, line, role) { + if (emittedNodes.has(id)) return; + emittedNodes.add(id); + lines.push(` `); + lines.push(` ${escapeXml(name)}`); + lines.push(` ${escapeXml(kind)}`); + lines.push(` ${escapeXml(file)}`); + lines.push(` ${line}`); + if (role) lines.push(` ${escapeXml(role)}`); + lines.push(' '); + } + + let eIdx = 0; + for (const e of edges) { + emitNode( + e.source_id, + e.source_name, + e.source_kind, + e.source_file, + e.source_line, + e.source_role, + ); + emitNode( + e.target_id, + e.target_name, + e.target_kind, + e.target_file, + e.target_line, + e.target_role, + ); + lines.push(` `); + lines.push(` ${escapeXml(e.edge_kind)}`); + lines.push(` ${e.confidence}`); + lines.push(' '); + } + } + + lines.push(' '); + lines.push(''); + return lines.join('\n'); +} + +/** + * Export the dependency graph in TinkerPop GraphSON v3 format. + */ +export function exportGraphSON(db, opts = {}) { + const noTests = opts.noTests || false; + const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE; + + let nodes = db + .prepare(` + SELECT id, name, kind, file, line, role FROM nodes + WHERE kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'file') + `) + .all(); + if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file)); + + let edges = db + .prepare(` + SELECT e.rowid AS id, n1.id AS outV, n2.id AS inV, e.kind, e.confidence + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE e.confidence >= ? + `) + .all(minConf); + if (noTests) { + const nodeIds = new Set(nodes.map((n) => n.id)); + edges = edges.filter((e) => nodeIds.has(e.outV) && nodeIds.has(e.inV)); + } + + const vertices = nodes.map((n) => ({ + id: n.id, + label: n.kind, + properties: { + name: [{ id: 0, value: n.name }], + file: [{ id: 0, value: n.file }], + ...(n.line != null ? { line: [{ id: 0, value: n.line }] } : {}), + ...(n.role ? { role: [{ id: 0, value: n.role }] } : {}), + }, + })); + + const gEdges = edges.map((e) => ({ + id: e.id, + label: e.kind, + inV: e.inV, + outV: e.outV, + properties: { + confidence: e.confidence, + }, + })); + + const base = { vertices, edges: gEdges }; + return paginateResult(base, 'edges', { limit: opts.limit, offset: opts.offset }); +} + +/** + * Export the dependency graph as Neo4j bulk-import CSV files. + * Returns { nodes: string, relationships: string }. + */ +export function exportNeo4jCSV(db, opts = {}) { + const fileLevel = opts.fileLevel !== false; + const noTests = opts.noTests || false; + const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE; + const edgeLimit = opts.limit; + + if (fileLevel) { + let edges = db + .prepare(` + SELECT DISTINCT n1.file AS source, n2.file AS target, e.kind, e.confidence + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls') + AND e.confidence >= ? + `) + .all(minConf); + if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target)); + if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit); + + const files = new Map(); + let idx = 0; + for (const { source, target } of edges) { + if (!files.has(source)) files.set(source, idx++); + if (!files.has(target)) files.set(target, idx++); + } + + const nodeLines = ['nodeId:ID,name,file:string,:LABEL']; + for (const [file, id] of files) { + nodeLines.push(`${id},${escapeCsv(path.basename(file))},${escapeCsv(file)},File`); + } + + const relLines = [':START_ID,:END_ID,:TYPE,confidence:float']; + for (const e of edges) { + const edgeType = e.kind.toUpperCase().replace(/-/g, '_'); + relLines.push(`${files.get(e.source)},${files.get(e.target)},${edgeType},${e.confidence}`); + } + + return { nodes: nodeLines.join('\n'), relationships: relLines.join('\n') }; + } + + let edges = db + .prepare(` + SELECT n1.id AS source_id, n1.name AS source_name, n1.kind AS source_kind, + n1.file AS source_file, n1.line AS source_line, n1.role AS source_role, + n2.id AS target_id, n2.name AS target_name, n2.kind AS target_kind, + n2.file AS target_file, n2.line AS target_line, n2.role AS target_role, + e.kind AS edge_kind, e.confidence + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module') + AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module') + AND e.kind = 'calls' + AND e.confidence >= ? + `) + .all(minConf); + if (noTests) + edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file)); + if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit); + + const emitted = new Set(); + const nodeLines = ['nodeId:ID,name,kind,file:string,line:int,role,:LABEL']; + function emitNode(id, name, kind, file, line, role) { + if (emitted.has(id)) return; + emitted.add(id); + const label = kind.charAt(0).toUpperCase() + kind.slice(1); + nodeLines.push( + `${id},${escapeCsv(name)},${escapeCsv(kind)},${escapeCsv(file)},${line},${escapeCsv(role || '')},${label}`, + ); + } + + const relLines = [':START_ID,:END_ID,:TYPE,confidence:float']; + for (const e of edges) { + emitNode( + e.source_id, + e.source_name, + e.source_kind, + e.source_file, + e.source_line, + e.source_role, + ); + emitNode( + e.target_id, + e.target_name, + e.target_kind, + e.target_file, + e.target_line, + e.target_role, + ); + const edgeType = e.edge_kind.toUpperCase().replace(/-/g, '_'); + relLines.push(`${e.source_id},${e.target_id},${edgeType},${e.confidence}`); + } + + return { nodes: nodeLines.join('\n'), relationships: relLines.join('\n') }; +} diff --git a/src/index.js b/src/index.js index 968204bb..0532c2aa 100644 --- a/src/index.js +++ b/src/index.js @@ -87,8 +87,15 @@ export { search, searchData, } from './embedder.js'; -// Export (DOT/Mermaid/JSON) -export { exportDOT, exportJSON, exportMermaid } from './export.js'; +// Export (DOT/Mermaid/JSON/GraphML/GraphSON/Neo4j CSV) +export { + exportDOT, + exportGraphML, + exportGraphSON, + exportJSON, + exportMermaid, + exportNeo4jCSV, +} from './export.js'; // Execution flow tracing export { entryPointType, flowData, listEntryPointsData } from './flow.js'; // Logger @@ -163,5 +170,7 @@ export { } from './structure.js'; // Triage — composite risk audit export { triage, triageData } from './triage.js'; +// Interactive HTML viewer +export { generatePlotHTML, loadPlotConfig } from './viewer.js'; // Watch mode export { watchProject } from './watcher.js'; diff --git a/src/mcp.js b/src/mcp.js index 66cba606..b455852c 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -274,13 +274,14 @@ const BASE_TOOLS = [ }, { name: 'export_graph', - description: 'Export the dependency graph in DOT (Graphviz), Mermaid, or JSON format', + description: + 'Export the dependency graph in DOT, Mermaid, JSON, GraphML, GraphSON, or Neo4j CSV format', inputSchema: { type: 'object', properties: { format: { type: 'string', - enum: ['dot', 'mermaid', 'json'], + enum: ['dot', 'mermaid', 'json', 'graphml', 'graphson', 'neo4j'], description: 'Export format', }, file_level: { @@ -990,7 +991,14 @@ export async function startMCPServer(customDbPath, options = {}) { break; } case 'export_graph': { - const { exportDOT, exportMermaid, exportJSON } = await import('./export.js'); + const { + exportDOT, + exportGraphML, + exportGraphSON, + exportJSON, + exportMermaid, + exportNeo4jCSV, + } = await import('./export.js'); const db = new Database(findDbPath(dbPath), { readonly: true }); const fileLevel = args.file_level !== false; const exportLimit = args.limit @@ -1009,13 +1017,26 @@ export async function startMCPServer(customDbPath, options = {}) { offset: args.offset ?? 0, }); break; + case 'graphml': + result = exportGraphML(db, { fileLevel, limit: exportLimit }); + break; + case 'graphson': + result = exportGraphSON(db, { + fileLevel, + limit: exportLimit, + offset: args.offset ?? 0, + }); + break; + case 'neo4j': + result = exportNeo4jCSV(db, { fileLevel, limit: exportLimit }); + break; default: db.close(); return { content: [ { type: 'text', - text: `Unknown format: ${args.format}. Use dot, mermaid, or json.`, + text: `Unknown format: ${args.format}. Use dot, mermaid, json, graphml, graphson, or neo4j.`, }, ], isError: true, diff --git a/src/viewer.js b/src/viewer.js new file mode 100644 index 00000000..b213651b --- /dev/null +++ b/src/viewer.js @@ -0,0 +1,336 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { isTestFile } from './queries.js'; + +const DEFAULT_MIN_CONFIDENCE = 0.5; + +const DEFAULT_NODE_COLORS = { + function: '#4CAF50', + method: '#66BB6A', + class: '#2196F3', + interface: '#42A5F5', + type: '#7E57C2', + struct: '#FF7043', + enum: '#FFA726', + trait: '#26A69A', + record: '#EC407A', + module: '#78909C', + file: '#90A4AE', +}; + +const DEFAULT_ROLE_COLORS = { + entry: '#e8f5e9', + core: '#e3f2fd', + utility: '#f5f5f5', + dead: '#ffebee', + leaf: '#fffde7', +}; + +const DEFAULT_CONFIG = { + layout: { algorithm: 'hierarchical', direction: 'LR' }, + physics: { enabled: true, nodeDistance: 150 }, + nodeColors: DEFAULT_NODE_COLORS, + roleColors: DEFAULT_ROLE_COLORS, + colorBy: 'kind', + edgeStyle: { color: '#666', smooth: true }, + filter: { kinds: null, roles: null, files: null }, + title: 'Codegraph', +}; + +/** + * Load .plotDotCfg or .plotDotCfg.json from given directory. + * Returns merged config with defaults. + */ +export function loadPlotConfig(dir) { + for (const name of ['.plotDotCfg', '.plotDotCfg.json']) { + const p = path.join(dir, name); + if (fs.existsSync(p)) { + try { + const raw = JSON.parse(fs.readFileSync(p, 'utf-8')); + return { + ...DEFAULT_CONFIG, + ...raw, + layout: { ...DEFAULT_CONFIG.layout, ...(raw.layout || {}) }, + physics: { ...DEFAULT_CONFIG.physics, ...(raw.physics || {}) }, + nodeColors: { ...DEFAULT_CONFIG.nodeColors, ...(raw.nodeColors || {}) }, + roleColors: { ...DEFAULT_CONFIG.roleColors, ...(raw.roleColors || {}) }, + edgeStyle: { ...DEFAULT_CONFIG.edgeStyle, ...(raw.edgeStyle || {}) }, + filter: { ...DEFAULT_CONFIG.filter, ...(raw.filter || {}) }, + }; + } catch { + // Invalid JSON — use defaults + } + } + } + return { ...DEFAULT_CONFIG }; +} + +/** + * Generate a self-contained interactive HTML file with vis-network. + */ +export function generatePlotHTML(db, opts = {}) { + const fileLevel = opts.fileLevel !== false; + const noTests = opts.noTests || false; + const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE; + const cfg = opts.config || DEFAULT_CONFIG; + + let visNodes; + let visEdges; + + if (fileLevel) { + let edges = db + .prepare(` + SELECT DISTINCT n1.file AS source, n2.file AS target + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls') + AND e.confidence >= ? + `) + .all(minConf); + if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target)); + + const files = new Set(); + for (const { source, target } of edges) { + files.add(source); + files.add(target); + } + + const fileIds = new Map(); + let idx = 0; + for (const f of files) fileIds.set(f, idx++); + + visNodes = [...files].map((f) => ({ + id: fileIds.get(f), + label: path.basename(f), + title: f, + color: cfg.nodeColors.file || DEFAULT_NODE_COLORS.file, + })); + + visEdges = edges.map(({ source, target }) => ({ + from: fileIds.get(source), + to: fileIds.get(target), + })); + } else { + let edges = db + .prepare(` + SELECT n1.id AS source_id, n1.name AS source_name, n1.kind AS source_kind, + n1.file AS source_file, n1.line AS source_line, n1.role AS source_role, + n2.id AS target_id, n2.name AS target_name, n2.kind AS target_kind, + n2.file AS target_file, n2.line AS target_line, n2.role AS target_role, + e.kind AS edge_kind + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module') + AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module') + AND e.kind = 'calls' + AND e.confidence >= ? + `) + .all(minConf); + if (noTests) + edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file)); + + // Apply filters + if (cfg.filter.kinds) { + const kinds = new Set(cfg.filter.kinds); + edges = edges.filter((e) => kinds.has(e.source_kind) && kinds.has(e.target_kind)); + } + if (cfg.filter.files) { + const patterns = cfg.filter.files; + edges = edges.filter( + (e) => + patterns.some((p) => e.source_file.includes(p)) && + patterns.some((p) => e.target_file.includes(p)), + ); + } + + const nodeMap = new Map(); + for (const e of edges) { + if (!nodeMap.has(e.source_id)) { + nodeMap.set(e.source_id, { + id: e.source_id, + name: e.source_name, + kind: e.source_kind, + file: e.source_file, + line: e.source_line, + role: e.source_role, + }); + } + if (!nodeMap.has(e.target_id)) { + nodeMap.set(e.target_id, { + id: e.target_id, + name: e.target_name, + kind: e.target_kind, + file: e.target_file, + line: e.target_line, + role: e.target_role, + }); + } + } + + if (cfg.filter.roles) { + const roles = new Set(cfg.filter.roles); + for (const [id, n] of nodeMap) { + if (!roles.has(n.role)) nodeMap.delete(id); + } + const nodeIds = new Set(nodeMap.keys()); + edges = edges.filter((e) => nodeIds.has(e.source_id) && nodeIds.has(e.target_id)); + } + + visNodes = [...nodeMap.values()].map((n) => { + const color = + cfg.colorBy === 'role' && n.role + ? cfg.roleColors[n.role] || DEFAULT_ROLE_COLORS[n.role] || '#ccc' + : cfg.nodeColors[n.kind] || DEFAULT_NODE_COLORS[n.kind] || '#ccc'; + return { + id: n.id, + label: n.name, + title: `${n.file}:${n.line} (${n.kind}${n.role ? `, ${n.role}` : ''})`, + color, + kind: n.kind, + role: n.role || '', + file: n.file, + }; + }); + + visEdges = edges.map((e) => ({ + from: e.source_id, + to: e.target_id, + })); + } + + const layoutOpts = buildLayoutOptions(cfg); + const title = cfg.title || 'Codegraph'; + + return ` + + + + +${escapeHtml(title)} + + + + +
+ + + +
+
+
+ + +`; +} + +function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function buildLayoutOptions(cfg) { + const opts = { + nodes: { + shape: 'box', + font: { face: 'monospace', size: 12 }, + }, + edges: { + arrows: 'to', + color: cfg.edgeStyle.color || '#666', + smooth: cfg.edgeStyle.smooth !== false, + }, + physics: { + enabled: cfg.physics.enabled !== false, + barnesHut: { + gravitationalConstant: -3000, + springLength: cfg.physics.nodeDistance || 150, + }, + }, + interaction: { + tooltipDelay: 200, + hover: true, + }, + }; + + if (cfg.layout.algorithm === 'hierarchical') { + opts.layout = { + hierarchical: { + enabled: true, + direction: cfg.layout.direction || 'LR', + sortMethod: 'directed', + nodeSpacing: cfg.physics.nodeDistance || 150, + }, + }; + } + + return opts; +} diff --git a/tests/graph/export.test.js b/tests/graph/export.test.js index ac89b91a..3a12970e 100644 --- a/tests/graph/export.test.js +++ b/tests/graph/export.test.js @@ -5,7 +5,14 @@ import Database from 'better-sqlite3'; import { describe, expect, it } from 'vitest'; import { initSchema } from '../../src/db.js'; -import { exportDOT, exportJSON, exportMermaid } from '../../src/export.js'; +import { + exportDOT, + exportGraphML, + exportGraphSON, + exportJSON, + exportMermaid, + exportNeo4jCSV, +} from '../../src/export.js'; function createTestDb() { const db = new Database(':memory:'); @@ -252,3 +259,199 @@ describe('exportJSON', () => { db.close(); }); }); + +describe('exportGraphML', () => { + it('generates valid XML wrapper with graphml element', () => { + const db = createTestDb(); + const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0); + const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0); + insertEdge(db, a, b, 'imports'); + + const xml = exportGraphML(db); + expect(xml).toContain(''); + expect(xml).toContain(''); + db.close(); + }); + + it('declares key elements for node and edge attributes', () => { + const db = createTestDb(); + const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0); + const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0); + insertEdge(db, a, b, 'imports'); + + const xml = exportGraphML(db); + expect(xml).toContain(' { + const db = createTestDb(); + const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0); + const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0); + insertEdge(db, a, b, 'imports'); + + const xml = exportGraphML(db); + expect(xml).toContain(' { + const db = createTestDb(); + const fnA = insertNode(db, 'doWork', 'function', 'src/a.js', 5); + const fnB = insertNode(db, 'helper', 'function', 'src/b.js', 10); + insertEdge(db, fnA, fnB, 'calls'); + + const xml = exportGraphML(db, { fileLevel: false }); + expect(xml).toContain('doWork'); + expect(xml).toContain('helper'); + expect(xml).toContain('attr.name="kind"'); + expect(xml).toContain('attr.name="line"'); + db.close(); + }); + + it('produces valid output for empty graph', () => { + const db = createTestDb(); + const xml = exportGraphML(db); + expect(xml).toContain(''); + expect(xml).toContain(''); + db.close(); + }); + + it('escapes XML special characters', () => { + const db = createTestDb(); + const a = insertNode(db, 'src/.js', 'file', 'src/.js', 0); + const b = insertNode(db, 'src/b&c.js', 'file', 'src/b&c.js', 0); + insertEdge(db, a, b, 'imports'); + + const xml = exportGraphML(db); + expect(xml).toContain('<a>'); + expect(xml).toContain('b&c'); + expect(xml).not.toContain(''); + db.close(); + }); +}); + +describe('exportGraphSON', () => { + it('returns TinkerPop structure with vertices and edges', () => { + const db = createTestDb(); + const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0); + const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0); + insertEdge(db, a, b, 'imports'); + + const data = exportGraphSON(db); + expect(data).toHaveProperty('vertices'); + expect(data).toHaveProperty('edges'); + expect(data.vertices.length).toBeGreaterThanOrEqual(2); + db.close(); + }); + + it('uses multi-valued property format', () => { + const db = createTestDb(); + const fn = insertNode(db, 'doWork', 'function', 'src/a.js', 5); + const fn2 = insertNode(db, 'helper', 'function', 'src/b.js', 10); + insertEdge(db, fn, fn2, 'calls'); + + const data = exportGraphSON(db); + const vertex = data.vertices.find((v) => v.properties.name[0].value === 'doWork'); + expect(vertex).toBeDefined(); + expect(vertex.properties.name).toEqual([{ id: 0, value: 'doWork' }]); + expect(vertex.label).toBe('function'); + db.close(); + }); + + it('has inV and outV on edges', () => { + const db = createTestDb(); + const fn = insertNode(db, 'doWork', 'function', 'src/a.js', 5); + const fn2 = insertNode(db, 'helper', 'function', 'src/b.js', 10); + insertEdge(db, fn, fn2, 'calls'); + + const data = exportGraphSON(db); + expect(data.edges.length).toBeGreaterThanOrEqual(1); + const edge = data.edges[0]; + expect(edge).toHaveProperty('inV'); + expect(edge).toHaveProperty('outV'); + expect(edge).toHaveProperty('label'); + expect(edge).toHaveProperty('properties'); + db.close(); + }); + + it('includes confidence in edge properties', () => { + const db = createTestDb(); + const fn = insertNode(db, 'doWork', 'function', 'src/a.js', 5); + const fn2 = insertNode(db, 'helper', 'function', 'src/b.js', 10); + insertEdge(db, fn, fn2, 'calls'); + + const data = exportGraphSON(db); + const edge = data.edges[0]; + expect(edge.properties).toHaveProperty('confidence'); + expect(edge.properties.confidence).toBe(1.0); + db.close(); + }); +}); + +describe('exportNeo4jCSV', () => { + it('returns object with nodes and relationships strings', () => { + const db = createTestDb(); + const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0); + const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0); + insertEdge(db, a, b, 'imports'); + + const csv = exportNeo4jCSV(db); + expect(csv).toHaveProperty('nodes'); + expect(csv).toHaveProperty('relationships'); + expect(typeof csv.nodes).toBe('string'); + expect(typeof csv.relationships).toBe('string'); + db.close(); + }); + + it('has correct CSV headers for file-level', () => { + const db = createTestDb(); + const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0); + const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0); + insertEdge(db, a, b, 'imports'); + + const csv = exportNeo4jCSV(db); + expect(csv.nodes.split('\n')[0]).toBe('nodeId:ID,name,file:string,:LABEL'); + expect(csv.relationships.split('\n')[0]).toBe(':START_ID,:END_ID,:TYPE,confidence:float'); + db.close(); + }); + + it('capitalizes kind to Label for function-level', () => { + const db = createTestDb(); + const fnA = insertNode(db, 'doWork', 'function', 'src/a.js', 5); + const fnB = insertNode(db, 'helper', 'function', 'src/b.js', 10); + insertEdge(db, fnA, fnB, 'calls'); + + const csv = exportNeo4jCSV(db, { fileLevel: false }); + expect(csv.nodes).toContain(',Function'); + db.close(); + }); + + it('uppercases edge type and replaces hyphens', () => { + const db = createTestDb(); + const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0); + const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0); + insertEdge(db, a, b, 'imports-type'); + + const csv = exportNeo4jCSV(db); + expect(csv.relationships).toContain('IMPORTS_TYPE'); + db.close(); + }); + + it('has correct function-level CSV headers', () => { + const db = createTestDb(); + const fnA = insertNode(db, 'doWork', 'function', 'src/a.js', 5); + const fnB = insertNode(db, 'helper', 'function', 'src/b.js', 10); + insertEdge(db, fnA, fnB, 'calls'); + + const csv = exportNeo4jCSV(db, { fileLevel: false }); + expect(csv.nodes.split('\n')[0]).toBe('nodeId:ID,name,kind,file:string,line:int,role,:LABEL'); + db.close(); + }); +}); diff --git a/tests/graph/viewer.test.js b/tests/graph/viewer.test.js new file mode 100644 index 00000000..8573c2ab --- /dev/null +++ b/tests/graph/viewer.test.js @@ -0,0 +1,114 @@ +/** + * Interactive HTML viewer tests. + */ + +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; +import { initSchema } from '../../src/db.js'; +import { generatePlotHTML, loadPlotConfig } from '../../src/viewer.js'; + +function createTestDb() { + const db = new Database(':memory:'); + db.pragma('journal_mode = WAL'); + initSchema(db); + return db; +} + +function insertNode(db, name, kind, file, line) { + return db + .prepare('INSERT INTO nodes (name, kind, file, line) VALUES (?, ?, ?, ?)') + .run(name, kind, file, line).lastInsertRowid; +} + +function insertEdge(db, sourceId, targetId, kind) { + db.prepare( + 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, 1.0, 0)', + ).run(sourceId, targetId, kind); +} + +describe('generatePlotHTML', () => { + it('returns a valid HTML document', () => { + const db = createTestDb(); + const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0); + const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0); + insertEdge(db, a, b, 'imports'); + + const html = generatePlotHTML(db); + expect(html).toContain(''); + expect(html).toContain(''); + db.close(); + }); + + it('embeds graph data as JSON', () => { + const db = createTestDb(); + const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0); + const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0); + insertEdge(db, a, b, 'imports'); + + const html = generatePlotHTML(db); + expect(html).toContain('var graphNodes ='); + expect(html).toContain('var graphEdges ='); + expect(html).toContain('a.js'); + expect(html).toContain('b.js'); + db.close(); + }); + + it('includes vis-network CDN script', () => { + const db = createTestDb(); + const html = generatePlotHTML(db); + expect(html).toContain('vis-network'); + expect(html).toContain('unpkg.com'); + db.close(); + }); + + it('applies custom config title', () => { + const db = createTestDb(); + const html = generatePlotHTML(db, { + config: { + title: 'My Custom Graph', + layout: { algorithm: 'hierarchical', direction: 'LR' }, + physics: { enabled: true, nodeDistance: 150 }, + nodeColors: {}, + roleColors: {}, + colorBy: 'kind', + edgeStyle: { color: '#666', smooth: true }, + filter: { kinds: null, roles: null, files: null }, + }, + }); + expect(html).toContain('My Custom Graph'); + db.close(); + }); + + it('handles empty graph without error', () => { + const db = createTestDb(); + const html = generatePlotHTML(db); + expect(html).toContain(''); + expect(html).toContain('var graphNodes = []'); + expect(html).toContain('var graphEdges = []'); + db.close(); + }); + + it('supports function-level mode', () => { + const db = createTestDb(); + const fnA = insertNode(db, 'doWork', 'function', 'src/a.js', 5); + const fnB = insertNode(db, 'helper', 'function', 'src/b.js', 10); + insertEdge(db, fnA, fnB, 'calls'); + + const html = generatePlotHTML(db, { fileLevel: false }); + expect(html).toContain('doWork'); + expect(html).toContain('helper'); + db.close(); + }); +}); + +describe('loadPlotConfig', () => { + it('returns default config when no config file exists', () => { + const cfg = loadPlotConfig('/nonexistent/path'); + expect(cfg).toHaveProperty('layout'); + expect(cfg).toHaveProperty('physics'); + expect(cfg).toHaveProperty('nodeColors'); + expect(cfg.layout.algorithm).toBe('hierarchical'); + expect(cfg.title).toBe('Codegraph'); + }); +}); From e29dfa57c2dc4dcde3159f18361c8d6a1fed4fe4 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:23:02 -0700 Subject: [PATCH 2/5] feat(plot): add drill-down, clustering, complexity overlays, and detail panel Evolve the plot command from a static viewer into an interactive exploration tool with rich data overlays and navigation. Data preparation: - Extract prepareGraphData() with complexity, fan-in/fan-out, Louvain community detection, directory derivation, and risk flag computation - Seed strategies: all (default), top-fanin, entry Interactive features: - Detail sidebar: metrics, callers/callees lists, risk badges - Drill-down: click-to-expand / double-click-to-collapse neighbors - Clustering: community and directory grouping via vis-network API - Color by: kind, role, community, complexity (MI-based borders) - Size by: uniform, fan-in, fan-out, complexity - Risk overlay: dead-code (dashed), high-blast-radius (shadow), low-MI CLI options: - --cluster, --overlay, --seed, --seed-count, --size-by, --color-by Tests expanded from 7 to 21 covering all new data enrichment, seed strategies, risk flags, UI elements, and config backward compatibility. Impact: 5 functions changed, 3 affected --- src/cli.js | 19 + src/viewer.js | 876 +++++++++++++++++++++++++++++++------ tests/graph/viewer.test.js | 262 ++++++++++- 3 files changed, 1017 insertions(+), 140 deletions(-) diff --git a/src/cli.js b/src/cli.js index 06cb4c9e..aa8c55cc 100644 --- a/src/cli.js +++ b/src/cli.js @@ -528,6 +528,12 @@ program .option('-o, --output ', 'Write HTML to file') .option('-c, --config ', 'Path to .plotDotCfg config file') .option('--no-open', 'Do not open in browser') + .option('--cluster ', 'Cluster nodes: none | community | directory') + .option('--overlay ', 'Comma-separated overlays: complexity,risk') + .option('--seed ', 'Seed strategy: all | top-fanin | entry') + .option('--seed-count ', 'Number of seed nodes (default: 30)') + .option('--size-by ', 'Size nodes by: uniform | fan-in | fan-out | complexity') + .option('--color-by ', 'Color nodes by: kind | role | community | complexity') .action(async (opts) => { const { generatePlotHTML, loadPlotConfig } = await import('./viewer.js'); const { exec } = await import('node:child_process'); @@ -548,6 +554,19 @@ program plotCfg = loadPlotConfig(process.cwd()); } + // Merge CLI flags into config + if (opts.cluster) plotCfg.clusterBy = opts.cluster; + if (opts.colorBy) plotCfg.colorBy = opts.colorBy; + if (opts.sizeBy) plotCfg.sizeBy = opts.sizeBy; + if (opts.seed) plotCfg.seedStrategy = opts.seed; + if (opts.seedCount) plotCfg.seedCount = parseInt(opts.seedCount, 10); + if (opts.overlay) { + const parts = opts.overlay.split(',').map((s) => s.trim()); + if (!plotCfg.overlays) plotCfg.overlays = {}; + if (parts.includes('complexity')) plotCfg.overlays.complexity = true; + if (parts.includes('risk')) plotCfg.overlays.risk = true; + } + const html = generatePlotHTML(db, { fileLevel: !opts.functions, noTests: resolveNoTests(opts), diff --git a/src/viewer.js b/src/viewer.js index b213651b..3b714468 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -1,5 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import Graph from 'graphology'; +import louvain from 'graphology-communities-louvain'; import { isTestFile } from './queries.js'; const DEFAULT_MIN_CONFIDENCE = 0.5; @@ -26,6 +28,21 @@ const DEFAULT_ROLE_COLORS = { leaf: '#fffde7', }; +const COMMUNITY_COLORS = [ + '#4CAF50', + '#2196F3', + '#FF9800', + '#9C27B0', + '#F44336', + '#00BCD4', + '#CDDC39', + '#E91E63', + '#3F51B5', + '#FF5722', + '#009688', + '#795548', +]; + const DEFAULT_CONFIG = { layout: { algorithm: 'hierarchical', direction: 'LR' }, physics: { enabled: true, nodeDistance: 150 }, @@ -35,6 +52,12 @@ const DEFAULT_CONFIG = { edgeStyle: { color: '#666', smooth: true }, filter: { kinds: null, roles: null, files: null }, title: 'Codegraph', + seedStrategy: 'all', + seedCount: 30, + clusterBy: 'none', + sizeBy: 'uniform', + overlays: { complexity: false, risk: false }, + riskThresholds: { highBlastRadius: 10, lowMI: 40 }, }; /** @@ -52,10 +75,27 @@ export function loadPlotConfig(dir) { ...raw, layout: { ...DEFAULT_CONFIG.layout, ...(raw.layout || {}) }, physics: { ...DEFAULT_CONFIG.physics, ...(raw.physics || {}) }, - nodeColors: { ...DEFAULT_CONFIG.nodeColors, ...(raw.nodeColors || {}) }, - roleColors: { ...DEFAULT_CONFIG.roleColors, ...(raw.roleColors || {}) }, - edgeStyle: { ...DEFAULT_CONFIG.edgeStyle, ...(raw.edgeStyle || {}) }, + nodeColors: { + ...DEFAULT_CONFIG.nodeColors, + ...(raw.nodeColors || {}), + }, + roleColors: { + ...DEFAULT_CONFIG.roleColors, + ...(raw.roleColors || {}), + }, + edgeStyle: { + ...DEFAULT_CONFIG.edgeStyle, + ...(raw.edgeStyle || {}), + }, filter: { ...DEFAULT_CONFIG.filter, ...(raw.filter || {}) }, + overlays: { + ...DEFAULT_CONFIG.overlays, + ...(raw.overlays || {}), + }, + riskThresholds: { + ...DEFAULT_CONFIG.riskThresholds, + ...(raw.riskThresholds || {}), + }, }; } catch { // Invalid JSON — use defaults @@ -65,55 +105,26 @@ export function loadPlotConfig(dir) { return { ...DEFAULT_CONFIG }; } +// ─── Data Preparation ───────────────────────────────────────────────── + /** - * Generate a self-contained interactive HTML file with vis-network. + * Prepare enriched graph data for the HTML viewer. */ -export function generatePlotHTML(db, opts = {}) { +export function prepareGraphData(db, opts = {}) { const fileLevel = opts.fileLevel !== false; const noTests = opts.noTests || false; const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE; const cfg = opts.config || DEFAULT_CONFIG; - let visNodes; - let visEdges; - - if (fileLevel) { - let edges = db - .prepare(` - SELECT DISTINCT n1.file AS source, n2.file AS target - FROM edges e - JOIN nodes n1 ON e.source_id = n1.id - JOIN nodes n2 ON e.target_id = n2.id - WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls') - AND e.confidence >= ? - `) - .all(minConf); - if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target)); - - const files = new Set(); - for (const { source, target } of edges) { - files.add(source); - files.add(target); - } - - const fileIds = new Map(); - let idx = 0; - for (const f of files) fileIds.set(f, idx++); - - visNodes = [...files].map((f) => ({ - id: fileIds.get(f), - label: path.basename(f), - title: f, - color: cfg.nodeColors.file || DEFAULT_NODE_COLORS.file, - })); + return fileLevel + ? prepareFileLevelData(db, noTests, minConf, cfg) + : prepareFunctionLevelData(db, noTests, minConf, cfg); +} - visEdges = edges.map(({ source, target }) => ({ - from: fileIds.get(source), - to: fileIds.get(target), - })); - } else { - let edges = db - .prepare(` +function prepareFunctionLevelData(db, noTests, minConf, cfg) { + let edges = db + .prepare( + ` SELECT n1.id AS source_id, n1.name AS source_name, n1.kind AS source_kind, n1.file AS source_file, n1.line AS source_line, n1.role AS source_role, n2.id AS target_id, n2.name AS target_name, n2.kind AS target_kind, @@ -126,83 +137,286 @@ export function generatePlotHTML(db, opts = {}) { AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module') AND e.kind = 'calls' AND e.confidence >= ? - `) - .all(minConf); - if (noTests) - edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file)); - - // Apply filters - if (cfg.filter.kinds) { - const kinds = new Set(cfg.filter.kinds); - edges = edges.filter((e) => kinds.has(e.source_kind) && kinds.has(e.target_kind)); + `, + ) + .all(minConf); + if (noTests) + edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file)); + + if (cfg.filter.kinds) { + const kinds = new Set(cfg.filter.kinds); + edges = edges.filter((e) => kinds.has(e.source_kind) && kinds.has(e.target_kind)); + } + if (cfg.filter.files) { + const patterns = cfg.filter.files; + edges = edges.filter( + (e) => + patterns.some((p) => e.source_file.includes(p)) && + patterns.some((p) => e.target_file.includes(p)), + ); + } + + const nodeMap = new Map(); + for (const e of edges) { + if (!nodeMap.has(e.source_id)) { + nodeMap.set(e.source_id, { + id: e.source_id, + name: e.source_name, + kind: e.source_kind, + file: e.source_file, + line: e.source_line, + role: e.source_role, + }); + } + if (!nodeMap.has(e.target_id)) { + nodeMap.set(e.target_id, { + id: e.target_id, + name: e.target_name, + kind: e.target_kind, + file: e.target_file, + line: e.target_line, + role: e.target_role, + }); } - if (cfg.filter.files) { - const patterns = cfg.filter.files; - edges = edges.filter( - (e) => - patterns.some((p) => e.source_file.includes(p)) && - patterns.some((p) => e.target_file.includes(p)), - ); + } + + if (cfg.filter.roles) { + const roles = new Set(cfg.filter.roles); + for (const [id, n] of nodeMap) { + if (!roles.has(n.role)) nodeMap.delete(id); } + const nodeIds = new Set(nodeMap.keys()); + edges = edges.filter((e) => nodeIds.has(e.source_id) && nodeIds.has(e.target_id)); + } - const nodeMap = new Map(); - for (const e of edges) { - if (!nodeMap.has(e.source_id)) { - nodeMap.set(e.source_id, { - id: e.source_id, - name: e.source_name, - kind: e.source_kind, - file: e.source_file, - line: e.source_line, - role: e.source_role, - }); - } - if (!nodeMap.has(e.target_id)) { - nodeMap.set(e.target_id, { - id: e.target_id, - name: e.target_name, - kind: e.target_kind, - file: e.target_file, - line: e.target_line, - role: e.target_role, - }); - } + // Complexity data + const complexityMap = new Map(); + try { + const rows = db + .prepare( + 'SELECT node_id, cognitive, cyclomatic, max_nesting, maintainability_index FROM function_complexity', + ) + .all(); + for (const r of rows) { + complexityMap.set(r.node_id, { + cognitive: r.cognitive, + cyclomatic: r.cyclomatic, + maintainabilityIndex: r.maintainability_index, + }); } + } catch { + // table may not exist in old DBs + } - if (cfg.filter.roles) { - const roles = new Set(cfg.filter.roles); - for (const [id, n] of nodeMap) { - if (!roles.has(n.role)) nodeMap.delete(id); + // Fan-in / fan-out + const fanInMap = new Map(); + const fanOutMap = new Map(); + const fanInRows = db + .prepare( + "SELECT target_id AS node_id, COUNT(*) AS fan_in FROM edges WHERE kind = 'calls' GROUP BY target_id", + ) + .all(); + for (const r of fanInRows) fanInMap.set(r.node_id, r.fan_in); + + const fanOutRows = db + .prepare( + "SELECT source_id AS node_id, COUNT(*) AS fan_out FROM edges WHERE kind = 'calls' GROUP BY source_id", + ) + .all(); + for (const r of fanOutRows) fanOutMap.set(r.node_id, r.fan_out); + + // Communities (Louvain) + const communityMap = new Map(); + if (nodeMap.size > 0) { + try { + const graph = new Graph({ type: 'undirected' }); + for (const [id] of nodeMap) graph.addNode(String(id)); + for (const e of edges) { + const src = String(e.source_id); + const tgt = String(e.target_id); + if (src !== tgt && !graph.hasEdge(src, tgt)) graph.addEdge(src, tgt); } - const nodeIds = new Set(nodeMap.keys()); - edges = edges.filter((e) => nodeIds.has(e.source_id) && nodeIds.has(e.target_id)); + const communities = louvain(graph); + for (const [nid, cid] of Object.entries(communities)) communityMap.set(Number(nid), cid); + } catch { + // louvain can fail on disconnected graphs } + } - visNodes = [...nodeMap.values()].map((n) => { - const color = - cfg.colorBy === 'role' && n.role - ? cfg.roleColors[n.role] || DEFAULT_ROLE_COLORS[n.role] || '#ccc' + // Build enriched nodes + const visNodes = [...nodeMap.values()].map((n) => { + const cx = complexityMap.get(n.id) || null; + const fanIn = fanInMap.get(n.id) || 0; + const fanOut = fanOutMap.get(n.id) || 0; + const community = communityMap.get(n.id) ?? null; + const directory = path.dirname(n.file); + const risk = []; + if (n.role === 'dead') risk.push('dead-code'); + if (fanIn >= (cfg.riskThresholds?.highBlastRadius ?? 10)) risk.push('high-blast-radius'); + if (cx && cx.maintainabilityIndex < (cfg.riskThresholds?.lowMI ?? 40)) risk.push('low-mi'); + + const color = + cfg.colorBy === 'role' && n.role + ? cfg.roleColors[n.role] || DEFAULT_ROLE_COLORS[n.role] || '#ccc' + : cfg.colorBy === 'community' && community !== null + ? COMMUNITY_COLORS[community % COMMUNITY_COLORS.length] : cfg.nodeColors[n.kind] || DEFAULT_NODE_COLORS[n.kind] || '#ccc'; - return { - id: n.id, - label: n.name, - title: `${n.file}:${n.line} (${n.kind}${n.role ? `, ${n.role}` : ''})`, - color, - kind: n.kind, - role: n.role || '', - file: n.file, - }; - }); - visEdges = edges.map((e) => ({ - from: e.source_id, - to: e.target_id, - })); + return { + id: n.id, + label: n.name, + title: `${n.file}:${n.line} (${n.kind}${n.role ? `, ${n.role}` : ''})`, + color, + kind: n.kind, + role: n.role || '', + file: n.file, + line: n.line, + community, + cognitive: cx?.cognitive ?? null, + cyclomatic: cx?.cyclomatic ?? null, + maintainabilityIndex: cx?.maintainabilityIndex ?? null, + fanIn, + fanOut, + directory, + risk, + }; + }); + + const visEdges = edges.map((e, i) => ({ + id: `e${i}`, + from: e.source_id, + to: e.target_id, + })); + + // Seed strategy + let seedNodeIds; + if (cfg.seedStrategy === 'top-fanin') { + const sorted = [...visNodes].sort((a, b) => b.fanIn - a.fanIn); + seedNodeIds = sorted.slice(0, cfg.seedCount || 30).map((n) => n.id); + } else if (cfg.seedStrategy === 'entry') { + seedNodeIds = visNodes.filter((n) => n.role === 'entry').map((n) => n.id); + } else { + seedNodeIds = visNodes.map((n) => n.id); + } + + return { nodes: visNodes, edges: visEdges, seedNodeIds }; +} + +function prepareFileLevelData(db, noTests, minConf, cfg) { + let edges = db + .prepare( + ` + SELECT DISTINCT n1.file AS source, n2.file AS target + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls') + AND e.confidence >= ? + `, + ) + .all(minConf); + if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target)); + + const files = new Set(); + for (const { source, target } of edges) { + files.add(source); + files.add(target); + } + + const fileIds = new Map(); + let idx = 0; + for (const f of files) fileIds.set(f, idx++); + + // Fan-in/fan-out + const fanInCount = new Map(); + const fanOutCount = new Map(); + for (const { source, target } of edges) { + fanOutCount.set(source, (fanOutCount.get(source) || 0) + 1); + fanInCount.set(target, (fanInCount.get(target) || 0) + 1); + } + + // Communities + const communityMap = new Map(); + if (files.size > 0) { + try { + const graph = new Graph({ type: 'undirected' }); + for (const f of files) graph.addNode(f); + for (const { source, target } of edges) { + if (source !== target && !graph.hasEdge(source, target)) graph.addEdge(source, target); + } + const communities = louvain(graph); + for (const [file, cid] of Object.entries(communities)) communityMap.set(file, cid); + } catch { + // ignore + } } + const visNodes = [...files].map((f) => { + const id = fileIds.get(f); + const community = communityMap.get(f) ?? null; + const fanIn = fanInCount.get(f) || 0; + const fanOut = fanOutCount.get(f) || 0; + const directory = path.dirname(f); + const color = + cfg.colorBy === 'community' && community !== null + ? COMMUNITY_COLORS[community % COMMUNITY_COLORS.length] + : cfg.nodeColors.file || DEFAULT_NODE_COLORS.file; + + return { + id, + label: path.basename(f), + title: f, + color, + kind: 'file', + role: '', + file: f, + line: 0, + community, + cognitive: null, + cyclomatic: null, + maintainabilityIndex: null, + fanIn, + fanOut, + directory, + risk: [], + }; + }); + + const visEdges = edges.map(({ source, target }, i) => ({ + id: `e${i}`, + from: fileIds.get(source), + to: fileIds.get(target), + })); + + let seedNodeIds; + if (cfg.seedStrategy === 'top-fanin') { + const sorted = [...visNodes].sort((a, b) => b.fanIn - a.fanIn); + seedNodeIds = sorted.slice(0, cfg.seedCount || 30).map((n) => n.id); + } else if (cfg.seedStrategy === 'entry') { + seedNodeIds = visNodes.map((n) => n.id); + } else { + seedNodeIds = visNodes.map((n) => n.id); + } + + return { nodes: visNodes, edges: visEdges, seedNodeIds }; +} + +// ─── HTML Generation ────────────────────────────────────────────────── + +/** + * Generate a self-contained interactive HTML file with vis-network. + */ +export function generatePlotHTML(db, opts = {}) { + const cfg = opts.config || DEFAULT_CONFIG; + const data = prepareGraphData(db, opts); const layoutOpts = buildLayoutOptions(cfg); const title = cfg.title || 'Codegraph'; + // Resolve effective colorBy (overlays.complexity overrides) + const effectiveColorBy = + cfg.overlays?.complexity && cfg.colorBy === 'kind' ? 'complexity' : cfg.colorBy || 'kind'; + const effectiveRisk = cfg.overlays?.risk || false; + return ` @@ -215,11 +429,29 @@ export function generatePlotHTML(db, opts = {}) { body { font-family: monospace; background: #fafafa; } #controls { padding: 8px 12px; background: #fff; border-bottom: 1px solid #ddd; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } #controls label { font-size: 13px; } - #controls select, #controls input { font-size: 13px; padding: 2px 6px; } - #graph { width: 100%; height: calc(100vh - 80px); } - #legend { position: absolute; bottom: 12px; right: 12px; background: rgba(255,255,255,0.95); border: 1px solid #ddd; border-radius: 4px; padding: 8px 12px; font-size: 12px; } + #controls select, #controls input[type="text"] { font-size: 13px; padding: 2px 6px; } + #main { display: flex; height: calc(100vh - 44px); } + #graph { flex: 1; } + #detail { width: 320px; border-left: 1px solid #ddd; background: #fff; overflow-y: auto; display: none; padding: 12px; font-size: 13px; } + #detail h3 { margin-bottom: 6px; word-break: break-all; } + #detailClose { float: right; cursor: pointer; font-size: 18px; color: #999; line-height: 1; } + #detailClose:hover { color: #333; } + .detail-meta { margin-bottom: 4px; } + .detail-file { color: #666; margin-bottom: 10px; font-size: 12px; } + .detail-section { margin-bottom: 10px; } + .detail-section table { width: 100%; border-collapse: collapse; } + .detail-section td { padding: 2px 8px 2px 0; } + .detail-section ul { list-style: none; padding: 0; } + .detail-section li { padding: 2px 0; } + .detail-section a { color: #1976D2; text-decoration: none; cursor: pointer; } + .detail-section a:hover { text-decoration: underline; } + .badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 11px; margin-right: 4px; } + .kind-badge { background: #E3F2FD; color: #1565C0; } + .role-badge { background: #E8F5E9; color: #2E7D32; } + .risk-badge { background: #FFEBEE; color: #C62828; } + #legend { position: absolute; bottom: 12px; right: 12px; background: rgba(255,255,255,0.95); border: 1px solid #ddd; border-radius: 4px; padding: 8px 12px; font-size: 12px; max-height: 300px; overflow-y: auto; } #legend div { display: flex; align-items: center; gap: 6px; margin: 2px 0; } - #legend span.swatch { width: 14px; height: 14px; border-radius: 3px; display: inline-block; } + #legend span.swatch { width: 14px; height: 14px; border-radius: 3px; display: inline-block; flex-shrink: 0; } @@ -233,32 +465,385 @@ export function generatePlotHTML(db, opts = {}) { + + + + + +
+
+
+ × +
+
-
`; } +// ─── Internal Helpers ───────────────────────────────────────────────── + function escapeHtml(s) { return String(s) .replace(/&/g, '&') diff --git a/tests/graph/viewer.test.js b/tests/graph/viewer.test.js index 8573c2ab..0ace2b01 100644 --- a/tests/graph/viewer.test.js +++ b/tests/graph/viewer.test.js @@ -5,7 +5,7 @@ import Database from 'better-sqlite3'; import { describe, expect, it } from 'vitest'; import { initSchema } from '../../src/db.js'; -import { generatePlotHTML, loadPlotConfig } from '../../src/viewer.js'; +import { generatePlotHTML, loadPlotConfig, prepareGraphData } from '../../src/viewer.js'; function createTestDb() { const db = new Database(':memory:'); @@ -14,10 +14,10 @@ function createTestDb() { return db; } -function insertNode(db, name, kind, file, line) { +function insertNode(db, name, kind, file, line, role) { return db - .prepare('INSERT INTO nodes (name, kind, file, line) VALUES (?, ?, ?, ?)') - .run(name, kind, file, line).lastInsertRowid; + .prepare('INSERT INTO nodes (name, kind, file, line, role) VALUES (?, ?, ?, ?, ?)') + .run(name, kind, file, line, role || null).lastInsertRowid; } function insertEdge(db, sourceId, targetId, kind) { @@ -26,6 +26,12 @@ function insertEdge(db, sourceId, targetId, kind) { ).run(sourceId, targetId, kind); } +function insertComplexity(db, nodeId, cognitive, cyclomatic, mi) { + db.prepare( + 'INSERT INTO function_complexity (node_id, cognitive, cyclomatic, max_nesting, maintainability_index) VALUES (?, ?, ?, 2, ?)', + ).run(nodeId, cognitive, cyclomatic, mi); +} + describe('generatePlotHTML', () => { it('returns a valid HTML document', () => { const db = createTestDb(); @@ -47,8 +53,8 @@ describe('generatePlotHTML', () => { insertEdge(db, a, b, 'imports'); const html = generatePlotHTML(db); - expect(html).toContain('var graphNodes ='); - expect(html).toContain('var graphEdges ='); + expect(html).toContain('var allNodes ='); + expect(html).toContain('var allEdges ='); expect(html).toContain('a.js'); expect(html).toContain('b.js'); db.close(); @@ -74,6 +80,12 @@ describe('generatePlotHTML', () => { colorBy: 'kind', edgeStyle: { color: '#666', smooth: true }, filter: { kinds: null, roles: null, files: null }, + seedStrategy: 'all', + seedCount: 30, + clusterBy: 'none', + sizeBy: 'uniform', + overlays: { complexity: false, risk: false }, + riskThresholds: { highBlastRadius: 10, lowMI: 40 }, }, }); expect(html).toContain('My Custom Graph'); @@ -84,8 +96,8 @@ describe('generatePlotHTML', () => { const db = createTestDb(); const html = generatePlotHTML(db); expect(html).toContain(''); - expect(html).toContain('var graphNodes = []'); - expect(html).toContain('var graphEdges = []'); + expect(html).toContain('var allNodes = []'); + expect(html).toContain('var allEdges = []'); db.close(); }); @@ -100,6 +112,227 @@ describe('generatePlotHTML', () => { expect(html).toContain('helper'); db.close(); }); + + it('includes detail panel elements', () => { + const db = createTestDb(); + const html = generatePlotHTML(db); + expect(html).toContain('id="detail"'); + expect(html).toContain('id="detailContent"'); + expect(html).toContain('id="detailClose"'); + db.close(); + }); + + it('includes new control elements', () => { + const db = createTestDb(); + const html = generatePlotHTML(db); + expect(html).toContain('id="colorBySelect"'); + expect(html).toContain('id="sizeBySelect"'); + expect(html).toContain('id="clusterBySelect"'); + expect(html).toContain('id="riskToggle"'); + db.close(); + }); +}); + +describe('prepareGraphData', () => { + it('embeds complexity data into function-level nodes', () => { + const db = createTestDb(); + const fnA = insertNode(db, 'doWork', 'function', 'src/a.js', 5); + const fnB = insertNode(db, 'helper', 'function', 'src/b.js', 10); + insertEdge(db, fnA, fnB, 'calls'); + insertComplexity(db, fnA, 8, 5, 72.3); + insertComplexity(db, fnB, 2, 1, 95.0); + + const data = prepareGraphData(db, { fileLevel: false }); + const nodeA = data.nodes.find((n) => n.label === 'doWork'); + const nodeB = data.nodes.find((n) => n.label === 'helper'); + + expect(nodeA.cognitive).toBe(8); + expect(nodeA.cyclomatic).toBe(5); + expect(nodeA.maintainabilityIndex).toBeCloseTo(72.3, 1); + expect(nodeB.cognitive).toBe(2); + expect(nodeB.cyclomatic).toBe(1); + expect(nodeB.maintainabilityIndex).toBeCloseTo(95.0, 1); + db.close(); + }); + + it('computes fan-in and fan-out', () => { + const db = createTestDb(); + const fnA = insertNode(db, 'caller1', 'function', 'src/a.js', 1); + const fnB = insertNode(db, 'caller2', 'function', 'src/a.js', 10); + const fnC = insertNode(db, 'target', 'function', 'src/b.js', 1); + insertEdge(db, fnA, fnC, 'calls'); + insertEdge(db, fnB, fnC, 'calls'); + + const data = prepareGraphData(db, { fileLevel: false }); + const target = data.nodes.find((n) => n.label === 'target'); + const caller1 = data.nodes.find((n) => n.label === 'caller1'); + + expect(target.fanIn).toBe(2); + expect(caller1.fanOut).toBe(1); + db.close(); + }); + + it('assigns community IDs via Louvain', () => { + const db = createTestDb(); + // Create two clusters of nodes + const a1 = insertNode(db, 'a1', 'function', 'src/a.js', 1); + const a2 = insertNode(db, 'a2', 'function', 'src/a.js', 10); + const b1 = insertNode(db, 'b1', 'function', 'src/b.js', 1); + const b2 = insertNode(db, 'b2', 'function', 'src/b.js', 10); + insertEdge(db, a1, a2, 'calls'); + insertEdge(db, a2, a1, 'calls'); + insertEdge(db, b1, b2, 'calls'); + insertEdge(db, b2, b1, 'calls'); + // One cross-cluster edge + insertEdge(db, a1, b1, 'calls'); + + const data = prepareGraphData(db, { fileLevel: false }); + for (const n of data.nodes) { + expect(n.community).not.toBeNull(); + expect(typeof n.community).toBe('number'); + } + db.close(); + }); + + it('flags dead-code nodes as risk', () => { + const db = createTestDb(); + const fnA = insertNode(db, 'alive', 'function', 'src/a.js', 1, 'core'); + const fnB = insertNode(db, 'dead', 'function', 'src/b.js', 1, 'dead'); + insertEdge(db, fnA, fnB, 'calls'); + + const data = prepareGraphData(db, { fileLevel: false }); + const deadNode = data.nodes.find((n) => n.label === 'dead'); + expect(deadNode.risk).toContain('dead-code'); + + const aliveNode = data.nodes.find((n) => n.label === 'alive'); + expect(aliveNode.risk).not.toContain('dead-code'); + db.close(); + }); + + it('flags high-blast-radius nodes', () => { + const db = createTestDb(); + const target = insertNode(db, 'popular', 'function', 'src/a.js', 1); + // Create 10 callers to exceed default threshold + for (let i = 0; i < 10; i++) { + const caller = insertNode(db, `caller${i}`, 'function', 'src/c.js', i + 1); + insertEdge(db, caller, target, 'calls'); + } + + const data = prepareGraphData(db, { fileLevel: false }); + const popularNode = data.nodes.find((n) => n.label === 'popular'); + expect(popularNode.risk).toContain('high-blast-radius'); + expect(popularNode.fanIn).toBe(10); + db.close(); + }); + + it('flags low-mi nodes', () => { + const db = createTestDb(); + const fnA = insertNode(db, 'messy', 'function', 'src/a.js', 1); + const fnB = insertNode(db, 'clean', 'function', 'src/b.js', 1); + insertEdge(db, fnA, fnB, 'calls'); + insertComplexity(db, fnA, 30, 20, 25.0); // MI < 40 + insertComplexity(db, fnB, 2, 1, 90.0); // MI >= 40 + + const data = prepareGraphData(db, { fileLevel: false }); + const messy = data.nodes.find((n) => n.label === 'messy'); + const clean = data.nodes.find((n) => n.label === 'clean'); + expect(messy.risk).toContain('low-mi'); + expect(clean.risk).not.toContain('low-mi'); + db.close(); + }); + + it('seed strategy top-fanin limits seed count', () => { + const db = createTestDb(); + const nodes = []; + for (let i = 0; i < 5; i++) { + nodes.push(insertNode(db, `fn${i}`, 'function', 'src/a.js', i + 1)); + } + // fn0 calls all others → they all get fan-in + for (let i = 1; i < 5; i++) { + insertEdge(db, nodes[0], nodes[i], 'calls'); + } + + const data = prepareGraphData(db, { + fileLevel: false, + config: { + seedStrategy: 'top-fanin', + seedCount: 2, + colorBy: 'kind', + nodeColors: {}, + roleColors: {}, + filter: { kinds: null, roles: null, files: null }, + edgeStyle: { color: '#666', smooth: true }, + riskThresholds: { highBlastRadius: 10, lowMI: 40 }, + overlays: {}, + }, + }); + expect(data.seedNodeIds).toHaveLength(2); + db.close(); + }); + + it('seed strategy entry selects only entry nodes', () => { + const db = createTestDb(); + const fnA = insertNode(db, 'entryFn', 'function', 'src/a.js', 1, 'entry'); + const fnB = insertNode(db, 'coreFn', 'function', 'src/b.js', 1, 'core'); + insertEdge(db, fnA, fnB, 'calls'); + + const data = prepareGraphData(db, { + fileLevel: false, + config: { + seedStrategy: 'entry', + seedCount: 30, + colorBy: 'kind', + nodeColors: {}, + roleColors: {}, + filter: { kinds: null, roles: null, files: null }, + edgeStyle: { color: '#666', smooth: true }, + riskThresholds: { highBlastRadius: 10, lowMI: 40 }, + overlays: {}, + }, + }); + expect(data.seedNodeIds).toHaveLength(1); + expect(data.seedNodeIds[0]).toBe(Number(fnA)); + db.close(); + }); + + it('seed strategy all (default) includes all nodes', () => { + const db = createTestDb(); + const fnA = insertNode(db, 'fn1', 'function', 'src/a.js', 1); + const fnB = insertNode(db, 'fn2', 'function', 'src/b.js', 1); + insertEdge(db, fnA, fnB, 'calls'); + + const data = prepareGraphData(db, { fileLevel: false }); + expect(data.seedNodeIds).toHaveLength(data.nodes.length); + db.close(); + }); + + it('handles empty complexity table gracefully', () => { + const db = createTestDb(); + const fnA = insertNode(db, 'doWork', 'function', 'src/a.js', 5); + const fnB = insertNode(db, 'helper', 'function', 'src/b.js', 10); + insertEdge(db, fnA, fnB, 'calls'); + + const data = prepareGraphData(db, { fileLevel: false }); + const nodeA = data.nodes.find((n) => n.label === 'doWork'); + expect(nodeA.cognitive).toBeNull(); + expect(nodeA.cyclomatic).toBeNull(); + expect(nodeA.maintainabilityIndex).toBeNull(); + db.close(); + }); + + it('includes directory field derived from file path', () => { + const db = createTestDb(); + const fnA = insertNode(db, 'doWork', 'function', 'src/lib/a.js', 5); + const fnB = insertNode(db, 'helper', 'function', 'src/utils/b.js', 10); + insertEdge(db, fnA, fnB, 'calls'); + + const data = prepareGraphData(db, { fileLevel: false }); + const nodeA = data.nodes.find((n) => n.label === 'doWork'); + const nodeB = data.nodes.find((n) => n.label === 'helper'); + expect(nodeA.directory).toContain('lib'); + expect(nodeB.directory).toContain('utils'); + db.close(); + }); }); describe('loadPlotConfig', () => { @@ -111,4 +344,17 @@ describe('loadPlotConfig', () => { expect(cfg.layout.algorithm).toBe('hierarchical'); expect(cfg.title).toBe('Codegraph'); }); + + it('includes new config fields with defaults', () => { + const cfg = loadPlotConfig('/nonexistent/path'); + expect(cfg.seedStrategy).toBe('all'); + expect(cfg.seedCount).toBe(30); + expect(cfg.clusterBy).toBe('none'); + expect(cfg.sizeBy).toBe('uniform'); + expect(cfg.overlays).toEqual({ complexity: false, risk: false }); + expect(cfg.riskThresholds).toEqual({ + highBlastRadius: 10, + lowMI: 40, + }); + }); }); From e4958d47f0ac4ff0e570b495430f970e79f4be53 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:35:20 -0700 Subject: [PATCH 3/5] fix(test): update MCP export_graph enum to include new formats The previous commit added graphml, graphson, and neo4j export formats to the MCP tool definition but did not update the test assertion. --- tests/unit/mcp.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index 395878ec..d4cda5b1 100644 --- a/tests/unit/mcp.test.js +++ b/tests/unit/mcp.test.js @@ -165,7 +165,7 @@ describe('TOOLS', () => { it('export_graph requires format parameter with enum', () => { const eg = TOOLS.find((t) => t.name === 'export_graph'); expect(eg.inputSchema.required).toContain('format'); - expect(eg.inputSchema.properties.format.enum).toEqual(['dot', 'mermaid', 'json']); + expect(eg.inputSchema.properties.format.enum).toEqual(['dot', 'mermaid', 'json', 'graphml', 'graphson', 'neo4j']); expect(eg.inputSchema.properties).toHaveProperty('file_level'); }); From d01412e22d3ead47edf72502458ebcab50851fc0 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:35:38 -0700 Subject: [PATCH 4/5] style: format mcp test after enum update --- tests/unit/mcp.test.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index d4cda5b1..5ef738cf 100644 --- a/tests/unit/mcp.test.js +++ b/tests/unit/mcp.test.js @@ -165,7 +165,14 @@ describe('TOOLS', () => { it('export_graph requires format parameter with enum', () => { const eg = TOOLS.find((t) => t.name === 'export_graph'); expect(eg.inputSchema.required).toContain('format'); - expect(eg.inputSchema.properties.format.enum).toEqual(['dot', 'mermaid', 'json', 'graphml', 'graphson', 'neo4j']); + expect(eg.inputSchema.properties.format.enum).toEqual([ + 'dot', + 'mermaid', + 'json', + 'graphml', + 'graphson', + 'neo4j', + ]); expect(eg.inputSchema.properties).toHaveProperty('file_level'); }); From 15418fd26ccf46716eb2ce940efea7b6f075d32d Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:05:24 -0700 Subject: [PATCH 5/5] fix(security): escape config values in HTML template to prevent XSS Use JSON.stringify() for cfg.layout.direction, effectiveColorBy, and cfg.clusterBy when interpolated into inline JavaScript. Replace shell exec() with execFile() for browser-open to avoid path injection. Impact: 1 functions changed, 1 affected --- src/cli.js | 12 ++++++------ src/viewer.js | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/cli.js b/src/cli.js index aa8c55cc..f04789c7 100644 --- a/src/cli.js +++ b/src/cli.js @@ -536,7 +536,6 @@ program .option('--color-by ', 'Color nodes by: kind | role | community | complexity') .action(async (opts) => { const { generatePlotHTML, loadPlotConfig } = await import('./viewer.js'); - const { exec } = await import('node:child_process'); const os = await import('node:os'); const db = openReadonlyOrFail(opts.db); @@ -580,13 +579,14 @@ program console.log(`Plot written to ${outPath}`); if (opts.open !== false) { - const cmd = + const { execFile } = await import('node:child_process'); + const args = process.platform === 'win32' - ? `start "" "${outPath}"` + ? ['cmd', ['/c', 'start', '', outPath]] : process.platform === 'darwin' - ? `open "${outPath}"` - : `xdg-open "${outPath}"`; - exec(cmd, (err) => { + ? ['open', [outPath]] + : ['xdg-open', [outPath]]; + execFile(args[0], args[1], (err) => { if (err) console.error('Could not open browser:', err.message); }); } diff --git a/src/viewer.js b/src/viewer.js index 3b714468..c0c4243d 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -847,7 +847,7 @@ network.on('doubleClick', function(params) { document.getElementById('layoutSelect').addEventListener('change', function(e) { var val = e.target.value; if (val === 'hierarchical') { - network.setOptions({ layout: { hierarchical: { enabled: true, direction: '${cfg.layout.direction || 'LR'}' } }, physics: { enabled: document.getElementById('physicsToggle').checked } }); + network.setOptions({ layout: { hierarchical: { enabled: true, direction: ${JSON.stringify(cfg.layout.direction || 'LR')} } }, physics: { enabled: document.getElementById('physicsToggle').checked } }); } else if (val === 'radial') { network.setOptions({ layout: { hierarchical: false, improvedLayout: true }, physics: { enabled: true, solver: 'repulsion', repulsion: { nodeDistance: 200 } } }); } else { @@ -892,8 +892,8 @@ document.getElementById('detailClose').addEventListener('click', hideDetail); /* ── Init ──────────────────────────────────────────────────────────── */ refreshNodeAppearance(); -updateLegend('${effectiveColorBy}'); -${(cfg.clusterBy || 'none') !== 'none' ? `applyClusterBy('${cfg.clusterBy}');` : ''} +updateLegend(${JSON.stringify(effectiveColorBy)}); +${(cfg.clusterBy || 'none') !== 'none' ? `applyClusterBy(${JSON.stringify(cfg.clusterBy)});` : ''} `;