From d1e7ff2cdc75633203e046d2c09f7ada6f5ff278 Mon Sep 17 00:00:00 2001 From: Amir Salehi Date: Tue, 28 Jul 2026 15:55:05 +0330 Subject: [PATCH] feat(visualization): add dark-theme D3 HTML graph export functionality Introduces a new command `codegraph visual` that generates a dark-theme D3 HTML visualization of the code graph, saved to `.codegraph/visual.html`. The `writeVisualHtml` method in the CodeGraph class handles the creation of the visualization, and a new query method retrieves distinct cross-file links for the graph. Updates to the CHANGELOG reflect this new feature. --- CHANGELOG.md | 4 + __tests__/cli-visual.test.ts | 131 +++++++++++++++++++++ src/bin/codegraph.ts | 27 +++++ src/db/queries.ts | 25 ++++ src/index.ts | 15 +++ src/visual/export-graph.ts | 42 +++++++ src/visual/html.ts | 221 +++++++++++++++++++++++++++++++++++ src/visual/index.ts | 8 ++ src/visual/types.ts | 28 +++++ 9 files changed, 501 insertions(+) create mode 100644 __tests__/cli-visual.test.ts create mode 100644 src/visual/export-graph.ts create mode 100644 src/visual/html.ts create mode 100644 src/visual/index.ts create mode 100644 src/visual/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ae567d0..ce4029aa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- `codegraph visual` writes a dark-theme D3 HTML file-level graph of the index to `.codegraph/visual.html` you can open in a browser. + ## [1.5.0] - 2026-07-21 diff --git a/__tests__/cli-visual.test.ts b/__tests__/cli-visual.test.ts new file mode 100644 index 000000000..009bf2188 --- /dev/null +++ b/__tests__/cli-visual.test.ts @@ -0,0 +1,131 @@ +/** + * `codegraph visual` — writes `.codegraph/visual.html` with a file-level D3 graph. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { getCodeGraphDir } from '../src/directory'; +import { buildVisualPayload, FILE_LINK_CAP, renderVisualHtml } from '../src/visual'; +import { DatabaseConnection, getDatabasePath } from '../src/db'; +import { QueryBuilder } from '../src/db/queries'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +describe('codegraph visual', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-visual-')); + fs.mkdirSync(path.join(tempDir, 'src')); + fs.writeFileSync( + path.join(tempDir, 'src/a.ts'), + 'export function alpha() { return beta(); }\n', + ); + fs.writeFileSync( + path.join(tempDir, 'src/b.ts'), + 'export function beta() { return 1; }\n', + ); + const cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + cg.close(); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('buildVisualPayload includes the file graph', () => { + const db = DatabaseConnection.open(getDatabasePath(tempDir)); + const queries = new QueryBuilder(db.getDb()); + const payload = buildVisualPayload(queries); + db.close(); + + expect(payload.nodes.length).toBeGreaterThanOrEqual(2); + expect(payload.nodes.some((n) => n.path.endsWith('a.ts') || n.id.endsWith('a.ts'))).toBe(true); + expect(payload.nodes.every((n) => n.kind === 'file')).toBe(true); + + const nodeIds = new Set(payload.nodes.map((n) => n.id)); + for (const link of payload.links) { + expect(nodeIds.has(link.source)).toBe(true); + expect(nodeIds.has(link.target)).toBe(true); + } + expect(payload.links.length).toBeLessThanOrEqual(FILE_LINK_CAP); + + const html = renderVisualHtml(payload); + expect(html).toContain('cdn.jsdelivr.net/npm/d3@7'); + expect(html).toContain('invertX'); + expect(html).not.toContain('File-Level'); + expect(html).not.toContain('Symbol-Level'); + expect(html).toContain('"nodes"'); + expect(html).toContain('"links"'); + }); + + it('drops cross-file links whose endpoints are missing from files', () => { + const db = DatabaseConnection.open(getDatabasePath(tempDir)); + const raw = db.getDb(); + // Plant an edge between symbols whose file_path is not in `files` + // (mid-sync orphan shape) so the visual builder must skip it. + const insertNode = raw.prepare(` + INSERT INTO nodes (id, kind, name, qualified_name, file_path, language, start_line, end_line, start_column, end_column, is_exported, is_async, is_static, is_abstract, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + insertNode.run( + 'orphan-src', 'function', 'orphanSrc', 'orphanSrc', 'missing/src.ts', 'typescript', + 1, 1, 0, 0, 0, 0, 0, 0, Date.now(), + ); + insertNode.run( + 'orphan-tgt', 'function', 'orphanTgt', 'orphanTgt', 'missing/tgt.ts', 'typescript', + 1, 1, 0, 0, 0, 0, 0, 0, Date.now(), + ); + raw.prepare(`INSERT INTO edges (source, target, kind) VALUES (?, ?, ?)`).run( + 'orphan-src', + 'orphan-tgt', + 'calls', + ); + + const queries = new QueryBuilder(raw); + const payload = buildVisualPayload(queries); + db.close(); + + const nodeIds = new Set(payload.nodes.map((n) => n.id)); + expect(nodeIds.has('missing/src.ts')).toBe(false); + expect(payload.links.every((l) => nodeIds.has(l.source) && nodeIds.has(l.target))).toBe(true); + expect( + payload.links.some((l) => l.source === 'missing/src.ts' || l.target === 'missing/tgt.ts'), + ).toBe(false); + }); + + it('CodeGraph.writeVisualHtml writes .codegraph/visual.html', async () => { + const cg = await CodeGraph.open(tempDir); + const out = cg.writeVisualHtml(); + cg.close(); + + const expected = path.join(getCodeGraphDir(tempDir), 'visual.html'); + expect(out).toBe(expected); + expect(fs.existsSync(expected)).toBe(true); + const html = fs.readFileSync(expected, 'utf8'); + expect(html).toContain('d3@7'); + expect(html).not.toContain('Symbol-Level'); + expect(html).not.toContain('id="tabs"'); + }); + + it('CLI visual command writes the HTML file', () => { + const out = execFileSync(process.execPath, [BIN, 'visual', tempDir], { + encoding: 'utf-8', + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + expect(out).toMatch(/Wrote /); + const htmlPath = path.join(getCodeGraphDir(tempDir), 'visual.html'); + expect(fs.existsSync(htmlPath)).toBe(true); + const html = fs.readFileSync(htmlPath, 'utf8'); + expect(html).toContain('d3@7'); + expect(html).not.toContain('Symbol-Level'); + expect(html).toContain('"nodes"'); + expect(html).not.toContain('"symbols"'); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index eefb5d907..3ca256787 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1086,6 +1086,33 @@ program } }); +/** + * codegraph visual [path] + */ +program + .command('visual [path]') + .description('Write a dark-theme D3 HTML graph visualization to .codegraph/visual.html') + .action(async (pathArg: string | undefined) => { + const projectPath = resolveProjectPath(pathArg); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph not initialized in ${projectPath}`); + info('Run "codegraph init" to initialize'); + process.exit(1); + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + const outPath = cg.writeVisualHtml(); + cg.destroy(); + success(`Wrote ${outPath}`); + } catch (err) { + error(`Failed to write visual: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + /** * codegraph query */ diff --git a/src/db/queries.ts b/src/db/queries.ts index 16b9d5f91..5d3d4cd48 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -2466,6 +2466,31 @@ export class QueryBuilder { }; } + /** + * Distinct cross-file links for the visual File-Level graph (imports + calls). + * Both endpoints must exist in `files` (skips mid-sync orphans). Capped so + * large indexes don't materialize unbounded edge sets into the HTML export. + */ + getCrossFileLinks(limit: number): Array<{ source: string; target: string; kind: string }> { + return this.db + .prepare( + ` + SELECT DISTINCT ns.file_path AS source, nt.file_path AS target, e.kind AS kind + FROM edges e + JOIN nodes ns ON ns.id = e.source + JOIN nodes nt ON nt.id = e.target + JOIN files fs ON fs.path = ns.file_path + JOIN files ft ON ft.path = nt.file_path + WHERE ns.file_path != nt.file_path + AND e.kind IN ('imports', 'calls') + AND ns.file_path != '' + AND nt.file_path != '' + LIMIT ? + `, + ) + .all(limit) as Array<{ source: string; target: string; kind: string }>; + } + // =========================================================================== // Project Metadata // =========================================================================== diff --git a/src/index.ts b/src/index.ts index 461ff4797..057509a76 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,6 +57,8 @@ import { CodeGraphPackageVersion } from './mcp/version'; import { segmentLookupVariants, splitIdentifierSegments } from './search/identifier-segments'; import { createYielder } from './resolution/cooperative-yield'; import { minRefsForPool } from './resolution/resolver-pool'; +import { buildVisualPayload, renderVisualHtml } from './visual'; +import * as fs from 'fs'; // Re-export types for consumers export * from './types'; @@ -1792,6 +1794,19 @@ export class CodeGraph { // Database Management // =========================================================================== + /** + * Write a self-contained dark-theme D3 HTML visualization of the graph to + * `.codegraph/visual.html` (or `outputPath` if provided). Returns the path written. + */ + writeVisualHtml(outputPath?: string): string { + const html = renderVisualHtml(buildVisualPayload(this.queries)); + const out = + outputPath ?? + path.join(getCodeGraphDir(this.projectRoot), 'visual.html'); + fs.writeFileSync(out, html, 'utf8'); + return out; + } + /** * Optimize the database (vacuum and analyze) */ diff --git a/src/visual/export-graph.ts b/src/visual/export-graph.ts new file mode 100644 index 000000000..ab131995b --- /dev/null +++ b/src/visual/export-graph.ts @@ -0,0 +1,42 @@ +/** + * Build the file-level graph payload for the visual HTML export. + */ + +import type { QueryBuilder } from '../db/queries'; +import { FILE_LINK_CAP, type VisualGraph, type VisualLink, type VisualNode, type VisualPayload } from './types'; + +function basename(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + const i = normalized.lastIndexOf('/'); + return i >= 0 ? normalized.slice(i + 1) : normalized; +} + +function buildFileGraph(queries: QueryBuilder, linkCap: number = FILE_LINK_CAP): VisualGraph { + const files = queries.getAllFiles(); + const nodes: VisualNode[] = files.map((f) => ({ + id: f.path, + name: basename(f.path), + kind: 'file', + path: f.path, + })); + const nodeIds = new Set(nodes.map((n) => n.id)); + + const seen = new Set(); + const links: VisualLink[] = []; + for (const link of queries.getCrossFileLinks(linkCap)) { + if (!nodeIds.has(link.source) || !nodeIds.has(link.target)) continue; + const key = `${link.source}\0${link.target}\0${link.kind}`; + if (seen.has(key)) continue; + seen.add(key); + links.push({ source: link.source, target: link.target, kind: link.kind }); + } + + return { nodes, links }; +} + +/** + * Build the visual payload from an open QueryBuilder. + */ +export function buildVisualPayload(queries: QueryBuilder): VisualPayload { + return buildFileGraph(queries); +} diff --git a/src/visual/html.ts b/src/visual/html.ts new file mode 100644 index 000000000..c8a70d0e3 --- /dev/null +++ b/src/visual/html.ts @@ -0,0 +1,221 @@ +/** + * Single-file dark-theme HTML template for `codegraph visual`. + * Loads D3 v7 from jsDelivr and embeds graph JSON inline. + */ + +import type { VisualPayload } from './types'; + +function embedJson(payload: VisualPayload): string { + // Prevent `` in path/name strings from breaking out of the data tag. + return JSON.stringify(payload).replace(/ + + + + +CodeGraph Visual + + + + +
+ + + + +`; +} diff --git a/src/visual/index.ts b/src/visual/index.ts new file mode 100644 index 000000000..96a16a479 --- /dev/null +++ b/src/visual/index.ts @@ -0,0 +1,8 @@ +/** + * Visual HTML export for `codegraph visual`. + */ + +export { buildVisualPayload } from './export-graph'; +export { renderVisualHtml } from './html'; +export { FILE_LINK_CAP } from './types'; +export type { VisualGraph, VisualLink, VisualNode, VisualPayload } from './types'; diff --git a/src/visual/types.ts b/src/visual/types.ts new file mode 100644 index 000000000..82a358459 --- /dev/null +++ b/src/visual/types.ts @@ -0,0 +1,28 @@ +/** + * Payload embedded in `.codegraph/visual.html` for the D3 force layout. + */ + +export interface VisualNode { + id: string; + name: string; + kind: string; + /** Full file path — shown on hover. */ + path: string; +} + +export interface VisualLink { + source: string; + target: string; + kind: string; +} + +export interface VisualGraph { + nodes: VisualNode[]; + links: VisualLink[]; +} + +/** File-level graph embedded in the visual HTML. */ +export type VisualPayload = VisualGraph; + +/** Max distinct cross-file links kept in the visual export. */ +export const FILE_LINK_CAP = 10000;