pi-edit-file 0.2.2 → 0.3.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 +4 -2
- package/package.json +1 -1
- package/src/core.ts +68 -7
- package/src/extension.ts +28 -11
package/README.md
CHANGED
|
@@ -128,10 +128,12 @@ Not-found is the most expensive failure because it is the one models retry blind
|
|
|
128
128
|
| Typo in one character (`1000` → `1001`) | `closest line 2 (95% similar): "const timeout = 1000;"` |
|
|
129
129
|
| Hand-written `\n` inside a line | `Escaping note: a literal "\n" in a before-line is a backslash followed by "n", not a line break …` |
|
|
130
130
|
| Block occurs more than once in the file | `not unique — the before-block matches at lines 12, 45 (exact match, 2 copies)` + *include more surrounding lines … a line number cannot choose between identical blocks* |
|
|
131
|
-
| Every hunk is a no-op (`before == after`) | `nothing applied — every hunk of this patch is a no-op
|
|
131
|
+
| Every hunk is a no-op (`before == after`) or empty (`@@@` with nothing on either side) | `nothing applied — every hunk of this patch is empty or a no-op …`; inside a batch each one is `SKIPPED — no-op …` / `SKIPPED — empty hunk …` and the rest still applies |
|
|
132
132
|
| No hunk header at all (chain form), a header where the closing delimiter belongs, or an unterminated hunk | the blocks are located in the file and re-emitted as a ready-to-paste numbered skeleton (`NOT UNIQUE` / `NOT FOUND` placeholders when a block cannot be pinned), with the note that a single unique block may start with a bare delimiter line instead of a number |
|
|
133
|
+
| **One hunk whose opening delimiter line was left out** (`old` / delim / `new` / delim) | not called a chain: `the opening delimiter line is missing … Only that first line is missing`, with both legal forms spelled out and no placeholders (24 of 27 rejections in one session were this shape) |
|
|
134
|
+
| Closing delimiter missing inside the patch | the skeleton's preamble says so — `the hunk header is there, but a "@@@" line is missing inside the patch` — instead of claiming the patch opened with content |
|
|
133
135
|
|
|
134
|
-
Grammar failures get the same treatment as not-found ones: the patch is never applied, and the reply rebuilds the model's own blocks into legal form instead of stopping at a parse error. Not-found failures also include the nearest candidate region with per-line `=` / `≠` markers, and a **ready-to-paste corrected hunk** built from the real file content:
|
|
136
|
+
Grammar failures get the same treatment as not-found ones: the patch is never applied, and the reply rebuilds the model's own blocks into legal form instead of stopping at a parse error. A block that is nowhere in the file is reported with its closest line by similarity (`closest line N (87% similar)`), in both the not-found diagnosis and the rebuilt skeleton. Not-found failures also include the nearest candidate region with per-line `=` / `≠` markers, and a **ready-to-paste corrected hunk** built from the real file content:
|
|
135
137
|
|
|
136
138
|
```
|
|
137
139
|
Closest candidate: lines 2-4 — 2 of 3 line(s) match.
|
package/package.json
CHANGED
package/src/core.ts
CHANGED
|
@@ -274,9 +274,10 @@ export function parsePatch(patch: string): Hunk[] {
|
|
|
274
274
|
if (hint < 1) throw new EditError("insert-after needs NNN >= 1");
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
277
|
+
// An empty hunk (both blocks blank — usually a stray delimiter pair after a
|
|
278
|
+
// terminator) is kept rather than rejected here: inside a batch the tool
|
|
279
|
+
// skips it with a note, like a no-op, and only a patch that is nothing but
|
|
280
|
+
// empty/no-op hunks is an error (Nik, 2026-10-08).
|
|
280
281
|
hunks.push({ hint, before, after, ...(header.insertAfter ? { insertAfter: true } : {}) });
|
|
281
282
|
|
|
282
283
|
if (terminated && i < lines.length && lines[i].trim() === "" && i === lines.length - 1) {
|
|
@@ -320,7 +321,7 @@ function locateBlock(fileLines: string[], before: string[]): { status: "ok"; lin
|
|
|
320
321
|
/** Skeleton for a chain patch; fileLines are needed to number the headers.
|
|
321
322
|
* Always returns model-facing text — the file is never written from a chain
|
|
322
323
|
* patch; the model re-emits the skeleton with its own blocks. */
|
|
323
|
-
export function chainSkeleton(patch: string, fileLines: string[]): string {
|
|
324
|
+
export function chainSkeleton(patch: string, fileLines: string[], situation?: string): string {
|
|
324
325
|
const lines = patch.split("\n");
|
|
325
326
|
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
326
327
|
|
|
@@ -373,7 +374,8 @@ export function chainSkeleton(patch: string, fileLines: string[]): string {
|
|
|
373
374
|
}
|
|
374
375
|
|
|
375
376
|
const out: string[] = [
|
|
376
|
-
|
|
377
|
+
situation ??
|
|
378
|
+
`the patch opens with content instead of a hunk header, and the chain form ("old ${delimRun} new ${delimRun} old ${delimRun} new") is not legal input — every block needs its own header.`,
|
|
377
379
|
`A block that occurs exactly once in the file needs no number: start the patch with a bare "${delimRun}" line.`,
|
|
378
380
|
"Here is the same edit with the headers filled in — paste your blocks between the header and delimiter lines:",
|
|
379
381
|
"",
|
|
@@ -405,7 +407,15 @@ export function chainSkeleton(patch: string, fileLines: string[]): string {
|
|
|
405
407
|
const more = loc.lines.length > 5 ? ` … and ${loc.lines.length - 5} more` : "";
|
|
406
408
|
headerLine = `<NOT UNIQUE: this before-block matches at lines ${capped.join(", ")}${more} — extend the before-block with surrounding lines to make it unique, then re-emit with the chosen NNN>`;
|
|
407
409
|
} else {
|
|
408
|
-
|
|
410
|
+
// Symmetry with the not-found diagnosis: when the block is nowhere in the
|
|
411
|
+
// file, name the closest line by similarity too instead of only saying
|
|
412
|
+
// "re-read the file" (2026-10-08 feedback).
|
|
413
|
+
const distinctive = before.lines.reduce((best, l) => (l.trim().length > best.trim().length ? l : best), before.lines[0] ?? "");
|
|
414
|
+
const near = distinctive ? closestLine(fileLines, distinctive, before.hint ?? 1) : undefined;
|
|
415
|
+
const nearText = near
|
|
416
|
+
? ` — closest line ${near.index + 1} (${Math.round(near.similarity * 100)}% similar): ${JSON.stringify(near.text)}`
|
|
417
|
+
: "";
|
|
418
|
+
headerLine = `<NOT FOUND: this before-block does not appear in the file (exact, trimmed and whitespace-collapsed matching all failed)${nearText} — re-read the file and copy the lines exactly>`;
|
|
409
419
|
}
|
|
410
420
|
}
|
|
411
421
|
out.push(headerLine);
|
|
@@ -420,11 +430,42 @@ export function chainSkeleton(patch: string, fileLines: string[]): string {
|
|
|
420
430
|
out.push(
|
|
421
431
|
`Rules: the header is "NNN ${delimRun}" — the line where the old block starts; "+" after the number inserts AFTER that line (inserts only); ` +
|
|
422
432
|
`inserts have an empty before-block (header line, then the delimiter line, then the new lines); a delete is an empty after-block. ` +
|
|
423
|
-
`
|
|
433
|
+
`Replace every "<...>" placeholder (including "<NOT UNIQUE …>" / "<NOT FOUND …>", after fixing that block) with your own lines — the angle brackets are explanations, not patch text.`,
|
|
424
434
|
);
|
|
425
435
|
return out.join("\n");
|
|
426
436
|
}
|
|
427
437
|
|
|
438
|
+
/** A patch that opens with content and holds exactly the delimiter lines of ONE
|
|
439
|
+
* hunk (its body plus the optional terminator) is not a chain: it is a single
|
|
440
|
+
* hunk whose opening delimiter line was left out. That shape gets called out
|
|
441
|
+
* precisely, with both legal forms spelled out and no placeholder skeleton —
|
|
442
|
+
* the chain-form diagnosis sent the model fixing a problem it did not have, and
|
|
443
|
+
* 24 of 27 rejections in one session were exactly this (2026-10-08). The
|
|
444
|
+
* one-delimiter variant of the same shape is accepted by headerlessSingleHunk(). */
|
|
445
|
+
export function openingDelimiterDiagnosis(patch: string): string | null {
|
|
446
|
+
const lines = patch.split("\n");
|
|
447
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
448
|
+
// A numbered header anywhere means the author knows about headers — that is a
|
|
449
|
+
// mixed/chain patch, and the rebuilt skeleton with real numbers serves it.
|
|
450
|
+
if (lines.some((line) => HEADER_RE.test(line))) return null;
|
|
451
|
+
|
|
452
|
+
const runs = lines.map((line) => delimiterRun(line));
|
|
453
|
+
const at: number[] = [];
|
|
454
|
+
for (let i = 0; i < runs.length; i++) if (runs[i] !== null) at.push(i);
|
|
455
|
+
if (at.length !== 2) return null; // 1 delimiter: accepted; 0 or ≥3: no single-hunk reading
|
|
456
|
+
const run = runs[at[0]];
|
|
457
|
+
if (runs[at[1]] !== run) return null; // mixed runs: let the parser complain about that
|
|
458
|
+
if (!lines.slice(at[1] + 1).every((l) => l.trim() === "")) return null; // content after the 2nd: a chain
|
|
459
|
+
|
|
460
|
+
return (
|
|
461
|
+
`the patch opens with content instead of a hunk header — the opening delimiter line is missing (the blocks themselves are fine).\n` +
|
|
462
|
+
`A hunk carries that line in front:\n\n` +
|
|
463
|
+
`${run}\nold lines\n${run}\nnew lines\n${run}\n\n` +
|
|
464
|
+
`or, with a line-number hint instead of the bare first line:\n\n` +
|
|
465
|
+
`NNN ${run}\nold lines\n${run}\nnew lines\n${run}\n\n` +
|
|
466
|
+
`Only that first line is missing — the rest of the patch is already in the right shape.`
|
|
467
|
+
);
|
|
468
|
+
}
|
|
428
469
|
const exactEq: LineEq = (a, b) => a === b;
|
|
429
470
|
const trimEq: LineEq = (a, b) => a.trim() === b.trim();
|
|
430
471
|
const collapseEq: LineEq = (a, b) => a.replace(/\s+/g, " ").trim() === b.replace(/\s+/g, " ").trim();
|
|
@@ -706,6 +747,20 @@ function resolveOne(hunk: Hunk, fileLines: string[], index: number): { ok: Resol
|
|
|
706
747
|
const hint = hunk.hint;
|
|
707
748
|
const kind: HunkKind = hunk.before.length === 0 ? "insert" : hunk.after.length === 0 ? "delete" : "replace";
|
|
708
749
|
|
|
750
|
+
// Both blocks blank: nothing to change. The extension skips these inside a
|
|
751
|
+
// batch, so reaching here means the patch was nothing but skipped hunks.
|
|
752
|
+
if (isEmptyHunk(hunk)) {
|
|
753
|
+
return {
|
|
754
|
+
fail: {
|
|
755
|
+
index,
|
|
756
|
+
hint,
|
|
757
|
+
kind,
|
|
758
|
+
reason: "empty hunk — no before-block and no after-block, so there is nothing to change (stray delimiter lines?)",
|
|
759
|
+
detail: "",
|
|
760
|
+
},
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
709
764
|
if (hunk.before.length === 0) {
|
|
710
765
|
if (hint === null) {
|
|
711
766
|
return {
|
|
@@ -1272,6 +1327,12 @@ export function insertTip(resolved: ResolvedHunk[], numbers?: number[], delim =
|
|
|
1272
1327
|
* (exact): applying it changes nothing, so it is skipped with a note instead
|
|
1273
1328
|
* of killing the batch or silently doing busywork. Trim-equal blocks still
|
|
1274
1329
|
* apply — whitespace normalization is a real change. */
|
|
1330
|
+
/** A hunk with nothing on either side — usually stray delimiter lines after a
|
|
1331
|
+
* terminator. Skipped inside a batch; a patch made only of these is an error. */
|
|
1332
|
+
export function isEmptyHunk(h: Hunk): boolean {
|
|
1333
|
+
return h.before.length === 0 && h.after.length === 0;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1275
1336
|
export function isNoOpHunk(h: Hunk): boolean {
|
|
1276
1337
|
return h.before.length > 0 && h.before.length === h.after.length && h.before.every((l, i) => l === h.after[i]);
|
|
1277
1338
|
}
|
package/src/extension.ts
CHANGED
|
@@ -43,6 +43,8 @@ import {
|
|
|
43
43
|
reportCaveats,
|
|
44
44
|
insertTip,
|
|
45
45
|
appliedLine,
|
|
46
|
+
openingDelimiterDiagnosis,
|
|
47
|
+
isEmptyHunk,
|
|
46
48
|
} from "./core.ts";
|
|
47
49
|
|
|
48
50
|
const TOOL_NAME = "edit_file";
|
|
@@ -165,30 +167,45 @@ export default function editFileExtension(pi: { registerTool: (t: unknown) => vo
|
|
|
165
167
|
// blocks on. Without one (only "NNN @@@" headers, no standalone
|
|
166
168
|
// delimiter line) its fallback text is the generic "no delimiter
|
|
167
169
|
// found" — then the precise diagnosis alone is more useful.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
170
|
+
// A patch that opens with content gets a reply built from its own
|
|
171
|
+
// blocks. When it is ONE hunk missing only its opening delimiter line
|
|
172
|
+
// (the shape models reach for again and again), the diagnosis is
|
|
173
|
+
// precise and placeholder-free; a real chain gets the rebuilt skeleton.
|
|
174
|
+
if (err.code === "content-start") {
|
|
175
|
+
const precise = openingDelimiterDiagnosis(params.patch);
|
|
176
|
+
const body = precise ?? chainSkeleton(params.patch, readLines(absPath).lines);
|
|
177
|
+
throw new EditError(`patch rejected — nothing was written to the file.\n${body}`);
|
|
178
|
+
}
|
|
179
|
+
// A delimiter line inside the patch is missing: the skeleton is right,
|
|
180
|
+
// but its preamble must describe THIS failure — it used to claim the
|
|
181
|
+
// patch opened with content even when the header was there.
|
|
182
|
+
const delim = patchDelimiter(params.patch);
|
|
183
|
+
if ((err.code === "missing-separator" || err.code === "unterminated") && delim !== null) {
|
|
184
|
+
const situation = `the hunk header is there, but a "${delim}" line is missing inside the patch — every block needs its closing "${delim}" line.`;
|
|
185
|
+
const skeleton = chainSkeleton(params.patch, readLines(absPath).lines, situation);
|
|
186
|
+
throw new EditError(`patch rejected — nothing was written to the file.\n${err.message}\n\n${skeleton}`);
|
|
173
187
|
}
|
|
174
188
|
throw new EditError(`patch rejected — nothing was written to the file.\n${err.message}`);
|
|
175
189
|
}
|
|
176
190
|
throw err;
|
|
177
191
|
}
|
|
178
192
|
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
193
|
+
// Hunks that change nothing are skipped inside a batch, each with a loud
|
|
194
|
+
// note: no-ops (before == after) and empty ones (both blocks blank, usually
|
|
195
|
+
// stray delimiter lines after a terminator — one such hunk used to reject
|
|
196
|
+
// the whole batch). A patch made ONLY of them is rejected: that patch means
|
|
197
|
+
// the author copied the wrong lines, and such a signal must never come back
|
|
198
|
+
// as a success (Nik, 2026-10-02).
|
|
183
199
|
const active: Array<{ hunk: Hunk; origIndex: number }> = [];
|
|
184
200
|
const noOpNotes: string[] = [];
|
|
185
201
|
parsed.forEach((h, i) => {
|
|
186
|
-
if (
|
|
202
|
+
if (isEmptyHunk(h)) noOpNotes.push(`hunk ${i + 1}: SKIPPED — empty hunk (no old block, no new block); this hunk changes nothing`);
|
|
203
|
+
else if (isNoOpHunk(h)) noOpNotes.push(`hunk ${i + 1}: SKIPPED — no-op (the old block equals the new block); this hunk changes nothing`);
|
|
187
204
|
else active.push({ hunk: h, origIndex: i });
|
|
188
205
|
});
|
|
189
206
|
if (active.length === 0) {
|
|
190
207
|
throw new EditError(
|
|
191
|
-
`nothing applied — every hunk of this patch is a no-op (
|
|
208
|
+
`nothing applied — every hunk of this patch is empty or a no-op (it changes nothing), so the file was NOT written.\n` +
|
|
192
209
|
`${noOpNotes.join("\n")}\n` +
|
|
193
210
|
`Check that the old block is the text you mean to replace, and that the new block differs from it.`,
|
|
194
211
|
);
|