UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

1,031 lines (1,030 loc) • 45.3 kB
import * as nodeFs from "node:fs"; import { hostWouldApplyOldText, normalizeForGuardMatch, } from "./host-edit-normalize.js"; import { logReadGuardEvent } from "./read-guard-logger.js"; import { isToolCallEventType } from "./tool-event.js"; // Track repeated oldtext_not_found failures per (filePath, preview) to escalate messages. const recentOldTextFailures = new Map(); const REPEAT_FAILURE_TTL_MS = 300_000; const MAX_FAILURE_TRACKER_SIZE = 200; function trackOldTextFailure(filePath, preview) { const key = `${filePath}::${preview}`; const now = Date.now(); const prev = recentOldTextFailures.get(key); const count = prev && now - prev.lastTs < REPEAT_FAILURE_TTL_MS ? prev.count + 1 : 1; if (recentOldTextFailures.size >= MAX_FAILURE_TRACKER_SIZE) { const oldest = recentOldTextFailures.keys().next().value; if (oldest !== undefined) recentOldTextFailures.delete(oldest); } recentOldTextFailures.set(key, { count, lastTs: now }); return count; } function findFirstLineOfOldText(content, oldText) { const firstLine = oldText.replace(/\r\n/g, "\n").split("\n")[0].trim(); if (firstLine.length < 5) return undefined; const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { if (lines[i].trim() === firstLine) return i + 1; } return undefined; } function tokenizeForSimilarity(text) { return text.toLowerCase().match(/[a-z0-9_]+/g) ?? []; } /** Jaccard similarity over identifier/number tokens (whitespace + punctuation insensitive). */ function tokenSimilarity(a, b) { const ta = new Set(tokenizeForSimilarity(a)); const tb = new Set(tokenizeForSimilarity(b)); if (ta.size === 0 || tb.size === 0) return 0; let intersection = 0; for (const token of ta) if (tb.has(token)) intersection += 1; return intersection / (ta.size + tb.size - intersection); } /** * "Did you mean?" recovery: when an oldText line can't be found, surface the * closest *current* file lines (by token similarity) so the model can rebuild * its edit from verbatim text in one turn instead of re-reading blind. Scans a * ±window around `nearLine` when known (the first-line locator), else the whole * file. Returns the top matches above `minScore`, with their real line numbers. */ function findSimilarLines(content, target, options = {}) { const { nearLine, window = 60, max = 3, minScore = 0.5 } = options; const needle = target.trim(); if (needle.length < 4) return []; const lines = content.split("\n"); const start = nearLine ? Math.max(0, nearLine - 1 - window) : 0; const end = nearLine ? Math.min(lines.length, nearLine - 1 + window) : lines.length; const scored = []; for (let i = start; i < end; i += 1) { const text = lines[i]; if (text.trim() === "") continue; const score = tokenSimilarity(needle, text); if (score >= minScore) scored.push({ line: i + 1, text, score }); } scored.sort((a, b) => b.score - a.score); return scored.slice(0, max); } function formatSimilarLines(suggestions) { const pad = (n) => String(n).padStart(4, " "); const rows = suggestions.map(({ line, text }) => ` ${pad(line)} │ ${text.trimEnd()}`); return `\n\nDid you mean one of these current lines?\n${rows.join("\n")}`; } export function countFileLines(filePath) { try { const content = nodeFs.readFileSync(filePath, "utf-8"); if (content.length === 0) return 1; return content.split(/\r?\n/).length; } catch { return 1; } } // Match the host edit tool's fuzzy-match space (NFKC + smart quotes/dashes/ // spaces + BOM + lone-CR), so the guard resolves oldText -> range exactly where // the host would apply it instead of false-blocking valid edits (#257). // // This is also where #505's "confusable-hyphen normalization" bundled item // lives: normalizeForGuardMatch folds HOST_UNICODE_DASHES (U+2010, U+2011, // U+2012, U+2013, U+2014, U+2015, U+2212 -> ASCII '-') before either side of // the comparison below, so it runs on the PRIMARY match here, before any of // the Tier A/B/C fallbacks in tryCorrectIndentationMismatchFromContent are // even reached. Comparison-only, same as every tier below it — the bytes // actually written on a successful edit are always the caller's original // oldText/newText (see resolveOldTextEdits / applyPartiallyApplicableEdits), // never this normalized form. function normalizeContent(text) { return normalizeForGuardMatch(text); } function lineNumberAt(content, index) { return content.substring(0, index).split("\n").length; } function parseHashlineAnchor(anchor) { if (typeof anchor !== "string") return undefined; const trimmed = anchor.trim(); const separator = trimmed.indexOf(":"); const lineText = separator === -1 ? trimmed : trimmed.slice(0, separator); if (!/^\d+$/.test(lineText)) return undefined; const line = Number(lineText); return Number.isInteger(line) && line > 0 ? line : undefined; } function combineRanges(ranges) { const starts = ranges.map(([start]) => start); const ends = ranges.map(([, end]) => end); return { touchedLines: [Math.min(...starts), Math.max(...ends)], editRanges: ranges.length > 1 ? ranges : undefined, }; } function getHashlineOperations(input) { if (Array.isArray(input.operations)) return input.operations; if (Array.isArray(input.ops)) return input.ops; if (input.set_line || input.replace_lines || input.replace_symbol) return [input]; return []; } function resolveHashlineEditInput(input, filePath, sessionId) { const operations = getHashlineOperations(input); if (operations.length === 0) return undefined; const ranges = []; const errors = []; for (let index = 0; index < operations.length; index += 1) { const op = operations[index]; if (op.set_line) { const payload = op.set_line; const line = parseHashlineAnchor(payload.anchor); if (!line) { errors.push(`operation[${index}].set_line.anchor is malformed`); continue; } ranges.push([line, line]); continue; } if (op.replace_lines) { const payload = op.replace_lines; const start = parseHashlineAnchor(payload.start_anchor); const end = parseHashlineAnchor(payload.end_anchor); if (!start || !end) { errors.push(`operation[${index}].replace_lines anchors are malformed`); continue; } if (start > end) { errors.push(`operation[${index}].replace_lines range is inverted`); continue; } ranges.push([start, end]); continue; } if (op.replace_symbol) { errors.push(`operation[${index}].replace_symbol cannot be resolved safely yet; use line anchors or a native ranged edit`); continue; } errors.push(`operation[${index}] is not a recognized hashline edit`); } if (errors.length > 0) { if (filePath) { logReadGuardEvent({ event: "edit_preflight_blocked", sessionId, filePath, metadata: { tool: "edit", source: "hashline_edit", reasonKind: "unsupported_hashline_edit_target", operationCount: operations.length, errorCount: errors.length, errors: errors.slice(0, 10), }, }); } // Every blocking verdict ends with a single concrete next-action line so // the agent recovers in one turn (#328 — uniform recovery-hint discipline). const target = filePath ? `\`${filePath}\`` : "the file"; const retryHint = `Re-read ${target} to get current #line anchors, then retry using set_line / replace_lines with those anchors — or use a native ranged edit.`; return { touchedLines: undefined, preflightError: `🔴 BLOCKED — Unsupported hashline edit target\n\n${errors.join("\n")}\n\n${retryHint}`, }; } if (ranges.length === 0) return undefined; const result = combineRanges(ranges); if (filePath) { logReadGuardEvent({ event: "touched_lines_detected", sessionId, filePath, metadata: { tool: "edit", source: ranges.length === 1 && ranges[0][0] === ranges[0][1] ? "hashline_set_line" : "hashline_replace_lines", touchedLines: result.touchedLines, editRanges: result.editRanges, operationCount: operations.length, }, }); } return result; } function findOccurrenceLines(content, needle) { const lines = []; let pos = 0; while (pos < content.length) { const idx = content.indexOf(needle, pos); if (idx === -1) break; lines.push(lineNumberAt(content, idx)); pos = idx + needle.length; } return lines; } function formatOccurrenceContext(content, occurrenceLines, matchSpanLines, maxOccurrences = 5) { const fileLines = content.split("\n"); const shown = occurrenceLines.slice(0, maxOccurrences); const extra = occurrenceLines.length - shown.length; const pad = (n) => String(n).padStart(4, " "); const blocks = shown.map((startLine) => { const endLine = startLine + matchSpanLines - 1; const before = startLine > 1 ? fileLines[startLine - 2] : undefined; const after = endLine < fileLines.length ? fileLines[endLine] : undefined; const lines = [` • Line ${startLine}:`]; if (before !== undefined) lines.push(` ${pad(startLine - 1)} │ ${before}`); if (matchSpanLines === 1) { lines.push(` ${pad(startLine)} │ ${fileLines[startLine - 1] ?? ""} ← match`); } else { lines.push(` ${pad(startLine)} │ ${fileLines[startLine - 1] ?? ""} ← match start`); if (matchSpanLines > 2) { lines.push(` ${pad(0)} │ … (${matchSpanLines - 2} more line${matchSpanLines - 2 === 1 ? "" : "s"})`); } lines.push(` ${pad(endLine)} │ ${fileLines[endLine - 1] ?? ""} ← match end`); } if (after !== undefined) lines.push(` ${pad(endLine + 1)} │ ${after}`); return lines.join("\n"); }); const tail = extra > 0 ? `\n • … and ${extra} more occurrence${extra === 1 ? "" : "s"}` : ""; return blocks.join("\n") + tail; } function countRawOccurrences(content, needle) { if (!needle) return 0; let count = 0; let pos = 0; while (pos < content.length) { const idx = content.indexOf(needle, pos); if (idx === -1) break; count += 1; pos = idx + needle.length; } return count; } function exactOldTextForApply(rawContentLf, oldText, candidate) { const oldTextLf = oldText.replace(/\r\n/g, "\n"); if (countRawOccurrences(rawContentLf, oldTextLf) === 1) return oldTextLf; if (candidate !== oldTextLf && countRawOccurrences(rawContentLf, candidate) === 1) { return candidate; } return undefined; } function resolveOldTextEdits(edits, filePath, sessionId) { let rawContent; try { rawContent = nodeFs.readFileSync(filePath, "utf-8"); } catch { logReadGuardEvent({ event: "touched_lines_missing", sessionId, filePath, metadata: { tool: "edit", source: "edits_without_ranges", editCount: edits.length, }, }); return { touchedLines: undefined }; } const rawContentLf = rawContent.replace(/\r\n/g, "\n"); const content = normalizeContent(rawContent); const errors = []; const failureKinds = []; const failedEditIndexes = []; const failedOldTextPreviews = []; const resolvedRanges = []; const passedEdits = []; let maxFailCount = 0; for (let i = 0; i < edits.length; i++) { const oldText = edits[i].oldText; const editIndex = edits[i].originalIndex ?? i; if (!oldText) continue; let needle = normalizeContent(oldText); let occurrenceLines = findOccurrenceLines(content, needle); if (occurrenceLines.length === 0) { const corrected = tryCorrectIndentationMismatchFromContent(oldText, rawContentLf); if (corrected !== undefined) { needle = normalizeContent(corrected); occurrenceLines = findOccurrenceLines(content, needle); if (occurrenceLines.length > 0) { logReadGuardEvent({ event: "oldtext_indent_corrected", sessionId, filePath, metadata: { tool: "edit", source: "edits_without_ranges", editIndex, }, }); } } } if (occurrenceLines.length === 0) { const preview = oldText.trimStart().substring(0, 60).replace(/\n/g, "↵"); failureKinds.push("oldtext_not_found"); failedEditIndexes.push(editIndex); failedOldTextPreviews.push(preview); const failCount = trackOldTextFailure(filePath, preview); if (failCount > maxFailCount) maxFailCount = failCount; let errorMsg = `edits[${editIndex}].oldText ("${preview}") was not found in the current file content.`; // Quote-style hint: if swapping " ↔ ' gives exactly one match, tell the agent why it failed. const quoteSwapCandidates = []; if (needle.includes('"')) quoteSwapCandidates.push(needle.replace(/"/g, "'")); if (needle.includes("'")) quoteSwapCandidates.push(needle.replace(/'/g, '"')); const quoteHit = quoteSwapCandidates.find((s) => s !== needle && findOccurrenceLines(content, s).length === 1); if (quoteHit !== undefined) { errorMsg += ` The file uses a different quote style — your oldText has ${needle.includes('"') ? "double" : "single"} quotes but the file has ${needle.includes('"') ? "single" : "double"} quotes. Fix the quote style in both oldText and newText before retrying.`; } else { const lineHint = findFirstLineOfOldText(content, oldText); const offsetHint = lineHint !== undefined ? `\`offset=${Math.max(1, lineHint - 2)} limit=20\`` : undefined; if (lineHint !== undefined) { // First line content exists in the file — the surrounding block has drifted. // Indentation autopatch already ran before this point and did not fix it, // so this is a content-drift failure, not a whitespace issue. if (failCount >= 2) { errorMsg += ` This is attempt #${failCount} — the first line of your oldText appears near line ${lineHint}` + ` but the surrounding content no longer matches. This is a content-drift failure,` + ` not an indentation issue (indentation autopatch already ran and did not fix it).` + ` Re-read ${offsetHint} and rebuild oldText verbatim from the current file.`; } else { errorMsg += ` The first line of your oldText appears near line ${lineHint} but the rest doesn't match.` + ` The file has likely changed since your last read — this is a content-drift issue, not indentation.` + ` Re-read ${offsetHint} and rebuild oldText from the verbatim file content.`; } } else { // First line not found anywhere in the file, even ignoring whitespace. if (failCount >= 2) { errorMsg += ` This is attempt #${failCount} — this text does not appear anywhere in the file,` + ` even ignoring whitespace differences. Do NOT retry from memory.` + ` Re-read the relevant section before rebuilding your edit.`; } else { errorMsg += ` This text does not appear anywhere in the file, even ignoring indentation differences —` + ` the file has likely changed significantly. Re-read the relevant section before retrying.`; } } } // "Did you mean?" — surface the closest current lines (token // similarity) so the model can rebuild oldText verbatim in one turn // instead of re-reading blind. Skipped on the quote-style path, which // already names the precise fix. Anchored near the first-line locator // when known, else scans the whole file. if (!errorMsg.includes("quote style")) { const similarLines = findSimilarLines(content, oldText.replace(/\r\n/g, "\n").split("\n")[0], { nearLine: findFirstLineOfOldText(content, oldText) }); if (similarLines.length > 0) { errorMsg += formatSimilarLines(similarLines); } } errors.push(errorMsg); // Counterfactual: would the host's edit tool have applied this oldText // anyway? hostWouldApply=true => this block is a false-block (pi-lens // friction the host wouldn't have); false => a genuine miss. This is the // measurement that tells us whether the guard earns its keep (#257). const hostMatch = hostWouldApplyOldText(rawContent, oldText); logReadGuardEvent({ event: "oldtext_not_found", sessionId, filePath, metadata: { tool: "edit", source: "edits_without_ranges", editIndex, oldTextPreview: preview, repeatFailureCount: failCount, hostWouldApply: hostMatch.wouldApply, hostOccurrences: hostMatch.occurrences, hostUsedFuzzyMatch: hostMatch.usedFuzzyMatch, }, }); } else if (occurrenceLines.length === 1) { const startLine = occurrenceLines[0]; const endLine = startLine + needle.split("\n").length - 1; resolvedRanges.push([startLine, endLine]); const applyOldText = exactOldTextForApply(rawContentLf, oldText, needle); if (applyOldText !== undefined) { passedEdits.push({ oldText: applyOldText, newText: edits[i].newText, originalIndex: editIndex, }); } logReadGuardEvent({ event: "oldtext_resolved", sessionId, filePath, metadata: { tool: "edit", source: "edits_without_ranges", editIndex, touchedLines: [startLine, endLine], }, }); } else { const preview = oldText.trimStart().substring(0, 60).replace(/\n/g, "↵"); failureKinds.push("oldtext_duplicate"); failedEditIndexes.push(editIndex); failedOldTextPreviews.push(preview); const matchSpanLines = needle.split("\n").length; const contextBlock = formatOccurrenceContext(content, occurrenceLines, matchSpanLines); errors.push(`edits[${editIndex}].oldText ("${preview}") appears ${occurrenceLines.length} times:\n${contextBlock}\nPick the location you want and extend your oldText with the unique line above or below it (shown as context).`); logReadGuardEvent({ event: "oldtext_duplicate", sessionId, filePath, metadata: { tool: "edit", source: "edits_without_ranges", editIndex, occurrenceCount: occurrenceLines.length, occurrenceLines, oldTextPreview: preview, }, }); } } const oldTextEditCount = edits.filter((edit) => !!edit.oldText).length; if (errors.length > 0 || resolvedRanges.length !== oldTextEditCount) { const failureDetails = errors.length > 0 ? errors : [ "One or more edit targets could not be resolved to exact lines. Re-read the relevant section and retry with the exact content as it appears in the file.", ]; const uniqueFailureKinds = [...new Set(failureKinds)]; logReadGuardEvent({ event: "edit_preflight_blocked", sessionId, filePath, metadata: { tool: "edit", source: "edits_without_ranges", reasonKind: uniqueFailureKinds.length === 1 ? uniqueFailureKinds[0] : "oldtext_resolution_failed", failureKinds: uniqueFailureKinds, editCount: edits.length, oldTextEditCount, resolvedOldTextEditCount: resolvedRanges.length, unresolvedOldTextEditCount: oldTextEditCount - resolvedRanges.length, failedEditIndexes, oldTextPreviews: failedOldTextPreviews.slice(0, 5), errorCount: errors.length, }, }); const appliedNote = passedEdits.length > 0 ? `\n\n${passedEdits.map((e) => `edits[${e.originalIndex}]`).join(", ")} ${passedEdits.length === 1 ? "was" : "were"} applied — do NOT re-submit ${passedEdits.length === 1 ? "it" : "them"}.` : ""; const header = maxFailCount >= 2 ? `🛑 RE-READ REQUIRED — You have submitted this oldText before and it still does not match.\n\nDo NOT retry from memory. Re-read \`${filePath}\` to get the current content, then rebuild your edit from the verbatim file text.` : `🔄 RETRYABLE — Edit target not found`; return { touchedLines: undefined, preflightError: `${header}\n\n${failureDetails.join("\n\n")}${appliedNote}`, partiallyApplicable: passedEdits.length > 0 ? passedEdits : undefined, }; } if (resolvedRanges.length === 0) { logReadGuardEvent({ event: "touched_lines_missing", sessionId, filePath, metadata: { tool: "edit", source: "edits_without_ranges", editCount: edits.length, }, }); return { touchedLines: undefined }; } const starts = resolvedRanges.map(([s]) => s); const ends = resolvedRanges.map(([, e]) => e); const touchedLines = [ Math.min(...starts), Math.max(...ends), ]; const editRanges = resolvedRanges.length > 1 ? resolvedRanges : undefined; logReadGuardEvent({ event: "touched_lines_detected", sessionId, filePath, metadata: { tool: "edit", source: "oldtext_resolved", touchedLines, resolvedEditCount: resolvedRanges.length, totalEditCount: edits.length, }, }); return { touchedLines, editRanges, contentMatchValidated: true }; } /** * Normalises an oldText string for whitespace-only differences that editors routinely * introduce: trailing spaces/tabs on each line are stripped, and any trailing blank * lines (lines that are empty after trimming) are removed from the end. CRLF is * normalised to LF. Returns the same string if no change was needed. */ export function stripOldTextTrailingWhitespace(value) { const lines = value .replace(/\r\n/g, "\n") .split("\n") .map((l) => l.trimEnd()); while (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); return lines.join("\n"); } /** * Tries to fix a tab/space indentation mismatch between the model's oldText and the * actual file. Returns the corrected oldText if a matching variant is found, or * undefined if the text already matches or no indentation conversion fixes it. */ export function tryCorrectIndentationMismatchFromContent(oldText, content) { const normalized = oldText.replace(/\r\n/g, "\n"); if (content.includes(normalized)) return undefined; const conversions = [ // tabs → 2 spaces (s) => s .split("\n") .map((l) => l.replace(/^\t+/, (m) => " ".repeat(m.length))) .join("\n"), // tabs → 4 spaces (s) => s .split("\n") .map((l) => l.replace(/^\t+/, (m) => " ".repeat(m.length))) .join("\n"), // 2 spaces → tabs (s) => s .split("\n") .map((l) => l.replace(/^( {2})+/, (m) => "\t".repeat(m.length / 2))) .join("\n"), // 4 spaces → tabs (s) => s .split("\n") .map((l) => l.replace(/^( {4})+/, (m) => "\t".repeat(m.length / 4))) .join("\n"), ]; for (const convert of conversions) { const candidate = convert(normalized); if (candidate !== normalized && content.includes(candidate)) return candidate; } const indentationInsensitiveCandidate = findIndentationInsensitiveCandidate(content, normalized); if (indentationInsensitiveCandidate !== undefined) { return indentationInsensitiveCandidate; } // Tier A (#200): the fixed-length matchers above can't bridge a mid-block // blank-line difference; fall back to a blank-line-insensitive match that // recovers the real file span (unique-match guarded). const blankLineCandidate = findBlankLineInsensitiveCandidate(content, normalized); if (blankLineCandidate !== undefined) { return blankLineCandidate; } // Tier B: interior-whitespace drift the earlier tiers can't bridge — the // indentation- and blank-line-insensitive tiers both still require each // non-blank line to match character-for-character after trimming only the // OUTER edges. When whitespace drifts INSIDE a line (a formatter collapsed // `a + b` → `a + b`, re-spaced operators/args, etc.) those tiers miss. // Matching on a fully-whitespace-collapsed signature catches it. Same // safety contract as Tier A: unique-match guarded, ≥2 anchors, recovers the // verbatim file span. const whitespaceCandidate = findWhitespaceInsensitiveCandidate(content, normalized); if (whitespaceCandidate !== undefined) { return whitespaceCandidate; } // Tier C: Unicode-punctuation drift the whitespace tiers can't bridge — the // model emitted smart quotes / em-dashes / NBSP where the file has straight // quotes / hyphens / regular spaces (or vice versa), common when text is // pasted from rendered Markdown or the model "tidies" punctuation. Folding // those to their ASCII equivalents (on top of whitespace collapse) catches it. // Same safety contract as Tier B: signature-matched, unique-match guarded, ≥2 // anchors, recovers the verbatim file span (the file's real characters). const unicodeCandidate = findUnicodePunctuationInsensitiveCandidate(content, normalized); if (unicodeCandidate !== undefined) { return unicodeCandidate; } return undefined; } export function tryCorrectIndentationMismatch(oldText, filePath) { try { return tryCorrectIndentationMismatchFromContent(oldText, nodeFs.readFileSync(filePath, "utf-8").replace(/\r\n/g, "\n")); } catch { return undefined; } } function findIndentationInsensitiveCandidate(content, oldText) { const contentLines = content.split("\n"); const oldLines = oldText.split("\n"); const stripIndent = (line) => line.replace(/^[\t ]+/, "").trimEnd(); const expected = oldLines.map(stripIndent); for (let start = 0; start <= contentLines.length - oldLines.length; start += 1) { let matches = true; for (let offset = 0; offset < oldLines.length; offset += 1) { if (stripIndent(contentLines[start + offset] ?? "") !== expected[offset]) { matches = false; break; } } if (matches) { const candidate = contentLines .slice(start, start + oldLines.length) .join("\n"); if (candidate !== oldText) return candidate; } } return undefined; } /** * Tier A of the blank-line autopatch (#200): tolerate mid-block blank-line * divergence — a blank line added or removed *inside* the block — which the * fixed-length window in {@link findIndentationInsensitiveCandidate} can't (any * interior blank-line delta breaks its 1:1 alignment). Blank lines are * semantically insignificant in every supported language, so matching the * oldText's non-blank lines (indentation-insensitive) against consecutive * content while skipping interior blanks on the content side is safe. * * Safety: matches by the non-blank "signature" but **recovers and returns the * real file span** (first→last matched non-blank line, real interior blanks * included) so the applied oldText is verbatim file bytes; requires the * signature to match **exactly once** (returns undefined on 0 or ≥2). Anchored * on ≥2 non-blank lines to avoid trivial single-line collisions. */ function findBlankLineInsensitiveCandidate(content, oldText) { const stripIndent = (line) => line.replace(/^[\t ]+/, "").trimEnd(); const isBlank = (line) => stripIndent(line) === ""; const contentLines = content.split("\n"); const oldNonBlank = oldText .split("\n") .map(stripIndent) .filter((line) => line !== ""); // Need ≥2 anchors to be meaningful and collision-resistant; single-line // drift has no interior to differ and is handled by other tiers. if (oldNonBlank.length < 2) return undefined; const spans = []; for (let start = 0; start < contentLines.length; start += 1) { if (stripIndent(contentLines[start]) !== oldNonBlank[0]) continue; let contentIdx = start + 1; let oldIdx = 1; let end = start; let ok = true; while (oldIdx < oldNonBlank.length) { while (contentIdx < contentLines.length && isBlank(contentLines[contentIdx])) contentIdx += 1; if (contentIdx >= contentLines.length || stripIndent(contentLines[contentIdx]) !== oldNonBlank[oldIdx]) { ok = false; break; } end = contentIdx; oldIdx += 1; contentIdx += 1; } if (ok) spans.push([start, end]); } if (spans.length !== 1) return undefined; const [start, end] = spans[0]; const candidate = contentLines.slice(start, end + 1).join("\n"); return candidate === oldText ? undefined : candidate; } /** * Tier B of the whitespace autopatch: tolerate INTERIOR whitespace divergence * that {@link findBlankLineInsensitiveCandidate} (outer-trim only) and the * fixed-width converters can't bridge. The signature is each non-blank line * with **all** whitespace removed (`/\s+/g` → ""), so re-spacing inside a line * — `a + b` ↔ `a + b`, `foo( x )` ↔ `foo(x)`, tab/space mixes mid-line — no * longer breaks the match. This mirrors the content-hash normalization the * read-guard already uses for staleness (`lineContentHash`), so a span that * passes here is a span the guard considers semantically identical. * * Safety mirrors Tier A exactly: matches by the collapsed signature but * **recovers and returns the real file span** (verbatim bytes, interior blanks * included) so the applied oldText is exact; requires the signature to match * **exactly once** (0 or ≥2 → undefined); anchored on ≥2 non-blank lines to * resist single-line collisions (collapsing whitespace makes single-line * collisions more likely, so the ≥2 floor matters more here than in Tier A). */ function findWhitespaceInsensitiveCandidate(content, oldText) { const collapse = (line) => line.replace(/\s+/g, ""); const isBlank = (line) => collapse(line) === ""; const contentLines = content.split("\n"); const oldSignature = oldText .split("\n") .map(collapse) .filter((line) => line !== ""); if (oldSignature.length < 2) return undefined; const spans = []; for (let start = 0; start < contentLines.length; start += 1) { if (collapse(contentLines[start]) !== oldSignature[0]) continue; let contentIdx = start + 1; let sigIdx = 1; let end = start; let ok = true; while (sigIdx < oldSignature.length) { while (contentIdx < contentLines.length && isBlank(contentLines[contentIdx])) contentIdx += 1; if (contentIdx >= contentLines.length || collapse(contentLines[contentIdx]) !== oldSignature[sigIdx]) { ok = false; break; } end = contentIdx; sigIdx += 1; contentIdx += 1; } if (ok) spans.push([start, end]); } if (spans.length !== 1) return undefined; const [start, end] = spans[0]; const candidate = contentLines.slice(start, end + 1).join("\n"); return candidate === oldText ? undefined : candidate; } /** * Fold the Unicode punctuation that models and rendered text routinely swap for * ASCII (and back) to a canonical ASCII form: smart single/double quotes → * `'`/`"`, the dash family (hyphen, figure/en/em dash, horizontal bar, minus) → * `-`, and non-breaking / typographic spaces → a regular space. Used only to * build a match signature — never to rewrite file content. */ function normalizeUnicodePunctuation(text) { return text .replace(/[‘’‚‛]/g, "'") .replace(/[“”„‟]/g, '"') .replace(/[‐-―−]/g, "-") .replace(/[  -    ]/g, " "); } /** * Tier C of the autopatch ladder: tolerate Unicode-punctuation divergence the * whitespace tiers can't bridge (smart quotes ↔ straight, em/en-dash ↔ hyphen, * NBSP ↔ space). The signature folds Unicode punctuation to ASCII and then * collapses all whitespace (so it subsumes Tier B and additionally absorbs the * punctuation swap). Safety mirrors Tier B exactly: matches by the folded * signature but **recovers and returns the verbatim file span** (the file's real * characters), requires the signature to match **exactly once**, and anchors on * ≥2 non-blank lines to resist single-line collisions. */ function findUnicodePunctuationInsensitiveCandidate(content, oldText) { const fold = (line) => normalizeUnicodePunctuation(line).replace(/\s+/g, ""); const isBlank = (line) => fold(line) === ""; const contentLines = content.split("\n"); const oldSignature = oldText .split("\n") .map(fold) .filter((line) => line !== ""); if (oldSignature.length < 2) return undefined; const spans = []; for (let start = 0; start < contentLines.length; start += 1) { if (fold(contentLines[start]) !== oldSignature[0]) continue; let contentIdx = start + 1; let sigIdx = 1; let end = start; let ok = true; while (sigIdx < oldSignature.length) { while (contentIdx < contentLines.length && isBlank(contentLines[contentIdx])) contentIdx += 1; if (contentIdx >= contentLines.length || fold(contentLines[contentIdx]) !== oldSignature[sigIdx]) { ok = false; break; } end = contentIdx; sigIdx += 1; contentIdx += 1; } if (ok) spans.push([start, end]); } if (spans.length !== 1) return undefined; const [start, end] = spans[0]; const candidate = contentLines.slice(start, end + 1).join("\n"); return candidate === oldText ? undefined : candidate; } /** * Shift a native range edit's line numbers, in place, by the relocation delta. * Returns true when a range matching `from` was found and rewritten. Powers the * content-verified range-stale auto-apply: the lines the agent meant to edit * moved (proven by read-time line hashes uniquely matching the new location), * so we re-target the positional edit to where the content now lives. * * Shifts by a constant line delta (`to[0] - from[0]`) applied to both the start * and end lines, so inclusive/exclusive end conventions and any character * offsets are preserved untouched — only the line position moves. Matches both * the single `oldRange` shape and `edits[].range` entries. */ export function relocateEditRange(input, from, to) { const delta = to[0] - from[0]; if (delta === 0 || !input || typeof input !== "object") return false; const editInput = input; const matchesFrom = (start, end) => start === from[0] && end === from[1]; let applied = false; const oldRange = editInput.oldRange; if (oldRange?.start?.line !== undefined && oldRange.end?.line !== undefined && matchesFrom(oldRange.start.line, oldRange.end.line)) { oldRange.start.line += delta; oldRange.end.line += delta; applied = true; } if (Array.isArray(editInput.edits)) { for (const edit of editInput.edits) { const start = edit.range?.start?.line; const end = edit.range?.end?.line ?? start; if (start !== undefined && matchesFrom(start, end)) { edit.range.start.line = start + delta; if (edit.range?.end?.line !== undefined) { edit.range.end.line += delta; } applied = true; } } } return applied; } export function getTouchedLinesForGuard(event, filePath, sessionId) { if (isToolCallEventType("edit", event)) { // The host standard-edit fields (path, edits[].oldText/newText) are pinned // to the SDK's EditToolInput, so a host edit-schema change is a compile // error instead of silently falling through to `unknown_edit_schema`. The // remaining keys are pi-lens's own extensions for native-ranged + hashline // edit tools; oldText/newText are probed as optional because range-only // edits omit them (refs #3). const editInput = event.input; const hashlineResult = resolveHashlineEditInput(editInput, filePath, sessionId); if (hashlineResult) return hashlineResult; if (editInput.oldRange) { const touchedLines = [ editInput.oldRange.start.line, editInput.oldRange.end.line, ]; if (filePath) { logReadGuardEvent({ event: "touched_lines_detected", sessionId, filePath, metadata: { tool: "edit", source: "oldRange", touchedLines, }, }); } return { touchedLines }; } if (editInput.edits?.length) { const rangedEdits = editInput.edits .map((edit) => { const start = edit.range?.start?.line; const end = edit.range?.end?.line ?? start; if (typeof start !== "number" || typeof end !== "number") { return null; } return [start, end]; }) .filter((range) => range !== null); const unresolvedOldTextEdits = editInput.edits .map((edit, index) => ({ ...edit, originalIndex: index })) .filter((edit) => typeof edit.range?.start?.line !== "number" && !!edit.oldText); if (rangedEdits.length === 0) { if (filePath) { return resolveOldTextEdits(editInput.edits, filePath, sessionId); } return { touchedLines: undefined }; } let oldTextTouchedLines; let oldTextEditRanges; if (unresolvedOldTextEdits.length > 0 && filePath) { const resolved = resolveOldTextEdits(unresolvedOldTextEdits, filePath, sessionId); if (resolved.preflightError) { return resolved; } oldTextTouchedLines = resolved.touchedLines; oldTextEditRanges = resolved.editRanges; } const starts = rangedEdits.map(([start]) => start); const ends = rangedEdits.map(([, end]) => end); if (oldTextTouchedLines) { starts.push(oldTextTouchedLines[0]); ends.push(oldTextTouchedLines[1]); } const touchedLines = [ Math.min(...starts), Math.max(...ends), ]; const allEditRanges = [...rangedEdits]; if (oldTextEditRanges?.length) { allEditRanges.push(...oldTextEditRanges); } else if (oldTextTouchedLines) { allEditRanges.push(oldTextTouchedLines); } const editRanges = allEditRanges.length > 1 ? allEditRanges : undefined; if (filePath) { logReadGuardEvent({ event: "touched_lines_detected", sessionId, filePath, metadata: { tool: "edit", source: unresolvedOldTextEdits.length > 0 ? "edits_mixed" : "edits_ranges", touchedLines, rangedEditCount: rangedEdits.length, resolvedOldTextEditCount: unresolvedOldTextEdits.length, totalEditCount: editInput.edits.length, }, }); } return { touchedLines, editRanges }; } if (filePath) { const topLevelKeys = Object.keys(editInput); logReadGuardEvent({ event: "touched_lines_missing", sessionId, filePath, metadata: { tool: "edit", source: "unknown_edit_schema", topLevelKeys, hasNativeOldRange: !!editInput.oldRange, hasNativeEdits: Array.isArray(editInput.edits), hasHashlineSetLine: !!editInput.set_line, hasHashlineReplaceLines: !!editInput.replace_lines, hasHashlineReplaceSymbol: !!editInput.replace_symbol, hasHashlineBatch: Array.isArray(editInput.operations) || Array.isArray(editInput.ops), strictModeWouldBlock: true, }, }); } return { touchedLines: undefined }; } if (isToolCallEventType("write", event)) { const lineCount = filePath ? countFileLines(filePath) : 1; const touchedLines = [1, lineCount]; if (filePath) { logReadGuardEvent({ event: "touched_lines_detected", sessionId, filePath, metadata: { tool: "write", source: "full_file_write", touchedLines, lineCount, }, }); } return { touchedLines }; } return { touchedLines: undefined }; }