UNPKG

code-auditor-mcp

Version:

Multi-language code quality auditor with MCP server - Analyze TypeScript, JavaScript, and Go code for SOLID principles, DRY violations, security patterns, and more

602 lines 21.1 kB
/** * DRY (Don't Repeat Yourself) Analyzer (Functional) * Detects code duplication across the entire codebase * * Uses a code index to efficiently find: * - Exact code duplicates * - Similar code patterns * - Repeated string literals * - Duplicate imports */ import * as ts from 'typescript'; import * as crypto from 'crypto'; import * as fs from 'fs/promises'; import { parseTypeScriptFile, getNodeText, getLineAndColumn, findNodesByKind, getImports } from './analyzerUtils.js'; import { getImportsDetailed, extractIdentifierUsage } from '../utils/astUtils.js'; /** * Debug logger */ class DebugLogger { logs = []; enabled; constructor(enabled = false) { this.enabled = enabled; } log(message, data) { if (!this.enabled) return; const timestamp = new Date().toISOString(); const logEntry = `[${timestamp}] ${message}`; if (data !== undefined) { this.logs.push(`${logEntry}\n${JSON.stringify(data, null, 2)}`); } else { this.logs.push(logEntry); } // Don't log to console to avoid interfering with MCP protocol // console.log(`[DRY] ${message}`, data || ''); } async writeToFile(filePath) { if (!this.enabled || this.logs.length === 0) return; const content = this.logs.join('\n\n'); await fs.writeFile(filePath, content, 'utf-8'); // Don't log to console - the file write is sufficient } getLogs() { return this.logs; } } /** * Default configuration */ const DEFAULT_CONFIG = { minLineThreshold: 5, similarityThreshold: 0.85, excludePatterns: ['**/*.test.ts', '**/*.spec.ts'], checkImports: true, checkStrings: true, checkUnusedImports: true, ignoreComments: true, ignoreWhitespace: true, debug: false, debugLogPath: './dry-analyzer-debug.log' }; /** * Build a code index from all files */ async function buildCodeIndex(files, config, logger) { const index = { blocks: [], hashMap: new Map(), stringLiterals: new Map(), imports: new Map(), unusedImports: new Map() }; logger.log(`Building code index for ${files.length} files`); for (const file of files) { // Skip excluded patterns if (config.excludePatterns?.some(pattern => file.includes(pattern.replace('**/', '').replace('*', '')))) { logger.log(`Skipping excluded file: ${file}`); continue; } try { const { sourceFile } = await parseTypeScriptFile(file); logger.log(`Processing file: ${file}`); // Extract code blocks const blocks = extractCodeBlocks(sourceFile, file, config, logger); logger.log(`Extracted ${blocks.length} code blocks from ${file}`); for (const block of blocks) { index.blocks.push(block); // Add to hash map for O(1) duplicate lookup if (!index.hashMap.has(block.hash)) { index.hashMap.set(block.hash, []); } index.hashMap.get(block.hash).push(block); } // Extract string literals if enabled if (config.checkStrings) { const stringCount = index.stringLiterals.size; extractStringLiterals(sourceFile, file, index.stringLiterals, logger); logger.log(`Found ${index.stringLiterals.size - stringCount} new string literals`); } // Extract imports if enabled if (config.checkImports) { const importCount = index.imports.size; extractImports(sourceFile, file, index.imports, logger); logger.log(`Found ${index.imports.size - importCount} new import patterns`); } // Extract unused imports if enabled if (config.checkUnusedImports) { const unusedImportCount = index.unusedImports.size; extractUnusedImports(sourceFile, file, index.unusedImports, logger); logger.log(`Found ${index.unusedImports.size - unusedImportCount} new unused imports`); } } catch (error) { logger.log(`Error processing ${file}: ${error}`); // Don't use console.error to avoid MCP interference } } logger.log('Code index built', { totalBlocks: index.blocks.length, uniqueHashes: index.hashMap.size, stringLiterals: index.stringLiterals.size, importPatterns: index.imports.size, unusedImports: index.unusedImports.size }); return index; } /** * Extract code blocks from a source file */ function extractCodeBlocks(sourceFile, filePath, config, logger) { const blocks = []; // Extract functions const functions = findNodesByKind(sourceFile, ts.SyntaxKind.FunctionDeclaration); for (const func of functions) { const block = createCodeBlock(sourceFile, func, filePath, 'function', config); if (block && isBlockLargeEnough(block, config)) { blocks.push(block); } } // Extract arrow functions const arrowFunctions = findNodesByKind(sourceFile, ts.SyntaxKind.ArrowFunction); for (const arrow of arrowFunctions) { const block = createCodeBlock(sourceFile, arrow, filePath, 'function', config); if (block && isBlockLargeEnough(block, config)) { blocks.push(block); } } // Extract methods const methods = findNodesByKind(sourceFile, ts.SyntaxKind.MethodDeclaration); for (const method of methods) { const block = createCodeBlock(sourceFile, method, filePath, 'method', config); if (block && isBlockLargeEnough(block, config)) { blocks.push(block); } } // Extract class declarations const classes = findNodesByKind(sourceFile, ts.SyntaxKind.ClassDeclaration); for (const cls of classes) { const block = createCodeBlock(sourceFile, cls, filePath, 'class', config); if (block && isBlockLargeEnough(block, config)) { blocks.push(block); } } // Extract block statements (if/else, loops, etc.) const blockStatements = findNodesByKind(sourceFile, ts.SyntaxKind.Block); for (const blockStmt of blockStatements) { // Only consider substantial blocks const block = createCodeBlock(sourceFile, blockStmt, filePath, 'block', config); if (block && isBlockLargeEnough(block, config)) { blocks.push(block); } } return blocks; } /** * Create a code block from a node */ function createCodeBlock(sourceFile, node, filePath, type, config) { const text = getNodeText(node, sourceFile); const normalizedText = normalizeCode(text, config); if (!normalizedText.trim()) { return null; } const { line: startLine } = getLineAndColumn(sourceFile, node.getStart()); const { line: endLine } = getLineAndColumn(sourceFile, node.getEnd()); let name; if ('name' in node && node.name) { const nodeName = node.name; if (ts.isIdentifier(nodeName)) { name = nodeName.text; } } return { file: filePath, startLine, endLine, text, normalizedText, hash: hashCode(normalizedText), type, name }; } /** * Normalize code for comparison */ function normalizeCode(code, config) { let normalized = code; if (config.ignoreComments) { // Remove single-line comments normalized = normalized.replace(/\/\/.*$/gm, ''); // Remove multi-line comments normalized = normalized.replace(/\/\*[\s\S]*?\*\//g, ''); } if (config.ignoreWhitespace) { // Normalize whitespace normalized = normalized.replace(/\s+/g, ' ').trim(); } // Remove variable names to detect similar patterns // This is a simple approach - could be improved with proper AST analysis normalized = normalized.replace(/\b(?:const|let|var)\s+(\w+)/g, 'VAR $1'); return normalized; } /** * Hash code for fast comparison */ function hashCode(text) { return crypto.createHash('md5').update(text).digest('hex'); } /** * Check if a block is large enough to be considered */ function isBlockLargeEnough(block, config) { const lineCount = block.endLine - block.startLine + 1; return lineCount >= (config.minLineThreshold || 5); } /** * Extract string literals */ function extractStringLiterals(sourceFile, filePath, stringMap, logger) { const stringLiterals = findNodesByKind(sourceFile, ts.SyntaxKind.StringLiteral); for (const literal of stringLiterals) { const text = literal.text; // Skip short strings and common ones if (text.length < 10 || isCommonString(text)) { continue; } const { line } = getLineAndColumn(sourceFile, literal.getStart()); if (!stringMap.has(text)) { stringMap.set(text, []); } stringMap.get(text).push({ file: filePath, line }); } } /** * Check if a string is too common to track */ function isCommonString(str) { const common = [ 'use strict', 'default', 'exports', 'undefined', 'null', 'true', 'false', '', ' ', '\n', '\t' ]; return common.includes(str) || /^[\s\d]+$/.test(str); } /** * Extract imports */ function extractImports(sourceFile, filePath, importMap, logger) { const imports = getImports(sourceFile); for (const imp of imports) { const key = `${imp.moduleSpecifier}:${imp.importedNames.sort().join(',')}`; if (!importMap.has(key)) { importMap.set(key, []); } importMap.get(key).push({ file: filePath, line: imp.line, modules: imp.importedNames }); } } /** * Extract unused imports from a source file */ function extractUnusedImports(sourceFile, filePath, unusedImportsMap, logger) { // Get detailed imports const detailedImports = getImportsDetailed(sourceFile); const importNames = new Set(detailedImports.map(imp => imp.localName)); // Extract identifier usage across the entire file const usageMap = extractIdentifierUsage(sourceFile, sourceFile, importNames); // Find unused imports for (const imp of detailedImports) { // Skip side-effect imports - they're never "unused" if (imp.importType === 'side-effect') continue; if (!usageMap.has(imp.localName)) { const key = `${filePath}:${imp.localName}`; if (!unusedImportsMap.has(key)) { unusedImportsMap.set(key, []); } // Get line number for this import const importInfo = getImports(sourceFile).find(i => i.moduleSpecifier === imp.modulePath && (i.importedNames.includes(imp.localName) || i.importedNames.some(name => name === `* as ${imp.localName}`))); unusedImportsMap.get(key).push({ file: filePath, line: importInfo?.line || 0, importName: imp.localName, moduleSpecifier: imp.modulePath }); } } } /** * Find duplicates in the code index */ function findDuplicates(index, config, logger) { const violations = []; // Find exact duplicates logger.log(`Checking ${index.hashMap.size} unique hashes for duplicates`); for (const [hash, blocks] of index.hashMap) { if (blocks.length > 1) { logger.log(`Hash ${hash} has ${blocks.length} blocks`); // Group by actual code (not just hash) to avoid false positives const groups = groupByExactCode(blocks); for (const group of groups) { if (group.length > 1) { logger.log(`Found exact duplicate with ${group.length} instances`, { files: group.map(b => ({ file: b.file, line: b.startLine, type: b.type })), preview: group[0].text.substring(0, 100) + '...' }); violations.push(createDuplicateViolation(group, 'exact-duplicate')); } } } } // Find similar code blocks const similarViolations = findSimilarBlocks(index.blocks, config, logger); violations.push(...similarViolations); // Find duplicate strings if (config.checkStrings) { for (const [str, locations] of index.stringLiterals) { if (locations.length > 2) { // More than 2 occurrences violations.push(createStringDuplicateViolation(str, locations)); } } } // Find duplicate imports if (config.checkImports) { for (const [importKey, locations] of index.imports) { if (locations.length > 3) { // More than 3 files with same imports violations.push(createImportDuplicateViolation(importKey, locations)); } } } // Find unused imports if (config.checkUnusedImports) { for (const [importKey, locations] of index.unusedImports) { if (locations.length > 0) { violations.push(createUnusedImportViolation(locations[0])); } } } return violations; } /** * Group blocks by exact code content */ function groupByExactCode(blocks) { const groups = new Map(); for (const block of blocks) { const key = block.normalizedText; if (!groups.has(key)) { groups.set(key, []); } groups.get(key).push(block); } return Array.from(groups.values()); } /** * Find similar (but not exact) code blocks */ function findSimilarBlocks(blocks, config, logger) { const violations = []; const threshold = config.similarityThreshold || 0.85; const processed = new Set(); for (let i = 0; i < blocks.length; i++) { const block1 = blocks[i]; const key1 = `${block1.file}:${block1.startLine}`; if (processed.has(key1)) continue; const similar = [block1]; for (let j = i + 1; j < blocks.length; j++) { const block2 = blocks[j]; const key2 = `${block2.file}:${block2.startLine}`; if (processed.has(key2)) continue; // Skip if same hash (already handled in exact duplicates) if (block1.hash === block2.hash) continue; const similarity = calculateSimilarity(block1.normalizedText, block2.normalizedText); if (similarity >= threshold) { similar.push(block2); processed.add(key2); } } if (similar.length > 1) { violations.push(createDuplicateViolation(similar, 'similar-logic', calculateAverageSimilarity(similar))); similar.forEach(b => processed.add(`${b.file}:${b.startLine}`)); } } return violations; } /** * Calculate similarity between two strings (0-1) */ function calculateSimilarity(str1, str2) { // Simple token-based similarity const tokens1 = tokenize(str1); const tokens2 = tokenize(str2); const set1 = new Set(tokens1); const set2 = new Set(tokens2); const intersection = new Set([...set1].filter(x => set2.has(x))); const union = new Set([...set1, ...set2]); // Jaccard similarity const jaccard = intersection.size / union.size; // Length similarity const lengthSim = Math.min(str1.length, str2.length) / Math.max(str1.length, str2.length); // Combined similarity return (jaccard * 0.7 + lengthSim * 0.3); } /** * Tokenize code for similarity comparison */ function tokenize(code) { return code .split(/\s+/) .filter(token => token.length > 0) .map(token => token.toLowerCase()); } /** * Calculate average similarity for a group */ function calculateAverageSimilarity(blocks) { if (blocks.length < 2) return 1; let totalSim = 0; let count = 0; for (let i = 0; i < blocks.length - 1; i++) { for (let j = i + 1; j < blocks.length; j++) { totalSim += calculateSimilarity(blocks[i].normalizedText, blocks[j].normalizedText); count++; } } return totalSim / count; } /** * Create a duplicate violation */ function createDuplicateViolation(blocks, type, similarity) { const primary = blocks[0]; const locations = blocks.map(b => ({ file: b.file, line: b.startLine })); const totalLines = blocks.reduce((sum, b) => sum + (b.endLine - b.startLine + 1), 0); return { analyzer: 'dry', file: primary.file, line: primary.startLine, severity: type === 'exact-duplicate' ? 'warning' : 'suggestion', type, message: `${type === 'exact-duplicate' ? 'Exact duplicate' : 'Similar'} code found in ${blocks.length} locations`, recommendation: `Consider extracting this ${primary.type} into a shared utility function`, locations, similarity, metrics: { duplicateLines: totalLines - (primary.endLine - primary.startLine + 1), totalLines }, estimatedEffort: 'medium' }; } /** * Create a string duplicate violation */ function createStringDuplicateViolation(str, locations) { return { analyzer: 'dry', file: locations[0].file, line: locations[0].line, severity: 'suggestion', type: 'exact-duplicate', message: `String literal "${str.substring(0, 50)}${str.length > 50 ? '...' : ''}" appears ${locations.length} times`, recommendation: 'Consider extracting this string into a named constant', locations, estimatedEffort: 'small' }; } /** * Create an import duplicate violation */ function createImportDuplicateViolation(importKey, locations) { const [moduleSpec] = importKey.split(':'); return { analyzer: 'dry', file: locations[0].file, line: locations[0].line, severity: 'suggestion', type: 'pattern-duplication', message: `Same import pattern from "${moduleSpec}" used in ${locations.length} files`, recommendation: 'Consider creating a barrel export or shared import module', locations: locations.map(l => ({ file: l.file, line: l.line })), estimatedEffort: 'small' }; } /** * Create an unused import violation */ function createUnusedImportViolation(location) { return { analyzer: 'dry', file: location.file, line: location.line, severity: 'suggestion', type: 'pattern-duplication', message: `Unused import '${location.importName}' from '${location.moduleSpecifier}'`, recommendation: `Remove this unused import to keep the codebase clean and reduce bundle size`, estimatedEffort: 'small' }; } /** * DRY Analyzer definition */ export const dryAnalyzer = { name: 'dry', defaultConfig: DEFAULT_CONFIG, analyze: async (files, config, options, progressCallback) => { const mergedConfig = { ...DEFAULT_CONFIG, ...config }; const logger = new DebugLogger(mergedConfig.debug || false); logger.log('DRY Analyzer started', { filesCount: files.length, config: mergedConfig }); // Report initial progress if (progressCallback) { progressCallback({ current: 0, total: files.length, analyzer: 'dry', phase: 'indexing' }); } // Build code index const startTime = Date.now(); const index = await buildCodeIndex(files, mergedConfig, logger); // Report analysis progress if (progressCallback) { progressCallback({ current: files.length / 2, total: files.length, analyzer: 'dry', phase: 'analyzing' }); } // Find duplicates const violations = findDuplicates(index, mergedConfig, logger); logger.log('DRY Analysis complete', { violationsFound: violations.length, executionTime: Date.now() - startTime }); // Write debug log to file if (mergedConfig.debug && mergedConfig.debugLogPath) { await logger.writeToFile(mergedConfig.debugLogPath); } // Complete if (progressCallback) { progressCallback({ current: files.length, total: files.length, analyzer: 'dry', phase: 'complete' }); } return { violations, filesProcessed: files.length, executionTime: Date.now() - startTime, analyzerName: 'dry' }; } }; //# sourceMappingURL=dryAnalyzer.js.map