Guide to creating and registering custom code issue detectors.
Overview
Detectors analyze AST (Abstract Syntax Tree) to find specific code patterns that indicate performance issues. Each detector focuses on a particular type of problem.
Detector Architecture
┌─────────────────────────────────────────────────────────┐
│ BaseDetector │
├─────────────────────────────────────────────────────────┤
│ + name: string │
│ # issues: Issue[] │
│ │
│ + detect(ast, context): Promise<DetectorResult> │
│ # createIssue(...): Issue │
│ # createImpact(...): EstimatedImpact │
│ # getCode(node, sourceCode): string │
│ # reset(): void │
└─────────────────────────────────────────────────────────┘
▲
│ extends
┌───────────────┼───────────────┐
│ │ │
┌─────────┴─────┐ ┌──────┴──────┐ ┌──────┴──────┐
│ N1QueryDetector│ │MemoryLeak │ │ YourCustom │
│ │ │ Detector │ │ Detector │
└───────────────┘ └─────────────┘ └─────────────┘
Step 1: Create Detector Class
Create a new file in backend/src/detectors/:
// backend/src/detectors/my-custom-detector.ts
import { BaseDetector } from './base-detector';
import { AnalysisContext, DetectorResult, Issue } from '../types';
import traverse from '@babel/traverse';
import * as t from '@babel/types';
export class MyCustomDetector extends BaseDetector {
name = 'My Custom Detector';
async detect(ast: any, context: AnalysisContext): Promise<DetectorResult> {
this.reset();
const issues: Issue[] = [];
traverse(ast, {
// Define which AST nodes to visit
CallExpression: (path) => {
if (this.isProblematicPattern(path, context)) {
issues.push(this.createIssueFromNode(path, context));
}
},
// Can visit multiple node types
ForStatement: (path) => {
// Check for loops
},
AwaitExpression: (path) => {
// Check for async patterns
}
});
return {
issues,
detectorName: this.name
};
}
private isProblematicPattern(
path: any,
context: AnalysisContext
): boolean {
const node = path.node;
// Example: Detect console.log in production code
if (t.isMemberExpression(node.callee)) {
const obj = node.callee.object;
const prop = node.callee.property;
if (t.isIdentifier(obj, { name: 'console' }) &&
t.isIdentifier(prop, { name: 'log' })) {
return true;
}
}
return false;
}
private createIssueFromNode(
path: any,
context: AnalysisContext
): Issue {
const node = path.node;
const lineNumber = node.loc?.start.line || 0;
const code = this.getCode(node, context.sourceCode);
return this.createIssue(
'console_log_detected', // type
'low', // severity
context,
lineNumber,
'Console.log Detected', // title
'Console.log statements should be removed in production code.',
code, // codeBefore
'// Removed console.log', // codeAfter (suggested fix)
this.createImpact(
20, // severityScore
'Minor performance impact from logging',
0.95, // confidenceScore
'performance', // category
'trivial', // fixDifficulty
{ logsRemoved: 1 } // metrics
)
);
}
}
Step 2: Register Detector
Add your detector to the CodeAnalyzer in backend/src/analyzer/code-analyzer.ts.
Detectors are keyed by slug in initializeDetectors():
import { MyCustomDetector } from '../detectors/my-custom-detector';
// Inside initializeDetectors()
const all: Record<string, any> = {
// ... existing detectors
'my-custom': new MyCustomDetector(),
};
Add a matching entry to DETECTOR_REGISTRY so the slug can be selected per
scan. All detectors run on every tier — there is no basic/advanced split.
Or add dynamically to an existing instance:
const analyzer = new CodeAnalyzer();
analyzer.addDetector(new MyCustomDetector());
Step 3: Add Solution Generator (Optional)
If your detector should have auto-generated solutions:
// In CodeAnalyzer constructor
this.generators.set('console_log_detected', new MyCustomSolutionGenerator());
Common AST Patterns
Detecting Function Calls
traverse(ast, {
CallExpression: (path) => {
const callee = path.node.callee;
// Direct call: myFunction()
if (t.isIdentifier(callee, { name: 'myFunction' })) {
// Found it
}
// Method call: obj.method()
if (t.isMemberExpression(callee)) {
const objName = t.isIdentifier(callee.object) ? callee.object.name : null;
const methodName = t.isIdentifier(callee.property) ? callee.property.name : null;
}
}
});
Detecting Loops
traverse(ast, {
// for (let i = 0; i < n; i++)
ForStatement: (path) => {
const body = path.node.body;
// Analyze loop body
},
// for (const item of items)
ForOfStatement: (path) => {
const right = path.node.right; // The iterable
},
// for (const key in obj)
ForInStatement: (path) => {},
// while (condition)
WhileStatement: (path) => {},
// array.forEach()
CallExpression: (path) => {
if (t.isMemberExpression(path.node.callee) &&
t.isIdentifier(path.node.callee.property, { name: 'forEach' })) {
// Found forEach
}
}
});
Detecting Async Patterns
traverse(ast, {
// async function
FunctionDeclaration: (path) => {
if (path.node.async) {
// Async function
}
},
// await expression
AwaitExpression: (path) => {
const argument = path.node.argument;
// What's being awaited
},
// Check if inside a loop
AwaitExpression: (path) => {
const loopParent = path.findParent(p =>
t.isForStatement(p) ||
t.isForOfStatement(p) ||
t.isWhileStatement(p)
);
if (loopParent) {
// await inside loop - potential issue
}
}
});
Detecting Variable Declarations
traverse(ast, {
VariableDeclaration: (path) => {
path.node.declarations.forEach(decl => {
if (t.isIdentifier(decl.id)) {
const varName = decl.id.name;
const init = decl.init; // Initial value
}
});
}
});
Using Context (ORM Detection)
async detect(ast: any, context: AnalysisContext): Promise<DetectorResult> {
const { ormContext } = context;
traverse(ast, {
CallExpression: (path) => {
// Check if this is an ORM call
if (ormContext?.detectedORMs.has('prisma')) {
// Prisma-specific detection
}
if (ormContext?.detectedORMs.has('sequelize')) {
// Sequelize-specific detection
}
}
});
}
Testing Your Detector
Create a test file:
// src/__tests__/my-custom-detector.test.ts
import { MyCustomDetector } from '../detectors/my-custom-detector';
import { CodeParser } from '../analyzer/parser';
describe('MyCustomDetector', () => {
const detector = new MyCustomDetector();
const parser = new CodeParser();
test('detects console.log', async () => {
const code = `
function test() {
console.log('debug');
}
`;
const ast = parser.parse(code);
const context = {
sourceCode: code,
filePath: 'test.js',
ast
};
const result = await detector.detect(ast, context);
expect(result.issues).toHaveLength(1);
expect(result.issues[0].type).toBe('console_log_detected');
expect(result.issues[0].severity).toBe('low');
});
test('ignores code without console.log', async () => {
const code = `
function test() {
return 42;
}
`;
const ast = parser.parse(code);
const context = {
sourceCode: code,
filePath: 'test.js',
ast
};
const result = await detector.detect(ast, context);
expect(result.issues).toHaveLength(0);
});
});
Run tests:
npm test -- my-custom-detector
Best Practices
1. Use Specific Node Types
// Good - specific
traverse(ast, {
CallExpression: (path) => { /* ... */ }
});
// Avoid - too broad
traverse(ast, {
enter: (path) => { /* checks every node */ }
});
2. Provide Confidence Scores
this.createImpact(
severityScore,
description,
0.85, // 85% confident
// ...
);
3. Include Helpful Metrics
metrics: {
queriesInLoop: 3,
estimatedQueryReduction: '300%',
affectedLines: [5, 12, 18]
}
4. Give Actionable Suggestions
codeBefore: 'for (const id of ids) { await User.findByPk(id); }',
codeAfter: 'const users = await User.findAll({ where: { id: ids } });'
5. Handle Edge Cases
private isProblematicPattern(path: any): boolean {
// Guard against missing nodes
if (!path.node || !path.node.callee) {
return false;
}
// Check node type before accessing properties
if (!t.isMemberExpression(path.node.callee)) {
return false;
}
// ...
}
Example: Complete Custom Detector
// src/detectors/expensive-computation-detector.ts
import { BaseDetector } from './base-detector';
import { AnalysisContext, DetectorResult, Issue } from '../types';
import traverse from '@babel/traverse';
import * as t from '@babel/types';
export class ExpensiveComputationDetector extends BaseDetector {
name = 'Expensive Computation Detector';
private expensiveMethods = new Set([
'sort', 'reverse', 'filter', 'map', 'reduce',
'find', 'findIndex', 'some', 'every', 'flat', 'flatMap'
]);
async detect(ast: any, context: AnalysisContext): Promise<DetectorResult> {
this.reset();
const issues: Issue[] = [];
const chainedCalls: Map<number, string[]> = new Map();
traverse(ast, {
CallExpression: (path) => {
// Detect chained array methods
const chain = this.getMethodChain(path);
if (chain.length >= 3) {
const lineNumber = path.node.loc?.start.line || 0;
if (!chainedCalls.has(lineNumber)) {
chainedCalls.set(lineNumber, chain);
issues.push(this.createIssue(
'expensive_computation_chain',
chain.length >= 4 ? 'high' : 'medium',
context,
lineNumber,
`Chained Array Methods (${chain.length} operations)`,
`Multiple array method calls (${chain.join(' → ')}) create intermediate arrays. Consider combining into a single reduce() or for loop.`,
this.getCode(path.node, context.sourceCode),
this.generateSuggestion(chain),
this.createImpact(
chain.length * 15,
`${chain.length} array iterations instead of 1`,
0.9,
'performance',
'moderate',
{
chainLength: chain.length,
methods: chain,
estimatedIterations: `${chain.length}n`
}
)
));
}
}
}
});
return { issues, detectorName: this.name };
}
private getMethodChain(path: any): string[] {
const chain: string[] = [];
let current = path.node;
while (t.isCallExpression(current)) {
if (t.isMemberExpression(current.callee) &&
t.isIdentifier(current.callee.property)) {
const methodName = current.callee.property.name;
if (this.expensiveMethods.has(methodName)) {
chain.unshift(methodName);
current = current.callee.object;
} else {
break;
}
} else {
break;
}
}
return chain;
}
private generateSuggestion(chain: string[]): string {
if (chain.includes('filter') && chain.includes('map')) {
return `// Consider using reduce() to combine operations:
// array.reduce((acc, item) => {
// if (/* filter condition */) {
// acc.push(/* mapped value */);
// }
// return acc;
// }, []);`;
}
return '// Consider combining into a single iteration';
}
}