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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ docs/business/
# CodeGraph data directories (in test projects)
.codegraph/

# `codegraph view` default HTML output
codegraph_view.html

test_frameworks

# Test language repos for manual testing
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@ codegraph query <search> # Search symbols (--kind, --limit, --json)
codegraph explore <query> # Relevant symbols' source + call paths in one shot (same output as the codegraph_explore MCP tool)
codegraph node <symbol|file> # One symbol's source + callers, or read a file with line numbers (same output as codegraph_node)
codegraph files [path] # Show file structure (--format, --filter, --max-depth, --json)
codegraph view # Render the index as an interactive HTML graph and open your browser (--symbol, --file, --no-open, --max-nodes)
codegraph callers <symbol> # Find what calls a function/method (--limit, --json)
codegraph callees <symbol> # Find what a function/method calls (--limit, --json)
codegraph impact <symbol> # Analyze what code is affected by changing a symbol (--depth, --json)
Expand All @@ -539,6 +540,30 @@ codegraph version # Print the installed version (also -v, --vers
codegraph help [command] # Show help, optionally for one command
```

### `codegraph view`

CodeGraph is otherwise terminal/MCP-only. `view` renders the same index as an
interactive, zoomable, searchable graph — nodes colored by kind, sized by how
connected they are — in a single self-contained HTML file. vis-network is
bundled and inlined, so the page works fully offline (no CDN, matching
CodeGraph's 100%-local design).

```bash
codegraph view # whole graph — opens in your browser automatically
codegraph view --no-open # write the HTML file without opening a browser
codegraph view --file campaign.ts # only this file's symbols + their 1-hop neighbors
codegraph view --symbol run_campaign # only this symbol + its immediate neighborhood
codegraph view --include-imports # show import/export/reference edges too (noisy on real repos)
codegraph view --max-nodes 400 # raise the whole-graph node cap (default 250)
codegraph view -o graph.html # choose the output file
```

By default the HTML is written to `<project>/.codegraph/codegraph_view.html`
(inside the gitignored index directory, so it never litters your working tree)
and opened in your browser via a loopback-only HTTP server; press Ctrl+C to stop.
Pass `--no-open` to skip the browser and just write the file, or `-o` to
override the output path.

### `codegraph affected`

Traces import dependencies transitively to find which test files are affected by changed source files.
Expand Down
144 changes: 144 additions & 0 deletions __tests__/cli-view-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* `codegraph view` — interactive HTML graph renderer.
*
* Renders the index as a single self-contained HTML file with vis-network
* bundled inline (works offline, no CDN). Exercised end-to-end against the
* built binary, plus the underlying `getGraphView()` projection for the filter
* modes (whole-graph / --symbol / --file) and the empty-match guard.
*/

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 { EmptyGraphViewError } from '../src/graph/viewer';

const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');

function runView(cwd: string, extraArgs: string[]): { stdout: string; stderr: string; code: number } {
try {
const stdout = execFileSync(process.execPath, [BIN, 'view', ...extraArgs, '-p', cwd], {
encoding: 'utf-8',
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
return { stdout, stderr: '', code: 0 };
} catch (err: any) {
return { stdout: err.stdout ?? '', stderr: err.stderr ?? '', code: err.status ?? 1 };
}
}

describe('codegraph view', () => {
let tempDir: string;

beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-view-cmd-'));
fs.mkdirSync(path.join(tempDir, 'src'));
fs.writeFileSync(
path.join(tempDir, 'src/util.ts'),
'export function add(a: number, b: number){ return a + b; }\n' +
'export function calc(x: number){ return add(x, 1); }\n'
);
fs.writeFileSync(
path.join(tempDir, 'src/main.ts'),
'import { calc } from "./util";\nexport function run(){ return calc(5); }\n'
);
const cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.close();
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});

it('writes a self-contained HTML file with vis-network inlined (no CDN)', () => {
const out = path.join(tempDir, 'graph.html');
const { stdout, code } = runView(tempDir, ['-o', out]);
expect(code).toBe(0);
expect(stdout).toMatch(/Wrote \d+ nodes/);

const html = fs.readFileSync(out, 'utf-8');
// vis-network is bundled inline, not pulled from a CDN.
expect(html).not.toContain('cdnjs');
expect(html).not.toContain('src="http');
expect(html).toContain('new vis.Network');
expect(html).toContain('new vis.DataSet');
// Bundling the whole minified lib makes the file large.
expect(html.length).toBeGreaterThan(100_000);
});

it('defaults the output into the .codegraph/ directory (not the working tree)', () => {
const { code } = runView(tempDir, []); // no -o
expect(code).toBe(0);
const defaultOut = path.join(tempDir, '.codegraph', 'codegraph_view.html');
expect(fs.existsSync(defaultOut)).toBe(true);
});

it('--symbol scopes the title and payload to that symbol', () => {
const out = path.join(tempDir, 's.html');
const { code } = runView(tempDir, ['--symbol', 'add', '-o', out]);
expect(code).toBe(0);
const html = fs.readFileSync(out, 'utf-8');
expect(html).toContain('<title>CodeGraph Viewer — add</title>');
});

it('prints a friendly hint (not a crash) when --symbol matches nothing', () => {
const { stdout, stderr, code } = runView(tempDir, ['--symbol', 'zzz_no_such_symbol']);
// Graceful: exit 0, hint on stdout, no stack trace.
expect(code).toBe(0);
expect(stdout).toMatch(/No symbol found matching/);
expect(stderr).not.toMatch(/Error:/);
});

it('rejects a non-numeric --max-nodes', () => {
const { stderr, code } = runView(tempDir, ['--max-nodes', 'abc']);
expect(code).not.toBe(0);
expect(stderr).toMatch(/max-nodes must be a positive integer/);
});
});

describe('getGraphView() projection', () => {
let tempDir: string;
let cg: CodeGraph;

beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-view-api-'));
fs.mkdirSync(path.join(tempDir, 'src'));
fs.writeFileSync(
path.join(tempDir, 'src/util.ts'),
'export function add(a: number, b: number){ return a + b; }\n' +
'export function calc(x: number){ return add(x, 1); }\n'
);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
});

afterEach(() => {
cg.close();
fs.rmSync(tempDir, { recursive: true, force: true });
});

it('returns render-ready nodes/edges/stats for the whole graph', () => {
const data = cg.getGraphView();
expect(data.nodes.length).toBeGreaterThan(0);
expect(data.stats.totalNodes).toBe(data.nodes.length);
expect(data.stats.totalEdges).toBe(data.edges.length);
// Every node carries a color + group for the legend.
for (const n of data.nodes) {
expect(n.color).toMatch(/^#/);
expect(typeof n.group).toBe('string');
}
});

it('respects --max-nodes as a cap', () => {
const data = cg.getGraphView({ maxNodes: 2 });
expect(data.nodes.length).toBeLessThanOrEqual(2);
});

it('throws EmptyGraphViewError for an unknown symbol', () => {
expect(() => cg.getGraphView({ symbol: 'definitely_not_here' })).toThrow(EmptyGraphViewError);
});
});
Binary file added assets/screenshots/graph_symbol.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/screenshots/graph_view.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 27 additions & 0 deletions assets/vis-network.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"scripts": {
"build": "tsc && npm run copy-assets && node -e \"require('fs').chmodSync('dist/bin/codegraph.js', 0o755)\"",
"preuninstall": "node dist/bin/uninstall.js",
"copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"",
"copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f));fs.mkdirSync('dist/assets',{recursive:true});fs.copyFileSync('assets/vis-network.min.js','dist/assets/vis-network.min.js')\"",
"dev": "tsc --watch",
"cli": "npm run build && node dist/bin/codegraph.js",
"test": "vitest run",
Expand Down
152 changes: 152 additions & 0 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* codegraph status [path] Show index status
* codegraph query <search> Search for symbols
* codegraph files [options] Show project file structure
* codegraph view [options] Render the index as an interactive HTML graph
* codegraph context <task> Build context for a task
* codegraph callers <symbol> Find what calls a function/method
* codegraph callees <symbol> Find what a function/method calls
Expand Down Expand Up @@ -1424,6 +1425,157 @@ program
}
});

/**
* codegraph view
*
* Render the index as an interactive, zoomable, searchable graph in a single
* self-contained HTML file (vis-network bundled locally, works offline).
* Opens the default browser automatically unless `--no-open` is passed.
*/
program
.command('view')
.description('Render the index as an interactive HTML graph and open it in your browser')
.option('-p, --path <path>', 'Project path')
.option('-o, --output <file>', 'Output HTML file (default: <project>/.codegraph/codegraph_view.html)')
.option('--file <substring>', 'Only show symbols from files matching this substring, plus 1-hop neighbors')
.option('--symbol <name>', 'Only show this symbol and its immediate neighborhood')
.option('--include-imports', 'Include import/export/reference edges (noisy on real repos, off by default)')
.option('--max-nodes <number>', 'Cap on nodes for whole-graph / file view (highest-degree kept)', '250')
.option('--no-open', 'Write the HTML file without opening a browser')
.action(async (options: {
path?: string;
output?: string;
file?: string;
symbol?: string;
includeImports?: boolean;
maxNodes?: string;
open?: boolean;
}) => {
const projectPath = resolveProjectPath(options.path);

try {
if (!isInitialized(projectPath)) {
error(`CodeGraph not initialized in ${projectPath}`);
process.exit(1);
}

const maxNodes = options.maxNodes ? parseInt(options.maxNodes, 10) : 250;
if (Number.isNaN(maxNodes) || maxNodes < 1) {
error(`--max-nodes must be a positive integer (got "${options.maxNodes}")`);
process.exit(1);
}

const { default: CodeGraph } = await loadCodeGraph();
const { renderViewerHtml, EmptyGraphViewError } = await import('../graph/viewer');
const cg = await CodeGraph.open(projectPath);

let data;
try {
data = cg.getGraphView({
symbol: options.symbol,
file: options.file,
includeImports: options.includeImports,
maxNodes,
});
} catch (err) {
cg.destroy();
if (err instanceof EmptyGraphViewError) {
info(err.message);
return;
}
throw err;
}
cg.destroy();

const titleSuffix = options.symbol
? ` — ${options.symbol}`
: options.file
? ` — ${options.file}`
: '';
const html = renderViewerHtml(data, titleSuffix);

// Default output lives inside the project's .codegraph/ directory (which
// is gitignored), so a `view` never litters the working tree. An explicit
// -o is honored as-is, resolved against the current directory.
const outPath = options.output
? path.resolve(options.output)
: path.join(getCodeGraphDir(projectPath), 'codegraph_view.html');
fs.writeFileSync(outPath, html, 'utf-8');
success(
`Wrote ${data.stats.totalNodes} nodes / ${data.stats.totalEdges} edges to ${outPath}`
);

if (options.open !== false) {
await serveAndOpen(html, outPath);
return;
}

info(`Open it in a browser: file://${outPath}`);
} catch (err) {
error(`Failed to build graph view: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
});

/**
* Serve a pre-rendered graph HTML on a loopback port and open the default
* browser at it, then block until the user stops the process (Ctrl+C). Used by
* `codegraph view` (default). Loopback-only bind so nothing is exposed off-box,
* matching CodeGraph's local-first stance.
*/
async function serveAndOpen(html: string, outPath: string): Promise<void> {
const http = await import('http');
const { spawn } = await import('child_process');
const body = Buffer.from(html, 'utf-8');

const server = http.createServer((_req, res) => {
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Content-Length': String(body.length),
});
res.end(body);
});

await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});

const addr = server.address();
const port = addr && typeof addr === 'object' ? addr.port : 0;
const url = `http://127.0.0.1:${port}/`;

// Best-effort browser launch; if the opener is missing we still print the URL.
try {
const [cmd, args] =
process.platform === 'darwin'
? ['open', [url]]
: process.platform === 'win32'
? ['cmd', ['/c', 'start', '', url]]
: ['xdg-open', [url]];
const child = spawn(cmd as string, args as string[], {
detached: true,
stdio: 'ignore',
});
child.on('error', () => {});
child.unref();
} catch {
// ignore — URL is printed below regardless
}

success(`Serving graph at ${url}`);
info(`Also saved to file://${outPath}`);
console.log(chalk.dim('Press Ctrl+C to stop the server.'));

await new Promise<void>((resolve) => {
const shutdown = () => {
server.close(() => resolve());
};
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
});
}

/**
* Normalize a user-supplied file path to the project-relative, forward-slash
* form CodeGraph stores in the index. Accepts an absolute path, a `./`-prefixed
Expand Down
Loading