@oh-my-pi/hashline 16.4.8 → 16.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.5.0] - 2026-07-13
6
+
7
+ ### Fixed
8
+
9
+ - Fixed a critical issue where ambiguous swaps could silently delete range boundaries.
10
+ - Prevented incorrect auto-repairing of structural closing lines when payload placement is ambiguous.
11
+ - Fixed a bug in stale-hash recovery that could incorrectly relocate edits onto duplicated context after the original target changed.
12
+
5
13
  ## [16.3.3] - 2026-07-02
6
14
 
7
15
  ### Breaking Changes
@@ -49,6 +49,23 @@ export declare function insertAfterBlockCloserLoweredWarning(line: number): stri
49
49
  * applying with a warning beats failing the patch.
50
50
  */
51
51
  export declare function insertAfterBlockUnresolvedLoweredWarning(line: number): string;
52
+ /**
53
+ * A one-sided boundary echo whose payload is too short to be the widened
54
+ * range's full content: dropping the echo deletes range line(s) the payload
55
+ * never restates (the "widened range" reading), while the "range shifted by
56
+ * the echo" reading keeps them. The readings produce different files, so the
57
+ * edit is rejected instead of repaired.
58
+ */
59
+ export declare function ambiguousBoundaryEchoMessage(startLine: number, endLine: number, side: "leading" | "trailing", count: number): string;
60
+ /**
61
+ * A replacement range deletes trailing structural closer(s) the payload never
62
+ * restates, and nothing anchors the payload inside the block those closers
63
+ * terminate: the payload has no unmatched opener for them and its indentation
64
+ * is not deeper than the closer. Sparing the closer would have to guess
65
+ * whether the payload belongs before it (inside the block) or after it (a
66
+ * sibling), so the edit is rejected instead of repaired.
67
+ */
68
+ export declare function ambiguousCloserSpareMessage(startLine: number, endLine: number, closerLine: number, count: number): string;
52
69
  /**
53
70
  * Internal invariant: `applyEdits` received an unresolved `replace_block N:`
54
71
  * edit; `resolveBlockEdits` must run first. Wiring bug, not authored input.
@@ -80,13 +97,6 @@ export declare function blockInsertLandingShiftWarning(blockStart: number, close
80
97
  export declare const RECOVERY_EXTERNAL_WARNING = "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
81
98
  /** `Recovery`: a prior in-session edit advanced the hash. */
82
99
  export declare 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).";
83
- /**
84
- * `Recovery`: session-chain replay fast-path. Less certain than
85
- * {@link RECOVERY_SESSION_CHAIN_WARNING} — the 3-way merge refused, the
86
- * anchor-content gate passed, but a coincidental insert+delete earlier in
87
- * the chain could still misplace an anchor — hence the verify hedge.
88
- */
89
- export declare 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.";
90
100
  /** `Recovery`: stale anchors were relocated to unchanged live lines after drift. */
91
101
  export declare 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.";
92
102
  /**
@@ -16,22 +16,13 @@ export interface RecoveryResult {
16
16
  }
17
17
  /**
18
18
  * Stateless recovery driver over a {@link SnapshotStore}. Construct once and
19
- * call {@link Recovery.tryRecover} per stale-tag incident. The default
20
- * implementation tries three strategies in order:
19
+ * call {@link Recovery.tryRecover} per stale-tag incident.
21
20
  *
22
- * 1. Apply the edits on the full-file version the tag names, then 3-way-merge
23
- * the resulting patch onto the live content (handles external writes).
24
- * 2. Remap every stale anchor through the unchanged-line diff from the tagged
25
- * snapshot to the live text, then replay on live content. This handles a
26
- * prior insertion/deletion before the target while refusing changed anchors
27
- * and mixed offsets across the same edit range.
28
- * 3. (Session chain) If that version wasn't the head, replay the edits onto
29
- * the live content directly when line counts match AND every edit's anchor
30
- * line content is unchanged between version and current — a prior in-session
31
- * edit advanced the tag and the model's anchors still name the same logical
32
- * rows. Emits a dedicated {@link RECOVERY_SESSION_REPLAY_WARNING} because
33
- * even with both guards a coincidental insert+delete pair on duplicate rows
34
- * can still land the edit on the wrong row; see {@link replaySessionChainOnCurrent}.
21
+ * Recovery maps every stale anchor through unchanged lines from the tagged
22
+ * snapshot to the live text, validates surrounding context, and replays the
23
+ * edit directly on live content. All anchors must move by one consistent
24
+ * offset. A changed, deleted, split, or ambiguous target is rejected so the
25
+ * caller can surface a {@link MismatchError} with current context.
35
26
  */
