@oh-my-pi/hashline 16.4.8 → 16.5.0
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 +8 -0
- package/dist/types/messages.d.ts +17 -7
- package/dist/types/recovery.d.ts +6 -15
- package/package.json +2 -2
- package/src/apply.ts +51 -1
- package/src/messages.ts +49 -9
- package/src/patcher.ts +4 -4
- package/src/recovery.ts +29 -156
- package/src/snapshots.ts +3 -4
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
|
package/dist/types/messages.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/types/recovery.d.ts
CHANGED
|
@@ -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.
|
|
20
|
-
* implementation tries three strategies in order:
|
|
19
|
+
* call {@link Recovery.tryRecover} per stale-tag incident.
|
|
21
20
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
+
"version": "16.5.0",
|
|
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.
|
|
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 {
|
|
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,
|
|
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
|
|
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:
|
|
644
|
-
//
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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: [
|
|
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.
|
|
371
|
-
* implementation tries three strategies in order:
|
|
266
|
+
* call {@link Recovery.tryRecover} per stale-tag incident.
|
|
372
267
|
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
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
|
|
396
|
-
//
|
|
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
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
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
|
|
203
|
-
//
|
|
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
|