Contract redline summary
@dealdesk/contract-redline-summary
Compare two versions of a contract (or one redline with insert and delete markup) clause by clause. Lists every change sorted by risk topic (liability, indemnity, termination, renewal, IP, data, payment) with the numbers that moved, then explains each in plain English with a suggested response. Not legal advice.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// contract-redline-summary: compare two versions of a contract clause by clause,
- 3// tag each change by risk topic, and pull out changed numbers (days, amounts,
- 4// percentages, months). Node 22, built-ins only. Not legal advice.
- 5//
- 6// Usage:
- 7// node scripts/clause-diff.mjs <original.txt> <revised.txt> [--out out] [--name redline]
- 8// node scripts/clause-diff.mjs --markup <redline.md> [--out out] [--name redline]
- 9// (markup: {++added++} {--removed--}, or [+added+] [-removed-])
- 11import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
- 12import { join } from "node:path";
- 14function fail(msg) {
- 15 console.error(`contract-redline-summary: ${msg}`);
- 16 process.exit(1);
- 17}
- 19const args = process.argv.slice(2);
- 20const opts = { out: "out", name: "redline", markup: null };
- 21const pos = [];
- 22for (let i = 0; i < args.length; i++) {
- 23 if (args[i] === "--out") opts.out = args[++i];
- 24 else if (args[i] === "--name") opts.name = args[++i];
- 25 else if (args[i] === "--markup") opts.markup = args[++i];
- 26 else if (args[i] === "--help" || args[i] === "-h") {
- 27 console.log("Usage: node scripts/clause-diff.mjs <original.txt> <revised.txt> [--out out] [--name redline]\n node scripts/clause-diff.mjs --markup <redline.md> [--out out]");
- 28 process.exit(0);
- 29 } else pos.push(args[i]);
- 30}
- 31const read = (p) => {
- 32 try {
- 33 return readFileSync(p, "utf8").replace(/\r\n?/g, "\n");
- 34 } catch {
- 35 fail(`cannot read ${p}`);
- 36 }
- 37};
- 39let oldText;
- 40let newText;
- 41if (opts.markup) {
- 42 const t = read(opts.markup);
- 43 const ins = /\{\+\+([\s\S]*?)\+\+\}|\[\+([\s\S]*?)\+\]/g;
- 44 const del = /\{--([\s\S]*?)--\}|\[-([\s\S]*?)-\]/g;
- 45 oldText = t.replace(ins, "").replace(del, (_, a, b) => a ?? b);
- 46 newText = t.replace(del, "").replace(ins, (_, a, b) => a ?? b);
- 47 if (oldText === newText) fail("no {++added++} or {--removed--} markup found in the redline file");
- 48} else {
- 49 if (pos.length !== 2) fail("give the original and the revised file, or --markup <file>. Try --help.");
- 50 oldText = read(pos[0]);
- 51 newText = read(pos[1]);
- 52}
- 54// ---------- split into clauses ----------
- 55const HEADING = /^\s*(?:#{1,6}\s*)?(?:(?:section|article|clause)\s+)?(\d+(?:\.\d+)*)[.)]?\s+(\S.*)$/i;
- 56function clauses(text) {
- 57 const lines = text.split("\n");
- 58 const numbered = lines.filter((l) => HEADING.test(l)).length;
- 59 const out = [];
- 60 if (numbered >= 2) {
- 61 let cur = null;
- 62 for (const line of lines) {
- 63 const m = line.match(HEADING);
- 64 if (m) {
- 65 if (cur) out.push(cur);
- 66 cur = { key: m[1], title: m[2].trim(), body: [line] };
- 67 } else if (cur) cur.body.push(line);
- 68 else if (line.trim()) {
- 69 cur = { key: "preamble", title: "Preamble", body: [line] };
- 70 }
- 71 }
- 72 if (cur) out.push(cur);
- 73 } else {
- 74 text
- 75 .split(/\n\s*\n/)
- 76 .map((p) => p.trim())
- 77 .filter(Boolean)
- 78 .forEach((p, i) => out.push({ key: `para-${i + 1}`, title: p.split(/\s+/).slice(0, 8).join(" "), body: [p] }));
- 79 }
- 80 return out.map((c, i) => {
- 81 const text = c.body.join("\n").trim();
- 82 return { ...c, index: i, text, norm: text.replace(/\s+/g, " ").trim() };
- 83 });
- 84}
- 85const A = clauses(oldText);
- 86const B = clauses(newText);
- 88// ---------- align ----------
- 89const words = (s) => new Set(s.toLowerCase().match(/[a-z0-9]+/g) || []);
- 90function jaccard(a, b) {
- 91 const x = words(a);
- 92 const y = words(b);
- 93 let inter = 0;
- 94 for (const w of x) if (y.has(w)) inter++;
- 95 return inter / (x.size + y.size - inter || 1);
- 96}
- 97const pairs = [];
- 98const usedB = new Set();
- 99const usedA = new Set();
- 100const numberedMode = A.some((c) => /^\d/.test(c.key)) && B.some((c) => /^\d/.test(c.key));
- 101// 1. identical text anywhere
- 102for (const a of A) {
- 103 const b = B.find((x) => !usedB.has(x.index) && x.norm === a.norm);
- 104 if (b) {
- 105 pairs.push({ a, b });
- 106 usedA.add(a.index);
- 107 usedB.add(b.index);
- 108 }
- 109}
- 110// 2. same clause number with similar text (numbered contracts)
- 111if (numberedMode)
- 112 for (const a of A) {
- 113 if (usedA.has(a.index)) continue;
- 114 const sameTitle = (x) => x.title.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim() === a.title.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
- 115 const b = B.find((x) => !usedB.has(x.index) && x.key === a.key && (sameTitle(x) || jaccard(a.norm, x.norm) >= 0.3));
- 116 if (b) {
- 117 pairs.push({ a, b });
- 118 usedA.add(a.index);
- 119 usedB.add(b.index);
- 120 }
- 121 }
- 122// 3. best similar text
- 123for (const a of A) {
- 124 if (usedA.has(a.index)) continue;
- 125 let best = null;
- 126 let score = 0.5;
- 127 for (const b of B) {
- 128 if (usedB.has(b.index)) continue;
- 129 const s = jaccard(a.norm, b.norm);
- 130 if (s > score) {
- 131 score = s;
- 132 best = b;
- 133 }
- 134 }
- 135 if (best) {
- 136 pairs.push({ a, b: best });
- 137 usedA.add(a.index);
- 138 usedB.add(best.index);
- 139 }
- 140}
- 142// ---------- word diff ----------
- 143const tokens = (s) => s.match(/[$€£]?\d[\d,.]*%?|[A-Za-z]+(?:['-][A-Za-z]+)*|[^\sA-Za-z0-9]/g) || [];
- 144const meaningful = (snip) => /[A-Za-z0-9]/.test(snip);
- 145function diff(a, b) {
- 146 const x = tokens(a);
- 147 const y = tokens(b);
- 148 if (x.length * y.length > 4e6) return { added: [b], removed: [a] };
- 149 const dp = Array.from({ length: x.length + 1 }, () => new Uint32Array(y.length + 1));
- 150 for (let i = x.length - 1; i >= 0; i--)
- 151 for (let j = y.length - 1; j >= 0; j--) dp[i][j] = x[i] === y[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
- 152 const added = [];
- 153 const removed = [];
- 154 let i = 0;
- 155 let j = 0;
- 156 let curA = [];
- 157 let curR = [];
- 158 const flush = () => {
- 159 const join = (t) => t.join(" ").replace(/\s+([.,;:!?)%])/g, "$1").replace(/([($])\s+/g, "$1");
- 160 if (curA.length) added.push(join(curA));
- 161 if (curR.length) removed.push(join(curR));
- 162 curA = [];
- 163 curR = [];
- 164 };
- 165 while (i < x.length || j < y.length) {
- 166 if (i < x.length && j < y.length && x[i] === y[j]) {
- 167 flush();
- 168 i++;
- 169 j++;
- 170 } else if (j < y.length && (i >= x.length || dp[i][j + 1] >= dp[i + 1][j])) curA.push(y[j++]);
- 171 else curR.push(x[i++]);
- 172 }
- 173 flush();
- 174 return { added: added.filter(meaningful), removed: removed.filter(meaningful) };
- 175}
- 177// ---------- risk ----------
- 178const TOPICS = [
- 179 ["high", "limitation of liability", /limitation of liability|aggregate liability|liability (?:is |shall be |will be )?(?:capped|limited)|consequential|indirect damages|uncapped|unlimited liability/i],
- 180 ["high", "indemnification", /indemnif|hold harmless|defend\b/i],
- 181 ["high", "termination", /terminat/i],
- 182 ["high", "renewal", /renew/i],
- 183 ["high", "intellectual property", /intellectual property|ownership|work product|license grant|derivative/i],
- 184 ["high", "exclusivity or MFN", /exclusiv|most favou?red|non-compete|non-solicit/i],
- 185 ["high", "data protection and security", /personal data|data protection|security incident|breach notification|subprocessor|privacy/i],
- 186 ["high", "governing law and disputes", /governing law|jurisdiction|venue|arbitrat/i],
- 187 ["high", "assignment", /\bassign|change of control/i],
- 188 ["high", "audit rights", /\baudit/i],
- 189 ["medium", "payment terms", /payment|invoice|\bfees?\b|net \d+|late charge|interest/i],
- 190 ["medium", "price changes", /increase|uplift|\bcpi\b|price adjust/i],
- 191 ["medium", "service levels", /service level|uptime|availability|service credit/i],
- 192 ["medium", "warranty", /warrant/i],
- 193 ["medium", "confidentiality", /confidential/i],
- 194 ["medium", "insurance", /insurance/i],
- 195 ["medium", "notice", /\bnotice\b/i],
- 196 ["medium", "acceptance", /accept/i],
- 197];
- 198const RANK = { high: 0, medium: 1, low: 2 };
- 199const NUM = /(?:[$€£]\s?)?\d[\d,]*(?:\.\d+)?\s?(?:%|percent|days?|months?|years?|x|times)?/gi;
- 200function classify(changedText, context) {
- 201 const hits = TOPICS.filter(([, , re]) => re.test(changedText) || re.test(context));
- 202 const level = hits.length ? hits.reduce((lv, h) => (RANK[h[0]] < RANK[lv] ? h[0] : lv), "low") : "low";
- 203 return { level, topics: [...new Set(hits.map((h) => h[1]))] };
- 204}
- 205const stripHeading = (s) => s.replace(/^\s*(?:#{1,6}\s*)?(?:(?:section|article|clause)\s+)?\d+(?:\.\d+)*[.)]?\s+/i, "");
- 206const nums = (arr) => [...new Set(arr.join(" ").match(NUM) || [])].map((s) => s.trim()).filter((s) => /\d/.test(s));
- 208const changes = [];
- 209for (const { a, b } of pairs) {
- 210 if (a.norm === b.norm) {
- 211 if (numberedMode && a.key !== b.key)
- 212 changes.push({ type: "renumbered", clause: b.key, was: a.key, title: b.title, added: [], removed: [], numbers: { before: [], after: [] }, risk: "low", topics: [] });
- 213 continue;
- 214 }
- 215 const d = diff(a.norm, b.norm);
- 216 const c = classify([...d.added, ...d.removed].join(" "), b.title + " " + a.title);
- 217 const before = nums(d.removed);
- 218 const after = nums(d.added);
- 219 changes.push({ type: "changed", clause: b.key, was: a.key !== b.key ? a.key : undefined, title: b.title, added: d.added, removed: d.removed, numbers: { before, after }, risk: c.level, topics: c.topics, position: b.index });
- 220}
- 221for (const b of B)
- 222 if (!usedB.has(b.index)) {
- 223 const c = classify(b.norm, b.title);
- 224 changes.push({ type: "added", clause: b.key, title: b.title, added: [b.norm], removed: [], numbers: { before: [], after: nums([stripHeading(b.norm)]) }, risk: c.level === "low" ? "medium" : c.level, topics: c.topics, position: b.index });
- 225 }
- 226for (const a of A)
- 227 if (!usedA.has(a.index)) {
- 228 const c = classify(a.norm, a.title);
- 229 changes.push({ type: "removed", clause: a.key, title: a.title, added: [], removed: [a.norm], numbers: { before: nums([stripHeading(a.norm)]), after: [] }, risk: c.level === "low" ? "medium" : c.level, topics: c.topics, position: a.index });
- 230 }
- 231changes.sort((x, y) => RANK[x.risk] - RANK[y.risk] || (x.position ?? 0) - (y.position ?? 0));
- 232for (const c of changes) delete c.position;
- 234const count = (f) => changes.filter(f).length;
- 235const summary = {
- 236 clauses_original: A.length,
- 237 clauses_revised: B.length,
- 238 changed: count((c) => c.type === "changed"),
- 239 added: count((c) => c.type === "added"),
- 240 removed: count((c) => c.type === "removed"),
- 241 renumbered: count((c) => c.type === "renumbered"),
- 242 high: count((c) => c.risk === "high"),
- 243 medium: count((c) => c.risk === "medium"),
- 244 low: count((c) => c.risk === "low"),
- 245};
- 247const clip = (s, n = 240) => (s.length > n ? s.slice(0, n - 3) + "..." : s);
- 248const md = [`# Redline summary`, ""];
- 249md.push(`${summary.high} high, ${summary.medium} medium, ${summary.low} low risk changes. ${summary.changed} changed, ${summary.added} added, ${summary.removed} removed, ${summary.renumbered} renumbered.`, "");
- 250md.push("Risk tags come from keywords. They show where to look, not whether a change is good or bad. Not legal advice.", "");
- 251for (const level of ["high", "medium", "low"]) {
- 252 const list = changes.filter((c) => c.risk === level);
- 253 if (!list.length) continue;
- 254 md.push(`## ${level[0].toUpperCase() + level.slice(1)} risk`, "");
- 255 for (const c of list) {
- 256 md.push(`### ${c.clause} ${c.title} (${c.type}${c.was ? `, was ${c.was}` : ""})`, "");
- 257 if (c.topics.length) md.push(`Topics: ${c.topics.join(", ")}`, "");
- 258 if (c.numbers.before.length || c.numbers.after.length) md.push(`Numbers: ${c.numbers.before.join(", ") || "none"} -> ${c.numbers.after.join(", ") || "none"}`, "");
- 259 for (const r of c.removed) md.push(`- Removed: "${clip(r)}"`);
- 260 for (const ad of c.added) md.push(`- Added: "${clip(ad)}"`);
- 261 md.push("");
- 262 }
- 263}
- 265mkdirSync(opts.out, { recursive: true });
- 266writeFileSync(join(opts.out, `${opts.name}.json`), JSON.stringify({ summary, changes }, null, 2) + "\n");
- 267writeFileSync(join(opts.out, `${opts.name}.md`), md.join("\n"));
- 268console.log(`${changes.length} change(s): ${summary.high} high, ${summary.medium} medium, ${summary.low} low. Wrote ${join(opts.out, opts.name)}.{md,json}`);
