/** * KHAELOR * File: src/tools/replacers.ts * Description: The nine-strategy edit replacer cascade with uniqueness and disproportion guards (TOOL_PROTOCOL §4.3–4.4). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ /** Strategy names, in cascade order (TOOL_PROTOCOL §4.3). */ export type ReplacerStrategy = | "exact" | "line-trimmed" | "whitespace-normalized" | "indentation-flexible" | "escape-normalized" | "trimmed-boundary" | "block-anchor" | "context-aware" | "multi-occurrence"; /** Model-facing phrase for the success parenthetical (§4.6). */ export const STRATEGY_PHRASES: Record = { exact: "matched exactly", "line-trimmed": "matched with line-trimmed whitespace", "whitespace-normalized": "matched with whitespace normalization", "indentation-flexible": "matched with indentation flexibility", "escape-normalized": "matched with escape normalization", "trimmed-boundary": "matched at trimmed boundaries", "block-anchor": "matched with block anchors", "context-aware": "matched with context anchors", "multi-occurrence": "matched exactly", }; /** One replacement site: [start, end) character span and the text to insert. */ export interface MatchSpan { start: number; end: number; replacement: string; } export interface NearMiss { /** 1-based line range of the closest candidate block. */ startLine: number; endLine: number; /** 1-based line number of the first differing line. */ diffLine: number; kind: "whitespace" | "content"; expected: string; actual: string; } export type CascadeResult = | { kind: "match"; strategy: ReplacerStrategy; matches: MatchSpan[] } | { kind: "disproportionate"; candidateLength: number; oldLength: number } | { kind: "not-found"; nearMiss?: NearMiss }; // ─────────────────────────── line indexing ─────────────────────────── interface LineIndex { lines: string[]; /** Character offset of each line start. */ starts: number[]; } function indexLines(text: string): LineIndex { const lines = text.split("\n"); const starts: number[] = new Array(lines.length); let offset = 0; for (let i = 0; i < lines.length; i++) { starts[i] = offset; offset += (lines[i] as string).length + 1; } return { lines, starts }; } /** Character span covering lines [i, i+count) without the trailing newline. */ function spanOf(index: LineIndex, i: number, count: number): { start: number; end: number } { const lastLine = i + count - 1; return { start: index.starts[i] as number, end: (index.starts[lastLine] as number) + (index.lines[lastLine] as string).length, }; } /** * For line-based strategies: a single trailing newline on old_string is * stripped (with the symmetric strip on new_string) so window spans replace * whole lines without duplicating the file's own newline. */ function stripTrailingNewline(oldString: string, newString: string): [string, string] { if (!oldString.endsWith("\n")) return [oldString, newString]; return [ oldString.slice(0, -1), newString.endsWith("\n") ? newString.slice(0, -1) : newString, ]; } // ─────────────────────────── small helpers ─────────────────────────── function exactScan(text: string, needle: string, replacement: string): MatchSpan[] { const matches: MatchSpan[] = []; if (needle.length === 0) return matches; let from = 0; for (;;) { const at = text.indexOf(needle, from); if (at === -1) break; matches.push({ start: at, end: at + needle.length, replacement }); from = at + needle.length; } return matches; } function normalizeWhitespace(s: string): string { return s.replace(/\s+/g, " ").trim(); } function leadingWhitespace(line: string): string { return (/^[ \t]*/.exec(line) as RegExpExecArray)[0]; } function commonIndentLength(lines: string[]): number { let min = Number.POSITIVE_INFINITY; for (const line of lines) { if (line.trim().length === 0) continue; min = Math.min(min, leadingWhitespace(line).length); } return Number.isFinite(min) ? min : 0; } function unescapeLiterals(s: string): string { return s.replace(/\\(n|t|r|'|"|`|\\)/g, (_all, ch: string) => { switch (ch) { case "n": return "\n"; case "t": return "\t"; case "r": return "\r"; default: return ch; } }); } const MAX_LEVENSHTEIN_CHARS = 10_000; /** Similarity ratio in [0, 1] — 1 means identical. */ function levenshteinRatio(a: string, b: string): number { if (a === b) return 1; const maxLen = Math.max(a.length, b.length); if (maxLen === 0) return 1; if (maxLen > MAX_LEVENSHTEIN_CHARS) return 0; let previous = new Uint32Array(b.length + 1); let current = new Uint32Array(b.length + 1); for (let j = 0; j <= b.length; j++) previous[j] = j; for (let i = 1; i <= a.length; i++) { current[0] = i; for (let j = 1; j <= b.length; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; current[j] = Math.min( (previous[j] as number) + 1, (current[j - 1] as number) + 1, (previous[j - 1] as number) + cost, ); } [previous, current] = [current, previous]; } return 1 - (previous[b.length] as number) / maxLen; } /** Disproportionate-match guard threshold (TOOL_PROTOCOL §4.4). */ function disproportionLimit(oldLength: number): number { return Math.max(3 * oldLength, oldLength + 1000); } // ─────────────────────────── strategies ─────────────────────────── /** * Strategy 2 — line-trimmed. Tolerates per-line trailing-whitespace drift * (each line's leading whitespace must still match). Leading-indent drift is * deliberately left to strategies 3/4 so that indentation-flexible matching * (and its re-indentation of new_string) stays reachable — with full-trim * comparison here, §4.3 strategy 4 would be dead code. */ function lineTrimmed(index: LineIndex, oldBody: string, newBody: string): MatchSpan[] { const oldLines = oldBody.split("\n"); const count = oldLines.length; const matches: MatchSpan[] = []; if (count === 0 || oldBody.trim().length === 0) return matches; for (let i = 0; i + count <= index.lines.length; i++) { let all = true; for (let k = 0; k < count; k++) { const fileLine = index.lines[i + k] as string; const oldLine = oldLines[k] as string; if ( fileLine.trim() !== oldLine.trim() || leadingWhitespace(fileLine) !== leadingWhitespace(oldLine) ) { all = false; break; } } if (all) matches.push({ ...spanOf(index, i, count), replacement: newBody }); } return matches; } /** * Strategy 3 — whitespace-normalized. Single-line old_strings only: all runs * of whitespace collapsed to single spaces on both sides. Multi-line * whitespace drift belongs to strategy 4 (which preserves file indentation). */ function whitespaceNormalized(index: LineIndex, oldBody: string, newBody: string): MatchSpan[] { const matches: MatchSpan[] = []; if (oldBody.includes("\n")) return matches; const target = normalizeWhitespace(oldBody); if (target.length === 0) return matches; // needs ≥1 non-whitespace token for (let i = 0; i < index.lines.length; i++) { const fileLine = index.lines[i] as string; if (normalizeWhitespace(fileLine) !== target) continue; // Preserve the file's own leading whitespace when the replacement is single-line. const replacement = newBody.includes("\n") ? newBody : leadingWhitespace(fileLine) + newBody.trimStart(); matches.push({ ...spanOf(index, i, 1), replacement }); } return matches; } function indentationFlexible(index: LineIndex, oldBody: string, newBody: string): MatchSpan[] { const oldLines = oldBody.split("\n"); const count = oldLines.length; const matches: MatchSpan[] = []; if (oldBody.trim().length === 0) return matches; const oldIndent = commonIndentLength(oldLines); const oldStripped = oldLines.map((l) => (l.trim().length === 0 ? "" : l.slice(oldIndent).trimEnd())); for (let i = 0; i + count <= index.lines.length; i++) { const window = index.lines.slice(i, i + count); const fileIndentLen = commonIndentLength(window); let all = true; for (let k = 0; k < count; k++) { const fileLine = window[k] as string; const stripped = fileLine.trim().length === 0 ? "" : fileLine.slice(fileIndentLen).trimEnd(); if (stripped !== oldStripped[k]) { all = false; break; } } if (!all) continue; // Re-apply the FILE's indentation to new_string (indentation preservation). const firstContent = window.find((l) => l.trim().length > 0) ?? ""; const fileIndent = leadingWhitespace(firstContent).slice(0, fileIndentLen); const newLines = newBody.split("\n"); const newIndentLen = commonIndentLength(newLines); const reindented = newLines .map((l) => (l.trim().length === 0 ? l : fileIndent + l.slice(newIndentLen))) .join("\n"); matches.push({ ...spanOf(index, i, count), replacement: reindented }); } return matches; } interface AnchorScan { matches: MatchSpan[]; guardHit?: { candidateLength: number }; } function blockAnchor( index: LineIndex, oldBody: string, newBody: string, oldRawLength: number, ): AnchorScan { const oldLines = oldBody.split("\n"); const count = oldLines.length; if (count < 3) return { matches: [] }; const first = (oldLines[0] as string).trim(); const last = (oldLines[count - 1] as string).trim(); const oldMiddle = oldLines .slice(1, -1) .map((l) => l.trim()) .join("\n"); const minLines = Math.max(3, Math.floor(count * 0.75)); const maxLines = Math.ceil(count * 1.25); const limit = disproportionLimit(oldRawLength); let guardHit: { candidateLength: number } | undefined; const scored: Array<{ span: { start: number; end: number }; score: number }> = []; for (let i = 0; i < index.lines.length; i++) { if ((index.lines[i] as string).trim() !== first) continue; for (let size = minLines; size <= maxLines; size++) { const j = i + size - 1; if (j >= index.lines.length) break; if ((index.lines[j] as string).trim() !== last) continue; const span = spanOf(index, i, size); const candidateLength = span.end - span.start; if (candidateLength > limit) { guardHit = { candidateLength }; continue; } const candidateMiddle = index.lines .slice(i + 1, j) .map((l) => l.trim()) .join("\n"); const score = levenshteinRatio(oldMiddle, candidateMiddle); if (score >= 0.65) scored.push({ span, score }); } } if (scored.length === 0) return guardHit !== undefined ? { matches: [], guardHit } : { matches: [] }; const best = Math.max(...scored.map((c) => c.score)); const winners = scored.filter((c) => c.score === best); const result: AnchorScan = { matches: winners.map((c) => ({ ...c.span, replacement: newBody })), }; if (guardHit !== undefined) result.guardHit = guardHit; return result; } function contextAware( index: LineIndex, oldBody: string, newBody: string, oldRawLength: number, ): AnchorScan { const oldLines = oldBody.split("\n"); const count = oldLines.length; if (count < 3) return { matches: [] }; const first = (oldLines[0] as string).trim(); const last = (oldLines[count - 1] as string).trim(); const middle = oldLines.slice(1, -1).map((l) => l.trim()); const limit = disproportionLimit(oldRawLength); let guardHit: { candidateLength: number } | undefined; const matches: MatchSpan[] = []; for (let i = 0; i + count <= index.lines.length; i++) { if ((index.lines[i] as string).trim() !== first) continue; if ((index.lines[i + count - 1] as string).trim() !== last) continue; let equal = 0; for (let k = 0; k < middle.length; k++) { if ((index.lines[i + 1 + k] as string).trim() === middle[k]) equal += 1; } if (middle.length > 0 && equal / middle.length < 0.5) continue; const span = spanOf(index, i, count); if (span.end - span.start > limit) { guardHit = { candidateLength: span.end - span.start }; continue; } matches.push({ ...span, replacement: newBody }); } const result: AnchorScan = { matches }; if (guardHit !== undefined) result.guardHit = guardHit; return result; } // ─────────────────────────── near-miss search ─────────────────────────── /** Best same-length window by fraction of trimmed-equal lines (for the not-found message). */ export function findNearMiss(text: string, oldString: string): NearMiss | undefined { const [oldBody] = stripTrailingNewline(oldString, ""); const index = indexLines(text); const oldLines = oldBody.split("\n"); const count = oldLines.length; if (count === 0 || oldBody.trim().length === 0 || count > index.lines.length) return undefined; let bestScore = 0; let bestAt = -1; for (let i = 0; i + count <= index.lines.length; i++) { let equal = 0; for (let k = 0; k < count; k++) { if ((index.lines[i + k] as string).trim() === (oldLines[k] as string).trim()) equal += 1; } const score = equal / count; if (score > bestScore) { bestScore = score; bestAt = i; } } if (bestAt === -1 || bestScore === 0) return undefined; for (let k = 0; k < count; k++) { const expected = oldLines[k] as string; const actual = index.lines[bestAt + k] as string; if (expected !== actual) { return { startLine: bestAt + 1, endLine: bestAt + count, diffLine: bestAt + k + 1, kind: expected.trim() === actual.trim() ? "whitespace" : "content", expected, actual, }; } } return undefined; } // ─────────────────────────── the cascade ─────────────────────────── /** * Run strategies 1–8 in order; the first strategy producing at least one * match wins and later strategies never run (TOOL_PROTOCOL §4.3). Guard * violations from fuzzy strategies surface as "disproportionate" only when * no strategy produced a trustworthy match. */ export function runCascade(text: string, oldString: string, newString: string): CascadeResult { // 1. Exact. const exact = exactScan(text, oldString, newString); if (exact.length > 0) return { kind: "match", strategy: "exact", matches: exact }; const [oldBody, newBody] = stripTrailingNewline(oldString, newString); const index = indexLines(text); // 2. Line-trimmed. const trimmedLines = lineTrimmed(index, oldBody, newBody); if (trimmedLines.length > 0) { return { kind: "match", strategy: "line-trimmed", matches: trimmedLines }; } // 3. Whitespace-normalized. const wsNormalized = whitespaceNormalized(index, oldBody, newBody); if (wsNormalized.length > 0) { return { kind: "match", strategy: "whitespace-normalized", matches: wsNormalized }; } // 4. Indentation-flexible. const indentFlex = indentationFlexible(index, oldBody, newBody); if (indentFlex.length > 0) { return { kind: "match", strategy: "indentation-flexible", matches: indentFlex }; } // 5. Escape-normalized. const unescapedOld = unescapeLiterals(oldString); if (unescapedOld !== oldString) { const escaped = exactScan(text, unescapedOld, unescapeLiterals(newString)); if (escaped.length > 0) { return { kind: "match", strategy: "escape-normalized", matches: escaped }; } } // 6. Trimmed-boundary. const trimmed = oldString.trim(); if (trimmed !== oldString && trimmed.length > 0) { const boundary = exactScan(text, trimmed, newString.trim()); if (boundary.length > 0) { return { kind: "match", strategy: "trimmed-boundary", matches: boundary }; } } // 7. Block-anchor (fuzzy, guarded). const anchor = blockAnchor(index, oldBody, newBody, oldString.length); if (anchor.matches.length > 0) { return { kind: "match", strategy: "block-anchor", matches: anchor.matches }; } // 8. Context-aware (fuzzy, guarded). const context = contextAware(index, oldBody, newBody, oldString.length); if (context.matches.length > 0) { return { kind: "match", strategy: "context-aware", matches: context.matches }; } const guardHit = anchor.guardHit ?? context.guardHit; if (guardHit !== undefined) { return { kind: "disproportionate", candidateLength: guardHit.candidateLength, oldLength: oldString.length, }; } const nearMiss = findNearMiss(text, oldString); return nearMiss !== undefined ? { kind: "not-found", nearMiss } : { kind: "not-found" }; } /** Strategy 9 — replace_all: all EXACT occurrences only (never fuzzy, §4.4). */ export function runMultiOccurrence( text: string, oldString: string, newString: string, ): CascadeResult { const matches = exactScan(text, oldString, newString); if (matches.length > 0) return { kind: "match", strategy: "multi-occurrence", matches }; const nearMiss = findNearMiss(text, oldString); return nearMiss !== undefined ? { kind: "not-found", nearMiss } : { kind: "not-found" }; } /** Apply match spans (non-overlapping, in order) to the text. */ export function applyMatches(text: string, matches: MatchSpan[]): string { let out = ""; let cursor = 0; for (const match of matches) { out += text.slice(cursor, match.start) + match.replacement; cursor = match.end; } return out + text.slice(cursor); } /** 1-based line number of a character offset. */ export function lineNumberAt(text: string, offset: number): number { let line = 1; for (let i = 0; i < offset && i < text.length; i++) { if (text[i] === "\n") line += 1; } return line; }