F2 SLICE-2: Anchorer (fingerprint, resolution ladder, live shift) (#4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+113
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
|
||||
/** 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) };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildFingerprint, resolve, shift, type OffsetRange } from "../src/anchorer";
|
||||
|
||||
const DOC = "line0\nline1 needle here\nline2\nneedle again on line3\n";
|
||||
|
||||
describe("buildFingerprint", () => {
|
||||
it("captures the exact text, bounded context, and 0-based lineHint", () => {
|
||||
const start = DOC.indexOf("needle here");
|
||||
const range: OffsetRange = { start, end: start + "needle".length };
|
||||
const fp = buildFingerprint(DOC, range);
|
||||
expect(fp.text).toBe("needle");
|
||||
expect(fp.lineHint).toBe(1);
|
||||
expect(fp.before.endsWith("line1 ")).toBe(true);
|
||||
expect(fp.after.startsWith(" here")).toBe(true);
|
||||
expect(fp.before.length).toBeLessThanOrEqual(120);
|
||||
expect(fp.after.length).toBeLessThanOrEqual(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolve", () => {
|
||||
it("exact-unique: returns the single occurrence's range", () => {
|
||||
const fp = { text: "line2", before: "", after: "", lineHint: 0 };
|
||||
const start = DOC.indexOf("line2");
|
||||
expect(resolve(DOC, fp)).toEqual({ start, end: start + 5 });
|
||||
});
|
||||
|
||||
it("context-disambiguated: picks the occurrence whose before/after match", () => {
|
||||
const fp = { text: "needle", before: "line1 ", after: " here", lineHint: 99 };
|
||||
const start = DOC.indexOf("needle here");
|
||||
expect(resolve(DOC, fp)).toEqual({ start, end: start + 6 });
|
||||
});
|
||||
|
||||
it("lineHint tiebreak: when context does not disambiguate, the closest line wins", () => {
|
||||
// both occurrences of 'needle', no usable context, hint points at line 3
|
||||
const fp = { text: "needle", before: "", after: "", lineHint: 3 };
|
||||
const start = DOC.indexOf("needle again");
|
||||
expect(resolve(DOC, fp)).toEqual({ start, end: start + 6 });
|
||||
});
|
||||
|
||||
it("orphaned: returns 'orphaned' when the text is gone", () => {
|
||||
const fp = { text: "absent-text", before: "", after: "", lineHint: 0 };
|
||||
expect(resolve(DOC, fp)).toBe("orphaned");
|
||||
});
|
||||
|
||||
it("orphaned: returns 'orphaned' rather than guessing on an unbreakable tie (INV-1)", () => {
|
||||
const doc = "needle\n....\nneedle\n"; // two identical occurrences, equidistant from hint
|
||||
const fp = { text: "needle", before: "", after: "", lineHint: 1 };
|
||||
expect(resolve(doc, fp)).toBe("orphaned");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shift", () => {
|
||||
it("edit entirely before the range shifts both endpoints by the delta", () => {
|
||||
const r: OffsetRange = { start: 10, end: 15 };
|
||||
// insert 2 chars at offset 0 (replace [0,0) with len 2)
|
||||
expect(shift(r, { start: 0, end: 0, newLength: 2 })).toEqual({ start: 12, end: 17 });
|
||||
});
|
||||
|
||||
it("edit entirely after the range leaves it unchanged", () => {
|
||||
const r: OffsetRange = { start: 2, end: 5 };
|
||||
expect(shift(r, { start: 10, end: 12, newLength: 0 })).toEqual({ start: 2, end: 5 });
|
||||
});
|
||||
|
||||
it("edit overlapping the range clamps the touched endpoints to the edit start", () => {
|
||||
const r: OffsetRange = { start: 5, end: 10 };
|
||||
// replace [3,7) with 1 char (delta = -3)
|
||||
expect(shift(r, { start: 3, end: 7, newLength: 1 })).toEqual({ start: 3, end: 7 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user