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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user