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 +2 -1
- package/package.json +1 -1
- package/src/hashline/apply.ts +30 -1
- package/src/hashline/index.ts +1 -0
- package/src/hashline/resolve.ts +36 -24
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
|
|
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
package/src/hashline/apply.ts
CHANGED
|
@@ -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,
|
package/src/hashline/index.ts
CHANGED
package/src/hashline/resolve.ts
CHANGED
|
@@ -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)
|
|
279
|
+
const replacementLastLine = edit.new_lines.at(-1);
|
|
270
280
|
if (
|
|
271
281
|
nextLine !== undefined &&
|
|
272
|
-
replacementLastLine &&
|
|
273
|
-
|
|
274
|
-
replacementLastLine === nextLine
|
|
282
|
+
replacementLastLine !== undefined &&
|
|
283
|
+
replacementLastLine.length > 0 &&
|
|
284
|
+
replacementLastLine === nextLine
|
|
275
285
|
) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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]
|
|
296
|
+
const replacementFirstLine = edit.new_lines[0];
|
|
286
297
|
if (
|
|
287
298
|
prevLine !== undefined &&
|
|
288
|
-
replacementFirstLine &&
|
|
289
|
-
|
|
290
|
-
replacementFirstLine === prevLine
|
|
299
|
+
replacementFirstLine !== undefined &&
|
|
300
|
+
replacementFirstLine.length > 0 &&
|
|
301
|
+
replacementFirstLine === prevLine
|
|
291
302
|
) {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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 };
|