@d3ara1n/pi-hashline-edit 0.1.1 → 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
@@ -6,10 +6,11 @@ Edits reference lines by `LINE#HASH` anchors (copied from `read` output) instead
6
6
 
7
7
  ## Design
8
8
 
9
- - **Per-line hash + line number, dual anchor**: `read` shows each line with a short hash (`3#aF3│code`); `edit` references `LINE#HASH`. The line number is for humans, the hash for the machine naturally resilient to line drift.
10
- - **Context-aware hash**: each line's hash incorporates its neighbors, so identical lines (blank lines, `}`) get different hashes when their context differs — in-file collisions approach zero.
11
- - **Strict core + pluggable tolerance**: the core `parse apply` makes zero guesses and fails fast; tolerance (boundary repair, drift relocation, block ops) is layered as independently toggleable middleware.
12
- - **No legacy compatibility**: overrides the built-in `edit`/`read`. `edit` accepts only the hashline `input`; sending legacy `oldText`/`newText` returns an explicit error (never silently degrades) so you always know whether hashline is actually in use.
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
 
@@ -22,18 +23,19 @@ src/foo.ts · 6 lines
22
23
  3#mP0│export function foo(x: number) {
23
24
  ```
24
25
 
25
- `edit` `input` `path` is a tool parameter; `input` holds only ops (no `file:` header, the tool injects it):
26
+ `edit` takes `path` + `edits` (an array of ops, each with `op`, `anchor`/`end` `{line, hash}` from read, and `body` string[]):
26
27
 
27
- ```
28
- replace 4#kLp:
29
- + if (x < 0) throw new Error("neg")
30
-
31
- insert_after 6#b2H:
32
- +
33
- +export const bar = foo
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
+ }
34
36
  ```
35
37
 
36
- Ops: `replace LINE#HASH[..LINE#HASH]:` · `delete LINE#HASH` · `insert_after LINE#HASH:` · `insert_before LINE#HASH:` · `append:` · `prepend:`. Body rows start with `+`; `+` alone is a blank line; literal `+`/`-` lines become `++`/`+-`.
38
+ Ops: `replace` · `delete` · `insert_after` · `insert_before` · `append` · `prepend`. `anchor`/`end` = `{line, hash}` from read; `body` = new content lines (string[], omit for `delete`).
37
39
 
38
40
  ## Installation
39
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-hashline-edit",
3
- "version": "0.1.1",
3
+ "version": "0.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
15
  test("replace single line", () => {
13
16
  const text = "a\nb\nc\n";
14
- const s = snap(text);
15
- const r = applyEdits(text, [{ op: "replace", start: ln(s, 2), body: ["B"] }], s);
17
+ const r = applyEdits(text, [{ op: "replace", start: at(text, 2), body: ["B"] }]);
16
18
  assert.equal(r.ok, true);
17
19
  if (r.ok) assert.equal(r.text, "a\nB\nc\n");
18
20
  });
19
21
 
20
22
  test("replace range", () => {
21
23
  const text = "a\nb\nc\nd\ne\n";
22
- const s = snap(text);
23
- const r = applyEdits(
24
- text,
25
- [{ op: "replace", start: ln(s, 2), end: ln(s, 4), body: ["X", "Y"] }],
26
- s,
27
- );
24
+ const r = applyEdits(text, [{ op: "replace", start: at(text, 2), end: at(text, 4), body: ["X", "Y"] }]);
28
25
  assert.equal(r.ok, true);
29
26
  if (r.ok) assert.equal(r.text, "a\nX\nY\ne\n");
30
27
  });
31
28
 
32
29
  test("delete single line / range", () => {
33
30
  const text = "a\nb\nc\nd\n";
34
- const s = snap(text);
35
- const r = applyEdits(text, [{ op: "delete", start: ln(s, 2), end: ln(s, 3) }], s);
31
+ const r = applyEdits(text, [{ op: "delete", start: at(text, 2), end: at(text, 3) }]);
36
32
  assert.equal(r.ok, true);
37
33
  if (r.ok) assert.equal(r.text, "a\nd\n");
38
34
  });
39
35
 
40
36
  test("insert_after", () => {
41
37
  const text = "a\nb\n";
42
- const s = snap(text);
43
- const r = applyEdits(text, [{ op: "insert_after", anchor: ln(s, 1), body: ["x"] }], s);
38
+ const r = applyEdits(text, [{ op: "insert_after", anchor: at(text, 1), body: ["x"] }]);
44
39
  assert.equal(r.ok, true);
45
40
  if (r.ok) assert.equal(r.text, "a\nx\nb\n");
46
41
  });
