Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
131 changes: 131 additions & 0 deletions __tests__/cli-visual.test.ts
Original file line number Diff line number Diff line change
@@ -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"');
});
});
27 changes: 27 additions & 0 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <search>
*/
Expand Down
25 changes: 25 additions & 0 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ===========================================================================
Expand Down
15 changes: 15 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
*/
Expand Down
42 changes: 42 additions & 0 deletions src/visual/export-graph.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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);
}
Loading