fix(#70): harden the tracked-span revert per branch review

- shiftTracked (pure, anchorer.ts + unit tests): interior/outside edits shift,
  boundary-straddling edits distrust; an insertion exactly at the span start
  lands BEFORE the span (never absorbed into the revert target)
- clear tracked spans on doc close (a closed buffer can change on disk with no
  change events — a kept span could revert over unrelated text, INV-11)
- renderAll only REBUILDS an absent span from an exact resolve; a present entry
  is continuously-tracked ground truth (duplicate-text resolve can't clobber it)
- revertInPlace/finalizeInPlace gain the guard.isReadOnly check (INV-16)
- the hard-fail warning offers 'Discard proposal (leave text)' so a reloaded
  session can still dismiss an unlocatable proposal record
- ✓/✗ CodeLens pair anchors at the tracked span when a tweak orphans the exact
  anchor (the fixed reject path stays reachable from the primary gesture)
- QuickPick batch menu routes through the reporting accept-all/reject-all
  commands (a discarded skip tally re-created the silent failure)
- one clearProposal helper replaces the thrice-copied clear sequence;
  reject() now cleans applied/appliedSpans too

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-02 18:17:56 -07:00
parent f47dfbc16a
commit e389406637
5 changed files with 173 additions and 33 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);