pi-hashline-edit-pro 0.9.3 → 0.9.4

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 CHANGED
@@ -79,7 +79,7 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
79
79
  - **Request structure validation.** The request envelope (`path`, `edits`) and individual edit items are validated before any file I/O. Unknown fields, missing required fields, invalid types, and malformed anchors are rejected with `[E_BAD_SHAPE]` or `[E_BAD_REF]`.
80
80
  - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{old_range: ["<START>", "<END>"], new_lines: [...]}`.
81
81
 
82
- All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so line numbers stay consistent across operations.
82
+ All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so the hashes from a single `read` call remain valid across all edits in the batch.
83
83
 
84
84
  ### Chained edits
85
85
 
@@ -108,6 +108,7 @@ The post-edit diff (with `+`/`-` markers) is exposed to the host UI via `details
108
108
  - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{old_range: ["<START>", "<END>"], new_lines: [...]}`.
109
109
  - **Atomic writes.** Files are written via temp-file-then-rename to avoid corruption from interrupted writes. Symlink chains are resolved so the target file is updated without replacing the symlink. Hard-linked files are updated in place to preserve the shared inode. File permissions are preserved across atomic renames.
110
110
  - **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
111
+ - **Boundary duplication warnings.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), a warning is emitted. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The warning includes the surviving line's post-edit hash so the model can reference it in a follow-up edit. No autocorrection is applied — the duplicate stays in the file and the model decides whether to remove it. Raw line comparison (not trimmed) avoids false positives when indentation differs.
111
112
 
112
113
  ## Hashing
113
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
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": {
@@ -10,6 +10,7 @@ import {
10
10
  type ResolvedHashlineEdit,
11
11
  type NoopEdit,
12
12
  type HashlineEdit,
13
+ type BoundaryDuplicationWarning,
13
14
  } from "./resolve";
14
15
  import { countVisibleLines } from "../utils";
15
16
 
@@ -248,7 +249,7 @@ export function applyHashlineEdits(
248
249
  const noopEdits: NoopEdit[] = [];
249
250
  const warnings: string[] = [];
250
251
 
251
- const { resolved, mismatches } = validateAnchorEdits(
252
+ const { resolved, mismatches, boundaryWarnings } = validateAnchorEdits(
252
253
  edits,
253
254
  lineIndex.fileLines,
254
255
  fileHashes,
@@ -277,6 +278,34 @@ export function applyHashlineEdits(
277
278
  assertDoesNotEmptyFile(content, result);
278
279
  const changedRange = computeChangedLineRange(content, result);
279
280
 
281
+ // Reconstruct boundary duplication warnings with post-edit hashes.
282
+ // Use position + occurrence to find the exact surviving line (handles
283
+ // files with multiple identical lines like several `}` closings).
284
+ const resultLines = result.split("\n");
285
+ const resultHashes = computeLineHashes(result);
286
+ for (const bw of boundaryWarnings) {
287
+ let seen = 0;
288
+ let matchIndex = -1;
289
+ for (let i = 0; i < resultLines.length; i++) {
290
+ if (resultLines[i] === bw.survivingLineContent) {
291
+ if (seen === bw.occurrence) { matchIndex = i; break; }
292
+ seen++;
293
+ }
294
+ }
295
+ if (matchIndex >= 0) {
296
+ const hash = resultHashes[matchIndex];
297
+ if (bw.kind === "trailing") {
298
+ warnings.push(
299
+ `Potential boundary duplication: the last line of the replacement (${JSON.stringify(bw.replacementLineContent)}) matches the next surviving line. Surviving line hash: ${hash}`
300
+ );
301
+ } else {
302
+ warnings.push(
303
+ `Potential boundary duplication: the first line of the replacement (${JSON.stringify(bw.replacementLineContent)}) matches the preceding surviving line. Surviving line hash: ${hash}`
304
+ );
305
+ }
306
+ }
307
+ }
308
+
280
309
  return {
281
310
  content: result,
282
311
  firstChangedLine: changedRange?.firstChangedLine,
@@ -26,6 +26,7 @@ export {
26
26
  type ResolvedHashlineEdit,
27
27
  type HashlineToolEdit,
28
28
  type NoopEdit,
29
+ type BoundaryDuplicationWarning,
29
30
  describeEdit,
30
31
  resolveEditAnchors,
31
32
  validateAnchorEdits,
@@ -21,6 +21,15 @@ interface HashMismatch {
21
21
  candidates?: number[];
22
22
  }
23
23
 
24
+ export interface BoundaryDuplicationWarning {
25
+ kind: "trailing" | "leading";
26
+ survivingLineContent: string;
27
+ survivingLineIndex: number;
28
+ occurrence: number;
29
+ replacementLineContent: string;
30
+ editIndex: number;
31
+ }
32
+
24
33
  export interface NoopEdit {
25
34
  editIndex: number;
26
35
  loc: string;
@@ -233,7 +242,7 @@ export function validateAnchorEdits(
233
242
  fileHashes: string[],
234
243
  warnings: string[],
235
244
  signal: AbortSignal | undefined,
236
- ): { resolved: ResolvedHashlineEdit[]; mismatches: HashMismatch[] } {
245
+ ): { resolved: ResolvedHashlineEdit[]; mismatches: HashMismatch[]; boundaryWarnings: BoundaryDuplicationWarning[] } {
237
246
  if (fileHashes.length !== fileLines.length) {
238
247
  throw new Error(
239
248
  `validateAnchorEdits: fileHashes.length (${fileHashes.length}) must match fileLines.length (${fileLines.length}).`,
@@ -241,6 +250,7 @@ export function validateAnchorEdits(
241
250
  }
242
251
  const resolved: ResolvedHashlineEdit[] = [];
243
252
  const mismatches: HashMismatch[] = [];
253
+ const boundaryWarnings: BoundaryDuplicationWarning[] = [];
244
254
 
245
255
  const tryResolve = (ref: Anchor): ResolvedAnchor | undefined => {
246
256
  const result = resolveAnchor(ref, fileLines, fileHashes);
@@ -266,36 +276,38 @@ export function validateAnchorEdits(
266
276
  }
267
277
  const endLine = endResolved.line;
268
278
  const nextLine = fileLines[endLine];
269
- const replacementLastLine = edit.new_lines.at(-1)?.trim();
279
+ const replacementLastLine = edit.new_lines.at(-1);
270
280
  if (
271
281
  nextLine !== undefined &&
272
- replacementLastLine &&
273
- /[\p{L}\p{N}]/u.test(replacementLastLine) &&
274
- replacementLastLine === nextLine.trim()
282
+ replacementLastLine !== undefined &&
283
+ replacementLastLine.length > 0 &&
284
+ replacementLastLine === nextLine
275
285
  ) {
276
- const resolvedEdit: ResolvedHashlineEdit = {
277
- old_range: [startResolved, endResolved],
278
- new_lines: edit.new_lines,
279
- };
280
- warnings.push(
281
- `Potential boundary duplication after ${describeEdit(resolvedEdit)}: the replacement ends with a line that matches the next surviving line after trim.`,
282
- );
286
+ boundaryWarnings.push({
287
+ kind: "trailing",
288
+ survivingLineContent: nextLine,
289
+ survivingLineIndex: endLine,
290
+ occurrence: fileLines.slice(0, endLine).filter(l => l === nextLine).length,
291
+ replacementLineContent: replacementLastLine,
292
+ editIndex: resolved.length,
293
+ });
283
294
  }
284
295
  const prevLine = fileLines[startResolved.line - 2];
285
- const replacementFirstLine = edit.new_lines[0]?.trim();
296
+ const replacementFirstLine = edit.new_lines[0];
286
297
  if (
287
298
  prevLine !== undefined &&
288
- replacementFirstLine &&
289
- /[\p{L}\p{N}]/u.test(replacementFirstLine) &&
290
- replacementFirstLine === prevLine.trim()
299
+ replacementFirstLine !== undefined &&
300
+ replacementFirstLine.length > 0 &&
301
+ replacementFirstLine === prevLine
291
302
  ) {
292
- const resolvedEdit: ResolvedHashlineEdit = {
293
- old_range: [startResolved, endResolved],
294
- new_lines: edit.new_lines,
295
- };
296
- warnings.push(
297
- `Potential boundary duplication before ${describeEdit(resolvedEdit)}: the replacement starts with a line that matches the preceding surviving line after trim.`,
298
- );
303
+ boundaryWarnings.push({
304
+ kind: "leading",
305
+ survivingLineContent: prevLine,
306
+ survivingLineIndex: startResolved.line - 2,
307
+ occurrence: fileLines.slice(0, startResolved.line - 2).filter(l => l === prevLine).length,
308
+ replacementLineContent: replacementFirstLine,
309
+ editIndex: resolved.length,
310
+ });
299
311
  }
300
312
  resolved.push({
301
313
  old_range: [startResolved, endResolved],
@@ -303,7 +315,7 @@ export function validateAnchorEdits(
303
315
  });
304
316
  }
305
317
 
306
- return { resolved, mismatches };
318
+ return { resolved, mismatches, boundaryWarnings };
307
319
  }
308
320
 
309
321
  export { maybeWarnSuspiciousUnicodeEscapePlaceholder };