@oh-my-pi/hashline 16.3.2 → 16.3.4
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 +14 -0
- package/dist/types/messages.d.ts +22 -2
- package/dist/types/snapshots.d.ts +5 -18
- package/package.json +1 -1
- package/src/messages.ts +51 -6
- package/src/patcher.ts +70 -23
- package/src/recovery.ts +4 -5
- package/src/snapshots.ts +6 -33
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.3.3] - 2026-07-02
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- Removed SnapshotStore.byHashExact. Consumers should now use byHash, which resolves collisions by returning the most recently recorded version.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Improved patch application robustness by resolving 16-bit snapshot tag collisions to the most recent version instead of rejecting them.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Fixed frequent edit rejections after a structural-summary read (affecting parseable code over 100 lines) by automatically inlining unseen anchor lines and merging them into the snapshot's seen lines, allowing immediate retries to succeed without requiring a separate range re-read.
|
|
18
|
+
|
|
5
19
|
## [16.3.0] - 2026-07-02
|
|
6
20
|
|
|
7
21
|
### Changed
|
package/dist/types/messages.d.ts
CHANGED
|
@@ -108,13 +108,33 @@ export declare function missingSnapshotTagMessage(sectionPath: string): string;
|
|
|
108
108
|
* model (and user) learn the corrected path and stop reusing the wrong one.
|
|
109
109
|
*/
|
|
110
110
|
export declare function pathRecoveredFromTagMessage(authoredPath: string, resolvedPath: string, tag: string): string;
|
|
111
|
+
/** One anchored line whose actual content is being surfaced in an error message. */
|
|
112
|
+
export interface RevealedLine {
|
|
113
|
+
line: number;
|
|
114
|
+
text: string;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Content preview handed to {@link unseenLinesMessage}. `lines` are the
|
|
118
|
+
* unseen anchor lines whose actual file content we surface inline (from the
|
|
119
|
+
* tagged snapshot the caller matched). `truncated` = true means the anchor
|
|
120
|
+
* range exceeded the inline reveal cap; the caller only revealed a prefix
|
|
121
|
+
* and the remaining unseen lines still require a range re-read.
|
|
122
|
+
*/
|
|
123
|
+
export interface UnseenLinesReveal {
|
|
124
|
+
lines: readonly RevealedLine[];
|
|
125
|
+
truncated: boolean;
|
|
126
|
+
}
|
|
111
127
|
/**
|
|
112
128
|
* An anchored edit referenced lines the read that minted the cited tag never
|
|
113
129
|
* displayed (a partial range, or a structural summary that collapsed bodies).
|
|
114
130
|
* Editing lines you have not read is the off-by-memory failure that mangles
|
|
115
|
-
* files
|
|
131
|
+
* files. When `reveal.lines` is non-empty, the caller has already inlined the
|
|
132
|
+
* actual file content at those lines and merged them into the snapshot's
|
|
133
|
+
* seen-line set, so the message points the model at a straight retry with the
|
|
134
|
+
* same `[path#tag]` header; when the reveal is empty or truncated, the
|
|
135
|
+
* message falls back to instructing a range re-read.
|
|
116
136
|
*/
|
|
117
|
-
export declare function unseenLinesMessage(sectionPath: string, unseenLines: readonly number[], tag: string): string;
|
|
137
|
+
export declare function unseenLinesMessage(sectionPath: string, unseenLines: readonly number[], tag: string, reveal?: UnseenLinesReveal): string;
|
|
118
138
|
/** Op kind of a deferred block edit, for {@link blockSingleLineMessage}. */
|
|
119
139
|
export type BlockOp = "replace" | "delete" | "insert_after";
|
|
120
140
|
/**
|
|
@@ -23,8 +23,8 @@ export interface Snapshot {
|
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
25
|
* Storage seam for full-file version snapshots. The patcher calls {@link head}
|
|
26
|
-
* for the latest version of a path and {@link
|
|
27
|
-
*
|
|
26
|
+
* for the latest version of a path and {@link byHash} when it needs the
|
|
27
|
+
* historical version a section's stale tag names.
|
|
28
28
|
*/
|
|
29
29
|
export declare abstract class SnapshotStore {
|
|
30
30
|
/** Most-recently recorded version for `path`, or `null` if none. */
|
|
@@ -32,25 +32,13 @@ export declare abstract class SnapshotStore {
|
|
|
32
32
|
/**
|
|
33
33
|
* Recorded version for `path` whose tag equals `hash`, or `null`. When two
|
|
34
34
|
* distinct texts collide on the 16-bit tag, returns the most-recently
|
|
35
|
-
* recorded one
|
|
36
|
-
* {@link byHashExact} (or verify {@link Snapshot.text} via {@link byContent}).
|
|
35
|
+
* recorded one.
|
|
37
36
|
*/
|
|
38
37
|
abstract byHash(path: string, hash: string): Snapshot | null;
|
|
39
|
-
/**
|
|
40
|
-
* Collision-safe {@link byHash}: the single retained version for `path`
|
|
41
|
-
* whose tag equals `hash`, or `null` when none is retained OR when two or
|
|
42
|
-
* more distinct texts collide on the tag. In the collision case there is
|
|
43
|
-
* no way to know which retained text the model's line anchors were minted
|
|
44
|
-
* against, so consumers that replay anchors (recovery, previews) must
|
|
45
|
-
* refuse rather than pick one.
|
|
46
|
-
*/
|
|
47
|
-
abstract byHashExact(path: string, hash: string): Snapshot | null;
|
|
48
38
|
/**
|
|
49
39
|
* Recorded version for `path` whose {@link Snapshot.text} equals `fullText`,
|
|
50
|
-
* or `null`.
|
|
51
|
-
*
|
|
52
|
-
* no-drift path so a colliding live text is never accepted as the exact
|
|
53
|
-
* snapshot the model's line anchors were minted against.
|
|
40
|
+
* or `null`. The patcher uses it on the no-drift path to attach seen-line
|
|
41
|
+
* provenance to the exact text the model read.
|
|
54
42
|
*/
|
|
55
43
|
abstract byContent(path: string, fullText: string): Snapshot | null;
|
|
56
44
|
/**
|
|
@@ -116,7 +104,6 @@ export declare class InMemorySnapshotStore extends SnapshotStore {
|
|
|
116
104
|
constructor(options?: InMemorySnapshotStoreOptions);
|
|
117
105
|
head(path: string): Snapshot | null;
|
|
118
106
|
byHash(path: string, hash: string): Snapshot | null;
|
|
119
|
-
byHashExact(path: string, hash: string): Snapshot | null;
|
|
120
107
|
byContent(path: string, fullText: string): Snapshot | null;
|
|
121
108
|
findByHash(hash: string): Snapshot[];
|
|
122
109
|
record(path: string, fullText: string, seenLines?: Iterable<number>): string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "16.3.
|
|
4
|
+
"version": "16.3.4",
|
|
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",
|
package/src/messages.ts
CHANGED
|
@@ -223,21 +223,66 @@ function formatLineRanges(lines: readonly number[]): string {
|
|
|
223
223
|
return parts.join(", ");
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
/** One anchored line whose actual content is being surfaced in an error message. */
|
|
227
|
+
export interface RevealedLine {
|
|
228
|
+
line: number;
|
|
229
|
+
text: string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Content preview handed to {@link unseenLinesMessage}. `lines` are the
|
|
234
|
+
* unseen anchor lines whose actual file content we surface inline (from the
|
|
235
|
+
* tagged snapshot the caller matched). `truncated` = true means the anchor
|
|
236
|
+
* range exceeded the inline reveal cap; the caller only revealed a prefix
|
|
237
|
+
* and the remaining unseen lines still require a range re-read.
|
|
238
|
+
*/
|
|
239
|
+
export interface UnseenLinesReveal {
|
|
240
|
+
lines: readonly RevealedLine[];
|
|
241
|
+
truncated: boolean;
|
|
242
|
+
}
|
|
243
|
+
|
|
226
244
|
/**
|
|
227
245
|
* An anchored edit referenced lines the read that minted the cited tag never
|
|
228
246
|
* displayed (a partial range, or a structural summary that collapsed bodies).
|
|
229
247
|
* Editing lines you have not read is the off-by-memory failure that mangles
|
|
230
|
-
* files
|
|
248
|
+
* files. When `reveal.lines` is non-empty, the caller has already inlined the
|
|
249
|
+
* actual file content at those lines and merged them into the snapshot's
|
|
250
|
+
* seen-line set, so the message points the model at a straight retry with the
|
|
251
|
+
* same `[path#tag]` header; when the reveal is empty or truncated, the
|
|
252
|
+
* message falls back to instructing a range re-read.
|
|
231
253
|
*/
|
|
232
|
-
export function unseenLinesMessage(
|
|
254
|
+
export function unseenLinesMessage(
|
|
255
|
+
sectionPath: string,
|
|
256
|
+
unseenLines: readonly number[],
|
|
257
|
+
tag: string,
|
|
258
|
+
reveal: UnseenLinesReveal = { lines: [], truncated: false },
|
|
259
|
+
): string {
|
|
233
260
|
const ranges = formatLineRanges(unseenLines);
|
|
234
261
|
const selector = ranges.replace(/, /g, ",");
|
|
235
|
-
|
|
262
|
+
const header =
|
|
236
263
|
`This edit anchors to lines ${ranges} of ${sectionPath} that ` +
|
|
237
264
|
`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}${tag}${HL_FILE_SUFFIX} never displayed (it showed a ` +
|
|
238
|
-
`partial range, a search hit, or a folded summary)
|
|
239
|
-
|
|
240
|
-
|
|
265
|
+
`partial range, a search hit, or a folded summary).`;
|
|
266
|
+
if (reveal.lines.length === 0) {
|
|
267
|
+
return (
|
|
268
|
+
`${header} Re-read them in full first with a ranged read like ` +
|
|
269
|
+
`\`${sectionPath}:${selector}\` — it skips summarization and mints a fresh tag (a plain re-read just re-folds ` +
|
|
270
|
+
`them) — then re-issue the edit.`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const preview = reveal.lines.map(({ line, text }) => ` ${formatNumberedLine(line, text)}`).join("\n");
|
|
274
|
+
if (reveal.truncated) {
|
|
275
|
+
return (
|
|
276
|
+
`${header} Preview of the actual file content at the first ${reveal.lines.length} unseen line(s):\n${preview}\n` +
|
|
277
|
+
`The range exceeds the inline preview cap — re-read the remainder with \`${sectionPath}:${selector}\` before ` +
|
|
278
|
+
`re-issuing the edit.`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return (
|
|
282
|
+
`${header} Actual file content at those lines:\n${preview}\n` +
|
|
283
|
+
`Verify the content matches what you intend to touch, then re-issue the edit with the same ` +
|
|
284
|
+
`${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}tag${HL_FILE_SUFFIX} header — a straight retry now succeeds without a re-read. ` +
|
|
285
|
+
`If the content does NOT match, fix your line numbers.`
|
|
241
286
|
);
|
|
242
287
|
}
|
|
243
288
|
|
package/src/patcher.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
HEADTAIL_DRIFT_WARNING,
|
|
34
34
|
missingSnapshotTagMessage,
|
|
35
35
|
pathRecoveredFromTagMessage,
|
|
36
|
+
type RevealedLine,
|
|
36
37
|
unseenLinesMessage,
|
|
37
38
|
} from "./messages";
|
|
38
39
|
import { MismatchError } from "./mismatch";
|
|
@@ -41,6 +42,26 @@ import { Recovery, type RecoveryResult } from "./recovery";
|
|
|
41
42
|
import type { Snapshot, SnapshotStore } from "./snapshots";
|
|
42
43
|
import type { ApplyResult, BlockResolution, BlockResolver, Edit, FileOp } from "./types";
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Upper bound on the number of unseen anchor lines whose actual file content
|
|
47
|
+
* we inline into a rejection error (see {@link Patcher.assertSeenLines}). Big
|
|
48
|
+
* enough to fit the common "edit a whole function body" retry path in one
|
|
49
|
+
* message, small enough to keep the error human-readable when the model
|
|
50
|
+
* over-anchors and to preserve the "re-read first" fallback for genuinely
|
|
51
|
+
* blind wide edits (only the revealed prefix gets merged into `seenLines`).
|
|
52
|
+
*/
|
|
53
|
+
const SEEN_LINE_REVEAL_CAP = 40;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Per-revealed-line character cap. Matches the read/search column cap so a
|
|
57
|
+
* revealed anchor line can never dump a minified megabyte-wide bundle line
|
|
58
|
+
* into the tool error, TUI, and model context. Lines longer than the cap
|
|
59
|
+
* are trimmed to `cap` characters plus an `…` marker AND flag the entire
|
|
60
|
+
* reveal as truncated so no line joins `seenLines` — the model must re-read
|
|
61
|
+
* the range to prove it saw the full width.
|
|
62
|
+
*/
|
|
63
|
+
const SEEN_LINE_REVEAL_MAX_COLUMNS = 512;
|
|
64
|
+
|
|
44
65
|
export interface PatcherOptions {
|
|
45
66
|
/** Storage backend used for all reads and writes. */
|
|
46
67
|
fs: Filesystem;
|
|
@@ -486,13 +507,55 @@ export class Patcher {
|
|
|
486
507
|
* externally minted or aged out), so the edit applies as before. Only runs
|
|
487
508
|
* on the no-drift path, where anchor line numbers index the tagged content
|
|
488
509
|
* 1:1.
|
|
510
|
+
*
|
|
511
|
+
* The rejection inlines the actual file content at the unseen anchor lines
|
|
512
|
+
* (from `matchedSnapshot.text`, which by definition equals the live
|
|
513
|
+
* normalized content) so the model can verify what it was about to touch.
|
|
514
|
+
* When the reveal covers EVERY unseen anchor line in full width
|
|
515
|
+
* (`truncated === false`) those lines also merge into the snapshot's
|
|
516
|
+
* seen-line set, so a straight retry with the same `[path#tag]` header
|
|
517
|
+
* succeeds without a follow-up range read — the content the model
|
|
518
|
+
* received in the error IS proof it has now seen those lines. When the
|
|
519
|
+
* anchor range exceeds {@link SEEN_LINE_REVEAL_CAP} lines OR any
|
|
520
|
+
* revealed line exceeds {@link SEEN_LINE_REVEAL_MAX_COLUMNS} characters
|
|
521
|
+
* (`truncated === true`), NO lines merge: the message keeps the
|
|
522
|
+
* range-re-read guidance intact and the model cannot piecewise-reveal
|
|
523
|
+
* its way past the guard across multiple retries
|
|
524
|
+
* (over-cap retry → tail reveal → next retry applies), nor coax the tool
|
|
525
|
+
* into dumping a minified megabyte-wide line into the error preview.
|
|
489
526
|
*/
|
|
490
527
|
#assertSeenLines(section: PatchSection, expected: string, matchedSnapshot: Snapshot | null): void {
|
|
491
528
|
const seen = matchedSnapshot?.seenLines;
|
|
492
529
|
if (!seen || seen.size === 0) return;
|
|
493
530
|
const unseen = section.collectAnchorLines().filter(line => !seen.has(line));
|
|
494
531
|
if (unseen.length === 0) return;
|
|
495
|
-
|
|
532
|
+
const sourceLines = matchedSnapshot?.text.split("\n") ?? [];
|
|
533
|
+
const revealed: RevealedLine[] = [];
|
|
534
|
+
const revealCount = Math.min(unseen.length, SEEN_LINE_REVEAL_CAP);
|
|
535
|
+
let columnTruncated = false;
|
|
536
|
+
for (let i = 0; i < revealCount; i++) {
|
|
537
|
+
const line = unseen[i];
|
|
538
|
+
// Out-of-range anchors are caught by parse/apply with a better
|
|
539
|
+
// message; skip them here so they never join the revealed set.
|
|
540
|
+
if (line < 1 || line > sourceLines.length) continue;
|
|
541
|
+
const source = sourceLines[line - 1] ?? "";
|
|
542
|
+
if (source.length > SEEN_LINE_REVEAL_MAX_COLUMNS) {
|
|
543
|
+
revealed.push({ line, text: `${source.slice(0, SEEN_LINE_REVEAL_MAX_COLUMNS)}…` });
|
|
544
|
+
columnTruncated = true;
|
|
545
|
+
} else {
|
|
546
|
+
revealed.push({ line, text: source });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const truncated = unseen.length > revealed.length || columnTruncated;
|
|
550
|
+
// Only merge when the reveal covered every unseen anchor line in full
|
|
551
|
+
// width. A prefix-truncated reveal would let the model split a blind
|
|
552
|
+
// edit into <=cap-line retries and land it without ever running the
|
|
553
|
+
// required range re-read; a column-clipped reveal would leave part of
|
|
554
|
+
// each line unseen while the model receives an "ok to retry" signal.
|
|
555
|
+
if (!truncated) {
|
|
556
|
+
for (const { line } of revealed) seen.add(line);
|
|
557
|
+
}
|
|
558
|
+
throw new Error(unseenLinesMessage(section.path, unseen, expected, { lines: revealed, truncated }));
|
|
496
559
|
}
|
|
497
560
|
#mismatchError(
|
|
498
561
|
section: PatchSection,
|
|
@@ -521,23 +584,13 @@ export class Patcher {
|
|
|
521
584
|
}): ApplyResult {
|
|
522
585
|
const { section, canonicalPath, exists, normalized, edits } = args;
|
|
523
586
|
const expected = exists ? section.fileHash : undefined;
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
// version carries the tag and its full text matches the live text. If
|
|
529
|
-
// multiple versions share the tag, the header is ambiguous: there is no
|
|
530
|
-
// safe way to know which stored text the model's line anchors came from.
|
|
531
|
-
const storedSnapshotsForTag =
|
|
532
|
-
expected === undefined
|
|
533
|
-
? []
|
|
534
|
-
: this.snapshots.findByHash(expected).filter(snapshot => snapshot.path === canonicalPath);
|
|
535
|
-
const ambiguousStoredTag = storedSnapshotsForTag.length > 1;
|
|
587
|
+
// The 4-hex tag is content-derived: when the live text hashes to it,
|
|
588
|
+
// trust the match and apply directly. `storedSnapshotForTag` feeds the
|
|
589
|
+
// drift paths below (block resolution, 3-way recovery); on a 16-bit
|
|
590
|
+
// tag collision it resolves to the most-recently recorded text.
|
|
536
591
|
const storedSnapshotForTag = expected === undefined ? null : this.snapshots.byHash(canonicalPath, expected);
|
|
537
|
-
const
|
|
538
|
-
const matchedSnapshot =
|
|
539
|
-
const liveMatches =
|
|
540
|
-
hashMatches && !ambiguousStoredTag && (storedSnapshotForTag === null || matchedSnapshot !== null);
|
|
592
|
+
const liveMatches = expected !== undefined && computeFileHash(normalized) === expected;
|
|
593
|
+
const matchedSnapshot = liveMatches ? this.snapshots.byContent(canonicalPath, normalized) : null;
|
|
541
594
|
|
|
542
595
|
// Resolve `replace_block N:` edits to concrete ranges before recovery
|
|
543
596
|
// runs. Block anchors are expressed against the snapshot the section tag
|
|
@@ -552,9 +605,6 @@ export class Patcher {
|
|
|
552
605
|
const resolveWarnings: string[] = [];
|
|
553
606
|
let resolved: readonly Edit[] = edits;
|
|
554
607
|
if (hasBlockEdit(edits)) {
|
|
555
|
-
if (ambiguousStoredTag) {
|
|
556
|
-
throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", true);
|
|
557
|
-
}
|
|
558
608
|
const baseText = expected === undefined || liveMatches ? normalized : storedSnapshotForTag?.text;
|
|
559
609
|
if (baseText === undefined) {
|
|
560
610
|
throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", false);
|
|
@@ -590,9 +640,6 @@ export class Patcher {
|
|
|
590
640
|
const result = applyEdits(normalized, resolved);
|
|
591
641
|
return withResolveWarnings({ ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] });
|
|
592
642
|
}
|
|
593
|
-
if (ambiguousStoredTag) {
|
|
594
|
-
throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", true);
|
|
595
|
-
}
|
|
596
643
|
// File drifted: try to replay the edit against the version the tag
|
|
597
644
|
// names and 3-way-merge it onto the live content.
|
|
598
645
|
const recovered = this.recovery.tryRecover({
|
package/src/recovery.ts
CHANGED
|
@@ -392,11 +392,10 @@ export class Recovery {
|
|
|
392
392
|
*/
|
|
393
393
|
tryRecover(args: RecoveryArgs): RecoveryResult | null {
|
|
394
394
|
const { path, currentText, fileHash, edits } = args;
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
|
|
399
|
-
const snapshot = this.store.byHashExact(path, fileHash);
|
|
395
|
+
// When two retained texts collide on the 16-bit tag, resolve to the
|
|
396
|
+
// most-recently recorded one; a wrong pick can only land if one of the
|
|
397
|
+
// merge/remap/session-chain strategies below applies it cleanly.
|
|
398
|
+
const snapshot = this.store.byHash(path, fileHash);
|
|
400
399
|
if (!snapshot) return null;
|
|
401
400
|
const isHead = isHeadSnapshot(this.store.head(path), snapshot);
|
|
402
401
|
const recoveryWarning = isHead ? RECOVERY_EXTERNAL_WARNING : RECOVERY_SESSION_CHAIN_WARNING;
|
package/src/snapshots.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* {@link SnapshotStore.record} with the full normalized text they observed.
|
|
12
12
|
* The store hashes it, dedups against the per-path history, and returns the
|
|
13
13
|
* tag. Consumers (recovery, the patcher) resolve a stale tag back to the
|
|
14
|
-
* recorded full text via {@link SnapshotStore.
|
|
14
|
+
* recorded full text via {@link SnapshotStore.byHash} and 3-way-merge the
|
|
15
15
|
* would-be edit onto the live content.
|
|
16
16
|
*
|
|
17
17
|
* The abstract base class lets callers plug in whatever storage they like
|
|
@@ -49,8 +49,8 @@ export interface Snapshot {
|
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Storage seam for full-file version snapshots. The patcher calls {@link head}
|
|
52
|
-
* for the latest version of a path and {@link
|
|
53
|
-
*
|
|
52
|
+
* for the latest version of a path and {@link byHash} when it needs the
|
|
53
|
+
* historical version a section's stale tag names.
|
|
54
54
|
*/
|
|
55
55
|
export abstract class SnapshotStore {
|
|
56
56
|
/** Most-recently recorded version for `path`, or `null` if none. */
|
|
@@ -59,27 +59,14 @@ export abstract class SnapshotStore {
|
|
|
59
59
|
/**
|
|
60
60
|
* Recorded version for `path` whose tag equals `hash`, or `null`. When two
|
|
61
61
|
* distinct texts collide on the 16-bit tag, returns the most-recently
|
|
62
|
-
* recorded one
|
|
63
|
-
* {@link byHashExact} (or verify {@link Snapshot.text} via {@link byContent}).
|
|
62
|
+
* recorded one.
|
|
64
63
|
*/
|
|
65
64
|
abstract byHash(path: string, hash: string): Snapshot | null;
|
|
66
65
|
|
|
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
66
|
/**
|
|
78
67
|
* Recorded version for `path` whose {@link Snapshot.text} equals `fullText`,
|
|
79
|
-
* or `null`.
|
|
80
|
-
*
|
|
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.
|
|
68
|
+
* or `null`. The patcher uses it on the no-drift path to attach seen-line
|
|
69
|
+
* provenance to the exact text the model read.
|
|
83
70
|
*/
|
|
84
71
|
abstract byContent(path: string, fullText: string): Snapshot | null;
|
|
85
72
|
|
|
@@ -189,20 +176,6 @@ export class InMemorySnapshotStore extends SnapshotStore {
|
|
|
189
176
|
return history?.find(version => version.hash === hash) ?? null;
|
|
190
177
|
}
|
|
191
178
|
|
|
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
179
|
byContent(path: string, fullText: string): Snapshot | null {
|
|
207
180
|
const history = this.#versions.get(path);
|
|
208
181
|
return history?.find(version => version.text === fullText) ?? null;
|