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
JavaScript
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();
}
}