36
27
  export declare class Recovery {
37
28
  readonly store: SnapshotStore;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "16.4.8",
4
+ "version": "16.5.1",
5
5
  "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "diff": "^9.0.0",
37
- "lru-cache": "11.5.1"
37
+ "lru-cache": "11.5.2"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/bun": "^1.3.14"
package/src/apply.ts CHANGED
@@ -7,7 +7,13 @@
7
7
  * which absorbs common model mistakes where a payload restates unchanged range
8
8
  * boundaries or duplicates/drops structural closers.
9
9
  */
10
- import { afterInsertLandingShiftWarning, blockInsertLandingShiftWarning, UNRESOLVED_BLOCK_INTERNAL } from "./messages";
10
+ import {
11
+ afterInsertLandingShiftWarning,
12
+ ambiguousBoundaryEchoMessage,
13
+ ambiguousCloserSpareMessage,
14
+ blockInsertLandingShiftWarning,
15
+ UNRESOLVED_BLOCK_INTERNAL,
16
+ } from "./messages";
11
17
  import { cloneCursor } from "./tokenizer";
12
18
  import type { Anchor, ApplyResult, Cursor, Edit } from "./types";
13
19
 
@@ -702,6 +708,12 @@ function describeBoundaryRepair(group: ReplacementGroup, action: string): string
702
708
  * carries no delimiter-balance signal itself, such as a JSX `</section>` close.
703
709
  * The dropped lines must keep the already-balanced result balanced, and must
704
710
  * not consume the whole payload.
711
+ *
712
+ * A detected echo is only *repairable* when the payload is long enough to be
713
+ * the widened range's full content (`payload ≥ range + echo`). Shorter
714
+ * payloads are ambiguous — the echo may instead mean the range itself was
715
+ * shifted by the echo, which keeps the far boundary line(s) the repair would
716
+ * delete — and the caller rejects the edit instead of guessing.
705
717
  */
706
718
  function findOneSidedBoundaryEcho(
707
719
  group: ReplacementGroup,
@@ -800,6 +812,12 @@ function slotPatchDelta(slot: RepairSlot, fileLines: readonly string[]): Delimit
800
812
  * deleted is only kept when the patch as a whole is missing it — never when
801
813
  * another hunk already removed the matching opener. Returns the repaired edits
802
814
  * plus one warning per repaired group.
815
+ *
816
+ * Repairs fire only when exactly one reading explains the mistake. When the
817
+ * evidence is ambiguous — a one-sided echo whose payload is too short for the
818
+ * widened range, or a spared closer the payload neither opens nor indents
819
+ * into — the function throws instead of guessing, so the author re-issues the
820
+ * edit rather than shipping silently corrupted content.
803
821
  */
804
822
  function repairReplacementBoundaries(
805
823
  edits: readonly AppliedEdit[],
@@ -842,6 +860,15 @@ function repairReplacementBoundaries(
842
860
  if (balanceIsZero(delta)) {
843
861
  const oneSided = findOneSidedBoundaryEcho(group, fileLines);
844
862
  if (oneSided) {
863
+ // A payload shorter than range+echo cannot be the widened
864
+ // range's full content: the repair would delete range line(s)
865
+ // the payload never restates, while the "shifted range"
866
+ // reading keeps them. Reject rather than guess.
867
+ if (group.payload.length < group.deleteIndices.length + oneSided.count) {
868
+ throw new Error(
869
+ ambiguousBoundaryEchoMessage(group.startLine, group.endLine, oneSided.side, oneSided.count),
870
+ );
871
+ }
845
872
  const trimmed =
846
873
  oneSided.side === "leading"
847
874
  ? inserts.slice(oneSided.count)
@@ -934,6 +961,29 @@ function repairReplacementBoundaries(
934
961
  insertedLineMaps,
935
962
  );
936
963
  if (droppedClosers) {
964
+ // Sparing a closer re-inserts it *after* the payload, which claims
965
+ // the payload lives inside the block the closer terminates. That
966
+ // claim needs evidence: the payload carries the closer's unmatched
967
+ // opener itself, or its indentation sits deeper than the closer.
968
+ // Without either, "before or after the closer" is a coin flip —
969
+ // reject rather than guess (e.g. a statement swapped onto a lone
970
+ // `}` at the closer's own depth belongs after the block).
971
+ const keptIndent = leadingIndent(fileLines[droppedClosers.startLine - 1] ?? "");
972
+ const payloadIndent = bodyTargetIndent(slot.group.payload);
973
+ const payloadOpens = balanceCovers(
974
+ computeDelimiterBalance(slot.group.payload),
975
+ balanceNegate(droppedClosers.balance),
976
+ );
977
+ if (!payloadOpens && !(payloadIndent !== undefined && isIndentDeeper(payloadIndent, keptIndent))) {
978
+ throw new Error(
979
+ ambiguousCloserSpareMessage(
980
+ slot.group.startLine,
981
+ slot.group.endLine,
982
+ droppedClosers.startLine,
983
+ droppedClosers.count,
984
+ ),
985
+ );
986
+ }
937
987
  warnings.push(
938
988
  describeBoundaryRepair(
939
989
  slot.group,
package/src/messages.ts CHANGED
@@ -108,6 +108,55 @@ export function insertAfterBlockCloserLoweredWarning(line: number): string {
108
108
  export function insertAfterBlockUnresolvedLoweredWarning(line: number): string {
109
109
  return `\`INS.BLK.POST ${line}:\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`INS.POST ${line}:\`. Verify the landing line; anchor on a line that OPENS a construct.`;
110
110
  }
111
+ /**
112
+ * A one-sided boundary echo whose payload is too short to be the widened
113
+ * range's full content: dropping the echo deletes range line(s) the payload
114
+ * never restates (the "widened range" reading), while the "range shifted by
115
+ * the echo" reading keeps them. The readings produce different files, so the
116
+ * edit is rejected instead of repaired.
117
+ */
118
+ export function ambiguousBoundaryEchoMessage(
119
+ startLine: number,
120
+ endLine: number,
121
+ side: "leading" | "trailing",
122
+ count: number,
123
+ ): string {
124
+ const where =
125
+ side === "leading"
126
+ ? `opens by restating the ${count} line(s) just above the range`
127
+ : `ends by restating the ${count} line(s) just below the range`;
128
+ return (
129
+ `\`SWAP ${startLine}${HL_RANGE_SEP}${endLine}:\` rejected: the body ${where}, ` +
130
+ `but is too short to be the full final content of the widened range — applying it as-is or ` +
131
+ `auto-repairing would delete range line(s) the body never restates. ` +
132
+ `Re-issue with the range covering exactly the lines that change and the body as their complete ` +
133
+ `final content: drop the restated keeper from the body, or widen the range to consume it.`
134
+ );
135
+ }
136
+
137
+ /**
138
+ * A replacement range deletes trailing structural closer(s) the payload never
139
+ * restates, and nothing anchors the payload inside the block those closers
140
+ * terminate: the payload has no unmatched opener for them and its indentation
141
+ * is not deeper than the closer. Sparing the closer would have to guess
142
+ * whether the payload belongs before it (inside the block) or after it (a
143
+ * sibling), so the edit is rejected instead of repaired.
144
+ */
145
+ export function ambiguousCloserSpareMessage(
146
+ startLine: number,
147
+ endLine: number,
148
+ closerLine: number,
149
+ count: number,
150
+ ): string {
151
+ const closers = count === 1 ? `line ${closerLine}` : `lines ${closerLine}-${closerLine + count - 1}`;
152
+ return (
153
+ `\`SWAP ${startLine}${HL_RANGE_SEP}${endLine}:\` rejected: the range deletes the closing-delimiter ` +
154
+ `${closers} but the body never restates it, and the body claims no position inside that block ` +
155
+ `(no unmatched opener, indentation not deeper than the closer) — whether the new content belongs ` +
156
+ `before or after the closer is ambiguous. Restate the closer in the body at the intended position, ` +
157
+ `or use \`INS.PRE ${closerLine}:\` / \`INS.POST ${closerLine}:\` instead.`
158
+ );
159
+ }
111
160
 
112
161
  /**
113
162
  * Internal invariant: `applyEdits` received an unresolved `replace_block N:`
@@ -159,15 +208,6 @@ export const RECOVERY_EXTERNAL_WARNING =
159
208
  export const RECOVERY_SESSION_CHAIN_WARNING =
160
209
  "Recovered from a stale file hash using an earlier in-session snapshot (a prior edit in this session advanced the hash).";
161
210
 
162
- /**
163
- * `Recovery`: session-chain replay fast-path. Less certain than
164
- * {@link RECOVERY_SESSION_CHAIN_WARNING} — the 3-way merge refused, the
165
- * anchor-content gate passed, but a coincidental insert+delete earlier in
166
- * the chain could still misplace an anchor — hence the verify hedge.
167
- */
168
- export const RECOVERY_SESSION_REPLAY_WARNING =
169
- "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.";
170
-
171
211
  /** `Recovery`: stale anchors were relocated to unchanged live lines after drift. */
172
212
  export const RECOVERY_LINE_REMAP_WARNING =
173
213
  "Recovered by remapping stale line anchors to unchanged current lines (file changed since the tagged read). Verify the diff matches your intent.";
package/src/patcher.ts CHANGED
@@ -586,7 +586,7 @@ export class Patcher {
586
586
  const expected = exists ? section.fileHash : undefined;
587
587
  // The 4-hex tag is content-derived: when the live text hashes to it,
588
588
  // trust the match and apply directly. `storedSnapshotForTag` feeds the
589
- // drift paths below (block resolution, 3-way recovery); on a 16-bit
589
+ // drift paths below (block resolution, anchor remapping); on a 16-bit
590
590
  // tag collision it resolves to the most-recently recorded text.
591
591
  const storedSnapshotForTag = expected === undefined ? null : this.snapshots.byHash(canonicalPath, expected);
592
592
  const liveMatches = expected !== undefined && computeFileHash(normalized) === expected;
@@ -598,7 +598,7 @@ export class Patcher {
598
598
  // - live content matches the tag (or there is no tag) → resolve against
599
599
  // the live, normalized content;
600
600
  // - the file drifted → resolve against the tagged snapshot's text so the
601
- // resulting ranges flow through the 3-way-merge recovery below.
601
+ // resulting ranges can be mapped to unchanged live lines below.
602
602
  // When a block edit needs the tagged snapshot but it is unavailable, the
603
603
  // range cannot be placed safely — reject with a MismatchError (re-read).
604
604
  const blockResolutions: BlockResolution[] = [];
@@ -640,8 +640,8 @@ export class Patcher {
640
640
  const result = applyEdits(normalized, resolved);
641
641
  return withResolveWarnings({ ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] });
642
642
  }
643
- // File drifted: try to replay the edit against the version the tag
644
- // names and 3-way-merge it onto the live content.
643
+ // File drifted: map every anchor from the tagged snapshot to unchanged
644
+ // live lines. Recovery refuses changed or ambiguous targets.
645
645
  const recovered = this.recovery.tryRecover({
646
646
  path: canonicalPath,
647
647
  currentText: normalized,
package/src/recovery.ts CHANGED
@@ -1,29 +1,17 @@
1
1
  /**
2
- * Recover from a stale section snapshot tag by replaying the would-be edit
3
- * against a cached pre-edit snapshot of the file and 3-way-merging the
4
- * result onto the current on-disk content.
2
+ * Recovers stale section tags by proving that every anchored line still maps
3
+ * to one unchanged, contiguous region in the current file, then replaying the
4
+ * edit against that live content.
5
5
  *
6
- * The patcher consults this when a section tag resolves to a snapshot that no
7
- * longer matches the live file content. The recovery class is stateless apart
8
- * from the {@link SnapshotStore} it queries; the snapshot store is the seam
9
- * lets you plug in your own caching strategy.
6
+ * Recovery fails closed when the target changed or became ambiguous. The
7
+ * patcher then returns a mismatch with fresh context instead of guessing.
10
8
  */
11
9
  import * as Diff from "diff";
12
10
  import { applyEdits } from "./apply";
13
- import {
14
- RECOVERY_EXTERNAL_WARNING,
15
- RECOVERY_LINE_REMAP_WARNING,
16
- RECOVERY_SESSION_CHAIN_WARNING,
17
- RECOVERY_SESSION_REPLAY_WARNING,
18
- } from "./messages";
19
- import type { Snapshot, SnapshotStore } from "./snapshots";
11
+ import { RECOVERY_EXTERNAL_WARNING, RECOVERY_LINE_REMAP_WARNING, RECOVERY_SESSION_CHAIN_WARNING } from "./messages";
12
+ import type { SnapshotStore } from "./snapshots";
20
13
  import type { Anchor, ApplyResult, Edit } from "./types";
21
14
 
22
- // Section tags are line-precise; never let Diff.applyPatch slide a hunk
23
- // onto a duplicate closer 100+ lines away. If snapshot replay does not
24
- // align exactly, refuse and let the caller re-read.
25
- const RECOVERY_FUZZ_FACTOR = 0;
26
-
27
15
  export interface RecoveryArgs {
28
16
  path: string;
29
17
  currentText: string;
@@ -40,31 +28,6 @@ export interface RecoveryResult {
40
28
  warnings: string[];
41
29
  }
42
30
 
43
- function applyEditsToSnapshot(
44
- previousText: string,
45
- currentText: string,
46
- edits: readonly Edit[],
47
- recoveryWarning: string,
48
- ): RecoveryResult | null {
49
- let applied: ApplyResult;
50
- try {
51
- applied = applyEdits(previousText, [...edits]);
52
- } catch {
53
- return null;
54
- }
55
- if (applied.text === previousText) return null;
56
-
57
- const patch = Diff.structuredPatch("file", "file", previousText, applied.text, "", "", { context: 3 });
58
- const merged = Diff.applyPatch(currentText, patch, { fuzzFactor: RECOVERY_FUZZ_FACTOR });
59
- if (typeof merged !== "string" || merged === currentText) return null;
60
-
61
- const firstChangedLine = findFirstChangedLine(currentText, merged) ?? applied.firstChangedLine;
62
- const hasNetChange = firstChangedLine !== undefined;
63
- const warnings = hasNetChange ? [recoveryWarning, ...(applied.warnings ?? [])] : [...(applied.warnings ?? [])];
64
-
65
- return { text: merged, firstChangedLine, warnings };
66
- }
67
-
68
31
  function collectAnchorLines(edits: readonly Edit[]): number[] {
69
32
  const lines: number[] = [];
70
33
  for (const edit of edits) {
@@ -81,27 +44,6 @@ function getEditAnchors(edit: Edit): Anchor[] {
81
44
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor" ? [edit.cursor.anchor] : [];
82
45
  }
83
46
 
84
- /**
85
- * Returns true when every anchor line in `edits` has identical content in
86
- * `previousText` and `currentText`. The session-chain replay fast-path
87
- * requires this: if the prior in-session edit rewrote the line the model is
88
- * now re-targeting with a stale hash, replaying onto current would silently
89
- * overwrite the new content with whatever the model authored against the
90
- * old content — a corruption window, not a recovery.
91
- */
92
- function verifyAnchorContent(previousText: string, currentText: string, edits: readonly Edit[]): boolean {
93
- const lines = collectAnchorLines(edits);
94
- if (lines.length === 0) return true;
95
- const prev = previousText.split("\n");
96
- const curr = currentText.split("\n");
97
- for (const line of lines) {
98
- const idx = line - 1;
99
- if (idx < 0 || idx >= prev.length || idx >= curr.length) return false;
100
- if (prev[idx] !== curr[idx]) return false;
101
- }
102
- return true;
103
- }
104
-
105
47
  function buildLineMap(previousText: string, currentText: string): Map<number, number> {
106
48
  const previousLines = previousText.split("\n");
107
49
  const currentLines = currentText.split("\n");
@@ -198,7 +140,7 @@ function validateUniqueAnchorContext(
198
140
  ): boolean {
199
141
  const offset = mapped - line;
200
142
  const { before, after } = neighbors;
201
- if (after !== undefined) return lineMap.get(after) === after + offset;
143
+ if (after !== undefined && lineMap.get(after) === after + offset) return true;
202
144
  return before !== undefined && lineMap.get(before) === before + offset;
203
145
  }
204
146
 
@@ -237,7 +179,12 @@ function validateRemappedAnchorContext(
237
179
  return true;
238
180
  }
239
181
 
240
- function remapEditsToCurrent(previousText: string, currentText: string, edits: readonly Edit[]): Edit[] | null {
182
+ interface RemappedEdits {
183
+ edits: Edit[];
184
+ offset: number;
185
+ }
186
+
187
+ function remapEditsToCurrent(previousText: string, currentText: string, edits: readonly Edit[]): RemappedEdits | null {
241
188
  const lineMap = buildLineMap(previousText, currentText);
242
189
  if (!validateRemappedAnchorContext(previousText, currentText, lineMap, edits)) return null;
243
190
  const offsets: number[] = [];
@@ -289,55 +236,21 @@ function remapEditsToCurrent(previousText: string, currentText: string, edits: r
289
236
 
290
237
  if (offsets.length === 0) return null;
291
238
  const firstOffset = offsets[0];
292
- if (firstOffset === 0) return null;
293
239
  if (!offsets.every(offset => offset === firstOffset)) return null;
294
- return remapped;
240
+ return { edits: remapped, offset: firstOffset };
295
241
  }
296
242
 
297
243
  function replayRemappedAnchorsOnCurrent(
298
244
  previousText: string,
299
245
  currentText: string,
300
246
  edits: readonly Edit[],
247
+ recoveryWarning: string,
301
248
  ): RecoveryResult | null {
302
249
  const remapped = remapEditsToCurrent(previousText, currentText, edits);
303
250
  if (remapped === null) return null;
304
251
  let applied: ApplyResult;
305
252
  try {
306
- applied = applyEdits(currentText, remapped);
307
- } catch {
308
- return null;
309
- }
310
- if (applied.text === currentText) return null;
311
- return {
312
- text: applied.text,
313
- firstChangedLine: applied.firstChangedLine,
314
- warnings: [RECOVERY_LINE_REMAP_WARNING, ...(applied.warnings ?? [])],
315
- };
316
- }
317
-
318
- function replaySessionChainOnCurrent(
319
- previousText: string,
320
- currentText: string,
321
- edits: readonly Edit[],
322
- ): RecoveryResult | null {
323
- // Two guards narrow the corruption window. Neither alone is sufficient,
324
- // and even together they don't fully prove correctness — replay is the
325
- // less-certain recovery mode and emits RECOVERY_SESSION_REPLAY_WARNING
326
- // so the caller can verify the diff.
327
- // - Equal line counts: every line number in `edits` still resolves to
328
- // SOME logical row (no net shift across the prior chain). A
329
- // coincidental insert+delete pair can still leave indices pointing
330
- // at different logical rows than the model anchored against.
331
- // - Anchor-content alignment: the row at each anchor's line index has
332
- // identical content in previous and current. Catches the common
333
- // case of a prior edit rewriting the targeted line; can still be
334
- // coincidentally satisfied by a duplicated row at the shifted
335
- // index.
336
- if (previousText.split("\n").length !== currentText.split("\n").length) return null;
337
- if (!verifyAnchorContent(previousText, currentText, edits)) return null;
338
- let applied: ApplyResult;
339
- try {
340
- applied = applyEdits(currentText, [...edits]);
253
+ applied = applyEdits(currentText, remapped.edits);
341
254
  } catch {
342
255
  return null;
343
256
  }
@@ -345,44 +258,18 @@ function replaySessionChainOnCurrent(
345
258
  return {
346
259
  text: applied.text,
347
260
  firstChangedLine: applied.firstChangedLine,
348
- warnings: [RECOVERY_SESSION_REPLAY_WARNING, ...(applied.warnings ?? [])],
261
+ warnings: [remapped.offset === 0 ? recoveryWarning : RECOVERY_LINE_REMAP_WARNING, ...(applied.warnings ?? [])],
349
262
  };
350
263
  }
351
-
352
- /** First 1-indexed line at which `a` and `b` diverge, or `undefined` if equal. */
353
- function findFirstChangedLine(a: string, b: string): number | undefined {
354
- if (a === b) return undefined;
355
- const aLines = a.split("\n");
356
- const bLines = b.split("\n");
357
- const max = Math.max(aLines.length, bLines.length);
358
- for (let i = 0; i < max; i++) {
359
- if (aLines[i] !== bLines[i]) return i + 1;
360
- }
361
- return undefined;
362
- }
363
-
364
- function isHeadSnapshot(head: Snapshot | null, snapshot: Snapshot): boolean {
365
- return head === snapshot;
366
- }
367
-
368
264
  /**
369
265
  * Stateless recovery driver over a {@link SnapshotStore}. Construct once and
370
- * call {@link Recovery.tryRecover} per stale-tag incident. The default
371
- * implementation tries three strategies in order:
266
+ * call {@link Recovery.tryRecover} per stale-tag incident.
372
267
  *
373
- * 1. Apply the edits on the full-file version the tag names, then 3-way-merge
374
- * the resulting patch onto the live content (handles external writes).
375
- * 2. Remap every stale anchor through the unchanged-line diff from the tagged
376
- * snapshot to the live text, then replay on live content. This handles a
377
- * prior insertion/deletion before the target while refusing changed anchors
378
- * and mixed offsets across the same edit range.
379
- * 3. (Session chain) If that version wasn't the head, replay the edits onto
380
- * the live content directly when line counts match AND every edit's anchor
381
- * line content is unchanged between version and current — a prior in-session
382
- * edit advanced the tag and the model's anchors still name the same logical
383
- * rows. Emits a dedicated {@link RECOVERY_SESSION_REPLAY_WARNING} because
384
- * even with both guards a coincidental insert+delete pair on duplicate rows
385
- * can still land the edit on the wrong row; see {@link replaySessionChainOnCurrent}.
268
+ * Recovery maps every stale anchor through unchanged lines from the tagged
269
+ * snapshot to the live text, validates surrounding context, and replays the
270
+ * edit directly on live content. All anchors must move by one consistent
271
+ * offset. A changed, deleted, split, or ambiguous target is rejected so the
272
+ * caller can surface a {@link MismatchError} with current context.
386
273
  */
387
274
  export class Recovery {
388
275
  constructor(readonly store: SnapshotStore) {}
@@ -392,26 +279,12 @@ export class Recovery {
392
279
  */
393
280
  tryRecover(args: RecoveryArgs): RecoveryResult | null {
394
281
  const { path, currentText, fileHash, edits } = args;
395
- // When two retained texts collide on the 16-bit tag, resolve to the
396
- // most-recently recorded one; a wrong pick can only land if one of the
397
- // merge/remap/session-chain strategies below applies it cleanly.
282
+ // When retained texts collide on the 16-bit tag, use the latest one.
283
+ // Recovery still requires its anchors and context to map unambiguously.
398
284
  const snapshot = this.store.byHash(path, fileHash);
399
285
  if (!snapshot) return null;
400
- const isHead = isHeadSnapshot(this.store.head(path), snapshot);
401
- const recoveryWarning = isHead ? RECOVERY_EXTERNAL_WARNING : RECOVERY_SESSION_CHAIN_WARNING;
402
- const merged = applyEditsToSnapshot(snapshot.text, currentText, edits, recoveryWarning);
403
- if (merged !== null) return merged;
404
- // Line-shift fallback: the 3-way merge refused, but unchanged anchor
405
- // lines may have moved because a prior edit inserted or deleted rows
406
- // before them. Remap only when every anchor resolves through the diff
407
- // with one consistent offset; otherwise the edit range was touched.
408
- const remapped = replayRemappedAnchorsOnCurrent(snapshot.text, currentText, edits);
409
- if (remapped !== null) return remapped;
410
- // Session-chain fallback: replay onto current is gated by line-count
411
- // equality AND anchor-content alignment — see
412
- // `replaySessionChainOnCurrent` for why both guards together still
413
- // don't fully prove correctness.
414
- if (!isHead) return replaySessionChainOnCurrent(snapshot.text, currentText, edits);
415
- return null;
286
+ const recoveryWarning =
287
+ this.store.head(path) === snapshot ? RECOVERY_EXTERNAL_WARNING : RECOVERY_SESSION_CHAIN_WARNING;
288
+ return replayRemappedAnchorsOnCurrent(snapshot.text, currentText, edits, recoveryWarning);
416
289
  }
417
290
  }
package/src/snapshots.ts CHANGED
@@ -11,8 +11,7 @@
11
11
  * {@link SnapshotStore.record} with the full normalized text they observed.
12
12
  * The store hashes it, dedups against the per-path history, and returns the
13
13
  * tag. Consumers (recovery, the patcher) resolve a stale tag back to the
14
- * recorded full text via {@link SnapshotStore.byHash} and 3-way-merge the
15
- * would-be edit onto the live content.
14
+ * recorded full text and map its unchanged edit anchors onto live content.
16
15
  *
17
16
  * The abstract base class lets callers plug in whatever storage they like
18
17
  * (LRU, persistent SQLite, etc.). {@link InMemorySnapshotStore} ships as a
@@ -199,8 +198,8 @@ export class InMemorySnapshotStore extends SnapshotStore {
199
198
  // texts that happen to share the 4-hex tag are DIFFERENT snapshots — fusing
200
199
  // them under one entry would corrupt seenLines (attaching lines from
201
200
  // text B onto the stored text A) and let the patcher misresolve which
202
- // snapshot the section tag names when it does 3-way merge or seen-line
203
- // validation. See issue #4075.
201
+ // snapshot the section tag names during recovery or seen-line validation.
202
+ // See issue #4075.
204
203
  const existing = history.find(version => version.hash === hash && version.text === fullText);
205
204
  if (existing) {
206
205
  // Same content state observed again: refresh recency and promote to