TypeScript 55.4%
Python 43.2%
SQL 1.2%
1import { diffLines, createTwoFilesPatch } from "diff";23/**4 * Diff engines. All produce a `DiffResult` with a compact, storable summary plus the5 * unified patch so the UI can render unified / side-by-side / semantic / raw views.6 */78export interface TextDiff {9 kind: "text";10 added: string[];11 removed: string[];12 /** Pairs of (removed → added) that look like modifications of the same line. */13 modified: { before: string; after: string }[];14 unified: string;15 stats: { added: number; removed: number; modified: number; unchangedRatio: number };16}1718export interface JsonDiff {19 kind: "json";20 changes: { path: string; op: "add" | "remove" | "replace"; before?: unknown; after?: unknown }[];21 unified: string;22}2324export interface ListDiff {25 kind: "list";26 added: ListItem[];27 removed: ListItem[];28 modified: { key: string; before: ListItem; after: ListItem; fields: string[] }[];29 unified: string;30}3132export interface ListItem {33 key: string;34 [k: string]: unknown;35}3637export type DiffResult = TextDiff | JsonDiff | ListDiff;3839function similarity(a: string, b: string): number {40 if (a === b) return 1;41 const la = a.length;42 const lb = b.length;43 if (!la || !lb) return 0;44 // cheap: common prefix + suffix ratio45 let p = 0;46 while (p < la && p < lb && a[p] === b[p]) p++;47 let s = 0;48 while (s < la - p && s < lb - p && a[la - 1 - s] === b[lb - 1 - s]) s++;49 return (p + s) / Math.max(la, lb);50}5152export function diffText(before: string, after: string, labelBefore = "before", labelAfter = "after"): TextDiff {53 const parts = diffLines(before, after, { newlineIsToken: false });54 const added: string[] = [];55 const removed: string[] = [];56 let unchanged = 0;57 let total = 0;58 for (const part of parts) {59 const lines = part.value.split("\n").filter((l) => l.length);60 total += lines.length;61 if (part.added) added.push(...lines);62 else if (part.removed) removed.push(...lines);63 else unchanged += lines.length;64 }65 // Pair removed/added lines that look like edits of the same line.66 const modified: { before: string; after: string }[] = [];67 const usedAdded = new Set<number>();68 const remainingRemoved: string[] = [];69 for (const r of removed) {70 let best = -1;71 let bestScore = 0.55;72 for (let i = 0; i < added.length; i++) {73 if (usedAdded.has(i)) continue;74 const sc = similarity(r, added[i]!);75 if (sc > bestScore) {76 bestScore = sc;77 best = i;78 }79 }80 if (best >= 0) {81 usedAdded.add(best);82 modified.push({ before: r, after: added[best]! });83 } else remainingRemoved.push(r);84 }85 const remainingAdded = added.filter((_, i) => !usedAdded.has(i));86 const unified = createTwoFilesPatch(labelBefore, labelAfter, before, after, "", "", { context: 2 });87 return {88 kind: "text",89 added: remainingAdded,90 removed: remainingRemoved,91 modified,92 unified,93 stats: { added: remainingAdded.length, removed: remainingRemoved.length, modified: modified.length, unchangedRatio: total ? unchanged / total : 1 },94 };95}9697export function diffJson(before: unknown, after: unknown): JsonDiff {98 const changes: JsonDiff["changes"] = [];99 const walk = (a: unknown, b: unknown, path: string): void => {100 if (JSON.stringify(a) === JSON.stringify(b)) return;101 const aObj = a && typeof a === "object" && !Array.isArray(a);102 const bObj = b && typeof b === "object" && !Array.isArray(b);103 if (aObj && bObj) {104 const keys = new Set([...Object.keys(a as object), ...Object.keys(b as object)]);105 for (const k of keys) {106 const p = path ? `${path}.${k}` : k;107 const av = (a as Record<string, unknown>)[k];108 const bv = (b as Record<string, unknown>)[k];109 if (av === undefined) changes.push({ path: p, op: "add", after: bv });110 else if (bv === undefined) changes.push({ path: p, op: "remove", before: av });111 else walk(av, bv, p);112 }113 return;114 }115 if (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.length <= 200) {116 for (let i = 0; i < a.length; i++) walk(a[i], b[i], `${path}[${i}]`);117 return;118 }119 changes.push({ path: path || "$", op: "replace", before: a, after: b });120 };121 walk(before, after, "");122 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");123 return { kind: "json", changes, unified };124}125126/** Diff keyed lists (feed items, sitemap URLs, releases, incidents). */127export function diffList(before: ListItem[], after: ListItem[], compareFields: string[] = []): ListDiff {128 const bm = new Map(before.map((i) => [i.key, i]));129 const am = new Map(after.map((i) => [i.key, i]));130 const added: ListItem[] = [];131 const removed: ListItem[] = [];132 const modified: ListDiff["modified"] = [];133 for (const [k, item] of am) {134 const prev = bm.get(k);135 if (!prev) {136 added.push(item);137 continue;138 }139 const fields = compareFields.filter((f) => JSON.stringify(prev[f]) !== JSON.stringify(item[f]));140 if (fields.length) modified.push({ key: k, before: prev, after: item, fields });141 }142 for (const [k, item] of bm) if (!am.has(k)) removed.push(item);143 const label = (i: ListItem): string => String(i.title ?? i.url ?? i.name ?? i.key);144 const unified = [...added.map((i) => `+ ${label(i)}`), ...removed.map((i) => `- ${label(i)}`), ...modified.map((m) => `~ ${label(m.after)} (${m.fields.join(", ")})`)].join("\n");145 return { kind: "list", added, removed, modified, unified };146}147148/** Compact, storable version of a diff for the `changes` table (bounded size). */149export function summarizeDiff(d: DiffResult, maxItems = 40, maxLen = 400): Record<string, unknown> {150 const cut = (s: string): string => (s.length > maxLen ? s.slice(0, maxLen) + "…" : s);151 if (d.kind === "text") {152 return {153 kind: d.kind,154 added: d.added.slice(0, maxItems).map(cut),155 removed: d.removed.slice(0, maxItems).map(cut),156 modified: d.modified.slice(0, maxItems).map((m) => ({ before: cut(m.before), after: cut(m.after) })),157 stats: d.stats,158 truncated: d.added.length > maxItems || d.removed.length > maxItems || d.modified.length > maxItems,159 };160 }161 if (d.kind === "json") return { kind: d.kind, changes: d.changes.slice(0, maxItems), truncated: d.changes.length > maxItems };162 return {163 kind: d.kind,164 added: d.added.slice(0, maxItems),165 removed: d.removed.slice(0, maxItems),166 modified: d.modified.slice(0, maxItems),167 counts: { added: d.added.length, removed: d.removed.length, modified: d.modified.length },168 truncated: d.added.length > maxItems || d.removed.length > maxItems,169 };170}171172export function diffIsEmpty(d: DiffResult): boolean {173 if (d.kind === "text") return d.added.length === 0 && d.removed.length === 0 && d.modified.length === 0;174 if (d.kind === "json") return d.changes.length === 0;175 return d.added.length === 0 && d.removed.length === 0 && d.modified.length === 0;176}177