/** * KHAELOR * File: src/tools/diff.ts * Description: Unified diff generation with addition/deletion counts for write/edit metadata. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ export interface UnifiedDiff { text: string; additions: number; deletions: number; } const CONTEXT_LINES = 3; /** LCS matrix guard: beyond this the diff degrades to a single replace hunk. */ const MAX_LCS_CELLS = 4_000_000; function splitLines(text: string): string[] { if (text.length === 0) return []; const lines = text.split("\n"); if (lines[lines.length - 1] === "") lines.pop(); return lines; } type DiffOp = { kind: "equal" | "delete" | "insert"; line: string }; function diffOps(oldLines: string[], newLines: string[]): DiffOp[] { // Trim common prefix/suffix to keep the DP small. let start = 0; while (start < oldLines.length && start < newLines.length && oldLines[start] === newLines[start]) { start += 1; } let endOld = oldLines.length; let endNew = newLines.length; while (endOld > start && endNew > start && oldLines[endOld - 1] === newLines[endNew - 1]) { endOld -= 1; endNew -= 1; } const a = oldLines.slice(start, endOld); const b = newLines.slice(start, endNew); const ops: DiffOp[] = []; for (let i = 0; i < start; i++) ops.push({ kind: "equal", line: oldLines[i] as string }); if ((a.length + 1) * (b.length + 1) > MAX_LCS_CELLS) { for (const line of a) ops.push({ kind: "delete", line }); for (const line of b) ops.push({ kind: "insert", line }); } else { // LCS lengths table. const m = a.length; const n = b.length; const table = new Uint32Array((m + 1) * (n + 1)); const at = (i: number, j: number): number => table[i * (n + 1) + j] as number; for (let i = m - 1; i >= 0; i--) { for (let j = n - 1; j >= 0; j--) { table[i * (n + 1) + j] = a[i] === b[j] ? at(i + 1, j + 1) + 1 : Math.max(at(i + 1, j), at(i, j + 1)); } } let i = 0; let j = 0; while (i < m && j < n) { if (a[i] === b[j]) { ops.push({ kind: "equal", line: a[i] as string }); i += 1; j += 1; } else if (at(i + 1, j) >= at(i, j + 1)) { ops.push({ kind: "delete", line: a[i] as string }); i += 1; } else { ops.push({ kind: "insert", line: b[j] as string }); j += 1; } } while (i < m) ops.push({ kind: "delete", line: a[i++] as string }); while (j < n) ops.push({ kind: "insert", line: b[j++] as string }); } for (let i = endOld; i < oldLines.length; i++) ops.push({ kind: "equal", line: oldLines[i] as string }); return ops; } /** Compute a unified diff (3 lines of context) between two texts. */ export function unifiedDiff( oldText: string, newText: string, oldLabel: string, newLabel: string, ): UnifiedDiff { const ops = diffOps(splitLines(oldText), splitLines(newText)); let additions = 0; let deletions = 0; for (const op of ops) { if (op.kind === "insert") additions += 1; if (op.kind === "delete") deletions += 1; } if (additions === 0 && deletions === 0) { return { text: "", additions: 0, deletions: 0 }; } // Group ops into hunks with context. interface Hunk { oldStart: number; oldCount: number; newStart: number; newCount: number; lines: string[]; } const hunks: Hunk[] = []; let oldLine = 1; let newLine = 1; let index = 0; while (index < ops.length) { const op = ops[index] as DiffOp; if (op.kind === "equal") { oldLine += 1; newLine += 1; index += 1; continue; } // Start of a change: back up for leading context. const contextStart = Math.max(0, index - CONTEXT_LINES); let leading = 0; for (let k = contextStart; k < index; k++) { if ((ops[k] as DiffOp).kind === "equal") leading += 1; } const hunk: Hunk = { oldStart: oldLine - leading, oldCount: 0, newStart: newLine - leading, newCount: 0, lines: [], }; for (let k = index - leading; k < index; k++) { hunk.lines.push(` ${(ops[k] as DiffOp).line}`); hunk.oldCount += 1; hunk.newCount += 1; } // Consume changes and interleaved context until a gap of > 2×context equals. let equalRun = 0; while (index < ops.length) { const current = ops[index] as DiffOp; if (current.kind === "equal") { equalRun += 1; if (equalRun > CONTEXT_LINES * 2) break; } else { equalRun = 0; } index += 1; if (current.kind === "equal") { hunk.lines.push(` ${current.line}`); hunk.oldCount += 1; hunk.newCount += 1; oldLine += 1; newLine += 1; } else if (current.kind === "delete") { hunk.lines.push(`-${current.line}`); hunk.oldCount += 1; oldLine += 1; } else { hunk.lines.push(`+${current.line}`); hunk.newCount += 1; newLine += 1; } } // Trim trailing context beyond CONTEXT_LINES. let trailing = 0; while ( trailing < hunk.lines.length && (hunk.lines[hunk.lines.length - 1 - trailing] as string).startsWith(" ") ) { trailing += 1; } const excess = Math.max(0, trailing - CONTEXT_LINES); if (excess > 0) { hunk.lines.length -= excess; hunk.oldCount -= excess; hunk.newCount -= excess; oldLine -= excess; newLine -= excess; // Re-advance the outer scan past the trimmed equals. index -= excess; } hunks.push(hunk); } const header = `--- ${oldLabel}\n+++ ${newLabel}`; const body = hunks .map( (h) => `@@ -${h.oldStart},${h.oldCount} +${h.newStart},${h.newCount} @@\n${h.lines.join("\n")}`, ) .join("\n"); return { text: `${header}\n${body}`, additions, deletions }; } /** Cap a diff string for durable events (default 32 KiB) with an explicit marker. */ export function capDiff(diff: string, maxBytes = 32 * 1024): string { if (Buffer.byteLength(diff, "utf8") <= maxBytes) return diff; return `${diff.slice(0, maxBytes)}\n[diff truncated]`; }