@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 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
- 用「行级内容 hash + 行号」双重锚替代 pi 内置的 `oldText`/`newText` 精确匹配编辑。模型指出要改的行(带 hash 校验),而不是重打要改的代码——从根上消除 string-not-found 死循环与空白战争。
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
- - **行级 hash + 行号双重锚**:`read` 每行带短 hash(`3#aF3│code`),`edit` 引用 `行号#hash`。行号给人读,hash 给机器校验——天然抗行号漂移。
10
- - **context-aware hash**:每行 hash 把上下两行一起算,使内容相同的行(空行、`}`)因邻居不同而 hash 不同,文件内碰撞接近 0。
11
- - **严格核心 + 可插拔容错**:核心 `parse apply` 零猜测、失败快;容错(旧格式归一化、漂移重定位、块解析)做成独立开关的中间件。
12
- - **不兼容旧格式**:override 内置 `edit`/`read`。edit 只接受 hashline `input`;模型误发 `oldText`/`newText` 会收到明确错误(而非静默降级到旧方案)——让开发者知道 hashline 是否真在用。容错仅限不影响结果的格式归一化。
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
- > **状态**:Phase 1 纯核心库(`src/core/`)已完成,可独立测试。pi 接入层(`src/pi/`)开发中。
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` `input`:path 在工具参数里,input 只含 ops(无需 `file:` 头,工具自动注入)
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 6#b2H:
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
- - 无额外 `@d3ara1n/pi-*` 依赖;peer `@earendil-works/pi-coding-agent` pi 附带(框架级,按惯例不列)。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-hashline-edit",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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,173 @@
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
- test("replace 单行", () => {
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
- test("delete 单行 / range", () => {
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
- test("多操作乱序按位置正确应用", () => {
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("结果带 diff 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("闭环:newSnapshot 可用于下一次 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(文件已变)拒绝", () => {
115
- const s = snap("a\nb\n");
116
- const r = applyEdits("a\nCHANGED\n", [{ op: "replace", start: ln(s, 1), body: ["x"] }], s);
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, "stale");
106
+ if (!r.ok) assert.equal(r.error.kind, "anchor");
119
107
  });
120
108
 
121
- test("anchor hash 不匹配拒绝(防记错)", () => {
122
- 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);
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
- 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);
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 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
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 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
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 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
148
  if (!r.ok) assert.equal(r.error.kind, "range");
177
149
  });
178
150
 
179
- test("noopbody 字节相同)拒绝", () => {
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"] }]);
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
- * - 要求当前 `text === snapshot.text`(stale 检查);漂移由 `transforms/relocate`
6
- * 中间件在调用 apply 前处理,纯 apply 不猜。
7
- * - 每个锚的 hash 必须匹配快照中对应行(防模型记错行号/hash)。
8
- * - 操作范围不得重叠(含同插入点)。
9
- * - body 与目标字节相同 → `noop` 错误(引导模型查 bug 而非盲目重试)。
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 { buildDiff } from "./diff.ts";
15
- import { createSnapshot, joinLines, splitLines } from "./snapshot.ts";
16
- import type { ApplyResult, Edit, FileSnapshot, PatchError } from "./types.ts";
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
- /** 行级操作:把 `[lo, hi)`(0-basedhi exclusive)区间的原始行替换为 newLines */
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
- /** 校验锚匹配快照(纯 apply:text 已等于 snapshot.text,故只防记错)。 */
26
- function checkAnchor(snapshot: FileSnapshot, line: number, hash: string): PatchError | null {
27
- if (line < 1 || line > snapshot.lineHashes.length) {
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 ${snapshot.lineHashes.length} lines)`,
34
+ message: `line ${line} does not exist (file has ${lines.length} lines)`,
31
35
  };
32
36
  }
33
- if (snapshot.lineHashes[line - 1] !== hash) {
37
+ if (computeLineHash(line, lines[line - 1], hashLen) !== hash) {
34
38
  return {
35
39
  kind: "anchor",
36
- message: `hash mismatch at line ${line}: file has #${snapshot.lineHashes[line - 1]}, edit says #${hash}`,
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
- /** Edit 翻译成 SpanOp,同时校验锚与范围。 */
43
- function translateEdit(edit: Edit, snapshot: FileSnapshot): { op: SpanOp } | { error: PatchError } {
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(snapshot, edit.start.line, edit.start.hash);
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(snapshot, edit.end.line, edit.end.hash);
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(snapshot, edit.anchor.line, edit.anchor.hash);
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(snapshot, edit.anchor.line, edit.anchor.hash);
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: snapshot.lineHashes.length, hi: snapshot.lineHashes.length, newLines: edit.body } };
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
- /** 零宽区间(插入点)的"最后影响位" lo;非零宽是 hi-1 */
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 snapshot read 时记录的快照(text 必须等于当前 text)
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[], snapshot: FileSnapshot): ApplyResult {
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, snapshot);
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
- // 重叠检查:按 lo 升序,相邻 op 的起点不得落在前一个的影响区内
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
- // 从后往前应用(lo 降序),避免行号偏移
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, snapshot.lineEnding);
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
- const newSnapshot = createSnapshot(snapshot.path, newText, snapshot.hashLen);
148
- const diff = buildDiff(snapshot.path, lines, sorted);
149
- return { ok: true, text: newText, newSnapshot, changed: true, diff };
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
  }
@@ -4,46 +4,51 @@ import { computeLineHash, hashFileLines } from "./hash.ts";
4
4
 
5
5
  const ALLOWED = new Set("0123456789ABCDEFGHJKMNPQRSTVWXYZ");
6
6
 
7
- test("computeLineHash 稳定且为 base32", () => {
8
- const a = computeLineHash("p", "c", "n", 4);
9
- const b = computeLineHash("p", "c", "n", 4);
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("context-aware:相同行不同邻居不同 hash", () => {
16
- const h1 = computeLineHash("a", "x", "b");
17
- const h2 = computeLineHash("c", "x", "d");
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("context-aware:相同三元组相同 hash", () => {
22
- assert.equal(computeLineHash("a", "x", "b"), computeLineHash("a", "x", "b"));
20
+ test("different content different hash", () => {
21
+ assert.notEqual(computeLineHash(1, "a"), computeLineHash(1, "b"));
23
22
  });
24
23
 
25
- test("base32 字符集(去 I/L/O/U)批量", () => {
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("", `line ${i}`, "", 4);
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, "collision not resolved");
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
  });