fix(#70): INV-5 — reject after an interior tweak restores the retained original (PR #73)

Reject on an optimistically-applied proposal now restores the retained original even after interior tweaks: appliedSpans tracked-range fallback (pure shiftTracked, boundary-straddle distrust, close-clears, rebuild-only resync), honest hard failure with a Discard action, INV-16 read-only guards, rejectAll {reverted,skipped} reporting on all batch surfaces, CodeLens reachability at the tracked span. 312 unit + 94/5 host E2E.

Closes #70.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit was merged in pull request #73.
This commit is contained in:
2026-07-03 01:19:46 +00:00
parent 42740f7cc6
commit 7583165354
8 changed files with 897 additions and 42 deletions
+24
View File
@@ -101,6 +101,30 @@ export function resolve(docText: string, fp: Fingerprint): OffsetRange | "orphan
return "orphaned";
}
/**
* #70 (INV-5/INV-11): maintain a TRACKED applied span across an in-session edit.
* Unlike `shift`, which clamps every point into a best-effort range, a tracked
* span must stay *provably* meaningful — it is a revert target. So:
* - an edit fully OUTSIDE or fully INSIDE the span shifts it (the span is
* still "the applied block, as tweaked");
* - an insertion exactly AT the span start lands BEFORE the span (both ends
* shift right) — matching exact-resolve semantics, where text typed just
* before the applied block is never part of it;
* - any edit STRADDLING a span boundary returns "distrusted": the shifted
* result would be a clamped guess, and stale text is never reverted by
* guess (INV-11).
*/
export function shiftTracked(range: OffsetRange, edit: TextEdit): OffsetRange | "distrusted" {
const delta = edit.newLength - (edit.end - edit.start);
if (edit.start === edit.end && edit.start === range.start) {
return { start: range.start + delta, end: range.end + delta };
}
const outside = edit.end <= range.start || edit.start >= range.end;
const inside = edit.start >= range.start && edit.end <= range.end;
if (!outside && !inside) return "distrusted";
return shift(range, edit);
}
/** Maintain a live range across an in-session edit (spec §6.4 `shift`). */
export function shift(range: OffsetRange, edit: TextEdit): OffsetRange {
const delta = edit.newLength - (edit.end - edit.start);