UNPKG

@claude-vector/core

Version:

Core vector search engine for code intelligence

503 lines (433 loc) 15.4 kB
/** * FileChangeDetector - ファイル変更検出と差分更新管理システム * * 機能: * - 既存インデックスとファイルシステムの比較 * - 変更されたファイルの検出 * - 新規・削除・更新ファイルの分類 * - インデックス更新の最適化 */ import fs from 'fs/promises'; import fsSync from 'fs'; import path from 'path'; import { glob } from 'glob'; import { EventEmitter } from 'events'; import crypto from 'crypto'; export class FileChangeDetector extends EventEmitter { constructor(options = {}) { super(); this.options = { // ファイルパターン(Advanced Vector Engineと統一) includePatterns: options.includePatterns || [ '**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx', '**/*.py', '**/*.java', '**/*.cpp', '**/*.c', '**/*.cs', '**/*.go', '**/*.rs', '**/*.php', '**/*.rb', '**/*.swift', '**/*.kt', '**/*.scala', '**/*.md', '**/*.txt', '**/*.json', '**/*.yml', '**/*.yaml' ], excludePatterns: options.excludePatterns || [ 'node_modules/**', '.git/**', 'dist/**', 'build/**', 'coverage/**', '**/*.min.js', '**/*.bundle.js', '.next/**', '.nuxt/**', 'vendor/**', '__pycache__/**', '*.pyc', '.claude-*/**', '.cache/**', 'tmp/**', 'temp/**', '*.log', '*.tmp', '*.bak', '.DS_Store', 'Thumbs.db' ], // 検出設定 checkMtime: options.checkMtime ?? true, checkContent: options.checkContent ?? true, // コンテンツハッシュベース判定を有効化 checksumAlgorithm: options.checksumAlgorithm || 'md5', // パフォーマンス設定 maxFileSize: options.maxFileSize || 10 * 1024 * 1024, // 10MB batchSize: options.batchSize || 100, useCache: options.useCache ?? true, // ログ設定 verboseLogging: options.verboseLogging ?? false, ...options }; this.fileCache = new Map(); this.lastScanTime = null; this.statsCache = null; } /** * プロジェクトのファイル変更を検出 */ async detectChanges(projectRoot, indexPath) { const startTime = Date.now(); if (this.options.verboseLogging) { console.log('🔍 Detecting file changes...'); } // 既存インデックスの読み込み const existingIndex = await this.loadExistingIndex(indexPath); // 現在のファイルシステムの状態を取得 const currentFiles = await this.scanProjectFiles(projectRoot); // 変更の分析 const changes = this.analyzeChanges(existingIndex, currentFiles); const scanTime = Date.now() - startTime; const result = { changes, stats: { totalFiles: currentFiles.size, newFiles: changes.added.length, modifiedFiles: changes.modified.length, deletedFiles: changes.deleted.length, unchangedFiles: changes.unchanged.length, scanTime }, indexExists: existingIndex !== null, requiresUpdate: changes.added.length > 0 || changes.modified.length > 0 || changes.deleted.length > 0 }; if (this.options.verboseLogging) { console.log(`📊 Change detection completed in ${scanTime}ms`); console.log(` New: ${result.stats.newFiles}, Modified: ${result.stats.modifiedFiles}, Deleted: ${result.stats.deletedFiles}`); } this.emit('changes-detected', result); return result; } /** * 既存インデックスの読み込み */ async loadExistingIndex(indexPath) { try { const chunksPath = path.join(indexPath, 'chunks.json'); const metadataPath = path.join(indexPath, 'metadata.json'); if (!fsSync.existsSync(chunksPath)) { return null; } const chunksData = await fs.readFile(chunksPath, 'utf-8'); const chunks = JSON.parse(chunksData); // メタデータの読み込み(存在する場合) let metadata = {}; if (fsSync.existsSync(metadataPath)) { const metadataData = await fs.readFile(metadataPath, 'utf-8'); metadata = JSON.parse(metadataData); } // ファイルベースのインデックス構築 const fileIndex = new Map(); // metadata.jsonのfilesオブジェクトからファイル情報を取得 if (metadata.files) { for (const [filePath, fileInfo] of Object.entries(metadata.files)) { fileIndex.set(filePath, { path: filePath, chunks: [], lastModified: fileInfo.lastModified || null, checksum: fileInfo.checksum || null, size: fileInfo.size || null, chunkCount: fileInfo.chunkCount || 0 }); } } else { // フォールバック: チャンクからファイル情報を構築(旧形式との互換性) for (const chunk of chunks) { if (chunk.metadata && chunk.metadata.file) { const filePath = chunk.metadata.file; if (!fileIndex.has(filePath)) { fileIndex.set(filePath, { path: filePath, chunks: [], lastModified: chunk.metadata.lastModified || null, checksum: chunk.metadata.checksum || null, size: chunk.metadata.size || null }); } fileIndex.get(filePath).chunks.push(chunk); } } } return { files: fileIndex, metadata, indexedAt: metadata.indexedAt || Date.now() }; } catch (error) { if (this.options.verboseLogging) { console.warn('⚠️ Failed to load existing index:', error.message); } return null; } } /** * プロジェクトファイルのスキャン */ async scanProjectFiles(projectRoot) { const files = new Map(); try { // glob パターンでファイルを検索 const globPattern = this.options.includePatterns.length > 1 ? `{${this.options.includePatterns.join(',')}}` : this.options.includePatterns[0]; const foundFiles = await glob(globPattern, { cwd: projectRoot, ignore: this.options.excludePatterns, absolute: true, nodir: true }); // ファイル情報の並列取得 const batchSize = this.options.batchSize; for (let i = 0; i < foundFiles.length; i += batchSize) { const batch = foundFiles.slice(i, i + batchSize); const fileInfos = await Promise.all( batch.map(async (filePath) => { try { const stats = await fs.stat(filePath); // ファイルサイズチェック if (stats.size > this.options.maxFileSize) { if (this.options.verboseLogging) { console.warn(`⚠️ Skipping large file: ${path.relative(projectRoot, filePath)} (${(stats.size / 1024 / 1024).toFixed(2)}MB)`); } return null; } const fileInfo = { path: filePath, size: stats.size, mtime: stats.mtime, lastModified: stats.mtime.getTime(), checksum: null }; // チェックサム計算(コンテンツベース判定のため常に計算) if (this.options.checkContent) { fileInfo.checksum = await this.calculateFileChecksum(filePath); } return fileInfo; } catch (error) { if (this.options.verboseLogging) { console.warn(`⚠️ Failed to stat file: ${filePath}`, error.message); } return null; } }) ); // 成功したファイル情報をマップに追加 for (const fileInfo of fileInfos) { if (fileInfo) { files.set(fileInfo.path, fileInfo); } } } } catch (error) { console.error('❌ Failed to scan project files:', error); throw error; } return files; } /** * ファイルチェックサムの計算 */ async calculateFileChecksum(filePath) { try { const content = await fs.readFile(filePath); const hash = crypto.createHash(this.options.checksumAlgorithm); hash.update(content); return hash.digest('hex'); } catch (error) { if (this.options.verboseLogging) { console.warn(`⚠️ Failed to calculate checksum for ${filePath}:`, error.message); } return null; } } /** * 変更の分析 */ analyzeChanges(existingIndex, currentFiles) { const changes = { added: [], modified: [], deleted: [], unchanged: [] }; // 既存インデックスがない場合は全て新規 if (!existingIndex) { changes.added = Array.from(currentFiles.values()); return changes; } const existingFiles = existingIndex.files; if (this.options.verboseLogging) { console.log(`🔍 Analyzing changes:`); console.log(` Current files: ${currentFiles.size}`); console.log(` Existing files: ${existingFiles.size}`); } // 現在のファイルと既存インデックスの比較 for (const [filePath, currentFile] of currentFiles) { const existingFile = existingFiles.get(filePath); if (!existingFile) { // デバッグ:なぜ新規と判定されたかログ出力 if (this.options.verboseLogging) { console.log(`❌ File not found in existing index: ${path.relative(process.cwd(), filePath)}`); // 似たようなパスが存在するかチェック const basename = path.basename(filePath); for (const [existingPath] of existingFiles) { if (path.basename(existingPath) === basename) { console.log(` 💡 Similar file exists: ${path.relative(process.cwd(), existingPath)}`); break; } } } changes.added.push(currentFile); } else { // 変更チェック const isModified = this.isFileModified(existingFile, currentFile); if (isModified) { changes.modified.push({ ...currentFile, previousVersion: existingFile }); } else { changes.unchanged.push(currentFile); } } } // 削除されたファイルの検出 for (const [filePath, existingFile] of existingFiles) { if (!currentFiles.has(filePath)) { changes.deleted.push(existingFile); } } return changes; } /** * ファイルの変更判定 */ isFileModified(existingFile, currentFile) { // 既存ファイルにメタデータが不足している場合の特別処理 if (!existingFile.lastModified || !existingFile.checksum) { if (this.options.verboseLogging) { console.log(`⚠️ File ${existingFile.path} missing metadata (lastModified: ${!!existingFile.lastModified}, checksum: ${!!existingFile.checksum})`); } // メタデータが不足している場合は、サイズのみで判定 return existingFile.size !== currentFile.size; } // 更新時刻による判定 if (this.options.checkMtime) { if (existingFile.lastModified && currentFile.lastModified) { if (currentFile.lastModified > existingFile.lastModified) { return true; } } } // ファイルサイズによる判定 if (existingFile.size !== currentFile.size) { return true; } // チェックサムによる判定(オプション) if (this.options.checkContent) { if (existingFile.checksum && currentFile.checksum) { if (existingFile.checksum !== currentFile.checksum) { return true; } } } return false; } /** * 高速変更チェック(更新時刻のみ) */ async quickChangeCheck(projectRoot, indexPath) { const startTime = Date.now(); try { // インデックスの最終更新時刻を取得 const indexStats = await fs.stat(path.join(indexPath, 'chunks.json')); const indexTime = indexStats.mtime.getTime(); // プロジェクトファイルの最新更新時刻を取得 const latestFileTime = await this.getLatestFileTime(projectRoot); const hasChanges = latestFileTime > indexTime; const checkTime = Date.now() - startTime; if (this.options.verboseLogging) { console.log(`⚡ Quick change check completed in ${checkTime}ms`); console.log(` Index time: ${new Date(indexTime).toISOString()}`); console.log(` Latest file time: ${new Date(latestFileTime).toISOString()}`); console.log(` Has changes: ${hasChanges}`); } return { hasChanges, indexTime, latestFileTime, checkTime }; } catch (error) { if (this.options.verboseLogging) { console.warn('⚠️ Quick change check failed:', error.message); } return { hasChanges: true, checkTime: Date.now() - startTime }; } } /** * 最新ファイル更新時刻の取得 */ async getLatestFileTime(projectRoot) { const globPattern = this.options.includePatterns.length > 1 ? `{${this.options.includePatterns.join(',')}}` : this.options.includePatterns[0]; const foundFiles = await glob(globPattern, { cwd: projectRoot, ignore: this.options.excludePatterns, absolute: true, nodir: true }); let latestTime = 0; for (const filePath of foundFiles) { try { const stats = await fs.stat(filePath); const fileTime = stats.mtime.getTime(); if (fileTime > latestTime) { latestTime = fileTime; } } catch (error) { // ファイルが存在しない場合は無視 } } return latestTime; } /** * 変更サマリーの生成 */ generateChangeSummary(changes) { const summary = { totalChanges: changes.added.length + changes.modified.length + changes.deleted.length, newFiles: changes.added.length, modifiedFiles: changes.modified.length, deletedFiles: changes.deleted.length, unchangedFiles: changes.unchanged.length }; // 変更されたファイルの詳細 summary.details = { added: changes.added.map(f => path.basename(f.path)), modified: changes.modified.map(f => path.basename(f.path)), deleted: changes.deleted.map(f => path.basename(f.path)) }; return summary; } /** * 統計情報の取得 */ getStats() { return { lastScanTime: this.lastScanTime, cacheSize: this.fileCache.size, options: this.options }; } /** * キャッシュのクリア */ clearCache() { this.fileCache.clear(); this.statsCache = null; this.lastScanTime = null; } }