@d3ara1n/pi-hashline-edit 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Integration tests for the pi integration layer's execute: drives the real
3
- * makeReadOverride/makeEditOverride execute, covering text read with anchors,
4
- * the hashline edit closed loop, and error returns with 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.
5
5
  */
6
6
  import { test } from "node:test";
7
7
  import assert from "node:assert/strict";
@@ -10,26 +10,36 @@ import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { makeEditOverride } from "./edit-tool.ts";
12
12
  import { makeReadOverride } from "./read-tool.ts";
13
- import { clearSnapshots, getSnapshot } from "./state.ts";
13
+ import { computeLineHash } from "../core/hash.ts";
14
+ import { splitLines } from "../core/lines.ts";
14
15
 
15
16
  async function withDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
16
17
  const dir = await mkdtemp(join(tmpdir(), "hl-"));
17
- clearSnapshots();
18
18
  try {
19
19
  return await fn(dir);
20
20
  } finally {
21
21
  await rm(dir, { recursive: true, force: true });
22
- clearSnapshots();
23
22
  }
24
23
  }
25
24
 
26
25
  const call = (tool: any, params: any) => tool.execute("0", params, undefined, undefined);
27
26
 
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
+
28
39
  test("read execute: text outputs LINE#HASH│content", async () => {
29
40
  await withDir(async (dir) => {
30
41
  await writeFile(join(dir, "f.txt"), "line1\nline2\n");
31
- const read = makeReadOverride(dir);
32
- const r: any = await call(read, { path: "f.txt" });
42
+ const r: any = await call(makeReadOverride(dir), { path: "f.txt" });
33
43
  const text = r.content[0];
34
44
  assert.equal(text.type, "text");
35
45
  assert.match(text.text, /1#[0-9A-Z]+│line1/);
@@ -38,88 +48,224 @@ test("read execute: text outputs LINE#HASH│content", async () => {
38
48
  });
39
49
  });
40
50
 
41
- test("read execute: records a snapshot for edit", async () => {
51
+ test("edit execute: hashline round-trip (read edit → file changed)", async () => {
42
52
  await withDir(async (dir) => {
43
- await writeFile(join(dir, "f.txt"), "a\nb\n");
44
- const read = makeReadOverride(dir);
45
- await call(read, { path: "f.txt" });
46
- const snap = getSnapshot(join(dir, "f.txt"));
47
- assert.ok(snap, "snapshot not recorded");
48
- 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");
49
63
  });
50
64
  });
51
65
 
52
- test("edit execute: hashline closed loop (read → edit → file changed)", async () => {
66
+ test("edit execute: multiple ops in one call", async () => {
53
67
  await withDir(async (dir) => {
54
68
  const f = join(dir, "f.txt");
55
- await writeFile(f, "a\nb\nc\n");
69
+ const text = "a\nb\nc\n";
70
+ await writeFile(f, text);
56
71
  await call(makeReadOverride(dir), { path: "f.txt" });
57
- 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);
58
89
  const edit = makeEditOverride(dir);
59
- const r: any = await call(edit, {
90
+ // first edit (model cites the read anchor for line 1)
91
+ const r1: any = await call(edit, {
60
92
  path: "f.txt",
61
- input: `replace 2#${snap.lineHashes[1]}:\n+B`,
93
+ edits: [{ op: "replace", anchor: h(text, 1), body: ["A"] }],
62
94
  });
63
- assert.equal(r.isError, undefined, "should not be an error");
64
- 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");
65
105
  });
66
106
  });
67
107
 
68
- test("edit execute: consecutive edits reuse the updated snapshot", async () => {
108
+ test("edit result anchors cover an inserted block (chain an edit inside it)", async () => {
69
109
  await withDir(async (dir) => {
70
110
  const f = join(dir, "f.txt");
71
- await writeFile(f, "a\nb\n");
72
- const read = makeReadOverride(dir);
111
+ const text = "a\nb\n";
112
+ await writeFile(f, text);
73
113
  const edit = makeEditOverride(dir);
74
- await call(read, { path: "f.txt" });
75
- let snap = getSnapshot(f)!;
76
- await call(edit, { path: "f.txt", input: `replace 1#${snap.lineHashes[0]}:\n+A` });
77
- // second edit: the snapshot was updated by the edit, use the new hash
78
- snap = getSnapshot(f)!;
79
- 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
+ });
80
142
  assert.equal(r.isError, undefined);
81
- assert.equal(await readFile(f, "utf-8"), "A\nB\n");
143
+ assert.equal(await readFile(f, "utf-8"), "A\nb\nCHANGED\n");
82
144
  });
83
145
  });
84
146
 
85
- test("edit execute: edit without a prior read → anchor verification fails", async () => {
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);
159
+ });
160
+ });
161
+
162
+ test("edit execute: no read before edit → anchor verification fails", async () => {
86
163
  await withDir(async (dir) => {
87
164
  await writeFile(join(dir, "f.txt"), "a\nb\n");
88
- const edit = makeEditOverride(dir);
89
- // never read, so the hash is made up
90
- 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
+ });
91
169
  assert.equal(r.isError, true);
92
170
  });
93
171
  });
94
172
 
95
- test("edit execute: missing input → isError + missing hint", async () => {
173
+ test("edit execute: empty edits → isError", async () => {
96
174
  await withDir(async (dir) => {
97
175
  await writeFile(join(dir, "f.txt"), "a\n");
98
- const r: any = await call(makeEditOverride(dir), { path: "f.txt" });
176
+ const r: any = await call(makeEditOverride(dir), { path: "f.txt", edits: [] });
99
177
  assert.equal(r.isError, true);
100
- assert.match(r.content[0].text, /missing/);
178
+ assert.match(r.content[0].text, /empty|missing/i);
101
179
  });
102
180
  });
103
181
 
104
- test("edit execute: legacy oldText/newText isError + legacy hint", async () => {
182
+ test("edit execute: malformed op (replace without body) isError", async () => {
105
183
  await withDir(async (dir) => {
106
184
  await writeFile(join(dir, "f.txt"), "a\n");
107
185
  const r: any = await call(makeEditOverride(dir), {
108
186
  path: "f.txt",
109
- edits: [{ oldText: "a", newText: "b" }],
187
+ edits: [{ op: "replace", anchor: { line: 1, hash: "XX" } }],
110
188
  });
111
189
  assert.equal(r.isError, true);
112
- assert.match(r.content[0].text, /legacy/);
113
- assert.match(r.content[0].text, /ONLY/);
190
+ assert.match(r.content[0].text, /body/i);
114
191
  });
115
192
  });
116
193
 
117
- test("edit execute: parse error → isError", async () => {
194
+ test("edit execute: delete op", async () => {
118
195
  await withDir(async (dir) => {
119
- 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);
120
199
  await call(makeReadOverride(dir), { path: "f.txt" });
121
- const r: any = await call(makeEditOverride(dir), { path: "f.txt", input: "SWAP 1#X:\n+a" });
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);
218
+ await call(makeReadOverride(dir), { path: "f.txt" });
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
+ });
122
254
  assert.equal(r.isError, true);
123
- 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
+ }
124
270
  });
125
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 resolves relative/absolute", () => {
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 array → explicit no-degradation message", () => {
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: top-level oldText/newText also recognized as legacy", () => {
20
- const msg = missingInputError("f.ts", { oldText: "a", newText: "b" });
21
- assert.ok(msg.includes("legacy"));
22
- });
23
-
24
- test("missingInputError: only input missing (not legacy)", () => {
25
- const msg = missingInputError("f.ts", {});
26
- assert.ok(msg.includes("missing"));
27
- assert.ok(!msg.includes("legacy"));
28
- });
@@ -1,7 +1,10 @@
1
1
  /**
2
- * Override read: text files output "lineNo#hash│content" and record a snapshot;
3
- * non-text (images/binary) and read errors delegate to the built-in read, whose
4
- * renderer is inherited automatically.
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.
5
8
  *
6
9
  * @module pi-hashline-edit/pi
7
10
  */
@@ -9,13 +12,14 @@
9
12
  import { createReadTool } from "@earendil-works/pi-coding-agent";
10
13
  import { readFile } from "node:fs/promises";
11
14
  import { resolve } from "node:path";
12
- import { splitLines } from "../core/snapshot.ts";
13
- 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";
14
18
 
15
19
  const MAX_LINES = 2000;
16
20
  const MAX_BYTES = 256 * 1024;
17
21
 
18
- /** canonical path: shared by read/edit to keep the snapshot key consistent. */
22
+ /** canonical path: shared by read/edit to resolve a file consistently. */
19
23
  export function canonicalPath(cwd: string, p: string): string {
20
24
  return resolve(cwd, p);
21
25
  }
@@ -55,9 +59,7 @@ export function makeReadOverride(cwd: string) {
55
59
  const text = buf.toString("utf-8");
56
60
  const allLines = splitLines(text);
57
61
  const totalLines = allLines.length;
58
-
59
- // record a full-file snapshot (edit anchors are based on full-file line numbers)
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
- /** Reused by the edit override: look up a recorded snapshot for a path (by canonical path). */
96
- export function lookupSnapshot(cwd: string, p: string) {
97
- return getSnapshot(canonicalPath(cwd, p));
98
- }
package/src/pi/state.ts CHANGED
@@ -1,28 +1,18 @@
1
1
  /**
2
- * Session-level state: file snapshot store (LRU) + config.
2
+ * Session-level config holder.
3
3
  *
4
- * globalThis singleton (avoids Bun module-identity issues; see the repo AGENTS).
5
- * Snapshot LRU eviction (default 64 files) prevents unbounded memory growth in
6
- * long sessions — cold files are pushed out by new ones. read records; edit
7
- * verifies; key is the canonical absolute path.
8
- * config lives in state and is reloaded on 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.
9
6
  *
10
7
  * @module pi-hashline-edit/pi
11
8
  */
12
9
 
13
- import { createSnapshot } from "../core/snapshot.ts";
14
- import type { FileSnapshot } from "../core/types.ts";
15
10
  import type { HashlineEditConfig } from "./config.ts";
16
11
 
17
12
  const GLOBAL_KEY = "__piHashlineEdit";
18
13
  const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4 };
19
- /** Maximum number of cached file snapshots; beyond this the least-recently-accessed is evicted. */
20
- const MAX_SNAPSHOTS = 64;
21
14
 
22
15
  export interface HashlineEditState {
23
- /** canonical path → snapshot. LRU order: Map insertion order, most recently accessed at the end. */
24
- readonly snapshots: Map<string, FileSnapshot>;
25
- hashLen: number;
26
16
  config: HashlineEditConfig;
27
17
  }
28
18
 
@@ -30,51 +20,7 @@ export function getState(): HashlineEditState {
30
20
  const g = globalThis as Record<string, unknown>;
31
21
  const existing = g[GLOBAL_KEY];
32
22
  if (existing) return existing as HashlineEditState;
33
- const state: HashlineEditState = {
34
- snapshots: new Map(),
35
- hashLen: DEFAULT_CONFIG.hashLen,
36
- config: DEFAULT_CONFIG,
37
- };
23
+ const state: HashlineEditState = { config: DEFAULT_CONFIG };
38
24
  g[GLOBAL_KEY] = state;
39
25
  return state;
40
26
  }
41
-
42
- /** Write a snapshot and maintain LRU: move to the end (most recently used); evict the oldest (Map's first entry) when over the limit. */
43
- function touchAndEvict(map: Map<string, FileSnapshot>, path: string, snap: FileSnapshot): void {
44
- if (map.has(path)) map.delete(path);
45
- map.set(path, snap);
46
- while (map.size > MAX_SNAPSHOTS) {
47
- const oldest = map.keys().next().value;
48
- if (oldest === undefined) break;
49
- map.delete(oldest);
50
- }
51
- }
52
-
53
- /** Record a file snapshot (called on read): compute line hashes + LRU bookkeeping. */
54
- export function recordSnapshot(canonicalPath: string, text: string): FileSnapshot {
55
- const state = getState();
56
- const snap = createSnapshot(canonicalPath, text, state.hashLen);
57
- touchAndEvict(state.snapshots, canonicalPath, snap);
58
- return snap;
59
- }
60
-
61
- /** Store an already-computed snapshot (updated after a successful edit), via LRU. */
62
- export function putSnapshot(canonicalPath: string, snap: FileSnapshot): void {
63
- touchAndEvict(getState().snapshots, canonicalPath, snap);
64
- }
65
-
66
- /** Get a file snapshot (for edit verification); on a hit move it to the end (LRU touch). */
67
- export function getSnapshot(canonicalPath: string): FileSnapshot | undefined {
68
- const map = getState().snapshots;
69
- const snap = map.get(canonicalPath);
70
- if (snap) {
71
- map.delete(canonicalPath);
72
- map.set(canonicalPath, snap);
73
- }
74
- return snap;
75
- }
76
-
77
- /** Clear all snapshots (on session restart). */
78
- export function clearSnapshots(): void {
79
- getState().snapshots.clear();
80
- }
@@ -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("single 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("multi-line range hunk carries counts", () => {
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 omits the count (git convention)
17
- });
18
-
19
- test("empty ops returns empty string", () => {
20
- assert.equal(buildDiff("f", ["a"], []), "");
21
- });
package/src/core/diff.ts DELETED
@@ -1,41 +0,0 @@
1
- /**
2
- * Ops-based unified diff preview.
3
- *
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.
8
- *
9
- * @module pi-hashline-edit/core
10
- */
11
-
12
- interface SpanOpLike {
13
- lo: number;
14
- hi: number;
15
- newLines: string[];
16
- }
17
-
18
- /**
19
- * Build a unified diff.
20
- *
21
- * @param path file path (for the diff header)
22
- * @param oldLines original line array before applying
23
- * @param ops line-level operations applied
24
- */
25
- export function buildDiff(path: string, oldLines: readonly string[], ops: readonly SpanOpLike[]): string {
26
- if (ops.length === 0) return "";
27
- const out: string[] = [`--- a/${path}`, `+++ b/${path}`];
28
- for (const op of ops) {
29
- const oldCount = op.hi - op.lo;
30
- const oldStart = oldCount === 0 ? op.lo : op.lo + 1; // zero-width (insertion point) uses lo, following the unified-diff "after line N" convention
31
- const newCount = op.newLines.length;
32
- const newStart = op.lo + 1;
33
- // single-line hunks omit the count, following the unified-diff convention
34
- const oldRange = oldCount === 1 ? `${oldStart}` : `${oldStart},${oldCount}`;
35
- const newRange = newCount === 1 ? `${newStart}` : `${newStart},${newCount}`;
36
- out.push(`@@ -${oldRange} +${newRange} @@`);
37
- for (let i = op.lo; i < op.hi; i++) out.push(`-${oldLines[i]}`);
38
- for (const nl of op.newLines) out.push(`+${nl}`);
39
- }
40
- return out.join("\n") + "\n";
41
- }
@@ -1,117 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { parsePatch } from "./parse.ts";
4
-
5
- test("parse replace single line", () => {
6
- const r = parsePatch("file: f.ts\n\nreplace 4#ABCD:\n+new line\n");
7
- assert.equal(r.ok, true);
8
- if (r.ok) {
9
- assert.equal(r.patch.path, "f.ts");
10
- assert.equal(r.patch.edits.length, 1);
11
- assert.equal(r.patch.edits[0].op, "replace");
12
- }
13
- });
14
-
15
- test("parse replace range", () => {
16
- const r = parsePatch("file: f.ts\nreplace 3#AAA..5#BBB:\n+a\n+b\n");
17
- assert.equal(r.ok, true);
18
- if (r.ok) {
19
- const e = r.patch.edits[0];
20
- if (e.op === "replace") {
21
- assert.equal(e.start.line, 3);
22
- assert.equal(e.end?.line, 5);
23
- assert.deepEqual(e.body, ["a", "b"]);
24
- }
25
- }
26
- });
27
-
28
- test("parse delete (no body)", () => {
29
- const r = parsePatch("file: f.ts\ndelete 2#XYZ\n");
30
- assert.equal(r.ok, true);
31
- if (r.ok) assert.equal(r.patch.edits[0].op, "delete");
32
- });
33
-
34
- test("parse insert_after / insert_before", () => {
35
- const r = parsePatch("file: f.ts\ninsert_after 3#ABC:\n+x\n\ninsert_before 5#DEF:\n+y\n");
36
- assert.equal(r.ok, true);
37
- if (r.ok) assert.equal(r.patch.edits.length, 2);
38
- });
39
-
40
- test("parse append / prepend", () => {
41
- const r = parsePatch("file: f.ts\nappend:\n+z\n\nprepend:\n+w\n");
42
- assert.equal(r.ok, true);
43
- if (r.ok) {
44
- assert.equal(r.patch.edits[0].op, "append");
45
- assert.equal(r.patch.edits[1].op, "prepend");
46
- }
47
- });
48
-
49
- test("multiple mixed operations", () => {
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
- assert.equal(r.ok, true);
52
- if (r.ok) assert.equal(r.patch.edits.length, 3);
53
- });
54
-
55
- test("body kept literally (with + prefix, markdown)", () => {
56
- const r = parsePatch("file: f.ts\nreplace 1#A:\n++i\n+- item\n+\n");
57
- assert.equal(r.ok, true);
58
- if (r.ok) {
59
- const e = r.patch.edits[0];
60
- if (e.op === "replace") assert.deepEqual(e.body, ["+i", "- item", ""]);
61
- }
62
- });
63
-
64
- test("colon is optional", () => {
65
- const r = parsePatch("file: f.ts\nreplace 1#A\n+x\n");
66
- assert.equal(r.ok, true);
67
- });
68
-
69
- test("CRLF normalization", () => {
70
- const r = parsePatch("file: f.ts\r\nreplace 1#A:\r\n+x\r\n");
71
- assert.equal(r.ok, true);
72
- if (r.ok) {
73
- const e = r.patch.edits[0];
74
- if (e.op === "replace") assert.deepEqual(e.body, ["x"]); // no \r
75
- }
76
- });
77
-
78
- // --- error paths ---
79
-
80
- test("missing file header errors", () => {
81
- const r = parsePatch("replace 1#A:\n+x\n");
82
- assert.equal(r.ok, false);
83
- });
84
-
85
- test("empty input errors", () => {
86
- assert.equal(parsePatch("").ok, false);
87
- assert.equal(parsePatch("\n\n").ok, false);
88
- });
89
-
90
- test("empty body errors", () => {
91
- const r = parsePatch("file: f.ts\nreplace 1#A:\n");
92
- assert.equal(r.ok, false);
93
- if (!r.ok) assert.equal(r.error.kind, "parse");
94
- });
95
-
96
- test("unknown verb errors (rejects SWAP/DEL)", () => {
97
- const r = parsePatch("file: f.ts\nSWAP 1#A:\n+x\n");
98
- assert.equal(r.ok, false);
99
- if (!r.ok) assert.equal(r.error.kind, "parse");
100
- });
101
-
102
- test("stray body errors", () => {
103
- const r = parsePatch("file: f.ts\n+x\n");
104
- assert.equal(r.ok, false);
105
- });
106
-
107
- test("delete with body errors", () => {
108
- const r = parsePatch("file: f.ts\ndelete 2#X\n+leak\n");
109
- // a + line right after delete → the next round treats it as a stray body
110
- assert.equal(r.ok, false);
111
- });
112
-
113
- test("error includes the input line number", () => {
114
- const r = parsePatch("file: f.ts\n\nreplace 1#A:\n+x\n\nSWAP 2#B:\n+y\n");
115
- assert.equal(r.ok, false);
116
- if (!r.ok) assert.ok(r.error.line && r.error.line >= 5);
117
- });