From a886a309018ef043ffd8565c2a376cc084e42885 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 13 Jul 2026 16:23:00 +0200 Subject: [PATCH] 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. --- packages/hashline/CHANGELOG.md | 1 + packages/hashline/src/messages.ts | 9 - packages/hashline/src/patcher.ts | 8 +- packages/hashline/src/recovery.ts | 185 +++--------------- packages/hashline/src/snapshots.ts | 7 +- packages/hashline/test/block.test.ts | 2 +- .../hashline/test/boundary-repair.test.ts | 6 +- .../test/recovery-session-chain.test.ts | 48 +++-- 8 files changed, 73 insertions(+), 193 deletions(-) diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index bd0e21a57..897f05038 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -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 diff --git a/packages/hashline/src/messages.ts b/packages/hashline/src/messages.ts index 5c93c70e7..458585143 100644 --- a/packages/hashline/src/messages.ts +++ b/packages/hashline/src/messages.ts @@ -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."; diff --git a/packages/hashline/src/patcher.ts b/packages/hashline/src/patcher.ts index d4d98488d..b49156135 100644 --- a/packages/hashline/src/patcher.ts +++ b/packages/hashline/src/patcher.ts @@ -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, diff --git a/packages/hashline/src/recovery.ts b/packages/hashline/src/recovery.ts index b3e1b9ff4..eb48cee9e 100644 --- a/packages/hashline/src/recovery.ts +++ b/packages/hashline/src/recovery.ts @@ -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 { 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); } } diff --git a/packages/hashline/src/snapshots.ts b/packages/hashline/src/snapshots.ts index 97189ec3b..874433b2b 100644 --- a/packages/hashline/src/snapshots.ts +++ b/packages/hashline/src/snapshots.ts @@ -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 diff --git a/packages/hashline/test/block.test.ts b/packages/hashline/test/block.test.ts index d6eb03b32..2526198b7 100644 --- a/packages/hashline/test/block.test.ts +++ b/packages/hashline/test/block.test.ts @@ -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"); diff --git a/packages/hashline/test/boundary-repair.test.ts b/packages/hashline/test/boundary-repair.test.ts index cdf90aa35..6c53e9a06 100644 --- a/packages/hashline/test/boundary-repair.test.ts +++ b/packages/hashline/test/boundary-repair.test.ts @@ -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(); diff --git a/packages/hashline/test/recovery-session-chain.test.ts b/packages/hashline/test/recovery-session-chain.test.ts index 08db6634e..1fbbb85e9 100644 --- a/packages/hashline/test/recovery-session-chain.test.ts +++ b/packages/hashline/test/recovery-session-chain.test.ts @@ -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);