@d3ara1n/pi-hashline-edit 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  /**
2
- * pi 接入层 execute 集成测试:驱动真实的 makeReadOverride/makeEditOverride
3
- * execute,覆盖文本 read 带锚、hashline edit 闭环、错误返回 isError。
2
+ * pi integration execute tests: drive the real makeReadOverride/makeEditOverride
3
+ * execute, covering text read with anchors, the hashline edit round-trip,
4
+ * chained edits via returned anchors, and error returns with isError.
4
5
  */
5
6
  import { test } from "node:test";
6
7
  import assert from "node:assert/strict";
@@ -9,26 +10,36 @@ import { tmpdir } from "node:os";
9
10
  import { join } from "node:path";
10
11
  import { makeEditOverride } from "./edit-tool.ts";
11
12
  import { makeReadOverride } from "./read-tool.ts";
12
- import { clearSnapshots, getSnapshot } from "./state.ts";
13
+ import { computeLineHash } from "../core/hash.ts";
14
+ import { splitLines } from "../core/lines.ts";
13
15
 
14
16
  async function withDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
15
17
  const dir = await mkdtemp(join(tmpdir(), "hl-"));
16
- clearSnapshots();
17
18
  try {
18
19
  return await fn(dir);
19
20
  } finally {
20
21
  await rm(dir, { recursive: true, force: true });
21
- clearSnapshots();
22
22
  }
23
23
  }
24
24
 
25
25
  const call = (tool: any, params: any) => tool.execute("0", params, undefined, undefined);
26
26
 
