7583165354
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>
138 lines
5.3 KiB
TypeScript
138 lines
5.3 KiB
TypeScript
/**
|
|
* Anchorer — hybrid anchoring (spec §6.5). The durable FINGERPRINT is the source
|
|
* of truth for an anchor's location (INV-3); live OFFSET ranges are a within-
|
|
* session optimization. The resolution ladder is:
|
|
* exact-unique → context-disambiguated → lineHint tiebreak → orphaned.
|
|
* It NEVER guesses on an unbreakable tie — it orphans instead (INV-1).
|
|
*
|
|
* vscode-free: operates on plain strings and character-offset ranges, so it is
|
|
* unit-testable in Node. The vscode layer converts OffsetRange <-> vscode.Range.
|
|
*/
|
|
import type { Fingerprint } from "./model";
|
|
|
|
export interface OffsetRange {
|
|
start: number;
|
|
end: number;
|
|
}
|
|
|
|
/** A document edit: the half-open range [start,end) was replaced with text of `newLength`. */
|
|
export interface TextEdit {
|
|
start: number;
|
|
end: number;
|
|
newLength: number;
|
|
}
|
|
|
|
const MAX_CTX_CHARS = 120;
|
|
const MAX_CTX_LINES = 3;
|
|
|
|
function lineNumberAt(doc: string, offset: number): number {
|
|
let line = 0;
|
|
const bound = Math.min(offset, doc.length);
|
|
for (let i = 0; i < bound; i++) if (doc.charCodeAt(i) === 10) line++;
|
|
return line;
|
|
}
|
|
|
|
function leadingContext(doc: string, start: number): string {
|
|
let s = doc.slice(Math.max(0, start - MAX_CTX_CHARS), start);
|
|
const lines = s.split("\n");
|
|
if (lines.length > MAX_CTX_LINES) s = lines.slice(lines.length - MAX_CTX_LINES).join("\n");
|
|
return s;
|
|
}
|
|
|
|
function trailingContext(doc: string, end: number): string {
|
|
let s = doc.slice(end, Math.min(doc.length, end + MAX_CTX_CHARS));
|
|
const lines = s.split("\n");
|
|
if (lines.length > MAX_CTX_LINES) s = lines.slice(0, MAX_CTX_LINES).join("\n");
|
|
return s;
|
|
}
|
|
|
|
export function buildFingerprint(docText: string, range: OffsetRange): Fingerprint {
|
|
return {
|
|
text: docText.slice(range.start, range.end),
|
|
before: leadingContext(docText, range.start),
|
|
after: trailingContext(docText, range.end),
|
|
lineHint: lineNumberAt(docText, range.start),
|
|
};
|
|
}
|
|
|
|
function allIndexesOf(hay: string, needle: string): number[] {
|
|
if (needle.length === 0) return [];
|
|
const out: number[] = [];
|
|
let from = 0;
|
|
let idx = hay.indexOf(needle, from);
|
|
while (idx !== -1) {
|
|
out.push(idx);
|
|
from = idx + 1;
|
|
idx = hay.indexOf(needle, from);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function contextMatches(doc: string, i: number, fp: Fingerprint): boolean {
|
|
const beforeOk = fp.before.length === 0 || doc.slice(Math.max(0, i - fp.before.length), i) === fp.before;
|
|
const afterStart = i + fp.text.length;
|
|
const afterOk = fp.after.length === 0 || doc.slice(afterStart, afterStart + fp.after.length) === fp.after;
|
|
return beforeOk && afterOk;
|
|
}
|
|
|
|
const rangeAt = (i: number, text: string): OffsetRange => ({ start: i, end: i + text.length });
|
|
|
|
/**
|
|
* Re-resolve a fingerprint against current text. Returns the resolved range or
|
|
* "orphaned" when no confident, unique match exists (INV-1).
|
|
*/
|
|
export function resolve(docText: string, fp: Fingerprint): OffsetRange | "orphaned" {
|
|
const occ = allIndexesOf(docText, fp.text);
|
|
if (occ.length === 0) return "orphaned";
|
|
if (occ.length === 1) return rangeAt(occ[0], fp.text);
|
|
|
|
// Multiple exact matches: try context disambiguation.
|
|
const ctx = occ.filter((i) => contextMatches(docText, i, fp));
|
|
if (ctx.length === 1) return rangeAt(ctx[0], fp.text);
|
|
|
|
// Still ambiguous: break ties by proximity to lineHint within the best pool.
|
|
const pool = ctx.length > 0 ? ctx : occ;
|
|
const dists = pool.map((i) => ({ i, d: Math.abs(lineNumberAt(docText, i) - fp.lineHint) }));
|
|
const min = Math.min(...dists.map((x) => x.d));
|
|
const closest = dists.filter((x) => x.d === min);
|
|
if (closest.length === 1) return rangeAt(closest[0].i, fp.text);
|
|
|
|
// Unbreakable tie — refuse to guess.
|
|
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);
|
|
const point = (p: number): number => {
|
|
if (p <= edit.start) return p;
|
|
if (p >= edit.end) return p + delta;
|
|
return edit.start; // inside the replaced span — clamp to its start
|
|
};
|
|
return { start: point(range.start), end: point(range.end) };
|
|
}
|