// 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, ''');
}
// 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);
}
}