decorated-pi 0.4.0 → 0.4.1
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/extensions/patch.ts +232 -52
- package/package.json +1 -1
package/extensions/patch.ts
CHANGED
|
@@ -208,8 +208,8 @@ async function applyEdits(
|
|
|
208
208
|
throw new ApplyError(`old_str must not be empty in ${displayPath}.`);
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
|
|
212
|
-
|
|
211
|
+
let oldNorm = normalizeLineEndings(edit.old_str);
|
|
212
|
+
let newNorm = normalizeLineEndings(edit.new_str);
|
|
213
213
|
|
|
214
214
|
// Determine search range
|
|
215
215
|
let searchFrom = 0;
|
|
@@ -227,46 +227,50 @@ async function applyEdits(
|
|
|
227
227
|
} else {
|
|
228
228
|
const secondAnchor = content.indexOf(anchorNorm, anchorIdx + 1);
|
|
229
229
|
if (secondAnchor !== -1) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
230
|
+
anchorNotFoundMessage = `Anchor is not unique in ${displayPath}: "${truncate(edit.anchor)}".`;
|
|
231
|
+
} else {
|
|
232
|
+
searchFrom = Math.max(0, anchorIdx - (oldNorm.length - 1));
|
|
233
|
+
displayAnchor = edit.anchor;
|
|
234
|
+
anchorMissing = false;
|
|
234
235
|
}
|
|
235
|
-
// Search from anchor start position onward (anchor narrows search range)
|
|
236
|
-
// old_str may start at anchor position (anchor is a prefix of old_str)
|
|
237
|
-
searchFrom = anchorIdx;
|
|
238
|
-
displayAnchor = edit.anchor;
|
|
239
|
-
anchorMissing = false;
|
|
240
236
|
}
|
|
241
237
|
}
|
|
242
238
|
|
|
243
239
|
// Find old_str in range — must be unique
|
|
244
240
|
let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
|
|
245
|
-
if (matchIdx === -1) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
`${anchorNotFoundMessage} old_str not found in ${displayPath}: "${truncate(edit.old_str)}". ` +
|
|
253
|
-
`The file may have changed — re-read it and try again.`
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
const secondGlobalMatch = content.indexOf(oldNorm, globalMatchIdx + 1);
|
|
241
|
+
if (matchIdx === -1 && anchorNotFoundMessage) {
|
|
242
|
+
// Anchor was missing/unusable — try global exact match first
|
|
243
|
+
displayAnchor = edit.anchor;
|
|
244
|
+
anchorMissing = true;
|
|
245
|
+
matchIdx = content.indexOf(oldNorm, 0);
|
|
246
|
+
if (matchIdx !== -1) {
|
|
247
|
+
const secondGlobalMatch = content.indexOf(oldNorm, matchIdx + 1);
|
|
257
248
|
if (secondGlobalMatch !== -1) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
`Add more context to old_str or use a more specific anchor.`
|
|
261
|
-
);
|
|
249
|
+
const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
|
|
250
|
+
throw new ApplyError(`${anchorNotFoundMessage}\n${dupDiag}`);
|
|
262
251
|
}
|
|
263
|
-
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (matchIdx === -1) {
|
|
256
|
+
// Fuzzy match fallback: normalize tab↔space + trailing whitespace
|
|
257
|
+
const searchLine = searchFrom === 0 ? 0 : content.substring(0, searchFrom).split("\n").length - 1;
|
|
258
|
+
const fuzzy = tryFuzzyLineMatch(oldNorm, content, searchLine);
|
|
259
|
+
if (fuzzy) {
|
|
260
|
+
oldNorm = fuzzy.matched;
|
|
261
|
+
matchIdx = fuzzy.idx;
|
|
262
|
+
newNorm = normalizeIndentForFuzzy(fuzzy.matched.split("\n")[0] ?? "", newNorm);
|
|
263
|
+
} else if (anchorNotFoundMessage) {
|
|
264
|
+
const diag = diagnoseOldStrMismatch(oldNorm, content);
|
|
265
|
+
throw new ApplyError(
|
|
266
|
+
`${anchorNotFoundMessage}\nold_str not found in ${displayPath}: "${truncate(edit.old_str)}".\n${diag}`
|
|
267
|
+
);
|
|
264
268
|
} else {
|
|
269
|
+
const diag = diagnoseOldStrMismatch(oldNorm, content);
|
|
265
270
|
throw new ApplyError(
|
|
266
271
|
`old_str not found in ${displayPath}` +
|
|
267
272
|
(edit.anchor ? ` after anchor "${truncate(edit.anchor)}"` : "") +
|
|
268
|
-
`: "${truncate(edit.old_str)}"
|
|
269
|
-
`The file may have changed — re-read it and try again.`
|
|
273
|
+
`: "${truncate(edit.old_str)}".\n${diag}`
|
|
270
274
|
);
|
|
271
275
|
}
|
|
272
276
|
}
|
|
@@ -275,9 +279,9 @@ async function applyEdits(
|
|
|
275
279
|
if (!anchorNotFoundMessage) {
|
|
276
280
|
const secondMatch = content.indexOf(oldNorm, matchIdx + 1);
|
|
277
281
|
if (secondMatch !== -1) {
|
|
282
|
+
const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
|
|
278
283
|
throw new ApplyError(
|
|
279
|
-
|
|
280
|
-
`Add more context to old_str or use an anchor to narrow the search.`
|
|
284
|
+
`${dupDiag}`
|
|
281
285
|
);
|
|
282
286
|
}
|
|
283
287
|
}
|
|
@@ -322,9 +326,10 @@ async function applyEdits(
|
|
|
322
326
|
|
|
323
327
|
// Generate diff using only needed context lines (no full-file split)
|
|
324
328
|
const mergedRanges = mergeRanges(neededRanges);
|
|
329
|
+
const currentLineOffsets = buildLineOffsets(content);
|
|
325
330
|
const neededLines: Map<number, string> = new Map();
|
|
326
331
|
for (const range of mergedRanges) {
|
|
327
|
-
const lines = extractLineRange(content,
|
|
332
|
+
const lines = extractLineRange(content, currentLineOffsets, range.startLine, range.endLine);
|
|
328
333
|
for (let i = 0; i < lines.length; i++) {
|
|
329
334
|
neededLines.set(range.startLine + i, lines[i]);
|
|
330
335
|
}
|
|
@@ -395,8 +400,8 @@ export async function computePatchPreview(
|
|
|
395
400
|
|
|
396
401
|
for (const edit of patch.edits) {
|
|
397
402
|
if (!edit.old_str) continue;
|
|
398
|
-
|
|
399
|
-
|
|
403
|
+
let oldNorm = normalizeLineEndings(edit.old_str);
|
|
404
|
+
let newNorm = normalizeLineEndings(edit.new_str);
|
|
400
405
|
|
|
401
406
|
let searchFrom = 0;
|
|
402
407
|
let displayAnchor: string | undefined;
|
|
@@ -410,30 +415,42 @@ export async function computePatchPreview(
|
|
|
410
415
|
} else {
|
|
411
416
|
const secondAnchor = content.indexOf(anchorNorm, idx + 1);
|
|
412
417
|
if (secondAnchor !== -1) {
|
|
413
|
-
|
|
418
|
+
anchorNotFoundMessage = `Anchor is not unique: "${truncate(edit.anchor)}"`;
|
|
419
|
+
} else {
|
|
420
|
+
searchFrom = Math.max(0, idx - (oldNorm.length - 1));
|
|
421
|
+
displayAnchor = edit.anchor;
|
|
422
|
+
anchorMissing = false;
|
|
414
423
|
}
|
|
415
|
-
searchFrom = idx;
|
|
416
|
-
displayAnchor = edit.anchor;
|
|
417
|
-
anchorMissing = false;
|
|
418
424
|
}
|
|
419
425
|
}
|
|
420
426
|
|
|
421
427
|
let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
|
|
422
|
-
if (matchIdx === -1) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
return { error: `${anchorNotFoundMessage}; old_str not found: "${truncate(edit.old_str)}"` };
|
|
429
|
-
}
|
|
430
|
-
const secondGlobalMatch = content.indexOf(oldNorm, globalMatchIdx + 1);
|
|
428
|
+
if (matchIdx === -1 && anchorNotFoundMessage) {
|
|
429
|
+
displayAnchor = edit.anchor;
|
|
430
|
+
anchorMissing = true;
|
|
431
|
+
matchIdx = content.indexOf(oldNorm, 0);
|
|
432
|
+
if (matchIdx !== -1) {
|
|
433
|
+
const secondGlobalMatch = content.indexOf(oldNorm, matchIdx + 1);
|
|
431
434
|
if (secondGlobalMatch !== -1) {
|
|
432
|
-
|
|
435
|
+
const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
|
|
436
|
+
return { error: `${anchorNotFoundMessage}\n${dupDiag}` };
|
|
433
437
|
}
|
|
434
|
-
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (matchIdx === -1) {
|
|
442
|
+
const searchLine = 0;
|
|
443
|
+
const fuzzy = tryFuzzyLineMatch(oldNorm, content, searchLine);
|
|
444
|
+
if (fuzzy) {
|
|
445
|
+
oldNorm = fuzzy.matched;
|
|
446
|
+
matchIdx = fuzzy.idx;
|
|
447
|
+
newNorm = normalizeIndentForFuzzy(fuzzy.matched.split("\n")[0] ?? "", newNorm);
|
|
448
|
+
} else if (anchorNotFoundMessage) {
|
|
449
|
+
const diag = diagnoseOldStrMismatch(oldNorm, content);
|
|
450
|
+
return { error: `${anchorNotFoundMessage}\nold_str not found: "${truncate(edit.old_str)}"\n${diag}` };
|
|
435
451
|
} else {
|
|
436
|
-
|
|
452
|
+
const diag = diagnoseOldStrMismatch(oldNorm, content);
|
|
453
|
+
return { error: `old_str not found: "${truncate(edit.old_str)}".\n${diag}` };
|
|
437
454
|
}
|
|
438
455
|
}
|
|
439
456
|
|
|
@@ -456,9 +473,10 @@ export async function computePatchPreview(
|
|
|
456
473
|
|
|
457
474
|
// Merge needed ranges and extract only those lines
|
|
458
475
|
const mergedRanges = mergeRanges(neededRanges);
|
|
476
|
+
const currentLineOffsets = buildLineOffsets(content);
|
|
459
477
|
const neededLines: Map<number, string> = new Map();
|
|
460
478
|
for (const range of mergedRanges) {
|
|
461
|
-
const lines = extractLineRange(content,
|
|
479
|
+
const lines = extractLineRange(content, currentLineOffsets, range.startLine, range.endLine);
|
|
462
480
|
for (let i = 0; i < lines.length; i++) {
|
|
463
481
|
neededLines.set(range.startLine + i, lines[i]);
|
|
464
482
|
}
|
|
@@ -833,6 +851,168 @@ function generateLocalDiff(
|
|
|
833
851
|
return parts.join("\n");
|
|
834
852
|
}
|
|
835
853
|
|
|
854
|
+
// ─── old_str mismatch diagnostics ─────────────────────────────────────────
|
|
855
|
+
|
|
856
|
+
/** Detect tab width from the file by analyzing indentation columns of tab-only lines. */
|
|
857
|
+
function detectTabWidth(content: string): number {
|
|
858
|
+
const lines = content.split("\n");
|
|
859
|
+
const cols: number[] = [];
|
|
860
|
+
for (const line of lines) {
|
|
861
|
+
const nonTabIdx = line.search(/[^\t]/);
|
|
862
|
+
if (nonTabIdx === -1 || nonTabIdx === 0) continue;
|
|
863
|
+
cols.push(nonTabIdx);
|
|
864
|
+
}
|
|
865
|
+
if (cols.length < 2) return 0;
|
|
866
|
+
const diffs: number[] = [];
|
|
867
|
+
for (let i = 1; i < cols.length; i++) {
|
|
868
|
+
if (cols[i] === cols[i - 1] || cols[i]! > cols[i - 1]! + 8) continue;
|
|
869
|
+
diffs.push(cols[i]! - cols[i - 1]!);
|
|
870
|
+
}
|
|
871
|
+
if (diffs.length === 0) return 0;
|
|
872
|
+
const sorted = [...diffs].sort((a, b) => a - b);
|
|
873
|
+
const median = sorted[Math.floor(sorted.length / 2)]!;
|
|
874
|
+
return [2, 4, 8].reduce((best, w) => Math.abs(w - median) < Math.abs(best - median) ? w : best, 4);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
export function diagnoseOldStrNotUnique(oldNorm: string, content: string): string {
|
|
878
|
+
const fileLines = content.split("\n");
|
|
879
|
+
const firstOldLine = (oldNorm.split("\n")[0] ?? "").trim();
|
|
880
|
+
const occurrences: number[] = [];
|
|
881
|
+
let idx = 0;
|
|
882
|
+
while ((idx = content.indexOf(oldNorm, idx)) !== -1) {
|
|
883
|
+
const lineNum = content.substring(0, idx).split("\n").length;
|
|
884
|
+
occurrences.push(lineNum);
|
|
885
|
+
idx++;
|
|
886
|
+
}
|
|
887
|
+
if (occurrences.length === 0) return "";
|
|
888
|
+
const shown = occurrences.slice(0, 5);
|
|
889
|
+
const extra = occurrences.length - shown.length;
|
|
890
|
+
const lines = shown.map((n) => ` line ${n}: "${(fileLines[n - 1] ?? "").replace(/\t/g, "\\t").slice(0, 60)}"`);
|
|
891
|
+
if (extra > 0) lines.push(` and ${extra} more occurrence(s)`);
|
|
892
|
+
return `old_str appears ${occurrences.length} times:\n${lines.join("\n")}\nAdd more surrounding context to make it unique.`;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** Try fuzzy match: normalize tab↔space and trailing whitespace, then search line-by-line. */
|
|
896
|
+
function tryFuzzyLineMatch(
|
|
897
|
+
oldNorm: string,
|
|
898
|
+
content: string,
|
|
899
|
+
searchLineStart: number,
|
|
900
|
+
): { idx: number; matched: string } | undefined {
|
|
901
|
+
const oldLines = oldNorm.split("\n");
|
|
902
|
+
const fileLines = content.split("\n");
|
|
903
|
+
|
|
904
|
+
const fuzzyEq = (fileLine: string, oldLine: string): boolean => {
|
|
905
|
+
if (fileLine === oldLine) return true;
|
|
906
|
+
for (const tw of [8, 4, 2]) {
|
|
907
|
+
if (fileLine.replace(/\t/g, " ".repeat(tw)) === oldLine.replace(/\t/g, " ".repeat(tw))) return true;
|
|
908
|
+
}
|
|
909
|
+
if (fileLine.replace(/[\t ]+$/, "") === oldLine.replace(/[\t ]+$/, "")) return true;
|
|
910
|
+
return false;
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
for (let i = searchLineStart; i <= fileLines.length - oldLines.length; i++) {
|
|
914
|
+
let ok = true;
|
|
915
|
+
for (let j = 0; j < oldLines.length; j++) {
|
|
916
|
+
if (!fuzzyEq(fileLines[i + j] ?? "", oldLines[j] ?? "")) { ok = false; break; }
|
|
917
|
+
}
|
|
918
|
+
if (ok) {
|
|
919
|
+
let idx = 0;
|
|
920
|
+
for (let k = 0; k < i; k++) idx += (fileLines[k] ?? "").length + 1;
|
|
921
|
+
const matched = oldLines.map((_, j) => fileLines[i + j]).join("\n");
|
|
922
|
+
// Check uniqueness in the fuzzy-matched range
|
|
923
|
+
const secondIdx = content.indexOf(matched, idx + 1);
|
|
924
|
+
if (secondIdx === -1) return { idx, matched };
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return undefined;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** Replace new_str's leading whitespace with the actual file line's leading whitespace style. */
|
|
931
|
+
function normalizeIndentForFuzzy(actualLine: string, newLine: string): string {
|
|
932
|
+
const actualLeading = actualLine.match(/^[\t ]*/)?.[0] ?? "";
|
|
933
|
+
const newLeading = newLine.match(/^[\t ]*/)?.[0] ?? "";
|
|
934
|
+
if (actualLeading === newLeading) return newLine;
|
|
935
|
+
return actualLeading + newLine.slice(newLeading.length);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
export function diagnoseOldStrMismatch(oldNorm: string, content: string, isConfigFile?: boolean): string {
|
|
939
|
+
const oldLines = oldNorm.split("\n");
|
|
940
|
+
const fileLines = content.split("\n");
|
|
941
|
+
const firstOldLine = oldLines[0] ?? "";
|
|
942
|
+
const parts: string[] = [];
|
|
943
|
+
|
|
944
|
+
// Find the closest matching line in the file
|
|
945
|
+
let bestMatchIdx = -1;
|
|
946
|
+
let bestMatchType = "";
|
|
947
|
+
|
|
948
|
+
for (let i = 0; i < fileLines.length; i++) {
|
|
949
|
+
const fileLine = fileLines[i] ?? "";
|
|
950
|
+
|
|
951
|
+
if (fileLine === firstOldLine) {
|
|
952
|
+
bestMatchIdx = i;
|
|
953
|
+
bestMatchType = "";
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (fileLine.replace(/\t/g, " ") === firstOldLine ||
|
|
958
|
+
fileLine.replace(/\t/g, " ") === firstOldLine ||
|
|
959
|
+
fileLine.replace(/\t/g, " ") === firstOldLine) {
|
|
960
|
+
bestMatchIdx = i;
|
|
961
|
+
bestMatchType = "tab vs space (file has tabs, old_str has spaces)";
|
|
962
|
+
break;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (fileLine.replace(/[\t ]+$/, "") === firstOldLine.replace(/[\t ]+$/, "")) {
|
|
966
|
+
bestMatchIdx = i;
|
|
967
|
+
bestMatchType = "trailing whitespace mismatch";
|
|
968
|
+
break;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if (fileLine.toLowerCase() === firstOldLine.toLowerCase()) {
|
|
972
|
+
bestMatchIdx = i;
|
|
973
|
+
bestMatchType = "case mismatch";
|
|
974
|
+
break;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const trimmedOld = firstOldLine.trim();
|
|
978
|
+
if (trimmedOld.length > 3 && fileLine.includes(trimmedOld)) {
|
|
979
|
+
if (bestMatchIdx === -1) {
|
|
980
|
+
bestMatchIdx = i;
|
|
981
|
+
bestMatchType = "indent mismatch (content matches, whitespace differs)";
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (bestMatchIdx >= 0 && bestMatchType) {
|
|
987
|
+
parts.push(`Hint: ${bestMatchType} at line ${bestMatchIdx + 1}.`);
|
|
988
|
+
parts.push(` actual: ${JSON.stringify(fileLines[bestMatchIdx])}`);
|
|
989
|
+
parts.push(` expected: ${JSON.stringify(firstOldLine)}`);
|
|
990
|
+
} else if (bestMatchIdx >= 0) {
|
|
991
|
+
// First line matched, but full old_str block does not — find the first mismatching line
|
|
992
|
+
const oldArr = oldNorm.split("\n");
|
|
993
|
+
let mismatchLine = 0;
|
|
994
|
+
for (let j = 1; j < oldArr.length; j++) {
|
|
995
|
+
const fileLine = fileLines[bestMatchIdx + j] ?? "<EOF>";
|
|
996
|
+
const oldLine = oldArr[j] ?? "";
|
|
997
|
+
if (fileLine !== oldLine) {
|
|
998
|
+
mismatchLine = bestMatchIdx + j + 1;
|
|
999
|
+
parts.push(`Line ${bestMatchIdx + 1} matches, but diff at line ${mismatchLine}:`);
|
|
1000
|
+
parts.push(` actual: ${JSON.stringify(fileLine)}`);
|
|
1001
|
+
parts.push(` expected: ${JSON.stringify(oldLine)}`);
|
|
1002
|
+
break;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (mismatchLine === 0) {
|
|
1006
|
+
parts.push(`First line matches at line ${bestMatchIdx + 1}, but full ${oldArr.length}-line block does not.`);
|
|
1007
|
+
}
|
|
1008
|
+
} else if (firstOldLine.trim().length > 3) {
|
|
1009
|
+
parts.push(`Content "${firstOldLine.trim().slice(0, 60)}" not found anywhere in the file.`);
|
|
1010
|
+
parts.push(`File may have changed — re-read it and try again.`);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
return parts.join("\n");
|
|
1014
|
+
}
|
|
1015
|
+
|
|
836
1016
|
function truncate(s: string, maxLen = 60): string {
|
|
837
1017
|
if (s.length <= maxLen) return s;
|
|
838
1018
|
// Show first line only
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "decorated-pi",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "A pi extension with better work-flow: patch tool, safety gates, secret redaction, smart @ completion, dynamic AGENTS loading, image fallback, and LSP tools",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|