@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 +12 -15
- package/package.json +1 -1
- package/src/core/apply.test.ts +14 -14
- package/src/core/apply.ts +21 -18
- package/src/core/diff.test.ts +4 -4
- package/src/core/diff.ts +11 -9
- package/src/core/hash.test.ts +8 -8
- package/src/core/hash.ts +25 -20
- package/src/core/index.ts +3 -3
- package/src/core/parse.test.ts +19 -19
- package/src/core/parse.ts +10 -9
- package/src/core/snapshot.test.ts +8 -8
- package/src/core/snapshot.ts +25 -19
- package/src/core/types.ts +19 -18
- package/src/index.ts +6 -5
- package/src/pi/config.ts +8 -7
- package/src/pi/edit-tool.ts +72 -27
- package/src/pi/execute.test.ts +15 -14
- package/src/pi/pi.test.ts +4 -4
- package/src/pi/read-tool.ts +10 -10
- package/src/pi/state.ts +13 -12
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
|
-
|
|
5
|
+
Edits reference lines by `LINE#HASH` anchors (copied from `read` output) instead of retyping the code to be changed — eliminating string-not-found loops and whitespace battles at the root.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Design
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
- **
|
|
11
|
-
-
|
|
12
|
-
-
|
|
9
|
+
- **Per-line hash + line number, dual anchor**: `read` shows each line with a short hash (`3#aF3│code`); `edit` references `LINE#HASH`. The line number is 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
|
-
|
|
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`
|
|
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
|
-
-
|
|
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
package/src/core/apply.test.ts
CHANGED
|
@@ -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
|
|
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("
|
|
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("
|
|
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("noop
|
|
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
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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)];
|
package/src/core/diff.test.ts
CHANGED
|
@@ -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("
|
|
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("
|
|
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
|
|
16
|
+
assert.ok(d.includes("@@ -1,3 +1 @@")); // newCount=1 omits the count (git convention)
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
test("
|
|
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
|
-
*
|
|
2
|
+
* Ops-based unified diff preview.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
19
|
+
* Build a unified diff.
|
|
18
20
|
*
|
|
19
|
-
* @param path
|
|
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; //
|
|
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
|
-
//
|
|
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} @@`);
|
package/src/core/hash.test.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { computeLineHash, hashFileLines } from "./hash.ts";
|
|
|
4
4
|
|
|
5
5
|
const ALLOWED = new Set("0123456789ABCDEFGHJKMNPQRSTVWXYZ");
|
|
6
6
|
|
|
7
|
-
test("computeLineHash
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
2
|
+
* Per-line context-aware hash.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
76
|
-
*
|
|
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
|
-
*
|
|
79
|
-
*
|
|
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
|
|
2
|
+
* Public API of the pi-hashline-edit core library.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 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
|
*/
|
package/src/core/parse.test.ts
CHANGED
|
@@ -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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
|
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"]); //
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
|
107
|
+
test("delete with body errors", () => {
|
|
108
108
|
const r = parsePatch("file: f.ts\ndelete 2#X\n+leak\n");
|
|
109
|
-
//
|
|
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);
|