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.
@@ -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
+ }
@@ -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
+ }