diff --git a/packages/hashline/src/patcher.ts b/packages/hashline/src/patcher.ts index c2a65d24d..3754fd56f 100644 --- a/packages/hashline/src/patcher.ts +++ b/packages/hashline/src/patcher.ts @@ -524,14 +524,19 @@ export class Patcher { // A 16-bit tag can collide across two different file states, so equality // on `computeFileHash(normalized) === expected` alone is not enough to // prove the live text IS the snapshot the tag names. Also require that, - // when a snapshot for `(path, expected)` is retained, its stored text - // matches the live text. Otherwise the tag collides and we route through - // recovery/mismatch instead of applying line-anchored edits to unrelated - // live content (issue #4075). + // when a snapshot for `(path, expected)` is retained, exactly one stored + // version carries the tag and its full text matches the live text. If + // multiple versions share the tag, the header is ambiguous: there is no + // safe way to know which stored text the model's line anchors came from. + const storedSnapshotsForTag = + expected === undefined + ? [] + : this.snapshots.findByHash(expected).filter(snapshot => snapshot.path === canonicalPath); + const ambiguousStoredTag = storedSnapshotsForTag.length > 1; + const storedSnapshotForTag = expected === undefined ? null : this.snapshots.byHash(canonicalPath, expected); const hashMatches = expected !== undefined && computeFileHash(normalized) === expected; const matchedSnapshot = hashMatches ? this.snapshots.byContent(canonicalPath, normalized) : null; - const anyStoredForTag = hashMatches ? this.snapshots.byHash(canonicalPath, expected as string) : null; - const liveMatches = hashMatches && (anyStoredForTag === null || matchedSnapshot !== null); + const liveMatches = hashMatches && !ambiguousStoredTag && (storedSnapshotForTag === null || matchedSnapshot !== null); // Resolve `replace_block N:` edits to concrete ranges before recovery // runs. Block anchors are expressed against the snapshot the section tag @@ -546,8 +551,10 @@ export class Patcher { const resolveWarnings: string[] = []; let resolved: readonly Edit[] = edits; if (hasBlockEdit(edits)) { - const baseText = - expected === undefined || liveMatches ? normalized : this.snapshots.byHash(canonicalPath, expected)?.text; + if (ambiguousStoredTag) { + throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", true); + } + const baseText = expected === undefined || liveMatches ? normalized : storedSnapshotForTag?.text; if (baseText === undefined) { throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", false); } @@ -582,6 +589,9 @@ export class Patcher { const result = applyEdits(normalized, resolved); return withResolveWarnings({ ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] }); } + if (ambiguousStoredTag) { + throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", true); + } // File drifted: try to replay the edit against the version the tag // names and 3-way-merge it onto the live content. const recovered = this.recovery.tryRecover({ diff --git a/packages/hashline/test/patcher.test.ts b/packages/hashline/test/patcher.test.ts index b742d8284..f07ab03a2 100644 --- a/packages/hashline/test/patcher.test.ts +++ b/packages/hashline/test/patcher.test.ts @@ -158,6 +158,47 @@ describe("Patcher snapshot tag integrity", () => { // model's edit anchored against the collider. expect(fs.get(PATH)).toBe(LIVE_TEXT); }); + + it("rejects an ambiguous colliding tag even when live text matches one retained collider", async () => { + const SNAPSHOT_TEXT = "line one 263\nline two 4471\n"; + const LIVE_TEXT = "line one 410\nline two 6970\n"; + expect(computeFileHash(SNAPSHOT_TEXT)).toBe(computeFileHash(LIVE_TEXT)); + + const snapshots = new InMemorySnapshotStore(); + const tag = snapshots.record(PATH, SNAPSHOT_TEXT, [1, 2]); + snapshots.record(PATH, LIVE_TEXT, [1, 2]); + + const fs = new InMemoryFilesystem([[PATH, LIVE_TEXT]]); + const patcher = new Patcher({ fs, snapshots }); + try { + await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 2.=2:\n+edited from snapshot`)); + throw new Error("expected MismatchError"); + } catch (error) { + expect(error).toBeInstanceOf(MismatchError); + } + expect(fs.get(PATH)).toBe(LIVE_TEXT); + }); + + it("rejects an ambiguous colliding tag before stale recovery can target the wrong snapshot", async () => { + const SNAPSHOT_TEXT = "line one 263\nline two 4471\n"; + const COLLIDING_TEXT = "line one 410\nline two 6970\n"; + const LIVE_TEXT = "line one 410\nline two 6970\nlive-added\n"; + expect(computeFileHash(SNAPSHOT_TEXT)).toBe(computeFileHash(COLLIDING_TEXT)); + + const snapshots = new InMemorySnapshotStore(); + const tag = snapshots.record(PATH, SNAPSHOT_TEXT, [1, 2]); + snapshots.record(PATH, COLLIDING_TEXT, [1, 2]); + + const fs = new InMemoryFilesystem([[PATH, LIVE_TEXT]]); + const patcher = new Patcher({ fs, snapshots }); + try { + await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 2.=2:\n+edited from snapshot`)); + throw new Error("expected MismatchError"); + } catch (error) { + expect(error).toBeInstanceOf(MismatchError); + } + expect(fs.get(PATH)).toBe(LIVE_TEXT); + }); }); describe("Patcher mandatory snapshot tag policy", () => {