// Copyright 2025 Chris Bunting
// Brief: Incremental analysis service for Static Analysis MCP Server
// Scope: Provides intelligent caching and incremental analysis for large codebases
import { readFileSync, existsSync, watchFile, unwatchFile, statSync, Stats } from 'fs';
import { join, dirname, basename, relative } from 'path';
import { watch } from 'fs';
import {
AnalysisResult,
AnalysisIssue,
AnalysisMetrics,
AnalysisOptions,
SeverityLevel,
IssueCategory,
Language,
FixSuggestion
} from '@mcp-code-analysis/shared-types';
import { Logger } from '../utils/Logger.js';
import { StaticAnalysisService } from './StaticAnalysisService.js';
import { LanguageDetector } from './LanguageDetector.js';
export interface FileChange {
path: string;
type: 'created' | 'modified' | 'deleted';
timestamp: Date;
size?: number;
}
export interface AnalysisCache {
filePath: string;
result: AnalysisResult;
lastModified: Date;
fileSize: number;
checksum: string;
}
export interface IncrementalAnalysisOptions {
enableCache: boolean;
cacheTTL: number; // Time to live in milliseconds
maxCacheSize: number; // Maximum number of files to cache
watchMode: boolean;
debounceTime: number; // Debounce time for file changes in milliseconds
}
export class IncrementalAnalysisService {
private logger: Logger;
private analysisService: StaticAnalysisService;
private languageDetector: LanguageDetector;
private cache: Map<string, AnalysisCache> = new Map();
private fileWatchers: Map<string, any> = new Map();
private pendingChanges: Map<string, FileChange> = new Map();
private debounceTimers: Map<string, NodeJS.Timeout> = new Map();
private options: IncrementalAnalysisOptions;
constructor(
analysisService: StaticAnalysisService,
languageDetector: LanguageDetector,
logger: Logger,
options: Partial<IncrementalAnalysisOptions> = {}
) {
this.analysisService = analysisService;
this.languageDetector = languageDetector;
this.logger = logger;
this.options = {
enableCache: true,
cacheTTL: 3600000, // 1 hour
maxCacheSize: 1000,
watchMode: false,
debounceTime: 1000, // 1 second
...options
};
this.logger.info('Incremental Analysis Service initialized', this.options);
}
async analyzeFileIncremental(
filePath: string,
language?: string,
options: AnalysisOptions = {}
): Promise<AnalysisResult> {
try {
this.logger.info(`Incremental analysis requested for: ${filePath}`);
// Check if file exists
if (!existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
// Get file stats
const stats = statSync(filePath);
const fileSize = stats.size;
const lastModified = stats.mtime;
// Check cache if enabled
if (this.options.enableCache) {
const cachedResult = this.getFromCache(filePath, fileSize, lastModified);
if (cachedResult) {
this.logger.info(`Returning cached result for: ${filePath}`);
return cachedResult;
}
}
// Perform full analysis
const result = await this.analysisService.analyzeFile(filePath, language, options);
// Cache the result if caching is enabled
if (this.options.enableCache) {
this.addToCache(filePath, result, fileSize, lastModified);
}
return result;
} catch (error) {
this.logger.error(`Error in incremental analysis for ${filePath}:`, error);
throw error;
}
}
async analyzeProjectIncremental(
projectPath: string,
filePatterns?: string[],
excludePatterns?: string[],
options: AnalysisOptions = {}
): Promise<AnalysisResult[]> {
try {
this.logger.info(`Incremental project analysis requested for: ${projectPath}`);
// Get all files in the project
const allFiles = await this.getProjectFiles(projectPath, filePatterns, excludePatterns);
const results: AnalysisResult[] = [];
// Analyze files incrementally
for (const file of allFiles) {
try {
const result = await this.analyzeFileIncremental(file, undefined, options);
results.push(result);
} catch (error) {
this.logger.warn(`Failed to incrementally analyze file ${file}:`, error);
}
}
return results;
} catch (error) {
this.logger.error(`Error in incremental project analysis for ${projectPath}:`, error);
throw error;
}
}
async startWatching(projectPath: string): Promise<void> {
try {
if (!this.options.watchMode) {
this.logger.info('Watch mode is disabled');
return;
}
this.logger.info(`Starting file watching for: ${projectPath}`);
// Get all files in the project
const files = await this.getProjectFiles(projectPath);
// Start watching each file
for (const file of files) {
this.watchFile(file);
}
this.logger.info(`Started watching ${files.length} files`);
} catch (error) {
this.logger.error(`Error starting file watching for ${projectPath}:`, error);
throw error;
}
}
async stopWatching(): Promise<void> {
try {
this.logger.info('Stopping file watching');
// Clear all file watchers
for (const [filePath, watcher] of this.fileWatchers) {
watcher.close();
this.logger.debug(`Stopped watching: ${filePath}`);
}
this.fileWatchers.clear();
// Clear all debounce timers
for (const [filePath, timer] of this.debounceTimers) {
clearTimeout(timer);
}
this.debounceTimers.clear();
this.logger.info('File watching stopped');
} catch (error) {
this.logger.error('Error stopping file watching:', error);
throw error;
}
}
async getChangedFiles(): Promise<FileChange[]> {
return Array.from(this.pendingChanges.values());
}
async clearPendingChanges(): Promise<void> {
this.pendingChanges.clear();
}
private async getProjectFiles(
projectPath: string,
includePatterns?: string[],
excludePatterns?: string[]
): Promise<string[]> {
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 = includePatterns || defaultPatterns;
const exclude = excludePatterns || defaultExcludePatterns;
const files = await glob(patterns, {
cwd: projectPath,
ignore: exclude,
absolute: true,
onlyFiles: true,
});
return files.sort();
}
private watchFile(filePath: string): void {
try {
if (this.fileWatchers.has(filePath)) {
this.logger.debug(`Already watching: ${filePath}`);
return;
}
const watcher = watch(filePath, (eventType) => {
if (eventType === 'change') {
this.handleFileChange(filePath, statSync(filePath), statSync(filePath));
}
});
this.fileWatchers.set(filePath, watcher);
this.logger.debug(`Started watching: ${filePath}`);
} catch (error) {
this.logger.error(`Error watching file ${filePath}:`, error);
}
}
private handleFileChange(filePath: string, curr: Stats, prev: Stats): void {
try {
// Clear existing debounce timer
if (this.debounceTimers.has(filePath)) {
clearTimeout(this.debounceTimers.get(filePath)!);
}
// Set new debounce timer
const timer = setTimeout(() => {
this.processFileChange(filePath, curr, prev);
}, this.options.debounceTime);
this.debounceTimers.set(filePath, timer);
} catch (error) {
this.logger.error(`Error handling file change for ${filePath}:`, error);
}
}
private async processFileChange(filePath: string, curr: Stats, prev: Stats): Promise<void> {
try {
let changeType: FileChange['type'];
if (!existsSync(filePath)) {
changeType = 'deleted';
} else if (prev.size === 0 && curr.size > 0) {
changeType = 'created';
} else {
changeType = 'modified';
}
const change: FileChange = {
path: filePath,
type: changeType,
timestamp: new Date(),
size: curr.size
};
this.pendingChanges.set(filePath, change);
this.logger.info(`File change detected: ${filePath} (${changeType})`);
// Invalidate cache for this file
if (changeType !== 'deleted') {
this.cache.delete(filePath);
}
// If file was deleted, stop watching it
if (changeType === 'deleted') {
const watcher = this.fileWatchers.get(filePath);
if (watcher) {
watcher.close();
this.fileWatchers.delete(filePath);
}
}
} catch (error) {
this.logger.error(`Error processing file change for ${filePath}:`, error);
}
}
private getFromCache(
filePath: string,
fileSize: number,
lastModified: Date
): AnalysisResult | null {
try {
const cached = this.cache.get(filePath);
if (!cached) {
return null;
}
// Check if cache is expired
const now = new Date();
const cacheAge = now.getTime() - cached.lastModified.getTime();
if (cacheAge > this.options.cacheTTL) {
this.logger.debug(`Cache expired for: ${filePath}`);
this.cache.delete(filePath);
return null;
}
// Check if file has been modified
if (cached.fileSize !== fileSize || cached.lastModified.getTime() !== lastModified.getTime()) {
this.logger.debug(`File modified, cache invalid for: ${filePath}`);
this.cache.delete(filePath);
return null;
}
return cached.result;
} catch (error) {
this.logger.error(`Error getting from cache for ${filePath}:`, error);
return null;
}
}
private addToCache(
filePath: string,
result: AnalysisResult,
fileSize: number,
lastModified: Date
): void {
try {
// Check cache size limit
if (this.cache.size >= this.options.maxCacheSize) {
this.evictOldestCacheEntries();
}
const checksum = this.generateChecksum(result);
const cacheEntry: AnalysisCache = {
filePath,
result,
lastModified,
fileSize,
checksum
};
this.cache.set(filePath, cacheEntry);
this.logger.debug(`Added to cache: ${filePath}`);
} catch (error) {
this.logger.error(`Error adding to cache for ${filePath}:`, error);
}
}
private evictOldestCacheEntries(): void {
try {
// Sort cache entries by last modified time
const entries = Array.from(this.cache.entries())
.sort((a, b) => a[1].lastModified.getTime() - b[1].lastModified.getTime());
// Remove oldest 25% of entries
const entriesToRemove = Math.ceil(entries.length * 0.25);
for (let i = 0; i < entriesToRemove; i++) {
const [filePath] = entries[i];
this.cache.delete(filePath);
this.logger.debug(`Evicted from cache: ${filePath}`);
}
} catch (error) {
this.logger.error('Error evicting cache entries:', error);
}
}
private generateChecksum(result: AnalysisResult): string {
try {
// Simple checksum based on issues count and severity
const issuesData = result.issues.map(issue =>
`${issue.ruleId}:${issue.severity}:${issue.line}:${issue.column}`
).join('|');
return require('crypto')
.createHash('md5')
.update(issuesData)
.digest('hex');
} catch (error) {
this.logger.error('Error generating checksum:', error);
return 'unknown';
}
}
getCacheStats(): any {
return {
size: this.cache.size,
maxSize: this.options.maxCacheSize,
ttl: this.options.cacheTTL,
enabled: this.options.enableCache,
watchMode: this.options.watchMode,
watchedFiles: this.fileWatchers.size,
pendingChanges: this.pendingChanges.size
};
}
clearCache(): void {
this.cache.clear();
this.logger.info('Cache cleared');
}
}