@d3ara1n/pi-hashline-edit 0.1.1 → 0.2.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
@@ -2,14 +2,44 @@
2
2
 
3
3
  > Hashline-style file editing for [pi](https://github.com/earendil-works/pi-coding-agent) — line-anchored edits verified by content hash, replacing `oldText`/`newText` matching.
4
4
 
5
- Edits reference lines by `LINE#HASH` anchors (copied from `read` output) instead of retyping the code to be changed — eliminating string-not-found loops and whitespace battles at the root.
5
+ Edits reference lines by `LINE#HASH` anchors (copied from `read`/`grep` output) instead of retyping the code to be changed — eliminating string-not-found loops and whitespace battles at the root.
6
+
7
+ ## Why hashline?
8
+
9
+ The built-in `edit` matches `oldText`/`newText` exactly. When the model can't reproduce the source verbatim — wrong indentation, a non-unique snippet, or a line that drifted since the read — the edit fails and you loop. Hashline sidesteps all of it:
10
+
11
+ - **No string-not-found loops** — you edit by reference (`LINE#HASH`), not by retyping the line you want to change.
12
+ - **No whitespace battles** — the new content is the only thing you type; nothing has to match what's already there. Indentation mistakes on the *old* code are impossible.
13
+ - **Unique-by-construction hashes** — the line number is folded into the hash, so identical lines (blank lines, `}`) never share a hash and never collide.
14
+ - **Chain edits without re-reading** — a successful `edit` returns fresh anchors for the lines it produced, so the next edit cites them directly instead of forcing a full re-read.
15
+ - **Grep-to-edit, no detour** — search results carry the same `LINE#HASH` anchors (grouped by file, context lines included); grab one and edit directly, skipping the read you'd otherwise need.
16
+ - **Surgical drift detection** — each cited anchor is rechecked against the current line only; an unrelated change elsewhere never blocks your edit.
17
+ - **Self-healing stale anchors** — a drifted anchor (content shifted by an edit above it) doesn't force a full re-read: the applicator rescans ±lines for the original content and hands back a fresh `LINE#HASH` to retry with, only asking for a re-read when the content genuinely changed.
18
+
19
+ ## When to use it
20
+
21
+ Routine local code editing in pi — the common case. If you spend turns fighting "old_string not found" or fixing indentation the model dropped, this is the fix.
22
+
23
+ ## When to turn it off
24
+
25
+ Set `hashlineEdit.enabled = false` (or uninstall) to fall back to the built-in `read`/`edit`/`grep` when you need **remote or custom-storage files** — the overrides read/write/search the local filesystem directly, so pi's custom `ReadOperations`/`GrepOperations` (SSH, etc.) aren't supported. The same switch lets you opt out per-project.
26
+
27
+ ## Gotchas (vs. the built-in `read`/`edit`)
28
+
29
+ Once hashline overrides the built-ins, a few things behave differently:
30
+
31
+ - **`read` is globally overridden.** Every read shows the `LINE#HASH│` prefix on each line — even reads that won't lead to an edit. This is expected (it's the substrate the reliability is built on), just don't be surprised when the format changes for all files.
32
+ - **Conservative overlap.** Two ops whose ranges touch (e.g. `insert_after` immediately followed by `replace` at the same line) are rejected to avoid backfill ambiguity — issue them as two separate `edit` calls.
6
33
 
7
34
  ## Design
8
35
 
