From 4c751c48a815519efd471db43499e5e407e0e299 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 1 Jul 2026 21:08:56 +0200 Subject: [PATCH] fix(hashline): validate unique stale anchor remaps --- packages/hashline/src/recovery.ts | 21 ++++++++++++++++++- .../test/recovery-session-chain.test.ts | 18 ++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/hashline/src/recovery.ts b/packages/hashline/src/recovery.ts index de0aafc75..e64b0091d 100644 --- a/packages/hashline/src/recovery.ts +++ b/packages/hashline/src/recovery.ts @@ -168,6 +168,20 @@ function validateDuplicateAnchorContext( return checked; } +function validateUniqueAnchorContext( + line: number, + mapped: number, + previousLines: readonly string[], + lineMap: ReadonlyMap, + anchorLines: ReadonlySet, +): boolean { + const offset = mapped - line; + const after = nearestContextLine(line, 1, anchorLines, previousLines.length); + if (after !== undefined) return lineMap.get(after) === after + offset; + const before = nearestContextLine(line, -1, anchorLines, previousLines.length); + return before !== undefined && lineMap.get(before) === before + offset; +} + function validateRemappedAnchorContext( previousText: string, currentText: string, @@ -181,7 +195,12 @@ function validateRemappedAnchorContext( for (const line of anchorLines) { const mapped = lineMap.get(line); if (mapped === undefined) return false; - if (!lineIsDuplicated(previousLines, line) && !lineIsDuplicated(currentLines, mapped)) continue; + if (!lineIsDuplicated(previousLines, line) && !lineIsDuplicated(currentLines, mapped)) { + if (!validateUniqueAnchorContext(line, mapped, previousLines, lineMap, anchorLines)) { + return false; + } + continue; + } if (!validateDuplicateAnchorContext(line, mapped, previousLines, lineMap, anchorLines)) { return false; } diff --git a/packages/hashline/test/recovery-session-chain.test.ts b/packages/hashline/test/recovery-session-chain.test.ts index 8af0314d4..9d789080c 100644 --- a/packages/hashline/test/recovery-session-chain.test.ts +++ b/packages/hashline/test/recovery-session-chain.test.ts @@ -139,4 +139,22 @@ describe("Recovery — session-chain replay anchor-content gate", () => { expect(recovered).toBeNull(); }); + + it("refuses unique-line remaps when following context no longer matches", () => { + 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"); + store.record(PATH, v1Text); + const { edits } = parsePatch("SWAP 5.=5:\n+MODEL"); + + const recovered = new Recovery(store).tryRecover({ + path: PATH, + currentText: v1Text, + fileHash: h0, + edits, + }); + + expect(recovered).toBeNull(); + }); });