chore: init from code-review-graph-main snapshot (v2.3.7)

This commit is contained in:
dev
2026-08-05 13:16:12 +08:00
commit 82b7c6dc9e
358 changed files with 118525 additions and 0 deletions
@@ -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);
}