9
- - **Per-line hash + line number, dual anchor**: `read` shows each line with a short hash (`3#aF3│code`); `edit` references `LINE#HASH`. The line number is for humans, the hash for the machine naturally resilient to line drift.
10
- - **Context-aware hash**: each line's hash incorporates its neighbors, so identical lines (blank lines, `}`) get different hashes when their context differs — in-file collisions approach zero.
11
- - **Strict core + pluggable tolerance**: the core `parse apply` makes zero guesses and fails fast; tolerance (boundary repair, drift relocation, block ops) is layered as independently toggleable middleware.
12
- - **No legacy compatibility**: overrides the built-in `edit`/`read`. `edit` accepts only the hashline `input`; sending legacy `oldText`/`newText` returns an explicit error (never silently degrades) — so you always know whether hashline is actually in use.
36
+ - **Per-line hash + line number, dual anchor**: `read` shows each line as `3#aF3│code`; `edit` references `LINE#HASH`. The line number is the address; the hash is a checksum that the line at that address is still what was read.
37
+ - **Line folded into the hash**: each line's hash mixes its 1-based line number into its content, so every line is unique by constructionno in-file collisions, no length extension. The hash changes only when the line's own content changes, never when a neighbor changes.
38
+ - **Live, surgical verification**: at apply time each cited anchor's hash is recomputed from the current line content and compared no stored snapshot, no whole-file stale check. A line that changed (or was misremembered) fails its own anchor; an unrelated change elsewhere never blocks the edit. No fuzzy matching, no boundary repair.
39
+ - **Shifted-anchor recovery**: a mismatched anchor isn't a dead end. The applicator rescans ±`shiftRadius` lines for the original content — holding the original line number fixed and re-hashing each candidate (`hash(line, candidate) === cited` iff the candidate *is* the original) — and returns a ready-to-resend anchor on a unique hit, the candidate list when ambiguous, or the cited line's live content when nothing matches. The model retries without a re-read in the common drift case.
40
+ - **Atomic batches, all failures collected**: every op in one `edit` is verified against the same snapshot; if any anchor fails, *all* failures (each with its recovery) are returned together and nothing is written — partial writes would shift lines and invalidate the very recovery info just returned.
41
+ - **Chain edits without re-reading**: a successful `edit` returns `Updated anchors` for the lines it produced (and the line that shifted into a deletion gap), so the next edit can cite them directly.
42
+ - **No legacy compatibility**: `edit` accepts only structured hashline ops; sending legacy `oldText`/`newText` is rejected at the schema layer (never silently degrades) — so you always know whether hashline is actually in use.
13
43
 
14
44
  ## Protocol
15
45
 
@@ -17,23 +47,49 @@ Edits reference lines by `LINE#HASH` anchors (copied from `read` output) instead
17
47
 
18
48
  ```
19
49
  src/foo.ts · 6 lines
20
- 1#aF3│import { compute } from "./util"
21
- 2#7Qk│
22
- 3#mP0│export function foo(x: number) {
50
+ 1#aF3│import { compute } from "./util"
51
+ 2#7Qk│
52
+ 3#mP0│export function foo(x: number) {
23
53
  ```
24
54
 
25
- `edit` `input` `path` is a tool parameter; `input` holds only ops (no `file:` header, the tool injects it):
55
+ `grep` output (results grouped by file, each line anchored copy `LINE#HASH` straight into an edit):
26
56
 
27
57
  ```
28
- replace 4#kLp:
29
- + if (x < 0) throw new Error("neg")
58
+ src/foo.ts · 2 matches
59
+ 3#mP0│export function foo(x: number) {
60
+ 4#kLp│ return x + 1
61
+ src/util.ts · 1 match
62
+ 10#aF3│ const z = compute(x)
63
+ ```
64
+
30
65
 
31
- insert_after 6#b2H:
32
- +
33
- +export const bar = foo
66
+ `edit` takes `path` + `edits` (an array of ops, each with `op`, `anchor`/`end` `{line, hash}` from read, and `body` string[]):
67
+
68
+ ```jsonc
69
+ {
70
+ "path": "src/foo.ts",
71
+ "edits": [
72
+ { "op": "replace", "anchor": { "line": 4, "hash": "kLp" }, "body": [" return x + 1"] },
73
+ { "op": "insert_after", "anchor": { "line": 6, "hash": "b2H" }, "body": ["", "export const bar = foo"] }
74
+ ]
75
+ }
34
76
  ```
35
77
 
