import { computeLineHash } from "./hash-computation" import { HASHLINE_REF_PATTERN } from "./constants" export interface LineRef { line: number hash: string } interface HashMismatch { line: number expected: string } const MISMATCH_CONTEXT = 2 const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/ function normalizeLineRef(ref: string): string { const trimmed = ref.trim() if (HASHLINE_REF_PATTERN.test(trimmed)) { return trimmed } const extracted = trimmed.match(LINE_REF_EXTRACT_PATTERN) if (extracted) { return extracted[1] } return trimmed } export function parseLineRef(ref: string): LineRef { const normalized = normalizeLineRef(ref) const match = normalized.match(HASHLINE_REF_PATTERN) if (match) { return { line: Number.parseInt(match[1], 10), hash: match[2], } } throw new Error( `Invalid line reference format: "${ref}". Expected format: "LINE#ID" (e.g., "42#VK")` ) } export function validateLineRef(lines: string[], ref: string): void { const { line, hash } = parseLineRef(ref) if (line < 1 || line > lines.length) { throw new Error( `Line number ${line} out of bounds. File has ${lines.length} lines.` ) } const content = lines[line - 1] const currentHash = computeLineHash(line, content) if (currentHash !== hash) { throw new HashlineMismatchError([{ line, expected: hash }], lines) } } export class HashlineMismatchError extends Error { readonly remaps: ReadonlyMap constructor( private readonly mismatches: HashMismatch[], private readonly fileLines: string[] ) { super(HashlineMismatchError.formatMessage(mismatches, fileLines)) this.name = "HashlineMismatchError" const remaps = new Map() for (const mismatch of mismatches) { const actual = computeLineHash(mismatch.line, fileLines[mismatch.line - 1] ?? "") remaps.set(`${mismatch.line}#${mismatch.expected}`, `${mismatch.line}#${actual}`) } this.remaps = remaps } static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string { const mismatchByLine = new Map() for (const mismatch of mismatches) mismatchByLine.set(mismatch.line, mismatch) const displayLines = new Set() for (const mismatch of mismatches) { const low = Math.max(1, mismatch.line - MISMATCH_CONTEXT) const high = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT) for (let line = low; line <= high; line++) displayLines.add(line) } const sortedLines = [...displayLines].sort((a, b) => a - b) const output: string[] = [] output.push( `${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. ` + "Use updated LINE#ID references below (>>> marks changed lines)." ) output.push("") let previousLine = -1 for (const line of sortedLines) { if (previousLine !== -1 && line > previousLine + 1) { output.push(" ...") } previousLine = line const content = fileLines[line - 1] ?? "" const hash = computeLineHash(line, content) const prefix = `${line}#${hash}|${content}` if (mismatchByLine.has(line)) { output.push(`>>> ${prefix}`) } else { output.push(` ${prefix}`) } } return output.join("\n") } } export function validateLineRefs(lines: string[], refs: string[]): void { const mismatches: HashMismatch[] = [] for (const ref of refs) { const { line, hash } = parseLineRef(ref) if (line < 1 || line > lines.length) { throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`) } const content = lines[line - 1] const currentHash = computeLineHash(line, content) if (currentHash !== hash) { mismatches.push({ line, expected: hash }) } } if (mismatches.length > 0) { throw new HashlineMismatchError(mismatches, lines) } }