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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ codegraph sync [path] # Incremental update
codegraph status [path] # Show statistics
codegraph query <search> # Search symbols (--kind, --limit, --json)
codegraph files [path] # Show file structure (--format, --filter, --max-depth, --json)
codegraph visualize [symbol] # Generate interactive HTML graph (--depth, --limit, --output)
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 Down
83 changes: 83 additions & 0 deletions __tests__/visualization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import {
buildVisualizationGraphFromSubgraph,
renderVisualizationHtml,
} from '../src/visualization';
import { Edge, Node, Subgraph } from '../src/types';

function makeNode(id: string, name: string, kind: Node['kind'] = 'function'): Node {
return {
id,
kind,
name,
qualifiedName: `src/example.ts::${name}`,
filePath: 'src/example.ts',
language: 'typescript',
startLine: 1,
endLine: 3,
startColumn: 0,
endColumn: 1,
isExported: true,
updatedAt: 1,
};
}

describe('visualization', () => {
it('keeps root nodes and removes dangling edges when limiting a subgraph', () => {
const root = makeNode('root', 'root');
const child = makeNode('child', 'child');
const omitted = makeNode('omitted', 'omitted');
const edges: Edge[] = [
{ source: root.id, target: child.id, kind: 'calls' },
{ source: child.id, target: omitted.id, kind: 'calls' },
];
const subgraph: Subgraph = {
nodes: new Map([
[root.id, root],
[child.id, child],
[omitted.id, omitted],
]),
edges,
roots: [root.id],
};

const graph = buildVisualizationGraphFromSubgraph({
subgraph,
projectRoot: '/repo',
title: 'Test graph',
mode: 'symbol',
limit: 2,
});

expect(graph.nodes.map((node) => node.id)).toContain(root.id);
expect(graph.nodes).toHaveLength(2);
expect(graph.edges).toEqual([{ source: root.id, target: child.id, kind: 'calls' }]);
expect(graph.stats.truncated).toBe(true);
});

it('escapes graph data before embedding it in the generated HTML', () => {
const dangerous = makeNode('danger', '</script><img src=x onerror=alert(1)>');
const graph = buildVisualizationGraphFromSubgraph({
subgraph: {
nodes: new Map([[dangerous.id, dangerous]]),
edges: [],
roots: [dangerous.id],
},
projectRoot: '/repo',
title: 'Graph <danger>',
mode: 'symbol',
query: dangerous.name,
});

const html = renderVisualizationHtml(graph);

expect(html).toContain('Graph &lt;danger&gt;');
expect(html).toContain('<canvas id="graph"');
expect(html).toContain('id="gravitySlider" type="range" min="0" max="140" value="18"');
expect(html).toContain('id="linkSlider" type="range" min="70" max="900" value="165"');
expect(html).toContain('id="spread"');
expect(html).toContain('function stepPhysics');
expect(html).toContain('\\u003c/script\\u003e');
expect(html).not.toContain('</script><img');
});
});
11 changes: 11 additions & 0 deletions site/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ codegraph sync [path] # Incremental update
codegraph status [path] # Show statistics
codegraph query <search> # Search symbols (--kind, --limit, --json)
codegraph files [path] # Show file structure (--format, --filter, --max-depth, --json)
codegraph visualize [symbol] # Generate interactive HTML graph (--depth, --limit, --output)
codegraph context <task> # Build context for AI (--format, --max-nodes)
codegraph callers <symbol> # Find what calls a function/method (--limit, --json)
codegraph callees <symbol> # Find what a function/method calls (--limit, --json)
Expand All @@ -32,6 +33,16 @@ codegraph callers handleRequest --json
codegraph impact AuthMiddleware --depth 3
```

## Visualization

`visualize` writes a self-contained HTML file that can be opened locally. Without a symbol it creates a project overview; with a symbol it renders the selected symbol's graph neighborhood.

```bash
codegraph visualize
codegraph visualize handleRequest --depth 3 --limit 500 --open
codegraph visualize --kind class,function,method --edge-kind calls,extends,implements
```

## affected

Traces import dependencies transitively to find which test files are affected by changed source files. See [Affected Tests in CI](/codegraph/guides/affected-tests/) for options and a CI example.
139 changes: 139 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 visualize [symbol] Generate 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 All @@ -29,6 +30,7 @@ import { getCodeGraphDir, isInitialized } from '../directory';
import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree';
import { createShimmerProgress } from '../ui/shimmer-progress';
import { getGlyphs } from '../ui/glyphs';
import { EDGE_KINDS, NODE_KINDS, EdgeKind, NodeKind } from '../types';

import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from './node-version-check';
import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime-flags';
Expand Down Expand Up @@ -1001,6 +1003,90 @@ program
}
});

/**
* codegraph visualize [symbol]
*/
program
.command('visualize [symbol]')
.description('Generate an interactive HTML graph visualization')
.option('-p, --path <path>', 'Project path')
.option('-o, --output <file>', 'Output HTML path (default: .codegraph/graph.html)')
.option('-d, --depth <number>', 'Traversal depth when a symbol is provided', '2')
.option('-l, --limit <number>', 'Maximum nodes to include', '300')
.option('--direction <direction>', 'Traversal direction: incoming, outgoing, both', 'both')
.option('-k, --kind <kinds>', 'Comma-separated node kinds to include')
.option('-e, --edge-kind <kinds>', 'Comma-separated edge kinds to include')
.option('--open', 'Open the generated HTML file in your browser')
.action(async (symbol: string | undefined, options: {
path?: string;
output?: string;
depth?: string;
limit?: string;
direction?: string;
kind?: string;
edgeKind?: string;
open?: boolean;
}) => {
const projectPath = resolveProjectPath(options.path);

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

const limit = parseBoundedInteger(options.limit, 'limit', 300, 1, 2000);
const depth = parseBoundedInteger(options.depth, 'depth', 2, 1, 10);
const direction = parseDirection(options.direction);
const nodeKinds = parseAllowedList(options.kind, NODE_KINDS, 'node kind') as NodeKind[] | undefined;
const edgeKinds = parseAllowedList(options.edgeKind, EDGE_KINDS, 'edge kind') as EdgeKind[] | undefined;

const { default: CodeGraph } = await loadCodeGraph();
const {
buildVisualizationGraph,
renderVisualizationHtml,
} = await import('../visualization');
const cg = await CodeGraph.open(projectPath);
const graph = buildVisualizationGraph(cg, {
projectRoot: projectPath,
symbol,
limit,
depth,
direction,
nodeKinds,
edgeKinds,
});

if (graph.nodes.length === 0) {
const label = symbol ? ` for "${symbol}"` : '';
info(`No graph nodes found${label}. Run "codegraph index" or adjust filters.`);
cg.destroy();
return;
}

const outputPath = options.output
? path.resolve(options.output)
: path.join(getCodeGraphDir(projectPath), symbol ? `graph-${slugForFileName(symbol)}.html` : 'graph.html');
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, renderVisualizationHtml(graph), 'utf-8');

success(`Generated graph visualization: ${outputPath}`);
info(`${graph.stats.includedNodes} nodes, ${graph.stats.includedEdges} edges included`);
if (graph.stats.truncated) {
info(`Limited from ${graph.stats.totalNodes} indexed nodes. Increase --limit for more.`);
}

if (options.open) {
await openHtmlFile(outputPath);
}

cg.destroy();
} catch (err) {
error(`visualize failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
});

