@d3ara1n/pi-hashline-edit 0.1.0 → 0.1.1

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,32 +2,27 @@
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 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.
13
13
 
14
- > **状态**:Phase 1 纯核心库(`src/core/`)已完成,可独立测试。pi 接入层(`src/pi/`)开发中。
14
+ ## Protocol
15
15
 
16
- ## 协议速览
17
-
18
- `read` 输出(每行带锚):
16
+ `read` output (each line anchored):
19
17
 
20
18
  ```
21
19
  src/foo.ts · 6 lines
22
20
  1#aF3│import { compute } from "./util"
23
21
  2#7Qk│
24
22
  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
23
  ```
29
24
 
30
- `edit` `input`:path 在工具参数里,input 只含 ops(无需 `file:` 头,工具自动注入)
25
+ `edit` `input` `path` is a tool parameter; `input` holds only ops (no `file:` header, the tool injects it):
31
26
 
32
27
  ```
33
28
  replace 4#kLp:
@@ -38,6 +33,8 @@ insert_after 6#b2H:
38
33
  +export const bar = foo
39
34
  ```
40
35
 
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 `++`/`+-`.
37
+
41
38
  ## Installation
42
39
 
43
40
  ```bash
@@ -56,4 +53,4 @@ Or add to `~/.pi/agent/settings.json`:
56
53
 
57
54
  ## Dependencies
58
55
 
59
- - 无额外 `@d3ara1n/pi-*` 依赖;peer `@earendil-works/pi-coding-agent` pi 附带(框架级,按惯例不列)。
56
+ - 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.1",
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": [
@@ -7,9 +7,9 @@ import type { Edit, FileSnapshot } from "./types.ts";
7
7
  const snap = (text: string): FileSnapshot => createSnapshot("f.ts", text);
8
8
  const ln = (s: FileSnapshot, line: number) => ({ line, hash: s.lineHashes[line - 1] });
9
9
 
10
- // —— 正常路径 ——
10
+ // --- happy paths ---
11
11
 
12
- test("replace 单行", () => {
12
+ test("replace single line", () => {
13
13
  const text = "a\nb\nc\n";
14
14
  const s = snap(text);
15
15
  const r = applyEdits(text, [{ op: "replace", start: ln(s, 2), body: ["B"] }], s);
@@ -29,7 +29,7 @@ test("replace range", () => {
29
29
  if (r.ok) assert.equal(r.text, "a\nX\nY\ne\n");
30
30
  });
31
31
 
32
- test("delete 单行 / range", () => {
32
+ test("delete single line / range", () => {
33
33
  const text = "a\nb\nc\nd\n";
34
34
  const s = snap(text);
35
35
  const r = applyEdits(text, [{ op: "delete", start: ln(s, 2), end: ln(s, 3) }], s);
@@ -68,7 +68,7 @@ test("append / prepend", () => {
68
68
  if (r.ok) assert.equal(r.text, "head\na\nb\ntail\n");
69
69
  });
70
70
 
71
- test("多操作乱序按位置正确应用", () => {
71
+ test("multiple out-of-order ops applied at the right positions", () => {
72
72
  const text = "a\nb\nc\n";
73
73
  const s = snap(text);
74
74
  const r = applyEdits(
@@ -83,7 +83,7 @@ test("多操作乱序 → 按位置正确应用", () => {
83
83
  if (r.ok) assert.equal(r.text, "A\nb\nc\nz\n");
84
84
  });
85
85
 
86
- test("结果带 diff newSnapshot", () => {
86
+ test("result carries diff and newSnapshot", () => {
87
87
  const text = "a\nb\n";
88
88
  const s = snap(text);
89
89
  const r = applyEdits(text, [{ op: "replace", start: ln(s, 1), body: ["A"] }], s);
@@ -95,7 +95,7 @@ test("结果带 diff 与 newSnapshot", () => {
95
95
  }
96
96
  });
97
97
 
98
- test("闭环:newSnapshot 可用于下一次 edit", () => {
98
+ test("closed loop: newSnapshot can be used for the next edit", () => {
99
99
  let s = snap("a\nb\n");
100
100
  let cur = s.text;
101
101
  const r1 = applyEdits(cur, [{ op: "replace", start: ln(s, 1), body: ["A"] }], s);
@@ -109,16 +109,16 @@ test("闭环:newSnapshot 可用于下一次 edit", () => {
109
109
  }
110
110
  });
111
111
 
112
- // —— 错误路径 ——
112
+ // --- error paths ---
113
113
 
114
- test("stale(文件已变)拒绝", () => {
114
+ test("stale (file changed) rejected", () => {
115
115
  const s = snap("a\nb\n");
116
116
  const r = applyEdits("a\nCHANGED\n", [{ op: "replace", start: ln(s, 1), body: ["x"] }], s);
117
117
  assert.equal(r.ok, false);
118
118
  if (!r.ok) assert.equal(r.error.kind, "stale");
119
119
  });
120
120
 
121
- test("anchor hash 不匹配拒绝(防记错)", () => {
121
+ test("anchor hash mismatch rejected (guards against misremembering)", () => {
122
122
  const text = "a\nb\n";
123
123
  const s = snap(text);
124
124
  const r = applyEdits(text, [{ op: "replace", start: { line: 1, hash: "WRONG" }, body: ["x"] }], s);
@@ -126,7 +126,7 @@ test("anchor hash 不匹配拒绝(防记错)", () => {
126
126
  if (!r.ok) assert.equal(r.error.kind, "anchor");
127
127
  });
128
128
 
129
- test("行号越界拒绝", () => {
129
+ test("line out of range rejected", () => {
130
130
  const text = "a\n";
131
131
  const s = snap(text);
132
132
  const r = applyEdits(text, [{ op: "replace", start: { line: 5, hash: s.lineHashes[0] }, body: ["x"] }], s);
@@ -134,7 +134,7 @@ test("行号越界拒绝", () => {
134
134
  if (!r.ok) assert.equal(r.error.kind, "anchor");
135
135
  });
136
136
 
137
- test("range 逆序拒绝", () => {
137
+ test("reverse-order range rejected", () => {
138
138
  const text = "a\nb\nc\n";
139
139
  const s = snap(text);
140
140
  const r = applyEdits(
@@ -146,7 +146,7 @@ test("range 逆序拒绝", () => {
146
146
  if (!r.ok) assert.equal(r.error.kind, "range");
147
147
  });
148
148
 
149
- test("重叠编辑拒绝", () => {
149
+ test("overlapping edits rejected", () => {
150
150
  const text = "a\nb\nc\nd\n";
151
151
  const s = snap(text);
152
152
  const r = applyEdits(
@@ -161,7 +161,7 @@ test("重叠编辑拒绝", () => {
161
161
  if (!r.ok) assert.equal(r.error.kind, "range");
162
162
  });
163
163
 
164
- test("同插入点冲突拒绝", () => {
164
+ test("conflict at the same insertion point rejected", () => {
165
165
  const text = "a\nb\n";
166
166
  const s = snap(text);
167
167
  const r = applyEdits(
@@ -176,7 +176,7 @@ test("同插入点冲突拒绝", () => {
176
176
  if (!r.ok) assert.equal(r.error.kind, "range");
177
177
  });
178
178
 
179
- test("noopbody 字节相同)拒绝", () => {
179
+ test("noop (byte-identical body) rejected", () => {
180
180
  const text = "a\nb\n";
181
181
  const s = snap(text);
182
182
  const r = applyEdits(text, [{ op: "replace", start: ln(s, 1), body: ["a"] }], s);
package/src/core/apply.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  /**
2
- * 纯函数应用器:在快照对应的文件上应用编辑。
2
+ * Pure-function applicator: applies edits to the file backing a snapshot.
3
3
  *
4
- * 严格语义:
5
- * - 要求当前 `text === snapshot.text`(stale 检查);漂移由 `transforms/relocate`
6
- * 中间件在调用 apply 前处理,纯 apply 不猜。
7
- * - 每个锚的 hash 必须匹配快照中对应行(防模型记错行号/hash)。
8
- * - 操作范围不得重叠(含同插入点)。
9
- * - body 与目标字节相同 `noop` 错误(引导模型查 bug 而非盲目重试)。
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).
10
+ * - 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).
10
13
  *
11
14
  * @module pi-hashline-edit/core
12
15
  */
@@ -15,14 +18,14 @@ import { buildDiff } from "./diff.ts";
15
18
  import { createSnapshot, joinLines, splitLines } from "./snapshot.ts";
16
19
  import type { ApplyResult, Edit, FileSnapshot, PatchError } from "./types.ts";
17
20
 
18
- /** 行级操作:把 `[lo, hi)`(0-basedhi exclusive)区间的原始行替换为 newLines */
21
+ /** Line-level operation: replace the raw lines in the `[lo, hi)` range (0-based, hi exclusive) with newLines. */
19
22
  interface SpanOp {
20
23
  lo: number;
21
24
  hi: number;
22
25
  newLines: string[];
23
26
  }
24
27
 
25
- /** 校验锚匹配快照(纯 applytext 已等于 snapshot.text,故只防记错)。 */
28
+ /** Verify the anchor matches the snapshot (pure apply: text already equals snapshot.text, so this only guards against misremembering). */
26
29
  function checkAnchor(snapshot: FileSnapshot, line: number, hash: string): PatchError | null {
27
30
  if (line < 1 || line > snapshot.lineHashes.length) {
28
31
  return {
@@ -39,7 +42,7 @@ function checkAnchor(snapshot: FileSnapshot, line: number, hash: string): PatchE
39
42
  return null;
40
43
  }
41
44
 
42
- /** Edit 翻译成 SpanOp,同时校验锚与范围。 */
45
+ /** Translate an Edit into a SpanOp, while verifying anchors and ranges. */
43
46
  function translateEdit(edit: Edit, snapshot: FileSnapshot): { op: SpanOp } | { error: PatchError } {
44
47
  switch (edit.op) {
45
48
  case "replace":
@@ -82,18 +85,18 @@ function translateEdit(edit: Edit, snapshot: FileSnapshot): { op: SpanOp } | { e
82
85
  }
83
86
  }
84
87
 
85
- /** 零宽区间(插入点)的"最后影响位" lo;非零宽是 hi-1 */
88
+ /** The "last affected position" of a zero-width range (insertion point) is lo; otherwise hi-1. */
86
89
  function maxAffected(op: SpanOp): number {
87
90
  return op.lo === op.hi ? op.lo : op.hi - 1;
88
91
  }
89
92
 
90
93
  /**
91
- * 在快照对应的文件上应用编辑。
94
+ * Apply edits to the file backing a snapshot.
92
95
  *
93
- * @param text 当前文件全文
94
- * @param edits 解析出的编辑操作
95
- * @param snapshot read 时记录的快照(text 必须等于当前 text
96
- * @returns 应用结果;失败返回结构化错误
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
97
100
  */
98
101
  export function applyEdits(text: string, edits: Edit[], snapshot: FileSnapshot): ApplyResult {
99
102
  if (text !== snapshot.text) {
@@ -112,7 +115,7 @@ export function applyEdits(text: string, edits: Edit[], snapshot: FileSnapshot):
112
115
  ops.push(t.op);
113
116
  }
114
117
 
115
- // 重叠检查:按 lo 升序,相邻 op 的起点不得落在前一个的影响区内
118
+ // Overlap check: sort ascending by lo; the next op's start must not fall inside the previous op's affected range
116
119
  const sorted = [...ops].sort((a, b) => a.lo - b.lo || a.hi - b.hi);
117
120
  for (let k = 1; k < sorted.length; k++) {
118
121
  if (sorted[k].lo <= maxAffected(sorted[k - 1])) {
@@ -126,7 +129,7 @@ export function applyEdits(text: string, edits: Edit[], snapshot: FileSnapshot):
126
129
  }
127
130
  }
128
131
 
129
- // 从后往前应用(lo 降序),避免行号偏移
132
+ // Apply back-to-front (lo descending) to avoid line-number shifts
130
133
  let result = [...lines];
131
134
  for (const op of [...sorted].sort((a, b) => b.lo - a.lo)) {
132
135
  result = [...result.slice(0, op.lo), ...op.newLines, ...result.slice(op.hi)];
@@ -2,7 +2,7 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { buildDiff } from "./diff.ts";
4
4
 
5
- test(" hunk", () => {
5
+ test("single hunk", () => {
6
6
  const d = buildDiff("f.ts", ["a", "b", "c"], [{ lo: 1, hi: 2, newLines: ["X"] }]);
7
7
  assert.ok(d.startsWith("--- a/f.ts\n"));
8
8
  assert.ok(d.includes("+++ b/f.ts"));
@@ -11,11 +11,11 @@ test("单 hunk", () => {
11
11
  assert.ok(d.includes("+X"));
12
12
  });
13
13
 
14
- test("多行 range hunk 带计数", () => {
14
+ test("multi-line range hunk carries counts", () => {
15
15
  const d = buildDiff("f", ["a", "b", "c", "d"], [{ lo: 0, hi: 3, newLines: ["X"] }]);
16
- assert.ok(d.includes("@@ -1,3 +1 @@")); // newCount=1 时省略计数(git 惯例)
16
+ assert.ok(d.includes("@@ -1,3 +1 @@")); // newCount=1 omits the count (git convention)
17
17
  });
18
18
 
19
- test(" ops 返回空串", () => {
19
+ test("empty ops returns empty string", () => {
20
20
  assert.equal(buildDiff("f", ["a"], []), "");
21
21
  });
package/src/core/diff.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
- * 基于 ops 的 unified diff 预览。
2
+ * Ops-based unified diff preview.
3
3
  *
4
- * 每个 SpanOp 生成一个 hunk,`@@` 行号基于原始文件(多 op 时各自的原始位置),
5
- * 内容准确。这是 Phase 1 的近似实现;如需精确的多 op 行号可后续换 LCS。
4
+ * Each SpanOp produces one hunk; the `@@` line numbers are based on the
5
+ * original file (each op's own original position), so the content is accurate.
6
+ * This is a Phase 1 approximation; if precise multi-op line numbers are needed,
7
+ * LCS can replace it later.
6
8
  *
7
9
  * @module pi-hashline-edit/core
8
10
  */
@@ -14,21 +16,21 @@ interface SpanOpLike {
14
16
  }
15
17
 
16
18
  /**
17
- * 生成 unified diff
19
+ * Build a unified diff.
18
20
  *
19
- * @param path 文件路径(用于 diff 头)
20
- * @param oldLines 应用前的原始行数组
21
- * @param ops 已应用的行级操作
21
+ * @param path file path (for the diff header)
22
+ * @param oldLines original line array before applying
23
+ * @param ops line-level operations applied
22
24
  */
23
25
  export function buildDiff(path: string, oldLines: readonly string[], ops: readonly SpanOpLike[]): string {
24
26
  if (ops.length === 0) return "";
25
27
  const out: string[] = [`--- a/${path}`, `+++ b/${path}`];
26
28
  for (const op of ops) {
27
29
  const oldCount = op.hi - op.lo;
28
- const oldStart = oldCount === 0 ? op.lo : op.lo + 1; // 零宽(插入点)用 lo,符合 unified-diff "after line N" 惯例
30
+ const oldStart = oldCount === 0 ? op.lo : op.lo + 1; // zero-width (insertion point) uses lo, following the unified-diff "after line N" convention
29
31
  const newCount = op.newLines.length;
30
32
  const newStart = op.lo + 1;
31
- // 单行 hunk 省略计数,符合 unified-diff 惯例
33
+ // single-line hunks omit the count, following the unified-diff convention
32
34
  const oldRange = oldCount === 1 ? `${oldStart}` : `${oldStart},${oldCount}`;
33
35
  const newRange = newCount === 1 ? `${newStart}` : `${newStart},${newCount}`;
34
36
  out.push(`@@ -${oldRange} +${newRange} @@`);
@@ -4,7 +4,7 @@ import { computeLineHash, hashFileLines } from "./hash.ts";
4
4
 
5
5
  const ALLOWED = new Set("0123456789ABCDEFGHJKMNPQRSTVWXYZ");
6
6
 
7
- test("computeLineHash 稳定且为 base32", () => {
7
+ test("computeLineHash is stable and base32", () => {
8
8
  const a = computeLineHash("p", "c", "n", 4);
9
9
  const b = computeLineHash("p", "c", "n", 4);
10
10
  assert.equal(a, b);
@@ -12,38 +12,38 @@ test("computeLineHash 稳定且为 base32", () => {
12
12
  for (const ch of a) assert.ok(ALLOWED.has(ch), `bad char ${ch}`);
13
13
  });
14
14
 
15
- test("context-aware:相同行不同邻居不同 hash", () => {
15
+ test("context-aware: same line, different neighbors different hash", () => {
16
16
  const h1 = computeLineHash("a", "x", "b");
17
17
  const h2 = computeLineHash("c", "x", "d");
18
18
  assert.notEqual(h1, h2);
19
19
  });
20
20
 
21
- test("context-aware:相同三元组相同 hash", () => {
21
+ test("context-aware: same triple same hash", () => {
22
22
  assert.equal(computeLineHash("a", "x", "b"), computeLineHash("a", "x", "b"));
23
23
  });
24
24
 
25
- test("base32 字符集(去 I/L/O/U)批量", () => {
25
+ test("base32 alphabet (without I/L/O/U) in bulk", () => {
26
26
  for (let i = 0; i < 2000; i++) {
27
27
  const h = computeLineHash("", `line ${i}`, "", 4);
28
28
  for (const ch of h) assert.ok(ALLOWED.has(ch), `bad char ${ch} in ${h}`);
29
29
  }
30
30
  });
31
31
 
32
- test("hashFileLines 长度等于行数", () => {
32
+ test("hashFileLines length equals line count", () => {
33
33
  assert.equal(hashFileLines(["a", "b", "c"]).length, 3);
34
34
  });
35
35
 
36
- test("hashFileLines 空文件", () => {
36
+ test("hashFileLines empty file", () => {
37
37
  assert.deepEqual(hashFileLines([]), []);
38
38
  });
39
39
 
40
- test("hashFileLines 文件内无碰撞(大量重复行)", () => {
40
+ test("hashFileLines has no in-file collisions (many duplicate lines)", () => {
41
41
  const lines = ["", "", "", "", "", "}", "}", "}", "return", "return", ",", ","];
42
42
  const hashes = hashFileLines(lines);
43
43
  assert.equal(new Set(hashes).size, hashes.length, "collision not resolved");
44
44
  });
45
45
 
46
- test("hashFileLines 长度参数生效", () => {
46
+ test("hashFileLines respects the length parameter", () => {
47
47
  assert.equal(hashFileLines(["a", "b"], 6)[0].length, 6);
48
48
  assert.equal(hashFileLines(["a", "b"], 4)[0].length, 4);
49
49
  });
package/src/core/hash.ts CHANGED
@@ -1,19 +1,22 @@
1
1
  /**
2
- * 行级 context-aware hash
2
+ * Per-line context-aware hash.
3
3
  *
4
- * 每行的 hash 把「上一行 + 本行 + 下一行」拼起来一起算,使内容相同的行
5
- * (空行、`}`、`return`)因邻居不同而 hash 不同,文件内碰撞实际接近 0。
6
- * 残余碰撞由 {@link hashFileLines} 自动扩展长度解决(per-file 无碰撞保证)。
4
+ * Each line's hash is computed by concatenating "previous line + this line +
5
+ * next line", so identical content lines (blank lines, `}`, `return`) get
6
+ * different hashes due to different neighbors, making in-file collisions
7
+ * effectively zero. Residual collisions are resolved by {@link hashFileLines}
8
+ * via automatic length extension (per-file zero-collision guarantee).
7
9
  *
8
10
  * @module pi-hashline-edit/core
9
11
  */
10
12
 
11
- /** Crockford base32 字符表(去 I/L/O/U,避免易混字符)。正好 32 个。 */
13
+ /** Crockford base32 alphabet (without I/L/O/U to avoid ambiguous characters). Exactly 32 characters. */
12
14
  const BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
13
15
 
14
16
  /**
15
- * FNV-1a 32-bit。稳定(同一输入永远同一输出)、分布均匀、非加密用途。
16
- * `Math.imul` 保证 32-bit 整数乘法在 JS 下正确。
17
+ * FNV-1a 32-bit. Stable (same input always yields the same output), evenly
18
+ * distributed, non-cryptographic. Uses `Math.imul` for correct 32-bit integer
19
+ * multiplication under JS.
17
20
  */
18
21
  function fnv1a32(str: string): number {
19
22
  let h = 0x811c9dc5;
@@ -24,7 +27,7 @@ function fnv1a32(str: string): number {
24
27
  return h >>> 0;
25
28
  }
26
29
 
27
- /** 32-bit 整数编码为指定长度的 base32 字符串。 */
30
+ /** Encode a 32-bit integer into a base32 string of the given length. */
28
31
  function toBase32(n: number, len: number): string {
29
32
  let s = "";
30
33
  for (let i = 0; i < len; i++) {
@@ -35,33 +38,33 @@ function toBase32(n: number, len: number): string {
35
38
  }
36
39
 
37
40
  /**
38
- * 计算单行的 context-aware hash
41
+ * Compute the context-aware hash of a single line.
39
42
  *
40
- * @param prev 上一行内容(首行传 `""`)
41
- * @param cur 本行内容
42
- * @param next 下一行内容(末行传 `""`)
43
- * @param len hash 长度(默认 420 bits ≈ 100 万值)
43
+ * @param prev previous line content (`""` for the first line)
44
+ * @param cur this line's content
45
+ * @param next next line content (`""` for the last line)
46
+ * @param len hash length (default 4, 20 bits ≈ 1M values)
44
47
  */
45
48
  export function computeLineHash(prev: string, cur: string, next: string, len = 4): string {
46
49
  const h = fnv1a32(`${prev}\n${cur}\n${next}`);
47
50
  return toBase32(h, len);
48
51
  }
49
52
 
50
- /** 给每行算 raw hash(不处理碰撞)。 */
53
+ /** Compute raw per-line hashes (no collision handling). */
51
54
  function rawHashes(lines: readonly string[], len: number): string[] {
52
55
  return lines.map((line, i) =>
53
56
  computeLineHash(lines[i - 1] ?? "", line, lines[i + 1] ?? "", len),
54
57
  );
55
58
  }
56
59
 
57
- /** 找出出现 >1 次的 hash 值。 */
60
+ /** Find hash values that appear more than once. */
58
61
  function duplicatedHashes(hashes: readonly string[]): Set<string> {
59
62
  const counts = new Map<string, number>();
60
63
  for (const h of hashes) counts.set(h, (counts.get(h) ?? 0) + 1);
61
64
  return new Set([...counts.entries()].filter(([, c]) => c > 1).map(([h]) => h));
62
65
  }
63
66
 
64
- /** 兜底:用序号后缀强制唯一(context-aware 下理论不可达)。 */
67
+ /** Fallback: force uniqueness with an index suffix (theoretically unreachable under context-aware hashing). */
65
68
  function forceUnique(hashes: string[]): string[] {
66
69
  const seen = new Map<string, number>();
67
70
  return hashes.map((h) => {
@@ -72,11 +75,13 @@ function forceUnique(hashes: string[]): string[] {
72
75
  }
73
76
 
74
77
  /**
75
- * 给整个文件的每行算 hash,并解决文件内碰撞:
76
- * 碰撞的行自动用更长 len 重算,直到文件内唯一(per-file 无碰撞保证)。
78
+ * Compute per-line hashes for the whole file and resolve in-file collisions:
79
+ * colliding lines are recomputed with a longer len until unique within the file
80
+ * (per-file zero-collision guarantee).
77
81
  *
78
- * 设计依据:context-aware 已使碰撞概率接近 0;此处扩展是防御性兜底,
79
- * 保证 apply 永远不会因 hash 歧义而误定位。
82
+ * Design rationale: context-aware hashing already drives the collision
83
+ * probability near zero; this extension is a defensive fallback guaranteeing
84
+ * apply never mislocates due to hash ambiguity.
80
85
  */
81
86
  export function hashFileLines(lines: readonly string[], len = 4): string[] {
82
87
  if (lines.length === 0) return [];
package/src/core/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * pi-hashline-edit 核心库公共 API。
2
+ * Public API of the pi-hashline-edit core library.
3
3
  *
4
- * hashline 引擎,零 pi 依赖,可独立 `node --test`。
5
- * pi 接入层在 `../pi/` 下。
4
+ * The pure hashline engine, with zero pi dependencies, runnable standalone via
5
+ * `node --test`. The pi integration layer lives under `../pi/`.
6
6
  *
7
7
  * @module pi-hashline-edit/core
8
8
  */
@@ -2,7 +2,7 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { parsePatch } from "./parse.ts";
4
4
 
5
- test("解析 replace 单行", () => {
5
+ test("parse replace single line", () => {
6
6
  const r = parsePatch("file: f.ts\n\nreplace 4#ABCD:\n+new line\n");
7
7
  assert.equal(r.ok, true);
8
8
  if (r.ok) {
@@ -12,7 +12,7 @@ test("解析 replace 单行", () => {
12
12
  }
13
13
  });
14
14
 
15
- test("解析 replace range", () => {
15
+ test("parse replace range", () => {
16
16
  const r = parsePatch("file: f.ts\nreplace 3#AAA..5#BBB:\n+a\n+b\n");
17
17
  assert.equal(r.ok, true);
18
18
  if (r.ok) {
@@ -25,19 +25,19 @@ test("解析 replace range", () => {
25
25
  }
26
26
  });
27
27
 
28
- test("解析 delete(无 body", () => {
28
+ test("parse delete (no body)", () => {
29
29
  const r = parsePatch("file: f.ts\ndelete 2#XYZ\n");
30
30
  assert.equal(r.ok, true);
31
31
  if (r.ok) assert.equal(r.patch.edits[0].op, "delete");
32
32
  });
33
33
 
34
- test("解析 insert_after / insert_before", () => {
34
+ test("parse insert_after / insert_before", () => {
35
35
  const r = parsePatch("file: f.ts\ninsert_after 3#ABC:\n+x\n\ninsert_before 5#DEF:\n+y\n");
36
36
  assert.equal(r.ok, true);
37
37
  if (r.ok) assert.equal(r.patch.edits.length, 2);
38
38
  });
39
39
 
40
- test("解析 append / prepend", () => {
40
+ test("parse append / prepend", () => {
41
41
  const r = parsePatch("file: f.ts\nappend:\n+z\n\nprepend:\n+w\n");
42
42
  assert.equal(r.ok, true);
43
43
  if (r.ok) {
@@ -46,13 +46,13 @@ test("解析 append / prepend", () => {
46
46
  }
47
47
  });
48
48
 
49
- test("多操作混合", () => {
49
+ test("multiple mixed operations", () => {
50
50
  const r = parsePatch("file: f.ts\n\nreplace 1#A:\n+x\n\ndelete 3#C\n\ninsert_after 5#E:\n+y\n");
51
51
  assert.equal(r.ok, true);
52
52
  if (r.ok) assert.equal(r.patch.edits.length, 3);
53
53
  });
54
54
 
55
- test("body 保留字面(含 + 前缀、markdown", () => {
55
+ test("body kept literally (with + prefix, markdown)", () => {
56
56
  const r = parsePatch("file: f.ts\nreplace 1#A:\n++i\n+- item\n+\n");
57
57
  assert.equal(r.ok, true);
58
58
  if (r.ok) {
@@ -61,56 +61,56 @@ test("body 保留字面(含 + 前缀、markdown)", () => {
61
61
  }
62
62
  });
63
63
 
64
- test("冒号可选", () => {
64
+ test("colon is optional", () => {
65
65
  const r = parsePatch("file: f.ts\nreplace 1#A\n+x\n");
66
66
  assert.equal(r.ok, true);
67
67
  });
68
68
 
69
- test("CRLF 归一", () => {
69
+ test("CRLF normalization", () => {
70
70
  const r = parsePatch("file: f.ts\r\nreplace 1#A:\r\n+x\r\n");
71
71
  assert.equal(r.ok, true);
72
72
  if (r.ok) {
73
73
  const e = r.patch.edits[0];
74
- if (e.op === "replace") assert.deepEqual(e.body, ["x"]); // \r
74
+ if (e.op === "replace") assert.deepEqual(e.body, ["x"]); // no \r
75
75
  }
76
76
  });
77
77
 
78
- // —— 错误路径 ——
78
+ // --- error paths ---
79
79
 
80
- test(" file 头报错", () => {
80
+ test("missing file header errors", () => {
81
81
  const r = parsePatch("replace 1#A:\n+x\n");
82
82
  assert.equal(r.ok, false);
83
83
  });
84
84
 
85
- test("空输入报错", () => {
85
+ test("empty input errors", () => {
86
86
  assert.equal(parsePatch("").ok, false);
87
87
  assert.equal(parsePatch("\n\n").ok, false);
88
88
  });
89
89
 
90
- test(" body 报错", () => {
90
+ test("empty body errors", () => {
91
91
  const r = parsePatch("file: f.ts\nreplace 1#A:\n");
92
92
  assert.equal(r.ok, false);
93
93
  if (!r.ok) assert.equal(r.error.kind, "parse");
94
94
  });
95
95
 
96
- test("未知 verb 报错(拒绝 SWAP/DEL", () => {
96
+ test("unknown verb errors (rejects SWAP/DEL)", () => {
97
97
  const r = parsePatch("file: f.ts\nSWAP 1#A:\n+x\n");
98
98
  assert.equal(r.ok, false);
99
99
  if (!r.ok) assert.equal(r.error.kind, "parse");
100
100
  });
101
101
 
102
- test("stray body 报错", () => {
102
+ test("stray body errors", () => {
103
103
  const r = parsePatch("file: f.ts\n+x\n");
104
104
  assert.equal(r.ok, false);
105
105
  });
106
106
 
107
- test("delete body 报错", () => {
107
+ test("delete with body errors", () => {
108
108
  const r = parsePatch("file: f.ts\ndelete 2#X\n+leak\n");
109
- // delete 后紧跟 + 下一轮判为 stray body
109
+ // a + line right after delete the next round treats it as a stray body
110
110
  assert.equal(r.ok, false);
111
111
  });
112
112
 
113
- test("错误含输入行号", () => {
113
+ test("error includes the input line number", () => {
114
114
  const r = parsePatch("file: f.ts\n\nreplace 1#A:\n+x\n\nSWAP 2#B:\n+y\n");
115
115
  assert.equal(r.ok, false);
116
116
  if (!r.ok) assert.ok(r.error.line && r.error.line >= 5);