pi-hashline-edit-pro 0.16.10 → 0.16.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.16.10",
3
+ "version": "0.16.11",
4
4
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
5
5
  "main": "index.ts",
6
6
  "repository": {
package/src/constants.ts CHANGED
@@ -3,3 +3,8 @@ export const SNIFF_BYTES = 8192;
3
3
  export const MAX_BYTES = 100 * 1024 * 1024;
4
4
 
5
5
  export const MAX_HASH_LINES = 1_000_000;
6
+ export const MAX_HASH_RETRIES = 262_144;
7
+
8
+ export const CONTENT_LINES_NOT_STRING_MSG =
9
+ `[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
10
+ + ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`;
package/src/fs-write.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import {
3
- lstat,
4
- mkdir,
5
- open,
6
- readlink,
7
- rename,
8
- rm,
9
- stat,
10
- writeFile,
3
+ lstat,
4
+ mkdir,
5
+ open,
6
+ readlink,
7
+ rename,
8
+ rm,
9
+ stat,
10
+ writeFile,
11
+ copyFile,
11
12
  } from "fs/promises";
12
13
  import { dirname, join, parse, resolve, sep } from "path";
13
14
  import { errCode } from "./validation";
@@ -108,6 +109,17 @@ export async function writeAtomic(
108
109
  await tempHandle.close();
109
110
  await rename(tempPath, targetPath);
110
111
  } catch (error: unknown) {
112
+ if (errCode(error) === "EXDEV") {
113
+ try {
114
+ await tempHandle.close();
115
+ await copyFile(tempPath, targetPath);
116
+ await rm(tempPath, { force: true });
117
+ return;
118
+ } catch {
119
+ try { await rm(tempPath, { force: true }); } catch {}
120
+ throw error;
121
+ }
122
+ }
111
123
  try { await rm(tempPath, { force: true }); } catch {}
112
124
  throw error;
113
125
  }
package/src/hash-store.ts CHANGED
@@ -3,7 +3,16 @@ import { stat } from "fs/promises";
3
3
  import { hashStorePath, hashStoreDir } from "./paths";
4
4
  import { errCode } from "./validation";
5
5
  import { writeAtomic } from "./fs-write";
6
-
6
+ function isValidSnapshot(value: unknown): value is FileSnapshot {
7
+ if (typeof value !== "object" || value === null) return false;
8
+ const v = value as Record<string, unknown>;
9
+ if (typeof v.content !== "string") return false;
10
+ if (!Array.isArray(v.hashes)) return false;
11
+ for (const h of v.hashes) {
12
+ if (typeof h !== "string") return false;
13
+ }
14
+ return true;
15
+ }
7
16
  export interface FileSnapshot {
8
17
  content: string;
9
18
  hashes: string[];
@@ -18,10 +27,16 @@ export async function loadHashStore(): Promise<HashStore> {
18
27
  try {
19
28
  const content = await readFile(hashStorePath(), "utf-8");
20
29
  const parsed = JSON.parse(content) as Partial<HashStore>;
21
- return {
22
- version: 1,
23
- snapshots: parsed.snapshots ?? {},
24
- };
30
+ const raw = parsed.snapshots;
31
+ const snapshots: Record<string, FileSnapshot> = {};
32
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
33
+ for (const [key, value] of Object.entries(raw)) {
34
+ if (isValidSnapshot(value)) {
35
+ snapshots[key] = value;
36
+ }
37
+ }
38
+ }
39
+ return { version: 1, snapshots };
25
40
  } catch (error: unknown) {
26
41
  if (errCode(error) !== "ENOENT") {
27
42
  console.error("Hash store corrupted, creating fresh store:", error);
@@ -1,6 +1,7 @@
1
1
  import xxhash from "xxhash-wasm";
2
2
  import * as Diff from "diff";
3
3
  import { loadHashStore, saveHashStore } from "../hash-store";
4
+ import { MAX_HASH_RETRIES } from "../constants";
4
5
 
5
6
  export const HASH_LEN = 3;
6
7
  export const ANCHOR_LEN = HASH_LEN;
@@ -79,6 +80,7 @@ export function _lineHashesPure(content: string): string[] {
79
80
  let retry = 0;
80
81
  while (assigned.has(hash)) {
81
82
  retry++;
83
+ if (retry > MAX_HASH_RETRIES) throw new Error("Hash space exhausted");
82
84
  hash = h2s(xxh32(`${c}:R${retry}`));
83
85
  }
84
86
  assigned.add(hash);
@@ -170,6 +172,7 @@ function mapStableHashes(
170
172
  let hash = h2s(xxh32(c));
171
173
  while (used.has(hash)) {
172
174
  retry++;
175
+ if (retry > MAX_HASH_RETRIES) throw new Error("Hash space exhausted");
173
176
  hash = h2s(xxh32(`${c}:R${retry}`));
174
177
  }
175
178
  used.add(hash);
@@ -5,6 +5,7 @@ import {
5
5
  HL_PREFIX_PLUS_RE,
6
6
  DIFF_MINUS_RE,
7
7
  } from "./hash";
8
+ import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
8
9
 
9
10
  export type Anchor = { hash: string };
10
11
 
@@ -58,10 +59,7 @@ function assertNoPrefixes(lines: string[]): void {
58
59
  export function parseText(edit: string[] | string | null): string[] {
59
60
  if (edit === null) return [];
60
61
  if (typeof edit === "string") {
61
- throw new Error(
62
- `[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
63
- + ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`
64
- );
62
+ throw new Error(CONTENT_LINES_NOT_STRING_MSG);
65
63
  }
66
64
  assertNoPrefixes(edit);
67
65
  return edit;
@@ -2,6 +2,7 @@ import { abortIf } from "../runtime";
2
2
  import { rejectUnknownFields } from "../utils";
3
3
  import { HL_BARE_PREFIX_RE } from "./hash";
4
4
  import { parseHashRef, parseText, type Anchor } from "./parse";
5
+ import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
5
6
 
6
7
  export type RAnchor = {
7
8
  line: number;
@@ -25,7 +26,6 @@ export interface BDupWarn {
25
26
  kind: "trailing" | "leading";
26
27
  survivingLineContent: string;
27
28
  survivingLineIndex: number;
28
- occurrence: number;
29
29
  replacementLineContent: string;
30
30
  editIndex: number;
31
31
  }
@@ -48,16 +48,12 @@ export type HTEdit = {
48
48
  hash_range_inclusive: [string, string];
49
49
  };
50
50
 
51
- function resAnchor(
51
+ function resAnchorFromMap(
52
52
  ref: Anchor,
53
- fileLines: string[],
54
- fileHashes: string[],
53
+ hashIndex: Map<string, number[]>,
55
54
  ): RAnchor | HMismatch {
56
- const hashMatches: number[] = [];
57
- for (let i = 0; i < fileHashes.length; i++) {
58
- if (fileHashes[i] === ref.hash) hashMatches.push(i + 1);
59
- }
60
- if (hashMatches.length === 0) {
55
+ const hashMatches = hashIndex.get(ref.hash);
56
+ if (!hashMatches || hashMatches.length === 0) {
61
57
  return { ref, kind: "not_found" };
62
58
  }
63
59
  if (hashMatches.length === 1) {
@@ -157,10 +153,7 @@ function assertItem(edit: Record<string, unknown>, index: number): void {
157
153
  if ("content_lines" in edit && !isStrArr(edit.content_lines)) {
158
154
  const val = edit.content_lines;
159
155
  if (typeof val === "string") {
160
- throw new Error(
161
- `[E_BAD_SHAPE] Edit ${index} field "content_lines" must be a native JSON array of strings, not a JSON string.`
162
- + ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`
163
- );
156
+ throw new Error(CONTENT_LINES_NOT_STRING_MSG);
164
157
  }
165
158
  throw new Error(`[E_BAD_SHAPE] Edit ${index} field "content_lines" must be a string array.`);
166
159
  }
@@ -238,24 +231,22 @@ export function descEdit(edit: RHEdit): string {
238
231
  }
239
232
 
240
233
  function checkBoundaryDup(
241
- adjacentLine: string | undefined,
242
- replacementEdge: string | undefined,
243
- kind: "trailing" | "leading",
244
- survivingLineIndex: number,
245
- fileLines: string[],
246
- editIndex: number,
234
+ adjacentLine: string | undefined,
235
+ replacementEdge: string | undefined,
236
+ kind: "trailing" | "leading",
237
+ survivingLineIndex: number,
238
+ editIndex: number,
247
239
  ): BDupWarn | null {
248
- if (
249
- adjacentLine === undefined ||
250
- replacementEdge === undefined ||
251
- replacementEdge.length === 0 ||
252
- replacementEdge !== adjacentLine
253
- ) return null;
240
+ if (
241
+ adjacentLine === undefined ||
242
+ replacementEdge === undefined ||
243
+ replacementEdge.length === 0 ||
244
+ replacementEdge !== adjacentLine
245
+ ) return null;
254
246
  return {
255
247
  kind,
256
248
  survivingLineContent: adjacentLine,
257
249
  survivingLineIndex,
258
- occurrence: fileLines.slice(0, survivingLineIndex).filter((l) => l === adjacentLine).length,
259
250
  replacementLineContent: replacementEdge,
260
251
  editIndex,
261
252
  };
@@ -263,54 +254,62 @@ function checkBoundaryDup(
263
254
 
264
255
 
265
256
  export function valEdits(
266
- edits: HEdit[],
267
- fileLines: string[],
268
- fileHashes: string[],
269
- warnings: string[],
270
- signal: AbortSignal | undefined,
257
+ edits: HEdit[],
258
+ fileLines: string[],
259
+ fileHashes: string[],
260
+ warnings: string[],
261
+ signal: AbortSignal | undefined,
271
262
  ): { resolved: RHEdit[]; mismatches: HMismatch[]; boundaryWarnings: BDupWarn[] } {
272
- assertAligned(fileLines, fileHashes, "valEdits");
273
- const resolved: RHEdit[] = [];
274
- const mismatches: HMismatch[] = [];
275
- const boundaryWarnings: BDupWarn[] = [];
276
-
277
- const tryResolve = (ref: Anchor): RAnchor | undefined => {
278
- const result = resAnchor(ref, fileLines, fileHashes);
279
- if ("kind" in result) {
280
- mismatches.push(result);
281
- return undefined;
282
- }
283
- return result;
284
- };
263
+ assertAligned(fileLines, fileHashes, "valEdits");
264
+ const resolved: RHEdit[] = [];
265
+ const mismatches: HMismatch[] = [];
266
+ const boundaryWarnings: BDupWarn[] = [];
285
267
 
286
- for (const edit of edits) {
287
- abortIf(signal);
288
- const startResolved = tryResolve(edit.hash_range_inclusive[0]);
289
- const endResolved = tryResolve(edit.hash_range_inclusive[1]);
290
- if (!startResolved || !endResolved) {
291
- continue;
292
- }
293
- if (startResolved.line > endResolved.line) {
294
- throw new Error(
295
- `[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.hash_range_inclusive[0].hash} and ${edit.hash_range_inclusive[1].hash}).`,
296
- );
297
- }
298
- const endLine = endResolved.line;
299
- const nextLine = fileLines[endLine];
300
- const replacementLastLine = edit.content_lines.at(-1);
301
- const trailing = checkBoundaryDup(nextLine, replacementLastLine, "trailing", endLine, fileLines, resolved.length);
302
- if (trailing) boundaryWarnings.push(trailing);
303
- const prevLine = fileLines[startResolved.line - 2];
304
- const replacementFirstLine = edit.content_lines[0];
305
- const leading = checkBoundaryDup(prevLine, replacementFirstLine, "leading", startResolved.line - 2, fileLines, resolved.length);
306
- if (leading) boundaryWarnings.push(leading);
307
- resolved.push({
308
- content_lines: edit.content_lines,
309
- hash_range_inclusive: [startResolved, endResolved],
310
- });
311
- }
268
+ const hashIndex = new Map<string, number[]>();
269
+ for (let i = 0; i < fileHashes.length; i++) {
270
+ const h = fileHashes[i]!;
271
+ const list = hashIndex.get(h) ?? [];
272
+ list.push(i + 1);
273
+ hashIndex.set(h, list);
274
+ }
275
+
276
+ const tryResolve = (ref: Anchor): RAnchor | undefined => {
277
+ const result = resAnchorFromMap(ref, hashIndex);
278
+ if ("kind" in result) {
279
+ mismatches.push(result);
280
+ return undefined;
281
+ }
282
+ return result;
283
+ };
284
+
285
+ for (const edit of edits) {
286
+ abortIf(signal);
287
+ const startResolved = tryResolve(edit.hash_range_inclusive[0]);
288
+ const endResolved = tryResolve(edit.hash_range_inclusive[1]);
289
+ if (!startResolved || !endResolved) {
290
+ continue;
291
+ }
292
+ if (startResolved.line > endResolved.line) {
293
+ throw new Error(
294
+ `[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.hash_range_inclusive[0].hash} and ${edit.hash_range_inclusive[1].hash}).`,
295
+ );
296
+ }
297
+ const endLine = endResolved.line;
298
+ const nextLine = fileLines[endLine];
299
+ const replacementLastLine = edit.content_lines.at(-1);
300
+ const trailing = checkBoundaryDup(nextLine, replacementLastLine, "trailing", endLine, resolved.length);
301
+ if (trailing) boundaryWarnings.push(trailing);
302
+ const prevLine = fileLines[startResolved.line - 2];
303
+ const replacementFirstLine = edit.content_lines[0];
304
+ const leading = checkBoundaryDup(prevLine, replacementFirstLine, "leading", startResolved.line - 2, resolved.length);
305
+ if (leading) boundaryWarnings.push(leading);
306
+ resolved.push({
307
+ content_lines: edit.content_lines,
308
+ hash_range_inclusive: [startResolved, endResolved],
309
+ });
310
+ }
312
311
 
313
- return { resolved, mismatches, boundaryWarnings };
312
+ return { resolved, mismatches, boundaryWarnings };
314
313
  }
315
314
 
316
315
  export { warnUnicodeEsc };
@@ -1,15 +1,12 @@
1
1
  import { isRec, has } from "./utils";
2
-
2
+ import { CONTENT_LINES_NOT_STRING_MSG } from "./constants";
3
3
  function assertContentLinesNotString(
4
- value: unknown,
5
- label: string,
4
+ value: unknown,
5
+ label: string,
6
6
  ): void {
7
- if (typeof value === "string") {
8
- throw new Error(
9
- `[E_BAD_SHAPE] ${label}: "content_lines" must be a native JSON array of strings, not a JSON string.`
10
- + ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`
11
- );
12
- }
7
+ if (typeof value === "string") {
8
+ throw new Error(CONTENT_LINES_NOT_STRING_MSG);
9
+ }
13
10
  }
14
11
 
15
12
  export function normalizeFilePath(record: Record<string, unknown>): void {