pi-hashline-edit-pro 0.14.1 → 0.15.0

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/README.md CHANGED
@@ -92,8 +92,13 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
92
92
  - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}`.
93
93
  - **Batched atomicity (bulk mode).** All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so the hashes from a single `read` call remain valid across all edits in the batch.
94
94
 
95
- ### Chained edits
95
+ ### Stable hashing across edits
96
+
97
+ Hashes are now computed with a persistent store (`~/.config/pi-hashline-edit-pro/hash-store.json`) that preserves hashes for unchanged lines across edits. When you replace lines in a file, the runtime diffs the old content against the new content and copies hashes for unchanged lines to their new positions. This means editing one part of a file does not change the hashes of unrelated lines elsewhere — the model can keep using previously seen anchors for untouched regions.
96
98
 
99
+ The store contains per-file snapshots: the last known content and hashes for each file. On read, if the file content matches the snapshot, the saved hashes are returned immediately. Stale snapshots (for files that no longer exist) are pruned on session start.
100
+
101
+ ### Chained edits
97
102
  After a successful replace, the response confirms with `Successfully replaced in {path}.` (warnings are still shown if present). To get fresh anchors for follow-up edits, call `read` on the file first. This avoids token overhead from re-displaying content the model already knows.
98
103
 
99
104
  ### Auto-read after write
@@ -140,7 +145,7 @@ The file is created automatically when any setting is toggled. Both fields are i
140
145
  - **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
141
146
  - **Boundary duplication warnings.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), a warning is emitted. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The warning is a short header followed by a hashline-anchored window: the duplicated pair plus 2 lines of context before and after, shown as plain `HASH│content` rows with post-edit hashes. Seeing the duplication in context (rather than just being told a hash) is what prompts the model to act on it — and since the hashes are current and staleness is per-line, the model can remove the duplicate in one follow-up `replace` without re-reading. No autocorrection is applied — the duplicate stays in the file and the model decides whether to remove it. Raw line comparison (not trimmed) avoids false positives when indentation differs.
142
147
  - **Flat mode normalization.** When flat mode is active, the tool's `execute` function wraps the top-level `hash_range_inclusive` and `content_lines` into a single-element `changes` array internally, then runs the same pipeline as bulk mode. The `normReq` function in `replace-normalize.ts` also handles flat format directly, so any code path that normalizes input (e.g. `compPreview`) works with both formats.
143
-
148
+ - **Persistent hash store.** `lineHashes` is async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is at `~/.config/pi-hashline-edit-pro/hash-store.json` and is auto-created on first use. It contains per-file snapshots (last known content+hashes). When called from the replace pipeline, it diffs old vs new content and copies hashes for unchanged lines. When called from read, it returns saved hashes if the content matches, otherwise computes fresh hashes via `_lineHashesPure`. Stale snapshots are pruned on session start. This ensures that editing one part of a file does not cascade to change hashes of unrelated lines.
144
149
  ## Hashing
145
150
 
146
151
  Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32 via WebAssembly), then mapped to a 3-character string from the URL-safe base64 alphabet `A-Za-z0-9-_`. That's 64 distinct characters, 6 bits per position, 18 bits of entropy per anchor.
@@ -148,9 +153,7 @@ Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (
148
153
  The alphabet is sized for an LLM consumer. The model tokenizes, it doesn't squint at pixel glyphs, so the human-readability heuristics used by smaller hand-curated alphabets (no G/L/I/O because they look like digits, no vowels so the hash doesn't accidentally spell a word, no hex digits so it can't be confused with `0xFF`) don't apply. The full 64 chars give maximum entropy per character, with case and digits included.
149
154
 
150
155
  **Perfect hashing (collision resolution):** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is incremented (using a retry counter: `:R{retry}`) until a unique hash is found. This ensures every line in a file gets a unique anchor, even with the shorter 3-character hash space. Two byte-identical lines (e.g. repeated `}` or repeated `import` statements) get different hashes automatically.
151
-
152
- The runtime always precomputes the full per-line hash array for a file via `lineHashes(content)`, then looks up by line number during validation and during `read` / `replace` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read.
153
-
156
+ The runtime always precomputes the full per-line hash array for a file via `lineHashes(content, path)`, then looks up by line number during validation and during `read` / `replace` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read. When `path` is provided, `lineHashes` uses a persistent store to preserve hashes for unchanged lines across edits — see [Stable hashing across edits](#stable-hashing-across-edits).
154
157
  `HASH_LEN` in `src/hashline/hash.ts` sets the hash body length; bump it to 4 if you need even more entropy without collision resolution.
155
158
 
156
159
  ### Bare-prefix detector
package/index.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  readAutoRead,
15
15
  toggleAutoRead,
16
16
  } from "./src/config";
17
+ import { loadHashStore, pruneHashStore } from "./src/hash-store";
17
18
 
18
19
  export default function (pi: ExtensionAPI): void {
19
20
  regRead(pi);
@@ -31,7 +32,11 @@ export default function (pi: ExtensionAPI): void {
31
32
  const active = pi.getActiveTools();
32
33
  pi.setActiveTools(active.filter((t) => t !== "edit"));
33
34
  await initHasher();
34
-
35
+ // Prune stale snapshots (files that no longer exist) on session start
36
+ try {
37
+ const store = await loadHashStore();
38
+ await pruneHashStore(store);
39
+ } catch { /* best-effort */ }
35
40
  // Re-register the replace tool according to the persisted mode
36
41
  const mode = await readReplaceMode();
37
42
  if (mode === "flat") {
@@ -93,8 +98,7 @@ export default function (pi: ExtensionAPI): void {
93
98
 
94
99
  if (visLines(normalized).length === 0) return;
95
100
 
96
- const preview = fmtReadPreview(normalized, { limit: AUTO_READ_MAX });
97
- if (!preview.text) return;
101
+ const preview = await fmtReadPreview(normalized, { limit: AUTO_READ_MAX }, undefined, absolutePath);
98
102
 
99
103
  return {
100
104
  content: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
5
5
  "main": "index.ts",
6
6
  "repository": {
@@ -46,8 +46,7 @@ export async function readNormFile(
46
46
  }
47
47
  }
48
48
 
49
- const fileHashes = lineHashes(normalized);
50
-
49
+ const fileHashes = await lineHashes(normalized, absolutePath);
51
50
  return {
52
51
  absolutePath,
53
52
  normalized,
@@ -0,0 +1,61 @@
1
+ import { readFile, writeFile, mkdir } from "fs/promises";
2
+ import { homedir } from "os";
3
+ import { join, dirname } from "path";
4
+ import { stat } from "fs/promises";
5
+
6
+ export interface FileSnapshot {
7
+ content: string;
8
+ hashes: string[];
9
+ }
10
+
11
+ export interface HashStore {
12
+ version: 1;
13
+ snapshots: Record<string, FileSnapshot>;
14
+ }
15
+
16
+ function storePath(): string {
17
+ return join(homedir(), ".config", "pi-hashline-edit-pro", "hash-store.json");
18
+ }
19
+
20
+ function storeDir(): string {
21
+ return dirname(storePath());
22
+ }
23
+
24
+ export async function loadHashStore(): Promise<HashStore> {
25
+ try {
26
+ const content = await readFile(storePath(), "utf-8");
27
+ const parsed = JSON.parse(content) as Partial<HashStore>;
28
+ return {
29
+ version: 1,
30
+ snapshots: parsed.snapshots ?? {},
31
+ };
32
+ } catch {
33
+ await mkdir(storeDir(), { recursive: true });
34
+ const defaultStore: HashStore = {
35
+ version: 1,
36
+ snapshots: {},
37
+ };
38
+ await writeFile(storePath(), JSON.stringify(defaultStore), "utf-8");
39
+ return defaultStore;
40
+ }
41
+ }
42
+
43
+ export async function saveHashStore(store: HashStore): Promise<void> {
44
+ await mkdir(storeDir(), { recursive: true });
45
+ await writeFile(storePath(), JSON.stringify(store, null, 2), "utf-8");
46
+ }
47
+
48
+ export async function pruneHashStore(store: HashStore): Promise<void> {
49
+ let changed = false;
50
+ for (const filePath of Object.keys(store.snapshots)) {
51
+ try {
52
+ await stat(filePath);
53
+ } catch {
54
+ delete store.snapshots[filePath];
55
+ changed = true;
56
+ }
57
+ }
58
+ if (changed) {
59
+ await saveHashStore(store);
60
+ }
61
+ }
@@ -1,5 +1,5 @@
1
1
  import { abortIf } from "../runtime";
2
- import { lineHashes, HASH_SEP } from "./hash";
2
+ import { _lineHashesPure, HASH_SEP } from "./hash";
3
3
  import {
4
4
  valEdits,
5
5
  assertNoBarePrefix,
@@ -272,9 +272,9 @@ export function applyEdits(
272
272
  content: string;
273
273
  firstChangedLine: number | undefined;
274
274
  lastChangedLine: number | undefined;
275
- resultHashes?: string[];
276
275
  warnings?: string[];
277
276
  noopEdits?: NEdit[];
277
+ boundaryWarnings?: BDupWarn[];
278
278
  } {
279
279
  abortIf(signal);
280
280
  if (!edits.length)
@@ -292,7 +292,7 @@ export function applyEdits(
292
292
  );
293
293
 
294
294
  const lineIndex = buildIdx(content);
295
- const fileHashes = precomputedHashes ?? lineHashes(content);
295
+ const fileHashes = precomputedHashes ?? _lineHashesPure(content);
296
296
  const noopEdits: NEdit[] = [];
297
297
  const warnings: string[] = [];
298
298
 
@@ -324,37 +324,13 @@ export function applyEdits(
324
324
  assertNotEmpty(content, result);
325
325
  const range = changedRange(content, result);
326
326
 
327
- const resultLines = result.split("\n");
328
- const resultHashes = lineHashes(result);
329
- for (const bw of boundaryWarnings) {
330
- let seen = 0;
331
- let matchIndex = -1;
332
- for (let i = 0; i < resultLines.length; i++) {
333
- if (resultLines[i] === bw.survivingLineContent) {
334
- if (seen === bw.occurrence) { matchIndex = i; break; }
335
- seen++;
336
- }
337
- }
338
- if (matchIndex >= 0) {
339
- warnings.push(
340
- fmtBoundaryWarning({
341
- kind: bw.kind,
342
- survivingContent: bw.survivingLineContent,
343
- matchIndex,
344
- resultLines,
345
- resultHashes,
346
- }),
347
- );
348
- }
349
- }
350
-
351
327
  return {
352
328
  content: result,
353
329
  firstChangedLine: range?.firstChangedLine,
354
330
  lastChangedLine: range?.lastChangedLine,
355
- resultHashes,
356
331
  ...(warnings.length ? { warnings } : {}),
357
332
  ...(noopEdits.length ? { noopEdits } : {}),
333
+ ...(boundaryWarnings.length ? { boundaryWarnings } : {}),
358
334
  };
359
335
  }
360
336
 
@@ -1,4 +1,6 @@
1
1
  import xxhash from "xxhash-wasm";
2
+ import * as Diff from "diff";
3
+ import { loadHashStore, saveHashStore } from "../hash-store";
2
4
 
3
5
  export const HASH_LEN = 3;
4
6
  export const ANCHOR_LEN = HASH_LEN;
@@ -73,8 +75,8 @@ function canon(line: string): string {
73
75
  }
74
76
 
75
77
  /**
76
- * Compute a unique 3-character hash for every line of `content`.
77
- *
78
+ * Pure hash computation no I/O, no persistence. Used internally by the
79
+ * stable path and as a fallback when no file path is available.
78
80
  * Each line is hashed with xxHash32 over its canonical form (trailing
79
81
  * whitespace and CR characters stripped). If the base hash collides with a
80
82
  * hash already assigned to an earlier line, a retry counter (`:R{retry}`)
@@ -83,7 +85,7 @@ function canon(line: string): string {
83
85
  * in a file receives a distinct anchor, even when multiple lines contain
84
86
  * identical text (e.g. repeated `}` or `import` statements).
85
87
  */
86
- export function lineHashes(content: string): string[] {
88
+ export function _lineHashesPure(content: string): string[] {
87
89
  const lines = content.split("\n");
88
90
  const hashes = new Array<string>(lines.length);
89
91
  const assigned = new Set<string>();
@@ -101,5 +103,113 @@ export function lineHashes(content: string): string[] {
101
103
  return hashes;
102
104
  }
103
105
 
106
+ /**
107
+ * Stable, persistent-aware hash computation.
108
+ *
109
+ * When `path` is provided, uses the persistent hash store to preserve
110
+ * hashes for unchanged lines across edits. When `previous` is also
111
+ * provided (called from the replace pipeline), diffs the previous content
112
+ * against the new content and copies hashes for unchanged lines to their
113
+ * new positions. New/changed lines allocate fresh hashes with collision
114
+ * avoidance against the preserved set.
115
+ *
116
+ * When `path` is provided without `previous` (called from read), loads
117
+ * the stored snapshot for that path. If the content matches, returns the
118
+ * saved hashes. Otherwise computes fresh hashes via `_lineHashesPure` and
119
+ * saves a new snapshot.
120
+ *
121
+ * When `path` is not provided, falls back to `_lineHashesPure` (for
122
+ * backward compatibility and tests that don't need persistence).
123
+ */
124
+ export async function lineHashes(
125
+ content: string,
126
+ path?: string,
127
+ previous?: { content: string; hashes: string[] },
128
+ ): Promise<string[]> {
129
+ if (!path) {
130
+ return _lineHashesPure(content);
131
+ }
132
+
133
+ const store = await loadHashStore();
134
+
135
+ // Case 1: previous content provided (replace pipeline) — diff and preserve
136
+ if (previous) {
137
+ const newHashes = mapStableHashes(
138
+ previous.content, previous.hashes,
139
+ content,
140
+ );
141
+ store.snapshots[path] = { content, hashes: newHashes };
142
+ await saveHashStore(store);
143
+ return newHashes;
144
+ }
145
+
146
+ // Case 2: no previous — check snapshot or compute fresh
147
+ const snapshot = store.snapshots[path];
148
+ if (snapshot && snapshot.content === content) {
149
+ return snapshot.hashes;
150
+ }
151
+
152
+ // Compute fresh hashes
153
+ const newHashes = _lineHashesPure(content);
154
+ store.snapshots[path] = { content, hashes: newHashes };
155
+ await saveHashStore(store);
156
+ return newHashes;
157
+ }
158
+
159
+ /**
160
+ * Diff old content against new content, preserving hashes for unchanged
161
+ * lines. New/changed lines allocate fresh hashes with collision avoidance.
162
+ */
163
+ function mapStableHashes(
164
+ oldContent: string,
165
+ oldHashes: string[],
166
+ newContent: string,
167
+ ): string[] {
168
+ const newLines = newContent.split("\n");
169
+ const newHashes = new Array<string>(newLines.length);
170
+ const used = new Set<string>();
171
+
172
+ const parts = Diff.diffLines(oldContent, newContent);
173
+ let oldIdx = 0;
174
+ let newIdx = 0;
175
+
176
+ for (const part of parts) {
177
+ const raw = part.value.split("\n");
178
+ if (raw[raw.length - 1] === "") raw.pop();
179
+ const count = raw.length;
180
+ if (count === 0) continue;
181
+
182
+ if (part.added) {
183
+ newIdx += count;
184
+ } else if (part.removed) {
185
+ oldIdx += count;
186
+ } else {
187
+ // unchanged — copy hashes
188
+ for (let k = 0; k < count; k++) {
189
+ const h = oldHashes[oldIdx]!;
190
+ newHashes[newIdx] = h;
191
+ used.add(h);
192
+ oldIdx++;
193
+ newIdx++;
194
+ }
195
+ }
196
+ }
197
+
198
+ // Fill remaining (new/changed) lines with fresh hashes
199
+ for (let i = 0; i < newLines.length; i++) {
200
+ if (newHashes[i]) continue;
201
+ const c = canon(newLines[i]!);
202
+ let retry = 0;
203
+ let hash = h2s(xxh32(c));
204
+ while (used.has(hash)) {
205
+ retry++;
206
+ hash = h2s(xxh32(`${c}:R${retry}`));
207
+ }
208
+ used.add(hash);
209
+ newHashes[i] = hash;
210
+ }
211
+
212
+ return newHashes;
213
+ }
104
214
 
105
215
  export { ALPH_RE };
@@ -8,6 +8,7 @@ export {
8
8
  DIFF_MINUS_RE,
9
9
  HL_BARE_PREFIX_RE,
10
10
  lineHashes,
11
+ _lineHashesPure,
11
12
  initHasher,
12
13
  } from "./hash";
13
14
 
package/src/read.ts CHANGED
@@ -57,26 +57,26 @@ export function formatPaginationHint(
57
57
  return `[Showing lines ${startLine}-${endLine} of ${totalLines}${sizeSuffix}. Use offset=${nextOffset} to continue.]`;
58
58
  }
59
59
 
60
- export function fmtReadPreview(
60
+ export async function fmtReadPreview(
61
61
  text: string,
62
62
  options: { offset?: number; limit?: number },
63
63
  precomputedHashes?: string[],
64
- ): { text: string; truncation?: TruncationResult; nextOffset?: number } {
64
+ path?: string,
65
+ ): Promise<{ text: string; truncation?: TruncationResult; nextOffset?: number }> {
65
66
  const allLines = visLines(text);
66
67
  const totalLines = allLines.length;
67
68
  const startLine = normPosInt(options.offset, "offset") ?? 1;
68
69
  if (totalLines === 0) {
69
70
  if (startLine === 1) {
70
- const allHashes = precomputedHashes ?? lineHashes(text);
71
- const emptyLineHash = allHashes[0] ?? "";
72
- return {
71
+ const allHashes = precomputedHashes ?? await (path ? lineHashes(text, path) : lineHashes(text));
72
+ const emptyLineHash = allHashes[0] ?? "";
73
+ return {
73
74
  text: `${emptyLineHash}${HASH_SEP}\n[File is empty. Use replace to insert content.]`,
74
75
  };
75
76
  }
76
77
  return {
77
78
  text: `Offset ${startLine} is beyond end of file (0 lines total). The file is empty. Use replace to insert content.`,
78
79
  };
79
-
80
80
  }
81
81
  if (startLine > totalLines) {
82
82
  return {
@@ -89,7 +89,7 @@ export function fmtReadPreview(
89
89
  ? Math.min(startLine - 1 + limit, totalLines)
90
90
  : totalLines;
91
91
  const selected = allLines.slice(startLine - 1, endIdx);
92
- const allHashes = precomputedHashes ?? lineHashes(text);
92
+ const allHashes = precomputedHashes ?? await (path ? lineHashes(text, path) : lineHashes(text));
93
93
  const selectedHashes = allHashes.slice(startLine - 1, endIdx);
94
94
  const formatted = fmtRegion(selectedHashes, selected);
95
95
 
@@ -171,13 +171,14 @@ export function regRead(pi: ExtensionAPI): void {
171
171
  const { normalized, fileHashes, hadUtf8DecodeErrors } = await readNormFile(
172
172
  rawPath, ctx.cwd, signal, undefined, file,
173
173
  );
174
- const preview = fmtReadPreview(
174
+ const preview = await fmtReadPreview(
175
175
  normalized,
176
176
  {
177
177
  offset: params.offset,
178
178
  limit: params.limit,
179
179
  },
180
180
  fileHashes,
181
+ absolutePath,
181
182
  );
182
183
  const snapshot = await fileSnap(absolutePath);
183
184
 
@@ -1,6 +1,6 @@
1
1
  import * as Diff from "diff";
2
2
  import {
3
- lineHashes,
3
+ _lineHashesPure,
4
4
  ANCHOR_LEN,
5
5
  HASH_SEP,
6
6
  } from "./hashline";
@@ -48,8 +48,7 @@ export function genDiff(
48
48
  ): { diff: string; firstChangedLine: number | undefined } {
49
49
  const parts = Diff.diffLines(oldContent, newContent);
50
50
  const output: string[] = [];
51
- const effectiveNewHashes = newContentHashes ?? lineHashes(newContent);
52
-
51
+ const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
53
52
  let oldLineNum = 1;
54
53
  let newLineNum = 1;
55
54
  let lastWasChange = false;
@@ -17,7 +17,6 @@ import { MAX_HASH_LINES } from "./constants";
17
17
  import { resolveTarget, writeAtomic } from "./fs-write";
18
18
  import {
19
19
  applyEdits,
20
- lineHashes,
21
20
  resEdits,
22
21
  type HTEdit,
23
22
  } from "./hashline";
@@ -1,8 +1,5 @@
1
1
  import type { ReplaceDetails } from "./replace";
2
2
  import { genDiff } from "./replace-diff";
3
- import {
4
- lineHashes,
5
- } from "./hashline";
6
3
  import { visLines } from "./utils";
7
4
 
8
5
  type TResult = {
@@ -46,7 +43,7 @@ export interface SuccessInput {
46
43
  path: string;
47
44
  originalNormalized: string;
48
45
  result: string;
49
- resultHashes?: string[];
46
+ resultHashes: string[];
50
47
  warnings: string[] | undefined;
51
48
  snapshotId: string;
52
49
  editMeta: RMeta;
@@ -142,10 +139,9 @@ export function buildNoop(input: NoopInput): TResult {
142
139
  }
143
140
 
144
141
  export function buildChanged(input: SuccessInput): TResult {
145
- const { path, result, warnings, snapshotId, originalNormalized, editMeta } = input;
142
+ const { path, result, warnings, snapshotId, originalNormalized, editMeta, resultHashes } = input;
146
143
 
147
144
  const resultLines = visLines(result);
148
- const resultHashes = input.resultHashes ?? lineHashes(result);
149
145
  const diffResult = genDiff(originalNormalized, result, 2, resultHashes);
150
146
  const addedLines = cntDiff(diffResult.diff, "+");
151
147
  const removedLines = cntDiff(diffResult.diff, "-");
package/src/replace.ts CHANGED
@@ -18,6 +18,7 @@ import { resolveTarget, writeAtomic } from "./fs-write";
18
18
  import {
19
19
  applyEdits,
20
20
  lineHashes,
21
+ fmtBoundaryWarning,
21
22
  resEdits,
22
23
  type HTEdit,
23
24
  } from "./hashline";
@@ -101,7 +102,7 @@ interface PipelineResult {
101
102
  firstChangedLine?: number;
102
103
  lastChangedLine?: number;
103
104
  originalHashes?: string[];
104
- resultHashes?: string[];
105
+ resultHashes: string[];
105
106
  }
106
107
 
107
108
  const ROOT_KS = new Set(["path", "changes"]);
@@ -152,6 +153,7 @@ export async function execPipeline(
152
153
  path, cwd, signal, accessMode, undefined, MAX_HASH_LINES,
153
154
  );
154
155
 
156
+ const absolutePath = toCwd(path, cwd);
155
157
  const resolved = resEdits(toolEdits);
156
158
  const anchorResult = applyEdits(
157
159
  originalNormalized,
@@ -161,19 +163,52 @@ export async function execPipeline(
161
163
  path,
162
164
  );
163
165
 
166
+ const result = anchorResult.content;
167
+
168
+ // Compute stable result hashes using diff-based preservation
169
+ const resultHashes = await lineHashes(result, absolutePath, {
170
+ content: originalNormalized,
171
+ hashes: originalHashes,
172
+ });
173
+
174
+ // Format boundary warnings using stable result hashes
175
+ const resultLines = result.split("\n");
176
+ const warnings = [...(anchorResult.warnings ?? [])];
177
+ for (const bw of anchorResult.boundaryWarnings ?? []) {
178
+ let seen = 0;
179
+ let matchIndex = -1;
180
+ for (let i = 0; i < resultLines.length; i++) {
181
+ if (resultLines[i] === bw.survivingLineContent) {
182
+ if (seen === bw.occurrence) { matchIndex = i; break; }
183
+ seen++;
184
+ }
185
+ }
186
+ if (matchIndex >= 0) {
187
+ warnings.push(
188
+ fmtBoundaryWarning({
189
+ kind: bw.kind,
190
+ survivingContent: bw.survivingLineContent,
191
+ matchIndex,
192
+ resultLines,
193
+ resultHashes,
194
+ }),
195
+ );
196
+ }
197
+ }
198
+
164
199
  return {
165
200
  path,
166
201
  toolEdits,
167
202
  originalNormalized,
168
- result: anchorResult.content,
203
+ result,
169
204
  bom,
170
205
  originalEnding,
171
206
  hadUtf8DecodeErrors,
172
- warnings: [...(anchorResult.warnings ?? [])],
207
+ warnings,
173
208
  noopEdits: anchorResult.noopEdits,
174
209
  firstChangedLine: anchorResult.firstChangedLine,
175
210
  lastChangedLine: anchorResult.lastChangedLine,
176
- resultHashes: anchorResult.resultHashes,
211
+ resultHashes,
177
212
  originalHashes,
178
213
  };
179
214
  }