feat: add dead_code MCP tool for detecting unused code

New MCP tool that detects functions, methods, and classes with zero
callers by traversing the knowledge graph. Two confidence tiers:
- dead: zero incoming CALLS/IMPORTS, not an entry point or process participant
- unused_export: exported but never imported from another file

Validated against real projects (Next.js, PHP, Swift). Includes label
validation, parallelized edge queries, unit tests (8), integration
tests (4), and purpose-built fixture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test 2026-03-26 08:21:31 +01:00
parent 969b4623ca
commit 2c1373193a
13 changed files with 1949 additions and 1329 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,228 @@
/**
* Dead Code Detection
*
* Detects functions, methods, and classes with zero callers by
* querying the knowledge graph for incoming edges.
*
* Two confidence tiers:
* - dead: zero incoming CALLS/IMPORTS, not an entry point, not in a process, not a route handler
* - unused_export: exported but never imported from another file
*/
import { executeQuery } from './core/lbug-adapter.js';
import { isTestFilePath } from './local/local-backend.js';
export interface SymbolWithEdges {
id: string;
name: string;
label: string;
filePath: string;
startLine: number;
isExported: boolean;
hasIncomingCalls: boolean;
hasIncomingImports: boolean;
isEntryPoint: boolean;
isProcessParticipant: boolean;
isRouteHandler: boolean;
}
export interface DeadSymbol {
name: string;
label: string;
filePath: string;
startLine: number;
tag: 'dead' | 'unused_export';
}
export interface DeadCodeResult {
summary: {
total: number;
dead: number;
unused_export: number;
files_affected: number;
};
by_file: Array<{
filePath: string;
symbols: Array<{
name: string;
label: string;
startLine: number;
tag: 'dead' | 'unused_export';
}>;
}>;
}
/**
* Pure classification logic — no DB access, fully testable.
* Takes enriched symbols and returns classified dead symbols sorted by file.
*/
export function classifyDeadSymbols(symbols: SymbolWithEdges[]): DeadSymbol[] {
const dead: DeadSymbol[] = [];
for (const sym of symbols) {
// Skip if it has any incoming calls
if (sym.hasIncomingCalls) continue;
// Skip if it's an entry point, process participant, or route handler
if (sym.isEntryPoint || sym.isProcessParticipant || sym.isRouteHandler) continue;
if (sym.isExported && !sym.hasIncomingImports) {
// Exported but never imported — unused_export
dead.push({
name: sym.name,
label: sym.label,
filePath: sym.filePath,
startLine: sym.startLine,
tag: 'unused_export',
});
} else if (!sym.isExported && !sym.hasIncomingImports) {
// Not exported, no callers, not special — dead
dead.push({
name: sym.name,
label: sym.label,
filePath: sym.filePath,
startLine: sym.startLine,
tag: 'dead',
});
}
}
// Sort by filePath, then startLine
dead.sort((a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine);
return dead;
}
/**
* Query the knowledge graph and detect dead code.
* Uses batch queries to build incoming-edge sets, then classifies.
*/
export async function findDeadCode(
repoId: string,
params: {
label?: string;
includeTests?: boolean;
limit?: number;
}
): Promise<DeadCodeResult> {
const { label, includeTests = false, limit = 50 } = params;
// Step 1: Get all callable nodes (query per label — labels() not supported in Cypher subset)
const callableLabels = ['Function', 'Method', 'Class', 'Constructor'];
if (label && !callableLabels.includes(label)) {
return {
summary: { total: 0, dead: 0, unused_export: 0, files_affected: 0 },
by_file: [],
};
}
const labelsToQuery = label ? [label] : callableLabels;
// isExported exists on Function, Method, Class, Interface — not on Constructor
const labelsWithExported = new Set(['Function', 'Method', 'Class', 'Interface']);
const allSymbols: any[] = [];
for (const lbl of labelsToQuery) {
const hasExported = labelsWithExported.has(lbl);
const rows = await executeQuery(repoId, `
MATCH (n:\`${lbl}\`)
RETURN n.id AS id, n.name AS name, n.filePath AS filePath,
n.startLine AS startLine${hasExported ? ', n.isExported AS isExported' : ''}
`);
for (const row of rows) {
allSymbols.push({ ...row, label: lbl, isExported: row.isExported ?? false });
}
}
// Step 2: Build sets of node IDs that have incoming edges
const [callEdges, importEdges, entryEdges, processEdges, routeEdges] = await Promise.all([
executeQuery(repoId, `MATCH (caller)-[r:CodeRelation {type: 'CALLS'}]->(target) RETURN DISTINCT target.id AS targetId`),
executeQuery(repoId, `MATCH (importer)-[r:CodeRelation {type: 'IMPORTS'}]->(target) RETURN DISTINCT target.id AS targetId`),
executeQuery(repoId, `MATCH (n)-[r:CodeRelation {type: 'ENTRY_POINT_OF'}]->(p) RETURN DISTINCT n.id AS nodeId`),
executeQuery(repoId, `MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p) RETURN DISTINCT n.id AS nodeId`),
executeQuery(repoId, `MATCH (n)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route) RETURN DISTINCT n.id AS nodeId`),
]);
const hasIncomingCalls = new Set<string>();
for (const row of callEdges) {
hasIncomingCalls.add(row.targetId ?? row[0]);
}
const hasIncomingImports = new Set<string>();
for (const row of importEdges) {
hasIncomingImports.add(row.targetId ?? row[0]);
}
const isEntryPoint = new Set<string>();
for (const row of entryEdges) {
isEntryPoint.add(row.nodeId ?? row[0]);
}
const isProcessParticipant = new Set<string>();
for (const row of processEdges) {
isProcessParticipant.add(row.nodeId ?? row[0]);
}
const isRouteHandler = new Set<string>();
for (const row of routeEdges) {
isRouteHandler.add(row.nodeId ?? row[0]);
}
// Step 3: Enrich symbols with edge info and filter
const enriched: SymbolWithEdges[] = [];
for (const row of allSymbols) {
const id = row.id ?? row[0];
const filePath = row.filePath ?? row[3] ?? '';
// Filter test files unless includeTests
if (!includeTests && isTestFilePath(filePath)) continue;
enriched.push({
id,
name: row.name ?? row[1] ?? '',
label: row.label ?? row[2] ?? '',
filePath,
startLine: row.startLine ?? row[4] ?? 0,
isExported: row.isExported ?? row[5] ?? false,
hasIncomingCalls: hasIncomingCalls.has(id),
hasIncomingImports: hasIncomingImports.has(id),
isEntryPoint: isEntryPoint.has(id),
isProcessParticipant: isProcessParticipant.has(id),
isRouteHandler: isRouteHandler.has(id),
});
}
// Step 4: Classify
const deadSymbols = classifyDeadSymbols(enriched);
// Step 5: Apply limit and group by file
const limited = deadSymbols.slice(0, limit);
const byFileMap = new Map<string, DeadSymbol[]>();
for (const sym of limited) {
const existing = byFileMap.get(sym.filePath);
if (existing) {
existing.push(sym);
} else {
byFileMap.set(sym.filePath, [sym]);
}
}
const by_file = Array.from(byFileMap.entries()).map(([filePath, symbols]) => ({
filePath,
symbols: symbols.map(s => ({
name: s.name,
label: s.label,
startLine: s.startLine,
tag: s.tag,
})),
}));
const deadCount = limited.filter(s => s.tag === 'dead').length;
const unusedExportCount = limited.filter(s => s.tag === 'unused_export').length;
return {
summary: {
total: limited.length,
dead: deadCount,
unused_export: unusedExportCount,
files_affected: byFileMap.size,
},
by_file,
};
}

View file

@ -497,6 +497,8 @@ export class LocalBackend {
return this.toolMap(repo, params);
case 'api_impact':
return this.apiImpact(repo, params);
case 'dead_code':
return this.deadCode(repo, params);
default:
throw new Error(`Unknown tool: ${method}`);
}
@ -3041,6 +3043,20 @@ export class LocalBackend {
return { routes: results, total: results.length };
}
private async deadCode(repo: RepoHandle, params: {
label?: string;
include_tests?: boolean;
limit?: number;
}): Promise<any> {
await this.ensureInitialized(repo.id);
const { findDeadCode } = await import('../dead-code.js');
return findDeadCode(repo.id, {
label: params.label,
includeTests: params.include_tests ?? false,
limit: params.limit ?? 50,
});
}
// ─── Direct Graph Queries (for resources.ts) ────────────────────
/**

View file

@ -64,6 +64,9 @@ function getNextStepHint(toolName: string, args: Record<string, any> | undefined
case 'cypher':
return `\n\n---\n**Next:** To explore a result symbol, use context({name: "<name>"${repoParam}}). For schema reference, READ gitnexus://repo/${repoPath}/schema.`;
case 'dead_code':
return `\n\n---\n**Next:** Use context({name: "<symbol_name>"${repoParam}}) to verify a flagged symbol is truly unused. Then use impact({target: "<name>", direction: "upstream"${repoParam}}) before removing it.`;
// Legacy tool names — still return useful hints
case 'search':
return `\n\n---\n**Next:** To understand a result in context, use context({name: "<symbol_name>"${repoParam}}).`;

View file

@ -453,4 +453,27 @@ WHEN TO USE: Before group_sync or when agents should refresh indexes.`,
required: ['name'],
},
},
{
name: 'dead_code',
description: `Detect unused functions, methods, and classes in the codebase.
Traverses the knowledge graph to find symbols with zero incoming references.
WHEN TO USE: Code cleanup, identifying unused code before refactoring, finding leftover functions after feature removal.
AFTER THIS: Use context() on flagged symbols to verify they're truly unused. Use impact() to check if removing them is safe.
Returns symbols grouped by file with confidence tags:
- dead: zero callers, zero imports, not an entry point, not in any execution flow
- unused_export: exported but never imported by another file`,
inputSchema: {
type: 'object',
properties: {
repo: { type: 'string', description: 'Repository name or path. Omit if only one repo is indexed.' },
label: { type: 'string', description: 'Filter by node type: Function, Method, Class, Constructor. Default: all callable types.' },
include_tests: { type: 'boolean', description: 'Include test files in results. Default: false.' },
limit: { type: 'number', description: 'Max results. Default: 50.', default: 50 },
},
required: [],
},
},
];

View file

@ -0,0 +1,8 @@
import { validateInput } from './alive-entry';
import { formatOutput } from './unused-exports';
export function handleRequest(input: string): string {
const valid = validateInput(input);
if (!valid) return 'invalid';
return formatOutput(input);
}

View file

@ -0,0 +1,10 @@
export function validateInput(input: string): boolean {
return input.length > 0;
}
// This is an entry point function (called from index.ts indirectly)
export function processCommand(args: string[]): void {
for (const arg of args) {
validateInput(arg);
}
}

View file

@ -0,0 +1,14 @@
// These functions are never called by anything
export function unusedHelper(x: number): number {
return x * 2;
}
export function deprecatedFormat(data: string): string {
return data.trim().toLowerCase();
}
function internalDead(): void {
// Not exported, not called
console.log('dead');
}

View file

@ -0,0 +1,10 @@
import { handleRequest } from './alive-called';
import { processCommand } from './alive-entry';
export function main() {
const result = handleRequest('test');
console.log(result);
processCommand(['arg1']);
}
main();

View file

@ -0,0 +1,14 @@
// formatOutput IS imported by alive-called.ts — should NOT be detected
export function formatOutput(input: string): string {
return `[${input}]`;
}
// neverImported is exported but never imported anywhere — unused_export
export function neverImported(): string {
return 'nobody imports me';
}
// alsoNeverImported is exported but never imported anywhere — unused_export
export function alsoNeverImported(): void {
console.log('also unused');
}

View file

@ -0,0 +1,3 @@
export function createMockData(): { id: number; name: string } {
return { id: 1, name: 'test' };
}

View file

@ -0,0 +1,121 @@
/**
* Integration Test: dead_code fixture graph verification
*
* Runs the full pipeline on the dead-code fixture and verifies
* the graph has correct nodes and edges for dead code classification.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import { classifyDeadSymbols, type SymbolWithEdges } from '../../src/mcp/dead-code.js';
import { isTestFilePath } from '../../src/mcp/local/local-backend.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'dead-code');
describe('dead_code graph verification', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(FIXTURE, () => {});
}, 60000);
it('fixture produces callable symbol nodes', () => {
const callableNames: string[] = [];
result.graph.forEachNode(n => {
if (['Function', 'Method', 'Class', 'Constructor'].includes(n.label)) {
callableNames.push(n.properties.name);
}
});
// Dead functions
expect(callableNames).toContain('unusedHelper');
expect(callableNames).toContain('deprecatedFormat');
// Alive functions
expect(callableNames).toContain('main');
expect(callableNames).toContain('handleRequest');
expect(callableNames).toContain('validateInput');
expect(callableNames).toContain('formatOutput');
});
it('CALLS edges exist for alive functions', () => {
const callTargets = new Set<string>();
for (const rel of result.graph.iterRelationships()) {
if (rel.type === 'CALLS') {
const target = result.graph.getNode(rel.targetId);
if (target) callTargets.add(target.properties.name);
}
}
expect(callTargets).toContain('handleRequest');
expect(callTargets).toContain('validateInput');
expect(callTargets).toContain('formatOutput');
});
it('dead functions have no incoming CALLS edges', () => {
const callTargetIds = new Set<string>();
for (const rel of result.graph.iterRelationships()) {
if (rel.type === 'CALLS') callTargetIds.add(rel.targetId);
}
result.graph.forEachNode(n => {
if (n.properties.name === 'unusedHelper' || n.properties.name === 'deprecatedFormat') {
expect(callTargetIds).not.toContain(n.id);
}
});
});
it('classifyDeadSymbols correctly classifies graph-derived data', () => {
// Build SymbolWithEdges from the actual graph
const callTargetIds = new Set<string>();
const importTargetIds = new Set<string>();
const entryPointIds = new Set<string>();
const processIds = new Set<string>();
const routeHandlerIds = new Set<string>();
for (const rel of result.graph.iterRelationships()) {
if (rel.type === 'CALLS') callTargetIds.add(rel.targetId);
if (rel.type === 'IMPORTS') importTargetIds.add(rel.targetId);
if (rel.type === 'ENTRY_POINT_OF') entryPointIds.add(rel.sourceId);
if (rel.type === 'STEP_IN_PROCESS') processIds.add(rel.sourceId);
if (rel.type === 'HANDLES_ROUTE') routeHandlerIds.add(rel.sourceId);
}
const symbols: SymbolWithEdges[] = [];
result.graph.forEachNode(n => {
if (!['Function', 'Method', 'Class', 'Constructor'].includes(n.label)) return;
if (isTestFilePath(n.properties.filePath || '')) return;
symbols.push({
id: n.id,
name: n.properties.name,
label: n.label,
filePath: n.properties.filePath || '',
startLine: n.properties.startLine || 0,
isExported: n.properties.isExported || false,
hasIncomingCalls: callTargetIds.has(n.id),
hasIncomingImports: importTargetIds.has(n.id),
isEntryPoint: entryPointIds.has(n.id),
isProcessParticipant: processIds.has(n.id),
isRouteHandler: routeHandlerIds.has(n.id),
});
});
const dead = classifyDeadSymbols(symbols);
const deadNames = dead.map(d => d.name);
// Dead functions should be detected
expect(deadNames).toContain('unusedHelper');
expect(deadNames).toContain('deprecatedFormat');
expect(deadNames).toContain('internalDead');
// Alive functions should NOT be detected
expect(deadNames).not.toContain('main');
expect(deadNames).not.toContain('handleRequest');
expect(deadNames).not.toContain('validateInput');
expect(deadNames).not.toContain('formatOutput');
// Unused exports
const unusedExports = dead.filter(d => d.tag === 'unused_export').map(d => d.name);
expect(unusedExports).toContain('neverImported');
expect(unusedExports).toContain('alsoNeverImported');
expect(unusedExports).not.toContain('formatOutput');
});
});

View file

@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { classifyDeadSymbols, type SymbolWithEdges } from '../../src/mcp/dead-code.js';
describe('classifyDeadSymbols', () => {
const makeSymbol = (overrides: Partial<SymbolWithEdges>): SymbolWithEdges => ({
id: 'test-id',
name: 'testFn',
label: 'Function',
filePath: 'src/test.ts',
startLine: 1,
isExported: false,
hasIncomingCalls: false,
hasIncomingImports: false,
isEntryPoint: false,
isProcessParticipant: false,
isRouteHandler: false,
...overrides,
});
it('classifies zero-caller non-entry-point as dead', () => {
const symbols = [makeSymbol({ name: 'unused' })];
const result = classifyDeadSymbols(symbols);
expect(result).toHaveLength(1);
expect(result[0].tag).toBe('dead');
});
it('excludes symbols with incoming CALLS', () => {
const symbols = [makeSymbol({ name: 'called', hasIncomingCalls: true })];
const result = classifyDeadSymbols(symbols);
expect(result).toHaveLength(0);
});
it('excludes entry points', () => {
const symbols = [makeSymbol({ name: 'entry', isEntryPoint: true })];
const result = classifyDeadSymbols(symbols);
expect(result).toHaveLength(0);
});
it('excludes process participants', () => {
const symbols = [makeSymbol({ name: 'inProcess', isProcessParticipant: true })];
const result = classifyDeadSymbols(symbols);
expect(result).toHaveLength(0);
});
it('excludes route handlers', () => {
const symbols = [makeSymbol({ name: 'handler', isRouteHandler: true })];
const result = classifyDeadSymbols(symbols);
expect(result).toHaveLength(0);
});
it('classifies exported-but-never-imported as unused_export', () => {
const symbols = [makeSymbol({ name: 'exported', isExported: true, hasIncomingImports: false })];
const result = classifyDeadSymbols(symbols);
expect(result).toHaveLength(1);
expect(result[0].tag).toBe('unused_export');
});
it('excludes symbols with incoming IMPORTS', () => {
const symbols = [makeSymbol({ name: 'imported', isExported: true, hasIncomingImports: true })];
const result = classifyDeadSymbols(symbols);
expect(result).toHaveLength(0);
});
it('groups results by file and sorts by filePath', () => {
const symbols = [
makeSymbol({ name: 'b', filePath: 'src/z.ts', startLine: 10 }),
makeSymbol({ name: 'a', filePath: 'src/a.ts', startLine: 5 }),
];
const result = classifyDeadSymbols(symbols);
expect(result[0].filePath).toBe('src/a.ts');
expect(result[1].filePath).toBe('src/z.ts');
});
});