UNPKG

@claude-vector/core

Version:

Core vector search engine for code intelligence

979 lines (826 loc) 31.4 kB
/** * Incremental Indexer - リアルタイム増分インデックス更新システム * * 機能: * - ファイル変更の監視とリアルタイム更新 * - 影響分析による効率的な部分更新 * - 依存関係グラフの動的更新 * - 差分ベースのエンベディング更新 */ import fs from 'fs/promises'; import fsSync from 'fs'; import path from 'path'; import { watch } from 'fs'; import { EventEmitter } from 'events'; import crypto from 'crypto'; import { SemanticChunker } from './semantic-chunker.js'; import { AIMetadataManager } from './ai-metadata-manager.js'; import { VectorSearchEngine } from './engine.js'; export class IncrementalIndexer extends EventEmitter { constructor(options = {}) { super(); this.options = { watchPatterns: options.watchPatterns || ['**/*.{js,jsx,ts,tsx,md}'], ignorePatterns: options.ignorePatterns || ['node_modules/**', '.git/**', 'dist/**'], debounceMs: options.debounceMs || 500, batchSize: options.batchSize || 10, enableRealtime: options.enableRealtime ?? true, indexPath: options.indexPath || '.claude-code-index', backupEnabled: options.backupEnabled ?? true, ...options }; this.semanticChunker = new SemanticChunker(); this.metadataManager = new AIMetadataManager(); this.vectorEngine = new VectorSearchEngine(); this.watchers = new Map(); this.changeQueue = []; this.processingQueue = false; this.debounceTimers = new Map(); this.dependencyGraph = new Map(); this.indexData = { embeddings: [], chunks: [], metadata: {}, relationships: new Map() }; this.isInitialized = false; } /** * 増分インデクサーの初期化 */ async initialize(projectRoot) { this.projectRoot = projectRoot; this.indexPath = path.join(projectRoot, this.options.indexPath); // 既存インデックスの読み込み await this.loadExistingIndex(); // 依存関係グラフの構築 await this.buildDependencyGraph(); // ファイル監視の開始 if (this.options.enableRealtime) { await this.startWatching(); } this.isInitialized = true; this.emit('initialized', { projectRoot, indexPath: this.indexPath }); console.log('✅ Incremental Indexer initialized'); } /** * 既存インデックスの読み込み */ async loadExistingIndex() { try { const embeddingsPath = path.join(this.indexPath, 'embeddings.json'); const chunksPath = path.join(this.indexPath, 'chunks.json'); const metadataPath = path.join(this.indexPath, 'metadata.json'); if (fsSync.existsSync(embeddingsPath) && fsSync.existsSync(chunksPath)) { console.log('📂 Loading existing index...'); const [embeddingsData, chunksData, metadataData] = await Promise.all([ fs.readFile(embeddingsPath, 'utf-8'), fs.readFile(chunksPath, 'utf-8'), fsSync.existsSync(metadataPath) ? fs.readFile(metadataPath, 'utf-8') : Promise.resolve('{}') ]); this.indexData.embeddings = JSON.parse(embeddingsData); this.indexData.chunks = JSON.parse(chunksData); this.indexData.metadata = JSON.parse(metadataData); console.log(`📊 Loaded ${this.indexData.chunks.length} existing chunks`); } else { console.log('📝 No existing index found, starting fresh'); } } catch (error) { console.warn('⚠️ Failed to load existing index:', error.message); // エラーの場合は空のインデックスから開始 this.indexData = { embeddings: [], chunks: [], metadata: {}, relationships: new Map() }; } } /** * 依存関係グラフの構築 */ async buildDependencyGraph() { console.log('🔗 Building dependency graph...'); for (const chunk of this.indexData.chunks) { if (chunk.metadata && chunk.metadata.relationships) { const chunkId = chunk.metadata.id || this.generateChunkId(chunk.metadata.file, chunk.metadata.startLine); // 依存関係の追加 if (chunk.metadata.relationships.dependencies) { chunk.metadata.relationships.dependencies.forEach(dep => { if (!this.dependencyGraph.has(dep)) { this.dependencyGraph.set(dep, new Set()); } this.dependencyGraph.get(dep).add(chunkId); }); } } } console.log(`🔗 Built dependency graph with ${this.dependencyGraph.size} nodes`); } /** * ファイル監視の開始 */ async startWatching() { console.log('👀 Starting file watching...'); const watchOptions = { recursive: true, persistent: true }; try { const watcher = watch(this.projectRoot, watchOptions, (eventType, filename) => { if (filename && this.shouldWatchFile(filename)) { this.handleFileChange(eventType, filename); } }); this.watchers.set(this.projectRoot, watcher); console.log('👀 File watching started'); } catch (error) { console.warn('⚠️ Failed to start file watching:', error.message); } } /** * ファイル監視対象判定 */ shouldWatchFile(filename) { // 無視パターンのチェック for (const pattern of this.options.ignorePatterns) { if (this.matchPattern(filename, pattern)) { return false; } } // 監視パターンのチェック for (const pattern of this.options.watchPatterns) { if (this.matchPattern(filename, pattern)) { return true; } } return false; } /** * 簡単なパターンマッチング */ matchPattern(filename, pattern) { // 簡略化されたglob風パターンマッチング const regexPattern = pattern .replace(/\*\*/g, '.*') .replace(/\*/g, '[^/]*') .replace(/\./g, '\\.'); const regex = new RegExp(regexPattern); return regex.test(filename); } /** * ファイル変更の処理 */ handleFileChange(eventType, filename) { const fullPath = path.join(this.projectRoot, filename); // デバウンス処理 if (this.debounceTimers.has(fullPath)) { clearTimeout(this.debounceTimers.get(fullPath)); } const timer = setTimeout(() => { this.queueFileUpdate(eventType, fullPath); this.debounceTimers.delete(fullPath); }, this.options.debounceMs); this.debounceTimers.set(fullPath, timer); } /** * ファイル更新のキューイング */ queueFileUpdate(eventType, filePath) { this.changeQueue.push({ eventType, filePath, timestamp: Date.now() }); this.emit('file-changed', { eventType, filePath }); // キューの処理開始 if (!this.processingQueue) { this.processChangeQueue(); } } /** * 変更キューの処理 */ async processChangeQueue() { if (this.processingQueue || this.changeQueue.length === 0) { return; } this.processingQueue = true; try { // バッチ処理 const batch = this.changeQueue.splice(0, this.options.batchSize); console.log(`🔄 Processing ${batch.length} file changes...`); for (const change of batch) { await this.processFileChange(change); } // インデックスの保存 await this.saveIndex(); this.emit('batch-processed', { count: batch.length }); } catch (error) { console.error('❌ Error processing change queue:', error); this.emit('error', error); } finally { this.processingQueue = false; // 残りのキューがある場合は続行 if (this.changeQueue.length > 0) { setTimeout(() => this.processChangeQueue(), 100); } } } /** * 個別ファイル変更の処理 */ async processFileChange(change) { const { eventType, filePath } = change; console.log(`📝 Processing ${eventType}: ${path.relative(this.projectRoot, filePath)}`); switch (eventType) { case 'change': await this.updateFile(filePath); break; case 'rename': // ファイルの存在確認 if (fsSync.existsSync(filePath)) { await this.addFile(filePath); } else { await this.removeFile(filePath); } break; default: console.log(`🔄 Unhandled event type: ${eventType}`); } } /** * ファイルの更新(真の差分更新) */ async updateFile(filePath) { try { const content = await fs.readFile(filePath, 'utf-8'); // ファイル統計情報を取得 const stats = await fs.stat(filePath); const fileStats = { lastModified: stats.mtime.getTime(), size: stats.size, checksum: crypto.createHash('md5').update(content).digest('hex') }; // 既存チャンクの取得(削除しない) const existingChunks = this.indexData.chunks.filter(chunk => chunk.metadata.file === filePath ); const existingIndices = this.indexData.chunks .map((chunk, index) => ({ chunk, index })) .filter(item => item.chunk.metadata.file === filePath) .map(item => item.index); // 新しいチャンクの作成 const newChunks = await this.semanticChunker.chunkFile(filePath, content); // チャンクの差分比較 const { added, modified, unchanged } = this.compareChunks(existingChunks, newChunks); let addedCount = 0; let modifiedCount = 0; let reusedCount = 0; let removedCount = 0; // 使用済みインデックスの追跡 const usedIndices = new Set(); // 変更されていないチャンクは再利用 for (const { existing, updated } of unchanged) { const index = existingIndices.find(i => this.isSameChunk(this.indexData.chunks[i], existing) ); if (index !== undefined) { // メタデータのみ更新(エンベディングは再利用) this.indexData.chunks[index] = await this.createEnhancedChunk(updated, filePath, fileStats); usedIndices.add(index); reusedCount++; } } // 変更されたチャンクの更新 for (const { existing, updated } of modified) { const index = existingIndices.find(i => this.isSameChunk(this.indexData.chunks[i], existing) ); if (index !== undefined) { // チャンクとエンベディングの更新 const enhancedChunk = await this.createEnhancedChunk(updated, filePath, fileStats); const embedding = await this.embeddingGenerator.generateEmbedding(updated.content); this.indexData.chunks[index] = enhancedChunk; this.indexData.embeddings[index] = embedding; usedIndices.add(index); modifiedCount++; } } // 新しいチャンクの追加 for (const chunk of added) { const enhancedChunk = await this.createEnhancedChunk(chunk, filePath, fileStats); const embedding = await this.embeddingGenerator.generateEmbedding(chunk.content); this.indexData.chunks.push(enhancedChunk); this.indexData.embeddings.push(embedding); addedCount++; } // 使用されなかった既存チャンクを削除(逆順で) const toRemove = existingIndices.filter(i => !usedIndices.has(i)).sort((a, b) => b - a); for (const index of toRemove) { this.indexData.chunks.splice(index, 1); this.indexData.embeddings.splice(index, 1); removedCount++; } // 依存関係グラフの更新 await this.updateDependencyGraphAfterRemoval(filePath); console.log(`✅ Updated: ${path.relative(this.projectRoot, filePath)}`); console.log(` 📊 Changes: +${addedCount} added, ~${modifiedCount} modified, =${reusedCount} reused, -${removedCount} removed`); } catch (error) { console.error(`❌ Failed to update file ${filePath}:`, error.message); } } /** * ファイルの追加 */ async addFile(filePath) { try { const content = await fs.readFile(filePath, 'utf-8'); // ファイル統計情報を取得 const stats = await fs.stat(filePath); const fileStats = { lastModified: stats.mtime.getTime(), size: stats.size, checksum: crypto.createHash('md5').update(content).digest('hex') }; // 既存チャンクの検索 const existingChunks = this.indexData.chunks.filter(chunk => chunk.metadata.file === filePath ); // 新しいチャンクの作成 const newChunks = await this.semanticChunker.chunkFile(filePath, content); // チャンクの差分比較 const { added, modified, unchanged } = this.compareChunks(existingChunks, newChunks); let addedCount = 0; let modifiedCount = 0; let reusedCount = 0; // 既存の不要なチャンクを削除 if (existingChunks.length > 0) { const toRemove = existingChunks.filter(existing => !unchanged.some(u => this.isSameChunk(u.existing, existing)) && !modified.some(m => this.isSameChunk(m.existing, existing)) ); for (const chunk of toRemove) { const index = this.indexData.chunks.findIndex(c => this.isSameChunk(c, chunk) ); if (index !== -1) { this.indexData.chunks.splice(index, 1); this.indexData.embeddings.splice(index, 1); } } } // 新しいチャンクの追加 for (const chunk of added) { const enhancedChunk = await this.createEnhancedChunk(chunk, filePath, fileStats); const embedding = await this.vectorEngine.generateQueryEmbedding(enhancedChunk.content); this.indexData.chunks.push(enhancedChunk); this.indexData.embeddings.push(embedding); addedCount++; } // 変更されたチャンクの更新 for (const { existing, updated } of modified) { const index = this.indexData.chunks.findIndex(c => this.isSameChunk(c, existing) ); if (index !== -1) { const enhancedChunk = await this.createEnhancedChunk(updated, filePath, fileStats); const embedding = await this.vectorEngine.generateQueryEmbedding(enhancedChunk.content); this.indexData.chunks[index] = enhancedChunk; this.indexData.embeddings[index] = embedding; modifiedCount++; } } // 変更されていないチャンクは再利用 reusedCount = unchanged.length; // 依存関係グラフの更新 for (const chunk of [...added, ...modified.map(m => m.updated)]) { await this.updateDependencyGraph(chunk); } const totalProcessed = addedCount + modifiedCount + reusedCount; // ログ出力の改善 if (addedCount > 0 || modifiedCount > 0) { console.log(`✅ Added ${addedCount} chunks from: ${path.relative(this.projectRoot, filePath)}`); if (process.env.DEBUG) { console.log(` 📊 Details: ${addedCount} new, ${modifiedCount} modified, ${reusedCount} reused`); } } else { // スキップされた場合はデバッグモードでのみ表示 if (process.env.DEBUG) { console.log(`✅ Skipped: ${path.relative(this.projectRoot, filePath)} (no changes, ${reusedCount} reused)`); } } } catch (error) { console.error(`❌ Failed to add file ${filePath}:`, error.message); } } /** * チャンクの差分比較 */ compareChunks(existingChunks, newChunks) { const added = []; const modified = []; const unchanged = []; const usedExisting = new Set(); // 新しいチャンクとの比較 for (const newChunk of newChunks) { const newContentHash = this.calculateContentHash(newChunk.content); let matched = false; // 既存チャンクから完全一致を検索 for (const [index, existing] of existingChunks.entries()) { if (usedExisting.has(index)) continue; const existingContentHash = this.calculateContentHash(existing.content); if (newContentHash === existingContentHash) { // コンテンツが同じ場合は未変更 unchanged.push({ existing, updated: newChunk }); usedExisting.add(index); matched = true; break; } } if (!matched) { // 類似チャンクを検索(位置ベース) for (const [index, existing] of existingChunks.entries()) { if (usedExisting.has(index)) continue; if (this.isSimilarPosition(existing, newChunk)) { modified.push({ existing, updated: newChunk }); usedExisting.add(index); matched = true; break; } } if (!matched) { added.push(newChunk); } } } return { added, modified, unchanged }; } /** * 位置ベースの類似性判定 */ isSimilarPosition(chunk1, chunk2) { if (!chunk1.metadata || !chunk2.metadata) return false; // 分割チャンクの場合、splitIndexも考慮 if (chunk1.metadata.split && chunk2.metadata.split) { // 同じsplitIndexの場合は対応するチャンク if (chunk1.metadata.splitIndex === chunk2.metadata.splitIndex) { return true; } } // 同じ範囲 if (chunk1.metadata.startLine === chunk2.metadata.startLine && chunk1.metadata.endLine === chunk2.metadata.endLine) { return true; } // 重複する範囲(70%以上の重複を持つ場合) if (this.hasSignificantOverlap(chunk1.metadata, chunk2.metadata)) { return true; } return false; } /** * 有意な重複の判定(70%以上) */ hasSignificantOverlap(meta1, meta2) { const overlapStart = Math.max(meta1.startLine, meta2.startLine); const overlapEnd = Math.min(meta1.endLine, meta2.endLine); if (overlapStart > overlapEnd) return false; const overlapLines = overlapEnd - overlapStart + 1; const chunk1Lines = meta1.endLine - meta1.startLine + 1; const chunk2Lines = meta2.endLine - meta2.startLine + 1; // どちらかのチャンクの70%以上が重複している場合 return (overlapLines / chunk1Lines >= 0.7) || (overlapLines / chunk2Lines >= 0.7); } /** * コンテンツハッシュの計算 */ calculateContentHash(content) { return crypto.createHash('md5').update(content).digest('hex'); } /** * チャンクの重複判定 */ hasOverlap(meta1, meta2) { return Math.max(meta1.startLine, meta2.startLine) <= Math.min(meta1.endLine, meta2.endLine); } /** * チャンクの同一性判定 */ isSameChunk(chunk1, chunk2) { // chunkIdが両方にある場合はそれを使用 if (chunk1.metadata && chunk1.metadata.chunkId && chunk2.metadata && chunk2.metadata.chunkId) { return chunk1.metadata.chunkId === chunk2.metadata.chunkId; } // ファイルパスと行番号による比較 if (chunk1.metadata && chunk2.metadata) { return chunk1.metadata.file === chunk2.metadata.file && chunk1.metadata.startLine === chunk2.metadata.startLine && chunk1.metadata.endLine === chunk2.metadata.endLine; } // フォールバック: コンテンツハッシュによる比較 return this.calculateContentHash(chunk1.content) === this.calculateContentHash(chunk2.content); } /** * 拡張チャンクの作成 */ async createEnhancedChunk(chunk, filePath, fileStats) { const aiMetadata = await this.metadataManager.generateAIMetadata( chunk, filePath, { phase: 'implementation' } ); return { ...chunk, metadata: { ...chunk.metadata, ...aiMetadata, addedAt: new Date().toISOString(), // ファイル統計情報を追加(FileChangeDetectorが必要とする) lastModified: fileStats.lastModified, size: fileStats.size, checksum: fileStats.checksum, fileSize: fileStats.size, // 追加のファイルサイズフィールド fileChecksum: fileStats.checksum // 追加のチェックサムフィールド }, // 開発情報にも追加(フォールバック用) development: { ...(chunk.development || {}), lastModified: fileStats.lastModified, fileSize: fileStats.size, fileChecksum: fileStats.checksum } }; } /** * ファイルの削除 */ async removeFile(filePath) { const removedIndices = []; // 該当ファイルのチャンクを特定 for (let i = this.indexData.chunks.length - 1; i >= 0; i--) { if (this.indexData.chunks[i].metadata.file === filePath) { removedIndices.push(i); } } // 逆順で削除(インデックスの整合性を保つため) for (const index of removedIndices) { this.indexData.chunks.splice(index, 1); this.indexData.embeddings.splice(index, 1); } // 依存関係グラフの更新 await this.updateDependencyGraphAfterRemoval(filePath); console.log(`🗑️ Removed ${removedIndices.length} chunks from: ${path.relative(this.projectRoot, filePath)}`); } /** * 依存関係グラフの更新 */ async updateDependencyGraph(chunk) { if (!chunk.metadata.relationships) return; const chunkId = chunk.metadata.id; // 依存関係の追加 if (chunk.metadata.relationships.dependencies) { chunk.metadata.relationships.dependencies.forEach(dep => { if (!this.dependencyGraph.has(dep)) { this.dependencyGraph.set(dep, new Set()); } this.dependencyGraph.get(dep).add(chunkId); }); } } /** * 削除後の依存関係グラフ更新 */ async updateDependencyGraphAfterRemoval(filePath) { // 削除されたファイルに関連する依存関係を清理 for (const [key, dependents] of this.dependencyGraph.entries()) { const toRemove = []; dependents.forEach(chunkId => { // チャンクIDからファイルパスを特定し、削除されたファイルのものかチェック const chunk = this.indexData.chunks.find(c => c.metadata.id === chunkId); if (!chunk || chunk.metadata.file === filePath) { toRemove.push(chunkId); } }); toRemove.forEach(chunkId => dependents.delete(chunkId)); if (dependents.size === 0) { this.dependencyGraph.delete(key); } } } /** * 影響分析 */ async analyzeImpact(filePath) { const impactedChunks = new Set(); // 直接的な影響(同じファイル) this.indexData.chunks.forEach((chunk, index) => { if (chunk.metadata.file === filePath) { impactedChunks.add(index); } }); // 間接的な影響(依存関係) for (const [dep, dependents] of this.dependencyGraph.entries()) { // ファイルパスから依存関係を特定 if (dep.includes(path.basename(filePath, path.extname(filePath)))) { dependents.forEach(chunkId => { const chunkIndex = this.indexData.chunks.findIndex(c => c.metadata.id === chunkId); if (chunkIndex !== -1) { impactedChunks.add(chunkIndex); } }); } } return { directImpact: Array.from(impactedChunks).filter(index => this.indexData.chunks[index].metadata.file === filePath ), indirectImpact: Array.from(impactedChunks).filter(index => this.indexData.chunks[index].metadata.file !== filePath ), totalImpacted: impactedChunks.size }; } /** * インデックスの保存 */ async saveIndex() { try { // バックアップの作成 if (this.options.backupEnabled) { await this.createBackup(); } // インデックスディレクトリの作成 await fs.mkdir(this.indexPath, { recursive: true }); // 並列保存 await Promise.all([ fs.writeFile( path.join(this.indexPath, 'embeddings.json'), JSON.stringify(this.indexData.embeddings) ), fs.writeFile( path.join(this.indexPath, 'chunks.json'), JSON.stringify(this.indexData.chunks) ), fs.writeFile( path.join(this.indexPath, 'metadata.json'), JSON.stringify({ createdAt: this.indexData.metadata.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), totalChunks: this.indexData.chunks.length, totalFiles: [...new Set(this.indexData.chunks.map(c => c.metadata.file))].length, incrementalUpdate: true, version: '2.0.0', // ファイルレベルのメタデータを追加 files: this.buildFileMetadata() }, null, 2) ) ]); this.emit('index-saved', { chunks: this.indexData.chunks.length, path: this.indexPath }); } catch (error) { console.error('❌ Failed to save index:', error); throw error; } } /** * ファイルレベルのメタデータ構築 */ buildFileMetadata() { const fileMetadata = {}; // チャンクからファイルレベルのメタデータを構築 for (const chunk of this.indexData.chunks) { if (chunk.metadata && chunk.metadata.file) { const filePath = chunk.metadata.file; // 実際のチャンクデータ構造に基づいて情報を抽出 const lastModified = chunk.metadata.lastModified || (chunk.development && chunk.development.lastModified) || (chunk.metadata.timestamp && new Date(chunk.metadata.timestamp).getTime()) || null; const size = chunk.metadata.size || chunk.metadata.fileSize || (chunk.development && chunk.development.fileSize) || null; const checksum = chunk.metadata.checksum || chunk.metadata.fileChecksum || (chunk.development && chunk.development.fileChecksum) || null; // ファイルの最新情報を取得(複数のチャンクから同じファイルの場合は最新の情報を使用) if (!fileMetadata[filePath]) { fileMetadata[filePath] = { lastModified: lastModified, size: size, checksum: checksum, chunkCount: 0 }; } else if (lastModified && (!fileMetadata[filePath].lastModified || (typeof lastModified === 'string' ? new Date(lastModified).getTime() : lastModified) > (typeof fileMetadata[filePath].lastModified === 'string' ? new Date(fileMetadata[filePath].lastModified).getTime() : fileMetadata[filePath].lastModified))) { // より新しい情報が利用可能な場合に更新 fileMetadata[filePath].lastModified = lastModified; fileMetadata[filePath].size = size || fileMetadata[filePath].size; fileMetadata[filePath].checksum = checksum || fileMetadata[filePath].checksum; } } } // チャンク数をカウント for (const chunk of this.indexData.chunks) { if (chunk.metadata && chunk.metadata.file && fileMetadata[chunk.metadata.file]) { fileMetadata[chunk.metadata.file].chunkCount++; } } // 情報が不足しているファイルの統計 const filesToUpdate = []; for (const [filePath, metadata] of Object.entries(fileMetadata)) { if (!metadata.lastModified || !metadata.checksum) { filesToUpdate.push(filePath); } } if (filesToUpdate.length > 0) { console.log(`⚠️ Warning: ${filesToUpdate.length} files missing complete metadata (lastModified: ${Object.values(fileMetadata).filter(f => f.lastModified).length}/${Object.keys(fileMetadata).length}, checksum: ${Object.values(fileMetadata).filter(f => f.checksum).length}/${Object.keys(fileMetadata).length})`); } return fileMetadata; } /** * バックアップの作成 */ async createBackup() { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupDir = path.join(this.indexPath, 'backups', timestamp); try { await fs.mkdir(backupDir, { recursive: true }); const files = ['embeddings.json', 'chunks.json', 'metadata.json']; for (const file of files) { const srcPath = path.join(this.indexPath, file); const destPath = path.join(backupDir, file); if (fsSync.existsSync(srcPath)) { await fs.copyFile(srcPath, destPath); } } } catch (error) { console.warn('⚠️ Failed to create backup:', error.message); } } /** * チャンクIDの生成 */ generateChunkId(filePath, startLine) { const hash = this.simpleHash(`${filePath}:${startLine}`); return `chunk_${hash}`; } /** * 簡単なハッシュ関数 */ simpleHash(str) { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; } return Math.abs(hash).toString(36); } /** * 統計情報の取得 */ getStats() { return { totalChunks: this.indexData.chunks.length, totalFiles: [...new Set(this.indexData.chunks.map(c => c.metadata.file))].length, queueLength: this.changeQueue.length, isWatching: this.watchers.size > 0, dependencyNodes: this.dependencyGraph.size, lastUpdate: new Date().toISOString() }; } /** * 監視停止 */ async stopWatching() { for (const [path, watcher] of this.watchers.entries()) { watcher.close(); } this.watchers.clear(); // デバウンスタイマーの清理 for (const timer of this.debounceTimers.values()) { clearTimeout(timer); } this.debounceTimers.clear(); console.log('🛑 File watching stopped'); } /** * クリーンアップ */ async cleanup() { await this.stopWatching(); // 最終的なインデックス保存 if (this.changeQueue.length > 0) { await this.processChangeQueue(); } await this.saveIndex(); console.log('🧹 Incremental indexer cleanup completed'); } }