47
42
 
48
43
  test("insert_before", () => {
49
44
  const text = "a\nb\n";
50
- const s = snap(text);
51
- const r = applyEdits(text, [{ op: "insert_before", anchor: ln(s, 2), body: ["x"] }], s);
45
+ const r = applyEdits(text, [{ op: "insert_before", anchor: at(text, 2), body: ["x"] }]);
52
46
  assert.equal(r.ok, true);
53
47
  if (r.ok) assert.equal(r.text, "a\nx\nb\n");
54
48
  });
55
49
 
56
50
  test("append / prepend", () => {
57
51
  const text = "a\nb\n";
58
- const s = snap(text);
59
- const r = applyEdits(
60
- text,
61
- [
62
- { op: "prepend", body: ["head"] },
63
- { op: "append", body: ["tail"] },
64
- ],
65
- s,
66
- );
52
+ const r = applyEdits(text, [
53
+ { op: "prepend", body: ["head"] },
54
+ { op: "append", body: ["tail"] },
55
+ ]);
67
56
  assert.equal(r.ok, true);
68
57
  if (r.ok) assert.equal(r.text, "head\na\nb\ntail\n");
69
58
  });
70
59
 
71
60
  test("multiple out-of-order ops → applied at the right positions", () => {
72
61
  const text = "a\nb\nc\n";
73
- const s = snap(text);
74
- const r = applyEdits(
75
- text,
76
- [
77
- { op: "insert_after", anchor: ln(s, 3), body: ["z"] },
78
- { op: "replace", start: ln(s, 1), body: ["A"] },
79
- ],
80
- s,
81
- );
62
+ const r = applyEdits(text, [
63
+ { op: "insert_after", anchor: at(text, 3), body: ["z"] },
64
+ { op: "replace", start: at(text, 1), body: ["A"] },
65
+ ]);
82
66
  assert.equal(r.ok, true);
83
67
  if (r.ok) assert.equal(r.text, "A\nb\nc\nz\n");
84
68
  });
85
69
 