27
- test("read execute:文本输出 LINE#HASH│content", async () => {
27
+ /** Anchor a model would copy from read output for `line` of `text` (1-based). */
28
+ function h(text: string, line: number) {
29
+ return { line, hash: computeLineHash(line, splitLines(text)[line - 1]) };
30
+ }
31
+
32
+ /** Extract a `LINE#HASH` anchor from a read/edit result text block. */
33
+ function anchorLine(block: string, line: number) {
34
+ const m = new RegExp(`^${line}#([0-9A-Z]+)│`, "m").exec(block);
35
+ if (!m) throw new Error(`line ${line} anchor not found in block`);
36
+ return { line, hash: m[1] };
37
+ }
38
+
39
+ test("read execute: text outputs LINE#HASH│content", async () => {
28
40
  await withDir(async (dir) => {
29
41
  await writeFile(join(dir, "f.txt"), "line1\nline2\n");
30
- const read = makeReadOverride(dir);
31
- const r: any = await call(read, { path: "f.txt" });
42
+ const r: any = await call(makeReadOverride(dir), { path: "f.txt" });
32
43
  const text = r.content[0];
33
44
  assert.equal(text.type, "text");
34
45
  assert.match(text.text, /1#[0-9A-Z]+│line1/);
@@ -37,88 +48,224 @@ test("read execute:文本输出 LINE#HASH│content", async () => {
37
48
  });
38
49
  });
39
50
 
40
- test("read execute:记录 snapshot edit ", async () => {
51
+ test("edit execute: hashline round-trip (read → edit → file changed)", async () => {
41
52
  await withDir(async (dir) => {
42
- await writeFile(join(dir, "f.txt"), "a\nb\n");
43
- const read = makeReadOverride(dir);
44
- await call(read, { path: "f.txt" });
45
- const snap = getSnapshot(join(dir, "f.txt"));
46
- assert.ok(snap, "snapshot 未记录");
47
- assert.equal(snap!.lineHashes.length, 2);
53
+ const f = join(dir, "f.txt");
54
+ const text = "a\nb\nc\n";
55
+ await writeFile(f, text);
56
+ await call(makeReadOverride(dir), { path: "f.txt" });
57
+ const r: any = await call(makeEditOverride(dir), {
58
+ path: "f.txt",
59
+ edits: [{ op: "replace", anchor: h(text, 2), body: ["B"] }],
60
+ });
61
+ assert.equal(r.isError, undefined, "should not be an error");
62
+ assert.equal(await readFile(f, "utf-8"), "a\nB\nc\n");
48
63
  });
49
64
  });
50
65
 
51
- test("edit execute:hashline 闭环(read edit 文件改)", async () => {
66
+ test("edit execute: multiple ops in one call", async () => {
52
67
  await withDir(async (dir) => {
53
68
  const f = join(dir, "f.txt");
54
- await writeFile(f, "a\nb\nc\n");
69
+ const text = "a\nb\nc\n";
70
+ await writeFile(f, text);
55
71
  await call(makeReadOverride(dir), { path: "f.txt" });
56
- const snap = getSnapshot(f)!;
72
+ const r: any = await call(makeEditOverride(dir), {
73
+ path: "f.txt",
74
+ edits: [
75
+ { op: "insert_after", anchor: h(text, 3), body: ["z"] },
76
+ { op: "replace", anchor: h(text, 1), body: ["A"] },
77
+ ],
78
+ });
79
+ assert.equal(r.isError, undefined);
80
+ assert.equal(await readFile(f, "utf-8"), "A\nb\nc\nz\n");
81
+ });
82
+ });
83
+
84
+ test("edit result returns Updated anchors that chain the next edit without a re-read", async () => {
85
+ await withDir(async (dir) => {
86
+ const f = join(dir, "f.txt");
87
+ const text = "a\nb\nc\n";
88
+ await writeFile(f, text);
57
89
  const edit = makeEditOverride(dir);
58
- const r: any = await call(edit, {
90
+ // first edit (model cites the read anchor for line 1)
91
+ const r1: any = await call(edit, {
59
92
  path: "f.txt",
60
- input: `replace 2#${snap.lineHashes[1]}:\n+B`,
93
+ edits: [{ op: "replace", anchor: h(text, 1), body: ["A"] }],
61
94
  });
62
- assert.equal(r.isError, undefined, "不应是错误");
63
- assert.equal(await readFile(f, "utf-8"), "a\nB\nc\n");
95
+ assert.equal(r1.isError, undefined);
96
+ const out: string = r1.content[0].text;
97
+ assert.match(out, /Updated anchors/);
98
+ // second edit chains on the anchor returned by the first edit — no read in between
99
+ const r2: any = await call(edit, {
100
+ path: "f.txt",
101
+ edits: [{ op: "replace", anchor: anchorLine(out, 1), body: ["AA"] }],
102
+ });
103
+ assert.equal(r2.isError, undefined);
104
+ assert.equal(await readFile(f, "utf-8"), "AA\nb\nc\n");
64
105
  });
65
106
  });
66
107
 
67
- test("edit execute:连续 edit 复用更新后的 snapshot", async () => {
108
+ test("edit result anchors cover an inserted block (chain an edit inside it)", async () => {
68
109
  await withDir(async (dir) => {
69
110
  const f = join(dir, "f.txt");
70
- await writeFile(f, "a\nb\n");
71
- const read = makeReadOverride(dir);
111
+ const text = "a\nb\n";
112
+ await writeFile(f, text);
72
113
  const edit = makeEditOverride(dir);
73
- await call(read, { path: "f.txt" });
74
- let snap = getSnapshot(f)!;
75
- await call(edit, { path: "f.txt", input: `replace 1#${snap.lineHashes[0]}:\n+A` });
76
- // 第二次 edit:snapshot 已被 edit 更新,用新 hash
77
- snap = getSnapshot(f)!;
78
- const r: any = await call(edit, { path: "f.txt", input: `replace 2#${snap.lineHashes[1]}:\n+B` });
114
+ const r1: any = await call(edit, {
115
+ path: "f.txt",
116
+ edits: [{ op: "insert_after", anchor: h(text, 2), body: ["c", "d", "e"] }],
117
+ });
118
+ assert.equal(r1.isError, undefined);
119
+ const out: string = r1.content[0].text;
120
+ // line 4 (d, one of the inserted lines) must be anchored in the result
121
+ const a4 = anchorLine(out, 4);
122
+ const r2: any = await call(edit, {
123
+ path: "f.txt",
124
+ edits: [{ op: "replace", anchor: a4, body: ["DD"] }],
125
+ });
126
+ assert.equal(r2.isError, undefined);
127
+ assert.equal(await readFile(f, "utf-8"), "a\nb\nc\nDD\ne\n");
128
+ });
129
+ });
130
+
131
+ test("unrelated external change does NOT block an edit on a stable line", async () => {
132
+ await withDir(async (dir) => {
133
+ const f = join(dir, "f.txt");
134
+ const text = "a\nb\nc\n";
135
+ await writeFile(f, text);
136
+ // simulate an external change at line 3 between read and edit
137
+ await writeFile(f, "a\nb\nCHANGED\n");
138
+ const r: any = await call(makeEditOverride(dir), {
139
+ path: "f.txt",
140
+ edits: [{ op: "replace", anchor: h(text, 1), body: ["A"] }],
141
+ });
79
142
  assert.equal(r.isError, undefined);
80
- assert.equal(await readFile(f, "utf-8"), "A\nB\n");
143
+ assert.equal(await readFile(f, "utf-8"), "A\nb\nCHANGED\n");
144
+ });
145
+ });
146
+
147
+ test("edit on a line that changed externally → anchor mismatch", async () => {
148
+ await withDir(async (dir) => {
149
+ const f = join(dir, "f.txt");
150
+ const text = "a\nb\nc\n";
151
+ await writeFile(f, text);
152
+ await writeFile(f, "a\nBCHANGED\nc\n"); // line 2 changed
153
+ const r: any = await call(makeEditOverride(dir), {
154
+ path: "f.txt",
155
+ edits: [{ op: "replace", anchor: h(text, 2), body: ["x"] }],
156
+ });
157
+ assert.equal(r.isError, true);
158
+ assert.match(r.content[0].text, /anchor|re-read/i);
81
159
  });
82
160
  });
83
161
 
84
- test("edit execute:无 read 直接 edit → anchor 校验失败", async () => {
162
+ test("edit execute: no read before edit → anchor verification fails", async () => {
85
163
  await withDir(async (dir) => {
86
164
  await writeFile(join(dir, "f.txt"), "a\nb\n");
87
- const edit = makeEditOverride(dir);
88
- // 没 read 过,hash 是瞎写的
89
- const r: any = await call(edit, { path: "f.txt", input: "replace 1#XXXX:\n+A" });
165
+ const r: any = await call(makeEditOverride(dir), {
166
+ path: "f.txt",
167
+ edits: [{ op: "replace", anchor: { line: 1, hash: "XXXX" }, body: ["A"] }],
168
+ });
90
169
  assert.equal(r.isError, true);
91
170
  });
92
171
  });
93
172
 
94
- test("edit execute:缺 input → isError + missing 提示", async () => {
173
+ test("edit execute: empty edits → isError", async () => {
95
174
  await withDir(async (dir) => {
96
175
  await writeFile(join(dir, "f.txt"), "a\n");
97
- const r: any = await call(makeEditOverride(dir), { path: "f.txt" });
176
+ const r: any = await call(makeEditOverride(dir), { path: "f.txt", edits: [] });
98
177
  assert.equal(r.isError, true);
99
- assert.match(r.content[0].text, /missing/);
178
+ assert.match(r.content[0].text, /empty|missing/i);
100
179
  });
101
180
  });
102
181
 
103
- test("edit execute:旧 oldText/newText isError + legacy 提示", async () => {
182
+ test("edit execute: malformed op (replace without body) → isError", async () => {
104
183
  await withDir(async (dir) => {
105
184
  await writeFile(join(dir, "f.txt"), "a\n");
106
185
  const r: any = await call(makeEditOverride(dir), {
107
186
  path: "f.txt",
108
- edits: [{ oldText: "a", newText: "b" }],
187
+ edits: [{ op: "replace", anchor: { line: 1, hash: "XX" } }],
109
188
  });
110
189
  assert.equal(r.isError, true);
111
- assert.match(r.content[0].text, /legacy/);
112
- assert.match(r.content[0].text, /ONLY/);
190
+ assert.match(r.content[0].text, /body/i);
113
191
  });
114
192
  });
115
193
 
116
- test("edit execute:parse 错误 → isError", async () => {
194
+ test("edit execute: delete op", async () => {
117
195
  await withDir(async (dir) => {
118
- await writeFile(join(dir, "f.txt"), "a\n");
196
+ const f = join(dir, "f.txt");
197
+ const text = "a\nb\nc\n";
198
+ await writeFile(f, text);
199
+ await call(makeReadOverride(dir), { path: "f.txt" });
200
+ const r: any = await call(makeEditOverride(dir), {
201
+ path: "f.txt",
202
+ edits: [{ op: "delete", anchor: h(text, 2) }],
203
+ });
204
+ assert.equal(r.isError, undefined);
205
+ assert.equal(await readFile(f, "utf-8"), "a\nc\n");
206
+ });
207
+ });
208
+
209
+ // --- renderer regression guards (details.diff must be a string, renderResult must not throw) ---
210
+
211
+ const stubTheme = { fg: (_k: string, s: string) => s, bold: (s: string) => s };
212
+
213
+ test("edit success: details.diff is a string (not the generateDiffString object)", async () => {
214
+ await withDir(async (dir) => {
215
+ const f = join(dir, "f.txt");
216
+ const text = "a\nb\nc\n";
217
+ await writeFile(f, text);
119
218
  await call(makeReadOverride(dir), { path: "f.txt" });
120
- const r: any = await call(makeEditOverride(dir), { path: "f.txt", input: "SWAP 1#X:\n+a" });
219
+ const r: any = await call(makeEditOverride(dir), {
220
+ path: "f.txt",
221
+ edits: [{ op: "replace", anchor: h(text, 2), body: ["B"] }],
222
+ });
223
+ assert.equal(typeof r.details.diff, "string", "details.diff must be a string");
224
+ assert.equal(typeof r.details.patch, "string");
225
+ assert.equal(typeof r.details.firstChangedLine, "number");
226
+ });
227
+ });
228
+
229
+ test("edit success: renderResult renders the diff without throwing", async () => {
230
+ await withDir(async (dir) => {
231
+ const f = join(dir, "f.txt");
232
+ const text = "a\nb\nc\n";
233
+ await writeFile(f, text);
234
+ const edit = makeEditOverride(dir);
235
+ const r: any = await call(edit, {
236
+ path: "f.txt",
237
+ edits: [{ op: "replace", anchor: h(text, 2), body: ["B"] }],
238
+ });
239
+ // @ts-ignore — drive the renderer with a stub theme
240
+ const comp: any = edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: true }, stubTheme, { isError: r.isError ?? false });
241
+ assert.ok(typeof comp?.text === "string");
242
+ assert.ok(comp.text.includes("B"), "rendered diff should contain the new content");
243
+ });
244
+ });
245
+
246
+ test("edit error: renderResult renders the error line without throwing", async () => {
247
+ await withDir(async (dir) => {
248
+ await writeFile(join(dir, "f.txt"), "a\n");
249
+ const edit = makeEditOverride(dir);
250
+ const r: any = await call(edit, {
251
+ path: "f.txt",
252
+ edits: [{ op: "replace", anchor: { line: 1, hash: "XXXX" }, body: ["A"] }],
253
+ });
121
254
  assert.equal(r.isError, true);
122
- assert.match(r.content[0].text, /Parse error|unknown verb/);
255
+ // @ts-ignore
256
+ const comp: any = edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: false }, stubTheme, { isError: r.isError ?? false });
257
+ assert.ok(typeof comp?.text === "string");
258
+ });
259
+ });
260
+
261
+ test("hash length stays 4 even for runs of identical lines (no explosion)", async () => {
262
+ await withDir(async (dir) => {
263
+ const f = join(dir, "f.txt");
264
+ await writeFile(f, "\n\n\n\ncode\n");
265
+ const r: any = await call(makeReadOverride(dir), { path: "f.txt" });
266
+ const text: string = r.content[0].text;
267
+ for (const m of text.matchAll(/\d+#([0-9A-Z]+)│/g)) {
268
+ assert.equal(m[1].length, 4, `anchor ${m[0]} hash is not 4 chars`);
269
+ }
123
270
  });
124
271
  });
package/src/pi/pi.test.ts CHANGED
@@ -1,28 +1,9 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { canonicalPath } from "./read-tool.ts";
4
- import { missingInputError } from "./edit-tool.ts";
5
4
 
6
- test("canonicalPath resolve 相对/绝对", () => {
5
+ test("canonicalPath resolves relative and absolute", () => {
7
6
  assert.equal(canonicalPath("/cwd", "foo.ts"), "/cwd/foo.ts");
8
7
  assert.equal(canonicalPath("/cwd", "./foo.ts"), "/cwd/foo.ts");
9
8
  assert.equal(canonicalPath("/cwd", "/abs/x.ts"), "/abs/x.ts");
10
9
  });
11
-
12
- test("missingInputError: edits 数组 → 明确告知不降级", () => {
13
- const msg = missingInputError("f.ts", { edits: [{ oldText: "a", newText: "b" }] });
14
- assert.ok(msg.includes("legacy"), msg);
15
- assert.ok(msg.includes("ONLY"), msg);
16
- assert.ok(msg.includes("f.ts"), msg);
17
- });
18
-
19
- test("missingInputError: 顶层 oldText/newText 也识别为旧格式", () => {
20
- const msg = missingInputError("f.ts", { oldText: "a", newText: "b" });
21
- assert.ok(msg.includes("legacy"));
22
- });
23
-
24
- test("missingInputError: 仅缺 input(非旧格式)", () => {
25
- const msg = missingInputError("f.ts", {});
26
- assert.ok(msg.includes("missing"));
27
- assert.ok(!msg.includes("legacy"));
28
- });
@@ -1,6 +1,10 @@
1
1
  /**
2
- * Override read:文本文件输出「行号#hash│内容」并记录快照;
3
- * 非文本(图片/二进制)与读取错误透传内置 read,renderer 自动继承。
2
+ * Override read: text files output "lineNo#hash│content"; non-text (images /
3
+ * binary) and read errors delegate to the built-in read.
4
+ *
5
+ * Hashes are computed from the current content on the fly — nothing is stored.
6
+ * The hash is `(line number, content)`, recomputed and checked at edit time, so
7
+ * no snapshot is needed to verify an anchor later.
4
8
  *
5
9
  * @module pi-hashline-edit/pi
6
10
  */
@@ -8,18 +12,19 @@
8
12
  import { createReadTool } from "@earendil-works/pi-coding-agent";
9
13
  import { readFile } from "node:fs/promises";
10
14
  import { resolve } from "node:path";
11
- import { splitLines } from "../core/snapshot.ts";
12
- import { getState, getSnapshot, recordSnapshot } from "./state.ts";
15
+ import { hashFileLines } from "../core/hash.ts";
16
+ import { splitLines } from "../core/lines.ts";
17
+ import { getState } from "./state.ts";
13
18
 
14
19
  const MAX_LINES = 2000;
15
20
  const MAX_BYTES = 256 * 1024;
16
21
 
17
- /** canonical pathread/edit 共用,保证 snapshot key 一致。 */
22
+ /** canonical path: shared by read/edit to resolve a file consistently. */
18
23
  export function canonicalPath(cwd: string, p: string): string {
19
24
  return resolve(cwd, p);
20
25
  }
21
26
 
22
- /** 构造 read override(registerTool ToolDefinition 片段)。 */
27
+ /** Build the read override (a ToolDefinition fragment for registerTool). */
23
28
  export function makeReadOverride(cwd: string) {
24
29
  const builtin = createReadTool(cwd);
25
30
 
@@ -36,8 +41,7 @@ export function makeReadOverride(cwd: string) {
36
41
  parameters: builtin.parameters,
37
42
 
38
43
  async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any) {
39
- // 未启用完全透传内置
40
- // 未启用 或 用户已取消 → 透传内置(builtin 自行处理 abort)
44
+ // Not enabled OR user cancelled delegate to the built-in (builtin handles abort itself)
41
45
  if (!getState().config.enabled || signal?.aborted) return builtin.execute(toolCallId, params, signal, onUpdate);
42
46
 
43
47
  const absPath = canonicalPath(cwd, params.path as string);
@@ -45,19 +49,17 @@ export function makeReadOverride(cwd: string) {
45
49
  try {
46
50
  buf = await readFile(absPath);
47
51
  } catch {
48
- // 读取错误透传内置(它有完善的错误信息)
52
+ // read error delegate to the built-in (it has polished error messages)
49
53
  return builtin.execute(toolCallId, params, signal, onUpdate);
50
54
  }
51
55
 
52
- // 二进制/图片检测(null 字节)→ 透传内置(内置用 file-type 处理图片)
56
+ // binary/image detection (null byte) delegate to the built-in (it uses file-type for images)
53
57
  if (buf.includes(0)) return builtin.execute(toolCallId, params, signal, onUpdate);
54
58
 
55
59
  const text = buf.toString("utf-8");
56
60
  const allLines = splitLines(text);
57
61
  const totalLines = allLines.length;
58
-
59
- // 记录全文快照(edit 锚基于全文行号)
60
- const snap = recordSnapshot(absPath, text);
62
+ const hashes = hashFileLines(allLines, getState().config.hashLen);
61
63
 
62
64
  // offset/limit
63
65
  const offset = (params.offset as number | undefined) ?? 1;
@@ -70,7 +72,7 @@ export function makeReadOverride(cwd: string) {
70
72
  let truncated = false;
71
73
  for (let i = startIdx; i < endIdx; i++) {
72
74
  const lineNo = i + 1;
73
- const row = `${lineNo}#${snap.lineHashes[i]}│${allLines[i]}`;
75
+ const row = `${lineNo}#${hashes[i]}│${allLines[i]}`;
74
76
  bytes += Buffer.byteLength(row, "utf-8");
75
77
  if (bytes > MAX_BYTES) {
76
78
  truncated = true;
@@ -91,8 +93,3 @@ export function makeReadOverride(cwd: string) {
91
93
  },
92
94
  };
93
95
  }
94
-
95
- /** 供 edit override 复用:取某 path 的已记录快照(按 canonical path)。 */
96
- export function lookupSnapshot(cwd: string, p: string) {
97
- return getSnapshot(canonicalPath(cwd, p));
98
- }
package/src/pi/state.ts CHANGED
@@ -1,27 +1,18 @@
1
1
  /**
2
- * Session 级状态:文件快照存储(LRU)+ 配置。
2
+ * Session-level config holder.
3
3
  *
4
- * globalThis 单例(规避 Bun module identity 问题,见仓库 AGENTS)。
5
- * 快照 LRU 驱逐(默认 64 个文件),防长会话内存无限增长——冷文件被新文件挤出。
6
- * read 记录、edit 校验;key 为 canonical 绝对路径。
7
- * config 放 state,session_start 重载。
4
+ * globalThis singleton (consistent with the repo's module-identity guidance).
5
+ * Config is loaded on session_start and read by the read/edit overrides.
8
6
  *
9
7
  * @module pi-hashline-edit/pi
10
8
  */
11
9
 
12
- import { createSnapshot } from "../core/snapshot.ts";
13
- import type { FileSnapshot } from "../core/types.ts";
14
10
  import type { HashlineEditConfig } from "./config.ts";
15
11
 
16
12
  const GLOBAL_KEY = "__piHashlineEdit";
17
13
  const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4 };
18
- /** 最多缓存的文件快照数;超出按 LRU 驱逐最久未访问的。 */
19
- const MAX_SNAPSHOTS = 64;
20
14
 
21
15
  export interface HashlineEditState {
22
- /** canonical path → 快照。LRU 顺序:Map 插入序,最近访问的在末尾。 */
23
- readonly snapshots: Map<string, FileSnapshot>;
24
- hashLen: number;
25
16
  config: HashlineEditConfig;
26
17
  }
27
18
 
@@ -29,51 +20,7 @@ export function getState(): HashlineEditState {
29
20
  const g = globalThis as Record<string, unknown>;
30
21
  const existing = g[GLOBAL_KEY];
31
22
  if (existing) return existing as HashlineEditState;
32
- const state: HashlineEditState = {
33
- snapshots: new Map(),
34
- hashLen: DEFAULT_CONFIG.hashLen,
35
- config: DEFAULT_CONFIG,
36
- };
23
+ const state: HashlineEditState = { config: DEFAULT_CONFIG };
37
24
  g[GLOBAL_KEY] = state;
38
25
  return state;
39
26
  }
40
-
41
- /** 写入快照并维持 LRU:移到末尾(最近使用),超限驱逐最旧(Map 首项)。 */
42
- function touchAndEvict(map: Map<string, FileSnapshot>, path: string, snap: FileSnapshot): void {
43
- if (map.has(path)) map.delete(path);
44
- map.set(path, snap);
45
- while (map.size > MAX_SNAPSHOTS) {
46
- const oldest = map.keys().next().value;
47
- if (oldest === undefined) break;
48
- map.delete(oldest);
49
- }
50
- }
51
-
52
- /** 记录文件快照(read 时调用):算行 hash + LRU。 */
53
- export function recordSnapshot(canonicalPath: string, text: string): FileSnapshot {
54
- const state = getState();
55
- const snap = createSnapshot(canonicalPath, text, state.hashLen);
56
- touchAndEvict(state.snapshots, canonicalPath, snap);
57
- return snap;
58
- }
59
-
60
- /** 存入已算好的快照(edit 成功后更新),走 LRU。 */
61
- export function putSnapshot(canonicalPath: string, snap: FileSnapshot): void {
62
- touchAndEvict(getState().snapshots, canonicalPath, snap);
63
- }
64
-
65
- /** 取文件快照(edit 校验用);命中时移到末尾(LRU touch)。 */
66
- export function getSnapshot(canonicalPath: string): FileSnapshot | undefined {
67
- const map = getState().snapshots;
68
- const snap = map.get(canonicalPath);
69
- if (snap) {
70
- map.delete(canonicalPath);
71
- map.set(canonicalPath, snap);
72
- }
73
- return snap;
74
- }
75
-
76
- /** 清空快照(session 重启时)。 */
77
- export function clearSnapshots(): void {
78
- getState().snapshots.clear();
79
- }
@@ -1,21 +0,0 @@
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
- });
package/src/core/diff.ts DELETED
@@ -1,39 +0,0 @@
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
- }