/**
* Convert glob pattern to regex
*/
Expand All @@ -1014,6 +1100,59 @@ function globToRegex(pattern: string): RegExp {
return new RegExp(escaped);
}

function parseBoundedInteger(
raw: string | undefined,
label: string,
fallback: number,
min: number,
max: number
): number {
const parsed = raw === undefined ? fallback : Number.parseInt(raw, 10);
if (!Number.isFinite(parsed)) {
throw new Error(`Invalid ${label}: ${raw}`);
}
return Math.min(Math.max(parsed, min), max);
}

function parseAllowedList<T extends string>(
raw: string | undefined,
allowed: readonly T[],
label: string
): T[] | undefined {
if (!raw) return undefined;
const values = raw.split(/[,\s]+/).map((value) => value.trim()).filter(Boolean);
const invalid = values.filter((value) => !(allowed as readonly string[]).includes(value));
if (invalid.length > 0) {
throw new Error(`Unknown ${label}: ${invalid.join(', ')}. Allowed: ${allowed.join(', ')}`);
}
return Array.from(new Set(values)) as T[];
}

function parseDirection(raw: string | undefined): 'incoming' | 'outgoing' | 'both' {
const value = raw ?? 'both';
if (value === 'incoming' || value === 'outgoing' || value === 'both') return value;
throw new Error(`Unknown direction: ${value}. Allowed: incoming, outgoing, both`);
}

function slugForFileName(value: string): string {
const slug = value
.replace(/[^a-z0-9_.-]+/gi, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return slug || 'symbol';
}

async function openHtmlFile(filePath: string): Promise<void> {
const { spawn } = await import('child_process');
const resolved = path.resolve(filePath);
const child = process.platform === 'win32'
? spawn('cmd', ['/c', 'start', '', resolved], { detached: true, stdio: 'ignore', windowsHide: true })
: process.platform === 'darwin'
? spawn('open', [resolved], { detached: true, stdio: 'ignore' })
: spawn('xdg-open', [resolved], { detached: true, stdio: 'ignore' });
child.unref();
}

/**
* Print files as a tree
*/
Expand Down
34 changes: 20 additions & 14 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,27 @@ export const NODE_KINDS = [
export type NodeKind = (typeof NODE_KINDS)[number];

/**
* Types of edges (relationships) between nodes
* Types of edges (relationships) between nodes.
*
* Like NODE_KINDS, this is runtime-iterable so CLI filters and generated
* visualizations can validate user input from the same source of truth.
*/
export type EdgeKind =
| 'contains' // Parent contains child (file→class, class→method)
| 'calls' // Function/method calls another
| 'imports' // File imports from another
| 'exports' // File exports a symbol
| 'extends' // Class/interface extends another
| 'implements' // Class implements interface
| 'references' // Generic reference to another symbol
| 'type_of' // Variable/parameter has type
| 'returns' // Function returns type
| 'instantiates' // Creates instance of class
| 'overrides' // Method overrides parent method
| 'decorates'; // Decorator applied to symbol
export const EDGE_KINDS = [
'contains', // Parent contains child (file -> class, class -> method)
'calls', // Function/method calls another
'imports', // File imports from another
'exports', // File exports a symbol
'extends', // Class/interface extends another
'implements', // Class implements interface
'references', // Generic reference to another symbol
'type_of', // Variable/parameter has type
'returns', // Function returns type
'instantiates', // Creates instance of class
'overrides', // Method overrides parent method
'decorates', // Decorator applied to symbol
] as const;

export type EdgeKind = (typeof EDGE_KINDS)[number];

/**
* Supported programming languages. See NODE_KINDS for why this is a
Expand Down
Loading