diff --git a/src/cli.js b/src/cli.js index e5b95942..f04789c7 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,81 @@ 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') + .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 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()); + } + + // 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), + 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 { execFile } = await import('node:child_process'); + const args = + process.platform === 'win32' + ? ['cmd', ['/c', 'start', '', outPath]] + : process.platform === 'darwin' + ? ['open', [outPath]] + : ['xdg-open', [outPath]]; + execFile(args[0], args[1], (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..c0c4243d --- /dev/null +++ b/src/viewer.js @@ -0,0 +1,948 @@ +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; + +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 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 }, + nodeColors: DEFAULT_NODE_COLORS, + roleColors: DEFAULT_ROLE_COLORS, + colorBy: 'kind', + 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 }, +}; + +/** + * 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 || {}) }, + overlays: { + ...DEFAULT_CONFIG.overlays, + ...(raw.overlays || {}), + }, + riskThresholds: { + ...DEFAULT_CONFIG.riskThresholds, + ...(raw.riskThresholds || {}), + }, + }; + } catch { + // Invalid JSON — use defaults + } + } + } + return { ...DEFAULT_CONFIG }; +} + +// ─── Data Preparation ───────────────────────────────────────────────── + +/** + * Prepare enriched graph data for the HTML viewer. + */ +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; + + return fileLevel + ? prepareFileLevelData(db, noTests, minConf, cfg) + : prepareFunctionLevelData(db, noTests, minConf, cfg); +} + +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, + 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)); + + 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)); + } + + // 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 + } + + // 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 communities = louvain(graph); + for (const [nid, cid] of Object.entries(communities)) communityMap.set(Number(nid), cid); + } catch { + // louvain can fail on disconnected graphs + } + } + + // 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, + 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 ` + + + + +${escapeHtml(title)} + + + + +
+ + + + + + + +
+
+
+
+ × +
+
+
+
+ + +`; +} + +// ─── Internal Helpers ───────────────────────────────────────────────── + +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..0ace2b01 --- /dev/null +++ b/tests/graph/viewer.test.js @@ -0,0 +1,360 @@ +/** + * Interactive HTML viewer tests. + */ + +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; +import { initSchema } from '../../src/db.js'; +import { generatePlotHTML, loadPlotConfig, prepareGraphData } 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, role) { + return db + .prepare('INSERT INTO nodes (name, kind, file, line, role) VALUES (?, ?, ?, ?, ?)') + .run(name, kind, file, line, role || null).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); +} + +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(); + 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 allNodes ='); + expect(html).toContain('var allEdges ='); + 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 }, + seedStrategy: 'all', + seedCount: 30, + clusterBy: 'none', + sizeBy: 'uniform', + overlays: { complexity: false, risk: false }, + riskThresholds: { highBlastRadius: 10, lowMI: 40 }, + }, + }); + 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 allNodes = []'); + expect(html).toContain('var allEdges = []'); + 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(); + }); + + 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', () => { + 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'); + }); + + 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, + }); + }); +}); diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index 395878ec..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']); + expect(eg.inputSchema.properties.format.enum).toEqual([ + 'dot', + 'mermaid', + 'json', + 'graphml', + 'graphson', + 'neo4j', + ]); expect(eg.inputSchema.properties).toHaveProperty('file_level'); });