diff --git a/CHANGELOG.md b/CHANGELOG.md index f0fcf6519..f04198eb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- `codegraph export` exports your indexed codebase as a graph in DOT (Graphviz), Mermaid, Cytoscape JSON, or interactive HTML formats — pipe to a file or render in any compatible tool. Filter by node kind, edge kind, and node count with `--kind`, `--edge-kind`, and `--limit`. ## [0.9.8] - 2026-06-01 diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 9e7f98887..63b821c18 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1602,6 +1602,127 @@ program } }); +/** + * codegraph export + */ +program + .command('export') + .description('Export the code graph to DOT, Mermaid, Cytoscape JSON, or HTML') + .option('-p, --path ', 'Project path') + .option('-f, --format ', 'Output format: dot, mermaid, cytoscape, html', 'dot') + .option('-o, --output ', 'Write output to file instead of stdout') + .option('-k, --kind ', 'Comma-separated node kinds to include (default: structural kinds)') + .option('--all-kinds', 'Include all node kinds (including file, parameter, import, etc.)') + .option('--edge-kind ', 'Comma-separated edge kinds to include (default: all)') + .option('-l, --limit ', 'Maximum nodes to export', '1000') + .option('--title ', 'Graph title (used in DOT and HTML output)') + .action(async (options: { + path?: string; + format?: string; + output?: string; + kind?: string; + allKinds?: boolean; + edgeKind?: string; + limit?: string; + title?: string; + }) => { + const projectPath = resolveProjectPath(options.path); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph not initialized in ${projectPath}`); + process.exit(1); + } + + const fmt = (options.format ?? 'dot').toLowerCase(); + const validFormats = ['dot', 'mermaid', 'cytoscape', 'html']; + if (!validFormats.includes(fmt)) { + error(`Unknown format "${fmt}". Choose from: ${validFormats.join(', ')}`); + process.exit(1); + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + const limit = parseInt(options.limit ?? '1000', 10); + + // Structural kinds that produce readable graphs by default. + const defaultKinds = [ + 'class', 'struct', 'interface', 'trait', 'protocol', + 'function', 'method', + 'component', 'route', + 'enum', 'module', 'namespace', 'type_alias', + ] as const; + + let nodes = options.allKinds + ? cg.getAllNodes() + : options.kind + ? ((): import('../types').Node[] => { + const requested = options.kind!.split(',').map(k => k.trim()).filter(Boolean); + const { NODE_KINDS } = require('../types') as typeof import('../types'); + const invalid = requested.filter(k => !(NODE_KINDS as readonly string[]).includes(k)); + if (invalid.length > 0) { + error(`Unknown node kind(s): ${invalid.join(', ')}`); + process.exit(1); + } + return requested.flatMap(k => cg.getNodesByKind(k as import('../types').NodeKind)); + })() + : defaultKinds.flatMap(k => cg.getNodesByKind(k)); + + // De-duplicate (possible when building from multiple kind queries) + const seen = new Set<string>(); + nodes = nodes.filter(n => { if (seen.has(n.id)) return false; seen.add(n.id); return true; }); + + let truncated = false; + if (nodes.length > limit) { + nodes = nodes.slice(0, limit); + truncated = true; + } + + const edgeKindFilter = options.edgeKind + ? options.edgeKind.split(',').map(k => k.trim()).filter(Boolean) as import('../types').EdgeKind[] + : undefined; + + const nodeIds = nodes.map(n => n.id); + const edges = cg.findEdgesBetweenNodes(nodeIds, edgeKindFilter); + + const { formatDot, formatMermaid, formatCytoscape, formatHtml } = + await import('../export/index'); + + const graphTitle = options.title ?? path.basename(projectPath); + + let output: string; + switch (fmt) { + case 'dot': output = formatDot(nodes, edges, graphTitle); break; + case 'mermaid': output = formatMermaid(nodes, edges, graphTitle); break; + case 'cytoscape': output = formatCytoscape(nodes, edges); break; + case 'html': output = formatHtml(nodes, edges, graphTitle); break; + default: output = formatDot(nodes, edges, graphTitle); + } + + if (options.output) { + fs.writeFileSync(options.output, output, 'utf-8'); + if (truncated) { + process.stderr.write( + chalk.yellow(`Warning: graph truncated to ${limit} nodes. Use --limit to raise the cap.\n`) + ); + } + info(`Exported ${nodes.length} nodes and ${edges.length} edges → ${options.output}`); + } else { + if (truncated) { + process.stderr.write( + chalk.yellow(`Warning: graph truncated to ${limit} nodes. Use --limit to raise the cap.\n`) + ); + } + process.stdout.write(output + '\n'); + } + + cg.destroy(); + } catch (err) { + error(`Export failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + /** * codegraph install */ diff --git a/src/export/index.ts b/src/export/index.ts new file mode 100644 index 000000000..572a790e2 --- /dev/null +++ b/src/export/index.ts @@ -0,0 +1,408 @@ +/** + * Graph Export Formatters + * + * Exports the CodeGraph knowledge graph to DOT, Mermaid, Cytoscape JSON, + * and self-contained HTML (vis-network) formats. + */ + +import { Node, Edge, NodeKind } from '../types'; + +export type ExportFormat = 'dot' | 'mermaid' | 'cytoscape' | 'html'; + +// Colour palette for node kinds — used in all visual formats. +const NODE_COLORS: Record<NodeKind, string> = { + file: '#9E9E9E', + module: '#607D8B', + class: '#2196F3', + struct: '#03A9F4', + interface: '#00BCD4', + trait: '#009688', + protocol: '#4CAF50', + function: '#8BC34A', + method: '#66BB6A', + property: '#FFC107', + field: '#FF9800', + variable: '#FF5722', + constant: '#F44336', + enum: '#E91E63', + enum_member: '#CE93D8', + type_alias: '#673AB7', + namespace: '#3F51B5', + parameter: '#A1887F', + import: '#BDBDBD', + export: '#90A4AE', + route: '#FF1744', + component: '#26C6DA', +}; + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); +} + +function escapeDotString(s: string): string { + return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); +} + +function escapeMermaidLabel(s: string): string { + // Mermaid node labels must not contain quotes or structural characters. + return s.replace(/['"[\](){}|<>]/g, ' ').replace(/\s+/g, ' ').trim(); +} + +/** + * Build a node set for fast membership tests and filter edges whose + * both endpoints are present. + */ +function boundEdges(nodes: Node[], edges: Edge[]): Edge[] { + const ids = new Set(nodes.map(n => n.id)); + return edges.filter(e => ids.has(e.source) && ids.has(e.target)); +} + +// ============================================================================= +// DOT +// ============================================================================= + +export function formatDot(nodes: Node[], edges: Edge[], title = 'CodeGraph'): string { + const visibleEdges = boundEdges(nodes, edges); + const lines: string[] = [ + `digraph "${escapeDotString(title)}" {`, + ' rankdir=LR;', + ' node [shape=box, style="filled,rounded", fontname="Helvetica", fontsize=10];', + ' edge [fontname="Helvetica", fontsize=9, arrowsize=0.7];', + '', + ]; + + for (const n of nodes) { + const color = NODE_COLORS[n.kind] ?? '#9E9E9E'; + const label = `${escapeDotString(n.name)}\\n${n.kind}`; + const tooltip = escapeDotString(`${n.filePath}:${n.startLine}`); + lines.push(` "${n.id}" [label="${label}", fillcolor="${color}22", color="${color}", tooltip="${tooltip}"];`); + } + + lines.push(''); + + for (const e of visibleEdges) { + lines.push(` "${e.source}" -> "${e.target}" [label="${e.kind}"];`); + } + + lines.push('}'); + return lines.join('\n'); +} + +// ============================================================================= +// Mermaid +// ============================================================================= + +export function formatMermaid(nodes: Node[], edges: Edge[], title = 'CodeGraph'): string { + const visibleEdges = boundEdges(nodes, edges); + // Mermaid requires alphanumeric IDs; map the hash IDs to N0…Nn. + const idMap = new Map<string, string>(nodes.map((n, i) => [n.id, `N${i}`])); + + const lines: string[] = [ + `%% ${title}`, + 'flowchart LR', + ]; + + for (const n of nodes) { + const mid = idMap.get(n.id)!; + const label = escapeMermaidLabel(`${n.name} · ${n.kind}`); + lines.push(` ${mid}["${label}"]`); + } + + lines.push(''); + + for (const e of visibleEdges) { + const src = idMap.get(e.source)!; + const tgt = idMap.get(e.target)!; + lines.push(` ${src} -->|${e.kind}| ${tgt}`); + } + + return lines.join('\n'); +} + +// ============================================================================= +// Cytoscape JSON +// ============================================================================= + +export function formatCytoscape(nodes: Node[], edges: Edge[]): string { + const visibleEdges = boundEdges(nodes, edges); + const elements: Array<{ data: Record<string, unknown> }> = []; + + for (const n of nodes) { + elements.push({ + data: { + id: n.id, + label: n.name, + kind: n.kind, + filePath: n.filePath, + startLine: n.startLine, + language: n.language, + color: NODE_COLORS[n.kind] ?? '#9E9E9E', + }, + }); + } + + for (const e of visibleEdges) { + elements.push({ + data: { + id: `${e.source}__${e.target}__${e.kind}`, + source: e.source, + target: e.target, + kind: e.kind, + ...(e.provenance ? { provenance: e.provenance } : {}), + }, + }); + } + + return JSON.stringify({ elements }, null, 2); +} + +// ============================================================================= +// HTML (cytoscape.js — batch layout, sidebar filters, search) +// ============================================================================= + +export function formatHtml(nodes: Node[], edges: Edge[], title = 'CodeGraph'): string { + const visibleEdges = boundEdges(nodes, edges); + + // Kind counts for sidebar checkboxes + const kindCounts = new Map<NodeKind, number>(); + for (const n of nodes) kindCounts.set(n.kind, (kindCounts.get(n.kind) ?? 0) + 1); + const kindsPresent = [...kindCounts.keys()].sort() as NodeKind[]; + + const cyNodes = nodes.map(n => ({ + data: { + id: n.id, + label: n.name, + kind: n.kind, + color: NODE_COLORS[n.kind] ?? '#9E9E9E', + tip: `${n.kind} · ${n.filePath}:${n.startLine}`, + }, + })); + + const cyEdges = visibleEdges.map((e, i) => ({ + data: { + id: `e${i}`, + source: e.source, + target: e.target, + label: e.kind, + dashed: e.provenance === 'heuristic', + }, + })); + + const sidebarRows = kindsPresent.map(k => { + const color = NODE_COLORS[k] ?? '#9E9E9E'; + const count = kindCounts.get(k) ?? 0; + return `<label class="kind-row"><input type="checkbox" checked data-kind="${k}"><span class="dot" style="background:${color}"></span><span class="kname">${k}</span><span class="kcount">${count}</span></label>`; + }).join(''); + + return `<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width,initial-scale=1"> +<title>${escapeHtml(title)} + + + + +
+

${escapeHtml(title)}

+ ${nodes.length.toLocaleString()} nodes · ${visibleEdges.length.toLocaleString()} edges +
+ + +
+ + + +
+
+ +
+
+
Computing layout…
+
+ + +`; +} diff --git a/src/index.ts b/src/index.ts index cac1c05ae..7dbda8c4d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -681,6 +681,13 @@ export class CodeGraph { return this.queries.getNodesByKind(kind); } + /** + * Get every node in the graph + */ + getAllNodes(): Node[] { + return this.queries.getAllNodes(); + } + /** * Search nodes by text */ @@ -733,6 +740,14 @@ export class CodeGraph { return this.queries.getIncomingEdges(nodeId); } + /** + * Get all edges whose source AND target are both in the given node ID set. + * Efficient for export/subgraph operations — one query instead of N. + */ + findEdgesBetweenNodes(nodeIds: string[], edgeKinds?: Edge['kind'][]): Edge[] { + return this.queries.findEdgesBetweenNodes(nodeIds, edgeKinds); + } + // =========================================================================== // File Operations // ===========================================================================