UNPKG

ts-project-indexer-mcp

Version:

A powerful MCP (Model Context Protocol) server that indexes and analyzes TypeScript/JavaScript projects, providing intelligent code exploration, dependency tracking, method discovery, and project structure analysis for AI assistants and development tools.

421 lines 19.4 kB
import { promises as fs } from 'node:fs'; import { join, relative, resolve, basename, extname } from 'node:path'; import { CodeParser } from './parser.js'; import { PathResolver } from './path-resolver.js'; import { logger } from '../mcp-server.js'; export class ProjectIndexer { cache; parser; pathResolver = null; indexData; constructor(cache) { this.cache = cache; this.parser = new CodeParser(); this.indexData = { files: new Map(), methods: new Map(), paths: new Map(), dependencies: new Map(), lastIndexed: new Date(0), }; logger.operation('ProjectIndexer initialized', `Current working directory: ${process.cwd()}`); } async initialize() { await this.cache.init(); } async analyzeProject(options) { const startTime = Date.now(); const projectPath = resolve(options.projectPath); logger.operation('analyzeProject started', `Project path: ${projectPath}`); logger.debug(`Analysis options: ${JSON.stringify(options, null, 2)}`); logger.debug(`Force reindex: ${options.forceReindex}`); logger.debug(`Include patterns: ${options.includePatterns.join(', ')}`); logger.debug(`Exclude patterns: ${options.excludePatterns.join(', ')}`); // Initialize PathResolver for proper import resolution (always needed) logger.operation('PathResolver initialization', `Initializing for project: ${projectPath}`); this.pathResolver = new PathResolver(projectPath); await this.pathResolver.initialize(); this.parser.setPathResolver(this.pathResolver); logger.debug('PathResolver successfully initialized and assigned to parser'); // Check if we can use cached data const cacheKey = `project-${projectPath}-${JSON.stringify(options)}`; logger.debug(`Cache key created: ${cacheKey}`); const cachedData = await this.cache.get(cacheKey); if (cachedData && !options.forceReindex) { logger.info('Using data from cache - skipping reindex'); this.indexData = this.deserializeIndexData(cachedData); const stats = this.getStats(Date.now() - startTime); logger.operation('analyzeProject completed (cached)', `Duration: ${stats.duration}ms, Files: ${stats.totalFiles}, Methods: ${stats.totalMethods}`); return stats; } logger.operation('File discovery started', `Searching in: ${projectPath}`); // Find all files to analyze const files = await this.findFiles(projectPath, options); logger.info(`${files.length} files found for analysis`); logger.debug(`Found files: ${files.slice(0, 10).join(', ')}${files.length > 10 ? '...' : ''}`); // Clear existing data logger.debug('Clearing existing index data'); this.clearIndexData(); // Process files in batches for better performance const batchSize = 50; logger.operation('File processing started', `Processing ${files.length} files in batches of ${batchSize}`); for (let i = 0; i < files.length; i += batchSize) { const batch = files.slice(i, i + batchSize); const batchNumber = Math.floor(i / batchSize) + 1; const totalBatches = Math.ceil(files.length / batchSize); logger.debug(`Processing batch ${batchNumber}/${totalBatches} (${batch.length} files)`); await Promise.all(batch.map((file) => this.processFile(file, projectPath))); // Progress logging every 5 batches if (batchNumber % 5 === 0 || batchNumber === totalBatches) { const processedFiles = Math.min(i + batchSize, files.length); logger.info(`Progress: ${processedFiles}/${files.length} files processed (${Math.round(processedFiles / files.length * 100)}%)`); } } this.indexData.lastIndexed = new Date(); // Cache the results logger.operation('Caching results', `Saving to cache with key: ${cacheKey}`); await this.cache.set(cacheKey, this.serializeIndexData(), 1000 * 60 * 60 * 24); // 24 hours logger.debug('Results successfully saved to cache'); const duration = Date.now() - startTime; const stats = this.getStats(duration); logger.operation('analyzeProject completed', `Duration: ${stats.duration}ms, Files: ${stats.totalFiles}, Methods: ${stats.totalMethods}, Paths: ${stats.totalPaths}, Dependencies: ${stats.totalDependencies}`); return stats; } async searchMethods(query, type = 'all', includeUsages = false) { const startTime = Date.now(); const results = []; const queryLower = query.toLowerCase(); const queryRegex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); for (const [filePath, methods] of this.indexData.methods) { for (const method of methods) { // Type filter if (type !== 'all' && method.type !== type) continue; // Name matching const nameMatch = method.name.toLowerCase().includes(queryLower) || queryRegex.test(method.name); if (nameMatch) { const result = { ...method }; if (includeUsages) { result.usages = this.findMethodUsages(method.name); } results.push(result); } } } return { items: results, totalCount: results.length, query, searchTime: Date.now() - startTime, }; } async findDependencies(entityName, direction = 'both', depth = 2) { const incoming = []; const outgoing = []; const visited = new Set(); const graph = new Map(); this.findDependenciesRecursive(entityName, direction, depth, incoming, outgoing, visited, graph); return { entity: entityName, incoming, outgoing, graph, }; } getFileInfo(filePath) { return this.indexData.files.get(filePath) || null; } getMethodsInFile(filePath) { return this.indexData.methods.get(filePath) || []; } getPathsInFile(filePath) { return this.indexData.paths.get(filePath) || []; } getAllFiles() { return Array.from(this.indexData.files.values()); } getAllMethods() { const methods = []; for (const fileMethods of this.indexData.methods.values()) { methods.push(...fileMethods); } return methods; } getAllPaths() { const paths = []; for (const filePaths of this.indexData.paths.values()) { paths.push(...filePaths); } return paths; } getAllDependencies() { const dependencies = []; for (const fileDeps of this.indexData.dependencies.values()) { dependencies.push(...fileDeps); } return dependencies; } async findUsages(options) { const results = []; const { filePath, methodName, className, searchType } = options; for (const [file, dependencies] of this.indexData.dependencies) { for (const dep of dependencies) { let isMatch = false; let matchType = 'usage'; // Check for file path imports using full path matching if (filePath && searchType !== 'usages') { // Convert search path to absolute path for exact matching const searchAbsolutePath = resolve(filePath); // Primary match: exact absolute path comparison if (dep.resolvedTo && resolve(dep.resolvedTo) === searchAbsolutePath) { isMatch = true; matchType = 'import'; } // Secondary match: try to resolve the import on-the-fly if (!isMatch && this.pathResolver) { const resolvedDepPath = this.pathResolver.resolveImportPath(dep.to, dep.from); if (resolvedDepPath && resolve(resolvedDepPath) === searchAbsolutePath) { isMatch = true; matchType = 'import'; } } // Fallback: filename and path suffix matching for backwards compatibility if (!isMatch) { const searchPath = filePath.replace(/\\/g, '/').replace(/\.(ts|js)$/, ''); const searchFilename = searchPath.split('/').pop() || ''; const depToPath = dep.to.replace(/\\/g, '/').replace(/\.(ts|js)$/, ''); const depToFilename = depToPath.split('/').pop() || ''; if ( // Filename match (searchFilename && depToFilename === searchFilename) || // Path suffix match depToPath.endsWith(searchPath) || searchPath.endsWith(depToPath) || // Direct inclusion check dep.to.includes(filePath) || dep.to.includes(searchPath)) { isMatch = true; matchType = 'import'; } } } // Check for method/class usages if ((methodName || className) && searchType !== 'imports') { const searchTerm = methodName || className; if (dep.to.includes(searchTerm) || dep.from.includes(searchTerm)) { isMatch = true; matchType = 'usage'; } } if (isMatch) { results.push({ file, line: dep.line || 0, type: matchType, context: dep.type || 'unknown', from: dep.from, to: dep.to, resolvedTo: dep.resolvedTo, }); } } } return results; } getProjectStats() { return { totalFiles: this.indexData.files.size, totalMethods: this.getAllMethods().length, totalPaths: this.getAllPaths().length, totalDependencies: this.getAllDependencies().length, lastIndexed: this.indexData.lastIndexed, }; } async findFiles(projectPath, options) { const allFiles = []; const shouldInclude = (filePath) => { const relativePath = relative(projectPath, filePath); // Check exclude patterns for (const excludePattern of options.excludePatterns) { if (this.matchesPattern(relativePath, excludePattern)) { return false; } } // Check include patterns if (options.includePatterns.length === 0) return true; for (const includePattern of options.includePatterns) { if (this.matchesPattern(relativePath, includePattern)) { return true; } } return false; }; const walkDirectory = async (dirPath) => { try { // Ensure we don't go outside the project root const resolvedDirPath = resolve(dirPath); const resolvedProjectPath = resolve(projectPath); if (!resolvedDirPath.startsWith(resolvedProjectPath)) { logger.debug(`🚫 Skipping directory outside project root: ${resolvedDirPath}`); return; } const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dirPath, entry.name); const resolvedFullPath = resolve(fullPath); // Double-check each entry doesn't escape project root if (!resolvedFullPath.startsWith(resolvedProjectPath)) { logger.debug(`🚫 Skipping path outside project root: ${resolvedFullPath}`); continue; } if (entry.isDirectory()) { await walkDirectory(fullPath); } else if (entry.isFile() && shouldInclude(fullPath) && this.parser.shouldParseFile(fullPath)) { allFiles.push(fullPath); } } } catch (error) { // Skip directories we can't read } }; await walkDirectory(projectPath); return allFiles; } matchesPattern(filePath, pattern) { // Simple pattern matching - convert glob-like patterns to regex const normalizedPath = filePath.replace(/\\/g, '/'); const normalizedPattern = pattern.replace(/\\/g, '/'); // Escape special regex characters first, except our glob characters let regexPattern = normalizedPattern .replace(/[.+^${}()|[\]\\]/g, '\\$&'); // Escape regex special chars except * and ? // Now handle glob patterns regexPattern = regexPattern .replace(/\*\*/g, '___DOUBLESTAR___') // Temporary placeholder .replace(/\*/g, '[^/]*') // * matches any characters except / .replace(/___DOUBLESTAR___/g, '.*') // ** matches any characters including / .replace(/\?/g, '.'); // ? matches any single character const regex = new RegExp(`^${regexPattern}$`); return regex.test(normalizedPath); } async processFile(filePath, projectRoot) { try { const stats = await fs.stat(filePath); const relativePath = relative(projectRoot, filePath); logger.debug(`Processing file: ${relativePath} (${stats.size} bytes)`); // Store file info const fileInfo = { path: filePath, name: basename(filePath), extension: extname(filePath), size: stats.size, lastModified: stats.mtime, isDirectory: stats.isDirectory(), relativePath, }; this.indexData.files.set(filePath, fileInfo); // Parse code content if (this.parser.shouldParseFile(filePath)) { logger.debug(`Starting code parsing: ${relativePath}`); const parseStartTime = Date.now(); const { methods, paths, dependencies } = await this.parser.parseFile(filePath); const parseTime = Date.now() - parseStartTime; logger.debug(`Parsing completed: ${relativePath} (${parseTime}ms) - Methods: ${methods.length}, Paths: ${paths.length}, Dependencies: ${dependencies.length}`); if (methods.length > 0) { this.indexData.methods.set(filePath, methods); logger.debug(`${methods.length} methods added to index: ${relativePath}`); } if (paths.length > 0) { this.indexData.paths.set(filePath, paths); logger.debug(`${paths.length} paths added to index: ${relativePath}`); } if (dependencies.length > 0) { this.indexData.dependencies.set(filePath, dependencies); logger.debug(`${dependencies.length} dependencies added to index: ${relativePath}`); } } else { logger.debug(`File not parsed (unsupported format): ${relativePath}`); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`File processing error ${filePath}: ${errorMessage}`); } } findMethodUsages(methodName) { const usages = []; for (const [filePath, dependencies] of this.indexData.dependencies) { for (const dep of dependencies) { if (dep.to.includes(methodName) || dep.from.includes(methodName)) { usages.push(`${filePath}:${dep.line}`); } } } return usages; } findDependenciesRecursive(entityName, direction, depth, incoming, outgoing, visited, graph, currentDepth = 0) { if (currentDepth >= depth || visited.has(entityName)) return; visited.add(entityName); for (const [filePath, dependencies] of this.indexData.dependencies) { for (const dep of dependencies) { if (direction === 'outgoing' || direction === 'both') { if (dep.from.includes(entityName)) { outgoing.push(dep); if (!graph.has(dep.from)) graph.set(dep.from, []); graph.get(dep.from).push(dep.to); this.findDependenciesRecursive(dep.to, direction, depth, incoming, outgoing, visited, graph, currentDepth + 1); } } if (direction === 'incoming' || direction === 'both') { if (dep.to.includes(entityName)) { incoming.push(dep); if (!graph.has(dep.to)) graph.set(dep.to, []); graph.get(dep.to).push(dep.from); this.findDependenciesRecursive(dep.from, direction, depth, incoming, outgoing, visited, graph, currentDepth + 1); } } } } } clearIndexData() { this.indexData.files.clear(); this.indexData.methods.clear(); this.indexData.paths.clear(); this.indexData.dependencies.clear(); } getStats(duration) { return { totalFiles: this.indexData.files.size, totalMethods: this.getAllMethods().length, totalPaths: this.getAllPaths().length, totalDependencies: this.getAllDependencies().length, duration, }; } serializeIndexData() { return { files: Array.from(this.indexData.files.entries()), methods: Array.from(this.indexData.methods.entries()), paths: Array.from(this.indexData.paths.entries()), dependencies: Array.from(this.indexData.dependencies.entries()), lastIndexed: this.indexData.lastIndexed, }; } deserializeIndexData(data) { return { files: new Map(data.files), methods: new Map(data.methods), paths: new Map(data.paths), dependencies: new Map(data.dependencies), lastIndexed: new Date(data.lastIndexed), }; } } //# sourceMappingURL=indexer.js.map