Skip to main content
Glama
cbunting99

MCP Code Analysis & Quality Server

by cbunting99
BatchAnalyzeTool.ts27.2 kB
// Copyright 2025 Chris Bunting // Brief: Batch analysis tool for Static Analysis MCP Server // Scope: Provides command-line interface for batch static analysis import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs'; import { join, dirname, basename, relative, extname } from 'path'; import { AnalysisResult, AnalysisIssue, AnalysisMetrics, AnalysisOptions, SeverityLevel, IssueCategory, Language, FixSuggestion } from '@mcp-code-analysis/shared-types'; import { Logger } from '../utils/Logger.js'; import { StaticAnalysisService } from '../services/StaticAnalysisService.js'; import { IncrementalAnalysisService } from '../services/IncrementalAnalysisService.js'; import { PreCommitHookService } from '../services/PreCommitHookService.js'; export interface BatchAnalyzeOptions { projectPath: string; outputFormat: 'json' | 'xml' | 'html' | 'csv' | 'console'; outputPath?: string; includePatterns?: string[]; excludePatterns?: string[]; languages?: string[]; rules?: string[]; excludeRules?: string[]; fixableOnly?: boolean; maxFiles?: number; concurrency?: number; failOnErrors?: boolean; failOnWarnings?: boolean; generateReport?: boolean; incremental?: boolean; watchMode?: boolean; preCommitCheck?: boolean; } export interface BatchAnalyzeResult { success: boolean; totalFiles: number; analyzedFiles: number; failedFiles: number; totalIssues: number; errorsFound: number; warningsFound: number; executionTime: number; results: AnalysisResult[]; outputPath?: string; summary: any; } export class BatchAnalyzeTool { private logger: Logger; private analysisService: StaticAnalysisService; private incrementalService: IncrementalAnalysisService; private preCommitService: PreCommitHookService; constructor( analysisService: StaticAnalysisService, incrementalService: IncrementalAnalysisService, preCommitService: PreCommitHookService, logger: Logger ) { this.analysisService = analysisService; this.incrementalService = incrementalService; this.preCommitService = preCommitService; this.logger = logger; this.logger.info('Batch Analyze Tool initialized'); } async runBatchAnalysis(options: BatchAnalyzeOptions): Promise<BatchAnalyzeResult> { try { const startTime = Date.now(); this.logger.info('Starting batch analysis', options); // Validate options this.validateOptions(options); // Get files to analyze const files = await this.getFilesToAnalyze(options); if (files.length === 0) { throw new Error('No files found matching the specified patterns'); } this.logger.info(`Found ${files.length} files to analyze`); // Limit files if specified const filesToAnalyze = options.maxFiles ? files.slice(0, options.maxFiles) : files; if (filesToAnalyze.length < files.length) { this.logger.info(`Limited analysis to ${filesToAnalyze.length} files (max: ${options.maxFiles})`); } // Run pre-commit check if requested if (options.preCommitCheck) { const preCommitResult = await this.preCommitService.runPreCommitCheck(options.projectPath); if (!preCommitResult.success) { this.logger.warn('Pre-commit check failed:', preCommitResult.message); } } // Analyze files const results: AnalysisResult[] = []; const failedFiles: string[] = []; if (options.incremental) { // Use incremental analysis for (const file of filesToAnalyze) { try { const result = await this.incrementalService.analyzeFileIncremental(file, undefined, { rules: options.rules, exclude: options.excludeRules, fixable: options.fixableOnly }); results.push(result); } catch (error) { this.logger.warn(`Failed to analyze file ${file}:`, error); failedFiles.push(file); } } } else { // Use batch analysis const concurrency = options.concurrency || 4; const batches = this.createBatches(filesToAnalyze, concurrency); for (const batch of batches) { const batchResults = await this.analyzeBatch(batch, options); results.push(...batchResults.successful); failedFiles.push(...batchResults.failed); } } // Calculate statistics const totalIssues = results.reduce((sum, result) => sum + result.issues.length, 0); const errorsFound = results.reduce((sum, result) => sum + result.issues.filter(issue => issue.severity === SeverityLevel.ERROR).length, 0); const warningsFound = results.reduce((sum, result) => sum + result.issues.filter(issue => issue.severity === SeverityLevel.WARNING).length, 0); // Generate summary const summary = this.generateSummary(results, failedFiles, options); // Generate report if requested let outputPath: string | undefined; if (options.generateReport || options.outputPath) { outputPath = await this.generateOutput(results, summary, options); } const executionTime = Date.now() - startTime; // Check if we should fail based on results let success = true; if (options.failOnErrors && errorsFound > 0) { success = false; this.logger.error(`Analysis failed: Found ${errorsFound} errors`); } else if (options.failOnWarnings && warningsFound > 0) { success = false; this.logger.error(`Analysis failed: Found ${warningsFound} warnings`); } const result: BatchAnalyzeResult = { success, totalFiles: files.length, analyzedFiles: results.length, failedFiles: failedFiles.length, totalIssues, errorsFound, warningsFound, executionTime, results, outputPath, summary }; this.logger.info('Batch analysis completed', { success, totalFiles: result.totalFiles, analyzedFiles: result.analyzedFiles, totalIssues: result.totalIssues, executionTime: `${result.executionTime}ms` }); return result; } catch (error) { this.logger.error('Error running batch analysis:', error); throw error; } } private validateOptions(options: BatchAnalyzeOptions): void { if (!existsSync(options.projectPath)) { throw new Error(`Project path does not exist: ${options.projectPath}`); } const validFormats: BatchAnalyzeOptions['outputFormat'][] = ['json', 'xml', 'html', 'csv', 'console']; if (!validFormats.includes(options.outputFormat)) { throw new Error(`Invalid output format: ${options.outputFormat}`); } if (options.maxFiles && options.maxFiles <= 0) { throw new Error('maxFiles must be greater than 0'); } if (options.concurrency && options.concurrency <= 0) { throw new Error('concurrency must be greater than 0'); } } private async getFilesToAnalyze(options: BatchAnalyzeOptions): Promise<string[]> { try { const { glob } = await import('fast-glob'); const defaultPatterns = [ '**/*.{js,jsx,ts,tsx,py,java,c,cpp,go,rs}', '**/*.{h,hpp,cpp,cxx,cc}', ]; const defaultExcludePatterns = [ '**/node_modules/**', '**/dist/**', '**/build/**', '**/target/**', '**/.git/**', '**/venv/**', '**/env/**', '**/__pycache__/**', '**/*.min.js', '**/*.bundle.js', ]; const patterns = options.includePatterns || defaultPatterns; const exclude = options.excludePatterns || defaultExcludePatterns; let files = await glob(patterns, { cwd: options.projectPath, ignore: exclude, absolute: true, onlyFiles: true, }); // Filter by languages if specified if (options.languages && options.languages.length > 0) { files = files.filter(file => { const language = this.analysisService['languageDetector'].detectLanguage(file); return options.languages!.includes(language); }); } return files.sort(); } catch (error) { this.logger.error('Error getting files to analyze:', error); throw error; } } private createBatches(files: string[], concurrency: number): string[][] { const batchSize = Math.ceil(files.length / concurrency); const batches: string[][] = []; for (let i = 0; i < files.length; i += batchSize) { batches.push(files.slice(i, i + batchSize)); } return batches; } private async analyzeBatch(files: string[], options: BatchAnalyzeOptions): Promise<{ successful: AnalysisResult[], failed: string[] }> { try { const analysisOptions: AnalysisOptions = { rules: options.rules, exclude: options.excludeRules, fixable: options.fixableOnly }; const results = await this.analysisService.batchAnalyze(files, analysisOptions); const successful: AnalysisResult[] = []; const failed: string[] = []; results.forEach(result => { if (result?.issues !== undefined) { successful.push(result); } else { failed.push(result.filePath || 'unknown'); } }); return { successful, failed }; } catch (error) { this.logger.error('Error analyzing batch:', error); return { successful: [], failed: files }; } } private generateSummary(results: AnalysisResult[], failedFiles: string[], options: BatchAnalyzeOptions): any { const totalIssues = results.reduce((sum, result) => sum + result.issues.length, 0); const errorsFound = results.reduce((sum, result) => sum + result.issues.filter(issue => issue.severity === SeverityLevel.ERROR).length, 0); const warningsFound = results.reduce((sum, result) => sum + result.issues.filter(issue => issue.severity === SeverityLevel.WARNING).length, 0); const issuesByLanguage: Record<string, any> = {}; const issuesBySeverity: Record<string, number> = { error: errorsFound, warning: warningsFound, info: 0, hint: 0 }; const issuesByCategory: Record<string, number> = { syntax: 0, style: 0, security: 0, performance: 0, maintainability: 0, accessibility: 0 }; results.forEach(result => { const language = result.language; if (!issuesByLanguage[language]) { issuesByLanguage[language] = { total: 0, errors: 0, warnings: 0, files: 0 }; } issuesByLanguage[language].files++; issuesByLanguage[language].total += result.issues.length; result.issues.forEach(issue => { if (issue.severity === SeverityLevel.ERROR) { issuesByLanguage[language].errors++; } else if (issue.severity === SeverityLevel.WARNING) { issuesByLanguage[language].warnings++; } else { issuesBySeverity[issue.severity as keyof typeof issuesBySeverity]++; } issuesByCategory[issue.category as keyof typeof issuesByCategory]++; }); }); return { projectPath: options.projectPath, timestamp: new Date().toISOString(), options, summary: { totalFiles: results.length + failedFiles.length, analyzedFiles: results.length, failedFiles: failedFiles.length, totalIssues, errorsFound, warningsFound, averageIssuesPerFile: results.length > 0 ? (totalIssues / results.length).toFixed(2) : 0 }, issuesBySeverity, issuesByCategory, issuesByLanguage, failedFiles }; } private async generateOutput(results: AnalysisResult[], summary: any, options: BatchAnalyzeOptions): Promise<string> { try { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const defaultFileName = `analysis-report-${timestamp}.${options.outputFormat}`; const outputPath = options.outputPath || join(options.projectPath, defaultFileName); // Ensure output directory exists const outputDir = dirname(outputPath); if (!existsSync(outputDir)) { mkdirSync(outputDir, { recursive: true }); } let content: string; switch (options.outputFormat) { case 'json': content = JSON.stringify({ results, summary }, null, 2); break; case 'xml': content = this.generateXmlOutput(results, summary); break; case 'html': content = this.generateHtmlOutput(results, summary); break; case 'csv': content = this.generateCsvOutput(results, summary); break; case 'console': content = this.generateConsoleOutput(results, summary); break; default: throw new Error(`Unsupported output format: ${options.outputFormat}`); } writeFileSync(outputPath, content); this.logger.info(`Analysis report generated: ${outputPath}`); return outputPath; } catch (error) { this.logger.error('Error generating output:', error); throw error; } } private generateXmlOutput(results: AnalysisResult[], summary: any): string { let xml = '<?xml version="1.0" encoding="UTF-8"?>\n'; xml += '<analysis-report>\n'; xml += ` <timestamp>${summary.timestamp}</timestamp>\n`; xml += ` <project-path>${summary.projectPath}</project-path>\n`; xml += ' <summary>\n'; xml += ` <total-files>${summary.summary.totalFiles}</total-files>\n`; xml += ` <analyzed-files>${summary.summary.analyzedFiles}</analyzed-files>\n`; xml += ` <total-issues>${summary.summary.totalIssues}</total-issues>\n`; xml += ` <errors-found>${summary.summary.errorsFound}</errors-found>\n`; xml += ` <warnings-found>${summary.summary.warningsFound}</warnings-found>\n`; xml += ' </summary>\n'; xml += ' <results>\n'; results.forEach(result => { xml += ' <file>\n'; xml += ` <path>${result.filePath}</path>\n`; xml += ` <language>${result.language}</language>\n`; xml += ` <severity>${result.severity}</severity>\n`; xml += ` <issues-count>${result.issues.length}</issues-count>\n`; xml += ' <issues>\n'; result.issues.forEach(issue => { xml += ' <issue>\n'; xml += ` <rule-id>${issue.ruleId}</rule-id>\n`; xml += ` <message>${this.escapeXml(issue.message)}</message>\n`; xml += ` <severity>${issue.severity}</severity>\n`; xml += ` <line>${issue.line}</line>\n`; xml += ` <column>${issue.column}</column>\n`; xml += ` <category>${issue.category}</category>\n`; xml += ' </issue>\n'; }); xml += ' </issues>\n'; xml += ' </file>\n'; }); xml += ' </results>\n'; xml += '</analysis-report>\n'; return xml; } private generateHtmlOutput(results: AnalysisResult[], summary: any): string { let html = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Static Analysis Report</title> <style> body { font-family: Arial, sans-serif; margin: 20px; } .header { background-color: #f5f5f5; padding: 20px; border-radius: 5px; margin-bottom: 20px; } .summary { background-color: #e8f5e8; padding: 15px; border-radius: 5px; margin-bottom: 20px; } .file-result { border: 1px solid #ddd; margin-bottom: 10px; border-radius: 5px; } .file-header { background-color: #f0f0f0; padding: 10px; font-weight: bold; } .issue { padding: 8px; border-left: 3px solid #ddd; margin: 5px 0; } .error { border-left-color: #ff4444; background-color: #ffeeee; } .warning { border-left-color: #ffaa00; background-color: #ffffee; } .info { border-left-color: #0088ff; background-color: #eeeeff; } .stats { display: flex; gap: 20px; margin-bottom: 20px; } .stat-box { background-color: #f9f9f9; padding: 15px; border-radius: 5px; flex: 1; } .failed-files { background-color: #ffeeee; padding: 10px; border-radius: 5px; margin-top: 20px; } </style> </head> <body> <div class="header"> <h1>Static Analysis Report</h1> <p><strong>Project:</strong> ${summary.projectPath}</p> <p><strong>Generated:</strong> ${new Date(summary.timestamp).toLocaleString()}</p> </div> <div class="stats"> <div class="stat-box"> <h3>Files Analyzed</h3> <p>${summary.summary.analyzedFiles} / ${summary.summary.totalFiles}</p> </div> <div class="stat-box"> <h3>Total Issues</h3> <p>${summary.summary.totalIssues}</p> </div> <div class="stat-box"> <h3>Errors</h3> <p>${summary.summary.errorsFound}</p> </div> <div class="stat-box"> <h3>Warnings</h3> <p>${summary.summary.warningsFound}</p> </div> </div> <div class="summary"> <h2>Summary</h2> <ul> <li>Average issues per file: ${summary.summary.averageIssuesPerFile}</li> <li>Failed files: ${summary.summary.failedFiles}</li> </ul> </div> `; results.forEach(result => { const hasErrors = result.issues.some(issue => issue.severity === SeverityLevel.ERROR); const hasWarnings = result.issues.some(issue => issue.severity === SeverityLevel.WARNING); html += ` <div class="file-result"> <div class="file-header"> ${result.filePath} (${result.language}) - ${result.issues.length} issues </div> `; result.issues.forEach(issue => { const severityClass = issue.severity.toLowerCase(); html += ` <div class="issue ${severityClass}"> <strong>${issue.ruleId}</strong> (${issue.severity}) - Line ${issue.line}:${issue.column}<br> ${issue.message}<br> <small>Category: ${issue.category}</small> </div> `; }); html += ` </div> `; }); if (summary.failedFiles.length > 0) { html += ` <div class="failed-files"> <h3>Failed Files</h3> <ul> `; summary.failedFiles.forEach((file: string) => { html += ` <li>${file}</li>\n`; }); html += ` </ul> </div> `; } html += ` </body> </html> `; return html; } private generateCsvOutput(results: AnalysisResult[], summary: any): string { let csv = 'File Path,Language,Rule ID,Message,Severity,Line,Column,Category\n'; results.forEach(result => { result.issues.forEach(issue => { csv += `"${result.filePath}","${result.language}","${issue.ruleId}","${issue.message}","${issue.severity}",${issue.line},${issue.column},"${issue.category}"\n`; }); }); return csv; } private generateConsoleOutput(results: AnalysisResult[], summary: any): string { let output = '\n╔══════════════════════════════════════════════════════════════════════════════╗\n'; output += '║ STATIC ANALYSIS REPORT ║\n'; output += '╚══════════════════════════════════════════════════════════════════════════════╝\n\n'; output += `📁 Project: ${summary.projectPath}\n`; output += `🕐 Generated: ${new Date(summary.timestamp).toLocaleString()}\n\n`; output += '📊 SUMMARY\n'; output += '═══════════\n'; output += `📄 Files: ${summary.summary.analyzedFiles} / ${summary.summary.totalFiles}\n`; output += `🐛 Total Issues: ${summary.summary.totalIssues}\n`; output += `❌ Errors: ${summary.summary.errorsFound}\n`; output += `⚠️ Warnings: ${summary.summary.warningsFound}\n`; output += `📈 Avg/File: ${summary.summary.averageIssuesPerFile}\n\n`; if (results.length > 0) { output += '📋 DETAILED RESULTS\n'; output += '═══════════════════\n'; results.forEach(result => { const hasErrors = result.issues.some(issue => issue.severity === SeverityLevel.ERROR); const hasWarnings = result.issues.some(issue => issue.severity === SeverityLevel.WARNING); const icon = hasErrors ? '❌' : hasWarnings ? '⚠️' : '✅'; output += `${icon} ${result.filePath} (${result.language})\n`; output += ` Issues: ${result.issues.length}\n`; if (result.issues.length > 0) { output += ' ──────────────────────────────────────────────────────────\n'; result.issues.forEach(issue => { const severityIcon = issue.severity === SeverityLevel.ERROR ? '❌' : issue.severity === SeverityLevel.WARNING ? '⚠️' : '💡'; output += ` ${severityIcon} ${issue.ruleId} (${issue.severity})\n`; output += ` 📍 Line ${issue.line}:${issue.column}\n`; output += ` 💬 ${issue.message}\n`; output += ` 🏷️ Category: ${issue.category}\n\n`; }); } output += '\n'; }); } if (summary.failedFiles.length > 0) { output += '❌ FAILED FILES\n'; output += '═══════════════\n'; summary.failedFiles.forEach((file: string) => { output += ` ❌ ${file}\n`; }); output += '\n'; } output += '╔══════════════════════════════════════════════════════════════════════════════╗\n'; output += '║ END OF REPORT ║\n'; output += '╚══════════════════════════════════════════════════════════════════════════════╝\n'; return output; } private escapeXml(text: string): string { return text .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '&#39;'); } // Utility methods for CLI integration parseCommandLineArgs(args: string[]): BatchAnalyzeOptions { const options: BatchAnalyzeOptions = { projectPath: process.cwd(), outputFormat: 'console', incremental: false, watchMode: false, preCommitCheck: false, generateReport: false, failOnErrors: false, failOnWarnings: false }; for (let i = 0; i < args.length; i++) { const arg = args[i]; switch (arg) { case '--project-path': case '-p': options.projectPath = args[++i]; break; case '--output-format': case '-f': options.outputFormat = args[++i] as BatchAnalyzeOptions['outputFormat']; break; case '--output-path': case '-o': options.outputPath = args[++i]; break; case '--include-patterns': options.includePatterns = args[++i].split(','); break; case '--exclude-patterns': options.excludePatterns = args[++i].split(','); break; case '--languages': options.languages = args[++i].split(','); break; case '--rules': options.rules = args[++i].split(','); break; case '--exclude-rules': options.excludeRules = args[++i].split(','); break; case '--fixable-only': options.fixableOnly = true; break; case '--max-files': options.maxFiles = parseInt(args[++i]); break; case '--concurrency': options.concurrency = parseInt(args[++i]); break; case '--fail-on-errors': options.failOnErrors = true; break; case '--fail-on-warnings': options.failOnWarnings = true; break; case '--generate-report': case '-r': options.generateReport = true; break; case '--incremental': case '-i': options.incremental = true; break; case '--watch-mode': case '-w': options.watchMode = true; break; case '--pre-commit-check': case '-c': options.preCommitCheck = true; break; case '--help': case '-h': this.printHelp(); process.exit(0); break; default: if (arg.startsWith('-')) { throw new Error(`Unknown option: ${arg}`); } } } return options; } private printHelp(): void { const help = ` Batch Static Analysis Tool USAGE: batch-analyze [OPTIONS] OPTIONS: -p, --project-path PATH Project path to analyze (default: current directory) -f, --output-format FORMAT Output format: json, xml, html, csv, console (default: console) -o, --output-path PATH Output file path --include-patterns PATTERNS Comma-separated file patterns to include --exclude-patterns PATTERNS Comma-separated file patterns to exclude --languages LANGUAGES Comma-separated languages to analyze --rules RULES Comma-separated rules to include --exclude-rules RULES Comma-separated rules to exclude --fixable-only Only show fixable issues --max-files NUMBER Maximum number of files to analyze --concurrency NUMBER Number of concurrent analysis operations --fail-on-errors Exit with error code if errors are found --fail-on-warnings Exit with error code if warnings are found -r, --generate-report Generate comprehensive report -i, --incremental Use incremental analysis -w, --watch-mode Enable watch mode -c, --pre-commit-check Run pre-commit check -h, --help Show this help message EXAMPLES: batch-analyze --project-path ./my-project --output-format html batch-analyze --languages javascript,python --fail-on-errors batch-analyze --incremental --generate-report --output-format json `; console.log(help); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cbunting99/mcp-code-analysis-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server