Spaces:
Sleeping
Sleeping
| /** | |
| * 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 }; | |
| } | |