feat(hashline): standardized drift recovery using anchor remapping

- Replaced 3-way-merge and session-chain replay strategies with a consistent anchor remapping flow for drift recovery.
- Removed legacy reconciliation logic and associated session-replay warning constants.
- Updated recovery flow to mandate anchor consistency and validate against duplicated context.
- Refined test suite to verify anchor mapping mechanics and explicit refusal of ambiguous remappings.
This commit is contained in:
can1357
2026-07-13 16:23:00 +02:00
parent b451f94562
commit a886a30901
8 changed files with 73 additions and 193 deletions
+1
View File
@@ -6,6 +6,7 @@
- Rejected ambiguous swaps that risk silent deletion of range boundaries
- Prevented ambiguous auto-repairing of structural closing lines when payload placement is unclear
- Prevented stale-hash recovery from relocating edits onto duplicated context after the original target changed
## [16.3.3] - 2026-07-02
-9
View File
@@ -208,15 +208,6 @@ export const RECOVERY_EXTERNAL_WARNING =
export const RECOVERY_SESSION_CHAIN_WARNING =
"Recovered from a stale file hash using an earlier in-session snapshot (a prior edit in this session advanced the hash).";
/**
* `Recovery`: session-chain replay fast-path. Less certain than
* {@link RECOVERY_SESSION_CHAIN_WARNING} — the 3-way merge refused, the
* anchor-content gate passed, but a coincidental insert+delete earlier in
* the chain could still misplace an anchor — hence the verify hedge.
*/
export const RECOVERY_SESSION_REPLAY_WARNING =
"Recovered by replaying your edits onto the current file content (a prior in-session edit changed the lines you re-targeted with a stale hash). Verify the diff matches your intent.";
/** `Recovery`: stale anchors were relocated to unchanged live lines after drift. */
export const RECOVERY_LINE_REMAP_WARNING =
"Recovered by remapping stale line anchors to unchanged current lines (file changed since the tagged read). Verify the diff matches your intent.";
+4 -4
View File
@@ -586,7 +586,7 @@ export class Patcher {
const expected = exists ? section.fileHash : undefined;
// The 4-hex tag is content-derived: when the live text hashes to it,
// trust the match and apply directly. `storedSnapshotForTag` feeds the
// drift paths below (block resolution, 3-way recovery); on a 16-bit
// drift paths below (block resolution, anchor remapping); on a 16-bit
// tag collision it resolves to the most-recently recorded text.
const storedSnapshotForTag = expected === undefined ? null : this.snapshots.byHash(canonicalPath, expected);
const liveMatches = expected !== undefined && computeFileHash(normalized) === expected;
@@ -598,7 +598,7 @@ export class Patcher {
// - live content matches the tag (or there is no tag) → resolve against
// the live, normalized content;
// - the file drifted → resolve against the tagged snapshot's text so the
// resulting ranges flow through the 3-way-merge recovery below.
// resulting ranges can be mapped to unchanged live lines below.
// When a block edit needs the tagged snapshot but it is unavailable, the
// range cannot be placed safely — reject with a MismatchError (re-read).
const blockResolutions: BlockResolution[] = [];
@@ -640,8 +640,8 @@ export class Patcher {
const result = applyEdits(normalized, resolved);
return withResolveWarnings({ ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] });
}
// File drifted: try to replay the edit against the version the tag
// names and 3-way-merge it onto the live content.
// File drifted: map every anchor from the tagged snapshot to unchanged
// live lines. Recovery refuses changed or ambiguous targets.
const recovered = this.recovery.tryRecover({
path: canonicalPath,
currentText: normalized,
+29 -156
View File
@@ -1,29 +1,17 @@
/**
* Recover from a stale section snapshot tag by replaying the would-be edit
* against a cached pre-edit snapshot of the file and 3-way-merging the
* result onto the current on-disk content.
* Recovers stale section tags by proving that every anchored line still maps
* to one unchanged, contiguous region in the current file, then replaying the
* edit against that live content.
*
* The patcher consults this when a section tag resolves to a snapshot that no
* longer matches the live file content. The recovery class is stateless apart
* from the {@link SnapshotStore} it queries; the snapshot store is the seam
* lets you plug in your own caching strategy.
* Recovery fails closed when the target changed or became ambiguous. The
* patcher then returns a mismatch with fresh context instead of guessing.
*/
import * as Diff from "diff";
import { applyEdits } from "./apply";
import {
RECOVERY_EXTERNAL_WARNING,
RECOVERY_LINE_REMAP_WARNING,
RECOVERY_SESSION_CHAIN_WARNING,
RECOVERY_SESSION_REPLAY_WARNING,
} from "./messages";
import type { Snapshot, SnapshotStore } from "./snapshots";
import { RECOVERY_EXTERNAL_WARNING, RECOVERY_LINE_REMAP_WARNING, RECOVERY_SESSION_CHAIN_WARNING } from "./messages";
import type { SnapshotStore } from "./snapshots";
import type { Anchor, ApplyResult, Edit } from "./types";
// Section tags are line-precise; never let Diff.applyPatch slide a hunk
// onto a duplicate closer 100+ lines away. If snapshot replay does not
// align exactly, refuse and let the caller re-read.
const RECOVERY_FUZZ_FACTOR = 0;
export interface RecoveryArgs {
path: string;
currentText: string;
@@ -40,31 +28,6 @@ export interface RecoveryResult {
warnings: string[];
}
function applyEditsToSnapshot(
previousText: string,
currentText: string,
edits: readonly Edit[],
recoveryWarning: string,
): RecoveryResult | null {
let applied: ApplyResult;
try {
applied = applyEdits(previousText, [...edits]);
} catch {
return null;
}
if (applied.text === previousText) return null;
const patch = Diff.structuredPatch("file", "file", previousText, applied.text, "", "", { context: 3 });
const merged = Diff.applyPatch(currentText, patch, { fuzzFactor: RECOVERY_FUZZ_FACTOR });
if (typeof merged !== "string" || merged === currentText) return null;
const firstChangedLine = findFirstChangedLine(currentText, merged) ?? applied.firstChangedLine;
const hasNetChange = firstChangedLine !== undefined;
const warnings = hasNetChange ? [recoveryWarning, ...(applied.warnings ?? [])] : [...(applied.warnings ?? [])];
return { text: merged, firstChangedLine, warnings };
}
function collectAnchorLines(edits: readonly Edit[]): number[] {
const lines: number[] = [];
for (const edit of edits) {
@@ -81,27 +44,6 @@ function getEditAnchors(edit: Edit): Anchor[] {
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor" ? [edit.cursor.anchor] : [];
}
/**
* Returns true when every anchor line in `edits` has identical content in
* `previousText` and `currentText`. The session-chain replay fast-path
* requires this: if the prior in-session edit rewrote the line the model is
* now re-targeting with a stale hash, replaying onto current would silently
* overwrite the new content with whatever the model authored against the
* old content — a corruption window, not a recovery.
*/
function verifyAnchorContent(previousText: string, currentText: string, edits: readonly Edit[]): boolean {
const lines = collectAnchorLines(edits);
if (lines.length === 0) return true;
const prev = previousText.split("\n");
const curr = currentText.split("\n");
for (const line of lines) {
const idx = line - 1;
if (idx < 0 || idx >= prev.length || idx >= curr.length) return false;
if (prev[idx] !== curr[idx]) return false;
}
return true;
}
function buildLineMap(previousText: string, currentText: string): Map<number, number> {
const previousLines = previousText.split("\n");
const currentLines = currentText.split("\n");
@@ -198,7 +140,7 @@ function validateUniqueAnchorContext(
): boolean {
const offset = mapped - line;
const { before, after } = neighbors;
if (after !== undefined) return lineMap.get(after) === after + offset;
if (after !== undefined && lineMap.get(after) === after + offset) return true;
return before !== undefined && lineMap.get(before) === before + offset;
}
@@ -237,7 +179,12 @@ function validateRemappedAnchorContext(
return true;
}
function remapEditsToCurrent(previousText: string, currentText: string, edits: readonly Edit[]): Edit[] | null {
interface RemappedEdits {
edits: Edit[];
offset: number;
}
function remapEditsToCurrent(previousText: string, currentText: string, edits: readonly Edit[]): RemappedEdits | null {
const lineMap = buildLineMap(previousText, currentText);
if (!validateRemappedAnchorContext(previousText, currentText, lineMap, edits)) return null;
const offsets: number[] = [];
@@ -289,21 +236,21 @@ function remapEditsToCurrent(previousText: string, currentText: string, edits: r
if (offsets.length === 0) return null;
const firstOffset = offsets[0];
if (firstOffset === 0) return null;
if (!offsets.every(offset => offset === firstOffset)) return null;
return remapped;
return { edits: remapped, offset: firstOffset };
}
function replayRemappedAnchorsOnCurrent(
previousText: string,
currentText: string,
edits: readonly Edit[],
recoveryWarning: string,
): RecoveryResult | null {
const remapped = remapEditsToCurrent(previousText, currentText, edits);
if (remapped === null) return null;
let applied: ApplyResult;
try {
applied = applyEdits(currentText, remapped);
applied = applyEdits(currentText, remapped.edits);
} catch {
return null;
}
@@ -311,78 +258,18 @@ function replayRemappedAnchorsOnCurrent(
return {
text: applied.text,
firstChangedLine: applied.firstChangedLine,
warnings: [RECOVERY_LINE_REMAP_WARNING, ...(applied.warnings ?? [])],
warnings: [remapped.offset === 0 ? recoveryWarning : RECOVERY_LINE_REMAP_WARNING, ...(applied.warnings ?? [])],
};
}
function replaySessionChainOnCurrent(
previousText: string,
currentText: string,
edits: readonly Edit[],
): RecoveryResult | null {
// Two guards narrow the corruption window. Neither alone is sufficient,
// and even together they don't fully prove correctness — replay is the
// less-certain recovery mode and emits RECOVERY_SESSION_REPLAY_WARNING
// so the caller can verify the diff.
// - Equal line counts: every line number in `edits` still resolves to
// SOME logical row (no net shift across the prior chain). A
// coincidental insert+delete pair can still leave indices pointing
// at different logical rows than the model anchored against.
// - Anchor-content alignment: the row at each anchor's line index has
// identical content in previous and current. Catches the common
// case of a prior edit rewriting the targeted line; can still be
// coincidentally satisfied by a duplicated row at the shifted
// index.
if (previousText.split("\n").length !== currentText.split("\n").length) return null;
if (!verifyAnchorContent(previousText, currentText, edits)) return null;
let applied: ApplyResult;
try {
applied = applyEdits(currentText, [...edits]);
} catch {
return null;
}
if (applied.text === currentText) return null;
return {
text: applied.text,
firstChangedLine: applied.firstChangedLine,
warnings: [RECOVERY_SESSION_REPLAY_WARNING, ...(applied.warnings ?? [])],
};
}
/** First 1-indexed line at which `a` and `b` diverge, or `undefined` if equal. */
function findFirstChangedLine(a: string, b: string): number | undefined {
if (a === b) return undefined;
const aLines = a.split("\n");
const bLines = b.split("\n");
const max = Math.max(aLines.length, bLines.length);
for (let i = 0; i < max; i++) {
if (aLines[i] !== bLines[i]) return i + 1;
}
return undefined;
}
function isHeadSnapshot(head: Snapshot | null, snapshot: Snapshot): boolean {
return head === snapshot;
}
/**
* Stateless recovery driver over a {@link SnapshotStore}. Construct once and
* call {@link Recovery.tryRecover} per stale-tag incident. The default
* implementation tries three strategies in order:
* call {@link Recovery.tryRecover} per stale-tag incident.
*
* 1. Apply the edits on the full-file version the tag names, then 3-way-merge
* the resulting patch onto the live content (handles external writes).
* 2. Remap every stale anchor through the unchanged-line diff from the tagged
* snapshot to the live text, then replay on live content. This handles a
* prior insertion/deletion before the target while refusing changed anchors
* and mixed offsets across the same edit range.
* 3. (Session chain) If that version wasn't the head, replay the edits onto
* the live content directly when line counts match AND every edit's anchor
* line content is unchanged between version and current — a prior in-session
* edit advanced the tag and the model's anchors still name the same logical
* rows. Emits a dedicated {@link RECOVERY_SESSION_REPLAY_WARNING} because
* even with both guards a coincidental insert+delete pair on duplicate rows
* can still land the edit on the wrong row; see {@link replaySessionChainOnCurrent}.
* Recovery maps every stale anchor through unchanged lines from the tagged
* snapshot to the live text, validates surrounding context, and replays the
* edit directly on live content. All anchors must move by one consistent
* offset. A changed, deleted, split, or ambiguous target is rejected so the
* caller can surface a {@link MismatchError} with current context.
*/
export class Recovery {
constructor(readonly store: SnapshotStore) {}
@@ -392,26 +279,12 @@ export class Recovery {
*/
tryRecover(args: RecoveryArgs): RecoveryResult | null {
const { path, currentText, fileHash, edits } = args;
// When two retained texts collide on the 16-bit tag, resolve to the
// most-recently recorded one; a wrong pick can only land if one of the
// merge/remap/session-chain strategies below applies it cleanly.
// When retained texts collide on the 16-bit tag, use the latest one.
// Recovery still requires its anchors and context to map unambiguously.
const snapshot = this.store.byHash(path, fileHash);
if (!snapshot) return null;
const isHead = isHeadSnapshot(this.store.head(path), snapshot);
const recoveryWarning = isHead ? RECOVERY_EXTERNAL_WARNING : RECOVERY_SESSION_CHAIN_WARNING;
const merged = applyEditsToSnapshot(snapshot.text, currentText, edits, recoveryWarning);
if (merged !== null) return merged;
// Line-shift fallback: the 3-way merge refused, but unchanged anchor
// lines may have moved because a prior edit inserted or deleted rows
// before them. Remap only when every anchor resolves through the diff
// with one consistent offset; otherwise the edit range was touched.
const remapped = replayRemappedAnchorsOnCurrent(snapshot.text, currentText, edits);
if (remapped !== null) return remapped;
// Session-chain fallback: replay onto current is gated by line-count
// equality AND anchor-content alignment — see
// `replaySessionChainOnCurrent` for why both guards together still
// don't fully prove correctness.
if (!isHead) return replaySessionChainOnCurrent(snapshot.text, currentText, edits);
return null;
const recoveryWarning =
this.store.head(path) === snapshot ? RECOVERY_EXTERNAL_WARNING : RECOVERY_SESSION_CHAIN_WARNING;
return replayRemappedAnchorsOnCurrent(snapshot.text, currentText, edits, recoveryWarning);
}
}
+3 -4
View File
@@ -11,8 +11,7 @@
* {@link SnapshotStore.record} with the full normalized text they observed.
* The store hashes it, dedups against the per-path history, and returns the
* tag. Consumers (recovery, the patcher) resolve a stale tag back to the
* recorded full text via {@link SnapshotStore.byHash} and 3-way-merge the
* would-be edit onto the live content.
* recorded full text and map its unchanged edit anchors onto live content.
*
* The abstract base class lets callers plug in whatever storage they like
* (LRU, persistent SQLite, etc.). {@link InMemorySnapshotStore} ships as a
@@ -199,8 +198,8 @@ export class InMemorySnapshotStore extends SnapshotStore {
// texts that happen to share the 4-hex tag are DIFFERENT snapshots — fusing
// them under one entry would corrupt seenLines (attaching lines from
// text B onto the stored text A) and let the patcher misresolve which
// snapshot the section tag names when it does 3-way merge or seen-line
// validation. See issue #4075.
// snapshot the section tag names during recovery or seen-line validation.
// See issue #4075.
const existing = history.find(version => version.hash === hash && version.text === fullText);
if (existing) {
// Same content state observed again: refresh recency and promote to
+1 -1
View File
@@ -229,7 +229,7 @@ describe("Patcher with a block resolver", () => {
const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver });
// `block 2` resolves against the SNAPSHOT → span [2,3] → replace
// "line1","line2"; recovery 3-way-merges the change onto the live file.
// "line1","line2"; recovery maps that unchanged span onto the live file.
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP.BLK 2:\n+NEW`));
expect(result.sections[0]?.op).toBe("update");
@@ -607,8 +607,8 @@ describe("boundary-balance repair through stale-snapshot recovery", () => {
// Recovery composes `applyEdits` to compute the intended change, so the
// boundary repair runs there too. The snapshot (what the model read)
// carries the structure; the live file has drifted far from the edit
// region, so the stale-hash 3-way merge succeeds and the repaired
// (de-duplicated) hunk lands without doubling the closer.
// region, so anchor recovery succeeds and the repaired (de-duplicated)
// hunk lands without doubling the closer.
it("de-duplicates a closer while recovering from a drifted file", () => {
const snapshotLines = [
'import { x } from "y";',
@@ -628,7 +628,7 @@ describe("boundary-balance repair through stale-snapshot recovery", () => {
];
const snapshotText = `${snapshotLines.join("\n")}\n`;
// Live file drifted only at the tail (line 13) — far outside the edit
// region (lines 4-6), so the 3-way merge applies cleanly.
// region (lines 4-6), so unchanged-anchor recovery succeeds.
const currentText = snapshotText.replace("const tail = 0;", "const tail = 99;");
const store = new InMemorySnapshotStore();
@@ -15,7 +15,7 @@ import {
InMemorySnapshotStore,
parsePatch,
RECOVERY_LINE_REMAP_WARNING,
RECOVERY_SESSION_REPLAY_WARNING,
RECOVERY_SESSION_CHAIN_WARNING,
Recovery,
} from "@oh-my-pi/hashline";
@@ -58,10 +58,9 @@ describe("Recovery — session-chain replay anchor-content gate", () => {
it("replays edits onto current when every anchor's line content is unchanged", () => {
const { store, v1Text, h0 } = seedTwoSnapshots();
// Edit anchored at line 3 — unchanged between v0 and v1. The 3-way
// merge fails (patch context includes the rewritten line 5), but the
// replay fallback is safe because the model's anchor still names the
// same logical content.
// Edit anchored at line 3 — unchanged between v0 and v1. Recovery
// proves that the target and its surrounding context still map to the
// same live lines before replaying the edit.
const { edits } = parsePatch("SWAP 3.=3:\n|L3-MODEL");
const recovered = new Recovery(store).tryRecover({
@@ -76,11 +75,10 @@ describe("Recovery — session-chain replay anchor-content gate", () => {
// Prior in-session change must survive — the model's edit lands on
// top of current, not on top of the stale snapshot.
expect(recovered?.text).toContain("L5-CHANGED");
// The replay path is the less-certain recovery mode (a coincidental
// insert+delete pair earlier in the chain could leave indices
// pointing at duplicated rows even with both guards satisfied), so
// the dedicated REPLAY warning surfaces a "verify the diff" hedge.
expect(recovered?.warnings).toContain(RECOVERY_SESSION_REPLAY_WARNING);
// Zero-offset recovery against an earlier retained snapshot reports the
// session-chain banner; unlike the removed direct replay fallback, this
// path has proved the anchors through the unchanged-line map.
expect(recovered?.warnings).toContain(RECOVERY_SESSION_CHAIN_WARNING);
});
it("recovers stale anchors shifted by a prior in-session insertion", () => {
@@ -141,11 +139,30 @@ describe("Recovery — session-chain replay anchor-content gate", () => {
expect(recovered).toBeNull();
});
it("refuses unique-line remaps when following context no longer matches", () => {
it("refuses to relocate a stale replacement onto duplicated context", () => {
const store = new InMemorySnapshotStore();
const block = ["head", "TARGET_A", "TARGET_B", "ctx1", "ctx2", "ctx3"];
const v0Text = lines(...block, "middle", ...block, "tail");
const hash = store.record(PATH, v0Text);
const currentText = lines("head", "CHANGED_A", "CHANGED_B", "ctx1", "ctx2", "ctx3", "middle", ...block, "tail");
const { edits } = parsePatch("SWAP 2.=3:\n+MODEL_A\n+MODEL_B");
const recovered = new Recovery(store).tryRecover({
path: PATH,
currentText,
fileHash: hash,
edits,
});
expect(recovered).toBeNull();
expect(currentText).toContain("TARGET_A\nTARGET_B");
});
it("refuses an isolated unique-line remap when neither neighbor follows its offset", () => {
const store = new InMemorySnapshotStore();
const v0Text = lines("L1", "L2", "L3", "L4", "T", "L6");
const h0 = store.record(PATH, v0Text);
const v1Text = lines("X", "L1", "L2", "L3", "L4", "T", "T_CHANGED", "L6");
const v1Text = lines("X", "L1", "L2", "L3", "L4", "BEFORE", "T", "AFTER", "L6");
store.record(PATH, v1Text);
const { edits } = parsePatch("SWAP 5.=5:\n+MODEL");
@@ -214,8 +231,8 @@ describe("Recovery — colliding snapshot tags", () => {
store.record(PATH, newer);
// Live drifted away from both colliders, so recovery cannot shortcut
// via live==snapshot. The tag cannot name a unique base; it resolves
// to the most-recently recorded collider and 3-way merges from there.
// via live==snapshot. The tag cannot name a unique base; recovery uses
// the most-recently retained collider and maps its unchanged anchors.
const currentText = `${newer}drifted trailer\n`;
const recovered = new Recovery(store).tryRecover({
path: PATH,
@@ -228,8 +245,7 @@ describe("Recovery — colliding snapshot tags", () => {
});
it("still recovers when exactly one retained text carries the tag", () => {
// Same drift scenario with a single retained text for the tag: the
// plain 3-way merge path.
// Same drift scenario with a single retained text for the tag.
const { older } = findCollidingTexts();
const store = new InMemorySnapshotStore();
const tag = store.record(PATH, older);