pi-hashline-edit-pro 2.1.2 → 2.2.0

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
@@ -91,7 +91,7 @@ One edit per call, with `hash_bounds` and `new_content` at the top level:
91
91
  Notes:
92
92
 
93
93
  - The request is checked before any file I/O, so a bad request never touches the file.
94
- - Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `new_content` or `hash_bounds`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. A new line that duplicates a unique line adjacent to the range is stripped automatically. `file_path` works as an alias for `path` in all three tools.
94
+ - Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `new_content` or `hash_bounds`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that duplicate unique lines adjacent to the range are stripped automatically — consecutive duplicates are stripped as a run, so re-including a whole unchanged block next to the range never duplicates it. `file_path` works as an alias for `path` in all three tools.
95
95
  - An edit that produces identical content reports `No changes made` and leaves the anchors alone.
96
96
  - After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
97
97
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 62-symbol, perfect hashing)",
6
6
  "main": "index.ts",
@@ -11,6 +11,7 @@ import {
11
11
  type NEdit,
12
12
  type HEdit,
13
13
  type AutoFix,
14
+ type BDup,
14
15
  } from "./resolve";
15
16
 
16
17
  type LIdx = {
@@ -186,7 +187,14 @@ export function applyEdit(
186
187
  ...prefixFixed,
187
188
  content_lines: [...prefixFixed.content_lines],
188
189
  };
189
- const dupsByIndex = [...boundaryDups].sort(
190
+ const seen = new Set<number>();
191
+ const uniqueDups: BDup[] = [];
192
+ for (const dup of boundaryDups) {
193
+ if (seen.has(dup.replacementLineIndex)) continue;
194
+ seen.add(dup.replacementLineIndex);
195
+ uniqueDups.push(dup);
196
+ }
197
+ const dupsByIndex = uniqueDups.sort(
190
198
  (a, b) => b.replacementLineIndex - a.replacementLineIndex,
191
199
  );
192
200
  for (const dup of dupsByIndex) {
@@ -1,4 +1,4 @@
1
- import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
1
+ import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
2
2
  import { HASH_CLASS, HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE, canon } from "./hash";
3
3
  import { parseHashRef, parseText, type Anchor } from "./parse";
4
4
  import { NEW_CONTENT_NOT_STRING_MSG } from "../constants";
@@ -283,38 +283,78 @@ export function swapReversedRanges(
283
283
  return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
284
284
  }
285
285
 
286
- function edgeRef(
287
- line: string | undefined,
288
- index: number,
289
- ): { index: number; line: string } | undefined {
290
- return line === undefined ? undefined : { index, line };
286
+ function trailingDups(
287
+ contentLines: string[],
288
+ fileLines: string[],
289
+ endLine: number,
290
+ ): BDup[] {
291
+ const start = lastNonEmptyIndex(contentLines);
292
+ if (start < 0) return [];
293
+ const dups: BDup[] = [];
294
+ const maxK = Math.min(start + 1, fileLines.length - endLine);
295
+ for (let k = 0; k < maxK; k++) {
296
+ if (contentLines[start - k] !== fileLines[endLine + k]) break;
297
+ dups.push({ kind: "trailing", replacementLineIndex: start - k });
298
+ }
299
+ return dups;
291
300
  }
292
301
 
293
- function checkBoundaryDup(
294
- adjacentLine: string | undefined,
295
- replacementEdge: { index: number; line: string } | undefined,
296
- kind: BDup["kind"],
297
- canonical = false,
298
- uniqueInFile?: Map<string, number>,
299
- ): BDup | null {
300
- if (
301
- adjacentLine === undefined ||
302
- replacementEdge === undefined ||
303
- replacementEdge.line.length === 0
304
- ) {
305
- return null;
302
+ function leadingDups(
303
+ contentLines: string[],
304
+ fileLines: string[],
305
+ startLine: number,
306
+ ): BDup[] {
307
+ const start = firstNonEmptyIndex(contentLines);
308
+ if (start < 0) return [];
309
+ const dups: BDup[] = [];
310
+ const maxK = Math.min(contentLines.length - start, startLine - 1);
311
+ for (let k = 0; k < maxK; k++) {
312
+ if (contentLines[start + k] !== fileLines[startLine - 2 - k]) break;
313
+ dups.push({ kind: "leading", replacementLineIndex: start + k });
306
314
  }
307
- const matches = canonical
308
- ? canon(adjacentLine) === canon(replacementEdge.line)
309
- : adjacentLine === replacementEdge.line;
310
- if (!matches) return null;
311
- if (uniqueInFile && (uniqueInFile.get(canon(adjacentLine)) ?? 0) !== 1) {
312
- return null;
315
+ return dups;
316
+ }
317
+
318
+ function firstNewAfterDups(
319
+ contentLines: string[],
320
+ rangeLines: string[],
321
+ fileLines: string[],
322
+ endLine: number,
323
+ fileCounts: Map<string, number>,
324
+ ): BDup[] {
325
+ const firstNew = findNewEdge(contentLines, rangeLines, false);
326
+ if (!firstNew) return [];
327
+ const dups: BDup[] = [];
328
+ const maxK = Math.min(contentLines.length - firstNew.index, fileLines.length - endLine);
329
+ for (let k = 0; k < maxK; k++) {
330
+ const newLine = contentLines[firstNew.index + k]!;
331
+ const fileLine = fileLines[endLine + k]!;
332
+ if (canon(newLine) !== canon(fileLine)) break;
333
+ if ((fileCounts.get(canon(fileLine)) ?? 0) !== 1) break;
334
+ dups.push({ kind: "first-new-after", replacementLineIndex: firstNew.index + k });
313
335
  }
314
- return {
315
- kind,
316
- replacementLineIndex: replacementEdge.index,
317
- };
336
+ return dups;
337
+ }
338
+
339
+ function lastNewBeforeDups(
340
+ contentLines: string[],
341
+ rangeLines: string[],
342
+ fileLines: string[],
343
+ startLine: number,
344
+ fileCounts: Map<string, number>,
345
+ ): BDup[] {
346
+ const lastNew = findNewEdge(contentLines, rangeLines, true);
347
+ if (!lastNew) return [];
348
+ const dups: BDup[] = [];
349
+ const maxK = Math.min(lastNew.index + 1, startLine - 1);
350
+ for (let k = 0; k < maxK; k++) {
351
+ const newLine = contentLines[lastNew.index - k]!;
352
+ const fileLine = fileLines[startLine - 2 - k]!;
353
+ if (canon(newLine) !== canon(fileLine)) break;
354
+ if ((fileCounts.get(canon(fileLine)) ?? 0) !== 1) break;
355
+ dups.push({ kind: "last-new-before", replacementLineIndex: lastNew.index - k });
356
+ }
357
+ return dups;
318
358
  }
319
359
 
320
360
  function canonCounts(lines: string[]): Map<string, number> {
@@ -348,16 +388,6 @@ export function findNewEdge(
348
388
  return undefined;
349
389
  }
350
390
 
351
- function newEdgeLines(
352
- contentLines: string[],
353
- rangeLines: string[],
354
- ): { firstNew: { index: number; line: string } | undefined; lastNew: { index: number; line: string } | undefined } {
355
- return {
356
- firstNew: findNewEdge(contentLines, rangeLines, false),
357
- lastNew: findNewEdge(contentLines, rangeLines, true),
358
- };
359
- }
360
-
361
391
  export function valEdit(
362
392
  edit: HEdit,
363
393
  fileLines: string[],
@@ -405,21 +435,14 @@ export function valEdit(
405
435
  );
406
436
  }
407
437
  const endLine = endResolved.line;
408
- const nextLine = fileLines[endLine];
409
- const replacementLastLine = lastNonEmpty(edit.content_lines);
410
- const trailing = checkBoundaryDup(nextLine, edgeRef(replacementLastLine, lastNonEmptyIndex(edit.content_lines)), "trailing");
411
- if (trailing) boundaryDups.push(trailing);
412
- const prevLine = fileLines[startResolved.line - 2];
413
- const replacementFirstLine = firstNonEmpty(edit.content_lines);
414
- const leading = checkBoundaryDup(prevLine, edgeRef(replacementFirstLine, firstNonEmptyIndex(edit.content_lines)), "leading");
415
- if (leading) boundaryDups.push(leading);
416
438
  const rangeLines = fileLines.slice(startResolved.line - 1, endLine);
417
- const { firstNew, lastNew } = newEdgeLines(edit.content_lines, rangeLines);
418
439
  const fileCounts = canonCounts(fileLines);
419
- const firstNewAfter = checkBoundaryDup(nextLine, firstNew, "first-new-after", true, fileCounts);
420
- if (firstNewAfter) boundaryDups.push(firstNewAfter);
421
- const lastNewBefore = checkBoundaryDup(prevLine, lastNew, "last-new-before", true, fileCounts);
422
- if (lastNewBefore) boundaryDups.push(lastNewBefore);
440
+ boundaryDups.push(
441
+ ...trailingDups(edit.content_lines, fileLines, endLine),
442
+ ...leadingDups(edit.content_lines, fileLines, startResolved.line),
443
+ ...firstNewAfterDups(edit.content_lines, rangeLines, fileLines, endLine, fileCounts),
444
+ ...lastNewBeforeDups(edit.content_lines, rangeLines, fileLines, startResolved.line, fileCounts),
445
+ );
423
446
 
424
447
  return {
425
448
  resolved: {