diff --git a/CHANGELOG.md b/CHANGELOG.md index b36a9ddef..6ba557a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,7 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] - -### Fixes - +- `codegraph visual` writes a dark-theme D3 HTML file-level graph of the index to `.codegraph/visual.html` you can open in a browser. - A CodeGraph process that gets force-killed — by the stuck-process watchdog, a crash, or the OS — no longer leaves the database's write-ahead log behind to grow without bound. Previously each killed session stacked more data onto the same log file and nothing ever shrank it, which on machines where sessions were killed regularly could quietly eat tens of gigabytes of disk. The log is now capped, and any oversized leftover is reclaimed automatically the next time the project is opened. Thanks @tiendungdev for the exceptional Windows report that pinned this down. (#1431) - The background server's watchdog no longer kills a healthy server that is just waiting on a slow disk: like indexing already does, it now checks whether the database files are still making progress before concluding the process is stuck. Fewer spurious kills also means fewer leftover write-ahead logs. (#1431) - `codegraph status` now shows the write-ahead log's size next to the database size and warns when killed sessions have left it oversized, and every line in the background server's log now carries a timestamp so kills and restarts can be placed in time. (#1431) 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 c6e259374..5416d6582 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1100,6 +1100,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 c5c780f3a..fa615f7a7 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -2467,6 +2467,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 694e93a3b..61b667cf5 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'; @@ -1793,6 +1795,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;