mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
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:
parent
969b4623ca
commit
2c1373193a
13 changed files with 1949 additions and 1329 deletions
2755
gitnexus/package-lock.json
generated
2755
gitnexus/package-lock.json
generated
File diff suppressed because it is too large
Load diff
228
gitnexus/src/mcp/dead-code.ts
Normal file
228
gitnexus/src/mcp/dead-code.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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) ────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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}}).`;
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
8
gitnexus/test/fixtures/dead-code/src/alive-called.ts
vendored
Normal file
8
gitnexus/test/fixtures/dead-code/src/alive-called.ts
vendored
Normal 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);
|
||||
}
|
||||
10
gitnexus/test/fixtures/dead-code/src/alive-entry.ts
vendored
Normal file
10
gitnexus/test/fixtures/dead-code/src/alive-entry.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
14
gitnexus/test/fixtures/dead-code/src/dead-functions.ts
vendored
Normal file
14
gitnexus/test/fixtures/dead-code/src/dead-functions.ts
vendored
Normal 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');
|
||||
}
|
||||
10
gitnexus/test/fixtures/dead-code/src/index.ts
vendored
Normal file
10
gitnexus/test/fixtures/dead-code/src/index.ts
vendored
Normal 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();
|
||||
14
gitnexus/test/fixtures/dead-code/src/unused-exports.ts
vendored
Normal file
14
gitnexus/test/fixtures/dead-code/src/unused-exports.ts
vendored
Normal 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');
|
||||
}
|
||||
3
gitnexus/test/fixtures/dead-code/test/test-helper.ts
vendored
Normal file
3
gitnexus/test/fixtures/dead-code/test/test-helper.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function createMockData(): { id: number; name: string } {
|
||||
return { id: 1, name: 'test' };
|
||||
}
|
||||
121
gitnexus/test/integration/dead-code-e2e.test.ts
Normal file
121
gitnexus/test/integration/dead-code-e2e.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
73
gitnexus/test/unit/dead-code.test.ts
Normal file
73
gitnexus/test/unit/dead-code.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue