@d3ara1n/pi-hashline-edit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # @d3ara1n/pi-hashline-edit
2
+
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
+
5
+ 用「行级内容 hash + 行号」双重锚替代 pi 内置的 `oldText`/`newText` 精确匹配编辑。模型指出要改的行(带 hash 校验),而不是重打要改的代码——从根上消除 string-not-found 死循环与空白战争。
6
+
7
+ ## 设计要点
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 是否真在用。容错仅限不影响结果的格式归一化。
13
+
14
+ > **状态**:Phase 1 纯核心库(`src/core/`)已完成,可独立测试。pi 接入层(`src/pi/`)开发中。
15
+
16
+ ## 协议速览
17
+
18
+ `read` 输出(每行带锚):
19
+
20
+ ```
21
+ src/foo.ts · 6 lines
22
+ 1#aF3│import { compute } from "./util"
23
+ 2#7Qk│
24
+ 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
+ ```
29
+
30
+ `edit` 的 `input`:path 在工具参数里,input 只含 ops(无需 `file:` 头,工具自动注入)
31
+
32
+ ```
33
+ replace 4#kLp:
34
+ + if (x < 0) throw new Error("neg")
35
+
36
+ insert_after 6#b2H:
37
+ +
38
+ +export const bar = foo
39
+ ```
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pi install npm:@d3ara1n/pi-hashline-edit
45
+ ```
46
+
47
+ Or add to `~/.pi/agent/settings.json`:
48
+
49
+ ```jsonc
50
+ {
51
+ "extensions": [
52
+ "/absolute/path/to/pi-extensions/packages/pi-hashline-edit"
53
+ ]
54
+ }
55
+ ```
56
+
57
+ ## Dependencies
58
+
59
+ - 无额外 `@d3ara1n/pi-*` 依赖;peer `@earendil-works/pi-coding-agent` 随 pi 附带(框架级,按惯例不列)。
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@d3ara1n/pi-hashline-edit",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Hashline-style file editing for pi — line-anchored edits verified by content hash, replacing oldText/newText matching",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi"
9
+ ],
10
+ "main": "src/index.ts",
11
+ "scripts": {
12
+ "test": "node --test src/core/*.test.ts src/pi/*.test.ts"
13
+ },
14
+ "peerDependencies": {
15
+ "@earendil-works/pi-coding-agent": "*"
16
+ },
17
+ "pi": {
18
+ "extensions": [
19
+ "./src/index.ts"
20
+ ]
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/d3ara1n/pi-extensions",
25
+ "directory": "packages/pi-hashline-edit"
26
+ },
27
+ "license": "MIT"
28
+ }
@@ -0,0 +1,185 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createSnapshot } from "./snapshot.ts";
4
+ import { applyEdits } from "./apply.ts";
5
+ import type { Edit, FileSnapshot } from "./types.ts";
6
+
7
+ const snap = (text: string): FileSnapshot => createSnapshot("f.ts", text);
8
+ const ln = (s: FileSnapshot, line: number) => ({ line, hash: s.lineHashes[line - 1] });
9
+
10
+ // —— 正常路径 ——
11
+
12
+ test("replace 单行", () => {
13
+ 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);
16
+ assert.equal(r.ok, true);
17
+ if (r.ok) assert.equal(r.text, "a\nB\nc\n");
18
+ });
19
+
20
+ test("replace range", () => {
21
+ 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
+ );
28
+ assert.equal(r.ok, true);
29
+ if (r.ok) assert.equal(r.text, "a\nX\nY\ne\n");
30
+ });
31
+
32
+ test("delete 单行 / range", () => {
33
+ 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);
36
+ assert.equal(r.ok, true);
37
+ if (r.ok) assert.equal(r.text, "a\nd\n");
38
+ });
39
+
40
+ test("insert_after", () => {
41
+ 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);
44
+ assert.equal(r.ok, true);
45
+ if (r.ok) assert.equal(r.text, "a\nx\nb\n");
46
+ });
47
+
48
+ test("insert_before", () => {
49
+ 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);
52
+ assert.equal(r.ok, true);
53
+ if (r.ok) assert.equal(r.text, "a\nx\nb\n");
54
+ });
55
+
56
+ test("append / prepend", () => {
57
+ 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
+ );
67
+ assert.equal(r.ok, true);
68
+ if (r.ok) assert.equal(r.text, "head\na\nb\ntail\n");
69
+ });
70
+
71
+ test("多操作乱序 → 按位置正确应用", () => {
72
+ 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
+ );
82
+ assert.equal(r.ok, true);
83
+ if (r.ok) assert.equal(r.text, "A\nb\nc\nz\n");
84
+ });
85
+
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);
90
+ assert.equal(r.ok, true);
91
+ 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);
95
+ }
96
+ });
97
+
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");
109
+ }
110
+ });
111
+
112
+ // —— 错误路径 ——
113
+
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);
117
+ assert.equal(r.ok, false);
118
+ if (!r.ok) assert.equal(r.error.kind, "stale");
119
+ });
120
+
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);
125
+ assert.equal(r.ok, false);
126
+ if (!r.ok) assert.equal(r.error.kind, "anchor");
127
+ });
128
+
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);
133
+ assert.equal(r.ok, false);
134
+ if (!r.ok) assert.equal(r.error.kind, "anchor");
135
+ });
136
+
137
+ test("range 逆序拒绝", () => {
138
+ 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
+ );
145
+ assert.equal(r.ok, false);
146
+ if (!r.ok) assert.equal(r.error.kind, "range");
147
+ });
148
+
149
+ test("重叠编辑拒绝", () => {
150
+ 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
+ );
160
+ assert.equal(r.ok, false);
161
+ if (!r.ok) assert.equal(r.error.kind, "range");
162
+ });
163
+
164
+ test("同插入点冲突拒绝", () => {
165
+ 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
+ );
175
+ assert.equal(r.ok, false);
176
+ if (!r.ok) assert.equal(r.error.kind, "range");
177
+ });
178
+
179
+ test("noop(body 字节相同)拒绝", () => {
180
+ const text = "a\nb\n";
181
+ const s = snap(text);
182
+ const r = applyEdits(text, [{ op: "replace", start: ln(s, 1), body: ["a"] }], s);
183
+ assert.equal(r.ok, false);
184
+ if (!r.ok) assert.equal(r.error.kind, "noop");
185
+ });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * 纯函数应用器:在快照对应的文件上应用编辑。
3
+ *
4
+ * 严格语义:
5
+ * - 要求当前 `text === snapshot.text`(stale 检查);漂移由 `transforms/relocate`
6
+ * 中间件在调用 apply 前处理,纯 apply 不猜。
7
+ * - 每个锚的 hash 必须匹配快照中对应行(防模型记错行号/hash)。
8
+ * - 操作范围不得重叠(含同插入点)。
9
+ * - body 与目标字节相同 → `noop` 错误(引导模型查 bug 而非盲目重试)。
10
+ *
11
+ * @module pi-hashline-edit/core
12
+ */
13
+
14
+ import { buildDiff } from "./diff.ts";
15
+ import { createSnapshot, joinLines, splitLines } from "./snapshot.ts";
16
+ import type { ApplyResult, Edit, FileSnapshot, PatchError } from "./types.ts";
17
+
18
+ /** 行级操作:把 `[lo, hi)`(0-based,hi exclusive)区间的原始行替换为 newLines。 */
19
+ interface SpanOp {
20
+ lo: number;
21
+ hi: number;
22
+ newLines: string[];
23
+ }
24
+
25
+ /** 校验锚匹配快照(纯 apply:text 已等于 snapshot.text,故只防记错)。 */
26
+ function checkAnchor(snapshot: FileSnapshot, line: number, hash: string): PatchError | null {
27
+ if (line < 1 || line > snapshot.lineHashes.length) {
28
+ return {
29
+ kind: "anchor",
30
+ message: `line ${line} does not exist (file has ${snapshot.lineHashes.length} lines)`,
31
+ };
32
+ }
33
+ if (snapshot.lineHashes[line - 1] !== hash) {
34
+ return {
35
+ kind: "anchor",
36
+ message: `hash mismatch at line ${line}: file has #${snapshot.lineHashes[line - 1]}, edit says #${hash}`,
37
+ };
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /** 把 Edit 翻译成 SpanOp,同时校验锚与范围。 */
43
+ function translateEdit(edit: Edit, snapshot: FileSnapshot): { op: SpanOp } | { error: PatchError } {
44
+ switch (edit.op) {
45
+ case "replace":
46
+ case "delete": {
47
+ const startErr = checkAnchor(snapshot, edit.start.line, edit.start.hash);
48
+ if (startErr) return { error: startErr };
49
+ let endLine = edit.start.line;
50
+ if (edit.end) {
51
+ const endErr = checkAnchor(snapshot, edit.end.line, edit.end.hash);
52
+ if (endErr) return { error: endErr };
53
+ endLine = edit.end.line;
54
+ }
55
+ if (endLine < edit.start.line) {
56
+ return { error: { kind: "range", message: `range ${edit.start.line}..${endLine} ends before it starts` } };
57
+ }
58
+ return {
59
+ op: {
60
+ lo: edit.start.line - 1,
61
+ hi: endLine,
62
+ newLines: edit.op === "delete" ? [] : edit.body,
63
+ },
64
+ };
65
+ }
66
+ case "insert_after": {
67
+ const err = checkAnchor(snapshot, edit.anchor.line, edit.anchor.hash);
68
+ if (err) return { error: err };
69
+ return { op: { lo: edit.anchor.line, hi: edit.anchor.line, newLines: edit.body } };
70
+ }
71
+ case "insert_before": {
72
+ const err = checkAnchor(snapshot, edit.anchor.line, edit.anchor.hash);
73
+ if (err) return { error: err };
74
+ return { op: { lo: edit.anchor.line - 1, hi: edit.anchor.line - 1, newLines: edit.body } };
75
+ }
76
+ case "append": {
77
+ return { op: { lo: snapshot.lineHashes.length, hi: snapshot.lineHashes.length, newLines: edit.body } };
78
+ }
79
+ case "prepend": {
80
+ return { op: { lo: 0, hi: 0, newLines: edit.body } };
81
+ }
82
+ }
83
+ }
84
+
85
+ /** 零宽区间(插入点)的"最后影响位"是 lo;非零宽是 hi-1。 */
86
+ function maxAffected(op: SpanOp): number {
87
+ return op.lo === op.hi ? op.lo : op.hi - 1;
88
+ }
89
+
90
+ /**
91
+ * 在快照对应的文件上应用编辑。
92
+ *
93
+ * @param text 当前文件全文
94
+ * @param edits 解析出的编辑操作
95
+ * @param snapshot read 时记录的快照(text 必须等于当前 text)
96
+ * @returns 应用结果;失败返回结构化错误
97
+ */
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
+
106
+ const lines = splitLines(text);
107
+
108
+ const ops: SpanOp[] = [];
109
+ for (const edit of edits) {
110
+ const t = translateEdit(edit, snapshot);
111
+ if ("error" in t) return { ok: false, error: t.error };
112
+ ops.push(t.op);
113
+ }
114
+
115
+ // 重叠检查:按 lo 升序,相邻 op 的起点不得落在前一个的影响区内
116
+ const sorted = [...ops].sort((a, b) => a.lo - b.lo || a.hi - b.hi);
117
+ for (let k = 1; k < sorted.length; k++) {
118
+ if (sorted[k].lo <= maxAffected(sorted[k - 1])) {
119
+ return {
120
+ ok: false,
121
+ error: {
122
+ kind: "range",
123
+ message: `overlapping edits near line ${sorted[k].lo + 1}; issue one edit per range`,
124
+ },
125
+ };
126
+ }
127
+ }
128
+
129
+ // 从后往前应用(lo 降序),避免行号偏移
130
+ let result = [...lines];
131
+ for (const op of [...sorted].sort((a, b) => b.lo - a.lo)) {
132
+ result = [...result.slice(0, op.lo), ...op.newLines, ...result.slice(op.hi)];
133
+ }
134
+
135
+ const newText = joinLines(result, snapshot.lineEnding);
136
+ if (newText === text) {
137
+ return {
138
+ ok: false,
139
+ error: {
140
+ 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",
143
+ },
144
+ };
145
+ }
146
+
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 };
150
+ }
@@ -0,0 +1,21 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { buildDiff } from "./diff.ts";
4
+
5
+ test("单 hunk", () => {
6
+ const d = buildDiff("f.ts", ["a", "b", "c"], [{ lo: 1, hi: 2, newLines: ["X"] }]);
7
+ assert.ok(d.startsWith("--- a/f.ts\n"));
8
+ assert.ok(d.includes("+++ b/f.ts"));
9
+ assert.ok(d.includes("@@ -2 +2 @@"));
10
+ assert.ok(d.includes("-b"));
11
+ assert.ok(d.includes("+X"));
12
+ });
13
+
14
+ test("多行 range hunk 带计数", () => {
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 惯例)
17
+ });
18
+
19
+ test("空 ops 返回空串", () => {
20
+ assert.equal(buildDiff("f", ["a"], []), "");
21
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * 基于 ops 的 unified diff 预览。
3
+ *
4
+ * 每个 SpanOp 生成一个 hunk,`@@` 行号基于原始文件(多 op 时各自的原始位置),
5
+ * 内容准确。这是 Phase 1 的近似实现;如需精确的多 op 行号可后续换 LCS。
6
+ *
7
+ * @module pi-hashline-edit/core
8
+ */
9
+
10
+ interface SpanOpLike {
11
+ lo: number;
12
+ hi: number;
13
+ newLines: string[];
14
+ }
15
+
16
+ /**
17
+ * 生成 unified diff。
18
+ *
19
+ * @param path 文件路径(用于 diff 头)
20
+ * @param oldLines 应用前的原始行数组
21
+ * @param ops 已应用的行级操作
22
+ */
23
+ export function buildDiff(path: string, oldLines: readonly string[], ops: readonly SpanOpLike[]): string {
24
+ if (ops.length === 0) return "";
25
+ const out: string[] = [`--- a/${path}`, `+++ b/${path}`];
26
+ for (const op of ops) {
27
+ const oldCount = op.hi - op.lo;
28
+ const oldStart = oldCount === 0 ? op.lo : op.lo + 1; // 零宽(插入点)用 lo,符合 unified-diff "after line N" 惯例
29
+ const newCount = op.newLines.length;
30
+ const newStart = op.lo + 1;
31
+ // 单行 hunk 省略计数,符合 unified-diff 惯例
32
+ const oldRange = oldCount === 1 ? `${oldStart}` : `${oldStart},${oldCount}`;
33
+ const newRange = newCount === 1 ? `${newStart}` : `${newStart},${newCount}`;
34
+ out.push(`@@ -${oldRange} +${newRange} @@`);
35
+ for (let i = op.lo; i < op.hi; i++) out.push(`-${oldLines[i]}`);
36
+ for (const nl of op.newLines) out.push(`+${nl}`);
37
+ }
38
+ return out.join("\n") + "\n";
39
+ }
@@ -0,0 +1,49 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { computeLineHash, hashFileLines } from "./hash.ts";
4
+
5
+ const ALLOWED = new Set("0123456789ABCDEFGHJKMNPQRSTVWXYZ");
6
+
7
+ test("computeLineHash 稳定且为 base32", () => {
8
+ const a = computeLineHash("p", "c", "n", 4);
9
+ const b = computeLineHash("p", "c", "n", 4);
10
+ assert.equal(a, b);
11
+ assert.equal(a.length, 4);
12
+ for (const ch of a) assert.ok(ALLOWED.has(ch), `bad char ${ch}`);
13
+ });
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);
19
+ });
20
+
21
+ test("context-aware:相同三元组 → 相同 hash", () => {
22
+ assert.equal(computeLineHash("a", "x", "b"), computeLineHash("a", "x", "b"));
23
+ });
24
+
25
+ test("base32 字符集(去 I/L/O/U)批量", () => {
26
+ for (let i = 0; i < 2000; i++) {
27
+ const h = computeLineHash("", `line ${i}`, "", 4);
28
+ for (const ch of h) assert.ok(ALLOWED.has(ch), `bad char ${ch} in ${h}`);
29
+ }
30
+ });
31
+
32
+ test("hashFileLines 长度等于行数", () => {
33
+ assert.equal(hashFileLines(["a", "b", "c"]).length, 3);
34
+ });
35
+
36
+ test("hashFileLines 空文件", () => {
37
+ assert.deepEqual(hashFileLines([]), []);
38
+ });
39
+
40
+ test("hashFileLines 文件内无碰撞(大量重复行)", () => {
41
+ const lines = ["", "", "", "", "", "}", "}", "}", "return", "return", ",", ","];
42
+ const hashes = hashFileLines(lines);
43
+ assert.equal(new Set(hashes).size, hashes.length, "collision not resolved");
44
+ });
45
+
46
+ test("hashFileLines 长度参数生效", () => {
47
+ assert.equal(hashFileLines(["a", "b"], 6)[0].length, 6);
48
+ assert.equal(hashFileLines(["a", "b"], 4)[0].length, 4);
49
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 行级 context-aware hash。
3
+ *
4
+ * 每行的 hash 把「上一行 + 本行 + 下一行」拼起来一起算,使内容相同的行
5
+ * (空行、`}`、`return`)因邻居不同而 hash 不同,文件内碰撞实际接近 0。
6
+ * 残余碰撞由 {@link hashFileLines} 自动扩展长度解决(per-file 无碰撞保证)。
7
+ *
8
+ * @module pi-hashline-edit/core
9
+ */
10
+
11
+ /** Crockford base32 字符表(去 I/L/O/U,避免易混字符)。正好 32 个。 */
12
+ const BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
13
+
14
+ /**
15
+ * FNV-1a 32-bit。稳定(同一输入永远同一输出)、分布均匀、非加密用途。
16
+ * 用 `Math.imul` 保证 32-bit 整数乘法在 JS 下正确。
17
+ */
18
+ function fnv1a32(str: string): number {
19
+ let h = 0x811c9dc5;
20
+ for (let i = 0; i < str.length; i++) {
21
+ h ^= str.charCodeAt(i);
22
+ h = Math.imul(h, 0x01000193);
23
+ }
24
+ return h >>> 0;
25
+ }
26
+
27
+ /** 把 32-bit 整数编码为指定长度的 base32 字符串。 */
28
+ function toBase32(n: number, len: number): string {
29
+ let s = "";
30
+ for (let i = 0; i < len; i++) {
31
+ s = BASE32[n & 31] + s;
32
+ n = Math.floor(n / 32);
33
+ }
34
+ return s;
35
+ }
36
+
37
+ /**
38
+ * 计算单行的 context-aware hash。
39
+ *
40
+ * @param prev 上一行内容(首行传 `""`)
41
+ * @param cur 本行内容
42
+ * @param next 下一行内容(末行传 `""`)
43
+ * @param len hash 长度(默认 4,20 bits ≈ 100 万值)
44
+ */
45
+ export function computeLineHash(prev: string, cur: string, next: string, len = 4): string {
46
+ const h = fnv1a32(`${prev}\n${cur}\n${next}`);
47
+ return toBase32(h, len);
48
+ }
49
+
50
+ /** 给每行算 raw hash(不处理碰撞)。 */
51
+ function rawHashes(lines: readonly string[], len: number): string[] {
52
+ return lines.map((line, i) =>
53
+ computeLineHash(lines[i - 1] ?? "", line, lines[i + 1] ?? "", len),
54
+ );
55
+ }
56
+
57
+ /** 找出出现 >1 次的 hash 值。 */
58
+ function duplicatedHashes(hashes: readonly string[]): Set<string> {
59
+ const counts = new Map<string, number>();
60
+ for (const h of hashes) counts.set(h, (counts.get(h) ?? 0) + 1);
61
+ return new Set([...counts.entries()].filter(([, c]) => c > 1).map(([h]) => h));
62
+ }
63
+
64
+ /** 兜底:用序号后缀强制唯一(context-aware 下理论不可达)。 */
65
+ function forceUnique(hashes: string[]): string[] {
66
+ const seen = new Map<string, number>();
67
+ return hashes.map((h) => {
68
+ const c = seen.get(h) ?? 0;
69
+ seen.set(h, c + 1);
70
+ return c === 0 ? h : `${h}${c}`;
71
+ });
72
+ }
73
+
74
+ /**
75
+ * 给整个文件的每行算 hash,并解决文件内碰撞:
76
+ * 碰撞的行自动用更长 len 重算,直到文件内唯一(per-file 无碰撞保证)。
77
+ *
78
+ * 设计依据:context-aware 已使碰撞概率接近 0;此处扩展是防御性兜底,
79
+ * 保证 apply 永远不会因 hash 歧义而误定位。
80
+ */
81
+ export function hashFileLines(lines: readonly string[], len = 4): string[] {
82
+ if (lines.length === 0) return [];
83
+ let hashes = rawHashes(lines, len);
84
+ for (let curLen = len; curLen <= len + 4; curLen++) {
85
+ const dups = duplicatedHashes(hashes);
86
+ if (dups.size === 0) return hashes;
87
+ hashes = hashes.map((h, i) =>
88
+ dups.has(h)
89
+ ? computeLineHash(lines[i - 1] ?? "", lines[i] ?? "", lines[i + 1] ?? "", curLen + 1)
90
+ : h,
91
+ );
92
+ }
93
+ return forceUnique(hashes);
94
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * pi-hashline-edit 核心库公共 API。
3
+ *
4
+ * 纯 hashline 引擎,零 pi 依赖,可独立 `node --test`。
5
+ * pi 接入层在 `../pi/` 下。
6
+ *
7
+ * @module pi-hashline-edit/core
8
+ */
9
+
10
+ export * from "./types.ts";
11
+ export { computeLineHash, hashFileLines } from "./hash.ts";
12
+ export { splitLines, joinLines, createSnapshot, verifyAnchor } from "./snapshot.ts";
13
+ export type { AnchorVerifyResult } from "./snapshot.ts";
14
+ export { parsePatch } from "./parse.ts";
15
+ export type { ParseResult } from "./parse.ts";
16
+ export { applyEdits } from "./apply.ts";
17
+ export { buildDiff } from "./diff.ts";