86
- test("result carries diff and newSnapshot", () => {
87
- const text = "a\nb\n";
88
- const s = snap(text);
89
- const r = applyEdits(text, [{ op: "replace", start: ln(s, 1), body: ["A"] }], s);
70
+ test("touchedLines covers exactly the lines this edit produced (new-file indices)", () => {
71
+ const text = "a\nb\nc\n";
72
+ const r = applyEdits(text, [
73
+ { op: "insert_after", anchor: at(text, 3), body: ["z"] },
74
+ { op: "replace", start: at(text, 1), body: ["A"] },
75
+ ]);
90
76
  assert.equal(r.ok, true);
91
77
  if (r.ok) {
92
- assert.ok(r.diff.includes("@@"));
93
- assert.equal(r.newSnapshot.text, "A\nb\n");
94
- assert.equal(r.newSnapshot.lineHashes.length, 2);
78
+ // new file: [A, b, c, z]; this edit produced line index 0 (A) and 3 (z)
79
+ assert.deepEqual([...r.touchedLines], [0, 3]);
95
80
  }
96
81
  });
97
82
 
98
- test("closed loop: newSnapshot can be used for the next edit", () => {
99
- let s = snap("a\nb\n");
100
- let cur = s.text;
101
- const r1 = applyEdits(cur, [{ op: "replace", start: ln(s, 1), body: ["A"] }], s);
102
- assert.equal(r1.ok, true);
103
- if (r1.ok) {
104
- cur = r1.text;
105
- s = r1.newSnapshot;
106
- const r2 = applyEdits(cur, [{ op: "replace", start: ln(s, 2), body: ["B"] }], s);
107
- assert.equal(r2.ok, true);
108
- if (r2.ok) assert.equal(r2.text, "A\nB\n");
83
+ test("touchedLines for append", () => {
84
+ const text = "a\n";
85
+ const r = applyEdits(text, [{ op: "append", body: ["b", "c"] }]);
86
+ assert.equal(r.ok, true);
87
+ if (r.ok) assert.deepEqual([...r.touchedLines], [1, 2]);
88
+ });
89
+
90
+ test("touchedLines for delete re-anchors the line that shifted into the gap", () => {
91
+ const text = "a\nb\nc\nd\n";
92
+ const r = applyEdits(text, [{ op: "delete", start: at(text, 2) }]);
93
+ assert.equal(r.ok, true);
94
+ if (r.ok) {
95
+ // new file: [a, c, d]; deleting line 2 (b) shifts c into index 1
96
+ assert.deepEqual([...r.touchedLines], [1]);
109
97
  }
110
98
  });
111
99
 
112
100
  // --- error paths ---
113
101
 
114
- test("stale (file changed) rejected", () => {
115
- const s = snap("a\nb\n");
116
- const r = applyEdits("a\nCHANGED\n", [{ op: "replace", start: ln(s, 1), body: ["x"] }], s);
117
- assert.equal(r.ok, false);
118
- if (!r.ok) assert.equal(r.error.kind, "stale");
119
- });
120
-
121
- test("anchor hash mismatch rejected (guards against misremembering)", () => {
102
+ test("anchor hash mismatch rejected (line changed)", () => {
122
103
  const text = "a\nb\n";
123
- const s = snap(text);
124
- const r = applyEdits(text, [{ op: "replace", start: { line: 1, hash: "WRONG" }, body: ["x"] }], s);
104
+ const r = applyEdits(text, [{ op: "replace", start: { line: 1, hash: "WRONG" }, body: ["x"] }]);
125
105
  assert.equal(r.ok, false);
126
106
  if (!r.ok) assert.equal(r.error.kind, "anchor");
127
107
  });
128
108
 
129
109
  test("line out of range rejected", () => {
130
110
  const text = "a\n";
131
- const s = snap(text);
132
- const r = applyEdits(text, [{ op: "replace", start: { line: 5, hash: s.lineHashes[0] }, body: ["x"] }], s);
111
+ const r = applyEdits(text, [{ op: "replace", start: { line: 5, hash: computeLineHash(1, "a") }, body: ["x"] }]);
112
+ assert.equal(r.ok, false);
113
+ if (!r.ok) assert.equal(r.error.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"] }]);
133
120
  assert.equal(r.ok, false);
134
121
  if (!r.ok) assert.equal(r.error.kind, "anchor");
135
122
  });
136
123
 
137
124
  test("reverse-order range rejected", () => {
138
125
  const text = "a\nb\nc\n";
139
- const s = snap(text);
140
- const r = applyEdits(
141
- text,
142
- [{ op: "replace", start: ln(s, 3), end: ln(s, 1), body: ["x"] }],
143
- s,
144
- );
126
+ const r = applyEdits(text, [{ op: "replace", start: at(text, 3), end: at(text, 1), body: ["x"] }]);
145
127
  assert.equal(r.ok, false);
146
128
  if (!r.ok) assert.equal(r.error.kind, "range");
147
129
  });
148
130
 
149
131
  test("overlapping edits rejected", () => {
150
132
  const text = "a\nb\nc\nd\n";
151
- const s = snap(text);
152
- const r = applyEdits(
153
- text,
154
- [
155
- { op: "replace", start: ln(s, 2), end: ln(s, 3), body: ["x"] },
156
- { op: "replace", start: ln(s, 3), body: ["y"] },
157
- ],
158
- s,
159
- );
133
+ const r = applyEdits(text, [
134
+ { op: "replace", start: at(text, 2), end: at(text, 3), body: ["x"] },
135
+ { op: "replace", start: at(text, 3), body: ["y"] },
136
+ ]);
160
137
  assert.equal(r.ok, false);
161
138
  if (!r.ok) assert.equal(r.error.kind, "range");
162
139
  });
163
140
 
164
141
  test("conflict at the same insertion point rejected", () => {
165
142
  const text = "a\nb\n";
166
- const s = snap(text);
167
- const r = applyEdits(
168
- text,
169
- [
170
- { op: "insert_after", anchor: ln(s, 1), body: ["x"] },
171
- { op: "insert_after", anchor: ln(s, 1), body: ["y"] },
172
- ],
173
- s,
174
- );
143
+ const r = applyEdits(text, [
144
+ { op: "insert_after", anchor: at(text, 1), body: ["x"] },
145
+ { op: "insert_after", anchor: at(text, 1), body: ["y"] },
146
+ ]);
175
147
  assert.equal(r.ok, false);
176
148
  if (!r.ok) assert.equal(r.error.kind, "range");
177
149
  });
178
150
 
179
151
  test("noop (byte-identical body) rejected", () => {
180
152
  const text = "a\nb\n";
181
- const s = snap(text);
182
- const r = applyEdits(text, [{ op: "replace", start: ln(s, 1), body: ["a"] }], s);
153
+ const r = applyEdits(text, [{ op: "replace", start: at(text, 1), body: ["a"] }]);
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,22 +1,23 @@
1
1
  /**
2
- * Pure-function applicator: applies edits to the file backing a snapshot.
2
+ * Pure-function applicator: applies edits to a file's current text.
3
3
  *
4
- * Strict semantics:
5
- * - Requires the current `text === snapshot.text` (stale check); drift is
6
- * handled by the `transforms/relocate` middleware before calling apply pure
7
- * apply does not guess.
8
- * - Each anchor's hash must match its corresponding line in the snapshot
9
- * (guards against the model misremembering line numbers / hashes).
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 checka line that changed (or was
7
+ * misremembered) fails its own anchor; unchanged lines elsewhere never block
8
+ * the edit.
9
+ *
10
+ * Other strict semantics:
10
11
  * - Operation ranges must not overlap (including the same insertion point).
11
- * - body byte-identical to the target → `noop` error (guides the model to
12
- * investigate the bug rather than blindly retry).
12
+ * - body byte-identical to the whole-file result → `noop` error (guides the
13
+ * model to investigate rather than blindly retry).
13
14
  *
14
15
  * @module pi-hashline-edit/core
15
16
  */
16
17
 
17
- import { buildDiff } from "./diff.ts";
18
- import { createSnapshot, joinLines, splitLines } from "./snapshot.ts";
19
- 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";
20
21
 
21
22
  /** Line-level operation: replace the raw lines in the `[lo, hi)` range (0-based, hi exclusive) with newLines. */
22
23
  interface SpanOp {
@@ -25,33 +26,33 @@ interface SpanOp {
25
26
  newLines: string[];
26
27
  }
27
28
 
28
- /** Verify the anchor matches the snapshot (pure apply: text already equals snapshot.text, so this only guards against misremembering). */
29
- function checkAnchor(snapshot: FileSnapshot, line: number, hash: string): PatchError | null {
30
- 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) {
31
32
  return {
32
33
  kind: "anchor",
33
- message: `line ${line} does not exist (file has ${snapshot.lineHashes.length} lines)`,
34
+ message: `line ${line} does not exist (file has ${lines.length} lines)`,
34
35
  };
35
36
  }
36
- if (snapshot.lineHashes[line - 1] !== hash) {
37
+ if (computeLineHash(line, lines[line - 1], hashLen) !== hash) {
37
38
  return {
38
39
  kind: "anchor",
39
- 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`,
40
41
  };
41
42
  }
42
43
  return null;
43
44
  }
44
45
 
45
- /** Translate an Edit into a SpanOp, while verifying anchors and ranges. */
46
- 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 } {
47
48
  switch (edit.op) {
48
49
  case "replace":
49
50
  case "delete": {
50
- const startErr = checkAnchor(snapshot, edit.start.line, edit.start.hash);
51
+ const startErr = checkAnchor(lines, edit.start.line, edit.start.hash, hashLen);
51
52
  if (startErr) return { error: startErr };
52
53
  let endLine = edit.start.line;
53
54
  if (edit.end) {
54
- const endErr = checkAnchor(snapshot, edit.end.line, edit.end.hash);
55
+ const endErr = checkAnchor(lines, edit.end.line, edit.end.hash, hashLen);
55
56
  if (endErr) return { error: endErr };
56
57
  endLine = edit.end.line;
57
58
  }
@@ -67,17 +68,17 @@ function translateEdit(edit: Edit, snapshot: FileSnapshot): { op: SpanOp } | { e
67
68
  };
68
69
  }
69
70
  case "insert_after": {
70
- const err = checkAnchor(snapshot, edit.anchor.line, edit.anchor.hash);
71
+ const err = checkAnchor(lines, edit.anchor.line, edit.anchor.hash, hashLen);
71
72
  if (err) return { error: err };
72
73
  return { op: { lo: edit.anchor.line, hi: edit.anchor.line, newLines: edit.body } };
73
74
  }
74
75
  case "insert_before": {
75
- const err = checkAnchor(snapshot, edit.anchor.line, edit.anchor.hash);
76
+ const err = checkAnchor(lines, edit.anchor.line, edit.anchor.hash, hashLen);
76
77
  if (err) return { error: err };
77
78
  return { op: { lo: edit.anchor.line - 1, hi: edit.anchor.line - 1, newLines: edit.body } };
78
79
  }
79
80
  case "append": {
80
- 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 } };
81
82
  }
82
83
  case "prepend": {
83
84
  return { op: { lo: 0, hi: 0, newLines: edit.body } };
@@ -91,26 +92,21 @@ function maxAffected(op: SpanOp): number {
91
92
  }
92
93
 
93
94
  /**
94
- * Apply edits to the file backing a snapshot.
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.
95
98
  *
96
- * @param text current full file text
97
- * @param edits parsed edit operations
98
- * @param snapshot snapshot recorded at read time (text must equal current text)
99
- * @returns apply result; on failure returns a structured error
99
+ * @param text current full file text
100
+ * @param edits parsed edit operations
101
+ * @param hashLen hash length used to verify anchors (default 4)
100
102
  */
101
- export function applyEdits(text: string, edits: Edit[], snapshot: FileSnapshot): ApplyResult {
102
- if (text !== snapshot.text) {
103
- return {
104
- ok: false,
105
- error: { kind: "stale", message: "file changed since last read; re-read before editing" },
106
- };
107
- }
108
-
103
+ export function applyEdits(text: string, edits: Edit[], hashLen = 4): ApplyResult {
109
104
  const lines = splitLines(text);
105
+ const ending = detectLineEnding(text);
110
106
 
111
107
  const ops: SpanOp[] = [];
112
108
  for (const edit of edits) {
113
- const t = translateEdit(edit, snapshot);
109
+ const t = translateEdit(edit, lines, hashLen);
114
110
  if ("error" in t) return { ok: false, error: t.error };
115
111
  ops.push(t.op);
116
112
  }
@@ -129,25 +125,37 @@ export function applyEdits(text: string, edits: Edit[], snapshot: FileSnapshot):
129
125
  }
130
126
  }
131
127
 
132
- // Apply back-to-front (lo descending) to avoid line-number shifts
128
+ // Apply back-to-front (lo descending) so original lo/hi stay valid
133
129
  let result = [...lines];
134
130
  for (const op of [...sorted].sort((a, b) => b.lo - a.lo)) {
135
131
  result = [...result.slice(0, op.lo), ...op.newLines, ...result.slice(op.hi)];
136
132
  }
137
133
 
138
- const newText = joinLines(result, snapshot.lineEnding);
134
+ const newText = joinLines(result, ending);
139
135
  if (newText === text) {
140
136
  return {
141
137
  ok: false,
142
138
  error: {
143
139
  kind: "noop",
144
- message:
145
- "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",
146
141
  },
147
142
  };
148
143
  }
149
144
 
150
- const newSnapshot = createSnapshot(snapshot.path, newText, snapshot.hashLen);
151
- const diff = buildDiff(snapshot.path, lines, sorted);
152
- 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 };
153
161
  }
@@ -5,26 +5,29 @@ import { computeLineHash, hashFileLines } from "./hash.ts";
5
5
  const ALLOWED = new Set("0123456789ABCDEFGHJKMNPQRSTVWXYZ");
6
6
 
7
7
  test("computeLineHash is stable and base32", () => {
8
- const a = computeLineHash("p", "c", "n", 4);
9
- const b = computeLineHash("p", "c", "n", 4);
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: same line, different neighbors → different 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: same triple same hash", () => {
22
- assert.equal(computeLineHash("a", "x", "b"), computeLineHash("a", "x", "b"));
20
+ test("different contentdifferent hash", () => {
21
+ assert.notEqual(computeLineHash(1, "a"), computeLineHash(1, "b"));
22
+ });
23
+
24
+ test("same (line, content) → same hash", () => {
25
+ assert.equal(computeLineHash(7, "x"), computeLineHash(7, "x"));
23
26
  });
24
27
 
25
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
  });
@@ -37,10 +40,12 @@ test("hashFileLines empty file", () => {
37
40
  assert.deepEqual(hashFileLines([]), []);
38
41
  });
39
42
 
40
- test("hashFileLines has no in-file collisions (many duplicate lines)", () => {
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
51
  test("hashFileLines respects the length parameter", () => {