/** * diffValidator.ts — S210: Diff Pre/Post Validation * * Due fasi di validazione attorno a `apply_patch`: * 1. PRE-APPLY: verifica che il diff sia sintatticamente valido prima di applicarlo * 2. POST-APPLY: verifica che il risultato sia coerente (sintassi, contenuto atteso) * * Supporta formati: * - Unified diff (--- +++ @@ ... +/- lines) * - SEARCH/REPLACE block (<<<<<<< SEARCH / ======= / >>>>>>> REPLACE) * * Pure, no side-effects, no external deps — Safari-safe. * * @module diffValidator */ import { checkCodeSyntaxBalance } from "@/lib/ai/output-validator"; // ─── Tipi ───────────────────────────────────────────────────────────────────── export type DiffFormat = "unified" | "search_replace" | "unknown"; export interface DiffValidationResult { valid: boolean; format: DiffFormat; issues: string[]; warnings: string[]; } export interface PostApplyValidation { valid: boolean; syntaxOk: boolean; syntaxError: string | null; lengthDelta: number; // byte: positivo = cresciuto, negativo = shrunk warnings: string[]; } // ─── Rilevamento formato ────────────────────────────────────────────────────── /** * Rileva il formato del diff. */ export function detectDiffFormat(diff: string): DiffFormat { const trimmed = diff.trim(); if (/^<<<<<<< SEARCH$/m.test(trimmed) && /^=======$/m.test(trimmed) && /^>>>>>>> REPLACE$/m.test(trimmed)) { return "search_replace"; } if (/^---\s+/m.test(trimmed) && /^\+\+\+\s+/m.test(trimmed) && /^@@\s+-\d/m.test(trimmed)) { return "unified"; } // Tolera diff unificati senza header (solo @@ ... +/- lines) if (/^@@\s+-\d+/m.test(trimmed) || /^[\-\+]{1}[^\-\+]/m.test(trimmed)) { return "unified"; } return "unknown"; } // ─── PRE-APPLY: Validazione sintassi diff ───────────────────────────────────── /** * Valida la sintassi del diff PRIMA di applicarlo. * * Unified diff checks: * - Ha almeno un hunk (@@ ... @@) * - Ogni hunk ha almeno una riga + o - * - Nessun mix di tab/space nei marcatori * * SEARCH/REPLACE checks: * - Blocchi SEARCH/REPLACE completi e bilanciati * - Sezione SEARCH non vuota * - Delimitatori non sovrapposti */ export function validateDiffSyntax(diff: string): DiffValidationResult { const issues: string[] = []; const warnings: string[] = []; const format = detectDiffFormat(diff); if (!diff.trim()) { return { valid: false, format, issues: ["Diff vuoto"], warnings }; } if (format === "unknown") { return { valid: false, format, issues: ["Formato diff non riconosciuto (atteso: unified diff o SEARCH/REPLACE)"], warnings, }; } if (format === "search_replace") { return _validateSearchReplace(diff, issues, warnings); } // unified return _validateUnified(diff, issues, warnings); } function _validateSearchReplace( diff: string, issues: string[], warnings: string[], ): DiffValidationResult { const blocks = diff.split(/(?=<<<<<<< SEARCH$)/m).filter(b => b.includes("<<<<<<< SEARCH")); if (blocks.length === 0) { issues.push("Nessun blocco SEARCH/REPLACE trovato"); return { valid: false, format: "search_replace", issues, warnings }; } let blockIdx = 0; for (const block of blocks) { blockIdx++; const searchMatch = block.match(/^<<<<<<< SEARCH\n([\s\S]*?)^=======$/m); const replaceMatch = block.match(/^=======\n([\s\S]*?)^>>>>>>> REPLACE$/m); if (!searchMatch) { issues.push(`Blocco ${blockIdx}: delimitatore <<<<<<< SEARCH o ======= mancante o malformato`); continue; } if (!replaceMatch) { issues.push(`Blocco ${blockIdx}: delimitatore ======= o >>>>>>> REPLACE mancante o malformato`); continue; } const searchContent = searchMatch[1]; if (!searchContent.trim()) { issues.push(`Blocco ${blockIdx}: sezione SEARCH vuota — impossibile localizzare il testo da sostituire`); } // Warning: sezione REPLACE vuota = cancellazione (lecita ma rischiosa) const replaceContent = replaceMatch[1]; if (!replaceContent.trim()) { warnings.push(`Blocco ${blockIdx}: sezione REPLACE vuota — il testo verrà eliminato senza sostituzione`); } } return { valid: issues.length === 0, format: "search_replace", issues, warnings }; } function _validateUnified( diff: string, issues: string[], warnings: string[], ): DiffValidationResult { const lines = diff.split("\n"); const hunks = lines.filter(l => l.startsWith("@@")); if (hunks.length === 0) { issues.push("Nessun hunk (@@ ... @@) trovato nel unified diff"); return { valid: false, format: "unified", issues, warnings }; } // Verifica che ci siano almeno righe + o - const hasChanges = lines.some(l => l.startsWith("+") || l.startsWith("-")); if (!hasChanges) { issues.push("Il diff non contiene righe aggiunte (+) o rimosse (-)"); } // Verifica header hunk: @@ -L,N +L,N @@ for (const hunk of hunks) { if (!/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/.test(hunk)) { warnings.push(`Hunk mal formato: "${hunk.slice(0, 60)}"`); } } // Avvisa su diff che rimuovono molte righe senza aggiungerne const removes = lines.filter(l => l.startsWith("-") && !l.startsWith("---")).length; const adds = lines.filter(l => l.startsWith("+") && !l.startsWith("+++")).length; if (removes > 20 && adds < removes * 0.2) { warnings.push(`Rimozione massiccia: -${removes} righe, +${adds} righe — verifica che sia intenzionale`); } return { valid: issues.length === 0, format: "unified", issues, warnings }; } // ─── POST-APPLY: Validazione risultato ──────────────────────────────────────── /** * Valida il file risultante DOPO l'applicazione del patch. * * @param originalContent Contenuto originale (prima del patch) * @param patchedContent Contenuto risultante (dopo il patch) * @param filePath Path del file (usato per rilevare il linguaggio) */ export function validatePostApply( originalContent: string, patchedContent: string, filePath: string, ): PostApplyValidation { const warnings: string[] = []; const ext = filePath.split(".").pop()?.toLowerCase() ?? ""; const CODE_EXTS = new Set(["ts","tsx","js","jsx","mjs"]); // Syntax balance check per TS/JS let syntaxError: string | null = null; let syntaxOk = true; if (CODE_EXTS.has(ext)) { syntaxError = checkCodeSyntaxBalance(patchedContent); if (syntaxError) { syntaxOk = false; // Verifica se anche l'originale aveva l'errore (pre-esistente) const originalSyntaxError = checkCodeSyntaxBalance(originalContent); if (originalSyntaxError) { // Errore preesistente → downgrade a warning syntaxOk = true; syntaxError = null; warnings.push(`Sintassi già non bilanciata nell'originale (preesistente): ${originalSyntaxError}`); } } } // Length delta const lengthDelta = patchedContent.length - originalContent.length; // Warning: contenuto azzerato if (patchedContent.trim().length === 0 && originalContent.trim().length > 0) { warnings.push("Il file risultante è vuoto — il patch ha rimosso tutto il contenuto"); } // Warning: shrinkage estremo (> 80% perso) if (originalContent.length > 100 && patchedContent.length < originalContent.length * 0.2) { warnings.push( `Riduzione drastica: ${originalContent.length} → ${patchedContent.length} chars (` + `${Math.round((1 - patchedContent.length / originalContent.length) * 100)}% rimosso)` ); } // Warning: import rotti (riga import presente nell'originale ma non nel risultato) if (CODE_EXTS.has(ext)) { const origImports = (originalContent.match(/^import .+ from .+;$/gm) ?? []); const patchedImports = new Set(patchedContent.match(/^import .+ from .+;$/gm) ?? []); const removedImports = origImports.filter(i => !patchedImports.has(i)); if (removedImports.length > 3) { warnings.push(`${removedImports.length} import rimossi — verifica dipendenze`); } } return { valid: syntaxOk, syntaxOk, syntaxError, lengthDelta, warnings, }; } // ─── Convenience: applica SEARCH/REPLACE in-process ────────────────────────── /** * Applica un blocco SEARCH/REPLACE a un testo sorgente. * Ritorna null se la stringa di ricerca non viene trovata. * * Utile per pre-verificare che il SEARCH sia effettivamente presente * nel file prima di inviare il tool_call al backend. */ export function applySearchReplace( source: string, diff: string, ): string | null { const blocks = diff.split(/(?=<<<<<<< SEARCH$)/m).filter(b => b.includes("<<<<<<< SEARCH")); let result = source; for (const block of blocks) { const searchMatch = block.match(/^<<<<<<< SEARCH\n([\s\S]*?)^=======$/m); const replaceMatch = block.match(/^=======\n([\s\S]*?)^>>>>>>> REPLACE$/m); if (!searchMatch || !replaceMatch) return null; const searchStr = searchMatch[1]; const replaceStr = replaceMatch[1]; if (!result.includes(searchStr)) return null; result = result.replace(searchStr, replaceStr); } return result; } /** * Wrapper tutto-in-uno: valida syntax diff + verifica SEARCH presente + valida post-apply. * Ritorna un report completo. */ export function fullDiffValidation( diff: string, originalContent: string, filePath: string, ): { preValidation: DiffValidationResult; searchFound: boolean | null; // null = non applicabile (unified diff) postValidation: PostApplyValidation | null; // null = non calcolabile } { const preValidation = validateDiffSyntax(diff); let searchFound: boolean | null = null; let postValidation: PostApplyValidation | null = null; if (!preValidation.valid) { return { preValidation, searchFound, postValidation }; } // Per SEARCH/REPLACE: prova applicazione in-process if (preValidation.format === "search_replace") { const applied = applySearchReplace(originalContent, diff); searchFound = applied !== null; if (applied !== null) { postValidation = validatePostApply(originalContent, applied, filePath); } } return { preValidation, searchFound, postValidation }; }