@claude-vector/core
Version:
Core vector search engine for code intelligence
503 lines (433 loc) • 15.4 kB
JavaScript
/**
* 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;
}
}