From ec645115e40bc11dfeb4b6f60e6c66213e6f06a8 Mon Sep 17 00:00:00 2001 From: xy200303 <3483421977@qq.com> Date: Wed, 3 Jun 2026 16:22:49 +0800 Subject: [PATCH 1/2] Add graph visualization CLI --- README.md | 1 + __tests__/visualization.test.ts | 78 ++ site/src/content/docs/reference/cli.md | 11 + src/bin/codegraph.ts | 139 +++ src/types.ts | 34 +- src/visualization.ts | 1445 ++++++++++++++++++++++++ 6 files changed, 1694 insertions(+), 14 deletions(-) create mode 100644 __tests__/visualization.test.ts create mode 100644 src/visualization.ts diff --git a/README.md b/README.md index 1a9800ee3..f95f614a3 100644 --- a/README.md +++ b/README.md @@ -452,6 +452,7 @@ codegraph sync [path] # Incremental update codegraph status [path] # Show statistics codegraph query # 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 # Find what calls a function/method (--limit, --json) codegraph callees # Find what a function/method calls (--limit, --json) codegraph impact # Analyze what code is affected by changing a symbol (--depth, --json) diff --git a/__tests__/visualization.test.ts b/__tests__/visualization.test.ts new file mode 100644 index 000000000..c4ea37012 --- /dev/null +++ b/__tests__/visualization.test.ts @@ -0,0 +1,78 @@ +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', ''); + const graph = buildVisualizationGraphFromSubgraph({ + subgraph: { + nodes: new Map([[dangerous.id, dangerous]]), + edges: [], + roots: [dangerous.id], + }, + projectRoot: '/repo', + title: 'Graph ', + mode: 'symbol', + query: dangerous.name, + }); + + const html = renderVisualizationHtml(graph); + + expect(html).toContain('Graph <danger>'); + expect(html).toContain('\\u003c/script\\u003e'); + expect(html).not.toContain(' # 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 # Build context for AI (--format, --max-nodes) codegraph callers # Find what calls a function/method (--limit, --json) codegraph callees # Find what a function/method calls (--limit, --json) @@ -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. diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 0acc70097..51e42365a 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -15,6 +15,7 @@ * codegraph status [path] Show index status * codegraph query Search for symbols * codegraph files [options] Show project file structure + * codegraph visualize [symbol] Generate an interactive HTML graph * codegraph context Build context for a task * codegraph callers Find what calls a function/method * codegraph callees Find what a function/method calls @@ -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'; @@ -1001,6 +1003,90 @@ program } }); +/** + * codegraph visualize [symbol] + */ +program + .command('visualize [symbol]') + .description('Generate an interactive HTML graph visualization') + .option('-p, --path ', 'Project path') + .option('-o, --output ', 'Output HTML path (default: .codegraph/graph.html)') + .option('-d, --depth ', 'Traversal depth when a symbol is provided', '2') + .option('-l, --limit ', 'Maximum nodes to include', '300') + .option('--direction ', 'Traversal direction: incoming, outgoing, both', 'both') + .option('-k, --kind ', 'Comma-separated node kinds to include') + .option('-e, --edge-kind ', '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 */ @@ -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( + 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 { + 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 */ diff --git a/src/types.ts b/src/types.ts index e710e31a1..dda9aa79f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 diff --git a/src/visualization.ts b/src/visualization.ts new file mode 100644 index 000000000..9f37e8b1d --- /dev/null +++ b/src/visualization.ts @@ -0,0 +1,1445 @@ +/** + * Static HTML graph visualization support for the CLI. + */ + +import { + EDGE_KINDS, + Edge, + EdgeKind, + GraphStats, + NODE_KINDS, + Node, + NodeKind, + SearchOptions, + SearchResult, + Subgraph, + TraversalOptions, +} from './types'; + +export const DEFAULT_VISUALIZATION_LIMIT = 300; +export const DEFAULT_VISUALIZATION_DEPTH = 2; + +export const DEFAULT_VISUALIZATION_NODE_KINDS: NodeKind[] = [ + 'file', + 'module', + 'namespace', + 'class', + 'struct', + 'interface', + 'trait', + 'protocol', + 'enum', + 'type_alias', + 'function', + 'method', + 'route', + 'component', +]; + +export const DEFAULT_VISUALIZATION_EDGE_KINDS: EdgeKind[] = [...EDGE_KINDS]; + +export interface VisualizationDataSource { + getStats(): GraphStats; + getNodesByKind(kind: NodeKind): Node[]; + getOutgoingEdges(nodeId: string): Edge[]; + getIncomingEdges(nodeId: string): Edge[]; + searchNodes(query: string, options?: SearchOptions): SearchResult[]; + traverse(startId: string, options?: TraversalOptions): Subgraph; +} + +export interface BuildVisualizationOptions { + projectRoot: string; + symbol?: string; + limit?: number; + depth?: number; + direction?: 'incoming' | 'outgoing' | 'both'; + nodeKinds?: NodeKind[]; + edgeKinds?: EdgeKind[]; +} + +export interface VisualizationNode { + id: string; + label: string; + kind: NodeKind; + qualifiedName: string; + filePath: string; + language: string; + line: number; + signature?: string; + exported: boolean; + root: boolean; + incoming: number; + outgoing: number; + size: number; +} + +export interface VisualizationEdge { + source: string; + target: string; + kind: EdgeKind; + line?: number; + provenance?: Edge['provenance']; +} + +export interface VisualizationGraph { + title: string; + projectRoot: string; + mode: 'overview' | 'symbol'; + query?: string; + generatedAt: number; + nodes: VisualizationNode[]; + edges: VisualizationEdge[]; + rootIds: string[]; + stats: { + totalNodes: number; + totalEdges: number; + includedNodes: number; + includedEdges: number; + truncated: boolean; + }; +} + +const KIND_WEIGHT: Record = { + file: 110, + route: 105, + module: 100, + namespace: 98, + class: 96, + struct: 94, + interface: 92, + trait: 91, + protocol: 91, + component: 90, + function: 86, + method: 84, + enum: 78, + type_alias: 76, + property: 50, + field: 48, + constant: 46, + variable: 40, + enum_member: 34, + import: 24, + export: 24, + parameter: 10, +}; + +const OVERVIEW_POOL_MULTIPLIER = 8; +const OVERVIEW_POOL_MIN = 500; +const OVERVIEW_POOL_MAX = 4000; + +function unique(values: T[]): T[] { + return Array.from(new Set(values)); +} + +function clampInt(value: number | undefined, fallback: number, min: number, max: number): number { + if (!Number.isFinite(value)) return fallback; + return Math.min(Math.max(Math.floor(value!), min), max); +} + +function edgeKey(edge: Edge): string { + return `${edge.source}\u0000${edge.target}\u0000${edge.kind}\u0000${edge.line ?? ''}`; +} + +function isExactSymbolMatch(node: Node, symbol: string): boolean { + return ( + node.name === symbol || + node.qualifiedName === symbol || + node.name.endsWith(`.${symbol}`) || + node.name.endsWith(`::${symbol}`) || + node.qualifiedName.endsWith(`.${symbol}`) || + node.qualifiedName.endsWith(`::${symbol}`) + ); +} + +function nodeBaseScore(node: Node): number { + return ( + KIND_WEIGHT[node.kind] + + (node.isExported ? 20 : 0) + + (node.visibility === 'public' ? 8 : 0) + + (node.kind === 'file' ? 6 : 0) + ); +} + +function collectOverviewCandidates( + source: VisualizationDataSource, + nodeKinds: NodeKind[], + limit: number +): Node[] { + const seen = new Set(); + const nodes: Node[] = []; + + for (const kind of nodeKinds) { + for (const node of source.getNodesByKind(kind)) { + if (seen.has(node.id)) continue; + seen.add(node.id); + nodes.push(node); + } + } + + const poolSize = Math.min( + nodes.length, + Math.max(OVERVIEW_POOL_MIN, Math.min(OVERVIEW_POOL_MAX, limit * OVERVIEW_POOL_MULTIPLIER)) + ); + + return nodes + .sort((a, b) => { + const score = nodeBaseScore(b) - nodeBaseScore(a); + if (score !== 0) return score; + const file = a.filePath.localeCompare(b.filePath); + return file !== 0 ? file : a.name.localeCompare(b.name); + }) + .slice(0, poolSize); +} + +function buildOverviewSubgraph( + source: VisualizationDataSource, + options: Required> +): Subgraph { + const candidates = collectOverviewCandidates(source, options.nodeKinds, options.limit); + const candidateById = new Map(candidates.map((node) => [node.id, node])); + const candidateIds = new Set(candidateById.keys()); + const edgeKinds = new Set(options.edgeKinds); + const edgesByKey = new Map(); + const degree = new Map(); + + for (const node of candidates) { + const outgoing = source.getOutgoingEdges(node.id); + for (const edge of outgoing) { + if (!edgeKinds.has(edge.kind)) continue; + if (!candidateIds.has(edge.target)) continue; + edgesByKey.set(edgeKey(edge), edge); + degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1); + degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1); + } + } + + const selected = candidates + .sort((a, b) => { + const aScore = (degree.get(a.id) ?? 0) * 100 + nodeBaseScore(a); + const bScore = (degree.get(b.id) ?? 0) * 100 + nodeBaseScore(b); + if (bScore !== aScore) return bScore - aScore; + const file = a.filePath.localeCompare(b.filePath); + return file !== 0 ? file : a.name.localeCompare(b.name); + }) + .slice(0, options.limit); + + const nodes = new Map(selected.map((node) => [node.id, node])); + const edges = Array.from(edgesByKey.values()).filter( + (edge) => nodes.has(edge.source) && nodes.has(edge.target) + ); + + return { nodes, edges, roots: [] }; +} + +function mergeSubgraphs(subgraphs: Subgraph[]): Subgraph { + const nodes = new Map(); + const edgesByKey = new Map(); + const roots: string[] = []; + + for (const subgraph of subgraphs) { + for (const [id, node] of subgraph.nodes) { + nodes.set(id, node); + } + for (const edge of subgraph.edges) { + edgesByKey.set(edgeKey(edge), edge); + } + roots.push(...subgraph.roots); + } + + return { + nodes, + edges: Array.from(edgesByKey.values()), + roots: unique(roots), + }; +} + +function findSymbolRoots( + source: VisualizationDataSource, + symbol: string, + nodeKinds: NodeKind[] +): Node[] { + const matches = source.searchNodes(symbol, { + limit: 50, + kinds: nodeKinds.length > 0 ? nodeKinds : undefined, + }); + const exact = matches.filter((match) => isExactSymbolMatch(match.node, symbol)); + const roots = exact.length > 0 ? exact : matches.slice(0, 1); + return unique(roots.map((match) => match.node.id)) + .map((id) => roots.find((match) => match.node.id === id)!.node) + .slice(0, 5); +} + +function buildSymbolSubgraph( + source: VisualizationDataSource, + symbol: string, + options: Required< + Pick + > +): Subgraph { + const roots = findSymbolRoots(source, symbol, options.nodeKinds); + if (roots.length === 0) return { nodes: new Map(), edges: [], roots: [] }; + + const perRootLimit = Math.max(options.limit, Math.ceil(options.limit / roots.length)); + const subgraphs = roots.map((root) => + source.traverse(root.id, { + maxDepth: options.depth, + direction: options.direction, + edgeKinds: options.edgeKinds, + nodeKinds: options.nodeKinds, + limit: perRootLimit, + includeStart: true, + }) + ); + + return mergeSubgraphs(subgraphs); +} + +export function buildVisualizationGraphFromSubgraph(args: { + subgraph: Subgraph; + projectRoot: string; + title: string; + mode: VisualizationGraph['mode']; + query?: string; + limit?: number; + totalNodes?: number; + totalEdges?: number; +}): VisualizationGraph { + const limit = clampInt(args.limit, DEFAULT_VISUALIZATION_LIMIT, 1, 2000); + const originalNodes = Array.from(args.subgraph.nodes.values()); + const originalEdges = args.subgraph.edges; + const rootIds = unique(args.subgraph.roots); + const rootIdSet = new Set(rootIds); + const degree = new Map(); + + for (const edge of originalEdges) { + const sourceDegree = degree.get(edge.source) ?? { incoming: 0, outgoing: 0 }; + sourceDegree.outgoing++; + degree.set(edge.source, sourceDegree); + + const targetDegree = degree.get(edge.target) ?? { incoming: 0, outgoing: 0 }; + targetDegree.incoming++; + degree.set(edge.target, targetDegree); + } + + const selected = originalNodes + .sort((a, b) => { + const aDegree = degree.get(a.id); + const bDegree = degree.get(b.id); + const aScore = + (rootIdSet.has(a.id) ? 100000 : 0) + + ((aDegree?.incoming ?? 0) + (aDegree?.outgoing ?? 0)) * 100 + + nodeBaseScore(a); + const bScore = + (rootIdSet.has(b.id) ? 100000 : 0) + + ((bDegree?.incoming ?? 0) + (bDegree?.outgoing ?? 0)) * 100 + + nodeBaseScore(b); + if (bScore !== aScore) return bScore - aScore; + const file = a.filePath.localeCompare(b.filePath); + return file !== 0 ? file : a.name.localeCompare(b.name); + }) + .slice(0, Math.max(limit, rootIds.length)); + + const selectedIds = new Set(selected.map((node) => node.id)); + const edges = originalEdges.filter( + (edge) => selectedIds.has(edge.source) && selectedIds.has(edge.target) + ); + const includedDegree = new Map(); + + for (const edge of edges) { + const sourceDegree = includedDegree.get(edge.source) ?? { incoming: 0, outgoing: 0 }; + sourceDegree.outgoing++; + includedDegree.set(edge.source, sourceDegree); + + const targetDegree = includedDegree.get(edge.target) ?? { incoming: 0, outgoing: 0 }; + targetDegree.incoming++; + includedDegree.set(edge.target, targetDegree); + } + + const nodes: VisualizationNode[] = selected.map((node) => { + const nodeDegree = includedDegree.get(node.id) ?? { incoming: 0, outgoing: 0 }; + const totalDegree = nodeDegree.incoming + nodeDegree.outgoing; + return { + id: node.id, + label: node.name || node.qualifiedName || node.filePath, + kind: node.kind, + qualifiedName: node.qualifiedName, + filePath: node.filePath, + language: node.language, + line: node.startLine, + signature: node.signature, + exported: node.isExported === true, + root: rootIdSet.has(node.id), + incoming: nodeDegree.incoming, + outgoing: nodeDegree.outgoing, + size: Math.min(22, 6 + Math.sqrt(totalDegree + 1) * 2 + (rootIdSet.has(node.id) ? 4 : 0)), + }; + }); + + return { + title: args.title, + projectRoot: args.projectRoot, + mode: args.mode, + query: args.query, + generatedAt: Date.now(), + nodes, + edges: edges.map((edge) => ({ + source: edge.source, + target: edge.target, + kind: edge.kind, + line: edge.line, + provenance: edge.provenance, + })), + rootIds, + stats: { + totalNodes: args.totalNodes ?? originalNodes.length, + totalEdges: args.totalEdges ?? originalEdges.length, + includedNodes: nodes.length, + includedEdges: edges.length, + truncated: originalNodes.length > nodes.length || originalEdges.length > edges.length, + }, + }; +} + +export function buildVisualizationGraph( + source: VisualizationDataSource, + options: BuildVisualizationOptions +): VisualizationGraph { + const limit = clampInt(options.limit, DEFAULT_VISUALIZATION_LIMIT, 1, 2000); + const depth = clampInt(options.depth, DEFAULT_VISUALIZATION_DEPTH, 1, 10); + const direction = options.direction ?? 'both'; + const nodeKinds = options.nodeKinds?.length + ? options.nodeKinds + : DEFAULT_VISUALIZATION_NODE_KINDS; + const edgeKinds = options.edgeKinds?.length + ? options.edgeKinds + : DEFAULT_VISUALIZATION_EDGE_KINDS; + const stats = source.getStats(); + + if (options.symbol) { + const subgraph = buildSymbolSubgraph(source, options.symbol, { + limit, + depth, + direction, + nodeKinds, + edgeKinds, + }); + + return buildVisualizationGraphFromSubgraph({ + subgraph, + projectRoot: options.projectRoot, + title: `CodeGraph: ${options.symbol}`, + mode: 'symbol', + query: options.symbol, + limit, + totalNodes: stats.nodeCount, + totalEdges: stats.edgeCount, + }); + } + + const subgraph = buildOverviewSubgraph(source, { + limit, + nodeKinds, + edgeKinds, + }); + + return buildVisualizationGraphFromSubgraph({ + subgraph, + projectRoot: options.projectRoot, + title: 'CodeGraph project map', + mode: 'overview', + limit, + totalNodes: stats.nodeCount, + totalEdges: stats.edgeCount, + }); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function serializeForInlineScript(value: unknown): string { + return JSON.stringify(value) + .replace(/&/g, '\\u0026') + .replace(//g, '\\u003e') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); +} + +export function isNodeKind(value: string): value is NodeKind { + return (NODE_KINDS as readonly string[]).includes(value); +} + +export function isEdgeKind(value: string): value is EdgeKind { + return (EDGE_KINDS as readonly string[]).includes(value); +} + +export function renderVisualizationHtml(graph: VisualizationGraph): string { + const title = escapeHtml(graph.title); + const dataJson = serializeForInlineScript(graph); + + return ` + + + + + ${title} + + + +
+ + +
+ + + + + + +
+ + +
+ + + + +`; +} From d1f8d48abcb89928da5b8e3b6292f99e62a091f1 Mon Sep 17 00:00:00 2001 From: xy200303 <3483421977@qq.com> Date: Wed, 3 Jun 2026 18:07:59 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=9B=BE=E8=B0=B1?= =?UTF-8?q?=E5=8A=9B=E5=9C=BA=E5=8F=AF=E8=A7=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __tests__/visualization.test.ts | 5 + src/visualization.ts | 1271 ++++++++++++++++++++----------- 2 files changed, 826 insertions(+), 450 deletions(-) diff --git a/__tests__/visualization.test.ts b/__tests__/visualization.test.ts index c4ea37012..af22a346f 100644 --- a/__tests__/visualization.test.ts +++ b/__tests__/visualization.test.ts @@ -72,6 +72,11 @@ describe('visualization', () => { const html = renderVisualizationHtml(graph); expect(html).toContain('Graph <danger>'); + expect(html).toContain('(values: T[]): T[] { } function clampInt(value: number | undefined, fallback: number, min: number, max: number): number { - if (!Number.isFinite(value)) return fallback; - return Math.min(Math.max(Math.floor(value!), min), max); + if (value === undefined || !Number.isFinite(value)) return fallback; + return Math.min(Math.max(Math.floor(value), min), max); } function edgeKey(edge: Edge): string { @@ -204,8 +204,7 @@ function buildOverviewSubgraph( const degree = new Map(); for (const node of candidates) { - const outgoing = source.getOutgoingEdges(node.id); - for (const edge of outgoing) { + for (const edge of source.getOutgoingEdges(node.id)) { if (!edgeKinds.has(edge.kind)) continue; if (!candidateIds.has(edge.target)) continue; edgesByKey.set(edgeKey(edge), edge); @@ -480,6 +479,10 @@ export function isEdgeKind(value: string): value is EdgeKind { return (EDGE_KINDS as readonly string[]).includes(value); } +function renderStatValue(count: number): string { + return count.toLocaleString(); +} + export function renderVisualizationHtml(graph: VisualizationGraph): string { const title = escapeHtml(graph.title); const dataJson = serializeForInlineScript(graph); @@ -493,61 +496,66 @@ export function renderVisualizationHtml(graph: VisualizationGraph): string {