import { diffLines, createTwoFilesPatch } from "diff"; /** * Diff engines. All produce a `DiffResult` with a compact, storable summary plus the * unified patch so the UI can render unified / side-by-side / semantic / raw views. */ export interface TextDiff { kind: "text"; added: string[]; removed: string[]; /** Pairs of (removed → added) that look like modifications of the same line. */ modified: { before: string; after: string }[]; unified: string; stats: { added: number; removed: number; modified: number; unchangedRatio: number }; } export interface JsonDiff { kind: "json"; changes: { path: string; op: "add" | "remove" | "replace"; before?: unknown; after?: unknown }[]; unified: string; } export interface ListDiff { kind: "list"; added: ListItem[]; removed: ListItem[]; modified: { key: string; before: ListItem; after: ListItem; fields: string[] }[]; unified: string; } export interface ListItem { key: string; [k: string]: unknown; } export type DiffResult = TextDiff | JsonDiff | ListDiff; function similarity(a: string, b: string): number { if (a === b) return 1; const la = a.length; const lb = b.length; if (!la || !lb) return 0; // cheap: common prefix + suffix ratio let p = 0; while (p < la && p < lb && a[p] === b[p]) p++; let s = 0; while (s < la - p && s < lb - p && a[la - 1 - s] === b[lb - 1 - s]) s++; return (p + s) / Math.max(la, lb); } export function diffText(before: string, after: string, labelBefore = "before", labelAfter = "after"): TextDiff { const parts = diffLines(before, after, { newlineIsToken: false }); const added: string[] = []; const removed: string[] = []; let unchanged = 0; let total = 0; for (const part of parts) { const lines = part.value.split("\n").filter((l) => l.length); total += lines.length; if (part.added) added.push(...lines); else if (part.removed) removed.push(...lines); else unchanged += lines.length; } // Pair removed/added lines that look like edits of the same line. const modified: { before: string; after: string }[] = []; const usedAdded = new Set(); const remainingRemoved: string[] = []; for (const r of removed) { let best = -1; let bestScore = 0.55; for (let i = 0; i < added.length; i++) { if (usedAdded.has(i)) continue; const sc = similarity(r, added[i]!); if (sc > bestScore) { bestScore = sc; best = i; } } if (best >= 0) { usedAdded.add(best); modified.push({ before: r, after: added[best]! }); } else remainingRemoved.push(r); } const remainingAdded = added.filter((_, i) => !usedAdded.has(i)); const unified = createTwoFilesPatch(labelBefore, labelAfter, before, after, "", "", { context: 2 }); return { kind: "text", added: remainingAdded, removed: remainingRemoved, modified, unified, stats: { added: remainingAdded.length, removed: remainingRemoved.length, modified: modified.length, unchangedRatio: total ? unchanged / total : 1 }, }; } export function diffJson(before: unknown, after: unknown): JsonDiff { const changes: JsonDiff["changes"] = []; const walk = (a: unknown, b: unknown, path: string): void => { if (JSON.stringify(a) === JSON.stringify(b)) return; const aObj = a && typeof a === "object" && !Array.isArray(a); const bObj = b && typeof b === "object" && !Array.isArray(b); if (aObj && bObj) { const keys = new Set([...Object.keys(a as object), ...Object.keys(b as object)]); for (const k of keys) { const p = path ? `${path}.${k}` : k; const av = (a as Record)[k]; const bv = (b as Record)[k]; if (av === undefined) changes.push({ path: p, op: "add", after: bv }); else if (bv === undefined) changes.push({ path: p, op: "remove", before: av }); else walk(av, bv, p); } return; } if (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.length <= 200) { for (let i = 0; i < a.length; i++) walk(a[i], b[i], `${path}[${i}]`); return; } changes.push({ path: path || "$", op: "replace", before: a, after: b }); }; walk(before, after, ""); const unified = changes.map((c) => (c.op === "add" ? `+ ${c.path}: ${JSON.stringify(c.after)}` : c.op === "remove" ? `- ${c.path}: ${JSON.stringify(c.before)}` : `- ${c.path}: ${JSON.stringify(c.before)}\n+ ${c.path}: ${JSON.stringify(c.after)}`)).join("\n"); return { kind: "json", changes, unified }; } /** Diff keyed lists (feed items, sitemap URLs, releases, incidents). */ export function diffList(before: ListItem[], after: ListItem[], compareFields: string[] = []): ListDiff { const bm = new Map(before.map((i) => [i.key, i])); const am = new Map(after.map((i) => [i.key, i])); const added: ListItem[] = []; const removed: ListItem[] = []; const modified: ListDiff["modified"] = []; for (const [k, item] of am) { const prev = bm.get(k); if (!prev) { added.push(item); continue; } const fields = compareFields.filter((f) => JSON.stringify(prev[f]) !== JSON.stringify(item[f])); if (fields.length) modified.push({ key: k, before: prev, after: item, fields }); } for (const [k, item] of bm) if (!am.has(k)) removed.push(item); const label = (i: ListItem): string => String(i.title ?? i.url ?? i.name ?? i.key); const unified = [...added.map((i) => `+ ${label(i)}`), ...removed.map((i) => `- ${label(i)}`), ...modified.map((m) => `~ ${label(m.after)} (${m.fields.join(", ")})`)].join("\n"); return { kind: "list", added, removed, modified, unified }; } /** Compact, storable version of a diff for the `changes` table (bounded size). */ export function summarizeDiff(d: DiffResult, maxItems = 40, maxLen = 400): Record { const cut = (s: string): string => (s.length > maxLen ? s.slice(0, maxLen) + "…" : s); if (d.kind === "text") { return { kind: d.kind, added: d.added.slice(0, maxItems).map(cut), removed: d.removed.slice(0, maxItems).map(cut), modified: d.modified.slice(0, maxItems).map((m) => ({ before: cut(m.before), after: cut(m.after) })), stats: d.stats, truncated: d.added.length > maxItems || d.removed.length > maxItems || d.modified.length > maxItems, }; } if (d.kind === "json") return { kind: d.kind, changes: d.changes.slice(0, maxItems), truncated: d.changes.length > maxItems }; return { kind: d.kind, added: d.added.slice(0, maxItems), removed: d.removed.slice(0, maxItems), modified: d.modified.slice(0, maxItems), counts: { added: d.added.length, removed: d.removed.length, modified: d.modified.length }, truncated: d.added.length > maxItems || d.removed.length > maxItems, }; } export function diffIsEmpty(d: DiffResult): boolean { if (d.kind === "text") return d.added.length === 0 && d.removed.length === 0 && d.modified.length === 0; if (d.kind === "json") return d.changes.length === 0; return d.added.length === 0 && d.removed.length === 0 && d.modified.length === 0; }