chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:42:18 +08:00
commit 05f60106aa
288 changed files with 76871 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
out/
*.vsix
+10
View File
@@ -0,0 +1,10 @@
src/**
test/**
node_modules/**
.vscode/**
*.ts
!*.d.ts
.gitignore
tsconfig.json
esbuild.mjs
**/*.map
+47
View File
@@ -0,0 +1,47 @@
# Changelog
## 0.2.2 - 2026-04-11
### Fixed
- Compatible with VS Code 1.115 / Electron 39 by updating the extension SQLite dependency stack.
## 0.2.1 — 2026-04-08
### Fixed
- Compatible with Python backend schema v6 (no extension-side schema changes in this release)
## 0.2.0 — 2026-03-20
### Added
- **Query Graph** command with 8 query patterns (callers_of, callees_of, imports_of, etc.)
- **Find Callees** command to trace all functions called by a target
- **Find Large Functions** command to identify oversized functions/classes
- **Compute Embeddings** command to generate vector embeddings
- **Watch Mode** command for continuous graph updates
- Cursor-aware resolution for blast radius and navigation commands
- Fuzzy fallback search when exact node matches fail
- SCM decorations for git-aware file status
### Changed
- Updated README with complete command table (13 commands)
- All 13 commands now documented
## 0.1.1 — 2026-03-17
### Fixed
- CLI path setting scoped to `machine` level (security fix)
- Secure nonce generation using `crypto.randomBytes()`
## 0.1.0 — 2026-03-17
Initial release.
- Code Graph tree view with file, class, function, type, and test nodes
- Interactive D3.js graph visualisation in a webview panel
- Blast radius analysis from cursor position
- Find callers and find tests commands
- Search across all graph nodes
- Review changes with git-aware impact analysis
- Auto-update graph on file save
- CLI auto-detection and guided installation
- Getting Started walkthrough
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Tirth Kanani
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+94
View File
@@ -0,0 +1,94 @@
# Code Review Graph for VS Code
Visualize code dependencies, blast radius, and review context from your code graph -- directly in VS Code.
## Features
- **Code Graph Explorer** -- Browse files, classes, functions, and their relationships in a tree view
- **Blast Radius** -- See which files and symbols are impacted when you change code
- **Review Changes** -- Automatically detect git changes and show their blast radius
- **Find Callers / Callees** -- Trace all callers or callees of any function
- **Find Tests** -- Locate tests for any symbol
- **Query Graph** -- Run semantic queries (callers, callees, imports, inheritance, tests) with 8 patterns
- **Find Large Functions** -- Identify functions or classes exceeding a line-count threshold
- **Interactive Graph** -- Force-directed D3.js visualization of your code dependencies
- **Live Search** -- Fuzzy search across your entire code graph with instant results
- **Compute Embeddings** -- Generate vector embeddings for semantic search
- **Watch Mode** -- Continuous graph updates as you work
- **Auto-Update** -- Graph rebuilds in the background when you save files
## Quick Start
### 1. Install the Extension
Install **Code Review Graph** from the VS Code Marketplace.
### 2. Install the Backend
The extension requires the `code-review-graph` Python CLI to parse your codebase.
```bash
# Recommended
uv pip install code-review-graph
# Alternatives
pipx install code-review-graph
pip install code-review-graph
```
Requires Python 3.10+.
### 3. Build Your Graph
Open the Command Palette (`Ctrl+Shift+P`) and run **Code Graph: Build Graph**.
The graph database is stored locally at `.code-review-graph/graph.db` and updates automatically on file save.
## Commands
| Command | Description |
|---|---|
| `Code Graph: Build Graph` | Parse the codebase and create the graph database |
| `Code Graph: Update Graph` | Incrementally update the graph |
| `Code Graph: Show Blast Radius` | Show the blast radius for a symbol |
| `Code Graph: Review Changes` | Analyze git changes and show impacted files |
| `Code Graph: Find Callers` | Find all callers of a function |
| `Code Graph: Find Callees` | Find all functions called by a target |
| `Code Graph: Find Tests` | Find tests for a symbol |
| `Code Graph: Find Large Functions` | Find functions/classes exceeding a size threshold |
| `Code Graph: Query Graph` | Run semantic queries (8 patterns: callers_of, callees_of, etc.) |
| `Code Graph: Search` | Search the code graph |
| `Code Graph: Show Graph` | Open the interactive graph visualization |
| `Code Graph: Compute Embeddings` | Generate vector embeddings for semantic search |
| `Code Graph: Watch Mode` | Run graph in watch mode for continuous updates |
## Settings
| Setting | Default | Description |
|---|---|---|
| `codeReviewGraph.cliPath` | `""` | Path to the CLI binary. Leave empty to use the bundled version or one found on `PATH`. |
| `codeReviewGraph.autoUpdate` | `true` | Auto-update the graph on file save. |
| `codeReviewGraph.blastRadiusDepth` | `2` | Max traversal depth for blast radius (1--10). |
| `codeReviewGraph.graphTheme` | `"auto"` | Graph color theme: `auto`, `light`, or `dark`. |
| `codeReviewGraph.graph.maxNodes` | `500` | Max nodes in the graph visualization (10--5000). |
| `codeReviewGraph.graph.defaultEdges` | All except CONTAINS | Edge types shown by default. |
| `codeReviewGraph.treeView.showFiles` | `true` | Show file nodes in the tree view. |
| `codeReviewGraph.treeView.showClasses` | `true` | Show class nodes in the tree view. |
| `codeReviewGraph.treeView.showFunctions` | `true` | Show function nodes in the tree view. |
| `codeReviewGraph.treeView.showTypes` | `true` | Show type nodes in the tree view. |
| `codeReviewGraph.treeView.showTests` | `true` | Show test nodes in the tree view. |
## Requirements
- VS Code 1.85+
- Python 3.10+ (for the backend CLI)
- A workspace with source code to analyze
## Links
- [Main Repository](https://github.com/tirth8205/code-review-graph)
- [Report an Issue](https://github.com/tirth8205/code-review-graph/issues)
## License
MIT
+51
View File
@@ -0,0 +1,51 @@
import * as esbuild from "esbuild";
const isWatch = process.argv.includes("--watch");
const isProduction = process.argv.includes("--production");
/** @type {esbuild.BuildOptions} */
const extensionConfig = {
entryPoints: ["src/extension.ts"],
bundle: true,
outfile: "dist/extension.js",
external: ["vscode", "better-sqlite3"],
format: "cjs",
platform: "node",
target: "node18",
sourcemap: !isProduction,
minify: isProduction,
logLevel: "info",
};
/** @type {esbuild.BuildOptions} */
const webviewConfig = {
entryPoints: ["src/webview/graph.ts"],
bundle: true,
outfile: "dist/webview/graph.js",
format: "iife",
platform: "browser",
target: "es2022",
sourcemap: !isProduction,
minify: isProduction,
logLevel: "info",
};
async function main() {
if (isWatch) {
const extensionCtx = await esbuild.context(extensionConfig);
const webviewCtx = await esbuild.context(webviewConfig);
await Promise.all([extensionCtx.watch(), webviewCtx.watch()]);
console.log("[watch] Build started. Watching for changes...");
} else {
await Promise.all([
esbuild.build(extensionConfig),
esbuild.build(webviewConfig),
]);
console.log("Build complete.");
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- edges -->
<line x1="6" y1="6" x2="18" y2="6" />
<line x1="6" y1="6" x2="6" y2="18" />
<line x1="18" y1="6" x2="12" y2="18" />
<line x1="6" y1="18" x2="12" y2="18" />
<!-- nodes -->
<circle cx="6" cy="6" r="2.5" fill="currentColor" />
<circle cx="18" cy="6" r="2.5" fill="currentColor" />
<circle cx="6" cy="18" r="2.5" fill="currentColor" />
<circle cx="12" cy="18" r="2.5" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,7 @@
## Build Your Graph
Click the button above to parse your codebase and create a knowledge graph.
This usually takes ~10 seconds for a 500-file project. The graph will be stored locally in `.code-review-graph/graph.db`.
After the initial build, the graph updates automatically when you save files.
@@ -0,0 +1,10 @@
## Explore Your Code
Open the **Code Graph** panel in the activity bar to browse your codebase structure.
**Try these commands** (Ctrl+Shift+P):
- **Code Graph: Show Blast Radius** -- See what's impacted when you change code
- **Code Graph: Find Callers** -- Find all callers of a function
- **Code Graph: Find Tests** -- Find tests for any function
- **Code Graph: Search** -- Search across your entire code graph
- **Code Graph: Show Graph** -- Open the interactive graph visualization
@@ -0,0 +1,17 @@
## Install the Backend
code-review-graph needs a Python backend to parse your codebase.
**Requirements:** Python 3.10+
**Recommended:** Install via [uv](https://docs.astral.sh/uv/):
```bash
uv pip install code-review-graph
```
**Alternatives:**
```bash
pipx install code-review-graph
# or
pip install code-review-graph
```
File diff suppressed because it is too large Load Diff
+311
View File
@@ -0,0 +1,311 @@
{
"name": "code-review-graph",
"displayName": "Code Review Graph",
"description": "Visualize code dependencies, blast radius, and review context from your code-review-graph database directly in VS Code.",
"version": "0.2.2",
"publisher": "tirth8205",
"license": "MIT",
"icon": "media/icons/icon.png",
"keywords": [
"code analysis",
"graph",
"visualization",
"dependencies",
"blast radius",
"code review",
"tree-sitter"
],
"repository": {
"type": "git",
"url": "https://github.com/tirth8205/code-review-graph"
},
"engines": {
"vscode": "^1.85.0"
},
"extensionKind": [
"workspace"
],
"categories": [
"Visualization",
"Other"
],
"activationEvents": [
"workspaceContains:.code-review-graph/graph.db",
"onCommand:codeReviewGraph.*",
"onView:codeReviewGraph.*"
],
"main": "./dist/extension.js",
"contributes": {
"viewsContainers": {
"activitybar": [
{
"id": "codeReviewGraph",
"title": "Code Graph",
"icon": "media/icons/graph.svg"
}
]
},
"views": {
"codeReviewGraph": [
{
"id": "codeReviewGraph.codeGraph",
"name": "Code Graph"
},
{
"id": "codeReviewGraph.blastRadius",
"name": "Blast Radius"
},
{
"id": "codeReviewGraph.stats",
"name": "Stats"
}
]
},
"commands": [
{
"command": "codeReviewGraph.showBlastRadius",
"title": "Code Graph: Show Blast Radius",
"icon": "$(pulse)"
},
{
"command": "codeReviewGraph.findCallers",
"title": "Code Graph: Find Callers",
"icon": "$(references)"
},
{
"command": "codeReviewGraph.findTests",
"title": "Code Graph: Find Tests",
"icon": "$(beaker)"
},
{
"command": "codeReviewGraph.queryGraph",
"title": "Code Graph: Query Graph",
"icon": "$(symbol-keyword)"
},
{
"command": "codeReviewGraph.findCallees",
"title": "Code Graph: Find Callees",
"icon": "$(call-outgoing)"
},
{
"command": "codeReviewGraph.findLargeFunctions",
"title": "Code Graph: Find Large Functions",
"icon": "$(warning)"
},
{
"command": "codeReviewGraph.showGraph",
"title": "Code Graph: Show Graph",
"icon": "$(type-hierarchy)"
},
{
"command": "codeReviewGraph.search",
"title": "Code Graph: Search",
"icon": "$(search)"
},
{
"command": "codeReviewGraph.reviewChanges",
"title": "Code Graph: Review Changes",
"icon": "$(git-compare)"
},
{
"command": "codeReviewGraph.embedGraph",
"title": "Code Graph: Compute Embeddings",
"icon": "$(sparkle)"
},
{
"command": "codeReviewGraph.watchGraph",
"title": "Code Graph: Watch Mode",
"icon": "$(eye)"
},
{
"command": "codeReviewGraph.buildGraph",
"title": "Code Graph: Build Graph",
"icon": "$(database)"
},
{
"command": "codeReviewGraph.updateGraph",
"title": "Code Graph: Update Graph",
"icon": "$(sync)"
}
],
"menus": {
"scm/title": [
{
"command": "codeReviewGraph.reviewChanges",
"group": "navigation",
"when": "scmProvider == git"
}
],
"view/item/context": [
{
"command": "codeReviewGraph.showBlastRadius",
"when": "viewItem =~ /node-/",
"group": "codeGraph@1"
},
{
"command": "codeReviewGraph.findCallers",
"when": "viewItem =~ /node-/",
"group": "codeGraph@2"
},
{
"command": "codeReviewGraph.findCallees",
"when": "viewItem =~ /node-/",
"group": "codeGraph@3"
},
{
"command": "codeReviewGraph.findTests",
"when": "viewItem =~ /node-/",
"group": "codeGraph@4"
},
{
"command": "codeReviewGraph.showGraph",
"when": "viewItem =~ /node-/",
"group": "codeGraph@5"
}
]
},
"configuration": {
"title": "Code Review Graph",
"properties": {
"codeReviewGraph.cliPath": {
"type": "string",
"default": "",
"scope": "machine",
"description": "Path to the code-review-graph CLI binary. Leave empty to use the bundled version or the one found on PATH."
},
"codeReviewGraph.autoUpdate": {
"type": "boolean",
"default": true,
"description": "Automatically update the graph database when files are saved."
},
"codeReviewGraph.blastRadiusDepth": {
"type": "number",
"default": 2,
"minimum": 1,
"maximum": 10,
"description": "Maximum depth for blast radius traversal."
},
"codeReviewGraph.graphTheme": {
"type": "string",
"default": "auto",
"enum": [
"auto",
"light",
"dark"
],
"description": "Color theme for the graph visualization. 'auto' follows the VS Code theme."
},
"codeReviewGraph.treeView.showFunctions": {
"type": "boolean",
"default": true,
"description": "Show function nodes in the tree view."
},
"codeReviewGraph.treeView.showClasses": {
"type": "boolean",
"default": true,
"description": "Show class nodes in the tree view."
},
"codeReviewGraph.treeView.showFiles": {
"type": "boolean",
"default": true,
"description": "Show file nodes in the tree view."
},
"codeReviewGraph.treeView.showTypes": {
"type": "boolean",
"default": true,
"description": "Show type nodes in the tree view."
},
"codeReviewGraph.treeView.showTests": {
"type": "boolean",
"default": true,
"description": "Show test nodes in the tree view."
},
"codeReviewGraph.graph.defaultEdges": {
"type": "array",
"default": [
"CALLS",
"IMPORTS_FROM",
"INHERITS",
"IMPLEMENTS",
"TESTED_BY",
"DEPENDS_ON"
],
"items": {
"type": "string",
"enum": [
"CALLS",
"IMPORTS_FROM",
"INHERITS",
"IMPLEMENTS",
"CONTAINS",
"TESTED_BY",
"DEPENDS_ON"
]
},
"description": "Edge types shown by default in the graph visualization."
},
"codeReviewGraph.graph.maxNodes": {
"type": "number",
"default": 500,
"minimum": 10,
"maximum": 5000,
"description": "Maximum number of nodes to display in the graph visualization."
}
}
},
"walkthroughs": [
{
"id": "codeReviewGraph.welcome",
"title": "Get Started with Code Review Graph",
"description": "Build a code graph and explore your codebase visually.",
"steps": [
{
"id": "codeReviewGraph.welcome.install",
"title": "Install the CLI",
"description": "Install the code-review-graph CLI tool to build your graph database.\n\n[Install CLI](command:codeReviewGraph.walkthrough.install)",
"media": {
"markdown": "media/walkthrough/install.md"
}
},
{
"id": "codeReviewGraph.welcome.build",
"title": "Build Your Graph",
"description": "Run the build command to analyze your codebase and create a graph database.\n\n[Build Graph](command:codeReviewGraph.buildGraph)",
"media": {
"markdown": "media/walkthrough/build.md"
}
},
{
"id": "codeReviewGraph.welcome.explore",
"title": "Explore the Graph",
"description": "Open the Code Graph panel in the activity bar to explore your code's structure, blast radius, and review context.\n\n[Open Code Graph](command:codeReviewGraph.walkthrough.explore)",
"media": {
"markdown": "media/walkthrough/explore.md"
}
}
]
}
]
},
"scripts": {
"compile": "node esbuild.mjs",
"watch": "node esbuild.mjs --watch",
"package": "vsce package",
"lint": "tsc --noEmit",
"test": "node --experimental-vm-modules node_modules/@vscode/test-electron/out/runTest.js"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/d3": "^7.4.3",
"@types/node": "^20.11.0",
"@types/vscode": "^1.85.0",
"@vscode/test-electron": "^2.3.8",
"@vscode/vsce": "^2.22.0",
"esbuild": "^0.20.0",
"typescript": "^5.3.3"
},
"dependencies": {
"better-sqlite3": "^12.4.1",
"d3": "^7.9.0"
}
}
+220
View File
@@ -0,0 +1,220 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import * as vscode from 'vscode';
const execFileAsync = promisify(execFile);
const CLI_TIMEOUT_MS = 60_000;
const INSTALL_TIMEOUT_MS = 120_000;
export interface CliResult {
success: boolean;
stdout: string;
stderr: string;
}
export class CliWrapper {
private readonly cliPath: string;
constructor() {
this.cliPath = this.getCliPath();
}
/**
* Check whether the CLI binary is reachable.
*/
async isInstalled(): Promise<boolean> {
try {
await execFileAsync(this.cliPath, ['--version'], { timeout: 10_000 });
return true;
} catch (err: unknown) {
if (isEnoent(err)) {
return false;
}
// Non-zero exit or other transient error — treat as not installed.
return false;
}
}
/**
* Return the CLI version string, or undefined when the CLI is not available.
*/
async getVersion(): Promise<string | undefined> {
try {
const { stdout } = await execFileAsync(this.cliPath, ['--version'], {
timeout: 10_000,
});
return stdout.trim();
} catch {
return undefined;
}
}
/**
* Build (or fully rebuild) the graph database for a workspace.
*/
async buildGraph(
workspaceRoot: string,
options?: { fullRebuild?: boolean },
): Promise<CliResult> {
const args = ['build'];
if (options?.fullRebuild) {
args.push('--full');
}
return vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Code Review Graph: Building graph\u2026',
cancellable: false,
},
() => this.exec(args, workspaceRoot),
);
}
/**
* Incrementally update the graph database for a workspace.
*/
async updateGraph(workspaceRoot: string): Promise<CliResult> {
return this.exec(['update'], workspaceRoot);
}
/**
* Start the watch daemon for continuous file monitoring.
*/
async watchGraph(workspaceRoot: string): Promise<CliResult> {
return this.exec(['watch'], workspaceRoot);
}
/**
* Compute embeddings for all graph nodes.
*/
async embedGraph(workspaceRoot: string): Promise<CliResult> {
return vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Code Review Graph: Computing embeddings\u2026',
cancellable: false,
},
() => this.exec(['embed'], workspaceRoot),
);
}
/**
* Detect which Python package installer is available on the system.
* Checks in preference order: uv, pipx, pip3.
*/
async detectPythonInstaller(): Promise<'uv' | 'pipx' | 'pip' | null> {
const candidates: Array<{ bin: string; result: 'uv' | 'pipx' | 'pip' }> = [
{ bin: 'uv', result: 'uv' },
{ bin: 'pipx', result: 'pipx' },
{ bin: 'pip3', result: 'pip' },
];
for (const { bin, result } of candidates) {
try {
await execFileAsync(bin, ['--version'], { timeout: 10_000 });
return result;
} catch {
// Not found or errored — try next.
}
}
return null;
}
/**
* Install the `code-review-graph` package using the specified installer.
*/
async installBackend(installer: 'uv' | 'pipx' | 'pip'): Promise<CliResult> {
const commandMap: Record<typeof installer, { bin: string; args: string[] }> = {
uv: { bin: 'uv', args: ['pip', 'install', 'code-review-graph'] },
pipx: { bin: 'pipx', args: ['install', 'code-review-graph'] },
pip: { bin: 'pip3', args: ['install', 'code-review-graph'] },
};
const { bin, args } = commandMap[installer];
return vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: `Code Review Graph: Installing via ${installer}\u2026`,
cancellable: false,
},
async () => {
try {
const { stdout, stderr } = await execFileAsync(bin, args, {
timeout: INSTALL_TIMEOUT_MS,
});
return { success: true, stdout, stderr };
} catch (err: unknown) {
return toCliResult(err);
}
},
);
}
// ------------------------------------------------------------------ private
private getCliPath(): string {
const configured = vscode.workspace
.getConfiguration('codeReviewGraph')
.get<string>('cliPath', '');
return configured || 'code-review-graph';
}
/**
* Execute the CLI with the given arguments inside `cwd`.
*/
private async exec(args: string[], cwd?: string): Promise<CliResult> {
try {
const { stdout, stderr } = await execFileAsync(this.cliPath, args, {
cwd,
timeout: CLI_TIMEOUT_MS,
});
return { success: true, stdout, stderr };
} catch (err: unknown) {
return toCliResult(err);
}
}
}
// --------------------------------------------------------------------- helpers
interface ExecError {
code?: string | number;
killed?: boolean;
stdout?: string;
stderr?: string;
message?: string;
}
function isEnoent(err: unknown): boolean {
return (err as ExecError)?.code === 'ENOENT';
}
function toCliResult(err: unknown): CliResult {
const e = err as ExecError;
if (isEnoent(err)) {
return {
success: false,
stdout: '',
stderr: 'CLI binary not found. Is code-review-graph installed?',
};
}
if (e.killed) {
return {
success: false,
stdout: e.stdout ?? '',
stderr: 'Command timed out.',
};
}
return {
success: false,
stdout: e.stdout ?? '',
stderr: e.stderr ?? e.message ?? 'Unknown error',
};
}
@@ -0,0 +1,595 @@
/**
* Read-only SQLite reader for the code-review-graph database.
*
* Opens the database created by the Python backend and provides typed
* query methods. All writes are performed by the Python side; this
* module never mutates the database.
*
* Uses `better-sqlite3` with prepared statements for performance.
*/
import type BetterSqlite3 from 'better-sqlite3';
type DatabaseType = BetterSqlite3.Database;
// Load better-sqlite3 with graceful error handling for ABI mismatches.
// On WSL or mismatched Node.js versions, the native module may fail to load.
// better-sqlite3 uses `export =` so we import the value via require() and
// type it as the DatabaseConstructor.
let Database: typeof import('better-sqlite3');
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
Database = require('better-sqlite3');
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
const isAbiMismatch = msg.includes('NODE_MODULE_VERSION')
|| msg.includes('was compiled against')
|| msg.includes('not a valid Win32');
if (isAbiMismatch) {
console.error(
'[code-review-graph] better-sqlite3 ABI mismatch. '
+ 'Your VS Code uses a different Node.js version than the one '
+ 'this extension was built for. '
+ 'Try: cd ~/.vscode/extensions/code-review-graph-* && npm rebuild better-sqlite3'
);
}
throw err;
}
// ---------------------------------------------------------------------------
// Interfaces
// ---------------------------------------------------------------------------
export type NodeKind = 'File' | 'Class' | 'Function' | 'Type' | 'Test';
export type EdgeKind =
| 'CALLS'
| 'IMPORTS_FROM'
| 'INHERITS'
| 'IMPLEMENTS'
| 'CONTAINS'
| 'TESTED_BY'
| 'DEPENDS_ON';
export interface GraphNode {
id: number;
kind: NodeKind;
name: string;
qualifiedName: string;
filePath: string;
lineStart: number | null;
lineEnd: number | null;
language: string | null;
parentName: string | null;
params: string | null;
returnType: string | null;
modifiers: string | null;
isTest: boolean;
fileHash: string | null;
}
export interface GraphEdge {
id: number;
kind: EdgeKind;
sourceQualified: string;
targetQualified: string;
filePath: string;
line: number;
}
export interface GraphStats {
totalNodes: number;
totalEdges: number;
nodesByKind: Record<string, number>;
edgesByKind: Record<string, number>;
languages: string[];
filesCount: number;
lastUpdated: string | null;
embeddingsCount: number;
}
export interface ImpactRadius {
changedNodes: GraphNode[];
impactedNodes: GraphNode[];
impactedFiles: string[];
edges: GraphEdge[];
}
// ---------------------------------------------------------------------------
// Raw row types returned by better-sqlite3
// ---------------------------------------------------------------------------
interface NodeRow {
id: number;
kind: string;
name: string;
qualified_name: string;
file_path: string;
line_start: number | null;
line_end: number | null;
language: string | null;
parent_name: string | null;
params: string | null;
return_type: string | null;
modifiers: string | null;
is_test: number;
file_hash: string | null;
extra: string;
updated_at: number;
}
interface EdgeRow {
id: number;
kind: string;
source_qualified: string;
target_qualified: string;
file_path: string;
line: number;
extra: string;
updated_at: number;
}
interface CountRow {
cnt: number;
}
interface KindCountRow {
kind: string;
cnt: number;
}
interface LanguageRow {
language: string;
}
interface FilePathRow {
file_path: string;
}
interface MetadataRow {
value: string;
}
// ---------------------------------------------------------------------------
// SqliteReader
// ---------------------------------------------------------------------------
const MAX_OPEN_RETRIES = 3;
const RETRY_BACKOFF_MS = 100;
export class SqliteReader {
private db: DatabaseType | null = null;
/**
* Create a SqliteReader with retry logic that does not block the event loop.
* Prefer this over the constructor when calling from async code.
*/
static async create(dbPath: string): Promise<SqliteReader> {
let lastError: unknown;
for (let attempt = 0; attempt < MAX_OPEN_RETRIES; attempt++) {
try {
return new SqliteReader(dbPath);
} catch (err) {
lastError = err;
if (attempt < MAX_OPEN_RETRIES - 1) {
await new Promise(resolve =>
setTimeout(resolve, RETRY_BACKOFF_MS * (attempt + 1))
);
}
}
}
throw lastError;
}
constructor(dbPath: string) {
this.db = new Database(dbPath, { readonly: true });
this.db.pragma('journal_mode = WAL');
this.db.pragma('busy_timeout = 5000');
}
/**
* Check if the database schema is compatible with this extension version.
* Returns a warning message if incompatible, or undefined if OK.
*/
checkSchemaCompatibility(): string | undefined {
if (!this.db) { return 'Database is not open'; }
try {
// Check that required tables exist
const tables = this.db
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as Array<{ name: string }>;
const tableNames = new Set(tables.map((t) => t.name));
if (!tableNames.has('nodes') || !tableNames.has('edges')) {
return 'Database is missing required tables (nodes/edges). Rebuild required.';
}
// Check for schema_version in metadata if it exists
if (tableNames.has('metadata')) {
const row = this.db
.prepare("SELECT value FROM metadata WHERE key = 'schema_version'")
.get() as { value: string } | undefined;
if (row) {
const version = parseInt(row.value, 10);
// Must match LATEST_VERSION in code_review_graph/migrations.py
const SUPPORTED_SCHEMA_VERSION = 9;
if (!isNaN(version) && version > SUPPORTED_SCHEMA_VERSION) {
return `Database was created with a newer version (schema v${version}). Update the extension.`;
}
}
}
return undefined;
} catch {
return 'Could not verify database schema.';
}
}
/** Close the database connection. Safe to call multiple times. */
close(): void {
if (this.db) {
this.db.close();
this.db = null;
}
}
/** Returns true if the database is open and contains the nodes table. */
isValid(): boolean {
if (!this.db) { return false; }
try {
const row = this.db
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='nodes'"
)
.get() as { name: string } | undefined;
return row !== undefined;
} catch {
return false;
}
}
// -----------------------------------------------------------------------
// Node queries
// -----------------------------------------------------------------------
/** All file paths (kind='File'), ordered by file_path. */
getAllFiles(): string[] {
const rows = this._db()
.prepare(
"SELECT DISTINCT file_path FROM nodes WHERE kind = 'File' ORDER BY file_path"
)
.all() as FilePathRow[];
return rows.map((r) => r.file_path);
}
/** All nodes in a file, ordered by line_start. */
getNodesByFile(filePath: string): GraphNode[] {
const rows = this._db()
.prepare(
'SELECT * FROM nodes WHERE file_path = ? ORDER BY line_start'
)
.all(filePath) as NodeRow[];
return rows.map((r) => this._rowToNode(r));
}
/** Single node lookup by qualified_name. */
getNode(qualifiedName: string): GraphNode | undefined {
const row = this._db()
.prepare('SELECT * FROM nodes WHERE qualified_name = ?')
.get(qualifiedName) as NodeRow | undefined;
return row ? this._rowToNode(row) : undefined;
}
/**
* Innermost node at a cursor position.
*
* Returns the node whose line range contains `line` with the smallest
* span (i.e. the most specific / innermost enclosing node).
*/
getNodeAtCursor(filePath: string, line: number): GraphNode | undefined {
const row = this._db()
.prepare(
`SELECT * FROM nodes
WHERE file_path = ? AND line_start <= ? AND line_end >= ?
ORDER BY (line_end - line_start) ASC
LIMIT 1`
)
.get(filePath, line, line) as NodeRow | undefined;
return row ? this._rowToNode(row) : undefined;
}
/** LIKE search on name and qualified_name. */
searchNodes(query: string, limit: number = 20): GraphNode[] {
const pattern = `%${query}%`;
const rows = this._db()
.prepare(
'SELECT * FROM nodes WHERE name LIKE ? OR qualified_name LIKE ? LIMIT ?'
)
.all(pattern, pattern, limit) as NodeRow[];
return rows.map((r) => this._rowToNode(r));
}
// -----------------------------------------------------------------------
// Edge queries
// -----------------------------------------------------------------------
/** Outgoing edges from a node. */
getEdgesBySource(qualifiedName: string): GraphEdge[] {
const rows = this._db()
.prepare('SELECT * FROM edges WHERE source_qualified = ?')
.all(qualifiedName) as EdgeRow[];
return rows.map((r) => this._rowToEdge(r));
}
/** Incoming edges to a node. */
getEdgesByTarget(qualifiedName: string): GraphEdge[] {
const rows = this._db()
.prepare('SELECT * FROM edges WHERE target_qualified = ?')
.all(qualifiedName) as EdgeRow[];
return rows.map((r) => this._rowToEdge(r));
}
/**
* Edges where both source and target are in the given set.
*
* Uses a parameterised IN clause -- safe for arbitrary set sizes
* (better-sqlite3 handles large parameter lists efficiently).
*/
getEdgesAmong(qualifiedNames: Set<string>): GraphEdge[] {
if (qualifiedNames.size === 0) { return []; }
const qns = [...qualifiedNames];
const placeholders = qns.map(() => '?').join(',');
const rows = this._db()
.prepare(
`SELECT * FROM edges
WHERE source_qualified IN (${placeholders})
AND target_qualified IN (${placeholders})`
)
.all(...qns, ...qns) as EdgeRow[];
return rows.map((r) => this._rowToEdge(r));
}
// -----------------------------------------------------------------------
// Statistics & metadata
// -----------------------------------------------------------------------
/** Aggregate counts, languages, last_updated, and embeddings count. */
getStats(): GraphStats {
const db = this._db();
const totalNodes = (
db.prepare('SELECT COUNT(*) AS cnt FROM nodes').get() as CountRow
).cnt;
const totalEdges = (
db.prepare('SELECT COUNT(*) AS cnt FROM edges').get() as CountRow
).cnt;
const nodesByKind: Record<string, number> = {};
const nkRows = db
.prepare('SELECT kind, COUNT(*) AS cnt FROM nodes GROUP BY kind')
.all() as KindCountRow[];
for (const r of nkRows) { nodesByKind[r.kind] = r.cnt; }
const edgesByKind: Record<string, number> = {};
const ekRows = db
.prepare('SELECT kind, COUNT(*) AS cnt FROM edges GROUP BY kind')
.all() as KindCountRow[];
for (const r of ekRows) { edgesByKind[r.kind] = r.cnt; }
const languages = (
db
.prepare(
"SELECT DISTINCT language FROM nodes WHERE language IS NOT NULL AND language != ''"
)
.all() as LanguageRow[]
).map((r) => r.language);
const filesCount = (
db
.prepare("SELECT COUNT(*) AS cnt FROM nodes WHERE kind = 'File'")
.get() as CountRow
).cnt;
const lastUpdated = this.getMetadata('last_updated') ?? null;
// Embeddings count -- table may not exist
let embeddingsCount = 0;
try {
embeddingsCount = (
db.prepare('SELECT COUNT(*) AS cnt FROM embeddings').get() as CountRow
).cnt;
} catch {
// embeddings table does not exist -- that is fine
}
return {
totalNodes,
totalEdges,
nodesByKind,
edgesByKind,
languages,
filesCount,
lastUpdated,
embeddingsCount,
};
}
/** Read a single key from the metadata table. */
getMetadata(key: string): string | undefined {
const row = this._db()
.prepare('SELECT value FROM metadata WHERE key = ?')
.get(key) as MetadataRow | undefined;
return row?.value;
}
// -----------------------------------------------------------------------
// Impact radius (BFS traversal)
// -----------------------------------------------------------------------
/**
* BFS from changed files to find all impacted nodes within `maxDepth` hops.
*
* Matches the Python `GraphStore.get_impact_radius` logic exactly:
* 1. Collect all nodes in `changedFiles` as seed set.
* 2. BFS forward (outgoing) AND backward (incoming) edges up to `maxDepth`.
* 3. Return impacted nodes (excluding seeds), impacted files, and edges
* among all involved nodes.
*/
getImpactRadius(
changedFiles: string[],
maxDepth: number = 2,
): ImpactRadius {
// 1. Seed: all qualified names in changed files
const seeds = new Set<string>();
for (const f of changedFiles) {
for (const node of this.getNodesByFile(f)) {
seeds.add(node.qualifiedName);
}
}
// 2. BFS outward through all edge types (forward + backward)
const visited = new Set<string>();
let frontier = new Set(seeds);
const impacted = new Set<string>();
let depth = 0;
while (frontier.size > 0 && depth < maxDepth) {
const nextFrontier = new Set<string>();
for (const qn of frontier) {
visited.add(qn);
// Forward edges (things this node affects)
for (const e of this.getEdgesBySource(qn)) {
if (!visited.has(e.targetQualified)) {
nextFrontier.add(e.targetQualified);
impacted.add(e.targetQualified);
}
}
// Reverse edges (things that depend on this node)
for (const e of this.getEdgesByTarget(qn)) {
if (!visited.has(e.sourceQualified)) {
nextFrontier.add(e.sourceQualified);
impacted.add(e.sourceQualified);
}
}
}
frontier = nextFrontier;
depth++;
}
// 3. Resolve to full node info
const changedNodes: GraphNode[] = [];
for (const qn of seeds) {
const node = this.getNode(qn);
if (node) { changedNodes.push(node); }
}
const impactedNodes: GraphNode[] = [];
for (const qn of impacted) {
if (seeds.has(qn)) { continue; }
const node = this.getNode(qn);
if (node) { impactedNodes.push(node); }
}
const impactedFiles = [
...new Set(impactedNodes.map((n) => n.filePath)),
];
// Collect relevant edges among all involved nodes
const allQns = new Set([...seeds, ...impacted]);
const edges = allQns.size > 0 ? this.getEdgesAmong(allQns) : [];
return { changedNodes, impactedNodes, impactedFiles, edges };
}
// -----------------------------------------------------------------------
// Size-based queries
// -----------------------------------------------------------------------
/**
* Find nodes exceeding a line-count threshold.
*
* Mirrors the Python `GraphStore.get_nodes_by_size()` method.
*/
getNodesBySize(
minLines: number = 50,
kind?: string,
filePathPattern?: string,
limit: number = 50,
): Array<GraphNode & { lineCount: number }> {
const conditions = ['(line_end - line_start + 1) >= ?'];
const params: Array<string | number> = [minLines];
if (kind) {
conditions.push('kind = ?');
params.push(kind);
}
if (filePathPattern) {
conditions.push('file_path LIKE ?');
params.push(`%${filePathPattern}%`);
}
params.push(limit);
const where = conditions.join(' AND ');
const rows = this._db()
.prepare(
`SELECT * FROM nodes WHERE ${where} ` + // nosec
'ORDER BY (line_end - line_start + 1) DESC LIMIT ?',
)
.all(...params) as NodeRow[];
return rows.map((r) => ({
...this._rowToNode(r),
lineCount:
r.line_start != null && r.line_end != null
? r.line_end - r.line_start + 1
: 0,
}));
}
// -----------------------------------------------------------------------
// Private helpers
// -----------------------------------------------------------------------
/** Return the open database handle or throw. */
private _db(): DatabaseType {
if (!this.db) {
throw new Error('SqliteReader: database is closed');
}
return this.db;
}
/** Convert a raw node row (snake_case) to a typed GraphNode (camelCase). */
private _rowToNode(row: NodeRow): GraphNode {
return {
id: row.id,
kind: row.kind as NodeKind,
name: row.name,
qualifiedName: row.qualified_name,
filePath: row.file_path,
lineStart: row.line_start,
lineEnd: row.line_end,
language: row.language ?? null,
parentName: row.parent_name ?? null,
params: row.params ?? null,
returnType: row.return_type ?? null,
modifiers: row.modifiers ?? null,
isTest: row.is_test === 1,
fileHash: row.file_hash ?? null,
};
}
/** Convert a raw edge row (snake_case) to a typed GraphEdge (camelCase). */
private _rowToEdge(row: EdgeRow): GraphEdge {
return {
id: row.id,
kind: row.kind as EdgeKind,
sourceQualified: row.source_qualified,
targetQualified: row.target_qualified,
filePath: row.file_path,
line: row.line ?? 0,
};
}
}
@@ -0,0 +1,60 @@
import * as path from 'path';
import * as vscode from 'vscode';
/**
* Return a debounced version of `fn` that delays invocation until `ms`
* milliseconds have elapsed since the last call. The returned function
* has the same signature as the original.
*/
export function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): T {
let timer: ReturnType<typeof setTimeout> | undefined;
const debounced = (...args: Parameters<T>): void => {
if (timer !== undefined) {
clearTimeout(timer);
}
timer = setTimeout(() => {
timer = undefined;
fn(...args);
}, ms);
};
return debounced as unknown as T;
}
/**
* Watches a `graph.db` file on disk and fires a callback whenever the
* file is created or modified (debounced to avoid rapid successive events).
*/
export class GraphWatcher implements vscode.Disposable {
private readonly watcher: vscode.FileSystemWatcher;
private readonly disposables: vscode.Disposable[] = [];
/**
* @param dbPath Absolute path to the `graph.db` file to watch.
* @param onChanged Callback invoked (at most once per 500 ms) when the file changes.
*/
constructor(dbPath: string, onChanged: () => void) {
const dir = path.dirname(dbPath);
const filename = path.basename(dbPath);
this.watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(vscode.Uri.file(dir), filename),
);
const debouncedOnChanged = debounce(onChanged, 500);
this.disposables.push(
this.watcher.onDidChange(() => debouncedOnChanged()),
this.watcher.onDidCreate(() => debouncedOnChanged()),
this.watcher,
);
}
dispose(): void {
for (const d of this.disposables) {
d.dispose();
}
this.disposables.length = 0;
}
}
+983
View File
@@ -0,0 +1,983 @@
import * as vscode from "vscode";
import * as path from "node:path";
import * as fs from "node:fs";
import { SqliteReader } from "./backend/sqlite";
import type { GraphNode } from "./backend/sqlite";
import { CliWrapper } from "./backend/cli";
import {
CodeGraphTreeProvider,
BlastRadiusTreeProvider,
StatsTreeProvider,
} from "./views/treeView";
import { GraphWebviewPanel } from "./views/graphWebview";
import { Installer } from "./onboarding/installer";
import { registerWalkthroughCommands, showWelcomeIfNeeded } from "./onboarding/welcome";
import { StatusBar } from "./views/statusBar";
import { ScmDecorationProvider } from "./features/scmDecorations";
let sqliteReader: SqliteReader | undefined;
let autoUpdateTimer: ReturnType<typeof setTimeout> | undefined;
let scmDecorationProvider: ScmDecorationProvider | undefined;
/**
* Locate the graph database file in the workspace.
* Checks `.code-review-graph/graph.db` first, then falls back to `.code-review-graph.db`.
*/
function findGraphDb(workspaceRoot: string): string | undefined {
const primary = path.join(workspaceRoot, ".code-review-graph", "graph.db");
if (fs.existsSync(primary)) {
return primary;
}
const fallback = path.join(workspaceRoot, ".code-review-graph.db");
if (fs.existsSync(fallback)) {
return fallback;
}
return undefined;
}
/**
* Get the workspace root folder path, or undefined if no workspace is open.
* Checks all workspace folders for a graph database (multi-root support).
*/
function getWorkspaceRoot(): string | undefined {
const folders = vscode.workspace.workspaceFolders;
if (!folders) { return undefined; }
// Prefer the folder that has a graph database
for (const folder of folders) {
if (findGraphDb(folder.uri.fsPath)) {
return folder.uri.fsPath;
}
}
// Fall back to first folder
return folders[0]?.uri.fsPath;
}
/**
* Navigate to a node's source file location.
*/
async function navigateToNode(node: GraphNode): Promise<void> {
const workspaceRoot = getWorkspaceRoot();
const filePath = workspaceRoot
? path.join(workspaceRoot, node.filePath)
: node.filePath;
const doc = await vscode.workspace.openTextDocument(filePath);
const line = Math.max(0, (node.lineStart ?? 1) - 1);
await vscode.window.showTextDocument(doc, {
selection: new vscode.Range(line, 0, line, 0),
});
}
/**
* Register all extension commands.
*/
function registerCommands(
context: vscode.ExtensionContext,
cli: CliWrapper
): void {
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.buildGraph",
async () => {
const workspaceRoot = getWorkspaceRoot();
if (!workspaceRoot) {
vscode.window.showErrorMessage("No workspace folder is open.");
return;
}
const result = await cli.buildGraph(workspaceRoot);
if (result.success) {
await reinitialize(context);
vscode.window.showInformationMessage("Code Graph: Build complete.");
} else {
vscode.window.showErrorMessage(
`Code Graph: Build failed. ${result.stderr}`
);
}
}
)
);
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.updateGraph",
async () => {
const workspaceRoot = getWorkspaceRoot();
if (!workspaceRoot) {
vscode.window.showErrorMessage("No workspace folder is open.");
return;
}
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Code Graph: Updating graph...",
cancellable: false,
},
async () => {
const result = await cli.updateGraph(workspaceRoot);
if (!result.success) {
vscode.window.showErrorMessage(
`Code Graph: Update failed. ${result.stderr}`
);
}
}
);
}
)
);
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.showBlastRadius",
async (qualifiedNameOrUri?: string | vscode.Uri) => {
if (!sqliteReader) {
vscode.window.showWarningMessage(
"Code Graph: No graph database loaded."
);
return;
}
let qualifiedName: string | undefined;
if (typeof qualifiedNameOrUri === "string") {
qualifiedName = qualifiedNameOrUri;
} else {
qualifiedName = await vscode.window.showInputBox({
prompt:
"Enter the qualified name (e.g., my_module.MyClass.my_method)",
placeHolder: "my_module.my_function",
});
}
if (!qualifiedName) {
return;
}
// Find the file for this node and compute impact radius
const node = sqliteReader.getNode(qualifiedName);
if (!node) {
vscode.window.showInformationMessage(
`Code Graph: Node "${qualifiedName}" not found.`
);
return;
}
const config = vscode.workspace.getConfiguration("codeReviewGraph");
const depth = config.get<number>("blastRadiusDepth", 2);
const impact = sqliteReader.getImpactRadius([node.filePath], depth);
if (impact.impactedNodes.length === 0) {
vscode.window.showInformationMessage(
`Code Graph: No blast radius found for "${qualifiedName}".`
);
return;
}
await vscode.commands.executeCommand(
"codeReviewGraph.blastRadius.focus"
);
}
)
);
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.findCallers",
async (qualifiedName?: string) => {
if (!sqliteReader) {
vscode.window.showWarningMessage(
"Code Graph: No graph database loaded."
);
return;
}
if (!qualifiedName) {
qualifiedName = await vscode.window.showInputBox({
prompt: "Enter the qualified name to find callers for",
placeHolder: "my_module.my_function",
});
}
if (!qualifiedName) {
return;
}
const edges = sqliteReader.getEdgesByTarget(qualifiedName);
const callerEdges = edges.filter((e) => e.kind === "CALLS");
if (callerEdges.length === 0) {
vscode.window.showInformationMessage(
`Code Graph: No callers found for "${qualifiedName}".`
);
return;
}
const items = callerEdges.map((e) => {
const callerNode = sqliteReader!.getNode(e.sourceQualified);
return {
label: callerNode?.name ?? e.sourceQualified,
description: callerNode?.filePath ?? e.filePath,
detail: `Line ${callerNode?.lineStart ?? e.line}`,
node: callerNode,
edge: e,
};
});
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `Callers of ${qualifiedName}`,
});
if (selected?.node) {
await navigateToNode(selected.node);
}
}
)
);
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.findTests",
async (qualifiedName?: string) => {
if (!sqliteReader) {
vscode.window.showWarningMessage(
"Code Graph: No graph database loaded."
);
return;
}
if (!qualifiedName) {
qualifiedName = await vscode.window.showInputBox({
prompt: "Enter the qualified name to find tests for",
placeHolder: "my_module.my_function",
});
}
if (!qualifiedName) {
return;
}
// Find tests via TESTED_BY edges
const edges = sqliteReader.getEdgesByTarget(qualifiedName);
const testEdges = edges.filter((e) => e.kind === "TESTED_BY");
// Also check reverse: source is the node, target is the test
const outEdges = sqliteReader.getEdgesBySource(qualifiedName);
const outTestEdges = outEdges.filter((e) => e.kind === "TESTED_BY");
const allTestQualifiedNames = new Set([
...testEdges.map((e) => e.sourceQualified),
...outTestEdges.map((e) => e.targetQualified),
]);
if (allTestQualifiedNames.size === 0) {
vscode.window.showInformationMessage(
`Code Graph: No tests found for "${qualifiedName}".`
);
return;
}
const items: Array<{
label: string;
description: string;
detail: string;
node: GraphNode | undefined;
}> = [];
for (const tqn of allTestQualifiedNames) {
const testNode = sqliteReader.getNode(tqn);
items.push({
label: testNode?.name ?? tqn,
description: testNode?.filePath ?? "",
detail: `Line ${testNode?.lineStart ?? "?"}`,
node: testNode,
});
}
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `Tests for ${qualifiedName}`,
});
if (selected?.node) {
await navigateToNode(selected.node);
}
}
)
);
// -----------------------------------------------------------------
// codeReviewGraph.queryGraph — expose all 8 query patterns
// -----------------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand("codeReviewGraph.queryGraph", async () => {
if (!sqliteReader) {
vscode.window.showWarningMessage("Code Graph: No graph database loaded.");
return;
}
const patterns = [
{ label: "callers_of", description: "Find functions calling the target" },
{ label: "callees_of", description: "Find functions called by the target" },
{ label: "imports_of", description: "Find modules imported by a file" },
{ label: "importers_of", description: "Find files importing from the target" },
{ label: "children_of", description: "Find nodes contained in a file or class" },
{ label: "tests_for", description: "Find tests for a function or class" },
{ label: "inheritors_of", description: "Find classes inheriting/implementing the target" },
{ label: "file_summary", description: "List all nodes in a file" },
];
const pattern = await vscode.window.showQuickPick(patterns, {
placeHolder: "Select a query pattern",
});
if (!pattern) { return; }
const target = await vscode.window.showInputBox({
prompt: `Enter the target for ${pattern.label}`,
placeHolder: "e.g., my_module.py::my_function or path/to/file.py",
});
if (!target) { return; }
// Map pattern to edge kind + direction
type QueryDef = { edgeKind: string; direction: "incoming" | "outgoing"; nodeFilter?: string };
const queryMap: Record<string, QueryDef> = {
callers_of: { edgeKind: "CALLS", direction: "incoming" },
callees_of: { edgeKind: "CALLS", direction: "outgoing" },
imports_of: { edgeKind: "IMPORTS_FROM", direction: "outgoing" },
importers_of: { edgeKind: "IMPORTS_FROM", direction: "incoming" },
children_of: { edgeKind: "CONTAINS", direction: "outgoing" },
tests_for: { edgeKind: "TESTED_BY", direction: "incoming" },
inheritors_of: { edgeKind: "INHERITS", direction: "incoming" },
file_summary: { edgeKind: "CONTAINS", direction: "outgoing" },
};
const qdef = queryMap[pattern.label];
if (!qdef) { return; }
// Try exact match, then search
let node = sqliteReader.getNode(target);
if (!node) {
const matches = sqliteReader.searchNodes(target, 5);
if (matches.length === 1) {
node = matches[0];
} else if (matches.length > 1) {
const selected = await vscode.window.showQuickPick(
matches.map(m => ({
label: m.name,
description: `${m.kind} · ${m.filePath}`,
node: m,
})),
{ placeHolder: `Multiple matches for "${target}" — select one` },
);
if (!selected) { return; }
node = selected.node;
}
}
if (!node) {
vscode.window.showInformationMessage(`Code Graph: "${target}" not found.`);
return;
}
const edges = qdef.direction === "incoming"
? sqliteReader.getEdgesByTarget(node.qualifiedName)
: sqliteReader.getEdgesBySource(node.qualifiedName);
const filtered = edges.filter(e => e.kind === qdef.edgeKind);
if (filtered.length === 0) {
vscode.window.showInformationMessage(
`Code Graph: No ${pattern.label} results for "${node.name}".`
);
return;
}
const items = filtered.map(e => {
const relatedQn = qdef.direction === "incoming" ? e.sourceQualified : e.targetQualified;
const relatedNode = sqliteReader!.getNode(relatedQn);
return {
label: relatedNode?.name ?? relatedQn,
description: relatedNode ? `${relatedNode.kind} · ${relatedNode.filePath}` : "",
detail: `Line ${relatedNode?.lineStart ?? e.line}`,
node: relatedNode,
};
});
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `${pattern.label}: ${node.name} (${filtered.length} results)`,
});
if (selected?.node) {
await navigateToNode(selected.node);
}
})
);
// -----------------------------------------------------------------
// codeReviewGraph.findCallees
// -----------------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.findCallees",
async (qualifiedName?: string) => {
if (!sqliteReader) {
vscode.window.showWarningMessage("Code Graph: No graph database loaded.");
return;
}
if (!qualifiedName) {
qualifiedName = await vscode.window.showInputBox({
prompt: "Enter the qualified name to find callees for",
placeHolder: "my_module.my_function",
});
}
if (!qualifiedName) { return; }
const edges = sqliteReader.getEdgesBySource(qualifiedName);
const calleeEdges = edges.filter(e => e.kind === "CALLS");
if (calleeEdges.length === 0) {
vscode.window.showInformationMessage(
`Code Graph: No callees found for "${qualifiedName}".`
);
return;
}
const items = calleeEdges.map(e => {
const calleeNode = sqliteReader!.getNode(e.targetQualified);
return {
label: calleeNode?.name ?? e.targetQualified,
description: calleeNode?.filePath ?? e.filePath,
detail: `Line ${calleeNode?.lineStart ?? e.line}`,
node: calleeNode,
};
});
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `Callees of ${qualifiedName}`,
});
if (selected?.node) {
await navigateToNode(selected.node);
}
}
)
);
// -----------------------------------------------------------------
// codeReviewGraph.findLargeFunctions
// -----------------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.findLargeFunctions",
async () => {
if (!sqliteReader) {
vscode.window.showWarningMessage("Code Graph: No graph database loaded.");
return;
}
const minLinesStr = await vscode.window.showInputBox({
prompt: "Minimum line count threshold",
placeHolder: "50",
value: "50",
});
if (!minLinesStr) { return; }
const minLines = parseInt(minLinesStr, 10);
if (isNaN(minLines) || minLines < 1) {
vscode.window.showWarningMessage("Code Graph: Invalid line count.");
return;
}
const results = sqliteReader.getNodesBySize(minLines, undefined, undefined, 50);
if (results.length === 0) {
vscode.window.showInformationMessage(
`Code Graph: No functions found with ${minLines}+ lines.`
);
return;
}
const items = results.map(r => ({
label: `${r.name} (${r.lineCount} lines)`,
description: `${r.kind} · ${r.filePath}`,
detail: `Lines ${r.lineStart ?? "?"}${r.lineEnd ?? "?"}`,
node: r as GraphNode,
}));
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `${results.length} nodes with ${minLines}+ lines`,
});
if (selected?.node) {
await navigateToNode(selected.node);
}
}
)
);
// -----------------------------------------------------------------
// codeReviewGraph.embedGraph
// -----------------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand("codeReviewGraph.embedGraph", async () => {
const workspaceRoot = getWorkspaceRoot();
if (!workspaceRoot) {
vscode.window.showErrorMessage("No workspace folder is open.");
return;
}
const result = await cli.embedGraph(workspaceRoot);
if (result.success) {
vscode.window.showInformationMessage("Code Graph: Embeddings computed.");
} else {
const msg = result.stderr.includes("not installed")
? "Install embeddings support: pip install code-review-graph[embeddings]"
: `Embedding failed: ${result.stderr}`;
vscode.window.showErrorMessage(`Code Graph: ${msg}`);
}
})
);
// -----------------------------------------------------------------
// codeReviewGraph.watchGraph
// -----------------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand("codeReviewGraph.watchGraph", async () => {
const workspaceRoot = getWorkspaceRoot();
if (!workspaceRoot) {
vscode.window.showErrorMessage("No workspace folder is open.");
return;
}
vscode.window.showInformationMessage("Code Graph: Watch mode started.");
const result = await cli.watchGraph(workspaceRoot);
if (!result.success) {
vscode.window.showErrorMessage(
`Code Graph: Watch failed. ${result.stderr}`
);
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand("codeReviewGraph.showGraph", async () => {
if (!sqliteReader) {
vscode.window.showWarningMessage(
"Code Graph: No graph database loaded. Run 'Code Graph: Build Graph' first."
);
return;
}
GraphWebviewPanel.createOrShow(context.extensionUri, sqliteReader);
})
);
context.subscriptions.push(
vscode.commands.registerCommand("codeReviewGraph.search", async () => {
if (!sqliteReader) {
vscode.window.showWarningMessage(
"Code Graph: No graph database loaded."
);
return;
}
const query = await vscode.window.showInputBox({
prompt: "Search the code graph",
placeHolder: "Enter a function, class, or module name",
});
if (!query) {
return;
}
const results = sqliteReader.searchNodes(query);
if (results.length === 0) {
vscode.window.showInformationMessage(
`Code Graph: No results found for "${query}".`
);
return;
}
const items = results.map((r) => ({
label: r.name,
description: r.kind,
detail: r.filePath
? `${r.filePath}:${r.lineStart ?? ""}`
: undefined,
result: r,
}));
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `Results for "${query}"`,
});
if (selected?.result) {
await navigateToNode(selected.result);
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.reviewChanges",
async () => {
if (!sqliteReader) {
vscode.window.showWarningMessage(
"Code Graph: No graph database loaded."
);
return;
}
const workspaceRoot = getWorkspaceRoot();
if (!workspaceRoot) {
vscode.window.showErrorMessage("No workspace folder is open.");
return;
}
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Code Graph: Analyzing changes...",
cancellable: false,
},
async () => {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
let changedFiles: string[] = [];
try {
const r1 = await execFileAsync(
"git", ["diff", "--name-only", "HEAD"],
{ cwd: workspaceRoot }
);
const r2 = await execFileAsync(
"git", ["diff", "--cached", "--name-only"],
{ cwd: workspaceRoot }
);
changedFiles = [...new Set([
...r1.stdout.trim().split("\n").filter(Boolean),
...r2.stdout.trim().split("\n").filter(Boolean),
])];
} catch {
// git not available or not a git repo
}
if (changedFiles.length === 0) {
vscode.window.showInformationMessage(
"Code Graph: No changes detected."
);
return;
}
const absFiles = changedFiles.map(f => path.join(workspaceRoot, f));
const impact = sqliteReader!.getImpactRadius(absFiles);
// --- Generate review guidance ---
const guidance: string[] = [];
const impactedFileCount = new Set(
impact.impactedNodes.map(n => n.filePath)
).size;
// Test coverage check
const untestedFns: string[] = [];
for (const node of impact.changedNodes) {
if (node.kind !== "Function" || node.isTest) { continue; }
const edges = sqliteReader!.getEdgesByTarget(node.qualifiedName);
const hasCoverage = edges.some(e => e.kind === "TESTED_BY");
if (!hasCoverage) {
const out = sqliteReader!.getEdgesBySource(node.qualifiedName);
if (!out.some(e => e.kind === "TESTED_BY")) {
untestedFns.push(node.name);
}
}
}
if (untestedFns.length > 0) {
guidance.push(
`\u26a0\ufe0f **${untestedFns.length} changed function(s) lack test coverage**: ${untestedFns.slice(0, 5).join(", ")}${untestedFns.length > 5 ? "..." : ""}`
);
}
// Wide blast radius warning
if (impactedFileCount > 10) {
guidance.push(
`\u26a0\ufe0f **Wide blast radius**: ${impactedFileCount} files impacted — consider splitting this change.`
);
}
// Inheritance changes
const inheritanceChanges = impact.edges.filter(
e => e.kind === "INHERITS" || e.kind === "IMPLEMENTS"
);
if (inheritanceChanges.length > 0) {
guidance.push(
`\u26a0\ufe0f **Inheritance chain affected**: ${inheritanceChanges.length} inheritance/implementation edge(s) touched.`
);
}
// Cross-file impacts
if (impact.impactedNodes.length > 0) {
guidance.push(
`\u2139\ufe0f ${impact.impactedNodes.length} nodes in ${impactedFileCount} file(s) may be affected by these changes.`
);
}
// Show guidance in output channel
const channel = vscode.window.createOutputChannel("Code Graph Review", { log: true });
channel.appendLine("# Review Guidance");
channel.appendLine("");
channel.appendLine(`Changed files: ${changedFiles.length}`);
channel.appendLine(`Changed nodes: ${impact.changedNodes.length}`);
channel.appendLine(`Impacted nodes: ${impact.impactedNodes.length}`);
channel.appendLine(`Impacted files: ${impactedFileCount}`);
channel.appendLine("");
if (guidance.length > 0) {
for (const g of guidance) { channel.appendLine(g); }
} else {
channel.appendLine("\u2705 No concerns detected.");
}
channel.show(true);
// Also show in graph
GraphWebviewPanel.createOrShow(
context.extensionUri,
sqliteReader!,
impact
);
// Update SCM decorations
if (scmDecorationProvider && sqliteReader) {
await scmDecorationProvider.update(sqliteReader, workspaceRoot);
}
}
);
}
)
);
}
/**
* Reinitialize the reader and tree providers after a graph rebuild.
*/
async function reinitialize(
context: vscode.ExtensionContext
): Promise<void> {
const workspaceRoot = getWorkspaceRoot();
if (!workspaceRoot) {
return;
}
const dbPath = findGraphDb(workspaceRoot);
if (!dbPath) {
return;
}
sqliteReader?.close();
sqliteReader = new SqliteReader(dbPath);
// Refresh tree views
await vscode.commands.executeCommand(
"codeReviewGraph.codeGraph.refresh"
);
}
/**
* Set up a FileSystemWatcher to detect changes to the graph database.
*/
function watchGraphDb(context: vscode.ExtensionContext): void {
const watcher = vscode.workspace.createFileSystemWatcher(
"**/.code-review-graph/graph.db"
);
const dbPathRef = { current: "" };
const workspaceRoot = getWorkspaceRoot();
if (workspaceRoot) {
const dbPath = findGraphDb(workspaceRoot);
if (dbPath) {
dbPathRef.current = dbPath;
}
}
watcher.onDidChange(() => {
// Close and reopen to pick up external writes
if (sqliteReader && dbPathRef.current) {
sqliteReader.close();
sqliteReader = new SqliteReader(dbPathRef.current);
vscode.commands.executeCommand("codeReviewGraph.codeGraph.refresh");
}
});
watcher.onDidCreate(async () => {
const wsRoot = getWorkspaceRoot();
if (wsRoot && !sqliteReader) {
const dbPath = findGraphDb(wsRoot);
if (dbPath) {
dbPathRef.current = dbPath;
sqliteReader = new SqliteReader(dbPath);
vscode.commands.executeCommand("codeReviewGraph.codeGraph.refresh");
}
}
});
watcher.onDidDelete(() => {
sqliteReader?.close();
sqliteReader = undefined;
dbPathRef.current = "";
});
context.subscriptions.push(watcher);
}
/**
* Set up debounced auto-update on file save.
*/
function setupAutoUpdate(
context: vscode.ExtensionContext,
cli: CliWrapper
): void {
const AUTO_UPDATE_DEBOUNCE_MS = 2000;
const onSave = vscode.workspace.onDidSaveTextDocument(() => {
const config = vscode.workspace.getConfiguration("codeReviewGraph");
if (!config.get<boolean>("autoUpdate", true)) {
return;
}
if (autoUpdateTimer) {
clearTimeout(autoUpdateTimer);
}
autoUpdateTimer = setTimeout(async () => {
const wsRoot = getWorkspaceRoot();
if (!wsRoot || !sqliteReader) {
return;
}
try {
await cli.updateGraph(wsRoot);
} catch {
// Silently ignore update errors on save; user can manually update
}
}, AUTO_UPDATE_DEBOUNCE_MS);
});
context.subscriptions.push(onSave);
}
/**
* Extension activation entry point.
*/
export async function activate(
context: vscode.ExtensionContext
): Promise<void> {
const cli = new CliWrapper();
const installer = new Installer(cli);
// Register walkthrough commands
registerWalkthroughCommands(context, cli, installer);
const workspaceRoot = getWorkspaceRoot();
if (workspaceRoot) {
const dbPath = findGraphDb(workspaceRoot);
if (dbPath) {
// Graph database found - initialize
sqliteReader = new SqliteReader(dbPath);
// Schema compatibility check
const schemaWarning = sqliteReader.checkSchemaCompatibility();
if (schemaWarning) {
const choice = await vscode.window.showWarningMessage(
`Code Graph: ${schemaWarning}`,
"Rebuild Graph",
"Dismiss"
);
if (choice === "Rebuild Graph") {
await vscode.commands.executeCommand("codeReviewGraph.buildGraph");
}
}
// Register tree view providers
const codeGraphProvider = new CodeGraphTreeProvider(
sqliteReader,
workspaceRoot
);
const blastRadiusProvider = new BlastRadiusTreeProvider();
const statsProvider = new StatsTreeProvider(sqliteReader);
context.subscriptions.push(
vscode.window.registerTreeDataProvider(
"codeReviewGraph.codeGraph",
codeGraphProvider
),
vscode.window.registerTreeDataProvider(
"codeReviewGraph.blastRadius",
blastRadiusProvider
),
vscode.window.registerTreeDataProvider(
"codeReviewGraph.stats",
statsProvider
)
);
// Create status bar
const statusBar = new StatusBar();
statusBar.update(sqliteReader);
statusBar.show();
context.subscriptions.push(statusBar);
// Register SCM file decoration provider
scmDecorationProvider = new ScmDecorationProvider();
context.subscriptions.push(
vscode.window.registerFileDecorationProvider(scmDecorationProvider)
);
} else {
// No graph database found - show welcome
showWelcomeIfNeeded(context);
}
}
// Register revealInTree command for bidirectional graph→tree sync
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.revealInTree",
(_qualifiedName: string) => {
// When a node is clicked in the graph, highlight it in the graph
// The graph webview already calls this; the tree view sync relies
// on the file navigation that nodeClicked also triggers.
// This command is a hook for future tree reveal integration.
GraphWebviewPanel.highlightNode(_qualifiedName);
}
)
);
// Register commands (always, even without a database)
registerCommands(context, cli);
// Watch for graph.db changes
watchGraphDb(context);
// Set up auto-update on save
setupAutoUpdate(context, cli);
}
/**
* Extension deactivation cleanup.
*/
export function deactivate(): void {
if (autoUpdateTimer) {
clearTimeout(autoUpdateTimer);
autoUpdateTimer = undefined;
}
sqliteReader?.close();
sqliteReader = undefined;
}
@@ -0,0 +1,70 @@
import * as vscode from 'vscode';
import { SqliteReader } from '../backend/sqlite';
import { BlastRadiusTreeProvider } from '../views/treeView';
import { resolveNodeAtCursor } from './cursorResolver';
/**
* Register the cursor-aware blast radius command.
*
* When invoked the command:
* 1. Gets the active editor's file path and cursor line.
* 2. Resolves the innermost node at cursor via the graph database.
* 3. Falls back to the file-level node when no specific node is found.
* 4. Runs a BFS impact radius query up to the configured depth.
* 5. Updates the BlastRadiusTreeProvider with the results.
* 6. Focuses the blast radius tree view.
*/
export function registerBlastRadiusCommand(
context: vscode.ExtensionContext,
getReader: () => SqliteReader | undefined,
blastRadiusProvider: BlastRadiusTreeProvider,
workspaceRoot: string,
): void {
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewGraph.showBlastRadius', async () => {
const reader = getReader();
if (!reader) {
vscode.window.showWarningMessage('Code Graph: No graph database loaded.');
return;
}
// --- Active editor check ---
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showWarningMessage('Open a file first');
return;
}
// --- Resolve file path and cursor position ---
const absFilePath = editor.document.uri.fsPath;
const cursorLine = editor.selection.active.line + 1; // 1-based
// --- Resolve node at cursor ---
const nodeAtCursor = reader.getNodeAtCursor(absFilePath, cursorLine);
// Determine the file path to feed into getImpactRadius.
// If we found a node, use its filePath (which is the canonical path
// stored in the database). Otherwise fall back to the editor path.
const filePath = nodeAtCursor ? nodeAtCursor.filePath : absFilePath;
// --- Read depth from settings ---
const config = vscode.workspace.getConfiguration('codeReviewGraph');
const depth = config.get<number>('blastRadiusDepth', 2);
// --- Compute blast radius ---
const impact = reader.getImpactRadius([filePath], depth);
// --- Update tree provider ---
blastRadiusProvider.setResults(impact.changedNodes, impact.impactedNodes);
// --- Focus the blast radius view ---
await vscode.commands.executeCommand('codeReviewGraph.blastRadius.focus');
// --- Summary message ---
const impactedFileCount = new Set(impact.impactedNodes.map((n) => n.filePath)).size;
vscode.window.showInformationMessage(
`Blast radius: ${impact.impactedNodes.length} nodes impacted across ${impactedFileCount} files`,
);
}),
);
}
@@ -0,0 +1,37 @@
import * as vscode from 'vscode';
import { SqliteReader, GraphNode } from '../backend/sqlite';
/**
* Resolve the innermost graph node at the current cursor position.
*
* Returns `undefined` when there is no active editor or no node spans the
* cursor line in the graph database.
*/
export function resolveNodeAtCursor(
reader: SqliteReader,
): GraphNode | undefined {
const editor = vscode.window.activeTextEditor;
if (!editor) {
return undefined;
}
const filePath = editor.document.uri.fsPath;
const line = editor.selection.active.line + 1; // VS Code is 0-based, SQLite data is 1-based
return reader.getNodeAtCursor(filePath, line);
}
/**
* Open a document and scroll to the node's start line.
*
* The node's `filePath` is treated as an absolute path. If `lineStart` is
* null the file is opened at the top.
*/
export async function navigateToNode(node: GraphNode): Promise<void> {
const uri = vscode.Uri.file(node.filePath);
const doc = await vscode.workspace.openTextDocument(uri);
const line = Math.max(0, (node.lineStart ?? 1) - 1);
await vscode.window.showTextDocument(doc, {
selection: new vscode.Range(line, 0, line, 0),
});
}
@@ -0,0 +1,196 @@
import * as vscode from 'vscode';
import { SqliteReader, GraphNode } from '../backend/sqlite';
import { resolveNodeAtCursor, navigateToNode } from './cursorResolver';
/**
* Register the navigation commands: findCallers, findTests, and search.
*/
export function registerNavigationCommands(
context: vscode.ExtensionContext,
getReader: () => SqliteReader | undefined,
): void {
// -----------------------------------------------------------------
// codeReviewGraph.findCallers
// -----------------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewGraph.findCallers', async () => {
const reader = getReader();
if (!reader) {
vscode.window.showWarningMessage('Code Graph: No graph database loaded.');
return;
}
// Resolve node at cursor
const node = resolveNodeAtCursor(reader);
if (!node) {
vscode.window.showWarningMessage(
'Code Graph: No graph node found at the current cursor position.',
);
return;
}
// Query incoming CALLS edges
const edges = reader.getEdgesByTarget(node.qualifiedName);
const callerEdges = edges.filter((e) => e.kind === 'CALLS');
if (callerEdges.length === 0) {
vscode.window.showInformationMessage(
`Code Graph: No callers found for "${node.name}".`,
);
return;
}
// Build QuickPick items, resolving each caller to its full node
const items: Array<{
label: string;
description: string;
detail: string;
node: GraphNode | undefined;
}> = [];
for (const edge of callerEdges) {
const callerNode = reader.getNode(edge.sourceQualified);
items.push({
label: callerNode?.name ?? edge.sourceQualified,
description: callerNode?.filePath ?? edge.filePath,
detail: `Line ${callerNode?.lineStart ?? edge.line}`,
node: callerNode,
});
}
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `Callers of ${node.name}`,
});
if (selected?.node) {
await navigateToNode(selected.node);
}
}),
);
// -----------------------------------------------------------------
// codeReviewGraph.findTests
// -----------------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewGraph.findTests', async () => {
const reader = getReader();
if (!reader) {
vscode.window.showWarningMessage('Code Graph: No graph database loaded.');
return;
}
// Resolve node at cursor
const node = resolveNodeAtCursor(reader);
if (!node) {
vscode.window.showWarningMessage(
'Code Graph: No graph node found at the current cursor position.',
);
return;
}
// --- Collect test qualified names from TESTED_BY edges (both directions) ---
const incomingEdges = reader.getEdgesByTarget(node.qualifiedName);
const incomingTestEdges = incomingEdges.filter((e) => e.kind === 'TESTED_BY');
const outgoingEdges = reader.getEdgesBySource(node.qualifiedName);
const outgoingTestEdges = outgoingEdges.filter((e) => e.kind === 'TESTED_BY');
const testQualifiedNames = new Set<string>([
...incomingTestEdges.map((e) => e.sourceQualified),
...outgoingTestEdges.map((e) => e.targetQualified),
]);
// --- Also search by naming convention: test_{name}, Test{name} ---
const conventionPatterns = [`test_${node.name}`, `Test${node.name}`];
for (const pattern of conventionPatterns) {
const matches = reader.searchNodes(pattern, 10);
for (const match of matches) {
if (match.isTest || match.kind === 'Test') {
testQualifiedNames.add(match.qualifiedName);
}
}
}
if (testQualifiedNames.size === 0) {
vscode.window.showInformationMessage(
`Code Graph: No tests found for "${node.name}".`,
);
return;
}
// --- Build QuickPick items ---
const items: Array<{
label: string;
description: string;
detail: string;
node: GraphNode | undefined;
}> = [];
for (const tqn of testQualifiedNames) {
const testNode = reader.getNode(tqn);
items.push({
label: testNode?.name ?? tqn,
description: testNode?.filePath ?? '',
detail: `Line ${testNode?.lineStart ?? '?'}`,
node: testNode,
});
}
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `Tests for ${node.name}`,
});
if (selected?.node) {
await navigateToNode(selected.node);
}
}),
);
// -----------------------------------------------------------------
// codeReviewGraph.search
// -----------------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewGraph.search', async () => {
const reader = getReader();
if (!reader) {
vscode.window.showWarningMessage('Code Graph: No graph database loaded.');
return;
}
const query = await vscode.window.showInputBox({
prompt: 'Search the code graph',
placeHolder: 'Enter a function, class, or module name',
});
if (!query) {
return;
}
const results = reader.searchNodes(query, 30);
if (results.length === 0) {
vscode.window.showInformationMessage(
`Code Graph: No results found for "${query}".`,
);
return;
}
const items = results.map((r) => ({
label: r.name,
description: r.kind,
detail: r.filePath
? `${r.filePath}:${r.lineStart ?? ''}`
: undefined,
result: r,
}));
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `Results for "${query}"`,
});
if (selected?.result) {
await navigateToNode(selected.result);
}
}),
);
}
@@ -0,0 +1,117 @@
/**
* SCM integration for code review.
*
* Detects staged and unstaged changes via git, computes the blast radius
* for those files, and populates the Blast Radius tree view so the reviewer
* can see what is impacted before committing.
*/
import * as vscode from 'vscode';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { SqliteReader } from '../backend/sqlite';
import { BlastRadiusTreeProvider } from '../views/treeView';
const execFileAsync = promisify(execFile);
/** Timeout for git commands (milliseconds). */
const GIT_TIMEOUT_MS = 10_000;
/**
* Run a git command in the given working directory and return trimmed stdout
* lines. Returns an empty array on any error (e.g. git not installed, not a
* git repo, etc.).
*/
async function gitLines(
args: string[],
cwd: string,
): Promise<string[]> {
try {
const { stdout } = await execFileAsync('git', args, {
cwd,
timeout: GIT_TIMEOUT_MS,
});
return stdout
.trim()
.split('\n')
.filter((line) => line.length > 0);
} catch {
return [];
}
}
/**
* Register the `codeReviewGraph.reviewChanges` command.
*
* The command:
* 1. Runs `git diff --name-only HEAD` and `git diff --cached --name-only`
* to collect changed + staged files.
* 2. Computes the blast radius for those files.
* 3. Updates the BlastRadiusTreeProvider with the results.
* 4. Focuses the blast radius view.
*/
export function registerReviewCommand(
context: vscode.ExtensionContext,
reader: SqliteReader,
blastRadiusProvider: BlastRadiusTreeProvider,
): void {
const disposable = vscode.commands.registerCommand(
'codeReviewGraph.reviewChanges',
async () => {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
vscode.window.showErrorMessage('No workspace folder is open.');
return;
}
const workspaceRoot = workspaceFolder.uri.fsPath;
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Code Graph: Analyzing changes...',
cancellable: false,
},
async () => {
// 1. Collect changed files (unstaged + staged, deduplicated)
const [unstaged, staged] = await Promise.all([
gitLines(['diff', '--name-only', 'HEAD'], workspaceRoot),
gitLines(['diff', '--cached', '--name-only'], workspaceRoot),
]);
const changedFiles = [...new Set([...unstaged, ...staged])];
if (changedFiles.length === 0) {
vscode.window.showInformationMessage(
'No changes detected.',
);
return;
}
// 2. Compute blast radius
const config = vscode.workspace.getConfiguration('codeReviewGraph');
const depth = config.get<number>('blastRadiusDepth', 2);
const impact = reader.getImpactRadius(changedFiles, depth);
// 3. Update tree provider
blastRadiusProvider.setResults(
impact.changedNodes,
impact.impactedNodes,
);
// 4. Focus the blast radius view
await vscode.commands.executeCommand(
'codeReviewGraph.blastRadius.focus',
);
// 5. Show summary
vscode.window.showInformationMessage(
`Review: ${changedFiles.length} changed file(s) impact ${impact.impactedNodes.length} additional file(s).`,
);
},
);
},
);
context.subscriptions.push(disposable);
}
@@ -0,0 +1,160 @@
/**
* SCM file decoration provider.
*
* Adds badges to files in the Explorer and SCM views:
* - IMPACTED (orange) — file is in the blast radius of staged/unstaged changes
* - TESTED (green) — changed functions in this file have test coverage
* - UNTESTED (red) — changed functions lack test coverage
*/
import * as vscode from 'vscode';
import { SqliteReader } from '../backend/sqlite';
export class ScmDecorationProvider
implements vscode.FileDecorationProvider
{
private readonly _onDidChange = new vscode.EventEmitter<vscode.Uri | vscode.Uri[] | undefined>();
readonly onDidChangeFileDecorations = this._onDidChange.event;
/** Files directly changed (staged + unstaged). */
private changedFiles = new Set<string>();
/** Files in the blast radius but not directly changed. */
private impactedFiles = new Set<string>();
/** Changed files whose functions all have TESTED_BY edges. */
private testedFiles = new Set<string>();
/** Changed files with at least one function lacking TESTED_BY edges. */
private untestedFiles = new Set<string>();
/**
* Recompute decorations from git state and the graph database.
*/
async update(
reader: SqliteReader,
workspaceRoot: string,
): Promise<void> {
const { execFile } = await import('node:child_process');
const { promisify } = await import('node:util');
const execFileAsync = promisify(execFile);
const path = await import('node:path');
// 1. Collect changed files
let unstaged: string[] = [];
let staged: string[] = [];
try {
const r1 = await execFileAsync('git', ['diff', '--name-only', 'HEAD'], {
cwd: workspaceRoot,
timeout: 10_000,
});
unstaged = r1.stdout.trim().split('\n').filter(Boolean);
} catch { /* ignore */ }
try {
const r2 = await execFileAsync('git', ['diff', '--cached', '--name-only'], {
cwd: workspaceRoot,
timeout: 10_000,
});
staged = r2.stdout.trim().split('\n').filter(Boolean);
} catch { /* ignore */ }
const changedRelative = [...new Set([...unstaged, ...staged])];
const changedAbsolute = changedRelative.map((f) => path.join(workspaceRoot, f));
// 2. Compute impact radius
const config = vscode.workspace.getConfiguration('codeReviewGraph');
const depth = config.get<number>('blastRadiusDepth', 2);
const impact = reader.getImpactRadius(changedAbsolute, depth);
// 3. Classify files
this.changedFiles = new Set(changedAbsolute);
this.impactedFiles = new Set(
impact.impactedNodes
.map((n) => n.filePath)
.filter((f) => !this.changedFiles.has(f)),
);
// 4. Test coverage classification
this.testedFiles = new Set<string>();
this.untestedFiles = new Set<string>();
for (const filePath of this.changedFiles) {
const nodes = reader.getNodesByFile(filePath);
const functions = nodes.filter(
(n) => n.kind === 'Function' && !n.isTest,
);
if (functions.length === 0) {
continue;
}
let allTested = true;
for (const fn of functions) {
const edges = reader.getEdgesByTarget(fn.qualifiedName);
const hasTest = edges.some((e) => e.kind === 'TESTED_BY');
if (!hasTest) {
// Also check outgoing TESTED_BY (reverse direction)
const outEdges = reader.getEdgesBySource(fn.qualifiedName);
const hasOutTest = outEdges.some((e) => e.kind === 'TESTED_BY');
if (!hasOutTest) {
allTested = false;
break;
}
}
}
if (allTested) {
this.testedFiles.add(filePath);
} else {
this.untestedFiles.add(filePath);
}
}
// 5. Fire change event
this._onDidChange.fire(undefined);
}
/** Clear all decorations. */
clear(): void {
this.changedFiles.clear();
this.impactedFiles.clear();
this.testedFiles.clear();
this.untestedFiles.clear();
this._onDidChange.fire(undefined);
}
provideFileDecoration(
uri: vscode.Uri,
): vscode.FileDecoration | undefined {
const filePath = uri.fsPath;
if (this.untestedFiles.has(filePath)) {
return {
badge: '!',
color: new vscode.ThemeColor('editorError.foreground'),
tooltip: 'Code Graph: Changed functions lack test coverage',
propagate: false,
};
}
if (this.testedFiles.has(filePath)) {
return {
badge: '\u2713',
color: new vscode.ThemeColor('testing.iconPassed'),
tooltip: 'Code Graph: All changed functions have test coverage',
propagate: false,
};
}
if (this.impactedFiles.has(filePath)) {
return {
badge: '\u25CF',
color: new vscode.ThemeColor('editorWarning.foreground'),
tooltip: 'Code Graph: In blast radius of current changes',
propagate: false,
};
}
return undefined;
}
dispose(): void {
this._onDidChange.dispose();
}
}
@@ -0,0 +1,131 @@
/**
* Quick search command with live filtering.
*
* Shows a QuickPick that queries the graph database as the user types,
* then navigates to the selected node's source location.
*/
import * as vscode from 'vscode';
import * as path from 'node:path';
import { SqliteReader, GraphNode } from '../backend/sqlite';
// ---------------------------------------------------------------------------
// Kind-to-icon mapping (uses VS Code codicon identifiers)
// ---------------------------------------------------------------------------
const KIND_ICON: Record<string, string> = {
Function: '$(symbol-method)',
Class: '$(symbol-class)',
File: '$(file)',
Test: '$(beaker)',
Type: '$(symbol-interface)',
};
/**
* Build a QuickPickItem from a GraphNode.
*/
function nodeToQuickPickItem(
node: GraphNode,
workspaceRoot: string | undefined,
): vscode.QuickPickItem & { node: GraphNode } {
const icon = KIND_ICON[node.kind] ?? '$(symbol-misc)';
const relativePath = workspaceRoot
? path.relative(workspaceRoot, node.filePath)
: node.filePath;
const lineInfo = node.lineStart != null ? `:${node.lineStart}` : '';
return {
label: `${icon} ${node.name}`,
description: node.kind,
detail: `${relativePath}${lineInfo}`,
node,
};
}
/**
* Navigate to a node's source location.
*/
async function navigateToNode(
node: GraphNode,
workspaceRoot: string | undefined,
): Promise<void> {
const filePath = workspaceRoot
? path.join(workspaceRoot, node.filePath)
: node.filePath;
const uri = vscode.Uri.file(filePath);
const doc = await vscode.workspace.openTextDocument(uri);
const line = Math.max(0, (node.lineStart ?? 1) - 1);
await vscode.window.showTextDocument(doc, {
selection: new vscode.Range(line, 0, line, 0),
});
}
/**
* Register the `codeReviewGraph.search` command.
*
* Opens a QuickPick with live filtering:
* - As the user types, `reader.searchNodes(value, 20)` is called.
* - Results are displayed with kind-specific icons.
* - On accept, the editor navigates to the selected node.
*/
export function registerSearchCommand(
context: vscode.ExtensionContext,
reader: SqliteReader,
): void {
const disposable = vscode.commands.registerCommand(
'codeReviewGraph.search',
async () => {
const workspaceRoot =
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const quickPick = vscode.window.createQuickPick<
vscode.QuickPickItem & { node: GraphNode }
>();
quickPick.placeholder = 'Search for functions, classes, files, types...';
quickPick.matchOnDescription = true;
quickPick.matchOnDetail = true;
// Debounce timer to avoid querying on every keystroke
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
quickPick.onDidChangeValue((value) => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
if (!value) {
quickPick.items = [];
return;
}
debounceTimer = setTimeout(() => {
const results = reader.searchNodes(value, 20);
quickPick.items = results.map((node) =>
nodeToQuickPickItem(node, workspaceRoot),
);
}, 100);
});
quickPick.onDidAccept(async () => {
const selected = quickPick.selectedItems[0];
quickPick.dispose();
if (selected?.node) {
await navigateToNode(selected.node, workspaceRoot);
}
});
quickPick.onDidHide(() => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
quickPick.dispose();
});
quickPick.show();
},
);
context.subscriptions.push(disposable);
}
@@ -0,0 +1,114 @@
import * as vscode from 'vscode';
import { CliWrapper } from '../backend/cli';
/**
* Handles auto-detection and installation of the Python backend.
*
* Checks whether the `code-review-graph` CLI is available and, if not,
* guides the user through installation via pip/pipx or manual instructions.
*/
export class Installer {
constructor(private cli: CliWrapper) {}
/**
* Check whether the backend is installed and prompt the user if it is not.
*
* @returns `true` if the backend is installed (or was just installed
* successfully), `false` if the user dismissed the prompt or
* installation failed.
*/
async checkAndPrompt(): Promise<boolean> {
const installed = await this.cli.isInstalled();
if (installed) {
return true;
}
const selection = await vscode.window.showInformationMessage(
'Code Review Graph backend is not installed.',
'Install Now',
'Manual Instructions',
'Dismiss',
);
if (selection === 'Install Now') {
return this.autoInstall();
}
if (selection === 'Manual Instructions') {
const terminal = vscode.window.createTerminal('Code Review Graph Setup');
terminal.show();
terminal.sendText('echo "=== Code Review Graph - Manual Installation ==="');
terminal.sendText('echo ""');
terminal.sendText('echo "Option 1: Install with pip"');
terminal.sendText('echo " pip install code-review-graph"');
terminal.sendText('echo ""');
terminal.sendText('echo "Option 2: Install with pipx (recommended)"');
terminal.sendText('echo " pipx install code-review-graph"');
terminal.sendText('echo ""');
terminal.sendText('echo "After installation, reload the VS Code window."');
return false;
}
// Dismissed
return false;
}
/**
* Attempt to automatically install the backend using the first available
* Python package installer (pip or pipx).
*
* @returns `true` if installation succeeded, `false` otherwise.
*/
async autoInstall(): Promise<boolean> {
const installer = await this.cli.detectPythonInstaller();
if (!installer) {
const openLink = 'Download Python';
const response = await vscode.window.showErrorMessage(
'Python 3.10+ is required. Install Python first.',
openLink,
);
if (response === openLink) {
vscode.env.openExternal(
vscode.Uri.parse('https://www.python.org/downloads/'),
);
}
return false;
}
let success = false;
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: `Installing code-review-graph via ${installer}...`,
cancellable: false,
},
async () => {
try {
await this.cli.installBackend(installer);
success = true;
} catch {
success = false;
}
},
);
if (success) {
vscode.window.showInformationMessage(
'Backend installed successfully!',
);
return true;
}
vscode.window.showErrorMessage(
`Failed to install code-review-graph via ${installer}. ` +
'Check the terminal output for details or try installing manually: ' +
`\`${installer} install code-review-graph\``,
);
return false;
}
}
@@ -0,0 +1,113 @@
import * as vscode from 'vscode';
import { Installer } from './installer';
import { CliWrapper } from '../backend/cli';
/**
* Register command handlers for the walkthrough steps defined in
* `package.json` contributes.walkthroughs.
*
* Each walkthrough step button triggers one of these commands so the user
* can install the CLI, build the graph, and explore the sidebar without
* leaving the walkthrough.
*/
export function registerWalkthroughCommands(
context: vscode.ExtensionContext,
cli: CliWrapper,
installer: Installer,
): void {
context.subscriptions.push(
vscode.commands.registerCommand(
'codeReviewGraph.walkthrough.install',
async () => {
await installer.autoInstall();
},
),
);
context.subscriptions.push(
vscode.commands.registerCommand(
'codeReviewGraph.walkthrough.build',
async () => {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
vscode.window.showWarningMessage(
'No workspace folder is open. Open a folder first.',
);
return;
}
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Code Graph: Building graph...',
cancellable: false,
},
async () => {
await cli.buildGraph(workspaceFolder.uri.fsPath);
},
);
vscode.window.showInformationMessage(
'Code Graph: Build complete.',
);
},
),
);
context.subscriptions.push(
vscode.commands.registerCommand(
'codeReviewGraph.walkthrough.explore',
async () => {
await vscode.commands.executeCommand(
'codeReviewGraph.codeGraph.focus',
);
},
),
);
}
/**
* Show a welcome notification if no graph database has been built yet
* in any of the open workspace folders.
*
* Checks for `.code-review-graph/graph.db` in every workspace folder.
* When none is found, a notification is shown with a button that opens
* the built-in walkthrough.
*/
export async function showWelcomeIfNeeded(
context: vscode.ExtensionContext,
): Promise<void> {
const workspaceFolders = vscode.workspace.workspaceFolders;
if (!workspaceFolders || workspaceFolders.length === 0) {
return;
}
for (const folder of workspaceFolders) {
const dbUri = vscode.Uri.joinPath(
folder.uri,
'.code-review-graph',
'graph.db',
);
try {
await vscode.workspace.fs.stat(dbUri);
// Database exists in at least one folder -- no need to prompt.
return;
} catch {
// File does not exist in this folder -- continue checking.
}
}
// No graph.db found in any workspace folder.
const selection = await vscode.window.showInformationMessage(
'Welcome to Code Review Graph! Get started by building your code graph.',
'Get Started',
);
if (selection === 'Get Started') {
await vscode.commands.executeCommand(
'workbench.action.openWalkthrough',
'tirth8205.code-review-graph#codeReviewGraph.welcome',
);
}
}
@@ -0,0 +1,633 @@
/**
* Webview panel for the interactive graph visualization.
* Uses D3.js (bundled via esbuild) to render a force-directed graph.
*
* Hosts the toolbar HTML, CSS, and manages communication with the
* browser-side graph.ts script.
*/
import * as vscode from "vscode";
import * as path from "node:path";
import * as crypto from "node:crypto";
import type { SqliteReader, ImpactRadius } from "../backend/sqlite";
export class GraphWebviewPanel {
private static currentPanel: GraphWebviewPanel | undefined;
private readonly panel: vscode.WebviewPanel;
private readonly reader: SqliteReader;
private readonly impactRadius?: ImpactRadius;
private readonly highlightQualifiedName?: string;
private disposables: vscode.Disposable[] = [];
private constructor(
panel: vscode.WebviewPanel,
extensionUri: vscode.Uri,
reader: SqliteReader,
impactRadius?: ImpactRadius,
highlightQualifiedName?: string
) {
this.panel = panel;
this.reader = reader;
this.impactRadius = impactRadius;
this.highlightQualifiedName = highlightQualifiedName;
this.panel.onDidDispose(() => this.dispose(), null, this.disposables);
this.panel.webview.html = this.getHtmlContent(
this.panel.webview,
extensionUri
);
this.panel.webview.onDidReceiveMessage(
(message) => this.handleMessage(message),
null,
this.disposables
);
// Listen for theme changes
this.disposables.push(
vscode.window.onDidChangeActiveColorTheme((theme) => {
const themeKind =
theme.kind === vscode.ColorThemeKind.Light ||
theme.kind === vscode.ColorThemeKind.HighContrastLight
? "light"
: "dark";
this.panel.webview.postMessage({
command: "setTheme",
theme: themeKind,
});
})
);
}
static createOrShow(
extensionUri: vscode.Uri,
reader: SqliteReader,
impactRadius?: ImpactRadius,
highlightQualifiedName?: string
): void {
const column = vscode.ViewColumn.Beside;
if (GraphWebviewPanel.currentPanel) {
GraphWebviewPanel.currentPanel.panel.reveal(column);
// Re-send data if a new highlight is requested
if (highlightQualifiedName) {
GraphWebviewPanel.currentPanel.panel.webview.postMessage({
command: "highlightNode",
qualifiedName: highlightQualifiedName,
});
}
return;
}
const panel = vscode.window.createWebviewPanel(
"codeReviewGraph.graph",
"Code Graph",
column,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [vscode.Uri.joinPath(extensionUri, "dist")],
}
);
GraphWebviewPanel.currentPanel = new GraphWebviewPanel(
panel,
extensionUri,
reader,
impactRadius,
highlightQualifiedName
);
}
private dispose(): void {
GraphWebviewPanel.currentPanel = undefined;
this.panel.dispose();
for (const d of this.disposables) {
d.dispose();
}
this.disposables = [];
}
// -----------------------------------------------------------------------
// Message handling
// -----------------------------------------------------------------------
private handleMessage(message: {
command: string;
[key: string]: unknown;
}): void {
switch (message.command) {
case "ready":
this.sendGraphData();
break;
case "nodeClicked":
this.openFileAtLine(
message.filePath as string,
message.lineStart as number
);
// Bidirectional sync: reveal in tree view
if (message.qualifiedName) {
vscode.commands.executeCommand(
"codeReviewGraph.revealInTree",
message.qualifiedName as string
);
}
break;
case "exportSvg":
this.exportSvgToClipboard(message.svg as string);
break;
case "exportPng":
this.savePngToFile(message.data as string);
break;
}
}
/**
* Send full graph data to the webview.
* If an impact radius was provided, send only those nodes/edges.
* Otherwise send the full graph.
*/
private sendGraphData(): void {
let nodes;
let edges;
if (this.impactRadius) {
nodes = [
...this.impactRadius.changedNodes,
...this.impactRadius.impactedNodes,
];
edges = this.impactRadius.edges;
} else {
// Load all nodes and edges
const files = this.reader.getAllFiles();
nodes = files.flatMap((f) => this.reader.getNodesByFile(f));
const qualifiedNames = new Set(nodes.map((n) => n.qualifiedName));
edges = this.reader.getEdgesAmong(qualifiedNames);
}
// Enforce maxNodes setting
const config = vscode.workspace.getConfiguration("codeReviewGraph");
const maxNodes = config.get<number>("graph.maxNodes", 500);
let truncated = false;
if (nodes.length > maxNodes) {
truncated = true;
nodes = nodes.slice(0, maxNodes);
const nodeQns = new Set(nodes.map((n: { qualifiedName: string }) => n.qualifiedName));
edges = edges.filter(
(e: { sourceQualified: string; targetQualified: string }) =>
nodeQns.has(e.sourceQualified) && nodeQns.has(e.targetQualified)
);
}
this.panel.webview.postMessage({
command: "setData",
nodes,
edges,
truncated,
maxNodes,
});
// Send theme
const themeKind =
vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light ||
vscode.window.activeColorTheme.kind ===
vscode.ColorThemeKind.HighContrastLight
? "light"
: "dark";
this.panel.webview.postMessage({
command: "setTheme",
theme: themeKind,
});
// Highlight node if requested
if (this.highlightQualifiedName) {
// Small delay to let the graph render first
setTimeout(() => {
this.panel.webview.postMessage({
command: "highlightNode",
qualifiedName: this.highlightQualifiedName,
});
}, 1000);
}
}
/**
* Open a file in the editor at a specific line.
*/
private async openFileAtLine(
filePath: string,
lineStart: number
): Promise<void> {
const workspaceRoot =
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const fullPath = workspaceRoot
? path.join(workspaceRoot, filePath)
: filePath;
try {
const doc = await vscode.workspace.openTextDocument(fullPath);
const line = Math.max(0, (lineStart ?? 1) - 1);
await vscode.window.showTextDocument(doc, {
viewColumn: vscode.ViewColumn.One,
selection: new vscode.Range(line, 0, line, 0),
preserveFocus: false,
});
} catch {
vscode.window.showWarningMessage(
`Code Graph: Could not open file ${filePath}`
);
}
}
/**
* Copy SVG string to clipboard.
*/
private async exportSvgToClipboard(svgString: string): Promise<void> {
await vscode.env.clipboard.writeText(svgString);
vscode.window.showInformationMessage(
"Code Graph: SVG copied to clipboard."
);
}
/**
* Save PNG data URL to a file.
*/
private async savePngToFile(dataUrl: string): Promise<void> {
const uri = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file("code-graph.png"),
filters: { "PNG Image": ["png"] },
});
if (!uri) { return; }
const base64 = dataUrl.replace(/^data:image\/png;base64,/, "");
const buffer = Buffer.from(base64, "base64");
await vscode.workspace.fs.writeFile(uri, buffer);
vscode.window.showInformationMessage("Code Graph: PNG saved.");
}
/**
* Highlight a node by qualified name from external code (tree view click).
*/
static highlightNode(qualifiedName: string): void {
if (GraphWebviewPanel.currentPanel) {
GraphWebviewPanel.currentPanel.panel.webview.postMessage({
command: "highlightNode",
qualifiedName,
});
}
}
// -----------------------------------------------------------------------
// HTML content
// -----------------------------------------------------------------------
private getHtmlContent(
webview: vscode.Webview,
extensionUri: vscode.Uri
): string {
const scriptUri = webview.asWebviewUri(
vscode.Uri.joinPath(extensionUri, "dist", "webview", "graph.js")
);
const nonce = getNonce();
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; img-src ${webview.cspSource};">
<title>Code Graph</title>
<style>
/* ------------------------------------------------------------------ */
/* CSS variables from VS Code theme */
/* ------------------------------------------------------------------ */
:root {
--bg: var(--vscode-editor-background, #1e1e2e);
--fg: var(--vscode-editor-foreground, #cdd6f4);
--toolbar-bg: var(--vscode-sideBar-background, #181825);
--toolbar-border: var(--vscode-panel-border, #313244);
--input-bg: var(--vscode-input-background, #313244);
--input-fg: var(--vscode-input-foreground, #cdd6f4);
--input-border: var(--vscode-input-border, #45475a);
--btn-bg: var(--vscode-button-background, #89b4fa);
--btn-fg: var(--vscode-button-foreground, #1e1e2e);
--btn-hover: var(--vscode-button-hoverBackground, #74c7ec);
--badge-bg: var(--vscode-badge-background, #45475a);
--badge-fg: var(--vscode-badge-foreground, #cdd6f4);
--font: var(--vscode-font-family, 'Segoe UI', sans-serif);
--font-size: var(--vscode-font-size, 13px);
--font-mono: var(--vscode-editor-font-family, 'Fira Code', monospace);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
font-size: var(--font-size);
background: var(--bg);
color: var(--fg);
overflow: hidden;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
/* ------------------------------------------------------------------ */
/* Toolbar */
/* ------------------------------------------------------------------ */
#toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
background: var(--toolbar-bg);
border-bottom: 1px solid var(--toolbar-border);
flex-shrink: 0;
flex-wrap: wrap;
min-height: 40px;
}
#toolbar .toolbar-group {
display: flex;
align-items: center;
gap: 6px;
}
#toolbar .toolbar-label {
font-size: 11px;
color: var(--fg);
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}
#toolbar .toolbar-separator {
width: 1px;
height: 20px;
background: var(--toolbar-border);
margin: 0 4px;
}
/* Search */
#search-input {
background: var(--input-bg);
color: var(--input-fg);
border: 1px solid var(--input-border);
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
font-family: var(--font);
width: 180px;
outline: none;
}
#search-input:focus {
border-color: var(--btn-bg);
}
#search-input::placeholder {
color: var(--fg);
opacity: 0.4;
}
/* Edge pills */
.edge-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
user-select: none;
border: 1px solid transparent;
opacity: 0.55;
transition: opacity 0.15s, border-color 0.15s;
}
.edge-pill.active {
opacity: 1;
border-color: currentColor;
}
.edge-pill:focus-visible { outline: 2px solid var(--btn-bg, #89b4fa); outline-offset: 2px; }
.edge-pill .pill-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
/* Edge filter popover */
#edge-popover {
display: none;
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--toolbar-bg);
border: 1px solid var(--toolbar-border);
border-radius: 6px;
padding: 8px 12px;
z-index: 100;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
min-width: 160px;
}
#edge-popover.visible { display: flex; flex-wrap: wrap; gap: 6px; }
#edge-filter-wrap { position: relative; }
/* Depth slider */
#depth-slider {
width: 80px;
accent-color: var(--btn-bg);
cursor: pointer;
}
#depth-slider:disabled {
opacity: 0.4;
cursor: not-allowed;
}
#depth-value {
font-size: 11px;
font-family: var(--font-mono);
min-width: 24px;
text-align: center;
}
/* Toolbar buttons */
.toolbar-btn {
background: var(--badge-bg);
color: var(--badge-fg);
border: none;
border-radius: 4px;
padding: 4px 10px;
font-size: 11px;
font-family: var(--font);
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.toolbar-btn:hover {
background: var(--btn-bg);
color: var(--btn-fg);
}
/* Node count badge */
#node-count {
position: absolute;
bottom: 8px;
right: 12px;
font-size: 11px;
color: var(--fg);
opacity: 0.6;
white-space: nowrap;
z-index: 5;
}
/* ------------------------------------------------------------------ */
/* Graph area */
/* ------------------------------------------------------------------ */
#graph-area {
flex: 1;
overflow: hidden;
position: relative;
}
#graph-area svg {
display: block;
}
/* ------------------------------------------------------------------ */
/* Tooltip */
/* ------------------------------------------------------------------ */
#tooltip {
display: none;
position: fixed;
z-index: 1000;
background: var(--toolbar-bg);
border: 1px solid var(--toolbar-border);
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
color: var(--fg);
max-width: 360px;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
line-height: 1.5;
}
#tooltip strong {
font-size: 13px;
}
#tooltip .tooltip-kind {
display: inline-block;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 1px 6px;
border-radius: 3px;
background: var(--badge-bg);
color: var(--badge-fg);
margin: 2px 0;
}
#tooltip .tooltip-path {
font-family: var(--font-mono);
font-size: 11px;
opacity: 0.7;
word-break: break-all;
}
#tooltip .tooltip-params {
font-family: var(--font-mono);
font-size: 11px;
color: var(--vscode-debugTokenExpression-string, #a6e3a1);
}
#tooltip .tooltip-return {
font-family: var(--font-mono);
font-size: 11px;
color: var(--vscode-debugTokenExpression-number, #89b4fa);
}
/* ------------------------------------------------------------------ */
/* Pulse animation for highlighted nodes */
/* ------------------------------------------------------------------ */
@keyframes pulse {
0% { r: 16; stroke-opacity: 1; }
100% { r: 30; stroke-opacity: 0; }
}
.pulse-ring {
animation: pulse 0.6s ease-out;
}
path.node-shape:focus { outline: none; }
path.node-shape:focus-visible { stroke: var(--btn-bg, #89b4fa) !important; stroke-width: 3 !important; }
</style>
</head>
<body>
<!-- Toolbar -->
<div id="toolbar">
<!-- Search -->
<div class="toolbar-group">
<input id="search-input" type="text" placeholder="Search nodes..." spellcheck="false" aria-label="Search nodes" />
</div>
<div class="toolbar-separator"></div>
<!-- Edge type filter -->
<div class="toolbar-group" id="edge-filter-wrap">
<button id="btn-edge-filter" class="toolbar-btn" title="Toggle edge type filters" aria-label="Toggle edge type filters">Edges</button>
<div id="edge-popover">
<span id="edge-CALLS" class="edge-pill active" style="color:#3fb950" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Calls edges" title="Toggle CALLS edges"><span class="pill-dot" style="background:#3fb950"></span>Calls</span>
<span id="edge-IMPORTS_FROM" class="edge-pill active" style="color:#f0883e" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Imports edges" title="Toggle IMPORTS_FROM edges"><span class="pill-dot" style="background:#f0883e"></span>Imports</span>
<span id="edge-INHERITS" class="edge-pill active" style="color:#d2a8ff" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Inherits edges" title="Toggle INHERITS edges"><span class="pill-dot" style="background:#d2a8ff"></span>Inherits</span>
<span id="edge-IMPLEMENTS" class="edge-pill active" style="color:#f9e2af" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Implements edges" title="Toggle IMPLEMENTS edges"><span class="pill-dot" style="background:#f9e2af"></span>Implements</span>
<span id="edge-TESTED_BY" class="edge-pill active" style="color:#f38ba8" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Tested edges" title="Toggle TESTED_BY edges"><span class="pill-dot" style="background:#f38ba8"></span>Tested</span>
<span id="edge-CONTAINS" class="edge-pill active" style="color:#8b949e" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Contains edges" title="Toggle CONTAINS edges"><span class="pill-dot" style="background:#8b949e"></span>Contains</span>
<span id="edge-DEPENDS_ON" class="edge-pill active" style="color:#fab387" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Depends edges" title="Toggle DEPENDS_ON edges"><span class="pill-dot" style="background:#fab387"></span>Depends</span>
</div>
</div>
<div class="toolbar-separator"></div>
<!-- Depth slider -->
<div class="toolbar-group">
<span class="toolbar-label">Depth</span>
<input id="depth-slider" type="range" min="0" max="10" value="0" aria-label="Graph depth limit" disabled title="Select a node first, then adjust depth" />
<span id="depth-value">All</span>
</div>
<div class="toolbar-separator"></div>
<!-- Action buttons -->
<div class="toolbar-group">
<button id="btn-fit" class="toolbar-btn">Fit</button>
<button id="btn-export" class="toolbar-btn">Export SVG</button>
<button id="btn-export-png" class="toolbar-btn">Export PNG</button>
</div>
</div>
<!-- Graph -->
<div id="graph-area">
<span id="node-count" aria-live="polite"></span>
<span id="truncation-warning" style="display:none;color:var(--btn-bg);font-size:11px;cursor:pointer;" title="Increase codeReviewGraph.graph.maxNodes in settings"></span>
<div id="empty-state" style="display:none;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:var(--fg);opacity:0.6">
<p style="font-size:16px;margin-bottom:12px">No graph data available</p>
<p style="font-size:13px">Run <strong>Code Graph: Build Graph</strong> from the Command Palette to get started.</p>
</div>
</div>
<!-- Tooltip -->
<div id="tooltip" role="tooltip" aria-live="polite"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}
}
function getNonce(): string {
return crypto.randomBytes(16).toString("hex");
}
@@ -0,0 +1,89 @@
import * as vscode from 'vscode';
import { SqliteReader } from '../backend/sqlite';
/** Number of milliseconds in one hour. */
const ONE_HOUR_MS = 60 * 60 * 1000;
/**
* Manages a status bar item that shows a summary of the code graph
* database and its staleness.
*
* Clicking the status bar item triggers `codeReviewGraph.updateGraph`.
*/
export class StatusBar implements vscode.Disposable {
private item: vscode.StatusBarItem;
constructor() {
this.item = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left,
100,
);
this.item.command = 'codeReviewGraph.updateGraph';
}
/**
* Update the status bar text, icon, and tooltip based on the current
* state of the graph database.
*
* @param reader The open SQLite reader, or `undefined` if no database
* is loaded.
*/
update(reader: SqliteReader | undefined): void {
if (!reader) {
this.item.text = '$(warning) Code Graph: Not built';
this.item.tooltip = 'Click to build';
return;
}
const stats = reader.getStats();
const lastUpdated = stats.lastUpdated;
const isOutdated = this.isOlderThanOneHour(lastUpdated);
if (isOutdated) {
this.item.text = '$(warning) Code Graph: Outdated';
this.item.tooltip =
`Code Graph: ${stats.filesCount} files, ${stats.totalEdges} edges\n` +
`Last updated: ${lastUpdated || 'unknown'}`;
} else {
this.item.text = `$(database) ${stats.totalNodes} nodes`;
this.item.tooltip =
`Code Graph: ${stats.filesCount} files, ${stats.totalEdges} edges\n` +
`Last updated: ${lastUpdated || 'unknown'}`;
}
}
/** Show the status bar item. */
show(): void {
this.item.show();
}
/** Hide the status bar item. */
hide(): void {
this.item.hide();
}
/** Dispose the status bar item. */
dispose(): void {
this.item.dispose();
}
/**
* Determine whether `lastUpdated` is more than one hour in the past.
*
* Returns `true` if the timestamp is missing, unparseable, or older
* than one hour.
*/
private isOlderThanOneHour(lastUpdated: string | null): boolean {
if (!lastUpdated) {
return true;
}
const updatedTime = new Date(lastUpdated).getTime();
if (isNaN(updatedTime)) {
return true;
}
return Date.now() - updatedTime > ONE_HOUR_MS;
}
}
@@ -0,0 +1,234 @@
import * as vscode from 'vscode';
import * as path from 'path';
// ---------------------------------------------------------------------------
// FileTreeItem represents a source file in the code graph
// ---------------------------------------------------------------------------
export class FileTreeItem extends vscode.TreeItem {
public readonly filePath: string;
public readonly qualifiedName: string;
constructor(filePath: string, workspaceRoot: string) {
const fileName = path.basename(filePath);
super(fileName, vscode.TreeItemCollapsibleState.Collapsed);
this.filePath = filePath;
this.qualifiedName = filePath;
const relativePath = path.relative(workspaceRoot, filePath);
this.description = relativePath !== fileName ? relativePath : '';
this.iconPath = new vscode.ThemeIcon('file');
this.contextValue = 'node-file';
this.tooltip = filePath;
this.command = {
title: 'Open File',
command: 'vscode.open',
arguments: [vscode.Uri.file(filePath)],
};
}
}
// ---------------------------------------------------------------------------
// SymbolTreeItem represents a class, function, type, or test node
// ---------------------------------------------------------------------------
const KIND_ICON_MAP: Record<string, string> = {
Function: 'symbol-method',
Class: 'symbol-class',
Type: 'symbol-interface',
Test: 'testing-run-icon',
};
const KIND_CONTEXT_MAP: Record<string, string> = {
Function: 'node-function',
Class: 'node-class',
Type: 'node-type',
Test: 'node-test',
};
function formatSymbolLabel(name: string, kind: string): string {
if (kind === 'Function' || kind === 'Test') {
return `${name}()`;
}
return name;
}
function formatSymbolDescription(kind: string, lineStart: number | null, lineEnd: number | null): string {
const kindLower = kind.toLowerCase();
if (lineStart != null && lineEnd != null) {
return `${kindLower} \u00b7 L${lineStart}\u2013${lineEnd}`;
}
if (lineStart != null) {
return `${kindLower} \u00b7 L${lineStart}`;
}
return kindLower;
}
export class SymbolTreeItem extends vscode.TreeItem {
public readonly qualifiedName: string;
public readonly filePath: string;
public readonly lineStart: number | null;
public readonly kind: string;
constructor(
qualifiedName: string,
name: string,
kind: string,
filePath: string,
lineStart: number | null,
lineEnd: number | null,
) {
const label = formatSymbolLabel(name, kind);
super(label, vscode.TreeItemCollapsibleState.Collapsed);
this.qualifiedName = qualifiedName;
this.filePath = filePath;
this.lineStart = lineStart;
this.kind = kind;
this.description = formatSymbolDescription(kind, lineStart, lineEnd);
this.iconPath = new vscode.ThemeIcon(KIND_ICON_MAP[kind] ?? 'symbol-misc');
this.contextValue = KIND_CONTEXT_MAP[kind] ?? 'node-function';
this.tooltip = qualifiedName;
const line = lineStart != null ? lineStart - 1 : 0;
this.command = {
title: 'Go to Symbol',
command: 'vscode.open',
arguments: [
vscode.Uri.file(filePath),
{ selection: new vscode.Range(line, 0, line, 0) } as vscode.TextDocumentShowOptions,
],
};
}
}
// ---------------------------------------------------------------------------
// EdgeTreeItem represents a relationship edge (leaf node)
// ---------------------------------------------------------------------------
const OUTGOING_EDGE_LABELS: Record<string, string> = {
CALLS: 'calls',
IMPORTS_FROM: 'imports',
INHERITS: 'inherits from',
IMPLEMENTS: 'implements',
TESTED_BY: 'tested by',
CONTAINS: 'contains',
DEPENDS_ON: 'depends on',
};
const INCOMING_EDGE_LABELS: Record<string, string> = {
CALLS: 'called by',
IMPORTS_FROM: 'imported by',
INHERITS: 'inherited by',
IMPLEMENTS: 'implemented by',
TESTED_BY: 'tests',
CONTAINS: 'contained in',
DEPENDS_ON: 'depended on by',
};
const EDGE_ICON_MAP_OUTGOING: Record<string, string> = {
CALLS: 'arrow-right',
IMPORTS_FROM: 'package',
INHERITS: 'type-hierarchy',
IMPLEMENTS: 'symbol-interface',
TESTED_BY: 'testing-run-icon',
CONTAINS: 'symbol-namespace',
DEPENDS_ON: 'references',
};
const EDGE_ICON_MAP_INCOMING: Record<string, string> = {
CALLS: 'arrow-left',
IMPORTS_FROM: 'package',
INHERITS: 'type-hierarchy',
IMPLEMENTS: 'symbol-interface',
TESTED_BY: 'testing-run-icon',
CONTAINS: 'symbol-namespace',
DEPENDS_ON: 'references',
};
function extractShortName(qualifiedName: string): string {
// Qualified names are like "/path/to/file.py::ClassName.method" or "/path/to/file.py"
const colonIdx = qualifiedName.lastIndexOf('::');
if (colonIdx >= 0) {
return qualifiedName.substring(colonIdx + 2);
}
return path.basename(qualifiedName);
}
export class EdgeTreeItem extends vscode.TreeItem {
public readonly targetQualifiedName: string;
public readonly targetFilePath: string;
public readonly targetLine: number;
constructor(
edgeKind: string,
direction: 'outgoing' | 'incoming',
targetQualifiedName: string,
targetFilePath: string,
targetLine: number,
) {
const shortName = extractShortName(targetQualifiedName);
const verb = direction === 'outgoing'
? (OUTGOING_EDGE_LABELS[edgeKind] ?? edgeKind.toLowerCase())
: (INCOMING_EDGE_LABELS[edgeKind] ?? edgeKind.toLowerCase());
const arrow = direction === 'outgoing' ? '\u2192' : '\u2190';
const label = `${arrow} ${verb} ${shortName}`;
super(label, vscode.TreeItemCollapsibleState.None);
this.targetQualifiedName = targetQualifiedName;
this.targetFilePath = targetFilePath;
this.targetLine = targetLine;
const iconMap = direction === 'outgoing' ? EDGE_ICON_MAP_OUTGOING : EDGE_ICON_MAP_INCOMING;
this.iconPath = new vscode.ThemeIcon(iconMap[edgeKind] ?? 'arrow-right');
this.contextValue = 'edge';
this.tooltip = `${arrow} ${verb} ${targetQualifiedName}`;
const line = targetLine > 0 ? targetLine - 1 : 0;
this.command = {
title: 'Go to Target',
command: 'vscode.open',
arguments: [
vscode.Uri.file(targetFilePath),
{ selection: new vscode.Range(line, 0, line, 0) } as vscode.TextDocumentShowOptions,
],
};
}
}
// ---------------------------------------------------------------------------
// BlastRadiusGroupItem groups "Changed" and "Impacted" results
// ---------------------------------------------------------------------------
export class BlastRadiusGroupItem extends vscode.TreeItem {
public readonly groupKind: 'changed' | 'impacted';
constructor(groupKind: 'changed' | 'impacted', count: number) {
const label = groupKind === 'changed' ? `Changed (${count})` : `Impacted (${count})`;
super(label, vscode.TreeItemCollapsibleState.Expanded);
this.groupKind = groupKind;
this.iconPath = new vscode.ThemeIcon(groupKind === 'changed' ? 'flame' : 'broadcast');
this.contextValue = `blast-radius-${groupKind}`;
this.tooltip = groupKind === 'changed'
? `${count} directly changed node(s)`
: `${count} transitively impacted node(s)`;
}
}
// ---------------------------------------------------------------------------
// StatsItem displays a single statistic line (leaf node)
// ---------------------------------------------------------------------------
export class StatsItem extends vscode.TreeItem {
constructor(label: string, value: string) {
super(label, vscode.TreeItemCollapsibleState.None);
this.description = value;
this.contextValue = 'stat';
this.tooltip = `${label}: ${value}`;
}
}
@@ -0,0 +1,237 @@
import * as vscode from 'vscode';
import { SqliteReader, GraphNode, GraphEdge } from '../backend/sqlite';
import {
FileTreeItem,
SymbolTreeItem,
EdgeTreeItem,
BlastRadiusGroupItem,
StatsItem,
} from './treeItems';
// ---------------------------------------------------------------------------
// CodeGraphTreeProvider -- main file > symbol > edge tree
// ---------------------------------------------------------------------------
export class CodeGraphTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem> {
private readonly _onDidChangeTreeData = new vscode.EventEmitter<vscode.TreeItem | undefined | null>();
readonly onDidChangeTreeData: vscode.Event<vscode.TreeItem | undefined | null> = this._onDidChangeTreeData.event;
constructor(
private readonly reader: SqliteReader,
private readonly workspaceRoot: string,
) {}
refresh(): void {
this._onDidChangeTreeData.fire(undefined);
}
getTreeItem(element: vscode.TreeItem): vscode.TreeItem {
return element;
}
getChildren(element?: vscode.TreeItem): vscode.ProviderResult<vscode.TreeItem[]> {
if (!element) {
return this.getRootChildren();
}
if (element instanceof FileTreeItem) {
return this.getFileChildren(element);
}
if (element instanceof SymbolTreeItem) {
return this.getSymbolChildren(element);
}
return [];
}
// -- Root level: one FileTreeItem per file --------------------------------
private getRootChildren(): vscode.TreeItem[] {
const files = this.reader.getAllFiles();
return files
.slice()
.sort((a, b) => a.localeCompare(b))
.map((filePath) => new FileTreeItem(filePath, this.workspaceRoot));
}
// -- File level: symbols (non-File nodes) sorted by line ------------------
private getFileChildren(fileItem: FileTreeItem): vscode.TreeItem[] {
const nodes = this.reader.getNodesByFile(fileItem.filePath);
return nodes
.filter((n) => n.kind !== 'File')
.sort((a, b) => (a.lineStart ?? 0) - (b.lineStart ?? 0))
.map(
(n) =>
new SymbolTreeItem(
n.qualifiedName,
n.name,
n.kind,
n.filePath,
n.lineStart,
n.lineEnd,
),
);
}
// -- Symbol level: outgoing + incoming edges (skip CONTAINS) --------------
private getSymbolChildren(symbolItem: SymbolTreeItem): vscode.TreeItem[] {
const items: vscode.TreeItem[] = [];
// Outgoing edges
const outgoing = this.reader.getEdgesBySource(symbolItem.qualifiedName);
for (const edge of outgoing) {
if (edge.kind === 'CONTAINS') {
continue;
}
const targetNode = this.reader.getNode(edge.targetQualified);
const targetFile = targetNode?.filePath ?? edge.filePath;
const targetLine = targetNode?.lineStart ?? edge.line;
items.push(
new EdgeTreeItem(
edge.kind,
'outgoing',
edge.targetQualified,
targetFile,
targetLine,
),
);
}
// Incoming edges
const incoming = this.reader.getEdgesByTarget(symbolItem.qualifiedName);
for (const edge of incoming) {
if (edge.kind === 'CONTAINS') {
continue;
}
const sourceNode = this.reader.getNode(edge.sourceQualified);
const sourceFile = sourceNode?.filePath ?? edge.filePath;
const sourceLine = sourceNode?.lineStart ?? edge.line;
items.push(
new EdgeTreeItem(
edge.kind,
'incoming',
edge.sourceQualified,
sourceFile,
sourceLine,
),
);
}
return items;
}
}
// ---------------------------------------------------------------------------
// BlastRadiusTreeProvider -- shows changed + impacted nodes
// ---------------------------------------------------------------------------
export class BlastRadiusTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem> {
private readonly _onDidChangeTreeData = new vscode.EventEmitter<vscode.TreeItem | undefined | null>();
readonly onDidChangeTreeData: vscode.Event<vscode.TreeItem | undefined | null> = this._onDidChangeTreeData.event;
private changedNodes: GraphNode[] = [];
private impactedNodes: GraphNode[] = [];
setResults(changed: GraphNode[], impacted: GraphNode[]): void {
this.changedNodes = changed;
this.impactedNodes = impacted;
this._onDidChangeTreeData.fire(undefined);
}
clear(): void {
this.changedNodes = [];
this.impactedNodes = [];
this._onDidChangeTreeData.fire(undefined);
}
getTreeItem(element: vscode.TreeItem): vscode.TreeItem {
return element;
}
getChildren(element?: vscode.TreeItem): vscode.ProviderResult<vscode.TreeItem[]> {
if (!element) {
return this.getRootChildren();
}
if (element instanceof BlastRadiusGroupItem) {
return this.getGroupChildren(element);
}
return [];
}
private getRootChildren(): vscode.TreeItem[] {
if (this.changedNodes.length === 0 && this.impactedNodes.length === 0) {
return [];
}
const groups: vscode.TreeItem[] = [];
if (this.changedNodes.length > 0) {
groups.push(new BlastRadiusGroupItem('changed', this.changedNodes.length));
}
if (this.impactedNodes.length > 0) {
groups.push(new BlastRadiusGroupItem('impacted', this.impactedNodes.length));
}
return groups;
}
private getGroupChildren(group: BlastRadiusGroupItem): vscode.TreeItem[] {
const nodes = group.groupKind === 'changed' ? this.changedNodes : this.impactedNodes;
return nodes.map(
(n) =>
new SymbolTreeItem(
n.qualifiedName,
n.name,
n.kind,
n.filePath,
n.lineStart,
n.lineEnd,
),
);
}
}
// ---------------------------------------------------------------------------
// StatsTreeProvider -- graph statistics overview
// ---------------------------------------------------------------------------
export class StatsTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem> {
private readonly _onDidChangeTreeData = new vscode.EventEmitter<vscode.TreeItem | undefined | null>();
readonly onDidChangeTreeData: vscode.Event<vscode.TreeItem | undefined | null> = this._onDidChangeTreeData.event;
constructor(private readonly reader: SqliteReader) {}
refresh(): void {
this._onDidChangeTreeData.fire(undefined);
}
getTreeItem(element: vscode.TreeItem): vscode.TreeItem {
return element;
}
getChildren(): vscode.ProviderResult<vscode.TreeItem[]> {
const stats = this.reader.getStats();
const items: StatsItem[] = [];
items.push(new StatsItem('Files', stats.filesCount.toLocaleString()));
items.push(new StatsItem('Total Nodes', stats.totalNodes.toLocaleString()));
items.push(new StatsItem('Total Edges', stats.totalEdges.toLocaleString()));
items.push(
new StatsItem(
'Languages',
stats.languages.length > 0 ? stats.languages.join(', ') : 'none',
),
);
items.push(
new StatsItem(
'Last Updated',
stats.lastUpdated ?? 'unknown',
),
);
items.push(
new StatsItem(
'Embeddings',
stats.embeddingsCount > 0 ? stats.embeddingsCount.toLocaleString() : 'none',
),
);
return items;
}
}
@@ -0,0 +1,990 @@
/**
* Webview entry point for the D3.js force-directed graph visualization.
* Runs in the browser context inside the VS Code webview panel.
*
* Communicates with the extension host via postMessage / addEventListener.
* NO Node.js APIs are available here.
*/
import * as d3 from "d3";
declare function acquireVsCodeApi(): {
postMessage(msg: unknown): void;
getState(): unknown;
setState(state: unknown): void;
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type NodeKind = "File" | "Class" | "Function" | "Test" | "Type";
type EdgeKind =
| "CALLS"
| "IMPORTS_FROM"
| "INHERITS"
| "IMPLEMENTS"
| "TESTED_BY"
| "CONTAINS"
| "DEPENDS_ON";
interface GraphNode {
id: number;
kind: NodeKind;
name: string;
qualifiedName: string;
filePath: string;
lineStart: number | null;
lineEnd: number | null;
language: string | null;
parentName: string | null;
params: string | null;
returnType: string | null;
modifiers: string | null;
isTest: boolean;
fileHash: string | null;
}
interface GraphEdge {
id: number;
kind: EdgeKind;
sourceQualified: string;
targetQualified: string;
filePath: string;
line: number;
}
/** D3 simulation node extends GraphNode with x/y/vx/vy. */
interface SimNode extends d3.SimulationNodeDatum, GraphNode {}
/** D3 simulation link with resolved source/target. */
interface SimLink extends d3.SimulationLinkDatum<SimNode> {
kind: EdgeKind;
sourceQualified: string;
targetQualified: string;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const NODE_RADIUS: Record<NodeKind, number> = {
File: 18,
Class: 12,
Function: 6,
Test: 6,
Type: 5,
};
const NODE_COLOR: Record<NodeKind, string> = {
File: "#58a6ff",
Class: "#f0883e",
Function: "#3fb950",
Test: "#d2a8ff",
Type: "#8b949e",
};
const NODE_SHAPE: Record<NodeKind, d3.SymbolType> = {
File: d3.symbolCircle,
Class: d3.symbolSquare,
Function: d3.symbolTriangle,
Test: d3.symbolDiamond,
Type: d3.symbolCross,
};
const NODE_AREA: Record<NodeKind, number> = {
File: 616,
Class: 452,
Function: 314,
Test: 314,
Type: 314,
};
const EDGE_COLOR: Record<EdgeKind, string> = {
CALLS: "#3fb950",
IMPORTS_FROM: "#f0883e",
INHERITS: "#d2a8ff",
IMPLEMENTS: "#f9e2af",
TESTED_BY: "#f38ba8",
CONTAINS: "rgba(139,148,158,0.15)",
DEPENDS_ON: "#fab387",
};
const ALL_EDGE_KINDS: EdgeKind[] = [
"CALLS",
"IMPORTS_FROM",
"INHERITS",
"IMPLEMENTS",
"TESTED_BY",
"CONTAINS",
"DEPENDS_ON",
];
// ---------------------------------------------------------------------------
// Global state
// ---------------------------------------------------------------------------
const vscodeApi = acquireVsCodeApi();
let allNodes: SimNode[] = [];
let allEdges: SimLink[] = [];
let nodeMap = new Map<string, SimNode>();
let visibleEdgeKinds = new Set<EdgeKind>(ALL_EDGE_KINDS);
let selectedNode: SimNode | null = null;
let depthLimit = 0; // 0 = show all
let simulation: d3.Simulation<SimNode, SimLink> | null = null;
let svg: d3.Selection<SVGSVGElement, unknown, null, undefined>;
let container: d3.Selection<SVGGElement, unknown, null, undefined>;
let linkGroup: d3.Selection<SVGGElement, unknown, null, undefined>;
let nodeGroup: d3.Selection<SVGGElement, unknown, null, undefined>;
let labelGroup: d3.Selection<SVGGElement, unknown, null, undefined>;
let zoomBehavior: d3.ZoomBehavior<SVGSVGElement, unknown>;
let linkSelection: d3.Selection<SVGLineElement, SimLink, SVGGElement, unknown>;
let nodeSelection: d3.Selection<SVGPathElement, SimNode, SVGGElement, unknown>;
let labelSelection: d3.Selection<SVGTextElement, SimNode, SVGGElement, unknown>;
let currentTheme: "dark" | "light" = "dark";
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
function init(): void {
createSvg();
bindToolbarEvents();
bindExtensionMessages();
vscodeApi.postMessage({ command: "ready" });
}
// ---------------------------------------------------------------------------
// SVG setup
// ---------------------------------------------------------------------------
function createSvg(): void {
const graphEl = document.getElementById("graph-area")!;
const width = graphEl.clientWidth || window.innerWidth;
const height = graphEl.clientHeight || window.innerHeight;
svg = d3
.select(graphEl)
.append("svg")
.attr("width", "100%")
.attr("height", "100%")
.attr("viewBox", `0 0 ${width} ${height}`);
// Arrow marker definitions -- one per edge kind
const defs = svg.append("defs");
for (const kind of ALL_EDGE_KINDS) {
defs
.append("marker")
.attr("id", `arrow-${kind}`)
.attr("viewBox", "0 -5 10 10")
.attr("refX", 20)
.attr("refY", 0)
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", EDGE_COLOR[kind]);
}
container = svg.append("g").attr("class", "graph-container");
linkGroup = container.append("g").attr("class", "links");
nodeGroup = container.append("g").attr("class", "nodes");
labelGroup = container.append("g").attr("class", "labels");
// Initialize empty selections
linkSelection = linkGroup.selectAll<SVGLineElement, SimLink>("line");
nodeSelection = nodeGroup.selectAll<SVGPathElement, SimNode>("path.node-shape");
labelSelection = labelGroup.selectAll<SVGTextElement, SimNode>("text");
// Zoom + pan
zoomBehavior = d3
.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.05, 8])
.on("zoom", (event: d3.D3ZoomEvent<SVGSVGElement, unknown>) => {
container.attr("transform", event.transform.toString());
});
svg.call(zoomBehavior);
// Resize handler
const resizeObserver = new ResizeObserver(() => {
const w = graphEl.clientWidth;
const h = graphEl.clientHeight;
svg.attr("viewBox", `0 0 ${w} ${h}`);
});
resizeObserver.observe(graphEl);
}
// ---------------------------------------------------------------------------
// Data ingestion
// ---------------------------------------------------------------------------
function setData(nodes: GraphNode[], edges: GraphEdge[]): void {
// Build SimNodes
allNodes = nodes.map((n) => ({ ...n } as SimNode));
nodeMap = new Map(allNodes.map((n) => [n.qualifiedName, n]));
// Build SimLinks, filtering to edges where both endpoints exist
allEdges = [];
for (const e of edges) {
const src = nodeMap.get(e.sourceQualified);
const tgt = nodeMap.get(e.targetQualified);
if (src && tgt) {
allEdges.push({
source: src,
target: tgt,
kind: e.kind,
sourceQualified: e.sourceQualified,
targetQualified: e.targetQualified,
});
}
}
// Reset depth filter
depthLimit = 0;
const slider = document.getElementById("depth-slider") as HTMLInputElement | null;
if (slider) {
slider.value = "0";
}
const depthValue = document.getElementById("depth-value");
if (depthValue) {
depthValue.textContent = "All";
}
// Show/hide empty state
const emptyState = document.getElementById("empty-state");
const graphArea = document.getElementById("graph-area");
if (nodes.length === 0) {
if (emptyState) emptyState.style.display = "block";
if (graphArea) {
// Hide the SVG but keep the container
const svgHide = graphArea.querySelector("svg");
if (svgHide) svgHide.style.display = "none";
}
updateDepthSliderState();
return;
}
if (emptyState) emptyState.style.display = "none";
const svgEl = graphArea?.querySelector("svg");
if (svgEl) svgEl.style.display = "";
buildGraph();
updateDepthSliderState();
}
// ---------------------------------------------------------------------------
// Graph construction
// ---------------------------------------------------------------------------
function getVisibleData(): { nodes: SimNode[]; links: SimLink[] } {
// Filter edges by visible kinds
let links = allEdges.filter((e) => visibleEdgeKinds.has(e.kind));
let nodes: SimNode[];
if (selectedNode && depthLimit > 0) {
// BFS from selected node up to depthLimit
const reachable = new Set<string>();
reachable.add(selectedNode.qualifiedName);
let frontier = new Set<string>([selectedNode.qualifiedName]);
for (let d = 0; d < depthLimit; d++) {
const next = new Set<string>();
for (const qn of frontier) {
for (const link of links) {
const srcQn =
typeof link.source === "object"
? (link.source as SimNode).qualifiedName
: link.sourceQualified;
const tgtQn =
typeof link.target === "object"
? (link.target as SimNode).qualifiedName
: link.targetQualified;
if (srcQn === qn && !reachable.has(tgtQn)) {
reachable.add(tgtQn);
next.add(tgtQn);
}
if (tgtQn === qn && !reachable.has(srcQn)) {
reachable.add(srcQn);
next.add(srcQn);
}
}
}
frontier = next;
if (frontier.size === 0) break;
}
nodes = allNodes.filter((n) => reachable.has(n.qualifiedName));
const reachableSet = reachable;
links = links.filter((l) => {
const srcQn =
typeof l.source === "object"
? (l.source as SimNode).qualifiedName
: l.sourceQualified;
const tgtQn =
typeof l.target === "object"
? (l.target as SimNode).qualifiedName
: l.targetQualified;
return reachableSet.has(srcQn) && reachableSet.has(tgtQn);
});
} else {
nodes = [...allNodes];
}
// Apply search filter
const searchInput = document.getElementById("search-input") as HTMLInputElement | null;
const query = searchInput?.value?.trim().toLowerCase() ?? "";
if (query.length > 0) {
const matchingQns = new Set(
nodes
.filter((n) => n.name.toLowerCase().includes(query) || n.qualifiedName.toLowerCase().includes(query))
.map((n) => n.qualifiedName)
);
// Keep matching nodes + their direct neighbors
const expanded = new Set(matchingQns);
for (const link of links) {
const srcQn =
typeof link.source === "object"
? (link.source as SimNode).qualifiedName
: link.sourceQualified;
const tgtQn =
typeof link.target === "object"
? (link.target as SimNode).qualifiedName
: link.targetQualified;
if (matchingQns.has(srcQn)) expanded.add(tgtQn);
if (matchingQns.has(tgtQn)) expanded.add(srcQn);
}
nodes = nodes.filter((n) => expanded.has(n.qualifiedName));
links = links.filter((l) => {
const srcQn =
typeof l.source === "object"
? (l.source as SimNode).qualifiedName
: l.sourceQualified;
const tgtQn =
typeof l.target === "object"
? (l.target as SimNode).qualifiedName
: l.targetQualified;
return expanded.has(srcQn) && expanded.has(tgtQn);
});
}
return { nodes, links };
}
function buildGraph(): void {
const { nodes, links } = getVisibleData();
// Stop existing simulation
if (simulation) {
simulation.stop();
}
const graphEl = document.getElementById("graph-area")!;
const width = graphEl.clientWidth || window.innerWidth;
const height = graphEl.clientHeight || window.innerHeight;
// --- Links ---
linkSelection = linkGroup
.selectAll<SVGLineElement, SimLink>("line")
.data(links, (d) => `${d.sourceQualified}-${d.targetQualified}-${d.kind}`)
.join("line")
.attr("stroke", (d) => EDGE_COLOR[d.kind])
.attr("stroke-width", 1.5)
.attr("stroke-opacity", 0.4)
.attr("marker-end", (d) => `url(#arrow-${d.kind})`);
// --- Nodes ---
nodeSelection = nodeGroup
.selectAll<SVGPathElement, SimNode>("path.node-shape")
.data(nodes, (d) => d.qualifiedName)
.join("path")
.attr("class", "node-shape")
.attr("d", (d) => d3.symbol().type(NODE_SHAPE[d.kind] ?? d3.symbolCircle).size(NODE_AREA[d.kind] ?? 314)()!)
.attr("fill", (d) => NODE_COLOR[d.kind] ?? "#cdd6f4")
.attr("stroke", "none")
.attr("stroke-width", 2)
.attr("cursor", "pointer")
.on("click", (_event, d) => {
selectNode(d);
vscodeApi.postMessage({
command: "nodeClicked",
qualifiedName: d.qualifiedName,
filePath: d.filePath,
lineStart: d.lineStart ?? 1,
});
})
.on("dblclick", (_event, d) => {
// Center on node and expand depth by 1
selectNode(d);
depthLimit = Math.min(depthLimit + 1, 10);
const slider = document.getElementById("depth-slider") as HTMLInputElement | null;
if (slider) slider.value = String(depthLimit);
const depthValue = document.getElementById("depth-value");
if (depthValue) depthValue.textContent = String(depthLimit);
buildGraph();
centerOnNode(d);
})
.on("mouseenter", (_event, d) => {
showTooltip(d);
highlightConnected(d);
})
.on("mouseleave", () => {
hideTooltip();
unhighlightAll();
})
.call(
d3
.drag<SVGPathElement, SimNode>()
.on("start", (event, d) => {
if (!event.active) simulation?.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on("drag", (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on("end", (event, d) => {
if (!event.active) simulation?.alphaTarget(0);
d.fx = null;
d.fy = null;
})
)
.attr("tabindex", 0)
.attr("role", "button")
.attr("aria-label", (d) => `${d.kind}: ${d.name}`)
.on("keydown", (event: KeyboardEvent, d: SimNode) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectNode(d);
vscodeApi.postMessage({
command: "nodeClicked",
qualifiedName: d.qualifiedName,
filePath: d.filePath,
lineStart: d.lineStart ?? 1,
});
} else if (event.key === "Escape") {
event.preventDefault();
selectedNode = null;
unhighlightAll();
nodeSelection.attr("stroke", "none");
} else if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)) {
event.preventDefault();
const visibleNodes = nodeSelection.data();
let best: SimNode | null = null;
let bestDist = Infinity;
for (const n of visibleNodes) {
if (n.qualifiedName === d.qualifiedName || n.x == null || n.y == null || d.x == null || d.y == null) continue;
const dx = n.x - d.x;
const dy = n.y - d.y;
const dist = Math.sqrt(dx * dx + dy * dy);
let ok = false;
if (event.key === "ArrowRight" && dx > 0 && Math.abs(dy) < Math.abs(dx)) ok = true;
if (event.key === "ArrowLeft" && dx < 0 && Math.abs(dy) < Math.abs(dx)) ok = true;
if (event.key === "ArrowDown" && dy > 0 && Math.abs(dx) < Math.abs(dy)) ok = true;
if (event.key === "ArrowUp" && dy < 0 && Math.abs(dx) < Math.abs(dy)) ok = true;
if (ok && dist < bestDist) {
best = n;
bestDist = dist;
}
}
if (best) {
const target = nodeGroup.selectAll<SVGPathElement, SimNode>("path.node-shape")
.filter((n) => n.qualifiedName === best!.qualifiedName)
.node();
if (target) (target as HTMLElement).focus();
}
}
})
.on("focus", (_event: FocusEvent, d: SimNode) => {
showTooltip(d);
highlightConnected(d);
})
.on("blur", () => {
hideTooltip();
unhighlightAll();
});
// Highlight search matches
const searchInput = document.getElementById("search-input") as HTMLInputElement | null;
const query = searchInput?.value?.trim().toLowerCase() ?? "";
if (query.length > 0) {
nodeSelection.attr("stroke", (d) => {
const matches =
d.name.toLowerCase().includes(query) ||
d.qualifiedName.toLowerCase().includes(query);
return matches ? "#e6edf3" : "none";
});
}
// Highlight selected node
if (selectedNode) {
nodeSelection.attr("stroke", (d) => {
if (d.qualifiedName === selectedNode!.qualifiedName) return "#e6edf3";
if (query.length > 0) {
const matches =
d.name.toLowerCase().includes(query) ||
d.qualifiedName.toLowerCase().includes(query);
return matches ? "#e6edf3" : "none";
}
return "none";
});
}
// --- Labels ---
labelSelection = labelGroup
.selectAll<SVGTextElement, SimNode>("text")
.data(nodes, (d) => d.qualifiedName)
.join("text")
.text((d) => d.name)
.attr("font-size", 10)
.attr("fill", currentTheme === "dark" ? "#cdd6f4" : "#4c4f69")
.attr("text-anchor", "middle")
.attr("dy", (d) => (NODE_RADIUS[d.kind] ?? 10) + 14)
.attr("pointer-events", "none");
// --- Force simulation ---
simulation = d3
.forceSimulation<SimNode>(nodes)
.alphaDecay(0.02)
.force(
"link",
d3
.forceLink<SimNode, SimLink>(links)
.id((d) => d.qualifiedName)
.distance(100)
)
.force("charge", d3.forceManyBody<SimNode>().strength(-200))
.force("center", d3.forceCenter(width / 2, height / 2))
.force(
"collide",
d3.forceCollide<SimNode>().radius((d) => (NODE_RADIUS[d.kind] ?? 10) + 5)
)
.on("tick", () => {
linkSelection
.attr("x1", (d) => (d.source as SimNode).x!)
.attr("y1", (d) => (d.source as SimNode).y!)
.attr("x2", (d) => (d.target as SimNode).x!)
.attr("y2", (d) => (d.target as SimNode).y!);
nodeSelection.attr("transform", (d) => `translate(${d.x},${d.y})`);
labelSelection.attr("x", (d) => d.x!).attr("y", (d) => d.y!);
});
// Update node count display
const countEl = document.getElementById("node-count");
if (countEl) {
countEl.textContent = `${nodes.length} nodes, ${links.length} edges`;
}
}
// ---------------------------------------------------------------------------
// Selection & highlight
// ---------------------------------------------------------------------------
function selectNode(node: SimNode): void {
selectedNode = node;
nodeSelection.attr("stroke", (d) =>
d.qualifiedName === node.qualifiedName ? "#e6edf3" : "none"
);
updateDepthSliderState();
}
function updateDepthSliderState(): void {
const slider = document.getElementById("depth-slider") as HTMLInputElement | null;
const depthValue = document.getElementById("depth-value");
if (slider) {
if (selectedNode) {
slider.disabled = false;
} else {
slider.disabled = true;
if (depthValue) depthValue.textContent = "N/A";
}
}
}
function highlightConnected(node: SimNode): void {
const connectedQns = new Set<string>();
connectedQns.add(node.qualifiedName);
linkSelection.attr("stroke-opacity", (d) => {
const srcQn = (d.source as SimNode).qualifiedName;
const tgtQn = (d.target as SimNode).qualifiedName;
if (srcQn === node.qualifiedName || tgtQn === node.qualifiedName) {
connectedQns.add(srcQn);
connectedQns.add(tgtQn);
return 0.8;
}
return 0.1;
});
nodeSelection.attr("opacity", (d) =>
connectedQns.has(d.qualifiedName) ? 1 : 0.2
);
labelSelection.attr("opacity", (d) =>
connectedQns.has(d.qualifiedName) ? 1 : 0.2
);
}
function unhighlightAll(): void {
linkSelection.attr("stroke-opacity", 0.4);
nodeSelection.attr("opacity", 1);
labelSelection.attr("opacity", 1);
}
// ---------------------------------------------------------------------------
// Tooltip
// ---------------------------------------------------------------------------
function showTooltip(node: SimNode): void {
const tooltip = document.getElementById("tooltip")!;
tooltip.style.display = "block";
let html = `<strong>${escapeHtml(node.name)}</strong><br/>`;
html += `<span class="tooltip-kind">${escapeHtml(node.kind)}</span><br/>`;
html += `<span class="tooltip-path">${escapeHtml(node.filePath)}</span>`;
if (node.lineStart != null) {
html += `<br/>Lines ${node.lineStart}`;
if (node.lineEnd != null && node.lineEnd !== node.lineStart) {
html += `-${node.lineEnd}`;
}
}
if (node.params) {
html += `<br/><span class="tooltip-params">${escapeHtml(node.params)}</span>`;
}
if (node.returnType) {
html += ` <span class="tooltip-return">&rarr; ${escapeHtml(node.returnType)}</span>`;
}
tooltip.innerHTML = html;
// Position near cursor -- we'll update on mousemove too
document.addEventListener("mousemove", positionTooltip);
}
function positionTooltip(event: MouseEvent): void {
const tooltip = document.getElementById("tooltip")!;
const x = event.clientX + 12;
const y = event.clientY + 12;
// Keep tooltip in viewport
const rect = tooltip.getBoundingClientRect();
const maxX = window.innerWidth - rect.width - 8;
const maxY = window.innerHeight - rect.height - 8;
tooltip.style.left = `${Math.min(x, maxX)}px`;
tooltip.style.top = `${Math.min(y, maxY)}px`;
}
function hideTooltip(): void {
const tooltip = document.getElementById("tooltip")!;
tooltip.style.display = "none";
document.removeEventListener("mousemove", positionTooltip);
}
function escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// ---------------------------------------------------------------------------
// Highlight node (from extension message)
// ---------------------------------------------------------------------------
function highlightNodeByName(qualifiedName: string): void {
const node = nodeMap.get(qualifiedName);
if (!node) return;
selectNode(node);
centerOnNode(node);
// Add pulsing ring animation
const ring = nodeGroup
.append("circle")
.attr("cx", node.x ?? 0)
.attr("cy", node.y ?? 0)
.attr("r", (NODE_RADIUS[node.kind] ?? 10) + 4)
.attr("fill", "none")
.attr("stroke", "#e6edf3")
.attr("stroke-width", 3)
.attr("class", "pulse-ring");
// Remove after animation completes
ring
.transition()
.duration(600)
.attr("r", (NODE_RADIUS[node.kind] ?? 10) + 20)
.attr("stroke-opacity", 0)
.on("end", function () {
d3.select(this).remove();
});
// Second pulse
setTimeout(() => {
if (!node.x) return;
const ring2 = nodeGroup
.append("circle")
.attr("cx", node.x)
.attr("cy", node.y ?? 0)
.attr("r", (NODE_RADIUS[node.kind] ?? 10) + 4)
.attr("fill", "none")
.attr("stroke", "#e6edf3")
.attr("stroke-width", 3);
ring2
.transition()
.duration(600)
.attr("r", (NODE_RADIUS[node.kind] ?? 10) + 20)
.attr("stroke-opacity", 0)
.on("end", function () {
d3.select(this).remove();
});
}, 300);
}
// ---------------------------------------------------------------------------
// Camera
// ---------------------------------------------------------------------------
function centerOnNode(node: SimNode): void {
if (!node.x || !node.y) return;
const graphEl = document.getElementById("graph-area")!;
const width = graphEl.clientWidth;
const height = graphEl.clientHeight;
const transform = d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(1.5)
.translate(-node.x, -node.y);
svg
.transition()
.duration(500)
.call(zoomBehavior.transform, transform);
}
function fitToView(): void {
const graphEl = document.getElementById("graph-area")!;
const width = graphEl.clientWidth;
const height = graphEl.clientHeight;
if (allNodes.length === 0) return;
// Find bounding box of visible nodes
const visibleNodes = nodeSelection.data();
if (visibleNodes.length === 0) return;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const n of visibleNodes) {
if (n.x == null || n.y == null) continue;
const r = NODE_RADIUS[n.kind] ?? 10;
minX = Math.min(minX, n.x - r);
maxX = Math.max(maxX, n.x + r);
minY = Math.min(minY, n.y - r);
maxY = Math.max(maxY, n.y + r);
}
if (!isFinite(minX)) return;
const padding = 60;
const bboxWidth = maxX - minX + padding * 2;
const bboxHeight = maxY - minY + padding * 2;
const scale = Math.min(width / bboxWidth, height / bboxHeight, 2);
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const transform = d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(scale)
.translate(-cx, -cy);
svg
.transition()
.duration(500)
.call(zoomBehavior.transform, transform);
}
// ---------------------------------------------------------------------------
// Toolbar events
// ---------------------------------------------------------------------------
function bindToolbarEvents(): void {
// Search
const searchInput = document.getElementById("search-input") as HTMLInputElement | null;
if (searchInput) {
let debounceTimer: ReturnType<typeof setTimeout>;
searchInput.addEventListener("input", () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
buildGraph();
}, 250);
});
}
// Edge toggle pills
for (const kind of ALL_EDGE_KINDS) {
const pill = document.getElementById(`edge-${kind}`);
if (pill) {
const toggle = () => {
if (visibleEdgeKinds.has(kind)) {
visibleEdgeKinds.delete(kind);
pill.classList.remove("active");
pill.setAttribute("aria-pressed", "false");
} else {
visibleEdgeKinds.add(kind);
pill.classList.add("active");
pill.setAttribute("aria-pressed", "true");
}
buildGraph();
};
pill.addEventListener("click", toggle);
pill.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); toggle(); }
});
}
}
// Edge filter popover toggle
const edgeFilterBtn = document.getElementById("btn-edge-filter");
const edgePopover = document.getElementById("edge-popover");
if (edgeFilterBtn && edgePopover) {
edgeFilterBtn.addEventListener("click", (e) => {
e.stopPropagation();
edgePopover.classList.toggle("visible");
});
document.addEventListener("click", (e) => {
if (!edgePopover.contains(e.target as Node) && e.target !== edgeFilterBtn) {
edgePopover.classList.remove("visible");
}
});
}
// Depth slider
const depthSlider = document.getElementById("depth-slider") as HTMLInputElement | null;
if (depthSlider) {
depthSlider.addEventListener("input", () => {
depthLimit = parseInt(depthSlider.value, 10);
const depthValue = document.getElementById("depth-value");
if (depthValue) {
depthValue.textContent = depthLimit === 0 ? "All" : String(depthLimit);
}
buildGraph();
});
}
// Fit button
const fitBtn = document.getElementById("btn-fit");
if (fitBtn) {
fitBtn.addEventListener("click", () => {
fitToView();
});
}
// Export SVG button
const exportBtn = document.getElementById("btn-export");
if (exportBtn) {
exportBtn.addEventListener("click", () => {
const svgEl = document.querySelector("#graph-area svg");
if (svgEl) {
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svgEl);
vscodeApi.postMessage({
command: "exportSvg",
svg: svgString,
});
}
});
}
// Export PNG button
const exportPngBtn = document.getElementById("btn-export-png");
if (exportPngBtn) {
exportPngBtn.addEventListener("click", () => {
const svgEl = document.querySelector("#graph-area svg") as SVGSVGElement | null;
if (!svgEl) { return; }
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svgEl);
const canvas = document.createElement("canvas");
const bbox = svgEl.getBoundingClientRect();
canvas.width = bbox.width * 2; // 2x for retina
canvas.height = bbox.height * 2;
const ctx = canvas.getContext("2d");
if (!ctx) { return; }
ctx.scale(2, 2);
const img = new Image();
img.onload = () => {
ctx.drawImage(img, 0, 0);
const pngData = canvas.toDataURL("image/png");
vscodeApi.postMessage({ command: "exportPng", data: pngData });
};
img.src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svgString)));
});
}
}
// ---------------------------------------------------------------------------
// Extension message handling
// ---------------------------------------------------------------------------
function bindExtensionMessages(): void {
window.addEventListener("message", (event) => {
const message = event.data;
switch (message.command) {
case "setData":
setData(
message.nodes as GraphNode[],
message.edges as GraphEdge[]
);
// Auto-fit after simulation settles a bit
setTimeout(() => fitToView(), 800);
// Show truncation warning if needed
if (message.truncated) {
const warn = document.getElementById("truncation-warning");
if (warn) {
warn.style.display = "inline";
warn.textContent = `\u26a0 Showing ${message.maxNodes} of more nodes. Increase maxNodes in settings.`;
}
}
break;
case "highlightNode":
highlightNodeByName(message.qualifiedName as string);
break;
case "setTheme":
currentTheme = message.theme as "dark" | "light";
applyTheme();
break;
}
});
}
// ---------------------------------------------------------------------------
// Theme
// ---------------------------------------------------------------------------
function applyTheme(): void {
const textColor = currentTheme === "dark" ? "#cdd6f4" : "#4c4f69";
labelSelection.attr("fill", textColor);
}
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
init();
@@ -0,0 +1,499 @@
/**
* Tests for the SqliteReader module.
*
* Creates a temporary SQLite database with the exact schema used by the
* Python backend, inserts representative test data, and validates every
* public method of SqliteReader.
*/
import * as assert from 'assert';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import Database from 'better-sqlite3';
import { SqliteReader, GraphNode, GraphEdge } from '../src/backend/sqlite';
// ---------------------------------------------------------------------------
// Schema (mirrors the Python backend exactly)
// ---------------------------------------------------------------------------
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
name TEXT NOT NULL,
qualified_name TEXT NOT NULL UNIQUE,
file_path TEXT NOT NULL,
line_start INTEGER,
line_end INTEGER,
language TEXT,
parent_name TEXT,
params TEXT,
return_type TEXT,
modifiers TEXT,
is_test INTEGER DEFAULT 0,
file_hash TEXT,
extra TEXT DEFAULT '{}',
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS edges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
source_qualified TEXT NOT NULL,
target_qualified TEXT NOT NULL,
file_path TEXT NOT NULL,
line INTEGER DEFAULT 0,
extra TEXT DEFAULT '{}',
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
CREATE INDEX IF NOT EXISTS idx_nodes_qualified ON nodes(qualified_name);
CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_qualified);
CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_qualified);
CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind);
CREATE INDEX IF NOT EXISTS idx_edges_file ON edges(file_path);
`;
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
const NOW = Date.now() / 1000;
interface TestNode {
kind: string;
name: string;
qualified_name: string;
file_path: string;
line_start: number;
line_end: number;
language: string;
parent_name: string | null;
params: string | null;
return_type: string | null;
modifiers: string | null;
is_test: number;
file_hash: string;
extra: string;
updated_at: number;
}
interface TestEdge {
kind: string;
source_qualified: string;
target_qualified: string;
file_path: string;
line: number;
extra: string;
updated_at: number;
}
const TEST_NODES: TestNode[] = [
// auth.py -- File node + 2 functions
{
kind: 'File', name: 'auth.py', qualified_name: 'src/auth.py',
file_path: 'src/auth.py', line_start: 1, line_end: 50,
language: 'python', parent_name: null, params: null, return_type: null,
modifiers: null, is_test: 0, file_hash: 'aaa', extra: '{}', updated_at: NOW,
},
{
kind: 'Function', name: 'login', qualified_name: 'src/auth.py::login',
file_path: 'src/auth.py', line_start: 5, line_end: 20,
language: 'python', parent_name: null, params: '(username, password)',
return_type: 'bool', modifiers: null, is_test: 0, file_hash: 'aaa',
extra: '{}', updated_at: NOW,
},
{
kind: 'Function', name: 'logout', qualified_name: 'src/auth.py::logout',
file_path: 'src/auth.py', line_start: 22, line_end: 35,
language: 'python', parent_name: null, params: '(session)',
return_type: 'None', modifiers: null, is_test: 0, file_hash: 'aaa',
extra: '{}', updated_at: NOW,
},
// routes.py -- File node + 1 function
{
kind: 'File', name: 'routes.py', qualified_name: 'src/routes.py',
file_path: 'src/routes.py', line_start: 1, line_end: 40,
language: 'python', parent_name: null, params: null, return_type: null,
modifiers: null, is_test: 0, file_hash: 'bbb', extra: '{}', updated_at: NOW,
},
{
kind: 'Function', name: 'handle_login', qualified_name: 'src/routes.py::handle_login',
file_path: 'src/routes.py', line_start: 10, line_end: 30,
language: 'python', parent_name: null, params: '(request)',
return_type: 'Response', modifiers: null, is_test: 0, file_hash: 'bbb',
extra: '{}', updated_at: NOW,
},
// test_auth.py -- File node + 1 test function
{
kind: 'File', name: 'test_auth.py', qualified_name: 'tests/test_auth.py',
file_path: 'tests/test_auth.py', line_start: 1, line_end: 30,
language: 'python', parent_name: null, params: null, return_type: null,
modifiers: null, is_test: 0, file_hash: 'ccc', extra: '{}', updated_at: NOW,
},
{
kind: 'Test', name: 'test_login', qualified_name: 'tests/test_auth.py::test_login',
file_path: 'tests/test_auth.py', line_start: 5, line_end: 25,
language: 'python', parent_name: null, params: '()',
return_type: 'None', modifiers: null, is_test: 1, file_hash: 'ccc',
extra: '{}', updated_at: NOW,
},
];
const TEST_EDGES: TestEdge[] = [
// routes.py::handle_login CALLS auth.py::login
{
kind: 'CALLS', source_qualified: 'src/routes.py::handle_login',
target_qualified: 'src/auth.py::login', file_path: 'src/routes.py',
line: 15, extra: '{}', updated_at: NOW,
},
// routes.py IMPORTS_FROM auth.py
{
kind: 'IMPORTS_FROM', source_qualified: 'src/routes.py',
target_qualified: 'src/auth.py', file_path: 'src/routes.py',
line: 1, extra: '{}', updated_at: NOW,
},
// auth.py CONTAINS login
{
kind: 'CONTAINS', source_qualified: 'src/auth.py',
target_qualified: 'src/auth.py::login', file_path: 'src/auth.py',
line: 5, extra: '{}', updated_at: NOW,
},
// auth.py CONTAINS logout
{
kind: 'CONTAINS', source_qualified: 'src/auth.py',
target_qualified: 'src/auth.py::logout', file_path: 'src/auth.py',
line: 22, extra: '{}', updated_at: NOW,
},
// test_auth.py::test_login TESTED_BY (reverse: login is tested by test_login)
{
kind: 'TESTED_BY', source_qualified: 'src/auth.py::login',
target_qualified: 'tests/test_auth.py::test_login', file_path: 'tests/test_auth.py',
line: 5, extra: '{}', updated_at: NOW,
},
];
// ---------------------------------------------------------------------------
// Helper: create a populated temp database
// ---------------------------------------------------------------------------
function createTestDb(): string {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'crg-test-'));
const dbPath = path.join(tmpDir, 'graph.db');
const db = new Database(dbPath);
db.exec(SCHEMA_SQL);
const insertNode = db.prepare(`
INSERT INTO nodes
(kind, name, qualified_name, file_path, line_start, line_end,
language, parent_name, params, return_type, modifiers, is_test,
file_hash, extra, updated_at)
VALUES
(@kind, @name, @qualified_name, @file_path, @line_start, @line_end,
@language, @parent_name, @params, @return_type, @modifiers, @is_test,
@file_hash, @extra, @updated_at)
`);
const insertEdge = db.prepare(`
INSERT INTO edges
(kind, source_qualified, target_qualified, file_path, line, extra, updated_at)
VALUES
(@kind, @source_qualified, @target_qualified, @file_path, @line, @extra, @updated_at)
`);
const insertMeta = db.prepare(
'INSERT INTO metadata (key, value) VALUES (?, ?)'
);
const insertMany = db.transaction(() => {
for (const n of TEST_NODES) { insertNode.run(n); }
for (const e of TEST_EDGES) { insertEdge.run(e); }
insertMeta.run('last_updated', '2025-06-15T10:30:00Z');
});
insertMany();
db.close();
return dbPath;
}
function cleanup(dbPath: string): void {
try {
const dir = path.dirname(dbPath);
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// best effort
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('SqliteReader', () => {
let dbPath: string;
let reader: SqliteReader;
before(() => {
dbPath = createTestDb();
reader = new SqliteReader(dbPath);
});
after(() => {
reader.close();
cleanup(dbPath);
});
// -- isValid ------------------------------------------------------------
it('isValid() returns true for a properly initialised database', () => {
assert.strictEqual(reader.isValid(), true);
});
// -- getAllFiles ---------------------------------------------------------
it('getAllFiles() returns file paths ordered alphabetically', () => {
const files = reader.getAllFiles();
assert.deepStrictEqual(files, [
'src/auth.py',
'src/routes.py',
'tests/test_auth.py',
]);
});
// -- getNodesByFile -----------------------------------------------------
it('getNodesByFile() returns nodes ordered by line_start', () => {
const nodes = reader.getNodesByFile('src/auth.py');
assert.strictEqual(nodes.length, 3); // File + login + logout
// Verify ordering
assert.strictEqual(nodes[0].name, 'auth.py');
assert.strictEqual(nodes[1].name, 'login');
assert.strictEqual(nodes[2].name, 'logout');
// Verify camelCase conversion
assert.strictEqual(nodes[1].qualifiedName, 'src/auth.py::login');
assert.strictEqual(nodes[1].lineStart, 5);
assert.strictEqual(nodes[1].lineEnd, 20);
assert.strictEqual(nodes[1].returnType, 'bool');
assert.strictEqual(nodes[1].isTest, false);
});
// -- getNode ------------------------------------------------------------
it('getNode() returns a single node by qualified name', () => {
const node = reader.getNode('src/auth.py::login');
assert.ok(node);
assert.strictEqual(node.kind, 'Function');
assert.strictEqual(node.name, 'login');
assert.strictEqual(node.params, '(username, password)');
});
it('getNode() returns undefined for non-existent qualified name', () => {
const node = reader.getNode('src/auth.py::nonexistent');
assert.strictEqual(node, undefined);
});
// -- getNodeAtCursor ----------------------------------------------------
it('getNodeAtCursor() returns the innermost node at the cursor', () => {
// Line 10 is inside login (5-20) and inside auth.py (1-50).
// login has the smaller span so it should be returned.
const node = reader.getNodeAtCursor('src/auth.py', 10);
assert.ok(node);
assert.strictEqual(node.name, 'login');
});
it('getNodeAtCursor() returns the File node when cursor is outside functions', () => {
// Line 45 is inside auth.py (1-50) but outside both functions.
const node = reader.getNodeAtCursor('src/auth.py', 45);
assert.ok(node);
assert.strictEqual(node.kind, 'File');
assert.strictEqual(node.name, 'auth.py');
});
it('getNodeAtCursor() returns undefined when no node covers the line', () => {
const node = reader.getNodeAtCursor('src/auth.py', 999);
assert.strictEqual(node, undefined);
});
// -- getEdgesBySource ---------------------------------------------------
it('getEdgesBySource() returns outgoing edges', () => {
const edges = reader.getEdgesBySource('src/routes.py::handle_login');
assert.strictEqual(edges.length, 1);
assert.strictEqual(edges[0].kind, 'CALLS');
assert.strictEqual(edges[0].targetQualified, 'src/auth.py::login');
assert.strictEqual(edges[0].line, 15);
});
// -- getEdgesByTarget ---------------------------------------------------
it('getEdgesByTarget() returns incoming edges', () => {
const edges = reader.getEdgesByTarget('src/auth.py::login');
// CALLS from handle_login + CONTAINS from auth.py
assert.strictEqual(edges.length, 2);
const kinds = edges.map((e) => e.kind).sort();
assert.deepStrictEqual(kinds, ['CALLS', 'CONTAINS']);
});
// -- getEdgesAmong ------------------------------------------------------
it('getEdgesAmong() returns only edges within the given set', () => {
const qns = new Set([
'src/routes.py::handle_login',
'src/auth.py::login',
'src/auth.py',
]);
const edges = reader.getEdgesAmong(qns);
// Should include: CALLS handle_login->login, CONTAINS auth.py->login,
// IMPORTS_FROM routes.py->auth.py only if routes.py is in set (it's not).
assert.ok(edges.length >= 2);
for (const e of edges) {
assert.ok(qns.has(e.sourceQualified), `source ${e.sourceQualified} should be in set`);
assert.ok(qns.has(e.targetQualified), `target ${e.targetQualified} should be in set`);
}
});
it('getEdgesAmong() returns empty array for empty set', () => {
const edges = reader.getEdgesAmong(new Set());
assert.deepStrictEqual(edges, []);
});
// -- searchNodes --------------------------------------------------------
it('searchNodes() finds nodes by name substring', () => {
const results = reader.searchNodes('login');
assert.ok(results.length >= 2); // login, handle_login, test_login
const names = results.map((n) => n.name);
assert.ok(names.includes('login'));
assert.ok(names.includes('handle_login'));
});
it('searchNodes() respects limit', () => {
const results = reader.searchNodes('login', 1);
assert.strictEqual(results.length, 1);
});
it('searchNodes() returns empty for no match', () => {
const results = reader.searchNodes('zzz_no_match_zzz');
assert.deepStrictEqual(results, []);
});
// -- getStats -----------------------------------------------------------
it('getStats() returns correct aggregate counts', () => {
const stats = reader.getStats();
assert.strictEqual(stats.totalNodes, TEST_NODES.length);
assert.strictEqual(stats.totalEdges, TEST_EDGES.length);
assert.strictEqual(stats.filesCount, 3); // 3 File nodes
assert.deepStrictEqual(stats.languages, ['python']);
assert.strictEqual(stats.lastUpdated, '2025-06-15T10:30:00Z');
assert.strictEqual(stats.embeddingsCount, 0); // no embeddings table data
// Nodes by kind
assert.strictEqual(stats.nodesByKind['File'], 3);
assert.strictEqual(stats.nodesByKind['Function'], 3);
assert.strictEqual(stats.nodesByKind['Test'], 1);
// Edges by kind
assert.strictEqual(stats.edgesByKind['CALLS'], 1);
assert.strictEqual(stats.edgesByKind['IMPORTS_FROM'], 1);
assert.strictEqual(stats.edgesByKind['CONTAINS'], 2);
assert.strictEqual(stats.edgesByKind['TESTED_BY'], 1);
});
// -- getMetadata --------------------------------------------------------
it('getMetadata() returns stored value', () => {
const val = reader.getMetadata('last_updated');
assert.strictEqual(val, '2025-06-15T10:30:00Z');
});
it('getMetadata() returns undefined for missing key', () => {
const val = reader.getMetadata('nonexistent_key');
assert.strictEqual(val, undefined);
});
// -- getImpactRadius ----------------------------------------------------
it('getImpactRadius() finds changed and impacted nodes', () => {
const result = reader.getImpactRadius(['src/auth.py'], 2);
// Changed nodes: everything in auth.py (File + login + logout)
assert.strictEqual(result.changedNodes.length, 3);
const changedNames = result.changedNodes.map((n) => n.name).sort();
assert.deepStrictEqual(changedNames, ['auth.py', 'login', 'logout']);
// Impacted nodes: handle_login (calls login), test_login (tested_by),
// routes.py (imports_from auth.py), test_auth.py file (contains test_login)
assert.ok(
result.impactedNodes.length > 0,
'should have impacted nodes'
);
const impactedNames = result.impactedNodes.map((n) => n.name);
assert.ok(
impactedNames.includes('handle_login'),
'handle_login should be impacted (calls login)'
);
assert.ok(
impactedNames.includes('test_login'),
'test_login should be impacted (tested_by)'
);
// Impacted files should include routes.py and/or tests/test_auth.py
assert.ok(
result.impactedFiles.length > 0,
'should have impacted files'
);
// Edges among all involved nodes
assert.ok(
result.edges.length > 0,
'should have connecting edges'
);
});
it('getImpactRadius() with depth 0 returns only seeds, no impacted nodes', () => {
const result = reader.getImpactRadius(['src/auth.py'], 0);
assert.strictEqual(result.changedNodes.length, 3);
assert.strictEqual(result.impactedNodes.length, 0);
});
it('getImpactRadius() for non-existent file returns empty', () => {
const result = reader.getImpactRadius(['nonexistent.py']);
assert.strictEqual(result.changedNodes.length, 0);
assert.strictEqual(result.impactedNodes.length, 0);
assert.strictEqual(result.impactedFiles.length, 0);
});
// -- close / isValid after close ----------------------------------------
it('isValid() returns false after close()', () => {
const tmpPath = createTestDb();
const tmpReader = new SqliteReader(tmpPath);
assert.strictEqual(tmpReader.isValid(), true);
tmpReader.close();
assert.strictEqual(tmpReader.isValid(), false);
cleanup(tmpPath);
});
// -- constructor retry on bad path --------------------------------------
it('constructor throws after retries for a non-existent path', () => {
assert.throws(() => {
new SqliteReader('/nonexistent/path/to/database.db');
});
});
});
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test", "src/webview"]
}