@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 +71 -15
- package/package.json +1 -1
- package/src/core/apply.test.ts +177 -98
- package/src/core/apply.ts +161 -66
- package/src/core/hash.test.ts +16 -11
- package/src/core/hash.ts +26 -57
- package/src/core/index.ts +1 -5
- package/src/core/lines.test.ts +30 -0
- package/src/core/lines.ts +42 -0
- package/src/core/types.ts +52 -36
- package/src/index.ts +9 -10
- package/src/pi/config.ts +7 -1
- package/src/pi/edit-tool.ts +184 -118
- package/src/pi/execute.test.ts +193 -47
- package/src/pi/grep-tool.ts +335 -0
- package/src/pi/pi.test.ts +6 -18
- package/src/pi/read-tool.ts +27 -17
- package/src/pi/state.ts +5 -59
- package/src/core/diff.test.ts +0 -21
- package/src/core/diff.ts +0 -41
- package/src/core/parse.test.ts +0 -117
- package/src/core/parse.ts +0 -166
- package/src/core/snapshot.test.ts +0 -60
- package/src/core/snapshot.ts +0 -84
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
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
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 construction — no 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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
50
|
+
1#aF3│import { compute } from "./util"
|
|
51
|
+
2#7Qk│
|
|
52
|
+
3#mP0│export function foo(x: number) {
|
|
23
53
|
```
|
|
24
54
|
|
|
25
|
-
`
|
|
55
|
+
`grep` output (results grouped by file, each line anchored — copy `LINE#HASH` straight into an edit):
|
|
26
56
|
|
|
27
57
|
```
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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
package/src/core/apply.test.ts
CHANGED
|
@@ -1,185 +1,264 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import {
|
|
3
|
+
import { computeLineHash } from "./hash.ts";
|
|
4
|
+
import { splitLines } from "./lines.ts";
|
|
4
5
|
import { applyEdits } from "./apply.ts";
|
|
5
|
-
import type {
|
|
6
|
+
import type { Anchor } from "./types.ts";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
74
|
-
|
|
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("
|
|
87
|
-
const text = "a\nb\n";
|
|
88
|
-
const
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
assert.
|
|
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("
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
assert.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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("
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
152
|
-
|
|
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.
|
|
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
|
|
167
|
-
|
|
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.
|
|
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
|
|
182
|
-
|
|
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
|
|
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
|
+
|