36
- Ops: `replace LINE#HASH[..LINE#HASH]:` · `delete LINE#HASH` · `insert_after LINE#HASH:` · `insert_before LINE#HASH:` · `append:` · `prepend:`. Body rows start with `+`; `+` alone is a blank line; literal `+`/`-` lines become `++`/`+-`.
78
+ Ops: `replace` · `delete` · `insert_after` · `insert_before` · `append` · `prepend`. `anchor`/`end` = `{line, hash}` from read; `body` = new content lines (string[], omit for `delete`).
79
+
80
+ ## Configuration
81
+
82
+ Add a `hashlineEdit` field to `~/.pi/agent/settings.json` (global) or `.pi/settings.json` in a project (project replaces global):
83
+
84
+ ```jsonc
85
+ {
86
+ "hashlineEdit": {
87
+ "enabled": true, // set false to fall back to the built-in read/edit
88
+ "hashLen": 4, // hash length, 2–8 (default 4)
89
+ "shiftRadius": 15 // ±lines scanned to rescue a stale anchor (default 15; 0 disables)
90
+ }
91
+ }
92
+ ```
37
93
 
38
94
  ## Installation
39
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-hashline-edit",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Hashline-style file editing for pi — line-anchored edits verified by content hash, replacing oldText/newText matching",
6
6
  "keywords": [
@@ -1,185 +1,264 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { createSnapshot } from "./snapshot.ts";
3
+ import { computeLineHash } from "./hash.ts";
4
+ import { splitLines } from "./lines.ts";
4
5
  import { applyEdits } from "./apply.ts";
5
- import type { Edit, FileSnapshot } from "./types.ts";
6
+ import type { Anchor } from "./types.ts";
6
7
 
7
- const snap = (text: string): FileSnapshot => createSnapshot("f.ts", text);
8
- const ln = (s: FileSnapshot, line: number) => ({ line, hash: s.lineHashes[line - 1] });
8
+ /** Live anchor: hash the current content at the given line (1-based). */
9
+ function at(text: string, line: number): Anchor {
10
+ return { line, hash: computeLineHash(line, splitLines(text)[line - 1]) };
11
+ }
9
12
 
10
13
  // --- happy paths ---
11
14
 
12
15
  test("replace single line", () => {
13
16
  const text = "a\nb\nc\n";
14
- const s = snap(text);
15
- const r = applyEdits(text, [{ op: "replace", start: ln(s, 2), body: ["B"] }], s);
17
+ const r = applyEdits(text, [{ op: "replace", start: at(text, 2), body: ["B"] }]);
16
18
  assert.equal(r.ok, true);
17
19
  if (r.ok) assert.equal(r.text, "a\nB\nc\n");
18
20
  });
19
21
 
20
22
  test("replace range", () => {
21
23
  const text = "a\nb\nc\nd\ne\n";
22
- const s = snap(text);
23
- const r = applyEdits(
24
- text,
25
- [{ op: "replace", start: ln(s, 2), end: ln(s, 4), body: ["X", "Y"] }],
26
- s,
27
- );
24
+ const r = applyEdits(text, [{ op: "replace", start: at(text, 2), end: at(text, 4), body: ["X", "Y"] }]);
28
25
  assert.equal(r.ok, true);
29
26
  if (r.ok) assert.equal(r.text, "a\nX\nY\ne\n");
30
27
  });
31
28
 
32
29
  test("delete single line / range", () => {
33
30
  const text = "a\nb\nc\nd\n";
34
- const s = snap(text);
35
- const r = applyEdits(text, [{ op: "delete", start: ln(s, 2), end: ln(s, 3) }], s);
31
+ const r = applyEdits(text, [{ op: "delete", start: at(text, 2), end: at(text, 3) }]);
36
32
  assert.equal(r.ok, true);
37
33
  if (r.ok) assert.equal(r.text, "a\nd\n");
38
34
  });
39
35
 
40
36
  test("insert_after", () => {
41
37
  const text = "a\nb\n";
42
- const s = snap(text);
43
- const r = applyEdits(text, [{ op: "insert_after", anchor: ln(s, 1), body: ["x"] }], s);
38
+ const r = applyEdits(text, [{ op: "insert_after", anchor: at(text, 1), body: ["x"] }]);
44
39
  assert.equal(r.ok, true);
45
40
  if (r.ok) assert.equal(r.text, "a\nx\nb\n");
46
41
  });
47
42
 
48
43
  test("insert_before", () => {
49
44
  const text = "a\nb\n";
50
- const s = snap(text);
51
- const r = applyEdits(text, [{ op: "insert_before", anchor: ln(s, 2), body: ["x"] }], s);
45
+ const r = applyEdits(text, [{ op: "insert_before", anchor: at(text, 2), body: ["x"] }]);
52
46
  assert.equal(r.ok, true);
53
47
  if (r.ok) assert.equal(r.text, "a\nx\nb\n");
54
48
  });
55
49
 
56
50
  test("append / prepend", () => {
57
51
  const text = "a\nb\n";
58
- const s = snap(text);
59
- const r = applyEdits(
60
- text,
61
- [
62
- { op: "prepend", body: ["head"] },
63
- { op: "append", body: ["tail"] },
64
- ],
65
- s,
66
- );
52
+ const r = applyEdits(text, [
53
+ { op: "prepend", body: ["head"] },
54
+ { op: "append", body: ["tail"] },
55
+ ]);
67
56
  assert.equal(r.ok, true);
68
57
  if (r.ok) assert.equal(r.text, "head\na\nb\ntail\n");
69
58
  });
70
59
 
71
60
  test("multiple out-of-order ops → applied at the right positions", () => {
72
61
  const text = "a\nb\nc\n";
73
- const s = snap(text);
74
- const r = applyEdits(
75
- text,
76
- [
77
- { op: "insert_after", anchor: ln(s, 3), body: ["z"] },
78
- { op: "replace", start: ln(s, 1), body: ["A"] },
79
- ],
80
- s,
81
- );
62
+ const r = applyEdits(text, [
63
+ { op: "insert_after", anchor: at(text, 3), body: ["z"] },
64
+ { op: "replace", start: at(text, 1), body: ["A"] },
65
+ ]);
82
66
  assert.equal(r.ok, true);
83
67
  if (r.ok) assert.equal(r.text, "A\nb\nc\nz\n");
84
68
  });
85
69
 
86
- test("result carries diff and newSnapshot", () => {
87
- const text = "a\nb\n";
88
- const s = snap(text);
89
- const r = applyEdits(text, [{ op: "replace", start: ln(s, 1), body: ["A"] }], s);
70
+ test("touchedLines covers exactly the lines this edit produced (new-file indices)", () => {
71
+ const text = "a\nb\nc\n";
72
+ const r = applyEdits(text, [
73
+ { op: "insert_after", anchor: at(text, 3), body: ["z"] },
74
+ { op: "replace", start: at(text, 1), body: ["A"] },
75
+ ]);
90
76
  assert.equal(r.ok, true);
91
77
  if (r.ok) {
92
- assert.ok(r.diff.includes("@@"));
93
- assert.equal(r.newSnapshot.text, "A\nb\n");
94
- assert.equal(r.newSnapshot.lineHashes.length, 2);
78
+ // new file: [A, b, c, z]; this edit produced line index 0 (A) and 3 (z)
79
+ assert.deepEqual([...r.touchedLines], [0, 3]);
95
80
  }
96
81
  });
97
82
 
98
- test("closed loop: newSnapshot can be used for the next edit", () => {
99
- let s = snap("a\nb\n");
100
- let cur = s.text;
101
- const r1 = applyEdits(cur, [{ op: "replace", start: ln(s, 1), body: ["A"] }], s);
102
- assert.equal(r1.ok, true);
103
- if (r1.ok) {
104
- cur = r1.text;
105
- s = r1.newSnapshot;
106
- const r2 = applyEdits(cur, [{ op: "replace", start: ln(s, 2), body: ["B"] }], s);
107
- assert.equal(r2.ok, true);
108
- if (r2.ok) assert.equal(r2.text, "A\nB\n");
83
+ test("touchedLines for append", () => {
84
+ const text = "a\n";
85
+ const r = applyEdits(text, [{ op: "append", body: ["b", "c"] }]);
86
+ assert.equal(r.ok, true);
87
+ if (r.ok) assert.deepEqual([...r.touchedLines], [1, 2]);
88
+ });
89
+
90
+ test("touchedLines for delete re-anchors the line that shifted into the gap", () => {
91
+ const text = "a\nb\nc\nd\n";
92
+ const r = applyEdits(text, [{ op: "delete", start: at(text, 2) }]);
93
+ assert.equal(r.ok, true);
94
+ if (r.ok) {
95
+ // new file: [a, c, d]; deleting line 2 (b) shifts c into index 1
96
+ assert.deepEqual([...r.touchedLines], [1]);
109
97
  }
110
98
  });
111
99
 
112
100
  // --- error paths ---
113
101
 
114
- test("stale (file changed) rejected", () => {
115
- const s = snap("a\nb\n");
116
- const r = applyEdits("a\nCHANGED\n", [{ op: "replace", start: ln(s, 1), body: ["x"] }], s);
117
- assert.equal(r.ok, false);
118
- if (!r.ok) assert.equal(r.error.kind, "stale");
119
- });
120
-
121
- test("anchor hash mismatch rejected (guards against misremembering)", () => {
102
+ test("anchor hash mismatch rejected (line changed)", () => {
122
103
  const text = "a\nb\n";
123
- const s = snap(text);
124
- const r = applyEdits(text, [{ op: "replace", start: { line: 1, hash: "WRONG" }, body: ["x"] }], s);
104
+ const r = applyEdits(text, [{ op: "replace", start: { line: 1, hash: "WRONG" }, body: ["x"] }]);
125
105
  assert.equal(r.ok, false);
126
- if (!r.ok) assert.equal(r.error.kind, "anchor");
106
+ if (!r.ok) assert.equal(r.failure.kind, "anchor");
127
107
  });
128
108
 
129
109
  test("line out of range rejected", () => {
130
110
  const text = "a\n";
131
- const s = snap(text);
132
- const r = applyEdits(text, [{ op: "replace", start: { line: 5, hash: s.lineHashes[0] }, body: ["x"] }], s);
111
+ const r = applyEdits(text, [{ op: "replace", start: { line: 5, hash: computeLineHash(1, "a") }, body: ["x"] }]);
133
112
  assert.equal(r.ok, false);
134
- if (!r.ok) assert.equal(r.error.kind, "anchor");
113
+ if (!r.ok) assert.equal(r.failure.kind, "anchor");
114
+ });
115
+
116
+ test("anchor mismatch when the cited line's content differs (live verification)", () => {
117
+ // hash for line 2's content, but cited as line 1 → must fail because line 1's live content differs
118
+ const text = "a\nb\n";
119
+ const r = applyEdits(text, [{ op: "replace", start: { line: 1, hash: computeLineHash(2, "b") }, body: ["x"] }]);
120
+ assert.equal(r.ok, false);
121
+ if (!r.ok) assert.equal(r.failure.kind, "anchor");
135
122
  });
136
123
 
137
124
  test("reverse-order range rejected", () => {
138
125
  const text = "a\nb\nc\n";
139
- const s = snap(text);
140
- const r = applyEdits(
141
- text,
142
- [{ op: "replace", start: ln(s, 3), end: ln(s, 1), body: ["x"] }],
143
- s,
144
- );
126
+ const r = applyEdits(text, [{ op: "replace", start: at(text, 3), end: at(text, 1), body: ["x"] }]);
145
127
  assert.equal(r.ok, false);
146
- if (!r.ok) assert.equal(r.error.kind, "range");
128
+ if (!r.ok) assert.equal(r.failure.kind, "range");
147
129
  });
148
130
 
149
131
  test("overlapping edits rejected", () => {
150
132
  const text = "a\nb\nc\nd\n";
151
- const s = snap(text);
152
- const r = applyEdits(
153
- text,
154
- [
155
- { op: "replace", start: ln(s, 2), end: ln(s, 3), body: ["x"] },
156
- { op: "replace", start: ln(s, 3), body: ["y"] },
157
- ],
158
- s,
159
- );
133
+ const r = applyEdits(text, [
134
+ { op: "replace", start: at(text, 2), end: at(text, 3), body: ["x"] },
135
+ { op: "replace", start: at(text, 3), body: ["y"] },
136
+ ]);
160
137
  assert.equal(r.ok, false);
161
- if (!r.ok) assert.equal(r.error.kind, "range");
138
+ if (!r.ok) assert.equal(r.failure.kind, "range");
162
139
  });
163
140
 
164
141
  test("conflict at the same insertion point rejected", () => {
165
142
  const text = "a\nb\n";
166
- const s = snap(text);
167
- const r = applyEdits(
168
- text,
169
- [
170
- { op: "insert_after", anchor: ln(s, 1), body: ["x"] },
171
- { op: "insert_after", anchor: ln(s, 1), body: ["y"] },
172
- ],
173
- s,
174
- );
143
+ const r = applyEdits(text, [
144
+ { op: "insert_after", anchor: at(text, 1), body: ["x"] },
145
+ { op: "insert_after", anchor: at(text, 1), body: ["y"] },
146
+ ]);
175
147
  assert.equal(r.ok, false);
176
- if (!r.ok) assert.equal(r.error.kind, "range");
148
+ if (!r.ok) assert.equal(r.failure.kind, "range");
177
149
  });
178
150
 
179
151
  test("noop (byte-identical body) rejected", () => {
180
152
  const text = "a\nb\n";
181
- const s = snap(text);
182
- const r = applyEdits(text, [{ op: "replace", start: ln(s, 1), body: ["a"] }], s);
153
+ const r = applyEdits(text, [{ op: "replace", start: at(text, 1), body: ["a"] }]);
154
+ assert.equal(r.ok, false);
155
+ if (!r.ok) assert.equal(r.failure.kind, "noop");
156
+ });
157
+
158
+ test("unrelated change elsewhere does NOT block the edit (no global stale check)", () => {
159
+ // read-time text and current text differ at line 3, but we only touch line 1 — must succeed
160
+ const readText = "a\nb\nc\n";
161
+ const currentText = "a\nb\nCHANGED\n";
162
+ const r = applyEdits(currentText, [{ op: "replace", start: at(readText, 1), body: ["A"] }]);
163
+ assert.equal(r.ok, true);
164
+ if (r.ok) assert.equal(r.text, "A\nb\nCHANGED\n");
165
+ });
166
+
167
+ test("CRLF line endings preserved", () => {
168
+ const text = "a\r\nb\r\n";
169
+ const r = applyEdits(text, [{ op: "replace", start: at(text, 1), body: ["A"] }]);
170
+ assert.equal(r.ok, true);
171
+ if (r.ok) assert.equal(r.text, "A\r\nb\r\n");
172
+ });
173
+
174
+ // --- shifted-anchor recovery ---
175
+
176
+ test("shifted recovery: content moved down → found with a fresh anchor", () => {
177
+ const readText = "a\nb\nc\nd\ne\n";
178
+ const currentText = "a\nX\nb\nc\nd\ne\n"; // inserted X after line 1 → "c" moved 3→4
179
+ const r = applyEdits(currentText, [{ op: "replace", start: at(readText, 3), body: ["C"] }]);
180
+ assert.equal(r.ok, false);
181
+ if (!r.ok && r.failure.kind === "anchor") {
182
+ const f = r.failure.failures[0];
183
+ assert.equal(f.recovery.kind, "found");
184
+ if (f.recovery.kind === "found") {
185
+ assert.equal(f.recovery.newLine, 4);
186
+ // the rescued anchor must verify against the current file
187
+ assert.equal(computeLineHash(4, splitLines(currentText)[3]), f.recovery.newHash);
188
+ }
189
+ }
190
+ });
191
+
192
+ test("rescued anchor lets the retry succeed without a re-read", () => {
193
+ const readText = "a\nb\nc\nd\ne\n";
194
+ const currentText = "a\nX\nb\nc\nd\ne\n";
195
+ // first attempt with a stale anchor → rescued
196
+ const r1 = applyEdits(currentText, [{ op: "replace", start: at(readText, 3), body: ["C"] }]);
197
+ assert.equal(r1.ok, false);
198
+ if (r1.ok) return;
199
+ if (r1.failure.kind !== "anchor") return;
200
+ const f = r1.failure.failures[0];
201
+ assert.equal(f.recovery.kind, "found");
202
+ if (f.recovery.kind !== "found") return;
203
+ // retry with the rescued anchor → succeeds, file unchanged elsewhere
204
+ const r2 = applyEdits(currentText, [
205
+ { op: "replace", start: { line: f.recovery.newLine, hash: f.recovery.newHash }, body: ["C"] },
206
+ ]);
207
+ assert.equal(r2.ok, true);
208
+ if (r2.ok) assert.equal(r2.text, "a\nX\nb\nC\nd\ne\n");
209
+ });
210
+
211
+ test("shifted recovery: duplicate content → ambiguous candidates", () => {
212
+ const readText = "a\nx\nb\nx\nc\n";
213
+ const currentText = "a\nY\nx\nb\nx\nc\n"; // both "x" shifted
214
+ const r = applyEdits(currentText, [{ op: "replace", start: at(readText, 2), body: ["Z"] }]);
183
215
  assert.equal(r.ok, false);
184
- if (!r.ok) assert.equal(r.error.kind, "noop");
216
+ if (!r.ok && r.failure.kind === "anchor") {
217
+ const f = r.failure.failures[0];
218
+ assert.equal(f.recovery.kind, "ambiguous");
219
+ if (f.recovery.kind === "ambiguous") {
220
+ assert.deepEqual(
221
+ f.recovery.candidates.map((c) => c.line),
222
+ [3, 5],
223
+ );
224
+ }
225
+ }
185
226
  });
227
+
228
+ test("shifted recovery: content genuinely changed → none, with live content", () => {
229
+ const readText = "a\nb\nc\n";
230
+ const currentText = "a\nBCHANGED\nc\n"; // "b" is gone
231
+ const r = applyEdits(currentText, [{ op: "replace", start: at(readText, 2), body: ["B"] }]);
232
+ assert.equal(r.ok, false);
233
+ if (!r.ok && r.failure.kind === "anchor") {
234
+ const f = r.failure.failures[0];
235
+ assert.equal(f.recovery.kind, "none");
236
+ assert.equal(f.current?.content, "BCHANGED");
237
+ }
238
+ });
239
+
240
+ test("collect-all: two stale anchors in one batch → both failures returned", () => {
241
+ const readText = "a\nb\nc\nd\n";
242
+ const currentText = "X\na\nb\nc\nd\n"; // inserted X at top → all shifted +1
243
+ const r = applyEdits(currentText, [
244
+ { op: "replace", start: at(readText, 2), body: ["B"] },
245
+ { op: "replace", start: at(readText, 4), body: ["D"] },
246
+ ]);
247
+ assert.equal(r.ok, false);
248
+ if (!r.ok && r.failure.kind === "anchor") {
249
+ assert.equal(r.failure.failures.length, 2);
250
+ const found = r.failure.failures.map((f) => (f.recovery.kind === "found" ? f.recovery.newLine : -1));
251
+ assert.deepEqual(found, [3, 5]);
252
+ }
253
+ });
254
+
255
+ test("shiftRadius=0 disables rescue (always none)", () => {
256
+ const readText = "a\nb\nc\nd\ne\n";
257
+ const currentText = "a\nX\nb\nc\nd\ne\n";
258
+ const r = applyEdits(currentText, [{ op: "replace", start: at(readText, 3), body: ["C"] }], 4, 0);
259
+ assert.equal(r.ok, false);
260
+ if (!r.ok && r.failure.kind === "anchor") {
261
+ assert.equal(r.failure.failures[0].recovery.kind, "none");
262
+ }
263
+ });
264
+