UNPKG

@boundless-oss/atlas

Version:

Atlas - MCP Server for comprehensive startup project management

959 lines (827 loc) 29.4 kB
import { promises as fs } from 'fs'; import path from 'path'; import crypto from 'crypto'; import { CodeMetrics, ComplexityMetrics, MaintainabilityMetrics, DuplicationMetrics, QualityMetrics, CodeIssue, FunctionComplexity, DuplicateBlock, CodeLocation, FileAnalysis, AnalysisResult, AnalysisSummary, IssueCategory, Suggestion, } from './types.js'; export class CodeAnalyzer { private knownPatterns: Map<string, RegExp>; constructor() { this.knownPatterns = this.initializePatterns(); } async findCodeFiles(directory: string, excludePatterns: string[] = []): Promise<string[]> { const files: string[] = []; async function walk(dir: string) { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); const relativePath = path.relative(directory, fullPath); // Check if should exclude if (excludePatterns.some(pattern => { // Simple glob matching const regex = pattern .replace(/\*\*/g, '.*') .replace(/\*/g, '[^/]*') .replace(/\?/g, '.'); return new RegExp(`^${regex}$`).test(relativePath); })) { continue; } if (entry.isDirectory()) { await walk(fullPath); } else if (entry.isFile() && isCodeFile(entry.name)) { files.push(fullPath); } } } function isCodeFile(name: string): boolean { const codeExtensions = ['.js', '.ts', '.jsx', '.tsx', '.py', '.java', '.cpp', '.c', '.h', '.cs', '.rb', '.go', '.rust', '.swift', '.php']; return codeExtensions.some(ext => name.endsWith(ext)); } await walk(directory); return files; } generateSuggestions(analysis: FileAnalysis): Suggestion[] { const suggestions: Suggestion[] = []; // Complexity suggestions if (analysis.metrics.complexity.average > 10) { suggestions.push({ title: 'Reduce complexity', description: 'Consider breaking down complex functions into smaller, more manageable pieces', priority: 'high', }); } // Maintainability suggestions if (analysis.metrics.maintainability.average < 70) { suggestions.push({ title: 'Improve maintainability', description: 'Add documentation, reduce complexity, and improve code organization', priority: 'medium', }); } // Duplication suggestions if (analysis.metrics.duplication.percentage > 5) { suggestions.push({ title: 'Reduce code duplication', description: 'Extract common code into reusable functions or modules', priority: 'medium', }); } return suggestions; } generateRefactoringIdeas(analysis: FileAnalysis): Suggestion[] { const ideas: Suggestion[] = []; // Check for long functions const longFunctions = analysis.metrics.complexity.functions.filter(f => f.lines > 50); if (longFunctions.length > 0) { ideas.push({ title: 'Split long functions', description: `${longFunctions.length} functions exceed 50 lines. Consider splitting them`, priority: 'medium', }); } // Check for high parameter count const highParamFunctions = analysis.metrics.complexity.functions.filter(f => f.parameters > 5); if (highParamFunctions.length > 0) { ideas.push({ title: 'Reduce parameter count', description: `${highParamFunctions.length} functions have more than 5 parameters. Consider using objects or builder patterns`, priority: 'low', }); } return ideas; } async analyzeFile(filePath: string): Promise<FileAnalysis> { const content = await fs.readFile(filePath, 'utf-8'); const stats = await fs.stat(filePath); const language = this.detectLanguage(filePath); const metrics = await this.calculateMetrics(content, language); const issues = await this.detectIssues(content, filePath, language); return { path: filePath, metrics, issues, size: stats.size, language, lastModified: stats.mtime.toISOString(), }; } async calculateMetrics(content: string, language: string): Promise<CodeMetrics> { const complexity = this.calculateComplexity(content, language); const maintainability = this.calculateMaintainability(content, complexity); const duplication = this.detectDuplication(content); const quality = await this.assessQuality(content, complexity, maintainability, duplication); return { complexity, maintainability, duplication, quality, }; } private calculateComplexity(content: string, language: string): ComplexityMetrics { const functions = this.extractFunctions(content, language); const cyclomaticComplexity = this.calculateCyclomaticComplexity(content); const cognitiveComplexity = this.calculateCognitiveComplexity(content); const halstead = this.calculateHalsteadMetrics(content); const average = functions.length > 0 ? functions.reduce((sum, f) => sum + f.complexity, 0) / functions.length : cyclomaticComplexity; const max = functions.length > 0 ? Math.max(...functions.map(f => f.complexity)) : cyclomaticComplexity; return { cyclomatic: cyclomaticComplexity, cognitive: cognitiveComplexity, halstead, functions, average, max, total: cyclomaticComplexity, }; } private calculateCyclomaticComplexity(content: string): number { // Count decision points const patterns = [ /\bif\b/g, /\belse\s+if\b/g, /\belse\b/g, /\bfor\b/g, /\bwhile\b/g, /\bdo\b/g, /\bswitch\b/g, /\bcase\b/g, /\bcatch\b/g, /\?\s*[^:]+\s*:/g, // ternary /&&/g, /\|\|/g, /\?\?/g, // nullish coalescing ]; let complexity = 1; // Base complexity patterns.forEach(pattern => { const matches = content.match(pattern); if (matches) { complexity += matches.length; } }); // Adjust for certain patterns const elseIfMatches = content.match(/\belse\s+if\b/g); if (elseIfMatches) { // Don't double count else if complexity -= elseIfMatches.length; } return complexity; } private calculateCognitiveComplexity(content: string): number { let complexity = 0; let nestingLevel = 0; const lines = content.split('\n'); const incrementPatterns = [ /\bif\b/, /\belse\s+if\b/, /\belse\b/, /\bfor\b/, /\bwhile\b/, /\bdo\b/, /\bcatch\b/, ]; const nestingPatterns = [ /\bif\b/, /\bfor\b/, /\bwhile\b/, /\bdo\b/, ]; lines.forEach(line => { const trimmed = line.trim(); // Check for nesting increase nestingPatterns.forEach(pattern => { if (pattern.test(trimmed) && trimmed.includes('{')) { nestingLevel++; } }); // Check for complexity increment incrementPatterns.forEach(pattern => { if (pattern.test(trimmed)) { complexity += 1 + nestingLevel; } }); // Check for nesting decrease if (trimmed.includes('}')) { nestingLevel = Math.max(0, nestingLevel - 1); } }); return complexity; } private calculateHalsteadMetrics(content: string) { // Simplified Halstead metrics calculation const operators = new Set<string>(); const operands = new Set<string>(); let totalOperators = 0; let totalOperands = 0; // Extract operators const operatorPatterns = [ /[+\-*/%=<>!&|^~]/g, /\b(if|else|for|while|do|switch|case|break|continue|return|throw|try|catch|finally)\b/g, ]; operatorPatterns.forEach(pattern => { const matches = content.match(pattern); if (matches) { matches.forEach(match => { operators.add(match); totalOperators++; }); } }); // Extract operands (simplified - identifiers and literals) const operandPattern = /\b[a-zA-Z_]\w*\b|\b\d+\b|"[^"]*"|'[^']*'/g; const operandMatches = content.match(operandPattern); if (operandMatches) { operandMatches.forEach(match => { if (!operators.has(match)) { operands.add(match); totalOperands++; } }); } const n1 = operators.size; // unique operators const n2 = operands.size; // unique operands const N1 = totalOperators; // total operators const N2 = totalOperands; // total operands const vocabulary = n1 + n2; const length = N1 + N2; const volume = length * Math.log2(vocabulary || 1); const difficulty = (n1 / 2) * (N2 / (n2 || 1)); const effort = difficulty * volume; return { difficulty: Math.round(difficulty * 100) / 100, volume: Math.round(volume * 100) / 100, effort: Math.round(effort * 100) / 100, }; } private extractFunctions(content: string, language: string): FunctionComplexity[] { const functions: FunctionComplexity[] = []; // Patterns for different languages const patterns: Record<string, RegExp> = { javascript: /(?:function\s+(\w+)|const\s+(\w+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>|(\w+)\s*:\s*(?:async\s*)?\([^)]*\)\s*=>)/g, typescript: /(?:function\s+(\w+)|const\s+(\w+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>|(\w+)\s*:\s*(?:async\s*)?\([^)]*\)\s*=>|(?:public|private|protected)\s+(?:async\s+)?(\w+)\s*\([^)]*\))/g, python: /def\s+(\w+)\s*\([^)]*\):/g, java: /(?:public|private|protected)?\s*(?:static)?\s*(?:\w+)\s+(\w+)\s*\([^)]*\)\s*{/g, }; const pattern = patterns[language] || patterns.javascript; const lines = content.split('\n'); let match; while ((match = pattern.exec(content)) !== null) { const functionName = match[1] || match[2] || match[3] || match[4] || 'anonymous'; const startIndex = match.index; const lineNumber = content.substring(0, startIndex).split('\n').length; // Extract function body (simplified) const functionStart = startIndex; const functionBody = this.extractFunctionBody(content, functionStart, language); const functionLines = functionBody.split('\n').length; // Calculate function-specific complexity const functionComplexity = this.calculateCyclomaticComplexity(functionBody); // Count parameters (simplified) const paramsMatch = match[0].match(/\(([^)]*)\)/); const parameters = paramsMatch && paramsMatch[1].trim() ? paramsMatch[1].split(',').length : 0; functions.push({ name: functionName, complexity: functionComplexity, lines: functionLines, parameters, location: { file: '', line: lineNumber, column: 1, }, }); } return functions; } private extractFunctionBody(content: string, startIndex: number, language: string): string { // Simplified function body extraction if (language === 'python') { // Python uses indentation const lines = content.substring(startIndex).split('\n'); const functionLines = [lines[0]]; const baseIndent = lines[0].match(/^\s*/)?.[0].length || 0; for (let i = 1; i < lines.length; i++) { const currentIndent = lines[i].match(/^\s*/)?.[0].length || 0; if (currentIndent > baseIndent || lines[i].trim() === '') { functionLines.push(lines[i]); } else { break; } } return functionLines.join('\n'); } else { // Brace-based languages let braceCount = 0; let inFunction = false; let functionEnd = startIndex; for (let i = startIndex; i < content.length; i++) { if (content[i] === '{') { braceCount++; inFunction = true; } else if (content[i] === '}') { braceCount--; if (inFunction && braceCount === 0) { functionEnd = i + 1; break; } } } return content.substring(startIndex, functionEnd); } } private calculateMaintainability(content: string, complexity: ComplexityMetrics): MaintainabilityMetrics { const lines = content.split('\n'); const totalLines = lines.length; const codeLines = lines.filter(line => line.trim() && !line.trim().startsWith('//')).length; const commentLines = lines.filter(line => line.trim().startsWith('//') || line.trim().startsWith('/*')).length; const commentRatio = commentLines / (codeLines || 1); // Simplified maintainability index calculation // MI = 171 - 5.2 * ln(Halstead Volume) - 0.23 * (Cyclomatic Complexity) - 16.2 * ln(Lines of Code) const volume = complexity.halstead.volume || 1; const cyclomatic = complexity.cyclomatic || 1; let maintainabilityIndex = 171 - 5.2 * Math.log(volume) - 0.23 * cyclomatic - 16.2 * Math.log(codeLines || 1); // Normalize to 0-100 maintainabilityIndex = Math.max(0, Math.min(100, maintainabilityIndex)); // Determine rating let rating: 'A' | 'B' | 'C' | 'D' | 'F'; if (maintainabilityIndex >= 80) rating = 'A'; else if (maintainabilityIndex >= 60) rating = 'B'; else if (maintainabilityIndex >= 40) rating = 'C'; else if (maintainabilityIndex >= 20) rating = 'D'; else rating = 'F'; return { index: Math.round(maintainabilityIndex * 100) / 100, rating, factors: { complexity: complexity.cyclomatic, lineCount: codeLines, commentRatio: Math.round(commentRatio * 100) / 100, }, average: Math.round(maintainabilityIndex * 100) / 100, min: Math.round(maintainabilityIndex * 100) / 100, distribution: {}, }; } private detectDuplication(content: string): DuplicationMetrics { const lines = content.split('\n'); const totalLines = lines.length; const blockSize = 6; // Minimum duplicate block size const hashes = new Map<string, number[]>(); const duplicateBlocks: DuplicateBlock[] = []; // Create hashes for each block of lines for (let i = 0; i <= lines.length - blockSize; i++) { const block = lines.slice(i, i + blockSize).join('\n'); const hash = this.hashBlock(block); if (!hashes.has(hash)) { hashes.set(hash, []); } hashes.get(hash)!.push(i); } // Find duplicate blocks let duplicatedLines = 0; const processedLines = new Set<number>(); hashes.forEach((locations, hash) => { if (locations.length > 1) { const block: DuplicateBlock = { locations: locations.map(line => ({ file: '', line: line + 1, column: 1, endLine: line + blockSize, })), lines: blockSize, tokens: blockSize * 10, // Approximation hash, }; duplicateBlocks.push(block); // Count duplicated lines (avoid double counting) locations.forEach(loc => { for (let i = loc; i < loc + blockSize; i++) { if (!processedLines.has(i)) { processedLines.add(i); duplicatedLines++; } } }); } }); const percentage = (duplicatedLines / totalLines) * 100; return { percentage: Math.round(percentage * 100) / 100, duplicateBlocks, totalLines, duplicatedLines, blocks: duplicateBlocks.length, }; } private hashBlock(block: string): string { // Normalize whitespace and create hash const normalized = block .split('\n') .map(line => line.trim()) .filter(line => line && !line.startsWith('//')) .join('\n'); return crypto.createHash('md5').update(normalized).digest('hex'); } private async assessQuality( content: string, complexity: ComplexityMetrics, maintainability: MaintainabilityMetrics, duplication: DuplicationMetrics ): Promise<QualityMetrics> { const issues = await this.detectIssues(content, '', this.detectLanguage('')); // Calculate quality score let score = 100; // Deduct for complexity if (complexity.cyclomatic > 10) score -= 10; if (complexity.cyclomatic > 20) score -= 10; // Deduct for maintainability if (maintainability.rating === 'B') score -= 5; if (maintainability.rating === 'C') score -= 10; if (maintainability.rating === 'D') score -= 20; if (maintainability.rating === 'F') score -= 30; // Deduct for duplication if (duplication.percentage > 5) score -= 5; if (duplication.percentage > 10) score -= 10; if (duplication.percentage > 20) score -= 20; // Deduct for issues issues.forEach(issue => { if (issue.severity === 'critical') score -= 10; else if (issue.severity === 'high') score -= 5; else if (issue.severity === 'medium') score -= 2; else if (issue.severity === 'low') score -= 1; }); score = Math.max(0, score); // Determine grade let grade: 'A' | 'B' | 'C' | 'D' | 'F'; if (score >= 90) grade = 'A'; else if (score >= 80) grade = 'B'; else if (score >= 70) grade = 'C'; else if (score >= 60) grade = 'D'; else grade = 'F'; const issuesSummary = { total: issues.length, high: issues.filter(i => i.severity === 'high').length, medium: issues.filter(i => i.severity === 'medium').length, low: issues.filter(i => i.severity === 'low').length, }; return { issues: issuesSummary, codeSmells: issues.filter(i => i.category === 'maintainability').length, technicalDebt: Math.round(issues.length * 0.5), score, grade, }; } async detectIssues(content: string, filePath: string, language: string): Promise<CodeIssue[]> { const issues: CodeIssue[] = []; const lines = content.split('\n'); // Security issues this.detectSecurityIssues(lines, issues); // Performance issues this.detectPerformanceIssues(lines, issues); // Code quality issues this.detectQualityIssues(lines, issues); // Style issues this.detectStyleIssues(lines, issues); return issues; } private detectSecurityIssues(lines: string[], issues: CodeIssue[]) { const securityPatterns = [ { pattern: /eval\s*\(/, message: 'Avoid using eval() as it can execute arbitrary code', severity: 'critical' as const, rule: 'no-eval', }, { pattern: /innerHTML\s*=/, message: 'Direct innerHTML assignment can lead to XSS vulnerabilities', severity: 'high' as const, rule: 'no-inner-html', }, { pattern: /password.*=.*["'][^"']+["']/i, message: 'Hardcoded password detected', severity: 'critical' as const, rule: 'no-hardcoded-secrets', }, { pattern: /api[_-]?key.*=.*["'][^"']+["']/i, message: 'Hardcoded API key detected', severity: 'critical' as const, rule: 'no-hardcoded-secrets', }, ]; lines.forEach((line, index) => { securityPatterns.forEach(({ pattern, message, severity, rule }) => { if (pattern.test(line)) { issues.push({ type: 'error', severity, rule, message, location: { file: '', line: index + 1, column: line.search(pattern) + 1, }, fixable: false, category: 'security', }); } }); }); } private detectPerformanceIssues(lines: string[], issues: CodeIssue[]) { const performancePatterns = [ { pattern: /\.forEach\s*\([^)]*\)\s*{[^}]*\.push\s*\(/, message: 'Consider using map() instead of forEach() with push()', severity: 'medium' as const, rule: 'prefer-map', }, { pattern: /for\s*\([^)]*in\s+/, message: 'for...in loop can be slow for arrays, consider for...of or traditional for loop', severity: 'low' as const, rule: 'no-for-in', }, ]; lines.forEach((line, index) => { performancePatterns.forEach(({ pattern, message, severity, rule }) => { if (pattern.test(line)) { issues.push({ type: 'warning', severity, rule, message, location: { file: '', line: index + 1, column: 1, }, fixable: true, category: 'performance', }); } }); }); } private detectQualityIssues(lines: string[], issues: CodeIssue[]) { lines.forEach((line, index) => { // Long lines if (line.length > 120) { issues.push({ type: 'warning', severity: 'low', rule: 'max-line-length', message: `Line exceeds maximum length of 120 characters (${line.length})`, location: { file: '', line: index + 1, column: 121, }, fixable: true, category: 'style', }); } // TODO comments if (/\/\/\s*TODO|\/\*\s*TODO/.test(line)) { issues.push({ type: 'info', severity: 'low', rule: 'no-todo', message: 'TODO comment found', location: { file: '', line: index + 1, column: line.search(/TODO/) + 1, }, fixable: false, category: 'maintainability', }); } // Console.log statements if (/console\.(log|error|warn|info)/.test(line)) { issues.push({ type: 'warning', severity: 'medium', rule: 'no-console', message: 'Remove console statements before production', location: { file: '', line: index + 1, column: line.search(/console\./) + 1, }, fixable: true, category: 'reliability', }); } }); } private detectStyleIssues(lines: string[], issues: CodeIssue[]) { lines.forEach((line, index) => { // Trailing whitespace if (/\s+$/.test(line)) { issues.push({ type: 'style', severity: 'low', rule: 'no-trailing-spaces', message: 'Trailing whitespace', location: { file: '', line: index + 1, column: line.search(/\s+$/) + 1, }, fixable: true, category: 'style', }); } // Mixed tabs and spaces if (/^\t+ /.test(line) || /^ +\t/.test(line)) { issues.push({ type: 'style', severity: 'low', rule: 'no-mixed-spaces-and-tabs', message: 'Mixed spaces and tabs', location: { file: '', line: index + 1, column: 1, }, fixable: true, category: 'style', }); } }); } detectLanguage(filePath: string): string { const ext = path.extname(filePath).toLowerCase(); const languageMap: Record<string, string> = { '.js': 'javascript', '.jsx': 'javascript', '.ts': 'typescript', '.tsx': 'typescript', '.py': 'python', '.java': 'java', '.c': 'c', '.cpp': 'cpp', '.cs': 'csharp', '.go': 'go', '.rb': 'ruby', '.php': 'php', '.swift': 'swift', '.kt': 'kotlin', '.rs': 'rust', }; return languageMap[ext] || 'unknown'; } private initializePatterns(): Map<string, RegExp> { const patterns = new Map<string, RegExp>(); // Add common patterns for various languages patterns.set('function-declaration-js', /function\s+\w+\s*\([^)]*\)\s*{/g); patterns.set('arrow-function-js', /\w+\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/g); patterns.set('class-declaration-js', /class\s+\w+(?:\s+extends\s+\w+)?\s*{/g); return patterns; } async analyzeDirectory(dirPath: string, options: { excludePatterns?: string[] } = {}): Promise<AnalysisResult> { const files = await this.getFilesRecursively(dirPath, options.excludePatterns); const fileAnalyses: FileAnalysis[] = []; for (const file of files) { try { const analysis = await this.analyzeFile(file); fileAnalyses.push(analysis); } catch (error) { console.error(`Error analyzing ${file}:`, error); } } const summary = this.generateSummary(fileAnalyses); const recommendations = this.generateRecommendations(summary); return { id: crypto.randomUUID(), timestamp: new Date().toISOString(), files: fileAnalyses, summary, recommendations, }; } async getFilesRecursively(dirPath: string, excludePatterns: string[] = []): Promise<string[]> { const files: string[] = []; const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); // Check if should exclude if (excludePatterns.some(pattern => fullPath.includes(pattern))) { continue; } if (entry.isDirectory()) { // Skip common directories if (['node_modules', '.git', 'dist', 'build', 'coverage'].includes(entry.name)) { continue; } const subFiles = await this.getFilesRecursively(fullPath, excludePatterns); files.push(...subFiles); } else if (entry.isFile()) { // Only include source files if (this.isSourceFile(entry.name)) { files.push(fullPath); } } } return files; } private isSourceFile(fileName: string): boolean { const sourceExtensions = [ '.js', '.jsx', '.ts', '.tsx', '.py', '.java', '.c', '.cpp', '.cs', '.go', '.rb', '.php', '.swift', '.kt', '.rs' ]; return sourceExtensions.some(ext => fileName.endsWith(ext)); } generateSummary(files: FileAnalysis[]): AnalysisSummary { if (files.length === 0) { return { totalFiles: 0, totalLines: 0, averageComplexity: 0, averageMaintainability: 0, totalIssues: 0, criticalIssues: 0, duplicatePercentage: 0, overallScore: 0, overallGrade: 'F', }; } const totalFiles = files.length; const totalLines = files.reduce((sum, f) => sum + f.metrics.duplication.totalLines, 0); const totalComplexity = files.reduce((sum, f) => sum + f.metrics.complexity.cyclomatic, 0); const totalMaintainability = files.reduce((sum, f) => sum + f.metrics.maintainability.index, 0); const totalIssues = files.reduce((sum, f) => sum + f.issues.length, 0); const criticalIssues = files.reduce( (sum, f) => sum + f.issues.filter(i => i.severity === 'critical').length, 0 ); const totalDuplicatedLines = files.reduce((sum, f) => sum + f.metrics.duplication.duplicatedLines, 0); const overallScore = files.reduce((sum, f) => sum + f.metrics.quality.score, 0) / totalFiles; let overallGrade: 'A' | 'B' | 'C' | 'D' | 'F'; if (overallScore >= 90) overallGrade = 'A'; else if (overallScore >= 80) overallGrade = 'B'; else if (overallScore >= 70) overallGrade = 'C'; else if (overallScore >= 60) overallGrade = 'D'; else overallGrade = 'F'; return { totalFiles, totalLines, averageComplexity: Math.round((totalComplexity / totalFiles) * 100) / 100, averageMaintainability: Math.round((totalMaintainability / totalFiles) * 100) / 100, totalIssues, criticalIssues, duplicatePercentage: Math.round((totalDuplicatedLines / totalLines) * 100 * 100) / 100, overallScore: Math.round(overallScore * 100) / 100, overallGrade, }; } generateRecommendations(summary: AnalysisSummary): string[] { const recommendations: string[] = []; if (summary.averageComplexity > 10) { recommendations.push('Consider refactoring complex functions to reduce cyclomatic complexity'); } if (summary.averageMaintainability < 60) { recommendations.push('Improve code maintainability by reducing complexity and adding documentation'); } if (summary.criticalIssues > 0) { recommendations.push(`Address ${summary.criticalIssues} critical security/quality issues immediately`); } if (summary.duplicatePercentage > 10) { recommendations.push('Reduce code duplication by extracting common functionality'); } if (summary.overallGrade === 'D' || summary.overallGrade === 'F') { recommendations.push('Consider a comprehensive code quality improvement initiative'); } if (recommendations.length === 0) { recommendations.push('Code quality is good! Consider adding more tests and documentation'); } return recommendations; } }