pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
910 lines (909 loc) • 40.4 kB
JavaScript
/**
* Read-Before-Edit Guard for pi-lens
*
* Blocks edits that lack adequate prior reading:
* 1. Zero-read edit: never read this file in this branch
* 2. File modified since read: disk content changed (FileTime)
* 3. Out-of-range edit: edit target not covered by any previous read
* 4. LSP expansion exemption: single-line read expanded to full symbol counts
*
* Falls back safely when LSP is unavailable.
*/
import * as fs from "node:fs";
import { createFileTime } from "./file-time.js";
import { normalizeFilePath } from "./path-utils.js";
import { logReadGuardEvent } from "./read-guard-logger.js";
// --- Constants ---
const DEFAULT_CONFIG = {
enabled: true,
mode: "block",
contextLines: 3,
exemptions: [
{ pattern: "*.md", mode: "warn" },
{ pattern: "*.txt", mode: "allow" },
{ pattern: "*.log", mode: "allow" },
],
};
const OWN_EDIT_STALE_GRACE_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_READ_GUARD_OWN_EDIT_GRACE_MS ?? "120000", 10) || 120000);
/** Avoid hashing very large reads in the hot path. */
const READ_HASH_MAX_LINES = Math.max(0, Number.parseInt(process.env.PI_LENS_READ_GUARD_HASH_MAX_LINES ?? "3000", 10) || 3000);
// Adaptive relocation window (findRelocation). A globally-unique hash-sequence
// match always wins; when the content is duplicated elsewhere, we fall back to
// a match that is unique WITHIN this window of the original position. The window
// widens with edits already applied to the file (accumulated line drift) —
// floor + per-edit growth, capped — the analog of pi-hashline-readmap's
// edits-scaled relocation window.
const RELOCATION_WINDOW_MIN = Math.max(1, Number.parseInt(process.env.PI_LENS_READ_GUARD_RELOCATION_WINDOW_MIN ?? "40", 10) || 40);
const RELOCATION_WINDOW_PER_EDIT = Math.max(0, Number.parseInt(process.env.PI_LENS_READ_GUARD_RELOCATION_WINDOW_PER_EDIT ?? "20", 10) || 20);
const RELOCATION_WINDOW_MAX = Math.max(RELOCATION_WINDOW_MIN, Number.parseInt(process.env.PI_LENS_READ_GUARD_RELOCATION_WINDOW_MAX ?? "400", 10) || 400);
function splitLines(text) {
return text.split(/\r?\n/);
}
function lineContentHash(line) {
// FNV-1a over whitespace-stripped content. This treats no-op formatter/touch
// changes as still-valid context while detecting semantic line changes.
const normalized = line.replace(/\s+/g, "");
let hash = 2166136261;
for (let i = 0; i < normalized.length; i++) {
hash = Math.imul(hash ^ normalized.charCodeAt(i), 16777619);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}
function readRangeCoversLine(read, lineNo) {
return (lineNo >= read.effectiveOffset &&
lineNo <= read.effectiveOffset + read.effectiveLimit - 1);
}
function readEffectiveRangeCoversRange(read, [startLine, endLine]) {
return (readRangeCoversLine(read, startLine) && readRangeCoversLine(read, endLine));
}
function captureLineHashes(filePath, offset, limit) {
if (limit <= 0 || limit > READ_HASH_MAX_LINES)
return undefined;
try {
const lines = splitLines(fs.readFileSync(filePath, "utf-8"));
const hashes = {};
const end = Math.min(lines.length, offset + limit - 1);
for (let lineNo = Math.max(1, offset); lineNo <= end; lineNo++) {
hashes[lineNo] = lineContentHash(lines[lineNo - 1] ?? "");
}
return Object.keys(hashes).length > 0 ? hashes : undefined;
}
catch {
return undefined;
}
}
export function currentLinesMatchReadSnapshot(filePath, read, [startLine, endLine]) {
const hashes = read.lineHashes ?? {};
const missingLines = [];
const mismatchedLines = [];
for (let lineNo = startLine; lineNo <= endLine; lineNo += 1) {
if (!readRangeCoversLine(read, lineNo) || hashes[lineNo] === undefined) {
missingLines.push(lineNo);
}
}
if (missingLines.length > 0) {
return { checked: false, matches: false, missingLines, mismatchedLines };
}
let lines;
try {
lines = splitLines(fs.readFileSync(filePath, "utf-8"));
}
catch {
return {
checked: true,
matches: false,
missingLines,
mismatchedLines: [...Array(endLine - startLine + 1)].map((_, index) => startLine + index),
};
}
for (let lineNo = startLine; lineNo <= endLine; lineNo += 1) {
if (lineNo < 1 || lineNo > lines.length) {
mismatchedLines.push(lineNo);
continue;
}
if (lineContentHash(lines[lineNo - 1] ?? "") !== hashes[lineNo]) {
mismatchedLines.push(lineNo);
}
}
return {
checked: true,
matches: mismatchedLines.length === 0,
missingLines,
mismatchedLines,
};
}
// --- ReadGuard Class ---
export class ReadGuard {
config;
reads = new Map();
edits = new Map();
fileTime;
exemptions = new Set(); // One-time exemptions via /lens-allow-edit
pendingCreations = new Map();
// Files that recordWritten() has fired on this session. Lets
// wasWrittenThisSession() return a deterministic answer for files the
// pi Write tool authored, independent of filesystem mtime granularity
// or clock skew (NFS, FAT32, etc.).
writtenThisSession = new Set();
sessionId;
sessionStartMs;
constructor(sessionId, config = {}) {
this.sessionId = sessionId;
this.sessionStartMs = Date.now();
this.config = { ...DEFAULT_CONFIG, ...config };
this.fileTime = createFileTime(sessionId);
}
/**
* Canonical Map key for a file path. Read sources arrive with mixed
* separators/casing — the Read tool gives OS-native backslashes on Windows,
* while LSP-expanded and search-tool reads arrive slash-normalized from URIs.
* Keying the reads/edits/exemptions maps on the raw path made a read recorded
* under one form invisible to an edit checked under another, producing a false
* `zero_read` block despite the file having been read. `normalizeFilePath`
* folds separators and Windows casing to one key, so record and lookup always
* agree. Every map access in this class MUST key through here.
*/
key(filePath) {
return normalizeFilePath(filePath);
}
// --- Public API ---
/**
* Record that a file was read.
* Call this from the tool_call handler after any LSP expansion.
*/
recordRead(record) {
const filePath = this.key(record.filePath);
const storedRecord = {
...record,
filePath,
lineHashes: record.lineHashes ??
captureLineHashes(filePath, record.effectiveOffset, record.effectiveLimit),
};
const arr = this.reads.get(storedRecord.filePath) ?? [];
arr.push(storedRecord);
this.reads.set(storedRecord.filePath, arr);
logReadGuardEvent({
event: "read_recorded",
sessionId: this.sessionId,
filePath: storedRecord.filePath,
requestedOffset: storedRecord.requestedOffset,
requestedLimit: storedRecord.requestedLimit,
effectiveOffset: storedRecord.effectiveOffset,
effectiveLimit: storedRecord.effectiveLimit,
symbol: storedRecord.enclosingSymbol?.name,
symbolKind: storedRecord.enclosingSymbol?.kind,
symbolStartLine: storedRecord.enclosingSymbol?.startLine,
symbolEndLine: storedRecord.enclosingSymbol?.endLine,
metadata: {
expandedByLsp: storedRecord.expandedByLsp,
turnIndex: storedRecord.turnIndex,
writeIndex: storedRecord.writeIndex,
readCountForFile: arr.length,
hashLineCount: Object.keys(storedRecord.lineHashes ?? {}).length,
},
});
// Also update FileTime stamp for this file
this.fileTime.read(storedRecord.filePath);
}
/**
* Record a structured symbol read (the `readSymbol` engine capability / its
* MCP mirror) as a genuine read of that symbol's line range — the
* read-substitute tie-in for #245. `readSymbol` returns the verbatim body, so
* an edit within [startLine, endLine] is legitimately covered, exactly like a
* TS/LSP-expanded read that delivered the whole enclosing symbol. Line hashes
* for the range are captured by recordRead, so the edit is also
* snapshot-verified (drift since the symbol read still blocks).
*
* Intentionally NOT offered for module *outlines*: an outline shows a symbol's
* shape (name/signature/range), not its body, so granting edit coverage from
* it would let the agent edit lines it never saw. Only a body-delivering read
* (readSymbol / raw Read) records coverage.
*/
recordSymbolRead(filePath, symbol, turnIndex, writeIndex) {
const span = Math.max(1, symbol.endLine - symbol.startLine + 1);
this.recordRead({
filePath,
requestedOffset: symbol.startLine,
requestedLimit: span,
effectiveOffset: symbol.startLine,
effectiveLimit: span,
expandedByLsp: false,
enclosingSymbol: {
name: symbol.name,
kind: symbol.kind,
startLine: symbol.startLine,
endLine: symbol.endLine,
},
turnIndex,
writeIndex,
timestamp: Date.now(),
});
}
/**
* Check if an edit should be allowed.
* Returns verdict with action and optional reason for blocking.
*/
checkEdit(filePath, touchedLines, editRanges, options) {
// Canonicalize once: every map lookup below (and every private helper this
// passes filePath to) must agree with how recordRead keyed the read.
filePath = this.key(filePath);
// Check exemptions
if (this.exemptions.has(filePath)) {
this.exemptions.delete(filePath); // One-time use
const verdict = this.allow();
this.recordVerdict(filePath, "edit", touchedLines, verdict, {
reasonKind: "manual_exemption",
});
return verdict;
}
// Check config exemptions by pattern
const exemptionMode = this.getExemptionMode(filePath);
if (exemptionMode === "allow") {
const verdict = this.allow();
this.recordVerdict(filePath, "edit", touchedLines, verdict, {
reasonKind: "pattern_exemption",
exemptionMode,
});
return verdict;
}
// "warn" pattern exemptions downgrade all blocking verdicts to warnings.
const effectiveMode = exemptionMode === "warn" ? "warn" : undefined;
// 1. Zero-read check
const fileReads = this.reads.get(filePath);
if (!fileReads || fileReads.length === 0) {
// If the file was written after this session started, the agent authored
// it in this session (via Write or any other mechanism). Allow the edit —
// a synthetic read would have been injected for Write tool calls, but
// this catches cases where the write bypassed the hook or the session
// restarted between write and edit.
if (this.wasWrittenThisSession(filePath)) {
this.injectCreationRead(filePath, 0, 0);
const verdict = this.allow();
this.recordVerdict(filePath, "edit", touchedLines, verdict, {
reasonKind: "session_authored",
});
return verdict;
}
const verdict = this.blockOrWarn("zero-read", `🔄 RETRYABLE — Edit without read\n\nYou are trying to edit \`${filePath}\` but have not read it in this conversation.\n\nRead the file first, then retry the edit: \`read path="${filePath}"\``, undefined, effectiveMode);
this.recordVerdict(filePath, "edit", touchedLines, verdict, {
reasonKind: "zero_read",
});
return verdict;
}
// 2. FileTime check (actual staleness)
let ignoredOwnEditStaleness = false;
let ignoredHashStaleness = false;
if (this.fileTime.hasChanged(filePath)) {
const lastRead = fileReads[fileReads.length - 1];
if (this.canTreatStalenessAsOwnPriorEdit(filePath, lastRead.timestamp)) {
ignoredOwnEditStaleness = true;
}
else if (this.canIgnoreStalenessByHashes(filePath, fileReads, touchedLines, editRanges)) {
ignoredHashStaleness = true;
}
else {
const verdict = this.blockOrWarn("file-modified", `🔄 RETRYABLE — File modified since read\n\nYou last read \`${filePath}\` at ${new Date(lastRead.timestamp).toISOString()}.\nThe file has been modified on disk since then (auto-format, external tool, or previous edit).\n\nYour mental model is out of sync with the actual file content.\nTo proceed:\n 1. Re-read the file: \`read path="${filePath}"\``, undefined, effectiveMode);
this.recordVerdict(filePath, "edit", touchedLines, verdict, {
reasonKind: "file_modified",
lastReadTimestamp: lastRead.timestamp,
});
return verdict;
}
}
// If no line range specified, we can only check zero-read and FileTime
if (!touchedLines) {
const verdict = this.allow();
this.recordVerdict(filePath, "edit", touchedLines, verdict, {
reasonKind: "no_line_info",
});
return verdict;
}
// 3. Range coverage check
// When the edit touches multiple disjoint spots (e.g. rename across 4 tool
// registrations), check each spot independently. Collapsing to a bounding
// box would falsely flag reads that cover exactly the right lines.
const rangesToCheck = editRanges && editRanges.length > 1 ? editRanges : [touchedLines];
let viaSymbol = false;
for (const range of rangesToCheck) {
const snapshotValidation = this.validateRangeSnapshot(filePath, range);
const coverage = this.checkCoverage(filePath, range);
if (!coverage.covered) {
const lastRead = fileReads[fileReads.length - 1];
const [editStart, editEnd] = range;
const lastReadEnd = lastRead.effectiveOffset + lastRead.effectiveLimit - 1;
// If oldText was resolved (content-verified), the model demonstrably
// knew the content it's replacing — line drift from prior edits in
// the session is the likely cause. Downgrade to warn rather than block.
const outOfRangeMode = options?.oldTextResolved ? "warn" : effectiveMode;
const verdict = this.blockOrWarn("out-of-range", `🔄 RETRYABLE — Edit outside read range\n\nYou read \`${filePath}\` lines ${lastRead.effectiveOffset}-${lastReadEnd}${lastRead.enclosingSymbol ? ` (${lastRead.enclosingSymbol.kind} \`${lastRead.enclosingSymbol.name}\`)` : ""}, but your edit touches lines ${editStart}-${editEnd}.\n\nRead the relevant section first, then retry the edit:\n \`read path="${filePath}" offset=${Math.max(1, editStart - 5)} limit=${Math.min(30, editEnd - editStart + 10)}\``, {
editRange: range,
readRanges: fileReads.map((r) => ({
start: r.effectiveOffset,
end: r.effectiveOffset + r.effectiveLimit - 1,
})),
symbolRanges: fileReads
.filter((r) => r.enclosingSymbol)
.map((r) => ({
name: r.enclosingSymbol.name,
start: r.enclosingSymbol.startLine,
end: r.enclosingSymbol.endLine,
})),
}, outOfRangeMode);
this.recordVerdict(filePath, "edit", touchedLines, verdict, {
reasonKind: "out_of_range",
oldTextResolved: options?.oldTextResolved ?? false,
});
return verdict;
}
if (snapshotValidation.shouldBlock && !options?.skipSnapshotCheck) {
const [editStart, editEnd] = range;
// Grace period: when the snapshot is stale because THIS session's own
// earlier edit shifted line numbers (ignoredOwnEditStaleness), and
// the agent read the file recently, downgrade to a warning rather
// than blocking. The agent has fresh context — they just don't
// know the exact new line numbers after the shift.
const RANGE_STALE_GRACE_MS = 60_000;
const lastRead = fileReads[fileReads.length - 1];
const graceActive = ignoredOwnEditStaleness &&
Date.now() - lastRead.timestamp < RANGE_STALE_GRACE_MS;
// Content-verified relocation: if the lines the agent read have
// merely shifted (same content, new offset), tell them exactly where
// so they re-target in one turn. We hint rather than silently
// re-apply: the host applies native range edits positionally and
// can't re-verify, so an unverified auto-relocation could corrupt.
const relocation = this.findRelocation(filePath, fileReads, range);
const relocationNote = relocation
? `\n\n📍 The content you read at lines ${relocation.from[0]}-${relocation.from[1]} now appears unchanged at lines ${relocation.to[0]}-${relocation.to[1]} — it shifted position. Re-target your edit to lines ${relocation.to[0]}-${relocation.to[1]}.`
: "";
const verdict = this.blockOrWarn("range-stale", `🔄 RETRYABLE — Edit range changed since read\n\nYou are editing \`${filePath}\` lines ${editStart}-${editEnd}, but those lines no longer match the content you read earlier.${relocationNote}\n\nRe-read the relevant section, then retry the edit using the current line range/content:\n \`read path="${filePath}" offset=${Math.max(1, editStart - 5)} limit=${Math.min(30, editEnd - editStart + 10)}\``, {
editRange: range,
readRanges: fileReads.map((r) => ({
start: r.effectiveOffset,
end: r.effectiveOffset + r.effectiveLimit - 1,
})),
symbolRanges: fileReads
.filter((r) => r.enclosingSymbol)
.map((r) => ({
name: r.enclosingSymbol.name,
start: r.enclosingSymbol.startLine,
end: r.enclosingSymbol.endLine,
})),
snapshot: {
status: snapshotValidation.status,
mismatchedLines: snapshotValidation.mismatchedLines,
missingLines: snapshotValidation.missingLines,
},
...(relocation ? { relocation } : {}),
}, graceActive ? "warn" : effectiveMode);
// Offer auto-apply only for a single-range edit: we relocated exactly
// one range, so shifting it is the whole edit. A multi-range edit
// could have other drifted spots we returned before checking, so it
// stays a hint.
if (relocation && rangesToCheck.length === 1) {
verdict.relocation = relocation;
}
this.recordVerdict(filePath, "edit", touchedLines, verdict, {
reasonKind: "range_stale",
range,
mismatchedLines: snapshotValidation.mismatchedLines.slice(0, 20),
graceActive,
relocatedTo: relocation?.to ?? null,
relocationAutoApplyOffered: !!verdict.relocation,
});
return verdict;
}
if (coverage.viaSymbol)
viaSymbol = true;
}
const verdict = this.allow();
this.recordVerdict(filePath, "edit", touchedLines, verdict, {
reasonKind: viaSymbol ? "symbol_coverage" : "range_coverage",
viaSymbol,
ignoredOwnEditStaleness,
ignoredHashStaleness,
});
return verdict;
}
/**
* Check if this is a new file (no existing file on disk).
* New file writes are exempt from the guard.
*/
isNewFile(filePath) {
try {
return !fs.existsSync(filePath);
}
catch {
return true; // Assume new if we can't stat
}
}
/**
* Mark a file as pending creation (Write tool to a non-existing file).
* Must be called from the tool_call handler before the write lands so
* isNewFile() still returns true. recordWritten will inject a synthetic
* read so immediate follow-up edits are not blocked by zero_read.
*/
noteCreatedFile(filePath, turnIndex, writeIndex) {
this.pendingCreations.set(this.key(filePath), { turnIndex, writeIndex });
}
/**
* Refresh the FileTime stamp after the model's own write lands on disk.
* Call this from the tool_result handler so the next checkEdit on the same
* file doesn't see "file_modified" caused by our own previous edit.
*/
recordWritten(filePath) {
filePath = this.key(filePath);
this.fileTime.read(filePath);
this.writtenThisSession.add(filePath);
const creation = this.pendingCreations.get(filePath);
if (creation) {
this.pendingCreations.delete(filePath);
this.injectCreationRead(filePath, creation.turnIndex, creation.writeIndex);
}
}
/**
* Add a one-time exemption for a file.
* Called via /lens-allow-edit command.
*/
addExemption(filePath) {
this.exemptions.add(this.key(filePath));
logReadGuardEvent({
event: "exemption_added",
sessionId: this.sessionId,
filePath,
metadata: {
source: "lens-allow-edit",
},
});
}
/**
* Get summary statistics for /lens-health.
*/
getSummary() {
let totalEdits = 0;
let totalBlocks = 0;
let lspExpansionsHelped = 0;
const byReason = {};
const byFile = {};
for (const [filePath, records] of this.edits) {
for (const record of records) {
totalEdits++;
byFile[filePath] = byFile[filePath] ?? { edits: 0, blocks: 0 };
byFile[filePath].edits++;
if (record.verdict === "blocked") {
totalBlocks++;
byFile[filePath].blocks++;
}
if (record.reason) {
byReason[record.reason] = (byReason[record.reason] ?? 0) + 1;
}
// Count LSP expansions that allowed an edit
if (record.precedingReads.some((r) => r.expandedByLsp) &&
record.verdict === "allowed") {
lspExpansionsHelped++;
}
}
}
return {
totalEdits,
totalBlocks,
byReason,
byFile,
lspExpansionsHelped,
};
}
/**
* Get all read records for a file (for debugging).
*/
getReadHistory(filePath) {
return this.reads.get(this.key(filePath)) ?? [];
}
/**
* Get all edit records for a file (for debugging).
*/
getEditHistory(filePath) {
return this.edits.get(this.key(filePath)) ?? [];
}
// --- Private helpers ---
injectCreationRead(filePath, turnIndex, writeIndex) {
let lineCount = 0;
try {
lineCount = splitLines(fs.readFileSync(filePath, "utf-8")).length;
}
catch {
return;
}
if (lineCount === 0)
return;
this.recordRead({
filePath,
requestedOffset: 1,
requestedLimit: lineCount,
effectiveOffset: 1,
effectiveLimit: lineCount,
expandedByLsp: false,
turnIndex,
writeIndex,
timestamp: Date.now(),
});
}
wasWrittenThisSession(filePath) {
// Authoritative path: we observed a write of this file via recordWritten.
// Survives mtime granularity (FAT32 ~2s), clock skew (NFS), and external
// tools that touch mtime backward.
if (this.writtenThisSession.has(filePath))
return true;
try {
return fs.statSync(filePath).mtimeMs >= this.sessionStartMs;
}
catch {
return false;
}
}
canTreatStalenessAsOwnPriorEdit(filePath, lastReadTimestamp) {
const edits = this.edits.get(filePath) ?? [];
const latest = edits.at(-1);
if (!latest)
return false;
if (latest.verdict !== "allowed" && latest.verdict !== "warned")
return false;
if (latest.timestamp < lastReadTimestamp)
return false;
return Date.now() - latest.timestamp <= OWN_EDIT_STALE_GRACE_MS;
}
canIgnoreStalenessByHashes(filePath, reads, touchedLines, editRanges) {
let lines;
try {
lines = splitLines(fs.readFileSync(filePath, "utf-8"));
}
catch {
return false;
}
const rangesToCheck = touchedLines
? editRanges && editRanges.length > 1
? editRanges
: [touchedLines]
: undefined;
if (!rangesToCheck) {
const lastRead = reads.at(-1);
return !!lastRead && this.readHashesStillMatch(lastRead, lines);
}
return rangesToCheck.every((range) => reads.some((read) => this.readCoversRange(read, range) &&
this.readRangeHashesStillMatch(read, lines, range)));
}
readCoversRange(read, [editStart, editEnd]) {
const readStart = Math.max(1, read.effectiveOffset - this.config.contextLines);
const readEnd = read.effectiveOffset + read.effectiveLimit - 1 + this.config.contextLines;
if (editStart >= readStart && editEnd <= readEnd)
return true;
if (!read.enclosingSymbol)
return false;
return (read.enclosingSymbol.startLine <= editStart &&
read.enclosingSymbol.endLine >= editEnd);
}
validateRangeSnapshot(filePath, range) {
const reads = this.reads.get(filePath) ?? [];
const candidates = reads.filter((read) => this.readCoversRange(read, range));
let status = "unavailable";
let matchingReadIndex = -1;
let missingLines = [];
let mismatchedLines = [];
let checkedCandidateCount = 0;
let unavailableCandidateCount = 0;
let hashUnavailableCandidateCount = 0;
let lastMismatchTimestamp = -Infinity;
let lastUnavailableTimestamp = -Infinity;
for (let i = 0; i < candidates.length; i += 1) {
const validation = currentLinesMatchReadSnapshot(filePath, candidates[i], range);
if (!validation.checked) {
unavailableCandidateCount += 1;
if (readEffectiveRangeCoversRange(candidates[i], range)) {
hashUnavailableCandidateCount += 1;
}
if (status === "unavailable") {
missingLines = validation.missingLines;
}
lastUnavailableTimestamp = Math.max(lastUnavailableTimestamp, candidates[i].timestamp);
continue;
}
checkedCandidateCount += 1;
if (validation.matches) {
status = "match";
matchingReadIndex = i;
missingLines = [];
mismatchedLines = [];
break;
}
status = "mismatch";
missingLines = [];
mismatchedLines = validation.mismatchedLines;
lastMismatchTimestamp = Math.max(lastMismatchTimestamp, candidates[i].timestamp);
}
// Enforce only when no candidate that actually delivered the target range
// lacks hashes. Context-only/symbol-only coverage may be unavailable without
// weakening enforcement from another hash-checkable read of the same range.
// Also suppress when a re-read (unavailable only due to context-zone boundary)
// is more recent than the stale read that triggered the mismatch — the agent
// refreshed their view, and the re-read's edge lines fall within contextLines.
const shouldBlock = status === "mismatch" &&
lastUnavailableTimestamp <= lastMismatchTimestamp &&
checkedCandidateCount > 0 &&
hashUnavailableCandidateCount === 0;
logReadGuardEvent({
event: "range_snapshot_validation",
sessionId: this.sessionId,
filePath,
metadata: {
range,
status,
candidateReadCount: candidates.length,
checkedCandidateCount,
unavailableCandidateCount,
hashUnavailableCandidateCount,
matchingReadIndex,
missingLineCount: missingLines.length,
mismatchedLineCount: mismatchedLines.length,
missingLines: missingLines.slice(0, 20),
mismatchedLines: mismatchedLines.slice(0, 20),
enforced: shouldBlock || status === "match",
},
});
return {
status,
matchingReadIndex,
missingLines,
mismatchedLines,
candidateReadCount: candidates.length,
checkedCandidateCount,
unavailableCandidateCount,
shouldBlock,
};
}
readRangeHashesStillMatch(read, lines, [startLine, endLine]) {
const hashes = read.lineHashes ?? {};
for (let lineNo = startLine; lineNo <= endLine; lineNo += 1) {
if (!readRangeCoversLine(read, lineNo) || hashes[lineNo] === undefined) {
return false;
}
if (lineNo < 1 || lineNo > lines.length)
return false;
if (lineContentHash(lines[lineNo - 1] ?? "") !== hashes[lineNo]) {
return false;
}
}
return true;
}
/**
* Content-verified relocation. When a range the agent read has drifted, find
* where the read-time line-hash sequence for [startLine,endLine] now appears
* in the current file. Returns the unique new location, or undefined when:
* no recorded read captured hashes for the whole range; the sequence is too
* short to be collision-resistant (<2 lines); or it now matches zero or
* multiple spots. Powers a *hint* only — never a silent positional re-apply.
*/
findRelocation(filePath, reads, [startLine, endLine]) {
const span = endLine - startLine + 1;
// A single line's hash collides too easily to relocate on confidently.
if (span < 2)
return undefined;
// Newest read that captured hashes for the entire target range wins.
let wanted;
for (let i = reads.length - 1; i >= 0; i -= 1) {
const hashes = reads[i].lineHashes;
if (!hashes)
continue;
const seq = [];
let complete = true;
for (let lineNo = startLine; lineNo <= endLine; lineNo += 1) {
const h = hashes[lineNo];
if (h === undefined) {
complete = false;
break;
}
seq.push(h);
}
if (complete) {
wanted = seq;
break;
}
}
if (!wanted)
return undefined;
let lines;
try {
lines = splitLines(fs.readFileSync(filePath, "utf-8"));
}
catch {
return undefined;
}
const currentHashes = lines.map((line) => lineContentHash(line));
const lastStart = currentHashes.length - span; // last valid 0-based start
const matchStarts = [];
for (let i = 0; i <= lastStart; i += 1) {
let ok = true;
for (let j = 0; j < span; j += 1) {
if (currentHashes[i + j] !== wanted[j]) {
ok = false;
break;
}
}
if (ok)
matchStarts.push(i + 1); // 1-indexed
}
let newStart;
if (matchStarts.length === 1) {
// Unique across the whole file → certainly the relocated span,
// regardless of how far it drifted (e.g. a large refactor moved it).
newStart = matchStarts[0];
}
else if (matchStarts.length > 1) {
// Duplicated elsewhere: fall back to locality. Lines rarely teleport,
// so accept a match unique WITHIN an adaptive window of the original
// position — out-of-window duplicates don't poison a locally
// unambiguous relocation. The window widens with the edits already
// applied to this file this session (each prior edit shifts line
// numbers, so accumulated drift grows).
const appliedEdits = (this.edits.get(filePath) ?? []).filter((record) => record.verdict !== "blocked").length;
const window = Math.min(RELOCATION_WINDOW_MAX, Math.max(RELOCATION_WINDOW_MIN, appliedEdits * RELOCATION_WINDOW_PER_EDIT));
const lo = startLine - window;
const hi = endLine + window;
const local = matchStarts.filter((start) => start >= lo && start <= hi);
if (local.length === 1)
newStart = local[0];
}
if (newStart === undefined || newStart === startLine)
return undefined;
return { from: [startLine, endLine], to: [newStart, newStart + span - 1] };
}
readHashesStillMatch(read, lines) {
const entries = Object.entries(read.lineHashes ?? {});
if (entries.length === 0)
return false;
for (const [lineText, expected] of entries) {
const lineNo = Number(lineText);
if (!Number.isInteger(lineNo) || lineNo < 1 || lineNo > lines.length) {
return false;
}
if (lineContentHash(lines[lineNo - 1] ?? "") !== expected)
return false;
}
return true;
}
checkCoverage(filePath, touchedLines) {
const [editStart, editEnd] = touchedLines;
const reads = this.reads.get(filePath) ?? [];
// First pass: check symbol coverage and any single read that covers the edit.
for (const read of reads) {
const readStart = Math.max(1, read.effectiveOffset - this.config.contextLines);
const readEnd = read.effectiveOffset +
read.effectiveLimit -
1 +
this.config.contextLines;
if (editStart >= readStart && editEnd <= readEnd) {
return { covered: true, viaSymbol: false };
}
if (read.enclosingSymbol) {
const symStart = read.enclosingSymbol.startLine;
const symEnd = read.enclosingSymbol.endLine;
if (symStart <= editStart && symEnd >= editEnd) {
return { covered: true, viaSymbol: true };
}
}
}
// Second pass: merge all read intervals and check if their union covers
// [editStart, editEnd]. Handles multi-chunk reads (e.g. 1-100 + 101-200).
const intervals = reads.map((read) => [
Math.max(1, read.effectiveOffset - this.config.contextLines),
read.effectiveOffset +
read.effectiveLimit -
1 +
this.config.contextLines,
]);
intervals.sort((a, b) => a[0] - b[0]);
// Merge overlapping/adjacent intervals
const merged = [];
for (const [s, e] of intervals) {
if (merged.length > 0 && s <= merged[merged.length - 1][1] + 1) {
merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], e);
}
else {
merged.push([s, e]);
}
}
for (const [s, e] of merged) {
if (editStart >= s && editEnd <= e) {
return { covered: true, viaSymbol: false };
}
}
return { covered: false, viaSymbol: false };
}
getExemptionMode(filePath) {
for (const exemption of this.config.exemptions) {
if (this.matchesPattern(filePath, exemption.pattern)) {
return exemption.mode;
}
}
return null;
}
matchesPattern(filePath, pattern) {
// Simple glob matching — can be expanded
if (pattern.startsWith("*")) {
const suffix = pattern.slice(1);
return filePath.endsWith(suffix);
}
if (pattern.includes("*")) {
// Convert glob to regex
const regex = new RegExp(`^${pattern.replace(/\\/g, "\\\\").replace(/\./g, "\\.").replace(/\*/g, ".*")}$`);
return regex.test(filePath);
}
return filePath === pattern;
}
blockOrWarn(_reason, message, details, overrideMode) {
const mode = overrideMode ?? this.config.mode;
if (mode === "warn") {
return { action: "warn", reason: message, details };
}
return { action: "block", reason: message, details };
}
allow() {
return { action: "allow" };
}
recordEdit(filePath, tool, touchedLines, verdict) {
const arr = this.edits.get(filePath) ?? [];
arr.push({
filePath,
tool,
touchedLines,
precedingReads: this.reads.get(filePath) ?? [],
verdict: mapVerdictAction(verdict.action),
reason: verdict.reason,
timestamp: Date.now(),
});
this.edits.set(filePath, arr);
}
recordVerdict(filePath, tool, touchedLines, verdict, metadata = {}) {
const normalizedTouchedLines = touchedLines ?? [1, 1];
this.recordEdit(filePath, tool, normalizedTouchedLines, verdict);
const reads = this.reads.get(filePath) ?? [];
logReadGuardEvent({
event: verdict.action === "allow"
? "edit_allowed"
: verdict.action === "warn"
? "edit_warned"
: "edit_blocked",
sessionId: this.sessionId,
filePath,
metadata: {
tool,
touchedLines: touchedLines ?? null,
normalizedTouchedLines,
readCount: reads.length,
reads: reads.map((read) => ({
requestedOffset: read.requestedOffset,
requestedLimit: read.requestedLimit,
effectiveOffset: read.effectiveOffset,
effectiveLimit: read.effectiveLimit,
expandedByLsp: read.expandedByLsp,
enclosingSymbol: read.enclosingSymbol ?? null,
timestamp: read.timestamp,
})),
verdictAction: verdict.action,
details: verdict.details,
...metadata,
},
});
}
}
// --- Factory ---
function mapVerdictAction(action) {
switch (action) {
case "allow":
return "allowed";
case "block":
return "blocked";
case "warn":
return "warned";
}
}
export function createReadGuard(sessionId, config) {
return new ReadGuard(sessionId, config);
}