/** * Markdown canonicalization for deterministic output. * PRD §8.4 - Canonical Markdown conventions * * CRITICAL: These rules are a compatibility contract. * Changing them invalidates all existing mirrorHash values. */ /** * Control character pattern built dynamically to avoid lint issues with literal control chars. * Matches U+0000-U+0008, U+000B-U+000C, U+000E-U+001F, U+007F (excludes \n and \t) */ const CONTROL_CHAR_PATTERN = new RegExp( `[${String.fromCharCode(0)}-${String.fromCharCode(8)}${String.fromCharCode(11)}${String.fromCharCode(12)}${String.fromCharCode(14)}-${String.fromCharCode(31)}${String.fromCharCode(127)}]`, "g" ); /** * Canonicalize markdown to ensure deterministic output. * * Rules (PRD §8.4): * 0. Strip BOM (U+FEFF) if present * 1. Normalize to \n newlines (no \r) * 2. Apply NFC Unicode normalization (cross-platform hash stability) * 3. Strip control chars U+0000-U+001F and U+007F except \n (U+000A) and \t (U+0009) * 4. Trim trailing whitespace per line * 5. Treat whitespace-only lines as blank (trim first, then count) * 6. Collapse 2+ consecutive blank lines to exactly 1 (content\n\ncontent) * 7. Ensure exactly one final \n */ export function canonicalize(markdown: string): string { if (!markdown) { return "\n"; } // 0. Strip BOM if present (U+FEFF) - ensures deterministic hashing let result = markdown.startsWith("\uFEFF") ? markdown.slice(1) : markdown; // 1. Normalize line endings: \r\n → \n, lone \r → \n result = result.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); // 2. Apply NFC Unicode normalization result = result.normalize("NFC"); // 3. Strip control characters except \n (U+000A) and \t (U+0009) // Range: U+0000-U+0008, U+000B-U+000C, U+000E-U+001F, U+007F result = result.replace(CONTROL_CHAR_PATTERN, ""); // 4. Trim trailing whitespace per line and // 5. Treat whitespace-only lines as blank const lines = result.split("\n").map((line) => line.trimEnd()); // 6. Collapse multiple blank lines to exactly 1 // (i.e., content\n\ncontent between paragraphs) const collapsed: string[] = []; let blankCount = 0; for (const line of lines) { if (line === "") { blankCount += 1; // Only keep one blank line between content if (blankCount === 1) { collapsed.push(line); } } else { blankCount = 0; collapsed.push(line); } } // 7. Ensure exactly one final \n // Remove trailing blank lines first while (collapsed.length > 0 && collapsed.at(-1) === "") { collapsed.pop(); } // Join and add single final newline return `${collapsed.join("\n")}\n`; } /** * Compute SHA-256 hash of canonical markdown. * Returns lowercase hex string (64 chars). */ export function mirrorHash(canonical: string): string { const hasher = new Bun.CryptoHasher("sha256"); hasher.update(canonical); return hasher.digest("hex"); }