@d3ara1n/pi-hashline-edit 0.1.0 → 0.1.2
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 +20 -21
- package/package.json +1 -1
- package/src/core/apply.test.ts +89 -101
- package/src/core/apply.ts +58 -47
- package/src/core/hash.test.ts +21 -16
- package/src/core/hash.ts +35 -57
- package/src/core/index.ts +4 -8
- package/src/core/lines.test.ts +30 -0
- package/src/core/lines.ts +42 -0
- package/src/core/types.ts +16 -34
- package/src/index.ts +8 -10
- package/src/pi/config.ts +8 -7
- package/src/pi/edit-tool.ts +159 -96
- package/src/pi/execute.test.ts +194 -47
- package/src/pi/pi.test.ts +1 -20
- package/src/pi/read-tool.ts +16 -19
- package/src/pi/state.ts +4 -57
- package/src/core/diff.test.ts +0 -21
- package/src/core/diff.ts +0 -39
- package/src/core/parse.test.ts +0 -117
- package/src/core/parse.ts +0 -165
- package/src/core/snapshot.test.ts +0 -60
- package/src/core/snapshot.ts +0 -78
package/README.md
CHANGED
|
@@ -2,41 +2,40 @@
|
|
|
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
|
-
|
|
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.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Design
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
- **
|
|
11
|
-
-
|
|
12
|
-
-
|
|
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 the address; the hash is a checksum that the line at that address is still what was read.
|
|
10
|
+
- **Line + content hash**: each line's hash mixes its 1-based line number into its content, so every line gets a unique hash 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.
|
|
11
|
+
- **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, no drift relocation.
|
|
12
|
+
- **Chain edits without re-reading**: a successful `edit` returns `Updated anchors` (`LINE#HASH│`) for the lines it produced (and the line that shifted into a deletion gap), so the next edit to the same file can cite them directly.
|
|
13
|
+
- **No legacy compatibility**: overrides the built-in `edit`/`read`. `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
14
|
|
|
14
|
-
|
|
15
|
+
## Protocol
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
`read` 输出(每行带锚):
|
|
17
|
+
`read` output (each line anchored):
|
|
19
18
|
|
|
20
19
|
```
|
|
21
20
|
src/foo.ts · 6 lines
|
|
22
21
|
1#aF3│import { compute } from "./util"
|
|
23
22
|
2#7Qk│
|
|
24
23
|
3#mP0│export function foo(x: number) {
|
|
25
|
-
4#kLp│ if (x < 0) return 0
|
|
26
|
-
5#xY9│ return compute(x)
|
|
27
|
-
6#b2H│}
|
|
28
24
|
```
|
|
29
25
|
|
|
30
|
-
`edit`
|
|
26
|
+
`edit` takes `path` + `edits` (an array of ops, each with `op`, `anchor`/`end` `{line, hash}` from read, and `body` string[]):
|
|
31
27
|
|
|
28
|
+
```jsonc
|
|
29
|
+
{
|
|
30
|
+
"path": "src/foo.ts",
|
|
31
|
+
"edits": [
|
|
32
|
+
{ "op": "replace", "anchor": { "line": 4, "hash": "kLp" }, "body": [" return x + 1"] },
|
|
33
|
+
{ "op": "insert_after", "anchor": { "line": 6, "hash": "b2H" }, "body": ["", "export const bar = foo"] }
|
|
34
|
+
]
|
|
35
|
+
}
|
|
32
36
|
```
|
|
33
|
-
replace 4#kLp:
|
|
34
|
-
+ if (x < 0) throw new Error("neg")
|
|
35
37
|
|
|
36
|
-
insert_after
|
|
37
|
-
+
|
|
38
|
-
+export const bar = foo
|
|
39
|
-
```
|
|
38
|
+
Ops: `replace` · `delete` · `insert_after` · `insert_before` · `append` · `prepend`. `anchor`/`end` = `{line, hash}` from read; `body` = new content lines (string[], omit for `delete`).
|
|
40
39
|
|
|
41
40
|
## Installation
|
|
42
41
|
|
|
@@ -56,4 +55,4 @@ Or add to `~/.pi/agent/settings.json`:
|
|
|
56
55
|
|
|
57
56
|
## Dependencies
|
|
58
57
|
|
|
59
|
-
-
|
|
58
|
+
- No additional `@d3ara1n/pi-*` dependencies; peer `@earendil-works/pi-coding-agent` ships with pi (framework-level, not listed by convention).
|
package/package.json
CHANGED
package/src/core/apply.test.ts
CHANGED
|
@@ -1,185 +1,173 @@
|
|
|
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
|
-
test("replace
|
|
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
|
-
test("delete
|
|
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
|
-
test("
|
|
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
|
|
116
|
-
const r = applyEdits(
|
|
102
|
+
test("anchor hash mismatch rejected (line changed)", () => {
|
|
103
|
+
const text = "a\nb\n";
|
|
104
|
+
const r = applyEdits(text, [{ op: "replace", start: { line: 1, hash: "WRONG" }, body: ["x"] }]);
|
|
117
105
|
assert.equal(r.ok, false);
|
|
118
|
-
if (!r.ok) assert.equal(r.error.kind, "
|
|
106
|
+
if (!r.ok) assert.equal(r.error.kind, "anchor");
|
|
119
107
|
});
|
|
120
108
|
|
|
121
|
-
test("
|
|
122
|
-
const text = "a\
|
|
123
|
-
const
|
|
124
|
-
const r = applyEdits(text, [{ op: "replace", start: { line: 1, hash: "WRONG" }, body: ["x"] }], s);
|
|
109
|
+
test("line out of range rejected", () => {
|
|
110
|
+
const text = "a\n";
|
|
111
|
+
const r = applyEdits(text, [{ op: "replace", start: { line: 5, hash: computeLineHash(1, "a") }, body: ["x"] }]);
|
|
125
112
|
assert.equal(r.ok, false);
|
|
126
113
|
if (!r.ok) assert.equal(r.error.kind, "anchor");
|
|
127
114
|
});
|
|
128
115
|
|
|
129
|
-
test("
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
const r = applyEdits(text, [{ op: "replace", start: { line:
|
|
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"] }]);
|
|
133
120
|
assert.equal(r.ok, false);
|
|
134
121
|
if (!r.ok) assert.equal(r.error.kind, "anchor");
|
|
135
122
|
});
|
|
136
123
|
|
|
137
|
-
test("range
|
|
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
128
|
if (!r.ok) assert.equal(r.error.kind, "range");
|
|
147
129
|
});
|
|
148
130
|
|
|
149
|
-
test("
|
|
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
138
|
if (!r.ok) assert.equal(r.error.kind, "range");
|
|
162
139
|
});
|
|
163
140
|
|
|
164
|
-
test("
|
|
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
148
|
if (!r.ok) assert.equal(r.error.kind, "range");
|
|
177
149
|
});
|
|
178
150
|
|
|
179
|
-
test("noop
|
|
151
|
+
test("noop (byte-identical body) rejected", () => {
|
|
180
152
|
const text = "a\nb\n";
|
|
181
|
-
const
|
|
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"] }]);
|
|
183
154
|
assert.equal(r.ok, false);
|
|
184
155
|
if (!r.ok) assert.equal(r.error.kind, "noop");
|
|
185
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
|
+
|
package/src/core/apply.ts
CHANGED
|
@@ -1,54 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Pure-function applicator: applies edits to a file's current text.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Verification is live and surgical: each anchor's hash is recomputed from the
|
|
5
|
+
* CURRENT line content at the cited line number and compared to the cited hash.
|
|
6
|
+
* No snapshot, no global stale check — a line that changed (or was
|
|
7
|
+
* misremembered) fails its own anchor; unchanged lines elsewhere never block
|
|
8
|
+
* the edit.
|
|
9
|
+
*
|
|
10
|
+
* Other strict semantics:
|
|
11
|
+
* - Operation ranges must not overlap (including the same insertion point).
|
|
12
|
+
* - body byte-identical to the whole-file result → `noop` error (guides the
|
|
13
|
+
* model to investigate rather than blindly retry).
|
|
10
14
|
*
|
|
11
15
|
* @module pi-hashline-edit/core
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import type { ApplyResult, Edit,
|
|
18
|
+
import { computeLineHash } from "./hash.ts";
|
|
19
|
+
import { detectLineEnding, joinLines, splitLines } from "./lines.ts";
|
|
20
|
+
import type { Anchor, ApplyResult, Edit, PatchError } from "./types.ts";
|
|
17
21
|
|
|
18
|
-
/**
|
|
22
|
+
/** Line-level operation: replace the raw lines in the `[lo, hi)` range (0-based, hi exclusive) with newLines. */
|
|
19
23
|
interface SpanOp {
|
|
20
24
|
lo: number;
|
|
21
25
|
hi: number;
|
|
22
26
|
newLines: string[];
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
/**
|
|
26
|
-
function checkAnchor(
|
|
27
|
-
if (line < 1 || line >
|
|
29
|
+
/** Verify an anchor against the current file: line in range and hash matches the live content. */
|
|
30
|
+
function checkAnchor(lines: readonly string[], line: number, hash: string, hashLen: number): PatchError | null {
|
|
31
|
+
if (line < 1 || line > lines.length) {
|
|
28
32
|
return {
|
|
29
33
|
kind: "anchor",
|
|
30
|
-
message: `line ${line} does not exist (file has ${
|
|
34
|
+
message: `line ${line} does not exist (file has ${lines.length} lines)`,
|
|
31
35
|
};
|
|
32
36
|
}
|
|
33
|
-
if (
|
|
37
|
+
if (computeLineHash(line, lines[line - 1], hashLen) !== hash) {
|
|
34
38
|
return {
|
|
35
39
|
kind: "anchor",
|
|
36
|
-
message: `
|
|
40
|
+
message: `line ${line} changed or hash is wrong: re-read to get the current #HASH`,
|
|
37
41
|
};
|
|
38
42
|
}
|
|
39
43
|
return null;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
/**
|
|
43
|
-
function translateEdit(edit: Edit,
|
|
46
|
+
/** Translate an Edit into a SpanOp, verifying anchors and ranges against the current lines. */
|
|
47
|
+
function translateEdit(edit: Edit, lines: readonly string[], hashLen: number): { op: SpanOp } | { error: PatchError } {
|
|
44
48
|
switch (edit.op) {
|
|
45
49
|
case "replace":
|
|
46
50
|
case "delete": {
|
|
47
|
-
const startErr = checkAnchor(
|
|
51
|
+
const startErr = checkAnchor(lines, edit.start.line, edit.start.hash, hashLen);
|
|
48
52
|
if (startErr) return { error: startErr };
|
|
49
53
|
let endLine = edit.start.line;
|
|
50
54
|
if (edit.end) {
|
|
51
|
-
const endErr = checkAnchor(
|
|
55
|
+
const endErr = checkAnchor(lines, edit.end.line, edit.end.hash, hashLen);
|
|
52
56
|
if (endErr) return { error: endErr };
|
|
53
57
|
endLine = edit.end.line;
|
|
54
58
|
}
|
|
@@ -64,17 +68,17 @@ function translateEdit(edit: Edit, snapshot: FileSnapshot): { op: SpanOp } | { e
|
|
|
64
68
|
};
|
|
65
69
|
}
|
|
66
70
|
case "insert_after": {
|
|
67
|
-
const err = checkAnchor(
|
|
71
|
+
const err = checkAnchor(lines, edit.anchor.line, edit.anchor.hash, hashLen);
|
|
68
72
|
if (err) return { error: err };
|
|
69
73
|
return { op: { lo: edit.anchor.line, hi: edit.anchor.line, newLines: edit.body } };
|
|
70
74
|
}
|
|
71
75
|
case "insert_before": {
|
|
72
|
-
const err = checkAnchor(
|
|
76
|
+
const err = checkAnchor(lines, edit.anchor.line, edit.anchor.hash, hashLen);
|
|
73
77
|
if (err) return { error: err };
|
|
74
78
|
return { op: { lo: edit.anchor.line - 1, hi: edit.anchor.line - 1, newLines: edit.body } };
|
|
75
79
|
}
|
|
76
80
|
case "append": {
|
|
77
|
-
return { op: { lo:
|
|
81
|
+
return { op: { lo: lines.length, hi: lines.length, newLines: edit.body } };
|
|
78
82
|
}
|
|
79
83
|
case "prepend": {
|
|
80
84
|
return { op: { lo: 0, hi: 0, newLines: edit.body } };
|
|
@@ -82,37 +86,32 @@ function translateEdit(edit: Edit, snapshot: FileSnapshot): { op: SpanOp } | { e
|
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
/**
|
|
89
|
+
/** The "last affected position" of a zero-width range (insertion point) is lo; otherwise hi-1. */
|
|
86
90
|
function maxAffected(op: SpanOp): number {
|
|
87
91
|
return op.lo === op.hi ? op.lo : op.hi - 1;
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
/**
|
|
91
|
-
*
|
|
95
|
+
* Apply edits to `text`. Anchors are verified against the current content; on
|
|
96
|
+
* success `touchedLines` gives the 0-based indices of the new-file lines this
|
|
97
|
+
* edit produced.
|
|
92
98
|
*
|
|
93
|
-
* @param text
|
|
94
|
-
* @param edits
|
|
95
|
-
* @param
|
|
96
|
-
* @returns 应用结果;失败返回结构化错误
|
|
99
|
+
* @param text current full file text
|
|
100
|
+
* @param edits parsed edit operations
|
|
101
|
+
* @param hashLen hash length used to verify anchors (default 4)
|
|
97
102
|
*/
|
|
98
|
-
export function applyEdits(text: string, edits: Edit[],
|
|
99
|
-
if (text !== snapshot.text) {
|
|
100
|
-
return {
|
|
101
|
-
ok: false,
|
|
102
|
-
error: { kind: "stale", message: "file changed since last read; re-read before editing" },
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
103
|
+
export function applyEdits(text: string, edits: Edit[], hashLen = 4): ApplyResult {
|
|
106
104
|
const lines = splitLines(text);
|
|
105
|
+
const ending = detectLineEnding(text);
|
|
107
106
|
|
|
108
107
|
const ops: SpanOp[] = [];
|
|
109
108
|
for (const edit of edits) {
|
|
110
|
-
const t = translateEdit(edit,
|
|
109
|
+
const t = translateEdit(edit, lines, hashLen);
|
|
111
110
|
if ("error" in t) return { ok: false, error: t.error };
|
|
112
111
|
ops.push(t.op);
|
|
113
112
|
}
|
|
114
113
|
|
|
115
|
-
//
|
|
114
|
+
// Overlap check: sort ascending by lo; the next op's start must not fall inside the previous op's affected range
|
|
116
115
|
const sorted = [...ops].sort((a, b) => a.lo - b.lo || a.hi - b.hi);
|
|
117
116
|
for (let k = 1; k < sorted.length; k++) {
|
|
118
117
|
if (sorted[k].lo <= maxAffected(sorted[k - 1])) {
|
|
@@ -126,25 +125,37 @@ export function applyEdits(text: string, edits: Edit[], snapshot: FileSnapshot):
|
|
|
126
125
|
}
|
|
127
126
|
}
|
|
128
127
|
|
|
129
|
-
//
|
|
128
|
+
// Apply back-to-front (lo descending) so original lo/hi stay valid
|
|
130
129
|
let result = [...lines];
|
|
131
130
|
for (const op of [...sorted].sort((a, b) => b.lo - a.lo)) {
|
|
132
131
|
result = [...result.slice(0, op.lo), ...op.newLines, ...result.slice(op.hi)];
|
|
133
132
|
}
|
|
134
133
|
|
|
135
|
-
const newText = joinLines(result,
|
|
134
|
+
const newText = joinLines(result, ending);
|
|
136
135
|
if (newText === text) {
|
|
137
136
|
return {
|
|
138
137
|
ok: false,
|
|
139
138
|
error: {
|
|
140
139
|
kind: "noop",
|
|
141
|
-
message:
|
|
142
|
-
"edit parsed and applied cleanly but produced no change; body is byte-identical to the target — the bug is elsewhere, re-read first",
|
|
140
|
+
message: "edit parsed and applied cleanly but produced no change; body is byte-identical — the bug is elsewhere, re-read first",
|
|
143
141
|
},
|
|
144
142
|
};
|
|
145
143
|
}
|
|
146
144
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
145
|
+
// touchedLines: new-file indices worth re-anchoring — each produced line,
|
|
146
|
+
// and for a pure delete the line that shifted into the gap (so the model
|
|
147
|
+
// gets a fresh anchor for the shifted region).
|
|
148
|
+
const touched: number[] = [];
|
|
149
|
+
let delta = 0;
|
|
150
|
+
for (const op of sorted) {
|
|
151
|
+
const newLo = op.lo + delta;
|
|
152
|
+
if (op.newLines.length > 0) {
|
|
153
|
+
for (let i = 0; i < op.newLines.length; i++) touched.push(newLo + i);
|
|
154
|
+
} else if (newLo < result.length) {
|
|
155
|
+
touched.push(newLo);
|
|
156
|
+
}
|
|
157
|
+
delta += op.newLines.length - (op.hi - op.lo);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { ok: true, text: newText, changed: true, touchedLines: touched };
|
|
150
161
|
}
|
package/src/core/hash.test.ts
CHANGED
|
@@ -4,46 +4,51 @@ import { computeLineHash, hashFileLines } from "./hash.ts";
|
|
|
4
4
|
|
|
5
5
|
const ALLOWED = new Set("0123456789ABCDEFGHJKMNPQRSTVWXYZ");
|
|
6
6
|
|
|
7
|
-
test("computeLineHash
|
|
8
|
-
const a = computeLineHash(
|
|
9
|
-
const b = computeLineHash(
|
|
7
|
+
test("computeLineHash is stable and base32", () => {
|
|
8
|
+
const a = computeLineHash(3, "code", 4);
|
|
9
|
+
const b = computeLineHash(3, "code", 4);
|
|
10
10
|
assert.equal(a, b);
|
|
11
11
|
assert.equal(a.length, 4);
|
|
12
12
|
for (const ch of a) assert.ok(ALLOWED.has(ch), `bad char ${ch}`);
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
-
test("
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
assert.notEqual(h1, h2);
|
|
15
|
+
test("different line number → different hash (even for identical content)", () => {
|
|
16
|
+
assert.notEqual(computeLineHash(2, ""), computeLineHash(5, ""));
|
|
17
|
+
assert.notEqual(computeLineHash(1, "}"), computeLineHash(2, "}"));
|
|
19
18
|
});
|
|
20
19
|
|
|
21
|
-
test("
|
|
22
|
-
assert.
|
|
20
|
+
test("different content → different hash", () => {
|
|
21
|
+
assert.notEqual(computeLineHash(1, "a"), computeLineHash(1, "b"));
|
|
23
22
|
});
|
|
24
23
|
|
|
25
|
-
test("
|
|
24
|
+
test("same (line, content) → same hash", () => {
|
|
25
|
+
assert.equal(computeLineHash(7, "x"), computeLineHash(7, "x"));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("base32 alphabet (without I/L/O/U) in bulk", () => {
|
|
26
29
|
for (let i = 0; i < 2000; i++) {
|
|
27
|
-
const h = computeLineHash(
|
|
30
|
+
const h = computeLineHash(i + 1, `line ${i}`, 4);
|
|
28
31
|
for (const ch of h) assert.ok(ALLOWED.has(ch), `bad char ${ch} in ${h}`);
|
|
29
32
|
}
|
|
30
33
|
});
|
|
31
34
|
|
|
32
|
-
test("hashFileLines
|
|
35
|
+
test("hashFileLines length equals line count", () => {
|
|
33
36
|
assert.equal(hashFileLines(["a", "b", "c"]).length, 3);
|
|
34
37
|
});
|
|
35
38
|
|
|
36
|
-
test("hashFileLines
|
|
39
|
+
test("hashFileLines empty file", () => {
|
|
37
40
|
assert.deepEqual(hashFileLines([]), []);
|
|
38
41
|
});
|
|
39
42
|
|
|
40
|
-
test("hashFileLines
|
|
43
|
+
test("hashFileLines: identical content lines get distinct hashes (no collision, no length bloat)", () => {
|
|
44
|
+
// runs of identical lines — the case neighbor-aware hashing explodes on
|
|
41
45
|
const lines = ["", "", "", "", "", "}", "}", "}", "return", "return", ",", ","];
|
|
42
46
|
const hashes = hashFileLines(lines);
|
|
43
|
-
assert.equal(new Set(hashes).size, hashes.length, "
|
|
47
|
+
assert.equal(new Set(hashes).size, hashes.length, "duplicate hashes");
|
|
48
|
+
for (const h of hashes) assert.equal(h.length, 4, `hash ${h} is not 4 chars`);
|
|
44
49
|
});
|
|
45
50
|
|
|
46
|
-
test("hashFileLines
|
|
51
|
+
test("hashFileLines respects the length parameter", () => {
|
|
47
52
|
assert.equal(hashFileLines(["a", "b"], 6)[0].length, 6);
|
|
48
53
|
assert.equal(hashFileLines(["a", "b"], 4)[0].length, 4);
|
|
49
54
|
});
|