@d3ara1n/pi-hashline-edit 0.1.2 → 0.3.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 +98 -10
- package/package.json +1 -1
- package/src/core/apply.test.ts +98 -7
- package/src/core/apply.ts +126 -39
- package/src/core/hash.ts +0 -4
- package/src/core/types.ts +45 -10
- package/src/index.ts +9 -2
- package/src/pi/config.ts +7 -1
- package/src/pi/edit-tool.ts +78 -25
- package/src/pi/execute.test.ts +39 -26
- package/src/pi/grep-tool.ts +353 -0
- package/src/pi/pi.test.ts +7 -0
- package/src/pi/read-tool.ts +16 -3
- package/src/pi/replace-tool.ts +302 -0
- package/src/pi/replace.test.ts +283 -0
- package/src/pi/state.ts +1 -1
package/README.md
CHANGED
|
@@ -1,16 +1,57 @@
|
|
|
1
1
|
# @d3ara1n/pi-hashline-edit
|
|
2
2
|
|
|
3
|
-
> Hashline-style file editing for [pi](https://github.com/earendil-works/pi-coding-agent) — line-anchored edits verified by content hash
|
|
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), plus a location-blind `replace` tool for bulk + regex transforms.
|
|
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. All four tools — `read`, `grep`, `edit`, `replace` — are one set governed by this switch: when disabled, `read`/`grep`/`edit` delegate to the built-ins and `replace` refuses (it has no built-in counterpart).
|
|
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.
|
|
33
|
+
|
|
34
|
+
## `replace` — bulk + regex
|
|
35
|
+
|
|
36
|
+
A separate, location-blind tool for transforms `edit` can't express: replace **all** occurrences of a string/regex across the whole file in one call. Use it for renames, normalizations, and pattern-based rewrites that would otherwise need many individual anchored ops.
|
|
37
|
+
|
|
38
|
+
- **Two modes** — `regex: false` (default) treats `find` as a literal substring (replaceAll; the replacement is inserted verbatim, no `$` expansion); `regex: true` treats `find` as a JavaScript pattern source and `replace` supports `$1`, `$2`, `$&`, …
|
|
39
|
+
- **Flags** — `flags` adds regex flags in both modes (`g` is always forced so every occurrence is replaced): `i` (case-insensitive), `m` (per-line `^`/`$`), `s` (dotall, `.` matches `\n`), `u` (unicode).
|
|
40
|
+
- **Safety** — a `maxMatches` cap (default 2000) errors *before writing* if exceeded, so a runaway pattern can't produce a catastrophic write. `0` matches is an error (no silent no-op).
|
|
41
|
+
- **Shares the edit queue** — `replace` and `edit` on the same file are serialized via the same mutation queue, so concurrent edits never interleave.
|
|
42
|
+
- **Returns a diff + fresh anchors** for the changed region, so a follow-up `edit` can chain on the new content without a re-read (when the region is small).
|
|
43
|
+
|
|
44
|
+
`edit` vs `replace`: `edit` is **surgical and verified** (you point at a `LINE#HASH` and the tool confirms the line is unchanged before rewriting it). `replace` is **global and unverified** (you give a pattern, it rewrites every match sight-unseen). Pick by intent: change a known spot → `edit`; transform every occurrence → `replace`.
|
|
6
45
|
|
|
7
46
|
## Design
|
|
8
47
|
|
|
9
|
-
- **Per-line hash + line number, dual anchor**: `read` shows each line
|
|
10
|
-
- **Line
|
|
11
|
-
- **Live, surgical verification**: at apply time each cited anchor's hash is recomputed from the
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
48
|
+
- **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.
|
|
49
|
+
- **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.
|
|
50
|
+
- **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.
|
|
51
|
+
- **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.
|
|
52
|
+
- **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.
|
|
53
|
+
- **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.
|
|
54
|
+
- **No legacy compatibility on `edit`**: `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. Bulk/regex replacement is a *separate* tool, `replace`, not an `edit` mode (see below).
|
|
14
55
|
|
|
15
56
|
## Protocol
|
|
16
57
|
|
|
@@ -18,11 +59,22 @@ Edits reference lines by `LINE#HASH` anchors (copied from `read` output) instead
|
|
|
18
59
|
|
|
19
60
|
```
|
|
20
61
|
src/foo.ts · 6 lines
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
62
|
+
1#aF3│import { compute } from "./util"
|
|
63
|
+
2#7Qk│
|
|
64
|
+
3#mP0│export function foo(x: number) {
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`grep` output (results grouped by file, each line anchored — copy `LINE#HASH` straight into an edit):
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
src/foo.ts · 2 matches
|
|
71
|
+
3#mP0│export function foo(x: number) {
|
|
72
|
+
4#kLp│ return x + 1
|
|
73
|
+
src/util.ts · 1 match
|
|
74
|
+
10#aF3│ const z = compute(x)
|
|
24
75
|
```
|
|
25
76
|
|
|
77
|
+
|
|
26
78
|
`edit` takes `path` + `edits` (an array of ops, each with `op`, `anchor`/`end` `{line, hash}` from read, and `body` string[]):
|
|
27
79
|
|
|
28
80
|
```jsonc
|
|
@@ -37,6 +89,42 @@ src/foo.ts · 6 lines
|
|
|
37
89
|
|
|
38
90
|
Ops: `replace` · `delete` · `insert_after` · `insert_before` · `append` · `prepend`. `anchor`/`end` = `{line, hash}` from read; `body` = new content lines (string[], omit for `delete`).
|
|
39
91
|
|
|
92
|
+
`replace` takes `path`, `find`, `replace` (+ optional `regex`, `flags`, `maxMatches`) and substitutes **every** match:
|
|
93
|
+
|
|
94
|
+
```jsonc
|
|
95
|
+
{
|
|
96
|
+
"path": "src/foo.ts",
|
|
97
|
+
"find": "oldName",
|
|
98
|
+
"replace": "newName"
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Regex with a capture group (rename `getName()` → `get_name()` everywhere):
|
|
103
|
+
|
|
104
|
+
```jsonc
|
|
105
|
+
{ "path": "src/foo.ts", "find": "get([A-Z]\w*)", "replace": "get_$1", "regex": true }
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Case-insensitive literal rename across the whole file:
|
|
109
|
+
|
|
110
|
+
```jsonc
|
|
111
|
+
{ "path": "src/foo.ts", "find": "TODO", "replace": "FIXME", "flags": "i" }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Configuration
|
|
115
|
+
|
|
116
|
+
Add a `hashlineEdit` field to `~/.pi/agent/settings.json` (global) or `.pi/settings.json` in a project (project replaces global):
|
|
117
|
+
|
|
118
|
+
```jsonc
|
|
119
|
+
{
|
|
120
|
+
"hashlineEdit": {
|
|
121
|
+
"enabled": true, // set false to fall back to the built-in read/edit
|
|
122
|
+
"hashLen": 4, // hash length, 2–8 (default 4)
|
|
123
|
+
"shiftRadius": 15 // ±lines scanned to rescue a stale anchor (default 15; 0 disables)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
40
128
|
## Installation
|
|
41
129
|
|
|
42
130
|
```bash
|
package/package.json
CHANGED
package/src/core/apply.test.ts
CHANGED
|
@@ -103,14 +103,14 @@ test("anchor hash mismatch rejected (line changed)", () => {
|
|
|
103
103
|
const text = "a\nb\n";
|
|
104
104
|
const r = applyEdits(text, [{ op: "replace", start: { line: 1, hash: "WRONG" }, body: ["x"] }]);
|
|
105
105
|
assert.equal(r.ok, false);
|
|
106
|
-
if (!r.ok) assert.equal(r.
|
|
106
|
+
if (!r.ok) assert.equal(r.failure.kind, "anchor");
|
|
107
107
|
});
|
|
108
108
|
|
|
109
109
|
test("line out of range rejected", () => {
|
|
110
110
|
const text = "a\n";
|
|
111
111
|
const r = applyEdits(text, [{ op: "replace", start: { line: 5, hash: computeLineHash(1, "a") }, body: ["x"] }]);
|
|
112
112
|
assert.equal(r.ok, false);
|
|
113
|
-
if (!r.ok) assert.equal(r.
|
|
113
|
+
if (!r.ok) assert.equal(r.failure.kind, "anchor");
|
|
114
114
|
});
|
|
115
115
|
|
|
116
116
|
test("anchor mismatch when the cited line's content differs (live verification)", () => {
|
|
@@ -118,14 +118,14 @@ test("anchor mismatch when the cited line's content differs (live verification)"
|
|
|
118
118
|
const text = "a\nb\n";
|
|
119
119
|
const r = applyEdits(text, [{ op: "replace", start: { line: 1, hash: computeLineHash(2, "b") }, body: ["x"] }]);
|
|
120
120
|
assert.equal(r.ok, false);
|
|
121
|
-
if (!r.ok) assert.equal(r.
|
|
121
|
+
if (!r.ok) assert.equal(r.failure.kind, "anchor");
|
|
122
122
|
});
|
|
123
123
|
|
|
124
124
|
test("reverse-order range rejected", () => {
|
|
125
125
|
const text = "a\nb\nc\n";
|
|
126
126
|
const r = applyEdits(text, [{ op: "replace", start: at(text, 3), end: at(text, 1), body: ["x"] }]);
|
|
127
127
|
assert.equal(r.ok, false);
|
|
128
|
-
if (!r.ok) assert.equal(r.
|
|
128
|
+
if (!r.ok) assert.equal(r.failure.kind, "range");
|
|
129
129
|
});
|
|
130
130
|
|
|
131
131
|
test("overlapping edits rejected", () => {
|
|
@@ -135,7 +135,7 @@ test("overlapping edits rejected", () => {
|
|
|
135
135
|
{ op: "replace", start: at(text, 3), body: ["y"] },
|
|
136
136
|
]);
|
|
137
137
|
assert.equal(r.ok, false);
|
|
138
|
-
if (!r.ok) assert.equal(r.
|
|
138
|
+
if (!r.ok) assert.equal(r.failure.kind, "range");
|
|
139
139
|
});
|
|
140
140
|
|
|
141
141
|
test("conflict at the same insertion point rejected", () => {
|
|
@@ -145,14 +145,14 @@ test("conflict at the same insertion point rejected", () => {
|
|
|
145
145
|
{ op: "insert_after", anchor: at(text, 1), body: ["y"] },
|
|
146
146
|
]);
|
|
147
147
|
assert.equal(r.ok, false);
|
|
148
|
-
if (!r.ok) assert.equal(r.
|
|
148
|
+
if (!r.ok) assert.equal(r.failure.kind, "range");
|
|
149
149
|
});
|
|
150
150
|
|
|
151
151
|
test("noop (byte-identical body) rejected", () => {
|
|
152
152
|
const text = "a\nb\n";
|
|
153
153
|
const r = applyEdits(text, [{ op: "replace", start: at(text, 1), body: ["a"] }]);
|
|
154
154
|
assert.equal(r.ok, false);
|
|
155
|
-
if (!r.ok) assert.equal(r.
|
|
155
|
+
if (!r.ok) assert.equal(r.failure.kind, "noop");
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
test("unrelated change elsewhere does NOT block the edit (no global stale check)", () => {
|
|
@@ -171,3 +171,94 @@ test("CRLF line endings preserved", () => {
|
|
|
171
171
|
if (r.ok) assert.equal(r.text, "A\r\nb\r\n");
|
|
172
172
|
});
|
|
173
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"] }]);
|
|
215
|
+
assert.equal(r.ok, false);
|
|
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
|
+
}
|
|
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
|
+
|
package/src/core/apply.ts
CHANGED
|
@@ -7,6 +7,20 @@
|
|
|
7
7
|
* misremembered) fails its own anchor; unchanged lines elsewhere never block
|
|
8
8
|
* the edit.
|
|
9
9
|
*
|
|
10
|
+
* Shifted-anchor recovery: when a cited anchor no longer matches, we rescan
|
|
11
|
+
* ±radius lines for the original content, holding the ORIGINAL line number fixed
|
|
12
|
+
* and re-hashing each candidate's content. On a unique hit the new anchor (with
|
|
13
|
+
* its freshly computed hash) is returned so the caller can retry without a
|
|
14
|
+
* re-read; on several hits they are reported as ambiguous; on none the live
|
|
15
|
+
* content at the cited line is returned to steer a re-read.
|
|
16
|
+
*
|
|
17
|
+
* Batch semantics: all ops are verified against the same current snapshot. If
|
|
18
|
+
* ANY anchor fails, EVERY failure (with recovery) is collected and returned
|
|
19
|
+
* together — nothing is written. This keeps the rescue report and the on-disk
|
|
20
|
+
* file in sync: a partial write would shift lines and invalidate the very
|
|
21
|
+
* recovery info we just returned. Range issues among the surviving ops are
|
|
22
|
+
* deferred until anchors are corrected.
|
|
23
|
+
*
|
|
10
24
|
* Other strict semantics:
|
|
11
25
|
* - Operation ranges must not overlap (including the same insertion point).
|
|
12
26
|
* - body byte-identical to the whole-file result → `noop` error (guides the
|
|
@@ -17,7 +31,7 @@
|
|
|
17
31
|
|
|
18
32
|
import { computeLineHash } from "./hash.ts";
|
|
19
33
|
import { detectLineEnding, joinLines, splitLines } from "./lines.ts";
|
|
20
|
-
import type { Anchor, ApplyResult, Edit
|
|
34
|
+
import type { Anchor, AnchorFailure, AnchorRecovery, ApplyResult, Edit } from "./types.ts";
|
|
21
35
|
|
|
22
36
|
/** Line-level operation: replace the raw lines in the `[lo, hi)` range (0-based, hi exclusive) with newLines. */
|
|
23
37
|
interface SpanOp {
|
|
@@ -26,40 +40,94 @@ interface SpanOp {
|
|
|
26
40
|
newLines: string[];
|
|
27
41
|
}
|
|
28
42
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
/** Default ±line radius for shifted-anchor recovery. */
|
|
44
|
+
const DEFAULT_SHIFT_RADIUS = 15;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Verify an anchor against the live content; on mismatch, attempt shifted
|
|
48
|
+
* recovery. Returns null when the anchor matches, otherwise an
|
|
49
|
+
* {@link AnchorFailure} carrying the recovery outcome and the cited line's
|
|
50
|
+
* current snapshot.
|
|
51
|
+
*
|
|
52
|
+
* Recovery holds the ORIGINAL line number fixed and re-hashes each candidate's
|
|
53
|
+
* content: `computeLineHash(citedLine, candidateContent) === citedHash` holds
|
|
54
|
+
* iff the candidate IS the original content (modulo negligible hash collision).
|
|
55
|
+
* A returned candidate's anchor uses the candidate's real line number with a
|
|
56
|
+
* hash computed for that line, so it verifies on retry.
|
|
57
|
+
*/
|
|
58
|
+
function verifyAnchor(
|
|
59
|
+
lines: readonly string[],
|
|
60
|
+
cited: Anchor,
|
|
61
|
+
which: "anchor" | "end",
|
|
62
|
+
opIndex: number,
|
|
63
|
+
op: Edit["op"],
|
|
64
|
+
hashLen: number,
|
|
65
|
+
radius: number,
|
|
66
|
+
): AnchorFailure | null {
|
|
67
|
+
const { line, hash } = cited;
|
|
68
|
+
if (line >= 1 && line <= lines.length && computeLineHash(line, lines[line - 1], hashLen) === hash) {
|
|
69
|
+
return null;
|
|
36
70
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
71
|
+
|
|
72
|
+
// Shifted recovery: scan ±radius (excluding the already-failed cited line).
|
|
73
|
+
const candidates: { line: number; hash: string }[] = [];
|
|
74
|
+
const lo = Math.max(1, line - radius);
|
|
75
|
+
const hi = Math.min(lines.length, line + radius);
|
|
76
|
+
for (let c = lo; c <= hi; c++) {
|
|
77
|
+
if (c === line) continue;
|
|
78
|
+
if (computeLineHash(line, lines[c - 1], hashLen) === hash) {
|
|
79
|
+
candidates.push({ line: c, hash: computeLineHash(c, lines[c - 1], hashLen) });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let recovery: AnchorRecovery;
|
|
84
|
+
if (candidates.length === 1) {
|
|
85
|
+
recovery = { kind: "found", newLine: candidates[0].line, newHash: candidates[0].hash };
|
|
86
|
+
} else if (candidates.length > 1) {
|
|
87
|
+
recovery = { kind: "ambiguous", candidates };
|
|
88
|
+
} else {
|
|
89
|
+
recovery = { kind: "none" };
|
|
42
90
|
}
|
|
43
|
-
|
|
91
|
+
|
|
92
|
+
const current =
|
|
93
|
+
line >= 1 && line <= lines.length
|
|
94
|
+
? { hash: computeLineHash(line, lines[line - 1], hashLen), content: lines[line - 1] }
|
|
95
|
+
: null;
|
|
96
|
+
|
|
97
|
+
return { opIndex, which, op, cited, recovery, current };
|
|
44
98
|
}
|
|
45
99
|
|
|
100
|
+
type TranslateResult =
|
|
101
|
+
| { readonly ok: true; readonly op: SpanOp }
|
|
102
|
+
| { readonly ok: false; readonly anchorFailures: AnchorFailure[] }
|
|
103
|
+
| { readonly ok: false; readonly rangeError: string };
|
|
104
|
+
|
|
46
105
|
/** Translate an Edit into a SpanOp, verifying anchors and ranges against the current lines. */
|
|
47
|
-
function translateEdit(
|
|
106
|
+
function translateEdit(
|
|
107
|
+
edit: Edit,
|
|
108
|
+
opIndex: number,
|
|
109
|
+
lines: readonly string[],
|
|
110
|
+
hashLen: number,
|
|
111
|
+
radius: number,
|
|
112
|
+
): TranslateResult {
|
|
48
113
|
switch (edit.op) {
|
|
49
114
|
case "replace":
|
|
50
115
|
case "delete": {
|
|
51
|
-
const
|
|
52
|
-
|
|
116
|
+
const failures: AnchorFailure[] = [];
|
|
117
|
+
const startF = verifyAnchor(lines, edit.start, "anchor", opIndex, edit.op, hashLen, radius);
|
|
118
|
+
if (startF) failures.push(startF);
|
|
53
119
|
let endLine = edit.start.line;
|
|
54
120
|
if (edit.end) {
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
121
|
+
const endF = verifyAnchor(lines, edit.end, "end", opIndex, edit.op, hashLen, radius);
|
|
122
|
+
if (endF) failures.push(endF);
|
|
57
123
|
endLine = edit.end.line;
|
|
58
124
|
}
|
|
125
|
+
if (failures.length > 0) return { ok: false, anchorFailures: failures };
|
|
59
126
|
if (endLine < edit.start.line) {
|
|
60
|
-
return {
|
|
127
|
+
return { ok: false, rangeError: `range ${edit.start.line}..${endLine} ends before it starts` };
|
|
61
128
|
}
|
|
62
129
|
return {
|
|
130
|
+
ok: true,
|
|
63
131
|
op: {
|
|
64
132
|
lo: edit.start.line - 1,
|
|
65
133
|
hi: endLine,
|
|
@@ -68,20 +136,20 @@ function translateEdit(edit: Edit, lines: readonly string[], hashLen: number): {
|
|
|
68
136
|
};
|
|
69
137
|
}
|
|
70
138
|
case "insert_after": {
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
73
|
-
return { op: { lo: edit.anchor.line, hi: edit.anchor.line, newLines: edit.body } };
|
|
139
|
+
const f = verifyAnchor(lines, edit.anchor, "anchor", opIndex, edit.op, hashLen, radius);
|
|
140
|
+
if (f) return { ok: false, anchorFailures: [f] };
|
|
141
|
+
return { ok: true, op: { lo: edit.anchor.line, hi: edit.anchor.line, newLines: edit.body } };
|
|
74
142
|
}
|
|
75
143
|
case "insert_before": {
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
return { op: { lo: edit.anchor.line - 1, hi: edit.anchor.line - 1, newLines: edit.body } };
|
|
144
|
+
const f = verifyAnchor(lines, edit.anchor, "anchor", opIndex, edit.op, hashLen, radius);
|
|
145
|
+
if (f) return { ok: false, anchorFailures: [f] };
|
|
146
|
+
return { ok: true, op: { lo: edit.anchor.line - 1, hi: edit.anchor.line - 1, newLines: edit.body } };
|
|
79
147
|
}
|
|
80
148
|
case "append": {
|
|
81
|
-
return { op: { lo: lines.length, hi: lines.length, newLines: edit.body } };
|
|
149
|
+
return { ok: true, op: { lo: lines.length, hi: lines.length, newLines: edit.body } };
|
|
82
150
|
}
|
|
83
151
|
case "prepend": {
|
|
84
|
-
return { op: { lo: 0, hi: 0, newLines: edit.body } };
|
|
152
|
+
return { ok: true, op: { lo: 0, hi: 0, newLines: edit.body } };
|
|
85
153
|
}
|
|
86
154
|
}
|
|
87
155
|
}
|
|
@@ -94,21 +162,40 @@ function maxAffected(op: SpanOp): number {
|
|
|
94
162
|
/**
|
|
95
163
|
* Apply edits to `text`. Anchors are verified against the current content; on
|
|
96
164
|
* success `touchedLines` gives the 0-based indices of the new-file lines this
|
|
97
|
-
* edit produced.
|
|
165
|
+
* edit produced. On any anchor mismatch, all failures (with shifted recovery)
|
|
166
|
+
* are collected and returned together — nothing is written.
|
|
98
167
|
*
|
|
99
|
-
* @param text
|
|
100
|
-
* @param edits
|
|
101
|
-
* @param hashLen
|
|
168
|
+
* @param text current full file text
|
|
169
|
+
* @param edits parsed edit operations
|
|
170
|
+
* @param hashLen hash length used to verify anchors (default 4)
|
|
171
|
+
* @param shiftRadius ±line radius for shifted-anchor recovery (default 15; 0 disables rescue)
|
|
102
172
|
*/
|
|
103
|
-
export function applyEdits(text: string, edits: Edit[], hashLen = 4): ApplyResult {
|
|
173
|
+
export function applyEdits(text: string, edits: Edit[], hashLen = 4, shiftRadius = DEFAULT_SHIFT_RADIUS): ApplyResult {
|
|
104
174
|
const lines = splitLines(text);
|
|
105
175
|
const ending = detectLineEnding(text);
|
|
106
176
|
|
|
107
177
|
const ops: SpanOp[] = [];
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
178
|
+
const anchorFailures: AnchorFailure[] = [];
|
|
179
|
+
let rangeError: string | null = null;
|
|
180
|
+
|
|
181
|
+
for (let i = 0; i < edits.length; i++) {
|
|
182
|
+
const t = translateEdit(edits[i], i, lines, hashLen, shiftRadius);
|
|
183
|
+
if (t.ok) {
|
|
184
|
+
ops.push(t.op);
|
|
185
|
+
} else if ("anchorFailures" in t) {
|
|
186
|
+
anchorFailures.push(...t.anchorFailures);
|
|
187
|
+
} else if (rangeError === null) {
|
|
188
|
+
rangeError = t.rangeError;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Anchor failures take priority: the model must fix anchors first; range
|
|
193
|
+
// issues among surviving ops are premature until anchors are corrected.
|
|
194
|
+
if (anchorFailures.length > 0) {
|
|
195
|
+
return { ok: false, failure: { kind: "anchor", failures: anchorFailures } };
|
|
196
|
+
}
|
|
197
|
+
if (rangeError !== null) {
|
|
198
|
+
return { ok: false, failure: { kind: "range", message: rangeError } };
|
|
112
199
|
}
|
|
113
200
|
|
|
114
201
|
// Overlap check: sort ascending by lo; the next op's start must not fall inside the previous op's affected range
|
|
@@ -117,7 +204,7 @@ export function applyEdits(text: string, edits: Edit[], hashLen = 4): ApplyResul
|
|
|
117
204
|
if (sorted[k].lo <= maxAffected(sorted[k - 1])) {
|
|
118
205
|
return {
|
|
119
206
|
ok: false,
|
|
120
|
-
|
|
207
|
+
failure: {
|
|
121
208
|
kind: "range",
|
|
122
209
|
message: `overlapping edits near line ${sorted[k].lo + 1}; issue one edit per range`,
|
|
123
210
|
},
|
|
@@ -135,7 +222,7 @@ export function applyEdits(text: string, edits: Edit[], hashLen = 4): ApplyResul
|
|
|
135
222
|
if (newText === text) {
|
|
136
223
|
return {
|
|
137
224
|
ok: false,
|
|
138
|
-
|
|
225
|
+
failure: {
|
|
139
226
|
kind: "noop",
|
|
140
227
|
message: "edit parsed and applied cleanly but produced no change; body is byte-identical — the bug is elsewhere, re-read first",
|
|
141
228
|
},
|
package/src/core/hash.ts
CHANGED
|
@@ -18,10 +18,6 @@
|
|
|
18
18
|
* - Content alone would leave identical lines (blank lines, `}`) sharing a
|
|
19
19
|
* hash; mixing the line number disambiguates them for free.
|
|
20
20
|
*
|
|
21
|
-
* Drift (file changed since read) is caught up-front by the global stale check
|
|
22
|
-
* (`text !== snapshot.text`). This hash's job is to verify the model actually
|
|
23
|
-
* read the line — it cannot forge a `(line, content)` hash without reading.
|
|
24
|
-
*
|
|
25
21
|
* @module pi-hashline-edit/core
|
|
26
22
|
*/
|
|
27
23
|
|
package/src/core/types.ts
CHANGED
|
@@ -25,22 +25,57 @@ export type Edit =
|
|
|
25
25
|
|
|
26
26
|
export type LineEnding = "lf" | "crlf";
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Outcome of shifted-anchor recovery. When a cited anchor's hash no longer
|
|
30
|
+
* matches the live content, the applicator rescans ±radius lines for the
|
|
31
|
+
* original content — holding the ORIGINAL line number fixed and re-hashing each
|
|
32
|
+
* candidate's content (`computeLineHash(citedLine, candidateContent) === citedHash`
|
|
33
|
+
* iff the candidate is the original content). A ready-to-resend anchor (with the
|
|
34
|
+
* freshly computed hash) is returned so the model can retry without a re-read.
|
|
35
|
+
*
|
|
36
|
+
* - `found` — exactly one nearby line holds the original content; resend the op
|
|
37
|
+
* with the provided anchor.
|
|
38
|
+
* - `ambiguous` — several nearby lines match (e.g. duplicate content); the model
|
|
39
|
+
* picks the right one from the candidates (each carries its own new hash).
|
|
40
|
+
* - `none` — the content genuinely changed; re-read.
|
|
41
|
+
*/
|
|
42
|
+
export type AnchorRecovery =
|
|
43
|
+
| { readonly kind: "found"; readonly newLine: number; readonly newHash: string }
|
|
44
|
+
| {
|
|
45
|
+
readonly kind: "ambiguous";
|
|
46
|
+
readonly candidates: ReadonlyArray<{ readonly line: number; readonly hash: string }>;
|
|
47
|
+
}
|
|
48
|
+
| { readonly kind: "none" };
|
|
33
49
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
50
|
+
/**
|
|
51
|
+
* A single anchor that failed verification, with its recovery attempt.
|
|
52
|
+
*
|
|
53
|
+
* `opIndex` is the 0-based position in the input `edits[]`; `which` names the
|
|
54
|
+
* op's anchor (`"anchor"` = start, `"end"` = range end); `op` is the op kind.
|
|
55
|
+
* `current` is the cited line's live content + hash (null if the line number is
|
|
56
|
+
* out of range) — surfaced when recovery is `none` so the model can self-diagnose.
|
|
57
|
+
*/
|
|
58
|
+
export interface AnchorFailure {
|
|
59
|
+
readonly opIndex: number;
|
|
60
|
+
readonly which: "anchor" | "end";
|
|
61
|
+
readonly op: Edit["op"];
|
|
62
|
+
readonly cited: Anchor;
|
|
63
|
+
readonly recovery: AnchorRecovery;
|
|
64
|
+
readonly current: { readonly hash: string; readonly content: string } | null;
|
|
37
65
|
}
|
|
38
66
|
|
|
67
|
+
/** Batch-level failure. `anchor` carries every per-anchor failure collected across the batch. */
|
|
68
|
+
export type ApplyFailure =
|
|
69
|
+
| { readonly kind: "anchor"; readonly failures: readonly AnchorFailure[] }
|
|
70
|
+
| { readonly kind: "range"; readonly message: string }
|
|
71
|
+
| { readonly kind: "noop"; readonly message: string };
|
|
72
|
+
|
|
39
73
|
/**
|
|
40
74
|
* Apply result. On success, `touchedLines` lists the 0-based line indices in
|
|
41
75
|
* the NEW file that this edit produced (inserted or replaced) — callers use it
|
|
42
76
|
* to surface fresh `LINE#HASH` anchors so the model can chain edits without a
|
|
43
|
-
* re-read.
|
|
77
|
+
* re-read. On failure, `failure` is either the collected set of anchor failures
|
|
78
|
+
* (each with recovery) or a single range/noop error; nothing is written.
|
|
44
79
|
*/
|
|
45
80
|
export type ApplyResult =
|
|
46
81
|
| {
|
|
@@ -49,4 +84,4 @@ export type ApplyResult =
|
|
|
49
84
|
readonly changed: boolean;
|
|
50
85
|
readonly touchedLines: readonly number[];
|
|
51
86
|
}
|
|
52
|
-
| { readonly ok: false; readonly
|
|
87
|
+
| { readonly ok: false; readonly failure: ApplyFailure };
|
package/src/index.ts
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Overrides the built-in read/edit: read outputs "lineNo#hash│content";
|
|
5
5
|
* edit accepts structured hashline ops (edits[] with LINE#HASH anchors), and
|
|
6
|
-
* legacy oldText/newText is rejected explicitly (no silent degradation).
|
|
7
|
-
*
|
|
6
|
+
* legacy oldText/newText is rejected explicitly (no silent degradation). grep
|
|
7
|
+
* is overridden the same way so results carry usable anchors. A separate
|
|
8
|
+
* `replace` tool adds location-blind bulk + regex replacement (replaceAll and
|
|
9
|
+
* full JS regex with capture groups) for renames/pattern transforms that
|
|
10
|
+
* would need many individual edits. Each tool carries its own renderer.
|
|
8
11
|
*
|
|
9
12
|
* @module pi-hashline-edit
|
|
10
13
|
*/
|
|
@@ -14,6 +17,8 @@ import { loadConfig } from "./pi/config.ts";
|
|
|
14
17
|
import { getState } from "./pi/state.ts";
|
|
15
18
|
import { makeEditOverride } from "./pi/edit-tool.ts";
|
|
16
19
|
import { makeReadOverride } from "./pi/read-tool.ts";
|
|
20
|
+
import { makeGrepOverride } from "./pi/grep-tool.ts";
|
|
21
|
+
import { makeReplaceTool } from "./pi/replace-tool.ts";
|
|
17
22
|
|
|
18
23
|
export default function (pi: ExtensionAPI) {
|
|
19
24
|
const cwd = process.cwd();
|
|
@@ -26,4 +31,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
26
31
|
|
|
27
32
|
pi.registerTool(makeReadOverride(cwd));
|
|
28
33
|
pi.registerTool(makeEditOverride(cwd));
|
|
34
|
+
pi.registerTool(makeGrepOverride(cwd));
|
|
35
|
+
pi.registerTool(makeReplaceTool(cwd));
|
|
29
36
|
}
|