AUDIT / src /lib /agent /diffValidator.ts
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 2)
cc11e77 verified
Raw
History Blame Contribute Delete
10.7 kB
/**
* 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 };
}