UNPKG

vibe-coder-mcp

Version:

Production-ready MCP server with complete agent integration, multi-transport support, and comprehensive development automation tools for AI-assisted workflows.

448 lines (447 loc) 17.8 kB
import fs from 'fs/promises'; import path from 'path'; import logger from '../../logger.js'; import { CacheManager } from './cache-manager.js'; import { FuzzyMatcher, GlobMatcher, PriorityQueue } from './search-strategies.js'; export class FileSearchService { static instance; cacheManager; searchMetrics; constructor() { this.cacheManager = new CacheManager({ maxEntries: 1000, defaultTtl: 5 * 60 * 1000, maxMemoryUsage: 50 * 1024 * 1024, enableStats: true }); this.searchMetrics = { searchTime: 0, filesScanned: 0, resultsFound: 0, cacheHitRate: 0, memoryUsage: 0, strategy: 'fuzzy' }; logger.debug('File search service initialized'); } static getInstance() { if (!FileSearchService.instance) { FileSearchService.instance = new FileSearchService(); } return FileSearchService.instance; } async searchFiles(projectPath, options = {}) { const startTime = Date.now(); try { logger.debug({ projectPath, options }, 'Starting file search'); if (!await this.isValidPath(projectPath)) { throw new Error(`Invalid or inaccessible project path: ${projectPath}`); } const query = options.pattern || options.glob || options.content || ''; const cachedResults = this.cacheManager.get(query, options); if (cachedResults) { this.updateMetrics(startTime, 0, cachedResults.length, 'fuzzy', true); return cachedResults; } const strategy = options.searchStrategy || 'fuzzy'; const results = await this.searchByStrategy(strategy, projectPath, options); if (options.cacheResults !== false) { this.cacheManager.set(query, options, results); } this.updateMetrics(startTime, this.searchMetrics.filesScanned, results.length, strategy, false); logger.info({ projectPath, strategy, resultsCount: results.length, searchTime: Date.now() - startTime }, 'File search completed'); return results; } catch (error) { logger.error({ err: error, projectPath, options }, 'File search failed'); throw error; } } async searchByStrategy(strategy, projectPath, options) { switch (strategy) { case 'fuzzy': return this.fuzzySearch(projectPath, options); case 'exact': return this.exactSearch(projectPath, options); case 'glob': return this.globSearch(projectPath, options); case 'regex': return this.regexSearch(projectPath, options); case 'content': return this.contentSearch(projectPath, options); default: throw new Error(`Unsupported search strategy: ${strategy}`); } } async streamingSearch(projectPath, strategy, options) { const pattern = options.pattern || options.glob || ''; if (!pattern && strategy !== 'content') return []; const maxResults = options.maxResults || 100; const resultQueue = new PriorityQueue((a, b) => b.score - a.score, maxResults * 2); const excludeDirs = new Set([ 'node_modules', '.git', 'dist', 'build', '.next', 'coverage', ...(options.excludeDirs || []) ]); const fileTypes = options.fileTypes ? new Set(options.fileTypes) : null; let filesProcessed = 0; for await (const filePath of this.scanDirectoryIterator(projectPath, excludeDirs, fileTypes)) { filesProcessed++; const result = await this.evaluateFile(filePath, strategy, options, projectPath); if (result) { const minScore = resultQueue.getMinScore(r => r.score); if (minScore === undefined || result.score >= minScore) { resultQueue.add(result); } } if (filesProcessed % 1000 === 0) { logger.debug({ filesProcessed, queueSize: resultQueue.size, strategy }, 'Streaming search progress'); } } logger.debug({ filesProcessed, resultsFound: resultQueue.size, strategy }, 'Streaming search completed'); this.searchMetrics.filesScanned = filesProcessed; const results = resultQueue.toArray().slice(0, maxResults); return results; } async fuzzySearch(projectPath, options) { return this.streamingSearch(projectPath, 'fuzzy', options); } async exactSearch(projectPath, options) { return this.streamingSearch(projectPath, 'exact', options); } async globSearch(projectPath, options) { return this.streamingSearch(projectPath, 'glob', options); } async regexSearch(projectPath, options) { const pattern = options.pattern || ''; if (!pattern) return []; try { new RegExp(pattern, options.caseSensitive ? 'g' : 'gi'); return this.streamingSearch(projectPath, 'regex', options); } catch (error) { logger.error({ err: error, pattern }, 'Invalid regex pattern'); return []; } } async *contentSearchIterator(projectPath, options) { const contentPattern = options.content || options.pattern || ''; if (!contentPattern) return; const maxFileSize = options.maxFileSize || 1024 * 1024; const excludeDirs = new Set([ 'node_modules', '.git', 'dist', 'build', '.next', 'coverage', ...(options.excludeDirs || []) ]); const fileTypes = options.fileTypes ? new Set(options.fileTypes) : null; let regex; try { regex = new RegExp(contentPattern, options.caseSensitive ? 'g' : 'gi'); } catch (error) { logger.error({ err: error, pattern: contentPattern }, 'Invalid content search pattern'); return; } for await (const filePath of this.scanDirectoryIterator(projectPath, excludeDirs, fileTypes)) { try { const stats = await fs.stat(filePath); if (stats.size > maxFileSize) continue; const content = await fs.readFile(filePath, 'utf-8'); const lines = content.split('\n'); const matchingLines = []; let preview = ''; lines.forEach((line, index) => { regex.lastIndex = 0; if (regex.test(line)) { matchingLines.push(index + 1); if (!preview && line.trim()) { preview = line.trim().substring(0, 100); } } }); if (matchingLines.length > 0) { yield { filePath, score: Math.min(0.8 + (matchingLines.length * 0.01), 1.0), matchType: 'content', lineNumbers: matchingLines, preview: options.includeContent ? preview : undefined, relevanceFactors: [`Found ${matchingLines.length} content matches`], metadata: { size: stats.size, lastModified: stats.mtime, extension: path.extname(filePath).toLowerCase() } }; } } catch (error) { logger.debug({ err: error, filePath }, 'Could not read file for content search'); continue; } } } async contentSearch(projectPath, options) { const contentPattern = options.content || options.pattern || ''; if (!contentPattern) return []; const maxResults = options.maxResults || 100; const resultQueue = new PriorityQueue((a, b) => b.score - a.score, maxResults * 2); let filesProcessed = 0; for await (const result of this.contentSearchIterator(projectPath, options)) { filesProcessed++; resultQueue.add(result); if (filesProcessed % 100 === 0) { logger.debug({ filesProcessed, resultsFound: resultQueue.size }, 'Content search progress'); } } logger.debug({ filesProcessed, resultsFound: resultQueue.size }, 'Content search completed'); this.searchMetrics.filesScanned = filesProcessed; return resultQueue.toArray().slice(0, maxResults); } async collectFiles(projectPath, options) { const files = []; const excludeDirs = new Set([ 'node_modules', '.git', 'dist', 'build', '.next', 'coverage', ...(options.excludeDirs || []) ]); const fileTypes = options.fileTypes ? new Set(options.fileTypes) : null; for await (const filePath of this.scanDirectoryIterator(projectPath, excludeDirs, fileTypes)) { files.push(filePath); } this.searchMetrics.filesScanned = files.length; return files; } async *scanDirectoryIterator(dirPath, excludeDirs, fileTypes, depth = 0, maxDepth = 25) { if (depth > maxDepth) return; try { const { FilesystemSecurity } = await import('../../tools/vibe-task-manager/security/filesystem-security.js'); const fsecurity = FilesystemSecurity.getInstance(); const securityCheck = await fsecurity.checkPathSecurity(dirPath, 'read'); if (!securityCheck.allowed) { if (securityCheck.securityViolation) { logger.warn({ dirPath, reason: securityCheck.reason }, 'Directory access blocked by security policy'); } return; } const entries = await fsecurity.readDirSecure(dirPath); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { if (!excludeDirs.has(entry.name)) { yield* this.scanDirectoryIterator(fullPath, excludeDirs, fileTypes, depth + 1, maxDepth); } } else if (entry.isFile()) { const fileSecurityCheck = await fsecurity.checkPathSecurity(fullPath, 'read'); if (!fileSecurityCheck.allowed) { continue; } if (fileTypes) { const ext = path.extname(entry.name).toLowerCase(); if (!fileTypes.has(ext)) continue; } yield fullPath; } } } catch (error) { if (error instanceof Error) { if (error.message.includes('Permission denied') || error.message.includes('EACCES')) { logger.debug({ dirPath }, 'Directory access denied - skipping'); } else if (error.message.includes('blacklist')) { logger.debug({ dirPath }, 'Directory in security blacklist - skipping'); } else { logger.debug({ err: error, dirPath }, 'Could not read directory'); } } else { logger.debug({ err: error, dirPath }, 'Could not read directory'); } } } async evaluateFile(filePath, strategy, options, projectPath) { const fileName = path.basename(filePath); switch (strategy) { case 'fuzzy': { const pattern = options.pattern || ''; if (!pattern) return null; const score = FuzzyMatcher.calculateScore(pattern, fileName, options.caseSensitive || false); const minScore = options.minScore || 0.3; if (score >= minScore) { return { filePath, score, matchType: 'fuzzy', relevanceFactors: [`Fuzzy match score: ${score.toFixed(2)}`], metadata: await this.getFileMetadata(filePath) }; } return null; } case 'exact': { const pattern = options.pattern || ''; if (!pattern) return null; const searchPattern = options.caseSensitive ? pattern : pattern.toLowerCase(); const searchTarget = options.caseSensitive ? fileName : fileName.toLowerCase(); if (searchTarget.includes(searchPattern)) { const score = searchTarget === searchPattern ? 1.0 : 0.8; return { filePath, score, matchType: 'exact', relevanceFactors: ['Exact name match'], metadata: await this.getFileMetadata(filePath) }; } return null; } case 'glob': { const globPattern = options.glob || options.pattern || ''; if (!globPattern) return null; const relativePath = path.relative(projectPath, filePath); if (GlobMatcher.matches(globPattern, relativePath)) { return { filePath, score: 1.0, matchType: 'glob', relevanceFactors: [`Matches glob pattern: ${globPattern}`], metadata: await this.getFileMetadata(filePath) }; } return null; } case 'regex': { const pattern = options.pattern || ''; if (!pattern) return null; try { const regex = new RegExp(pattern, options.caseSensitive ? 'g' : 'gi'); if (regex.test(fileName)) { return { filePath, score: 0.9, matchType: 'name', relevanceFactors: [`Matches regex: ${pattern}`], metadata: await this.getFileMetadata(filePath) }; } } catch (error) { logger.error({ err: error, pattern }, 'Invalid regex pattern'); } return null; } case 'content': { return null; } default: return null; } } async getFileMetadata(filePath) { try { const stats = await fs.stat(filePath); return { size: stats.size, lastModified: stats.mtime, extension: path.extname(filePath).toLowerCase() }; } catch { return undefined; } } limitResults(results, maxResults) { if (!maxResults || results.length <= maxResults) { return results; } return results.slice(0, maxResults); } async isValidPath(projectPath) { try { const { FilesystemSecurity } = await import('../../tools/vibe-task-manager/security/filesystem-security.js'); const fsecurity = FilesystemSecurity.getInstance(); const securityCheck = await fsecurity.checkPathSecurity(projectPath, 'read'); if (!securityCheck.allowed) { logger.debug({ projectPath, reason: securityCheck.reason }, 'Path validation failed security check'); return false; } const stats = await fsecurity.statSecure(projectPath); return stats.isDirectory(); } catch (error) { logger.debug({ err: error, projectPath }, 'Path validation failed'); return false; } } updateMetrics(startTime, filesScanned, resultsFound, strategy, fromCache) { this.searchMetrics = { searchTime: Date.now() - startTime, filesScanned, resultsFound, cacheHitRate: fromCache ? 1.0 : 0.0, memoryUsage: process.memoryUsage().heapUsed, strategy }; } async clearCache(projectPath) { this.cacheManager.clear(projectPath); logger.info({ projectPath }, 'File search cache cleared'); } getPerformanceMetrics() { return { ...this.searchMetrics }; } getCacheStats() { return this.cacheManager.getStats(); } }