@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.
@@ -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. Slot reuse on wrap is intentional: stale tags
114
- * may alias after 4096 distinct pushes, and the patcher catches misuse by
115
- * verifying the resolved snapshot's content (and path) against the live file
116
- * before applying edits.
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.10",
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 your previous edit in this session modified this file, copy the ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}newhash from that edit's response. Otherwise re-read the file before retrying.`,
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
@@ -359,6 +359,7 @@ export class Patcher {
359
359
  actualFileHash: currentHash,
360
360
  fileLines: normalized.split("\n"),
361
361
  anchorLines: section.collectAnchorLines(),
362
+ hashRecognized: snapshot !== null,
362
363
  });
363
364
  }
364
365
  }
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. Slot reuse on wrap is intentional: stale tags
240
- * may alias after 4096 distinct pushes, and the patcher catches misuse by
241
- * verifying the resolved snapshot's content (and path) against the live file
242
- * before applying edits.
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
- const dedup = this.#dedup(incoming);
292
- if (dedup !== null) return dedup;
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
- #dedup(incoming: Snapshot): string | null {
302
- for (let offset = 1; offset <= this.#filled; offset++) {
303
- const slot = (this.#nextCounter - offset) & RING_MASK;
304
- const existing = this.#slots[slot];
305
- if (!existing || existing.path !== incoming.path) continue;
306
- if (existing.isSuperset(incoming)) return FORWARD[slot] ?? FALLBACK_TAG;
307
- }
308
- return null;
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
  }