#!/usr/bin/env node /** * apply-claude-exports-fuzzy.js * * Applique les exports Claude avec fuzzy matching amélioré * * AMÉLIORATIONS: * - Normalisation des line endings (\r\n, \r, \n → \n unifié) * - Ignore les différences d'espacement (espaces multiples, tabs) * - Score de similarité abaissé à 85% pour plus de flexibilité * - Matching robuste qui ne casse pas sur les variations d'espaces * * Usage: * node tools/apply-claude-exports-fuzzy.js # Apply changes * node tools/apply-claude-exports-fuzzy.js --dry-run # Preview only */ const fs = require('fs'); const path = require('path'); const EXPORTS_DIR = path.join(__dirname, '../claude-exports-last-3-days'); const LOGS_DIR = path.join(__dirname, '../logs'); // Créer dossier logs si nécessaire if (!fs.existsSync(LOGS_DIR)) { fs.mkdirSync(LOGS_DIR, { recursive: true }); } // Fichier de log avec timestamp const timestamp = new Date().toISOString().replace(/:/g, '-').replace(/\..+/, ''); const LOG_FILE = path.join(LOGS_DIR, `apply-exports-fuzzy-${timestamp}.log`); // Configuration fuzzy matching const FUZZY_CONFIG = { minSimilarity: 0.85, // Minimum 85% de similarité pour accepter le match (abaissé de 95% pour plus de flexibilité) contextLines: 3, // Lignes de contexte avant/après ignoreWhitespace: true, // Ignorer les différences d'espacement (espaces multiples, tabs) ignoreComments: false, // Ignorer les différences dans les commentaires normalizeLineEndings: true // Unifier \r\n, \r, \n en \n (activé par défaut) }; /** * Logger dans console ET fichier */ function log(message, onlyFile = false) { const line = `${message}\n`; // Écrire dans le fichier fs.appendFileSync(LOG_FILE, line, 'utf-8'); // Écrire aussi dans la console (sauf si onlyFile) if (!onlyFile) { process.stdout.write(line); } } /** * Parse un fichier de session pour extraire les tool uses */ function parseSessionFile(filePath) { const content = fs.readFileSync(filePath, 'utf-8'); const tools = []; const jsonBlockRegex = /\[\s*\{[\s\S]*?"type":\s*"tool_use"[\s\S]*?\}\s*\]/g; const matches = content.match(jsonBlockRegex); if (!matches) return tools; for (const match of matches) { try { const parsed = JSON.parse(match); for (const item of parsed) { if (item.type === 'tool_use' && (item.name === 'Edit' || item.name === 'Write')) { tools.push({ name: item.name, input: item.input }); } } } catch (e) { // Skip invalid JSON } } return tools; } /** * Normaliser un texte complet pour la comparaison * - Unifie les line endings (\r\n, \r, \n → \n) * - Ignore les différences d'espacement selon config */ function normalizeText(text) { let normalized = text; // Unifier tous les retours à la ligne si configuré if (FUZZY_CONFIG.normalizeLineEndings) { normalized = normalized.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); } if (FUZZY_CONFIG.ignoreWhitespace) { // Réduire les espaces/tabs multiples en un seul espace normalized = normalized.replace(/[ \t]+/g, ' '); // Nettoyer les espaces en début/fin de chaque ligne normalized = normalized.split('\n').map(line => line.trim()).join('\n'); } return normalized; } /** * Normaliser une ligne pour la comparaison */ function normalizeLine(line) { if (FUZZY_CONFIG.ignoreWhitespace) { // Trim + réduire les espaces multiples à un seul return line.trim().replace(/\s+/g, ' '); } return line; } /** * Calculer la similarité entre deux chaînes (Levenshtein simplifié) */ function calculateSimilarity(str1, str2) { const len1 = str1.length; const len2 = str2.length; if (len1 === 0) return len2 === 0 ? 1.0 : 0.0; if (len2 === 0) return 0.0; // Matrice de distance const matrix = Array(len1 + 1).fill(null).map(() => Array(len2 + 1).fill(0)); for (let i = 0; i <= len1; i++) matrix[i][0] = i; for (let j = 0; j <= len2; j++) matrix[0][j] = j; for (let i = 1; i <= len1; i++) { for (let j = 1; j <= len2; j++) { const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; matrix[i][j] = Math.min( matrix[i - 1][j] + 1, // deletion matrix[i][j - 1] + 1, // insertion matrix[i - 1][j - 1] + cost // substitution ); } } const distance = matrix[len1][len2]; const maxLen = Math.max(len1, len2); return 1 - (distance / maxLen); } /** * Créer un diff détaillé ligne par ligne */ function createDiff(oldLines, newLines) { const diff = []; const maxLen = Math.max(oldLines.length, newLines.length); for (let i = 0; i < maxLen; i++) { const oldLine = oldLines[i]; const newLine = newLines[i]; if (oldLine === undefined) { // Ligne ajoutée diff.push({ type: 'add', line: newLine, lineNum: i }); } else if (newLine === undefined) { // Ligne supprimée diff.push({ type: 'del', line: oldLine, lineNum: i }); } else if (oldLine !== newLine) { // Ligne modifiée diff.push({ type: 'mod', oldLine, newLine, lineNum: i }); } else { // Ligne identique diff.push({ type: 'same', line: oldLine, lineNum: i }); } } return diff; } /** * Trouver la meilleure position pour un fuzzy match */ function findFuzzyMatch(content, oldString) { // 🔥 CRITICAL FIX: Unifier SEULEMENT line endings (comme dans applyEdit) // pour que les positions correspondent au même format de texte // On normalise les espaces SEULEMENT pour la COMPARAISON (ligne par ligne) const contentWithUnifiedLineEndings = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const oldStringWithUnifiedLineEndings = oldString.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const contentLines = contentWithUnifiedLineEndings.split('\n'); const oldLines = oldStringWithUnifiedLineEndings.split('\n'); if (oldLines.length === 0) return null; let bestMatch = null; let bestScore = 0; // Chercher dans tout le contenu for (let startLine = 0; startLine <= contentLines.length - oldLines.length; startLine++) { const candidateLines = contentLines.slice(startLine, startLine + oldLines.length); // Calculer le score de similarité ligne par ligne let totalScore = 0; let matchedLines = 0; for (let i = 0; i < oldLines.length; i++) { const oldNorm = normalizeLine(oldLines[i]); const candidateNorm = normalizeLine(candidateLines[i]); const similarity = calculateSimilarity(oldNorm, candidateNorm); totalScore += similarity; if (similarity > 0.8) matchedLines++; } const avgScore = totalScore / oldLines.length; const matchRatio = matchedLines / oldLines.length; // Score combiné: moyenne de similarité + ratio de lignes matchées const combinedScore = (avgScore * 0.7) + (matchRatio * 0.3); if (combinedScore > bestScore && combinedScore >= FUZZY_CONFIG.minSimilarity) { bestScore = combinedScore; bestMatch = { startLine, endLine: startLine + oldLines.length, score: combinedScore, matchedLines, totalLines: oldLines.length }; } } return bestMatch; } /** * Appliquer un Edit avec fuzzy matching */ function applyEdit(filePath, oldString, newString, dryRun = false) { try { if (!fs.existsSync(filePath)) { log(`⏭️ SKIP Edit - Fichier n'existe pas: ${filePath}`); return { success: false, reason: 'FILE_NOT_EXIST' }; } const content = fs.readFileSync(filePath, 'utf-8'); // 🔥 Essayer d'abord un match exact SANS normalisation (le plus rapide et sûr) if (content.includes(oldString)) { const newContent = content.replace(oldString, newString); if (!dryRun) { fs.writeFileSync(filePath, newContent, 'utf-8'); } log(`✅ EDIT EXACT appliqué: ${filePath}`); return { success: true, reason: 'EXACT_MATCH', method: 'exact' }; } // Si pas de match exact, essayer le fuzzy matching (avec normalisation) const fuzzyMatch = findFuzzyMatch(content, oldString); if (fuzzyMatch) { // 🔥 IMPORTANT: fuzzyMatch a trouvé les positions avec normalisation // Mais on applique le remplacement sur les versions ORIGINALES (espaces préservés) // On unifie SEULEMENT les line endings (\r\n → \n) pour que les positions correspondent // Unifier line endings UNIQUEMENT (garder espaces originaux) const contentWithUnifiedLineEndings = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const newStringWithUnifiedLineEndings = newString.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const contentLines = contentWithUnifiedLineEndings.split('\n'); const newLines = newStringWithUnifiedLineEndings.split('\n'); // Capturer les lignes matchées ORIGINALES (AVANT remplacement) const matchedLines = contentLines.slice(fuzzyMatch.startLine, fuzzyMatch.endLine); // Remplacer la zone identifiée avec le patch ORIGINAL const before = contentLines.slice(0, fuzzyMatch.startLine); const after = contentLines.slice(fuzzyMatch.endLine); const newContent = [...before, ...newLines, ...after].join('\n'); if (!dryRun) { fs.writeFileSync(filePath, newContent, 'utf-8'); } log(`🎯 EDIT FUZZY appliqué: ${filePath} (score: ${(fuzzyMatch.score * 100).toFixed(1)}%, lignes ${fuzzyMatch.startLine}-${fuzzyMatch.endLine})`); // Créer un diff détaillé const diff = createDiff(matchedLines, newLines); log(`┌─ 📝 DIFF DÉTAILLÉ ────────────────────────────────────────────────`); diff.forEach((item, idx) => { const lineNum = String(fuzzyMatch.startLine + idx + 1).padStart(4, ' '); if (item.type === 'same') { // Ligne identique log(`│ ${lineNum} │ ${item.line}`); } else if (item.type === 'add') { // Ligne ajoutée log(`│ ${lineNum} │ + ${item.line}`); } else if (item.type === 'del') { // Ligne supprimée log(`│ ${lineNum} │ - ${item.line}`); } else if (item.type === 'mod') { // Ligne modifiée - afficher les deux log(`│ ${lineNum} │ - ${item.oldLine}`); log(`│ ${lineNum} │ + ${item.newLine}`); } }); log(`└────────────────────────────────────────────────────────────────────`); log(''); return { success: true, reason: 'FUZZY_MATCH', method: 'fuzzy', score: fuzzyMatch.score, lines: `${fuzzyMatch.startLine}-${fuzzyMatch.endLine}` }; } log(`⏭️ SKIP Edit - Aucun match trouvé: ${filePath}`); return { success: false, reason: 'NO_MATCH' }; } catch (e) { log(`❌ ERREUR Edit sur ${filePath}: ${e.message}`); return { success: false, reason: 'ERROR', error: e.message }; } } /** * Appliquer un Write sur un fichier */ function applyWrite(filePath, content, dryRun = false) { try { if (fs.existsSync(filePath)) { log(`⏭️ SKIP Write - Fichier existe déjà: ${filePath}`); return { success: false, reason: 'FILE_EXISTS' }; } const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { if (!dryRun) { fs.mkdirSync(dir, { recursive: true }); } } if (!dryRun) { fs.writeFileSync(filePath, content, 'utf-8'); } log(`✅ WRITE appliqué: ${filePath}`); return { success: true, reason: 'CREATED' }; } catch (e) { log(`❌ ERREUR Write sur ${filePath}: ${e.message}`); return { success: false, reason: 'ERROR', error: e.message }; } } /** * Main */ function main() { // Check for dry-run mode const dryRun = process.argv.includes('--dry-run'); log(`📝 Logs sauvegardés dans: ${LOG_FILE}`); log(''); if (dryRun) { log('🔍 MODE DRY-RUN: Aucun fichier ne sera modifié'); log(''); } log('🔄 Application des exports Claude avec FUZZY MATCHING...'); log(`⚙️ Config: minSimilarity=${FUZZY_CONFIG.minSimilarity * 100}%, ignoreWhitespace=${FUZZY_CONFIG.ignoreWhitespace}`); log(''); // Lire tous les fichiers de session const sessionFiles = fs.readdirSync(EXPORTS_DIR) .filter(f => f.endsWith('-session.md')) .sort((a, b) => { const numA = parseInt(a.split('-')[0]); const numB = parseInt(b.split('-')[0]); return numB - numA; // Ordre inverse: 15 -> 1 }); log(`📁 ${sessionFiles.length} fichiers de session trouvés`); log(`📋 Ordre de traitement: ${sessionFiles.join(', ')}`); log(''); const stats = { totalEdits: 0, totalWrites: 0, exactMatches: 0, fuzzyMatches: 0, successWrites: 0, skipped: 0, errors: 0 }; for (const sessionFile of sessionFiles) { const filePath = path.join(EXPORTS_DIR, sessionFile); log(''); log(`📄 Traitement de: ${sessionFile}`); const tools = parseSessionFile(filePath); log(` ${tools.length} tool use(s) trouvé(s)`); for (const tool of tools) { if (tool.name === 'Edit') { stats.totalEdits++; const { file_path, old_string, new_string } = tool.input; const result = applyEdit(file_path, old_string, new_string, dryRun); if (result.success) { if (result.method === 'exact') { stats.exactMatches++; } else if (result.method === 'fuzzy') { stats.fuzzyMatches++; } } else { if (result.reason === 'ERROR') { stats.errors++; } else { stats.skipped++; } } } else if (tool.name === 'Write') { stats.totalWrites++; const { file_path, content } = tool.input; const result = applyWrite(file_path, content, dryRun); if (result.success) { stats.successWrites++; } else { stats.skipped++; } } } } log(''); log(''); log('📊 RÉSUMÉ:'); log(` Edit Exact: ${stats.exactMatches}/${stats.totalEdits} appliqués`); log(` Edit Fuzzy: ${stats.fuzzyMatches}/${stats.totalEdits} appliqués`); log(` Write: ${stats.successWrites}/${stats.totalWrites} appliqués`); log(` Skippés: ${stats.skipped}`); log(` Erreurs: ${stats.errors}`); log(` Total: ${stats.exactMatches + stats.fuzzyMatches + stats.successWrites}/${stats.totalEdits + stats.totalWrites} opérations réussies`); log(''); if (dryRun) { log('💡 Pour appliquer réellement, relancez sans --dry-run'); } else { log('✨ Terminé!'); } log(''); log(`📝 Logs complets: ${LOG_FILE}`); } main();