jeopi-hashline 16.2.13
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 +343 -0
- package/README.md +82 -0
- package/dist/types/apply.d.ts +10 -0
- package/dist/types/block.d.ts +39 -0
- package/dist/types/diff-preview.d.ts +14 -0
- package/dist/types/format.d.ts +83 -0
- package/dist/types/fs.d.ts +109 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/input.d.ts +110 -0
- package/dist/types/messages.d.ts +127 -0
- package/dist/types/mismatch.d.ts +44 -0
- package/dist/types/normalize.d.ts +20 -0
- package/dist/types/parser.d.ts +27 -0
- package/dist/types/patcher.d.ts +118 -0
- package/dist/types/prefixes.d.ts +42 -0
- package/dist/types/recovery.d.ts +44 -0
- package/dist/types/snapshots.d.ts +127 -0
- package/dist/types/stream.d.ts +2 -0
- package/dist/types/tokenizer.d.ts +73 -0
- package/dist/types/types.d.ts +172 -0
- package/package.json +64 -0
- package/src/apply.ts +1281 -0
- package/src/block.ts +168 -0
- package/src/diff-preview.ts +124 -0
- package/src/format.ts +141 -0
- package/src/fs.ts +246 -0
- package/src/grammar.lark +29 -0
- package/src/index.ts +17 -0
- package/src/input.ts +462 -0
- package/src/messages.ts +267 -0
- package/src/mismatch.ts +118 -0
- package/src/normalize.ts +38 -0
- package/src/parser.ts +456 -0
- package/src/patcher.ts +608 -0
- package/src/prefixes.ts +142 -0
- package/src/prompt.md +172 -0
- package/src/recovery.ts +418 -0
- package/src/snapshots.ts +282 -0
- package/src/stream.ts +132 -0
- package/src/tokenizer.ts +557 -0
- package/src/types.ts +172 -0
package/src/recovery.ts
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
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.
|
|
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.
|
|
10
|
+
*/
|
|
11
|
+
import * as Diff from "diff";
|
|
12
|
+
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";
|
|
20
|
+
import type { Anchor, ApplyResult, Edit } from "./types";
|
|
21
|
+
|
|
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
|
+
export interface RecoveryArgs {
|
|
28
|
+
path: string;
|
|
29
|
+
currentText: string;
|
|
30
|
+
fileHash: string;
|
|
31
|
+
edits: readonly Edit[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RecoveryResult {
|
|
35
|
+
/** Post-recovery text. */
|
|
36
|
+
text: string;
|
|
37
|
+
/** First changed line (1-indexed) relative to the live `currentText`, or `undefined`. */
|
|
38
|
+
firstChangedLine: number | undefined;
|
|
39
|
+
/** Warnings collected during recovery, including the user-facing recovery banner. */
|
|
40
|
+
warnings: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
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
|
+
function collectAnchorLines(edits: readonly Edit[]): number[] {
|
|
69
|
+
const lines: number[] = [];
|
|
70
|
+
for (const edit of edits) {
|
|
71
|
+
for (const anchor of getEditAnchors(edit)) lines.push(anchor.line);
|
|
72
|
+
}
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getEditAnchors(edit: Edit): Anchor[] {
|
|
77
|
+
if (edit.kind === "delete") return [edit.anchor];
|
|
78
|
+
// Recovery only ever receives already-resolved edits (no `block`); this arm
|
|
79
|
+
// exists for type-exhaustiveness over the full `Edit` union.
|
|
80
|
+
if (edit.kind === "block") return [edit.anchor];
|
|
81
|
+
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor" ? [edit.cursor.anchor] : [];
|
|
82
|
+
}
|
|
83
|
+
|
|
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
|
+
function buildLineMap(previousText: string, currentText: string): Map<number, number> {
|
|
106
|
+
const previousLines = previousText.split("\n");
|
|
107
|
+
const currentLines = currentText.split("\n");
|
|
108
|
+
const changes = Diff.diffArrays(previousLines, currentLines);
|
|
109
|
+
const map = new Map<number, number>();
|
|
110
|
+
let previousLine = 1;
|
|
111
|
+
let currentLine = 1;
|
|
112
|
+
|
|
113
|
+
for (const change of changes) {
|
|
114
|
+
const count = change.value.length;
|
|
115
|
+
if (change.added) {
|
|
116
|
+
currentLine += count;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (change.removed) {
|
|
120
|
+
previousLine += count;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
for (let offset = 0; offset < count; offset++) {
|
|
124
|
+
map.set(previousLine + offset, currentLine + offset);
|
|
125
|
+
}
|
|
126
|
+
previousLine += count;
|
|
127
|
+
currentLine += count;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return map;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Values appearing two or more times in `lines`, for O(1) duplicate checks. */
|
|
134
|
+
function collectDuplicatedValues(lines: readonly string[]): Set<string> {
|
|
135
|
+
const seen = new Set<string>();
|
|
136
|
+
const duplicated = new Set<string>();
|
|
137
|
+
for (const value of lines) {
|
|
138
|
+
if (seen.has(value)) duplicated.add(value);
|
|
139
|
+
else seen.add(value);
|
|
140
|
+
}
|
|
141
|
+
return duplicated;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface AnchorNeighbors {
|
|
145
|
+
/** Nearest non-anchor line below the anchor's run, or `undefined` at the file edge. */
|
|
146
|
+
before: number | undefined;
|
|
147
|
+
/** Nearest non-anchor line above the anchor's run, or `undefined` at the file edge. */
|
|
148
|
+
after: number | undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Nearest non-anchor context line on each side of every anchor, computed in
|
|
153
|
+
* one sweep over the sorted anchor set. Anchors in one contiguous run share
|
|
154
|
+
* both neighbors (the lines just outside the run), so this replaces the
|
|
155
|
+
* per-anchor directional walk across anchored ranges — O(anchors²) on a
|
|
156
|
+
* large block replacement — with one O(anchors log anchors) pass.
|
|
157
|
+
*/
|
|
158
|
+
function computeAnchorNeighbors(anchorLines: ReadonlySet<number>, lineCount: number): Map<number, AnchorNeighbors> {
|
|
159
|
+
const sorted = [...anchorLines].sort((a, b) => a - b);
|
|
160
|
+
const neighbors = new Map<number, AnchorNeighbors>();
|
|
161
|
+
for (let i = 0; i < sorted.length; ) {
|
|
162
|
+
let j = i;
|
|
163
|
+
while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1) j++;
|
|
164
|
+
const start = sorted[i];
|
|
165
|
+
const end = sorted[j];
|
|
166
|
+
const before = start - 1 >= 1 && start - 1 <= lineCount ? start - 1 : undefined;
|
|
167
|
+
const after = end + 1 <= lineCount ? end + 1 : undefined;
|
|
168
|
+
for (let k = i; k <= j; k++) neighbors.set(sorted[k], { before, after });
|
|
169
|
+
i = j + 1;
|
|
170
|
+
}
|
|
171
|
+
return neighbors;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function validateDuplicateAnchorContext(
|
|
175
|
+
line: number,
|
|
176
|
+
mapped: number,
|
|
177
|
+
neighbors: AnchorNeighbors,
|
|
178
|
+
lineMap: ReadonlyMap<number, number>,
|
|
179
|
+
): boolean {
|
|
180
|
+
let checked = false;
|
|
181
|
+
const { before, after } = neighbors;
|
|
182
|
+
if (before !== undefined) {
|
|
183
|
+
checked = true;
|
|
184
|
+
if (lineMap.get(before) !== mapped - (line - before)) return false;
|
|
185
|
+
}
|
|
186
|
+
if (after !== undefined) {
|
|
187
|
+
checked = true;
|
|
188
|
+
if (lineMap.get(after) !== mapped + (after - line)) return false;
|
|
189
|
+
}
|
|
190
|
+
return checked;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function validateUniqueAnchorContext(
|
|
194
|
+
line: number,
|
|
195
|
+
mapped: number,
|
|
196
|
+
neighbors: AnchorNeighbors,
|
|
197
|
+
lineMap: ReadonlyMap<number, number>,
|
|
198
|
+
): boolean {
|
|
199
|
+
const offset = mapped - line;
|
|
200
|
+
const { before, after } = neighbors;
|
|
201
|
+
if (after !== undefined) return lineMap.get(after) === after + offset;
|
|
202
|
+
return before !== undefined && lineMap.get(before) === before + offset;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function validateRemappedAnchorContext(
|
|
206
|
+
previousText: string,
|
|
207
|
+
currentText: string,
|
|
208
|
+
lineMap: ReadonlyMap<number, number>,
|
|
209
|
+
edits: readonly Edit[],
|
|
210
|
+
): boolean {
|
|
211
|
+
const previousLines = previousText.split("\n");
|
|
212
|
+
const currentLines = currentText.split("\n");
|
|
213
|
+
const anchorLines = new Set(collectAnchorLines(edits));
|
|
214
|
+
// Precompute once per validation pass: which line values are duplicated,
|
|
215
|
+
// and each anchor's nearest non-anchor context. The per-anchor forms —
|
|
216
|
+
// indexOf/lastIndexOf full-file scans plus directional walks across
|
|
217
|
+
// anchored ranges — are O(anchors×lines) + O(anchors²) and blow up on
|
|
218
|
+
// large block replacements.
|
|
219
|
+
const duplicatedPrevious = collectDuplicatedValues(previousLines);
|
|
220
|
+
const duplicatedCurrent = collectDuplicatedValues(currentLines);
|
|
221
|
+
const anchorNeighbors = computeAnchorNeighbors(anchorLines, previousLines.length);
|
|
222
|
+
|
|
223
|
+
for (const [line, neighbors] of anchorNeighbors) {
|
|
224
|
+
const mapped = lineMap.get(line);
|
|
225
|
+
if (mapped === undefined) return false;
|
|
226
|
+
if (!duplicatedPrevious.has(previousLines[line - 1]) && !duplicatedCurrent.has(currentLines[mapped - 1])) {
|
|
227
|
+
if (!validateUniqueAnchorContext(line, mapped, neighbors, lineMap)) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (!validateDuplicateAnchorContext(line, mapped, neighbors, lineMap)) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function remapEditsToCurrent(previousText: string, currentText: string, edits: readonly Edit[]): Edit[] | null {
|
|
241
|
+
const lineMap = buildLineMap(previousText, currentText);
|
|
242
|
+
if (!validateRemappedAnchorContext(previousText, currentText, lineMap, edits)) return null;
|
|
243
|
+
const offsets: number[] = [];
|
|
244
|
+
|
|
245
|
+
const mapLine = (line: number): number | null => {
|
|
246
|
+
const mapped = lineMap.get(line);
|
|
247
|
+
if (mapped === undefined) return null;
|
|
248
|
+
offsets.push(mapped - line);
|
|
249
|
+
return mapped;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const mapAnchor = (anchor: Anchor): Anchor | null => {
|
|
253
|
+
const line = mapLine(anchor.line);
|
|
254
|
+
return line === null ? null : { line };
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const remapped: Edit[] = [];
|
|
258
|
+
for (const edit of edits) {
|
|
259
|
+
if (edit.kind === "delete") {
|
|
260
|
+
const anchor = mapAnchor(edit.anchor);
|
|
261
|
+
if (anchor === null) return null;
|
|
262
|
+
remapped.push({ ...edit, anchor });
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (edit.kind === "block") {
|
|
266
|
+
const anchor = mapAnchor(edit.anchor);
|
|
267
|
+
if (anchor === null) return null;
|
|
268
|
+
remapped.push({ ...edit, anchor });
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let blockStart = edit.blockStart;
|
|
273
|
+
if (blockStart !== undefined) {
|
|
274
|
+
const mappedBlockStart = mapLine(blockStart);
|
|
275
|
+
if (mappedBlockStart === null) return null;
|
|
276
|
+
blockStart = mappedBlockStart;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const cursor = edit.cursor;
|
|
280
|
+
if (cursor.kind !== "before_anchor" && cursor.kind !== "after_anchor") {
|
|
281
|
+
remapped.push(blockStart === edit.blockStart ? edit : { ...edit, blockStart });
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const anchor = mapAnchor(cursor.anchor);
|
|
286
|
+
if (anchor === null) return null;
|
|
287
|
+
remapped.push({ ...edit, cursor: { kind: cursor.kind, anchor }, blockStart });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (offsets.length === 0) return null;
|
|
291
|
+
const firstOffset = offsets[0];
|
|
292
|
+
if (firstOffset === 0) return null;
|
|
293
|
+
if (!offsets.every(offset => offset === firstOffset)) return null;
|
|
294
|
+
return remapped;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function replayRemappedAnchorsOnCurrent(
|
|
298
|
+
previousText: string,
|
|
299
|
+
currentText: string,
|
|
300
|
+
edits: readonly Edit[],
|
|
301
|
+
): RecoveryResult | null {
|
|
302
|
+
const remapped = remapEditsToCurrent(previousText, currentText, edits);
|
|
303
|
+
if (remapped === null) return null;
|
|
304
|
+
let applied: ApplyResult;
|
|
305
|
+
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]);
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
if (applied.text === currentText) return null;
|
|
345
|
+
return {
|
|
346
|
+
text: applied.text,
|
|
347
|
+
firstChangedLine: applied.firstChangedLine,
|
|
348
|
+
warnings: [RECOVERY_SESSION_REPLAY_WARNING, ...(applied.warnings ?? [])],
|
|
349
|
+
};
|
|
350
|
+
}
|
|
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
|
+
/**
|
|
369
|
+
* 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:
|
|
372
|
+
*
|
|
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}.
|
|
386
|
+
*/
|
|
387
|
+
export class Recovery {
|
|
388
|
+
constructor(readonly store: SnapshotStore) {}
|
|
389
|
+
/**
|
|
390
|
+
* Attempt recovery. Returns `null` when no path forward is found — the
|
|
391
|
+
* caller should then surface a {@link MismatchError}.
|
|
392
|
+
*/
|
|
393
|
+
tryRecover(args: RecoveryArgs): RecoveryResult | null {
|
|
394
|
+
const { path, currentText, fileHash, edits } = args;
|
|
395
|
+
// Collision-safe lookup: when two retained texts share the 16-bit tag
|
|
396
|
+
// there is no way to know which one the model's anchors were minted
|
|
397
|
+
// against — replaying against the wrong collider would land the edit
|
|
398
|
+
// on unrelated content. Refuse and let the caller reject (re-read).
|
|
399
|
+
const snapshot = this.store.byHashExact(path, fileHash);
|
|
400
|
+
if (!snapshot) return null;
|
|
401
|
+
const isHead = isHeadSnapshot(this.store.head(path), snapshot);
|
|
402
|
+
const recoveryWarning = isHead ? RECOVERY_EXTERNAL_WARNING : RECOVERY_SESSION_CHAIN_WARNING;
|
|
403
|
+
const merged = applyEditsToSnapshot(snapshot.text, currentText, edits, recoveryWarning);
|
|
404
|
+
if (merged !== null) return merged;
|
|
405
|
+
// Line-shift fallback: the 3-way merge refused, but unchanged anchor
|
|
406
|
+
// lines may have moved because a prior edit inserted or deleted rows
|
|
407
|
+
// before them. Remap only when every anchor resolves through the diff
|
|
408
|
+
// with one consistent offset; otherwise the edit range was touched.
|
|
409
|
+
const remapped = replayRemappedAnchorsOnCurrent(snapshot.text, currentText, edits);
|
|
410
|
+
if (remapped !== null) return remapped;
|
|
411
|
+
// Session-chain fallback: replay onto current is gated by line-count
|
|
412
|
+
// equality AND anchor-content alignment — see
|
|
413
|
+
// `replaySessionChainOnCurrent` for why both guards together still
|
|
414
|
+
// don't fully prove correctness.
|
|
415
|
+
if (!isHead) return replaySessionChainOnCurrent(snapshot.text, currentText, edits);
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|
package/src/snapshots.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session snapshot store used by {@link Recovery} and {@link Patcher} to
|
|
3
|
+
* bind hashline section tags to the exact file content that minted them.
|
|
4
|
+
*
|
|
5
|
+
* A section tag is a content-derived hash of the *whole file* (see
|
|
6
|
+
* {@link computeFileHash}). Any read of byte-identical content mints the same
|
|
7
|
+
* tag, so reads of one file state fuse onto one anchor and a follow-up edit
|
|
8
|
+
* anchored at any line validates whenever the live file still hashes to it.
|
|
9
|
+
*
|
|
10
|
+
* Producers (typically `read` / `search` / `write` tools) call
|
|
11
|
+
* {@link SnapshotStore.record} with the full normalized text they observed.
|
|
12
|
+
* The store hashes it, dedups against the per-path history, and returns the
|
|
13
|
+
* tag. Consumers (recovery, the patcher) resolve a stale tag back to the
|
|
14
|
+
* recorded full text via {@link SnapshotStore.byHashExact} and 3-way-merge the
|
|
15
|
+
* would-be edit onto the live content.
|
|
16
|
+
*
|
|
17
|
+
* The abstract base class lets callers plug in whatever storage they like
|
|
18
|
+
* (LRU, persistent SQLite, etc.). {@link InMemorySnapshotStore} ships as a
|
|
19
|
+
* sensible default backed by `lru-cache`: a bounded set of paths, each with a
|
|
20
|
+
* short history of full-file versions so in-session edit chains can still
|
|
21
|
+
* recover against the version a stale tag names.
|
|
22
|
+
*/
|
|
23
|
+
import { LRUCache } from "lru-cache/raw";
|
|
24
|
+
import { computeFileHash } from "./format";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One full-file version observed at a point in time. The tag the model sees is
|
|
28
|
+
* {@link Snapshot.hash}; recovery replays edits against {@link Snapshot.text}.
|
|
29
|
+
*/
|
|
30
|
+
export interface Snapshot {
|
|
31
|
+
/** Canonical path this version belongs to. */
|
|
32
|
+
readonly path: string;
|
|
33
|
+
/** Full normalized (LF, no BOM) file text as observed. */
|
|
34
|
+
readonly text: string;
|
|
35
|
+
/** Content-derived tag for {@link Snapshot.text} (see {@link computeFileHash}). */
|
|
36
|
+
readonly hash: string;
|
|
37
|
+
/** Timestamp (ms since epoch) the version was recorded. */
|
|
38
|
+
recordedAt: number;
|
|
39
|
+
/**
|
|
40
|
+
* 1-indexed file lines a producer (read/search) actually *displayed* under
|
|
41
|
+
* this tag. A partial read (range, or a structural summary that collapsed
|
|
42
|
+
* bodies) leaves this sparse; a whole-file read fills every line. Multiple
|
|
43
|
+
* reads of the same content union into one set. `undefined` means "no
|
|
44
|
+
* provenance recorded" — the patcher then skips the seen-line check and
|
|
45
|
+
* applies as before. Mutated in place as more of the same content is read.
|
|
46
|
+
*/
|
|
47
|
+
seenLines?: Set<number>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Storage seam for full-file version snapshots. The patcher calls {@link head}
|
|
52
|
+
* for the latest version of a path and {@link byHashExact} when it needs the
|
|
53
|
+
* specific historical version a section's stale tag names.
|
|
54
|
+
*/
|
|
55
|
+
export abstract class SnapshotStore {
|
|
56
|
+
/** Most-recently recorded version for `path`, or `null` if none. */
|
|
57
|
+
abstract head(path: string): Snapshot | null;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Recorded version for `path` whose tag equals `hash`, or `null`. When two
|
|
61
|
+
* distinct texts collide on the 16-bit tag, returns the most-recently
|
|
62
|
+
* recorded one; callers that treat the tag as content identity must use
|
|
63
|
+
* {@link byHashExact} (or verify {@link Snapshot.text} via {@link byContent}).
|
|
64
|
+
*/
|
|
65
|
+
abstract byHash(path: string, hash: string): Snapshot | null;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Collision-safe {@link byHash}: the single retained version for `path`
|
|
69
|
+
* whose tag equals `hash`, or `null` when none is retained OR when two or
|
|
70
|
+
* more distinct texts collide on the tag. In the collision case there is
|
|
71
|
+
* no way to know which retained text the model's line anchors were minted
|
|
72
|
+
* against, so consumers that replay anchors (recovery, previews) must
|
|
73
|
+
* refuse rather than pick one.
|
|
74
|
+
*/
|
|
75
|
+
abstract byHashExact(path: string, hash: string): Snapshot | null;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Recorded version for `path` whose {@link Snapshot.text} equals `fullText`,
|
|
79
|
+
* or `null`. Disambiguates hash collisions where two distinct file states
|
|
80
|
+
* share the same 4-hex tag: the patcher consults this before taking the
|
|
81
|
+
* no-drift path so a colliding live text is never accepted as the exact
|
|
82
|
+
* snapshot the model's line anchors were minted against.
|
|
83
|
+
*/
|
|
84
|
+
abstract byContent(path: string, fullText: string): Snapshot | null;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Every retained version whose tag equals `hash`, across all tracked
|
|
88
|
+
* paths. The patcher uses this to recover the intended file when a section
|
|
89
|
+
* names a path that does not exist on disk but carries a tag the store
|
|
90
|
+
* minted — the model mistyped the path of a file it read this session.
|
|
91
|
+
*
|
|
92
|
+
* The base returns no matches (recovery disabled); stores that can
|
|
93
|
+
* enumerate their contents override it to enable tag-based path recovery.
|
|
94
|
+
*/
|
|
95
|
+
findByHash(_hash: string): Snapshot[] {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Record the full normalized text of `path` and return its content tag.
|
|
101
|
+
* `seenLines` (optional) are the 1-indexed lines the producer displayed;
|
|
102
|
+
* they merge into {@link Snapshot.seenLines} across reads of identical text.
|
|
103
|
+
*/
|
|
104
|
+
abstract record(path: string, fullText: string, seenLines?: Iterable<number>): string;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Merge `lines` into the {@link Snapshot.seenLines} of the version whose tag
|
|
108
|
+
* equals `hash`. No-op when no such version is retained (the content aged
|
|
109
|
+
* out or was overwritten). Lets producers attach displayed lines after the
|
|
110
|
+
* tag was already minted (the body is formatted after the hash is computed).
|
|
111
|
+
*/
|
|
112
|
+
abstract recordSeenLines(path: string, hash: string, lines: Iterable<number>): void;
|
|
113
|
+
|
|
114
|
+
/** Drop the version history for a single path. */
|
|
115
|
+
abstract invalidate(path: string): void;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Move retained version history (and read provenance) from `from` to `to`.
|
|
119
|
+
* No-op when `from` has no history. Used by file moves so tags minted from
|
|
120
|
+
* reads of the source path stay valid at the destination.
|
|
121
|
+
*/
|
|
122
|
+
abstract relocate(from: string, to: string): void;
|
|
123
|
+
|
|
124
|
+
/** Drop every version history. */
|
|
125
|
+
abstract clear(): void;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const DEFAULT_MAX_PATHS = 30;
|
|
129
|
+
const DEFAULT_MAX_VERSIONS_PER_PATH = 4;
|
|
130
|
+
/** Global ceiling on retained snapshot text across all paths (UTF-16 code units). */
|
|
131
|
+
const DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
132
|
+
|
|
133
|
+
/** Union `lines` into `snapshot.seenLines`, lazily creating the set. */
|
|
134
|
+
function mergeSeenLines(snapshot: Snapshot, lines: Iterable<number> | undefined): void {
|
|
135
|
+
if (lines === undefined) return;
|
|
136
|
+
if (snapshot.seenLines === undefined) snapshot.seenLines = new Set<number>();
|
|
137
|
+
for (const line of lines) snapshot.seenLines.add(line);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface InMemorySnapshotStoreOptions {
|
|
141
|
+
/** Maximum number of distinct paths tracked at once (default 30). LRU eviction. */
|
|
142
|
+
maxPaths?: number;
|
|
143
|
+
/** Maximum full-file versions retained per path (default 4). Oldest dropped first. */
|
|
144
|
+
maxVersionsPerPath?: number;
|
|
145
|
+
/**
|
|
146
|
+
* Global ceiling on retained snapshot text summed across every path's
|
|
147
|
+
* version history, measured in UTF-16 code units (default 64 MiB).
|
|
148
|
+
* Least-recently-used path histories are evicted to stay under it.
|
|
149
|
+
*/
|
|
150
|
+
maxTotalBytes?: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* In-memory {@link SnapshotStore} backed by `lru-cache`. Per-path history is a
|
|
155
|
+
* short ring of full-file versions (oldest dropped first); per-session path
|
|
156
|
+
* tracking is LRU-bounded so cold paths age out automatically.
|
|
157
|
+
*
|
|
158
|
+
* Recording byte-identical content again refreshes recency and reuses the
|
|
159
|
+
* existing tag (read fusion); recording new content unshifts a fresh version
|
|
160
|
+
* onto the front of the path history. Two distinct texts that collide on the
|
|
161
|
+
* short 4-hex tag are retained as separate versions so callers can still tell
|
|
162
|
+
* them apart via {@link Snapshot.text} — the tag is only a fast index, never
|
|
163
|
+
* the identity.
|
|
164
|
+
*/
|
|
165
|
+
export class InMemorySnapshotStore extends SnapshotStore {
|
|
166
|
+
readonly #versions: LRUCache<string, Snapshot[]>;
|
|
167
|
+
readonly #maxVersionsPerPath: number;
|
|
168
|
+
|
|
169
|
+
constructor(options: InMemorySnapshotStoreOptions = {}) {
|
|
170
|
+
super();
|
|
171
|
+
this.#versions = new LRUCache<string, Snapshot[]>({
|
|
172
|
+
max: options.maxPaths ?? DEFAULT_MAX_PATHS,
|
|
173
|
+
maxSize: options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES,
|
|
174
|
+
sizeCalculation: history => {
|
|
175
|
+
let total = 1;
|
|
176
|
+
for (const version of history) total += version.text.length;
|
|
177
|
+
return total;
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
this.#maxVersionsPerPath = options.maxVersionsPerPath ?? DEFAULT_MAX_VERSIONS_PER_PATH;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
head(path: string): Snapshot | null {
|
|
184
|
+
return this.#versions.get(path)?.[0] ?? null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
byHash(path: string, hash: string): Snapshot | null {
|
|
188
|
+
const history = this.#versions.get(path);
|
|
189
|
+
return history?.find(version => version.hash === hash) ?? null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
byHashExact(path: string, hash: string): Snapshot | null {
|
|
193
|
+
const history = this.#versions.get(path);
|
|
194
|
+
if (history === undefined) return null;
|
|
195
|
+
let match: Snapshot | null = null;
|
|
196
|
+
for (const version of history) {
|
|
197
|
+
if (version.hash !== hash) continue;
|
|
198
|
+
// Two retained versions with one tag are distinct texts by
|
|
199
|
+
// construction (record() dedups on full-text equality) — ambiguous.
|
|
200
|
+
if (match !== null) return null;
|
|
201
|
+
match = version;
|
|
202
|
+
}
|
|
203
|
+
return match;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
byContent(path: string, fullText: string): Snapshot | null {
|
|
207
|
+
const history = this.#versions.get(path);
|
|
208
|
+
return history?.find(version => version.text === fullText) ?? null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
findByHash(hash: string): Snapshot[] {
|
|
212
|
+
const matches: Snapshot[] = [];
|
|
213
|
+
for (const history of this.#versions.values()) {
|
|
214
|
+
for (const version of history) {
|
|
215
|
+
if (version.hash === hash) matches.push(version);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return matches;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
record(path: string, fullText: string, seenLines?: Iterable<number>): string {
|
|
222
|
+
const hash = computeFileHash(fullText);
|
|
223
|
+
// `get` refreshes LRU recency for `path`.
|
|
224
|
+
const history = this.#versions.get(path) ?? [];
|
|
225
|
+
// Dedup requires full-text equality, not just tag equality: two distinct
|
|
226
|
+
// texts that happen to share the 4-hex tag are DIFFERENT snapshots — fusing
|
|
227
|
+
// them under one entry would corrupt seenLines (attaching lines from
|
|
228
|
+
// text B onto the stored text A) and let the patcher misresolve which
|
|
229
|
+
// snapshot the section tag names when it does 3-way merge or seen-line
|
|
230
|
+
// validation. See issue #4075.
|
|
231
|
+
const existing = history.find(version => version.hash === hash && version.text === fullText);
|
|
232
|
+
if (existing) {
|
|
233
|
+
// Same content state observed again: refresh recency and promote to
|
|
234
|
+
// head (it is the current file content), then reuse the tag. Union any
|
|
235
|
+
// newly-displayed lines so re-reading more of the file widens coverage.
|
|
236
|
+
existing.recordedAt = Date.now();
|
|
237
|
+
mergeSeenLines(existing, seenLines);
|
|
238
|
+
if (history[0] !== existing) {
|
|
239
|
+
this.#versions.set(path, [existing, ...history.filter(version => version !== existing)]);
|
|
240
|
+
}
|
|
241
|
+
return hash;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const snapshot: Snapshot = { path, text: fullText, hash, recordedAt: Date.now() };
|
|
245
|
+
mergeSeenLines(snapshot, seenLines);
|
|
246
|
+
this.#versions.set(path, [snapshot, ...history].slice(0, this.#maxVersionsPerPath));
|
|
247
|
+
return hash;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
recordSeenLines(path: string, hash: string, lines: Iterable<number>): void {
|
|
251
|
+
const version = this.#versions.get(path)?.find(snapshot => snapshot.hash === hash);
|
|
252
|
+
if (version) mergeSeenLines(version, lines);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
invalidate(path: string): void {
|
|
256
|
+
this.#versions.delete(path);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
relocate(from: string, to: string): void {
|
|
260
|
+
const sourceHistory = this.#versions.get(from);
|
|
261
|
+
if (sourceHistory === undefined || sourceHistory.length === 0) return;
|
|
262
|
+
const relocated = sourceHistory.map(version => ({ ...version, path: to }));
|
|
263
|
+
const destHistory = this.#versions.get(to);
|
|
264
|
+
if (destHistory === undefined) {
|
|
265
|
+
this.#versions.set(to, relocated);
|
|
266
|
+
} else {
|
|
267
|
+
const seen = new Set<string>();
|
|
268
|
+
const merged: Snapshot[] = [];
|
|
269
|
+
for (const version of [...relocated, ...destHistory]) {
|
|
270
|
+
if (seen.has(version.hash)) continue;
|
|
271
|
+
seen.add(version.hash);
|
|
272
|
+
merged.push(version);
|
|
273
|
+
}
|
|
274
|
+
this.#versions.set(to, merged.slice(0, this.#maxVersionsPerPath));
|
|
275
|
+
}
|
|
276
|
+
this.#versions.delete(from);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
clear(): void {
|
|
280
|
+
this.#versions.clear();
|
|
281
|
+
}
|
|
282
|
+
}
|