#!/usr/bin/env bun
/**
* Error Handling Anti-Pattern Detector
*
* Detects try-catch anti-patterns that cause silent failures and debugging nightmares.
* Run this before committing code that touches error handling.
*
* Based on hard-learned lessons: defensive try-catch wastes 10+ hours of debugging time.
*/
import { readFileSync, readdirSync, statSync } from 'fs';
import { join, relative } from 'path';
interface AntiPattern {
file: string;
line: number;
pattern: string;
severity: 'ISSUE' | 'APPROVED_OVERRIDE';
description: string;
code: string;
overrideReason?: string;
}
const CRITICAL_PATHS = [
'SDKAgent.ts',
'GeminiAgent.ts',
'OpenRouterAgent.ts',
'SessionStore.ts',
'worker-service.ts'
];
function findFilesRecursive(dir: string, pattern: RegExp): string[] {
const files: string[] = [];
const items = readdirSync(dir);
for (const item of items) {
const fullPath = join(dir, item);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
if (!item.startsWith('.') && item !== 'node_modules' && item !== 'dist' && item !== 'plugin') {
files.push(...findFilesRecursive(fullPath, pattern));
}
} else if (pattern.test(item)) {
files.push(fullPath);
}
}
return files;
}
function detectAntiPatterns(filePath: string, projectRoot: string): AntiPattern[] {
const content = readFileSync(filePath, 'utf-8');
const lines = content.split('\n');
const antiPatterns: AntiPattern[] = [];
const relPath = relative(projectRoot, filePath);
const isCriticalPath = CRITICAL_PATHS.some(cp => filePath.includes(cp));
// Detect error message string matching for type detection (line-by-line patterns)
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Check for [ANTI-PATTERN IGNORED] on the same or previous line
const hasOverride = trimmed.includes('[ANTI-PATTERN IGNORED]') ||
(i > 0 && lines[i - 1].includes('[ANTI-PATTERN IGNORED]'));
const overrideMatch = (trimmed + (i > 0 ? lines[i - 1] : '')).match(/\[ANTI-PATTERN IGNORED\]:\s*(.+)/i);
const overrideReason = overrideMatch?.[1]?.trim();
// CRITICAL: Error message string matching for type detection
// Patterns like: errorMessage.includes('connection') or error.message.includes('timeout')
const errorStringMatchPatterns = [
/error(?:Message|\.message)\s*\.includes\s*\(\s*['"`](\w+)['"`]\s*\)/i,
/(?:err|e)\.message\s*\.includes\s*\(\s*['"`](\w+)['"`]\s*\)/i,
/String\s*\(\s*(?:error|err|e)\s*\)\s*\.includes\s*\(\s*['"`](\w+)['"`]\s*\)/i,
];
for (const pattern of errorStringMatchPatterns) {
const match = trimmed.match(pattern);
if (match) {
const matchedString = match[1];
// Common generic patterns that are too broad
const genericPatterns = ['error', 'fail', 'connection', 'timeout', 'not', 'invalid', 'unable'];
const isGeneric = genericPatterns.some(gp => matchedString.toLowerCase().includes(gp));
if (hasOverride && overrideReason) {
antiPatterns.push({
file: relPath,
line: i + 1,
pattern: 'ERROR_STRING_MATCHING',
severity: 'APPROVED_OVERRIDE',
description: `Error type detection via string matching on "${matchedString}" - approved override.`,
code: trimmed,
overrideReason
});
} else {
antiPatterns.push({
file: relPath,
line: i + 1,
pattern: 'ERROR_STRING_MATCHING',
severity: 'ISSUE',
description: `Error type detection via string matching on "${matchedString}" - fragile and masks the real error. Log the FULL error object. We don't care about pretty error handling, we care about SEEING what went wrong.`,
code: trimmed
});
}
}
}
// HIGH: Logging only error.message instead of the full error object
// Patterns like: logger.error('X', 'Y', {}, error.message) or console.error(error.message)
const partialErrorLoggingPatterns = [
/logger\.(error|warn|info|debug|failure)\s*\([^)]*,\s*(?:error|err|e)\.message\s*\)/,
/logger\.(error|warn|info|debug|failure)\s*\([^)]*\{\s*(?:error|err|e):\s*(?:error|err|e)\.message\s*\}/,
/console\.(error|warn|log)\s*\(\s*(?:error|err|e)\.message\s*\)/,
/console\.(error|warn|log)\s*\(\s*['"`][^'"`]+['"`]\s*,\s*(?:error|err|e)\.message\s*\)/,
];
for (const pattern of partialErrorLoggingPatterns) {
if (pattern.test(trimmed)) {
if (hasOverride && overrideReason) {
antiPatterns.push({
file: relPath,
line: i + 1,
pattern: 'PARTIAL_ERROR_LOGGING',
severity: 'APPROVED_OVERRIDE',
description: 'Logging only error.message instead of full error object - approved override.',
code: trimmed,
overrideReason
});
} else {
antiPatterns.push({
file: relPath,
line: i + 1,
pattern: 'PARTIAL_ERROR_LOGGING',
severity: 'ISSUE',
description: 'Logging only error.message HIDES the stack trace, error type, and all properties. ALWAYS pass the full error object - you need the complete picture, not a summary.',
code: trimmed
});
}
}
}
// CRITICAL: Catch-all error type guessing based on message content
// Pattern: if (errorMessage.includes('X') || errorMessage.includes('Y'))
const multipleIncludes = trimmed.match(/(?:error(?:Message|\.message)|(?:err|e)\.message).*\.includes.*\|\|.*\.includes/i);
if (multipleIncludes) {
if (hasOverride && overrideReason) {
antiPatterns.push({
file: relPath,
line: i + 1,
pattern: 'ERROR_MESSAGE_GUESSING',
severity: 'APPROVED_OVERRIDE',
description: 'Multiple string checks on error message to guess error type - approved override.',
code: trimmed,
overrideReason
});
} else {
antiPatterns.push({
file: relPath,
line: i + 1,
pattern: 'ERROR_MESSAGE_GUESSING',
severity: 'ISSUE',
description: 'Multiple string checks on error message to guess error type. STOP GUESSING. Log the FULL error object. We don\'t care what the library throws - we care about SEEING the error when it happens.',
code: trimmed
});
}
}
}
// Track try-catch blocks
let inTry = false;
let tryStartLine = 0;
let tryLines: string[] = [];
let braceDepth = 0;
let catchStartLine = 0;
let catchLines: string[] = [];
let inCatch = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Detect standalone promise empty catch: .catch(() => {})
const emptyPromiseCatch = trimmed.match(/\.catch\s*\(\s*\(\s*\)\s*=>\s*\{\s*\}\s*\)/);
if (emptyPromiseCatch) {
antiPatterns.push({
file: relPath,
line: i + 1,
pattern: 'PROMISE_EMPTY_CATCH',
severity: 'ISSUE',
description: 'Promise .catch() with empty handler - errors disappear into the void.',
code: trimmed
});
}
// Detect standalone promise catch without logging: .catch(err => ...)
const promiseCatchMatch = trimmed.match(/\.catch\s*\(\s*(?:\(\s*)?(\w+)(?:\s*\))?\s*=>/);
if (promiseCatchMatch && !emptyPromiseCatch) {
// Look ahead up to 10 lines to see if there's logging in the handler body
let catchBody = trimmed.substring(promiseCatchMatch.index || 0);
let braceCount = (catchBody.match(/{/g) || []).length - (catchBody.match(/}/g) || []).length;
// Collect subsequent lines if the handler spans multiple lines
let lookAhead = 0;
while (braceCount > 0 && lookAhead < 10 && i + lookAhead + 1 < lines.length) {
lookAhead++;
const nextLine = lines[i + lookAhead];
catchBody += '\n' + nextLine;
braceCount += (nextLine.match(/{/g) || []).length - (nextLine.match(/}/g) || []).length;
}
const hasLogging = catchBody.match(/logger\.(error|warn|debug|info|failure)/) ||
catchBody.match(/console\.(error|warn)/);
if (!hasLogging && lookAhead > 0) { // Only flag if it's actually a multi-line handler
antiPatterns.push({
file: relPath,
line: i + 1,
pattern: 'PROMISE_CATCH_NO_LOGGING',
severity: 'ISSUE',
description: 'Promise .catch() without logging - errors are silently swallowed.',
code: catchBody.trim().split('\n').slice(0, 5).join('\n')
});
}
}
// Detect try block start
if (trimmed.match(/^\s*try\s*{/) || trimmed.match(/}\s*try\s*{/)) {
inTry = true;
tryStartLine = i + 1;
tryLines = [line];
braceDepth = 1;
continue;
}
// Track try block content
if (inTry && !inCatch) {
tryLines.push(line);
// Count braces to find try block end
const openBraces = (line.match(/{/g) || []).length;
const closeBraces = (line.match(/}/g) || []).length;
braceDepth += openBraces - closeBraces;
// Found catch
if (trimmed.match(/}\s*catch\s*(\(|{)/)) {
inCatch = true;
catchStartLine = i + 1;
catchLines = [line];
braceDepth = 1;
continue;
}
}
// Track catch block
if (inCatch) {
catchLines.push(line);
const openBraces = (line.match(/{/g) || []).length;
const closeBraces = (line.match(/}/g) || []).length;
braceDepth += openBraces - closeBraces;
// Catch block ended
if (braceDepth === 0) {
// Analyze the try-catch block
analyzeTryCatchBlock(
filePath,
relPath,
tryStartLine,
tryLines,
catchStartLine,
catchLines,
isCriticalPath,
antiPatterns
);
// Reset
inTry = false;
inCatch = false;
tryLines = [];
catchLines = [];
}
}
}
return antiPatterns;
}
function analyzeTryCatchBlock(
filePath: string,
relPath: string,
tryStartLine: number,
tryLines: string[],
catchStartLine: number,
catchLines: string[],
isCriticalPath: boolean,
antiPatterns: AntiPattern[]
): void {
const tryBlock = tryLines.join('\n');
const catchBlock = catchLines.join('\n');
// CRITICAL: Empty catch block
const catchContent = catchBlock
.replace(/}\s*catch\s*\([^)]*\)\s*{/, '') // Remove catch signature
.replace(/}\s*catch\s*{/, '') // Remove catch without param
.replace(/}$/, '') // Remove closing brace
.trim();
// Check for comment-only catch blocks
const nonCommentContent = catchContent
.split('\n')
.filter(line => {
const t = line.trim();
return t && !t.startsWith('//') && !t.startsWith('/*') && !t.startsWith('*');
})
.join('\n')
.trim();
if (!nonCommentContent || nonCommentContent === '') {
antiPatterns.push({
file: relPath,
line: catchStartLine,
pattern: 'EMPTY_CATCH',
severity: 'CRITICAL',
description: 'Empty catch block - errors are silently swallowed. User will waste hours debugging.',
code: catchBlock.trim()
});
}
// Check for [ANTI-PATTERN IGNORED] marker
const overrideMatch = catchContent.match(/\/\/\s*\[ANTI-PATTERN IGNORED\]:\s*(.+)/i);
const overrideReason = overrideMatch?.[1]?.trim();
// CRITICAL: No logging in catch block (unless explicitly approved)
const hasLogging = catchContent.match(/logger\.(error|warn|debug|info|failure)/);
const hasConsoleError = catchContent.match(/console\.(error|warn)/);
const hasStderr = catchContent.match(/process\.stderr\.write/);
const hasThrow = catchContent.match(/throw/);
if (!hasLogging && !hasConsoleError && !hasStderr && !hasThrow && nonCommentContent) {
if (overrideReason) {
antiPatterns.push({
file: relPath,
line: catchStartLine,
pattern: 'NO_LOGGING_IN_CATCH',
severity: 'APPROVED_OVERRIDE',
description: 'Catch block has no logging - approved override.',
code: catchBlock.trim(),
overrideReason
});
} else {
antiPatterns.push({
file: relPath,
line: catchStartLine,
pattern: 'NO_LOGGING_IN_CATCH',
severity: 'ISSUE',
description: 'Catch block has no logging - errors occur invisibly.',
code: catchBlock.trim()
});
}
}
// HIGH: Large try block (>10 lines)
const significantTryLines = tryLines.filter(line => {
const t = line.trim();
return t && !t.startsWith('//') && t !== '{' && t !== '}';
}).length;
if (significantTryLines > 10) {
antiPatterns.push({
file: relPath,
line: tryStartLine,
pattern: 'LARGE_TRY_BLOCK',
severity: 'ISSUE',
description: `Try block has ${significantTryLines} lines - too broad. Multiple errors lumped together.`,
code: `${tryLines.slice(0, 3).join('\n')}\n... (${significantTryLines} lines) ...`
});
}
// HIGH: Generic catch without type checking
const catchParam = catchBlock.match(/catch\s*\(([^)]+)\)/)?.[1]?.trim();
const hasTypeCheck = catchContent.match(/instanceof\s+Error/) ||
catchContent.match(/\.name\s*===/) ||
catchContent.match(/typeof.*===\s*['"]object['"]/);
if (catchParam && !hasTypeCheck && nonCommentContent) {
antiPatterns.push({
file: relPath,
line: catchStartLine,
pattern: 'GENERIC_CATCH',
severity: 'ISSUE',
description: 'Catch block handles all errors identically - no error type discrimination.',
code: catchBlock.trim()
});
}
// CRITICAL on critical paths: Catch-and-continue
if (isCriticalPath && nonCommentContent && !hasThrow) {
const hasReturn = catchContent.match(/return/);
const hasProcessExit = catchContent.match(/process\.exit/);
const terminatesExecution = hasReturn || hasProcessExit;
if (!terminatesExecution && hasLogging) {
if (overrideReason) {
antiPatterns.push({
file: relPath,
line: catchStartLine,
pattern: 'CATCH_AND_CONTINUE_CRITICAL_PATH',
severity: 'APPROVED_OVERRIDE',
description: 'Critical path continues after error - anti-pattern ignored.',
code: catchBlock.trim(),
overrideReason
});
} else {
antiPatterns.push({
file: relPath,
line: catchStartLine,
pattern: 'CATCH_AND_CONTINUE_CRITICAL_PATH',
severity: 'ISSUE',
description: 'Critical path continues after error - may cause silent data corruption.',
code: catchBlock.trim()
});
}
}
}
}
function formatReport(antiPatterns: AntiPattern[]): string {
const issues = antiPatterns.filter(a => a.severity === 'ISSUE');
const approved = antiPatterns.filter(a => a.severity === 'APPROVED_OVERRIDE');
if (antiPatterns.length === 0) {
return '✅ No error handling anti-patterns detected!\n';
}
let report = '\n';
report += '═══════════════════════════════════════════════════════════════\n';
report += ' ERROR HANDLING ANTI-PATTERNS DETECTED\n';
report += '═══════════════════════════════════════════════════════════════\n\n';
report += `Found ${issues.length} anti-patterns that must be fixed:\n`;
if (approved.length > 0) {
report += ` ⚪ APPROVED OVERRIDES: ${approved.length}\n`;
}
report += '\n';
if (issues.length > 0) {
report += '❌ ISSUES TO FIX:\n';
report += '─────────────────────────────────────────────────────────────\n\n';
for (const ap of issues) {
report += `📁 ${ap.file}:${ap.line} - ${ap.pattern}\n`;
report += ` ${ap.description}\n\n`;
}
}
if (approved.length > 0) {
report += '⚪ APPROVED OVERRIDES (Review reasons for accuracy):\n';
report += '─────────────────────────────────────────────────────────────\n\n';
for (const ap of approved) {
report += `📁 ${ap.file}:${ap.line} - ${ap.pattern}\n`;
report += ` Reason: ${ap.overrideReason}\n`;
report += ` Code:\n`;
const codeLines = ap.code.split('\n');
for (const line of codeLines.slice(0, 3)) {
report += ` ${line}\n`;
}
if (codeLines.length > 3) {
report += ` ... (${codeLines.length - 3} more lines)\n`;
}
report += '\n';
}
}
report += '═══════════════════════════════════════════════════════════════\n';
report += 'REMINDER: Every try-catch must answer these questions:\n';
report += '1. What SPECIFIC error am I catching? (Name it)\n';
report += '2. Show me documentation proving this error can occur\n';
report += '3. Why can\'t this error be prevented?\n';
report += '4. What will the catch block DO? (Log + rethrow? Fallback?)\n';
report += '5. Why shouldn\'t this error propagate to the caller?\n';
report += '\n';
report += 'To ignore an anti-pattern, add: // [ANTI-PATTERN IGNORED]: reason\n';
report += '═══════════════════════════════════════════════════════════════\n\n';
return report;
}
// Main execution
const projectRoot = process.cwd();
const srcDir = join(projectRoot, 'src');
console.log('🔍 Scanning for error handling anti-patterns...\n');
const tsFiles = findFilesRecursive(srcDir, /\.ts$/);
console.log(`Found ${tsFiles.length} TypeScript files\n`);
let allAntiPatterns: AntiPattern[] = [];
for (const file of tsFiles) {
const patterns = detectAntiPatterns(file, projectRoot);
allAntiPatterns = allAntiPatterns.concat(patterns);
}
const report = formatReport(allAntiPatterns);
console.log(report);
// Exit with error code if any issues found
const issues = allAntiPatterns.filter(a => a.severity === 'ISSUE');
if (issues.length > 0) {
console.error(`❌ FAILED: ${issues.length} error handling anti-patterns must be fixed.\n`);
process.exit(1);
}
process.exit(0);