FileScopeMCP

by admica
Verified
import * as fs from 'fs'; import * as fsPromises from 'fs/promises'; import * as path from 'path'; import * as fsSync from "fs"; import { FileNode, PackageDependency, FileTreeConfig } from "./types.js"; import { normalizeAndResolvePath } from "./storage-utils.js"; import { getProjectRoot, getConfig, addExclusionPattern } from './global-state.js'; import { saveFileTree } from './storage-utils.js'; // Import saveFileTree import { log } from './logger.js'; // Import the logger /** * Normalizes a file path for consistent comparison across platforms * Handles Windows and Unix paths, relative and absolute paths */ export function normalizePath(filepath: string): string { if (!filepath) return ''; try { // Handle URL-encoded paths const decoded = filepath.includes('%') ? decodeURIComponent(filepath) : filepath; // Handle Windows paths with drive letters that may start with a slash const cleanPath = decoded.match(/^\/[a-zA-Z]:/) ? decoded.substring(1) : decoded; // Handle Windows backslashes by converting to forward slashes // Note: we need to escape the backslash in regex since it's a special character const forwardSlashed = cleanPath.replace(/\\/g, '/'); // Remove any double quotes that might be present const noQuotes = forwardSlashed.replace(/"/g, ''); // Remove duplicate slashes const deduped = noQuotes.replace(/\/+/g, '/'); // Remove trailing slash return deduped.endsWith('/') ? deduped.slice(0, -1) : deduped; } catch (error) { log(`Failed to normalize path: ${filepath} - ${error}`); // Return original as fallback return filepath; } } export function toPlatformPath(normalizedPath: string): string { return normalizedPath.split('/').join(path.sep); } const SUPPORTED_EXTENSIONS = [".py", ".c", ".cpp", ".h", ".rs", ".lua", ".js", ".jsx", ".ts", ".tsx", ".zig", ".php", ".blade.php", ".phtml"]; const IMPORT_PATTERNS: { [key: string]: RegExp } = { '.js': /(?:import\s+(?:(?:[\w*\s{},]*)\s+from\s+)?["']([^"']+)["'])|(?:require\(["']([^"']+)["']\))|(?:import\s*\(["']([^"']+)["']\))/g, '.jsx': /(?:import\s+(?:[^;]*?)\s+from\s+["']([^"']+)["'])|(?:import\s+["']([^"']+)["'])|(?:require\(["']([^"']+)["']\))|(?:import\s*\(["']([^"']+)["']\))/g, '.ts': /(?:import\s+(?:(?:[\w*\s{},]*)\s+from\s+)?["']([^"']+)["'])|(?:require\(["']([^"']+)["']\))|(?:import\s*\(["']([^"']+)["']\))/g, '.tsx': /(?:import\s+(?:[^;]*?)\s+from\s+["']([^"']+)["'])|(?:import\s+["']([^"']+)["'])|(?:require\(["']([^"']+)["']\))|(?:import\s*\(["']([^"']+)["']\))/g, '.py': /(?:import\s+[\w.]+|from\s+[\w.]+\s+import\s+[\w*]+)/g, '.c': /#include\s+["<][^">]+[">]/g, '.cpp': /#include\s+["<][^">]+[">]/g, '.h': /#include\s+["<][^">]+[">]/g, '.rs': /use\s+[\w:]+|mod\s+\w+/g, '.lua': /require\s*\(['"][^'"]+['"]\)/g, '.zig': /@import\s*\(['"][^'"]+['"]\)|const\s+[\w\s,{}]+\s*=\s*@import\s*\(['"][^'"]+['"]\)/g, '.php': /(?:(?:require|require_once|include|include_once)\s*\(?["']([^"']+)["']\)?)|(?:use\s+([A-Za-z0-9\\]+(?:\s+as\s+[A-Za-z0-9]+)?);)/g, '.blade.php': /@(?:include|extends|component)\s*\(\s*["']([^"']+)["']\s*\)|@(?:include|extends|component)\s*\(\s*["']([^"']+)["']\s*,\s*\[.*?\]\s*\)|@(?:include|extends|component)\s*\(["']([^"']+)["']\)/g, '.phtml': /(?:(?:require|require_once|include|include_once)\s*\(?["']([^"']+)["']\)?)|(?:use\s+([A-Za-z0-9\\]+(?:\s+as\s+[A-Za-z0-9]+)?);)/g }; /** * Utility function to detect unresolved template literals in strings * This helps prevent treating template literals like ${importPath} as actual import paths */ function isUnresolvedTemplateLiteral(str: string): boolean { // Check for ${...} pattern which indicates an unresolved template literal return typeof str === 'string' && str.includes('${') && str.includes('}'); } // Helper to resolve TypeScript/JavaScript import paths function resolveImportPath(importPath: string, currentFilePath: string, baseDir: string): string { log(`Resolving import path: ${importPath} from file: ${currentFilePath}`); // Check if the importPath is an unresolved template literal if (isUnresolvedTemplateLiteral(importPath)) { log(`Warning: Attempting to resolve unresolved template literal: ${importPath}`); // We'll return a special path that's unlikely to exist or cause issues return path.join(baseDir, '_UNRESOLVED_TEMPLATE_PATH_'); } // For TypeScript files, if the import ends with .js, convert it to .ts if (currentFilePath.endsWith('.ts') || currentFilePath.endsWith('.tsx')) { if (importPath.endsWith('.js')) { importPath = importPath.replace(/\.js$/, '.ts'); } } // Handle relative imports if (importPath.startsWith('.')) { const resolvedPath = path.resolve(path.dirname(currentFilePath), importPath); log(`Resolved relative import to: ${resolvedPath}`); return path.normalize(resolvedPath); } // Handle absolute imports (from project root) if (importPath.startsWith('/')) { const resolvedPath = path.join(baseDir, importPath); log(`Resolved absolute import to: ${resolvedPath}`); return path.normalize(resolvedPath); } // Handle package imports const nodeModulesPath = path.join(baseDir, 'node_modules', importPath); log(`Resolved package import to: ${nodeModulesPath}`); return path.normalize(nodeModulesPath); } function calculateInitialImportance(filePath: string, baseDir: string): number { let importance = 0; const ext = path.extname(filePath); const relativePath = path.relative(baseDir, filePath); const parts = relativePath.split(path.sep); const fileName = path.basename(filePath, ext); // Base importance by file type switch (ext) { case '.ts': case '.tsx': importance += 3; break; case '.js': case '.jsx': importance += 2; break; case '.php': // PHP controllers and models are highly important if (fileName.toLowerCase().includes('controller') || fileName.toLowerCase().includes('model')) { importance += 3; } else { importance += 2; } break; case '.blade.php': // Blade layout files are more important than regular views if (fileName.toLowerCase().includes('layout') || fileName.toLowerCase().includes('app')) { importance += 3; } else { importance += 2; } break; case '.json': if (fileName === 'package' || fileName === 'tsconfig' || fileName === 'composer') { importance += 3; } else { importance += 1; } break; case '.md': if (fileName.toLowerCase() === 'readme') { importance += 2; } else { importance += 1; } break; default: importance += 0; } // Importance by location if (parts[0] === 'src' || parts[0] === 'app') { importance += 2; } else if (parts[0] === 'test' || parts[0] === 'tests') { importance += 1; } // Laravel-specific directory importance if (parts.includes('app')) { if (parts.includes('Http') && parts.includes('Controllers')) { importance += 2; } else if (parts.includes('Models')) { importance += 2; } else if (parts.includes('Providers')) { importance += 2; } } // Importance by name significance const significantNames = [ 'index', 'main', 'server', 'app', 'config', 'types', 'utils', 'kernel', 'provider', 'middleware', 'service', 'repository', 'controller', 'model', 'layout', 'master' ]; if (significantNames.includes(fileName.toLowerCase())) { importance += 2; } // Cap importance at 10 return Math.min(importance, 10); } // Helper to extract import path from different import styles function extractImportPath(importStatement: string): string | null { // Try to match dynamic imports first const dynamicMatch = importStatement.match(/import\s*\(["']([^"']+)["']\)/); if (dynamicMatch) { return dynamicMatch[1]; } // Try to match require statements const requireMatch = importStatement.match(/require\(["']([^"']+)["']\)/); if (requireMatch) { return requireMatch[1]; } // Try to match regular imports const importMatch = importStatement.match(/from\s+["']([^"']+)["']/); if (importMatch) { return importMatch[1]; } // Try to match direct imports (like import 'firebase/auth') const directMatch = importStatement.match(/import\s+["']([^"']+)["']/); if (directMatch) { return directMatch[1]; } return null; } // Helper to extract package version from package.json if available async function extractPackageVersion(packageName: string, baseDir: string): Promise<string | undefined> { try { // Handle scoped packages by getting the basic package name let basicPackageName = packageName; if (packageName.startsWith('@')) { // For scoped packages like @supabase/supabase-js, extract the scope part const parts = packageName.split('/'); if (parts.length > 1) { // Keep the scoped name as is basicPackageName = packageName; } } else if (packageName.includes('/')) { // For imports like 'firebase/auth', extract the base package basicPackageName = packageName.split('/')[0]; } const packageJsonPath = path.join(baseDir, 'package.json'); const content = await fsPromises.readFile(packageJsonPath, 'utf-8'); const packageData = JSON.parse(content); // Check both dependencies and devDependencies if (packageData.dependencies && packageData.dependencies[basicPackageName]) { return packageData.dependencies[basicPackageName]; } if (packageData.devDependencies && packageData.devDependencies[basicPackageName]) { return packageData.devDependencies[basicPackageName]; } return undefined; } catch (error) { log(`Failed to extract package version for ${packageName}: ${error}`); return undefined; } } // Helper function to check if a path matches any exclude pattern function isExcluded(filePath: string, baseDir: string): boolean { // Add a failsafe check specifically for .git directory if (filePath.includes('.git') || path.basename(filePath) === '.git') { log(`🔴 SPECIAL CASE: .git directory/file detected: ${filePath}`); return true; } // Add a failsafe check for node_modules if (filePath.includes('node_modules') || path.basename(filePath) === 'node_modules') { log(`🔴 SPECIAL CASE: node_modules directory/file detected: ${filePath}`); return true; } // Add a failsafe check for test_excluded files if (filePath.includes('test_excluded') || path.basename(filePath).startsWith('test_excluded')) { log(`🔴 SPECIAL CASE: test_excluded file detected: ${filePath}`); return true; } log(`\n===== EXCLUDE CHECK for: ${filePath} =====`); const config = getConfig(); if (!config) { log('❌ ERROR: Config is null! Global state not initialized properly.'); return false; } if (!config.excludePatterns || config.excludePatterns.length === 0) { log('❌ WARNING: No exclude patterns found in config!'); log(`Config object: ${JSON.stringify(config, null, 2)}`); return false; } // Get relative path for matching, normalize to forward slashes for cross-platform consistency const relativePath = path.relative(baseDir, filePath).replace(/\\/g, '/'); const fileName = path.basename(filePath); log(`📂 Path details:`); log(` - Full path: ${filePath}`); log(` - Base dir: ${baseDir}`); log(` - Relative path: ${relativePath}`); log(` - File name: ${fileName}`); log(` - Platform: ${process.platform}, path separator: ${path.sep}`); log(`\n🔍 Testing against ${config.excludePatterns.length} exclude patterns...`); // Special case check for .git and node_modules if (relativePath.includes('/.git/') || relativePath === '.git' || fileName === '.git' || relativePath.startsWith('.git/')) { log(`✅ MATCH! Special case for .git directory detected: ${relativePath}`); return true; } if (relativePath.includes('/node_modules/') || relativePath === 'node_modules' || fileName === 'node_modules' || relativePath.startsWith('node_modules/')) { log(`✅ MATCH! Special case for node_modules directory detected: ${relativePath}`); return true; } // Check each exclude pattern for (let i = 0; i < config.excludePatterns.length; i++) { const pattern = config.excludePatterns[i]; log(`\n [${i+1}/${config.excludePatterns.length}] Testing pattern: "${pattern}"`); try { const regex = globToRegExp(pattern); //log(` - Converted to regex: ${regex}`); // Uncomment for debugging // Test against full relative path const fullPathMatch = regex.test(relativePath); //log(` - Match against relative path: ${fullPathMatch ? '✅ YES' : '❌ NO'}`); // Uncomment for debugging if (fullPathMatch) { log(`✅ MATCH! Path ${relativePath} matches exclude pattern ${pattern}`); return true; } // Also test against just the filename for file extension patterns if (pattern.startsWith('**/*.') || pattern.includes('/*.')) { const filenameMatch = regex.test(fileName); //log(` - Match against filename only: ${filenameMatch ? '✅ YES' : '❌ NO'}`); // Uncomment for debugging if (filenameMatch) { log(`✅ MATCH! Filename ${fileName} matches exclude pattern ${pattern}`); return true; } } } catch (error) { log(` - ❌ ERROR converting pattern to regex: ${error}`); } } log(`❌ No pattern matches found for ${relativePath}`); log(`===== END EXCLUDE CHECK =====\n`); return false; } // Helper function to convert glob pattern to RegExp function globToRegExp(pattern: string): RegExp { //log(` Converting glob pattern: ${pattern}`); // Uncomment for debugging // Escape special regex characters except * and ? const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); //log(` - After escaping special chars: ${escaped}`); // Uncomment for debugging // Handle patterns starting with **/ let prefix = ''; if (escaped.startsWith('**/')) { // Make the initial part optional to match root level prefix = '(?:.*/)?'; // Remove the leading **/ from the pattern being converted pattern = escaped.substring(3); } else { // Make the initial part optional for patterns not starting with **/ prefix = '(?:.*/)?'; pattern = escaped; } // Convert glob patterns to regex patterns (applied to the potentially shortened pattern) const converted = pattern // Convert ** to special marker (use a different marker to avoid conflict) .replace(/\*\*/g, '__GLOBSTAR__') // Convert remaining * to [^/\\]* .replace(/\*/g, '[^/\\\\]*') // Convert ? to single character match .replace(/\?/g, '[^/\\\\]') // Convert globstar back to proper pattern .replace(/__GLOBSTAR__/g, '.*'); //log(` - After pattern conversion: ${converted}`); // Uncomment for debugging // Create regex that matches entire path, adding the optional prefix // Ensure the pattern is anchored correctly const finalPattern = `^${prefix}${converted}$`; const regex = new RegExp(finalPattern, 'i'); //log(` - Final regex: ${regex}`); // Uncomment for debugging return regex; } export async function scanDirectory(baseDir: string, currentDir: string = baseDir): Promise<FileNode> { log(`\n📁 SCAN DIRECTORY: ${currentDir}`); log(` - Base dir: ${baseDir}`); // Handle special case for current directory const normalizedBaseDir = path.normalize(baseDir); const normalizedDirPath = path.normalize(currentDir); log(` - Normalized base dir: ${normalizedBaseDir}`); log(` - Normalized current dir: ${normalizedDirPath}`); // Create root node for this directory const rootNode: FileNode = { path: normalizedDirPath, name: path.basename(normalizedDirPath), isDirectory: true, children: [] }; // Read directory entries let entries: fs.Dirent[]; try { entries = await fsPromises.readdir(normalizedDirPath, { withFileTypes: true }); log(` - Read ${entries.length} entries in directory`); } catch (error) { log(` - ❌ Error reading directory ${normalizedDirPath}:`, error); return rootNode; } // Process each entry let excluded = 0; let included = 0; let dirProcessed = 0; let fileProcessed = 0; log(`\n Processing ${entries.length} entries in ${normalizedDirPath}...`); // ==================== CRITICAL CODE ==================== // Log the global config status before processing entries log(`\n🔍 BEFORE PROCESSING: Is config loaded? ${getConfig() !== null ? 'YES ✅' : 'NO ❌'}`); if (getConfig()) { const excludePatternsLength = getConfig()?.excludePatterns?.length || 0; log(` - Exclude patterns count: ${excludePatternsLength}`); if (excludePatternsLength > 0) { log(` - First few patterns: ${getConfig()?.excludePatterns?.slice(0, 3).join(', ')}`); } } // ====================================================== for (const entry of entries) { const fullPath = path.join(normalizedDirPath, entry.name); const normalizedFullPath = path.normalize(fullPath); log(`\n Entry: ${entry.name} (${entry.isDirectory() ? 'directory' : 'file'})`); log(` - Full path: ${normalizedFullPath}`); // Here's the critical exclusion check log(` 🔍 Checking if path should be excluded: ${normalizedFullPath}`); const shouldExclude = isExcluded(normalizedFullPath, normalizedBaseDir); log(` 🔍 Exclusion check result: ${shouldExclude ? 'EXCLUDE ✅' : 'INCLUDE ❌'}`); if (shouldExclude) { log(` - ✅ Skipping excluded path: ${normalizedFullPath}`); excluded++; continue; } log(` - ✅ Including path: ${normalizedFullPath}`); included++; if (entry.isDirectory()) { log(` - Processing directory: ${normalizedFullPath}`); const childNode = await scanDirectory(normalizedBaseDir, fullPath); rootNode.children?.push(childNode); dirProcessed++; } else { log(` - Processing file: ${normalizedFullPath}`); fileProcessed++; const ext = path.extname(entry.name); const importPattern = IMPORT_PATTERNS[ext]; const dependencies: string[] = []; const packageDependencies: PackageDependency[] = []; if (importPattern) { try { const content = await fsPromises.readFile(fullPath, 'utf-8'); const matches = content.match(importPattern); log(`Found ${matches?.length || 0} potential imports in ${normalizedFullPath}`); if (matches) { for (const match of matches) { const importPath = extractImportPath(match); if (importPath) { // Skip if the importPath looks like an unresolved template literal if (isUnresolvedTemplateLiteral(importPath)) { log(`Skipping unresolved template literal: ${importPath}`); continue; } try { let resolvedPath; if (['.js', '.jsx', '.ts', '.tsx'].includes(ext)) { resolvedPath = resolveImportPath(importPath, normalizedFullPath, normalizedBaseDir); } else { resolvedPath = path.resolve(path.dirname(fullPath), importPath); } log(`Resolved path: ${resolvedPath}`); // Handle package imports if (resolvedPath.includes('node_modules') || importPath.startsWith('@') || (!importPath.startsWith('.') && !importPath.startsWith('/'))) { // Create a package dependency object with more information const pkgDep = PackageDependency.fromPath(resolvedPath); // Set the package name directly from the import path if it's empty if (!pkgDep.name) { // Skip if the importPath looks like an unresolved template literal if (isUnresolvedTemplateLiteral(importPath)) { log(`Skipping package dependency with template literal name: ${importPath}`); continue; } // For imports like '@scope/package' if (importPath.startsWith('@')) { const parts = importPath.split('/'); if (parts.length >= 2) { pkgDep.scope = parts[0]; pkgDep.name = `${parts[0]}/${parts[1]}`; } } // For imports like 'package' else if (importPath.includes('/')) { pkgDep.name = importPath.split('/')[0]; } else { pkgDep.name = importPath; } } // Skip if the resolved package name is a template literal if (isUnresolvedTemplateLiteral(pkgDep.name)) { log(`Skipping package with template literal name: ${pkgDep.name}`); continue; } // Try to extract version information if (pkgDep.name) { const version = await extractPackageVersion(pkgDep.name, normalizedBaseDir); if (version) { pkgDep.version = version; } // Check if it's a dev dependency try { const packageJsonPath = path.join(normalizedBaseDir, 'package.json'); const content = await fsPromises.readFile(packageJsonPath, 'utf-8'); const packageData = JSON.parse(content); if (packageData.devDependencies && packageData.devDependencies[pkgDep.name]) { pkgDep.isDevDependency = true; } } catch (error) { // Ignore package.json errors } } packageDependencies.push(pkgDep); continue; } // Try with different extensions for TypeScript/JavaScript files const possibleExtensions = ['.ts', '.tsx', '.js', '.jsx', '']; for (const extension of possibleExtensions) { const pathToCheck = resolvedPath + extension; try { await fsPromises.access(pathToCheck); log(`Found existing path: ${pathToCheck}`); dependencies.push(pathToCheck); break; } catch { // File doesn't exist with this extension, try next one } } } catch (error) { log(`Failed to resolve path for ${importPath}:`, error); } } } } } catch (error) { log(`Failed to read or process file ${fullPath}:`, error); } } const fileNode: FileNode = { path: normalizedFullPath, name: entry.name, isDirectory: false, importance: calculateInitialImportance(normalizedFullPath, normalizedBaseDir), dependencies: dependencies, packageDependencies: packageDependencies, dependents: [], summary: undefined }; rootNode.children?.push(fileNode); } } // Log summary for this directory log(`\n 📊 DIRECTORY SCAN SUMMARY for ${normalizedDirPath}:`); log(` - Total entries: ${entries.length}`); log(` - Excluded: ${excluded}`); log(` - Included: ${included}`); log(` - Directories processed: ${dirProcessed}`); log(` - Files processed: ${fileProcessed}`); log(` 📁 END SCAN DIRECTORY: ${currentDir}\n`); return rootNode; } // Find all file nodes in the tree function getAllFileNodes(root: FileNode): FileNode[] { const results: FileNode[] = []; function traverse(node: FileNode) { if (!node.isDirectory) { results.push(node); } if (node.children) { node.children.forEach(traverse); } } traverse(root); return results; } // Build the reverse dependency map (dependents) export function buildDependentMap(root: FileNode) { const allFiles = getAllFileNodes(root); const pathToNodeMap = new Map<string, FileNode>(); // First, create a map of all file paths to their nodes allFiles.forEach(file => { pathToNodeMap.set(file.path, file); }); // Then, process dependencies to create the reverse mapping allFiles.forEach(file => { if (file.dependencies && file.dependencies.length > 0) { file.dependencies.forEach(depPath => { const depNode = pathToNodeMap.get(depPath); if (depNode) { if (!depNode.dependents) { depNode.dependents = []; } if (!depNode.dependents.includes(file.path)) { depNode.dependents.push(file.path); } } }); } }); } export function calculateImportance(node: FileNode): void { if (!node.isDirectory) { // Start with initial importance let importance = node.importance || calculateInitialImportance(node.path, process.cwd()); // Add importance based on number of dependents (files that import this file) if (node.dependents && node.dependents.length > 0) { importance += Math.min(node.dependents.length, 3); } // Add importance based on number of local dependencies (files this file imports) if (node.dependencies && node.dependencies.length > 0) { importance += Math.min(node.dependencies.length, 2); } // Add importance based on number of package dependencies if (node.packageDependencies && node.packageDependencies.length > 0) { // Add more importance for SDK dependencies const sdkDeps = node.packageDependencies.filter(dep => dep.name && dep.name.includes('@modelcontextprotocol/sdk')); const otherDeps = node.packageDependencies.filter(dep => dep.name && !dep.name.includes('@modelcontextprotocol/sdk')); importance += Math.min(sdkDeps.length, 2); // SDK dependencies are more important importance += Math.min(otherDeps.length, 1); // Other package dependencies } // Cap importance at 10 node.importance = Math.min(importance, 10); } // Recursively calculate importance for children if (node.children) { for (const child of node.children) { calculateImportance(child); } } } // Add a function to manually set importance export function setFileImportance(fileTree: FileNode, filePath: string, importance: number): boolean { const normalizedInputPath = normalizePath(filePath); log(`Setting importance for file: ${normalizedInputPath}`); log(`Current tree root: ${fileTree.path}`); function findAndSetImportance(node: FileNode): boolean { const normalizedNodePath = normalizePath(node.path); log(`Checking node: ${normalizedNodePath}`); // Try exact match if (normalizedNodePath === normalizedInputPath) { log(`Found exact match for: ${normalizedInputPath}`); node.importance = Math.min(10, Math.max(0, importance)); return true; } // Try case-insensitive match for Windows compatibility if (normalizedNodePath.toLowerCase() === normalizedInputPath.toLowerCase()) { log(`Found case-insensitive match for: ${normalizedInputPath}`); node.importance = Math.min(10, Math.max(0, importance)); return true; } // Check if the path ends with our target (to handle relative vs absolute paths) if (normalizedInputPath.endsWith(normalizedNodePath) || normalizedNodePath.endsWith(normalizedInputPath)) { log(`Found path suffix match for: ${normalizedInputPath}`); node.importance = Math.min(10, Math.max(0, importance)); return true; } // Try with basename const inputBasename = normalizedInputPath.split('/').pop() || ''; const nodeBasename = normalizedNodePath.split('/').pop() || ''; if (nodeBasename === inputBasename && nodeBasename !== '') { log(`Found basename match for: ${inputBasename}`); node.importance = Math.min(10, Math.max(0, importance)); return true; } if (node.isDirectory && node.children) { for (const child of node.children) { if (findAndSetImportance(child)) { return true; } } } return false; } return findAndSetImportance(fileTree); } export async function createFileTree(baseDir: string): Promise<FileNode> { const normalizedBaseDir = path.normalize(baseDir); const nodes = await scanDirectory(normalizedBaseDir); // The first node should be the root directory if (nodes.isDirectory && nodes.path === normalizedBaseDir) { return nodes; } // If for some reason we didn't get a root node, create one const rootNode: FileNode = { path: normalizedBaseDir, name: path.basename(normalizedBaseDir), isDirectory: true, children: [] }; // Add all nodes that don't have a parent for (const node of nodes.children || []) { if (path.dirname(node.path) === normalizedBaseDir) { rootNode.children?.push(node); } } return rootNode; } export function getFileImportance(fileTree: FileNode, targetPath: string): FileNode | null { const normalizedInputPath = normalizePath(targetPath); log(`Looking for file: ${normalizedInputPath}`); function findNode(node: FileNode, targetPath: string): FileNode | null { // Normalize paths to handle both forward and backward slashes const normalizedTargetPath = path.normalize(targetPath).toLowerCase(); const normalizedNodePath = path.normalize(node.path).toLowerCase(); if (normalizedNodePath === normalizedTargetPath) { return node; } if (node.children) { for (const child of node.children) { const found = findNode(child, targetPath); if (found) return found; } } return null; } return findNode(fileTree, normalizedInputPath); } /** * Finds a node in the file tree by its absolute path. * @param tree The file tree node to search within. * @param targetPath The absolute path of the node to find. * @returns The found FileNode or null if not found. */ export function findNodeByPath(tree: FileNode | null, targetPath: string): FileNode | null { if (!tree) return null; const normalizedTargetPath = normalizePath(targetPath); const normalizedNodePath = normalizePath(tree.path); // Check the current node if (normalizedNodePath === normalizedTargetPath) { return tree; } // If it's a directory, search its children if (tree.isDirectory && tree.children) { for (const child of tree.children) { const found = findNodeByPath(child, targetPath); if (found) { return found; } } } // Node not found in this subtree return null; } // --- New Functions for Incremental Updates --- // Placeholder for dependency analysis of a single new file // This needs to replicate the relevant logic from scanDirectory async function analyzeNewFile(filePath: string, projectRoot: string): Promise<{ dependencies: string[]; packageDependencies: PackageDependency[] }> { log(`[analyzeNewFile] Analyzing ${filePath}`); const dependencies: string[] = []; const packageDependencies: PackageDependency[] = []; const ext = path.extname(filePath); const pattern = IMPORT_PATTERNS[ext]; if (pattern) { try { const content = await fsPromises.readFile(filePath, 'utf-8'); let match; while ((match = pattern.exec(content)) !== null) { const importPath = match[1] || match[2] || match[3]; // Adjust indices based on specific regex if (importPath) { // Skip if the importPath looks like an unresolved template literal if (isUnresolvedTemplateLiteral(importPath)) { log(`[analyzeNewFile] Skipping unresolved template literal: ${importPath}`); continue; } try { const resolvedPath = resolveImportPath(importPath, filePath, projectRoot); const normalizedResolvedPath = normalizePath(resolvedPath); // Check if it's a package dependency (heuristic: includes node_modules or doesn't start with . or /) if (normalizedResolvedPath.includes('node_modules') || (!importPath.startsWith('.') && !importPath.startsWith('/'))) { const pkgDep = PackageDependency.fromPath(normalizedResolvedPath); // Skip if the package name is a template literal if (isUnresolvedTemplateLiteral(pkgDep.name)) { log(`[analyzeNewFile] Skipping package with template literal name: ${pkgDep.name}`); continue; } const version = await extractPackageVersion(pkgDep.name, projectRoot); if (version) { pkgDep.version = version; } packageDependencies.push(pkgDep); } else { // Attempt to confirm local file exists (you might need more robust checking like in scanDirectory) try { await fsPromises.access(normalizedResolvedPath); dependencies.push(normalizedResolvedPath); } catch { //console.warn(`[analyzeNewFile] Referenced local file not found: ${normalizedResolvedPath}`); } } } catch (resolveError) { log(`[analyzeNewFile] Error resolving import '${importPath}' in ${filePath}: ${resolveError}`); } } } } catch (readError) { log(`[analyzeNewFile] Error reading file ${filePath}: ${readError}`); } } log(`[analyzeNewFile] Found deps for ${filePath}: ${JSON.stringify({ dependencies, packageDependencies })}`); return { dependencies, packageDependencies }; } /** * Incrementally adds a new file node to the global file tree. * Analyzes the new file, calculates its importance, and updates relevant dependents. * Must be called with the currently active file tree and its config. * @param filePath The absolute path of the file to add. * @param activeFileTree The currently active FileNode tree. * @param activeProjectRoot The project root directory. */ export async function addFileNode( filePath: string, activeFileTree: FileNode, activeProjectRoot: string ): Promise<void> { const normalizedFilePath = normalizePath(filePath); // Removed reliance on getConfig() here log(`[addFileNode] Attempting to add file: ${normalizedFilePath} to tree rooted at ${activeFileTree.path}`); // 1. Find the parent directory node within the provided active tree const parentDir = path.dirname(normalizedFilePath); const parentNode = findNodeByPath(activeFileTree, parentDir); if (!parentNode || !parentNode.isDirectory) { log(`[addFileNode] Could not find parent directory node for: ${normalizedFilePath}`); // Optionally: Handle cases where intermediate directories might also need creation return; } // 2. Check if node already exists (should not happen if watcher is correct, but good practice) if (parentNode.children?.some(child => normalizePath(child.path) === normalizedFilePath)) { log(`[addFileNode] Node already exists: ${normalizedFilePath}`); return; } try { // 3. Create the new FileNode (Removed size, createdAt, modifiedAt) const newNode = new FileNode(); // Use class constructor newNode.path = normalizedFilePath; newNode.name = path.basename(normalizedFilePath); newNode.isDirectory = false; newNode.dependencies = []; // Initialize as empty arrays newNode.packageDependencies = []; newNode.dependents = []; newNode.summary = ''; // 4. Analyze the new file's content for dependencies // Use the placeholder analysis function const { dependencies, packageDependencies } = await analyzeNewFile(normalizedFilePath, activeProjectRoot); newNode.dependencies = dependencies; newNode.packageDependencies = packageDependencies; // 5. Calculate initial importance for the new node // Use the existing calculateInitialImportance function newNode.importance = calculateInitialImportance(newNode.path, activeProjectRoot); // 6. Add the new node to the parent's children if (!parentNode.children) { parentNode.children = []; } parentNode.children.push(newNode); parentNode.children.sort((a, b) => a.name.localeCompare(b.name)); // Keep sorted // 7. Update dependents lists of the files imported by the new node await updateDependentsForNewNode(newNode, activeFileTree); // Pass active tree // 8. Recalculate importance for affected nodes (new node and its dependencies) // Ensure dependencies is an array before mapping const depPaths = (newNode.dependencies ?? []).map(d => normalizePath(d)); await recalculateImportanceForAffected([newNode.path, ...depPaths], activeFileTree, activeProjectRoot); // Pass active tree & root // 9. Global state update is handled by the caller (mcp-server) after saving log(`[addFileNode] Successfully added node: ${normalizedFilePath}`); } catch (error: any) { if (error.code === 'ENOENT') { log(`[addFileNode] File not found during add operation (might have been deleted quickly): ${normalizedFilePath}`); } else { log(`[addFileNode] Error adding file node ${normalizedFilePath}:`, error); } } } /** * Incrementally removes a file node from the global file tree. * Updates dependents of the removed file and the files it depended on. * Must be called with the currently active file tree. * @param filePath The absolute path of the file to remove. * @param activeFileTree The currently active FileNode tree. * @param activeProjectRoot The project root directory. */ export async function removeFileNode( filePath: string, activeFileTree: FileNode, activeProjectRoot: string ): Promise<void> { // Check if filePath is a relative path, and if so, resolve it to an absolute path let absoluteFilePath = filePath; if (!path.isAbsolute(filePath)) { absoluteFilePath = path.join(activeProjectRoot, filePath); log(`[removeFileNode] Converted relative path "${filePath}" to absolute path "${absoluteFilePath}"`); } const normalizedFilePath = normalizePath(absoluteFilePath); log(`[removeFileNode] Attempting to remove file: ${normalizedFilePath} from tree rooted at ${activeFileTree.path}`); // Log the current state of the file tree - fix this by converting to string // log(`Current file tree state before removal: ${JSON.stringify(activeFileTree, null, 2)}`); // 1. Find the node to remove within the provided active tree const nodeToRemove = findNodeByPath(activeFileTree, normalizedFilePath); // If node not found, try looking it up by basename as a fallback if (!nodeToRemove || nodeToRemove.isDirectory) { log(`[removeFileNode] Initial search failed for: ${normalizedFilePath}`); // Fallback: Find by basename in case of relative path issues const basename = path.basename(normalizedFilePath); log(`[removeFileNode] Trying fallback search by basename: ${basename}`); // Get all file nodes and search by basename const allFileNodes = getAllFileNodes(activeFileTree); const nodeByName = allFileNodes.find(node => !node.isDirectory && path.basename(node.path) === basename ); if (nodeByName) { log(`[removeFileNode] Found node by basename: ${nodeByName.path}`); // Call removeFileNode recursively with the found absolute path return removeFileNode(nodeByName.path, activeFileTree, activeProjectRoot); } // If still not found, report an error log(`[removeFileNode] File node not found or is a directory: ${normalizedFilePath}`); return; } log(`[removeFileNode] Found node to remove: ${nodeToRemove.path}`); // 2. Find the parent directory node within the provided active tree const parentDir = path.dirname(normalizedFilePath); const parentNode = findNodeByPath(activeFileTree, parentDir); if (!parentNode || !parentNode.isDirectory || !parentNode.children) { log(`[removeFileNode] Could not find parent directory node for: ${normalizedFilePath}`); return; } log(`[removeFileNode] Found parent node: ${parentNode.path}`); // 3. Store necessary info before removal (Ensure arrays exist) const dependenciesToRemoveFrom = [...(nodeToRemove.dependencies ?? [])]; const dependentsToUpdate = [...(nodeToRemove.dependents ?? [])]; // Files that depended on this node // 4. Remove the node from its parent's children array const index = parentNode.children.findIndex(child => normalizePath(child.path) === normalizedFilePath); if (index > -1) { parentNode.children.splice(index, 1); log(`[removeFileNode] Node removed from parent's children: ${normalizedFilePath}`); } else { log(`[removeFileNode] Node not found in parent's children: ${normalizedFilePath}`); // Continue removal process anyway, as the node might be detached elsewhere } // 5. Update the 'dependents' list of files the removed node imported await updateDependentsAfterRemoval(nodeToRemove, activeFileTree); // Pass active tree // 6. Update the 'dependencies' list of files that imported the removed node await updateDependersAfterRemoval(nodeToRemove, activeFileTree); // Pass active tree // 7. Recalculate importance for affected nodes (dependents and dependencies) const affectedPaths = [ ...(dependenciesToRemoveFrom ?? []).map(d => normalizePath(d)), ...(dependentsToUpdate ?? []).map(depPath => normalizePath(depPath)) ]; await recalculateImportanceForAffected(affectedPaths, activeFileTree, activeProjectRoot); // Pass active tree & root // 8. Global state update is handled by the caller (mcp-server) after saving log(`[removeFileNode] Successfully removed node: ${normalizedFilePath}`); } // --- Helper / Placeholder Functions for Incremental Updates --- /** * Calculates the importance of a node, considering dependents and dependencies. * This adapts the existing `calculateImportance` logic for targeted recalculation. */ function calculateNodeImportance(node: FileNode, projectRoot: string): number { // Use existing initial calculation let importance = calculateInitialImportance(node.path, projectRoot); // Add importance based on number of dependents (files that import this file) const dependentsCount = node.dependents?.length ?? 0; if (dependentsCount > 0) { importance += Math.min(dependentsCount, 3); } // Add importance based on number of local dependencies (files this file imports) const localDepsCount = node.dependencies?.length ?? 0; if (localDepsCount > 0) { importance += Math.min(localDepsCount, 2); } // Add importance based on number of package dependencies const pkgDeps = node.packageDependencies ?? []; if (pkgDeps.length > 0) { const sdkDeps = pkgDeps.filter(dep => dep.name?.includes('@modelcontextprotocol/sdk')); const otherDeps = pkgDeps.filter(dep => !dep.name?.includes('@modelcontextprotocol/sdk')); importance += Math.min(sdkDeps.length, 2); // SDK dependencies are more important importance += Math.min(otherDeps.length, 1); // Other package dependencies } // Cap importance at 10 return Math.min(10, Math.max(0, Math.round(importance))); } /** * Updates the 'dependents' list of nodes that the new node imports. * @param newNode The node that was just added. * @param activeFileTree The tree to search within. */ async function updateDependentsForNewNode(newNode: FileNode, activeFileTree: FileNode): Promise<void> { log(`[updateDependentsForNewNode] Updating dependents for new node ${newNode.path}`); // Removed reliance on getConfig() // Ensure dependencies is an array for (const depPath of (newNode.dependencies ?? [])) { const depNode = findNodeByPath(activeFileTree, depPath); // depPath is already string if (depNode && !depNode.isDirectory) { // Ensure dependents is an array if (!depNode.dependents) { depNode.dependents = []; } if (!depNode.dependents.includes(newNode.path)) { depNode.dependents.push(newNode.path); log(`[updateDependentsForNewNode] Added ${newNode.path} as dependent for ${depNode.path}`); } } else { // console.warn(`[updateDependentsForNewNode] Dependency node not found or is directory: ${depPath}`); } } // Package dependencies don't have dependents lists in our model } /** * Updates the 'dependents' list of nodes that the removed node imported. * @param removedNode The node that was removed. * @param activeFileTree The tree to search within. */ async function updateDependentsAfterRemoval(removedNode: FileNode, activeFileTree: FileNode): Promise<void> { log(`[updateDependentsAfterRemoval] Updating dependents after removing ${removedNode.path}`); // Removed reliance on getConfig() // Ensure dependencies is an array for (const depPath of (removedNode.dependencies ?? [])) { const depNode = findNodeByPath(activeFileTree, depPath); // depPath is string if (depNode && !depNode.isDirectory) { // Ensure dependents is an array before searching/splicing if (depNode.dependents) { const index = depNode.dependents.indexOf(removedNode.path); if (index > -1) { depNode.dependents.splice(index, 1); log(`[updateDependentsAfterRemoval] Removed ${removedNode.path} from dependents of ${depNode.path}`); } } } } } /** * Updates the 'dependencies' list of nodes that imported the removed node. * @param removedNode The node that was removed. * @param activeFileTree The tree to search within. */ async function updateDependersAfterRemoval(removedNode: FileNode, activeFileTree: FileNode): Promise<void> { log(`[updateDependersAfterRemoval] Updating dependers after removing ${removedNode.path}`); // Removed reliance on getConfig() // Ensure dependents is an array for (const dependentPath of (removedNode.dependents ?? [])) { const dependerNode = findNodeByPath(activeFileTree, dependentPath); if (dependerNode && !dependerNode.isDirectory) { // Ensure dependencies is an array before searching/splicing if (dependerNode.dependencies) { const normalizedRemovedPath = normalizePath(removedNode.path); const index = dependerNode.dependencies.findIndex(d => normalizePath(d) === normalizedRemovedPath); if (index > -1) { dependerNode.dependencies.splice(index, 1); log(`[updateDependersAfterRemoval] Removed dependency on ${removedNode.path} from ${dependerNode.path}`); } } } } } /** * Recalculates importance for a specific set of affected nodes. * @param affectedPaths Array of absolute paths for nodes needing recalculation. * @param activeFileTree The tree to search/update within. * @param activeProjectRoot The project root directory. */ async function recalculateImportanceForAffected( affectedPaths: string[], activeFileTree: FileNode, activeProjectRoot: string ): Promise<void> { log(`[recalculateImportanceForAffected] Recalculating importance for paths: ${JSON.stringify(affectedPaths)}`); // Removed reliance on getConfig() const uniquePaths = [...new Set(affectedPaths)]; // Ensure uniqueness for (const filePath of uniquePaths) { const node = findNodeByPath(activeFileTree, filePath); if (node && !node.isDirectory) { const oldImportance = node.importance; // Use the corrected importance calculation function node.importance = calculateNodeImportance(node, activeProjectRoot); if(oldImportance !== node.importance) { log(`[recalculateImportanceForAffected] Importance for ${node.path} changed from ${oldImportance} to ${node.importance}`); // Potential future enhancement: trigger recursive recalculation if importance changed significantly } } else { // console.warn(`[recalculateImportanceForAffected] Node not found or is directory during recalculation: ${filePath}`); } } } // --- End of New Functions --- /** * Recursively calculates importance scores for all file nodes in the tree. * Uses calculateNodeImportance for individual node calculation. */ export async function excludeAndRemoveFile(filePath: string, activeFileTree: FileNode, activeProjectRoot: string): Promise<void> { // Normalize the file path let absoluteFilePath = filePath; if (!path.isAbsolute(filePath)) { absoluteFilePath = path.join(activeProjectRoot, filePath); log(`[excludeAndRemoveFile] Converted relative path "${filePath}" to absolute path "${absoluteFilePath}"`); } const normalizedFilePath = normalizePath(absoluteFilePath); log(`[excludeAndRemoveFile] Excluding and removing file: ${normalizedFilePath}`); // Add the file path to the exclusion patterns - use basename pattern to exclude anywhere it appears const basenamePattern = `**/${path.basename(normalizedFilePath)}`; log(`[excludeAndRemoveFile] Adding exclusion pattern: ${basenamePattern}`); addExclusionPattern(basenamePattern); // Remove the file node from the file tree await removeFileNode(normalizedFilePath, activeFileTree, activeProjectRoot); log(`[excludeAndRemoveFile] File removed from tree and added to exclusion patterns: ${normalizedFilePath}`); }
ID: mcrren8xsa