@oh-my-pi/hashline 15.5.10 → 15.5.12
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/dist/types/mismatch.d.ts +9 -0
- package/dist/types/snapshots.d.ts +11 -4
- package/package.json +1 -1
- package/src/mismatch.ts +19 -1
- package/src/patcher.ts +1 -0
- package/src/snapshots.ts +138 -14
package/dist/types/mismatch.d.ts
CHANGED
|
@@ -10,6 +10,14 @@ export interface MismatchDetails {
|
|
|
10
10
|
actualFileHash: string;
|
|
11
11
|
fileLines: string[];
|
|
12
12
|
anchorLines?: readonly number[];
|
|
13
|
+
/**
|
|
14
|
+
* `true` when the section's expected hash resolved to a recorded snapshot
|
|
15
|
+
* (file content drifted since that snapshot), `false` when no snapshot
|
|
16
|
+
* was ever recorded for the hash (likely fabricated or carried over from
|
|
17
|
+
* a prior session). Drives a more actionable rejection message; defaults
|
|
18
|
+
* to `true` for backward compatibility with direct callers.
|
|
19
|
+
*/
|
|
20
|
+
hashRecognized?: boolean;
|
|
13
21
|
}
|
|
14
22
|
/**
|
|
15
23
|
* Raised when a hashline section's snapshot tag doesn't match the live file's
|
|
@@ -23,6 +31,7 @@ export declare class MismatchError extends Error {
|
|
|
23
31
|
readonly actualFileHash: string;
|
|
24
32
|
readonly fileLines: string[];
|
|
25
33
|
readonly anchorLines: readonly number[];
|
|
34
|
+
readonly hashRecognized: boolean;
|
|
26
35
|
constructor(details: MismatchDetails);
|
|
27
36
|
get displayMessage(): string;
|
|
28
37
|
static rejectionHeader(details: MismatchDetails): string[];
|
|
@@ -110,10 +110,17 @@ export declare abstract class SnapshotStore {
|
|
|
110
110
|
* In-memory {@link SnapshotStore} backed by a flat 4096-slot ring shared across
|
|
111
111
|
* all paths. Slot allocation is a simple `counter & 0xfff`; the tag the model
|
|
112
112
|
* sees is `FORWARD[slot]` from the module-level permutation, so consecutive
|
|
113
|
-
* pushes hand out unrelated tags.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
113
|
+
* pushes hand out unrelated tags. Before allocating, {@link InMemorySnapshotStore}
|
|
114
|
+
* folds a new view into an existing same-path slot when the two agree on every
|
|
115
|
+
* shared line: one covering the other reuses it verbatim (dedup), overlapping
|
|
116
|
+
* or abutting runs extend in place, and gapped runs union into a sparse view
|
|
117
|
+
* (coalesce). All reuse the original tag, so sequential reads of an unchanged
|
|
118
|
+
* file collapse onto one anchor instead of fragmenting. A disagreeing shared
|
|
119
|
+
* line means the file changed on disk, so a fresh slot (new tag) is minted.
|
|
120
|
+
* Slot reuse on wrap is intentional: stale tags may
|
|
121
|
+
* alias after 4096 distinct pushes, and the patcher catches misuse by verifying
|
|
122
|
+
* the resolved snapshot's content (and path) against the live file before
|
|
123
|
+
* applying edits.
|
|
117
124
|
*/
|
|
118
125
|
export declare class InMemorySnapshotStore extends SnapshotStore {
|
|
119
126
|
#private;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "15.5.
|
|
4
|
+
"version": "15.5.12",
|
|
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/mismatch.ts
CHANGED
|
@@ -37,6 +37,14 @@ export interface MismatchDetails {
|
|
|
37
37
|
actualFileHash: string;
|
|
38
38
|
fileLines: string[];
|
|
39
39
|
anchorLines?: readonly number[];
|
|
40
|
+
/**
|
|
41
|
+
* `true` when the section's expected hash resolved to a recorded snapshot
|
|
42
|
+
* (file content drifted since that snapshot), `false` when no snapshot
|
|
43
|
+
* was ever recorded for the hash (likely fabricated or carried over from
|
|
44
|
+
* a prior session). Drives a more actionable rejection message; defaults
|
|
45
|
+
* to `true` for backward compatibility with direct callers.
|
|
46
|
+
*/
|
|
47
|
+
hashRecognized?: boolean;
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
function getMismatchDisplayLines(anchorLines: readonly number[], fileLines: string[]): number[] {
|
|
@@ -62,6 +70,7 @@ export class MismatchError extends Error {
|
|
|
62
70
|
readonly actualFileHash: string;
|
|
63
71
|
readonly fileLines: string[];
|
|
64
72
|
readonly anchorLines: readonly number[];
|
|
73
|
+
readonly hashRecognized: boolean;
|
|
65
74
|
|
|
66
75
|
constructor(details: MismatchDetails) {
|
|
67
76
|
super(MismatchError.formatMessage(details));
|
|
@@ -71,6 +80,7 @@ export class MismatchError extends Error {
|
|
|
71
80
|
this.actualFileHash = details.actualFileHash;
|
|
72
81
|
this.fileLines = details.fileLines;
|
|
73
82
|
this.anchorLines = details.anchorLines ?? [];
|
|
83
|
+
this.hashRecognized = details.hashRecognized ?? true;
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
get displayMessage(): string {
|
|
@@ -80,14 +90,22 @@ export class MismatchError extends Error {
|
|
|
80
90
|
actualFileHash: this.actualFileHash,
|
|
81
91
|
fileLines: this.fileLines,
|
|
82
92
|
anchorLines: this.anchorLines,
|
|
93
|
+
hashRecognized: this.hashRecognized,
|
|
83
94
|
});
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
static rejectionHeader(details: MismatchDetails): string[] {
|
|
87
98
|
const pathText = details.path ? ` for ${details.path}` : "";
|
|
99
|
+
const hashRecognized = details.hashRecognized ?? true;
|
|
100
|
+
if (!hashRecognized) {
|
|
101
|
+
return [
|
|
102
|
+
`Edit rejected${pathText}: hash ${HL_FILE_HASH_SEP}${details.expectedFileHash} is not from this session.`,
|
|
103
|
+
`The current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. Re-read the file with \`read\` to copy a current ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}tag header — never invent the tag and never reuse one from a prior session.`,
|
|
104
|
+
];
|
|
105
|
+
}
|
|
88
106
|
return [
|
|
89
107
|
`Edit rejected${pathText}: file changed between read and edit.`,
|
|
90
|
-
`Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. If
|
|
108
|
+
`Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. If a prior edit in this session modified this file, copy the ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}newhash header from that edit's response; otherwise re-read the file with \`read\` to refresh the tag before retrying.`,
|
|
91
109
|
];
|
|
92
110
|
}
|
|
93
111
|
|
package/src/patcher.ts
CHANGED
package/src/snapshots.ts
CHANGED
|
@@ -236,10 +236,17 @@ function buildHexTables(): { FORWARD: readonly string[]; INVERSE: ReadonlyMap<st
|
|
|
236
236
|
* In-memory {@link SnapshotStore} backed by a flat 4096-slot ring shared across
|
|
237
237
|
* all paths. Slot allocation is a simple `counter & 0xfff`; the tag the model
|
|
238
238
|
* sees is `FORWARD[slot]` from the module-level permutation, so consecutive
|
|
239
|
-
* pushes hand out unrelated tags.
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
239
|
+
* pushes hand out unrelated tags. Before allocating, {@link InMemorySnapshotStore}
|
|
240
|
+
* folds a new view into an existing same-path slot when the two agree on every
|
|
241
|
+
* shared line: one covering the other reuses it verbatim (dedup), overlapping
|
|
242
|
+
* or abutting runs extend in place, and gapped runs union into a sparse view
|
|
243
|
+
* (coalesce). All reuse the original tag, so sequential reads of an unchanged
|
|
244
|
+
* file collapse onto one anchor instead of fragmenting. A disagreeing shared
|
|
245
|
+
* line means the file changed on disk, so a fresh slot (new tag) is minted.
|
|
246
|
+
* Slot reuse on wrap is intentional: stale tags may
|
|
247
|
+
* alias after 4096 distinct pushes, and the patcher catches misuse by verifying
|
|
248
|
+
* the resolved snapshot's content (and path) against the live file before
|
|
249
|
+
* applying edits.
|
|
243
250
|
*/
|
|
244
251
|
export class InMemorySnapshotStore extends SnapshotStore {
|
|
245
252
|
readonly #slots: Array<Snapshot | null> = new Array<Snapshot | null>(RING_SIZE).fill(null);
|
|
@@ -288,8 +295,20 @@ export class InMemorySnapshotStore extends SnapshotStore {
|
|
|
288
295
|
}
|
|
289
296
|
|
|
290
297
|
#record(incoming: Snapshot): string {
|
|
291
|
-
|
|
292
|
-
|
|
298
|
+
// Walk newest→oldest for a same-path snapshot we can fold `incoming`
|
|
299
|
+
// into: either it already covers `incoming` (dedup) or the two agree on
|
|
300
|
+
// every shared line and merge into one run/sparse view (coalesce).
|
|
301
|
+
// Folding keeps the original slot, so the tag the model already saw for
|
|
302
|
+
// an earlier read also anchors this one.
|
|
303
|
+
for (let offset = 1; offset <= this.#filled; offset++) {
|
|
304
|
+
const slot = (this.#nextCounter - offset) & RING_MASK;
|
|
305
|
+
const existing = this.#slots[slot];
|
|
306
|
+
if (!existing || existing.path !== incoming.path) continue;
|
|
307
|
+
const folded = coalesceSnapshots(existing, incoming);
|
|
308
|
+
if (folded === null) continue;
|
|
309
|
+
if (folded !== existing) this.#slots[slot] = folded;
|
|
310
|
+
return FORWARD[slot] ?? FALLBACK_TAG;
|
|
311
|
+
}
|
|
293
312
|
|
|
294
313
|
const slot = this.#nextCounter & RING_MASK;
|
|
295
314
|
this.#slots[slot] = incoming;
|
|
@@ -297,14 +316,119 @@ export class InMemorySnapshotStore extends SnapshotStore {
|
|
|
297
316
|
if (this.#filled < RING_SIZE) this.#filled++;
|
|
298
317
|
return FORWARD[slot] ?? FALLBACK_TAG;
|
|
299
318
|
}
|
|
319
|
+
}
|
|
300
320
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Fold `incoming` into `existing` (callers guarantee same path). Returns:
|
|
323
|
+
* - `existing` when it already covers every line `incoming` asserts — pure
|
|
324
|
+
* dedup, no new storage required;
|
|
325
|
+
* - a fresh merged snapshot when the two agree on every shared line — a
|
|
326
|
+
* {@link ContiguousSnapshot} when the union is a single run (overlapping or
|
|
327
|
+
* abutting reads), otherwise a {@link SparseSnapshot} spanning the gap(s).
|
|
328
|
+
* Agreement is the "file unchanged" proof, so one tag can anchor both;
|
|
329
|
+
* - `null` when a shared line disagrees: the file changed on disk between the
|
|
330
|
+
* reads, so the views describe different states and MUST keep distinct tags.
|
|
331
|
+
*
|
|
332
|
+
* Disjoint reads share no lines and so never conflict — they union optimistically
|
|
333
|
+
* (the patcher re-verifies recorded lines against live content before applying,
|
|
334
|
+
* so a stale union degrades to a re-read prompt, never a corrupt edit).
|
|
335
|
+
*/
|
|
336
|
+
function coalesceSnapshots(existing: Snapshot, incoming: Snapshot): Snapshot | null {
|
|
337
|
+
// Contiguous∩contiguous is the hot path (sequential range reads); settle it
|
|
338
|
+
// with range arithmetic so dedup and in-run extension allocate nothing.
|
|
339
|
+
if (
|
|
340
|
+
existing instanceof ContiguousSnapshot &&
|
|
341
|
+
incoming instanceof ContiguousSnapshot &&
|
|
342
|
+
existing.lines.length > 0 &&
|
|
343
|
+
incoming.lines.length > 0
|
|
344
|
+
) {
|
|
345
|
+
return coalesceContiguous(existing, incoming);
|
|
346
|
+
}
|
|
347
|
+
return coalesceGeneral(existing, incoming);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Range-arithmetic coalesce for two non-empty contiguous runs. */
|
|
351
|
+
function coalesceContiguous(a: ContiguousSnapshot, b: ContiguousSnapshot): Snapshot | null {
|
|
352
|
+
const aEnd = a.offset + a.lines.length - 1;
|
|
353
|
+
const bEnd = b.offset + b.lines.length - 1;
|
|
354
|
+
|
|
355
|
+
// Every shared line must agree, else the file changed between the reads.
|
|
356
|
+
const lo = Math.max(a.offset, b.offset);
|
|
357
|
+
const hi = Math.min(aEnd, bEnd);
|
|
358
|
+
for (let line = lo; line <= hi; line++) {
|
|
359
|
+
if (a.lines[line - a.offset] !== b.lines[line - b.offset]) return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// `a` already covers `b` verbatim → reuse the slot untouched.
|
|
363
|
+
if (b.offset >= a.offset && bEnd <= aEnd) return a;
|
|
364
|
+
|
|
365
|
+
// Overlapping or directly abutting → a single, larger contiguous run.
|
|
366
|
+
if (b.offset <= aEnd + 1 && a.offset <= bEnd + 1) {
|
|
367
|
+
const start = Math.min(a.offset, b.offset);
|
|
368
|
+
const end = Math.max(aEnd, bEnd);
|
|
369
|
+
const lines = new Array<string>(end - start + 1);
|
|
370
|
+
for (let i = 0; i < a.lines.length; i++) lines[a.offset - start + i] = a.lines[i] ?? "";
|
|
371
|
+
// `b` is the fresher read; overlay it last (shared lines are equal anyway).
|
|
372
|
+
for (let i = 0; i < b.lines.length; i++) lines[b.offset - start + i] = b.lines[i] ?? "";
|
|
373
|
+
return new ContiguousSnapshot(a.path, start, lines, pickFullText(a, b, start, lines));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// A gap separates the runs → fold into a sparse view that preserves it.
|
|
377
|
+
return unionSnapshots(a, b);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Entry-based coalesce covering any snapshot shape (sparse, or mixed runs). */
|
|
381
|
+
function coalesceGeneral(existing: Snapshot, incoming: Snapshot): Snapshot | null {
|
|
382
|
+
let covered = 0;
|
|
383
|
+
let total = 0;
|
|
384
|
+
for (const [line, content] of incoming.entries()) {
|
|
385
|
+
total++;
|
|
386
|
+
const seen = existing.get(line);
|
|
387
|
+
if (seen === undefined) continue;
|
|
388
|
+
if (seen !== content) return null;
|
|
389
|
+
covered++;
|
|
309
390
|
}
|
|
391
|
+
if (covered === total) return existing;
|
|
392
|
+
return unionSnapshots(existing, incoming);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Union two compatible views (callers guarantee agreement on shared lines).
|
|
397
|
+
* Collapses back to a {@link ContiguousSnapshot} when the merged line numbers
|
|
398
|
+
* form a gap-free run, otherwise yields a {@link SparseSnapshot}.
|
|
399
|
+
*/
|
|
400
|
+
function unionSnapshots(a: Snapshot, b: Snapshot): Snapshot {
|
|
401
|
+
const merged = new Map<number, string>();
|
|
402
|
+
for (const [line, content] of a.entries()) merged.set(line, content);
|
|
403
|
+
// `b` is the fresher read; it wins ties (shared lines are equal anyway).
|
|
404
|
+
for (const [line, content] of b.entries()) merged.set(line, content);
|
|
405
|
+
|
|
406
|
+
let min = Number.POSITIVE_INFINITY;
|
|
407
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
408
|
+
for (const line of merged.keys()) {
|
|
409
|
+
if (line < min) min = line;
|
|
410
|
+
if (line > max) max = line;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (max - min + 1 === merged.size) {
|
|
414
|
+
const lines = new Array<string>(merged.size);
|
|
415
|
+
for (const [line, content] of merged) lines[line - min] = content;
|
|
416
|
+
return new ContiguousSnapshot(a.path, min, lines, pickFullText(a, b, min, lines));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const ordered = [...merged].sort((x, y) => x[0] - y[0]);
|
|
420
|
+
return new SparseSnapshot(a.path, new Map(ordered));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Carry a whole-file `fullText` onto a merged run only when it is provably
|
|
425
|
+
* still accurate: the run must start at line 1 and reconstruct the candidate
|
|
426
|
+
* byte-for-byte. Otherwise the text is stale (the file grew past it) and the
|
|
427
|
+
* snapshot falls back to line-by-line verification.
|
|
428
|
+
*/
|
|
429
|
+
function pickFullText(a: Snapshot, b: Snapshot, start: number, lines: readonly string[]): string | undefined {
|
|
430
|
+
if (start !== 1) return undefined;
|
|
431
|
+
const candidate = b.fullText ?? a.fullText;
|
|
432
|
+
if (candidate === undefined) return undefined;
|
|
433
|
+
return candidate === lines.join("\n") ? candidate : undefined;
|
|
310
434
|
}
|