decorated-pi 0.3.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/README.md +58 -34
- package/extensions/file-times.ts +60 -2
- package/extensions/guidance.ts +5 -3
- package/extensions/index.ts +2 -0
- package/extensions/io.ts +210 -29
- package/extensions/lsp/client.ts +181 -428
- package/extensions/lsp/env.ts +45 -12
- package/extensions/lsp/format.ts +102 -237
- package/extensions/lsp/index.ts +8 -11
- package/extensions/lsp/manager.ts +249 -0
- package/extensions/lsp/prompt.ts +3 -42
- package/extensions/lsp/protocol.ts +219 -0
- package/extensions/lsp/servers.ts +80 -160
- package/extensions/lsp/tools.ts +160 -553
- package/extensions/lsp/types.ts +42 -0
- package/extensions/mcp/builtin.ts +126 -0
- package/extensions/mcp/client.ts +106 -0
- package/extensions/mcp/index.ts +123 -0
- package/extensions/patch.ts +475 -77
- package/extensions/providers/ark-coding.ts +2 -0
- package/extensions/safety/detect.ts +20 -744
- package/extensions/safety/entropy.ts +226 -0
- package/extensions/safety/index.ts +1 -93
- package/extensions/safety/patterns.ts +155 -0
- package/extensions/safety/types.ts +50 -0
- package/extensions/settings.ts +8 -0
- package/extensions/slash.ts +161 -7
- package/extensions/smart-at.ts +5 -5
- package/extensions/subdir-agents.ts +43 -13
- package/package.json +2 -3
- package/tsconfig.json +16 -0
- package/extensions/lsp/server-manager.ts +0 -309
- package/extensions/lsp/trust.ts +0 -45
package/extensions/patch.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
-
import * as
|
|
14
|
+
import * as fsPromises from "node:fs/promises";
|
|
15
15
|
|
|
16
16
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
17
17
|
// Types
|
|
@@ -45,6 +45,8 @@ export interface PatchResult {
|
|
|
45
45
|
replacements: Map<string, ReplacementInfo[]>;
|
|
46
46
|
/** Original file lines per file, for diff context generation */
|
|
47
47
|
originalLines: Map<string, string[]>;
|
|
48
|
+
/** Pre-generated diff string (set by applyEdits to avoid re-reading files) */
|
|
49
|
+
diff: string;
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
/** Records a single old_str→new_str replacement within a file */
|
|
@@ -63,6 +65,8 @@ export interface ReplacementInfo {
|
|
|
63
65
|
newLines: string[];
|
|
64
66
|
/** Optional anchor text (first line only, for hunk display) */
|
|
65
67
|
anchor?: string;
|
|
68
|
+
/** Anchor was provided but not found, and patch fell back to global old_str search */
|
|
69
|
+
anchorMissing?: boolean;
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -90,6 +94,7 @@ export async function applyPatch(patch: FilePatch, cwd: string): Promise<PatchRe
|
|
|
90
94
|
warnings: [],
|
|
91
95
|
replacements: new Map(),
|
|
92
96
|
originalLines: new Map(),
|
|
97
|
+
diff: "",
|
|
93
98
|
};
|
|
94
99
|
|
|
95
100
|
const absPath = resolveAbsPath(cwd, patch.path);
|
|
@@ -119,6 +124,7 @@ export async function applyPatches(patches: FilePatch[], cwd: string): Promise<P
|
|
|
119
124
|
warnings: [],
|
|
120
125
|
replacements: new Map(),
|
|
121
126
|
originalLines: new Map(),
|
|
127
|
+
diff: "",
|
|
122
128
|
};
|
|
123
129
|
|
|
124
130
|
for (const p of patches) {
|
|
@@ -185,82 +191,106 @@ async function applyEdits(
|
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
const rawContent = fs.readFileSync(absPath, "utf8");
|
|
188
|
-
// Store original lines for diff context generation later
|
|
189
|
-
const origLines = rawContent.split("\n");
|
|
190
|
-
// Remove trailing empty line from split (trailing newline), but keep lines for content-less files
|
|
191
|
-
if (origLines.length > 1 && origLines[origLines.length - 1] === "") origLines.pop();
|
|
192
|
-
result.originalLines.set(displayPath, origLines);
|
|
193
|
-
|
|
194
194
|
const lineEnding = detectLineEnding(rawContent);
|
|
195
195
|
let content = normalizeLineEndings(rawContent);
|
|
196
196
|
|
|
197
197
|
// Precompute line offsets for O(log n) line number lookups
|
|
198
|
-
const
|
|
198
|
+
const lineOffsets = buildLineOffsets(rawContent);
|
|
199
|
+
const totalLines = lineOffsets.length - 1;
|
|
199
200
|
|
|
200
201
|
// Track cumulative offset for mapping current positions back to original
|
|
201
202
|
let cumulativeOffset = 0;
|
|
202
203
|
const replacements: ReplacementInfo[] = [];
|
|
204
|
+
const neededRanges: LineRange[] = [];
|
|
203
205
|
|
|
204
206
|
for (const edit of edits) {
|
|
205
207
|
if (!edit.old_str) {
|
|
206
208
|
throw new ApplyError(`old_str must not be empty in ${displayPath}.`);
|
|
207
209
|
}
|
|
208
210
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
+
let oldNorm = normalizeLineEndings(edit.old_str);
|
|
212
|
+
let newNorm = normalizeLineEndings(edit.new_str);
|
|
211
213
|
|
|
212
214
|
// Determine search range
|
|
213
215
|
let searchFrom = 0;
|
|
216
|
+
let displayAnchor: string | undefined;
|
|
217
|
+
let anchorMissing = false;
|
|
218
|
+
let anchorNotFoundMessage: string | undefined;
|
|
214
219
|
|
|
215
220
|
if (edit.anchor) {
|
|
216
221
|
const anchorNorm = normalizeLineEndings(edit.anchor);
|
|
217
222
|
|
|
218
|
-
// Find anchor — must be unique
|
|
223
|
+
// Find anchor — must be unique when present
|
|
219
224
|
const anchorIdx = content.indexOf(anchorNorm);
|
|
220
225
|
if (anchorIdx === -1) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
);
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
);
|
|
226
|
+
anchorNotFoundMessage = `Anchor not found in ${displayPath}: "${truncate(edit.anchor)}".`;
|
|
227
|
+
} else {
|
|
228
|
+
const secondAnchor = content.indexOf(anchorNorm, anchorIdx + 1);
|
|
229
|
+
if (secondAnchor !== -1) {
|
|
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;
|
|
235
|
+
}
|
|
232
236
|
}
|
|
233
|
-
|
|
234
|
-
// Search from anchor start position onward (anchor narrows search range)
|
|
235
|
-
// old_str may start at anchor position (anchor is a prefix of old_str)
|
|
236
|
-
searchFrom = anchorIdx;
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
// Find old_str in range — must be unique
|
|
240
|
-
|
|
240
|
+
let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
|
|
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);
|
|
248
|
+
if (secondGlobalMatch !== -1) {
|
|
249
|
+
const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
|
|
250
|
+
throw new ApplyError(`${anchorNotFoundMessage}\n${dupDiag}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
241
255
|
if (matchIdx === -1) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
+
);
|
|
268
|
+
} else {
|
|
269
|
+
const diag = diagnoseOldStrMismatch(oldNorm, content);
|
|
270
|
+
throw new ApplyError(
|
|
271
|
+
`old_str not found in ${displayPath}` +
|
|
272
|
+
(edit.anchor ? ` after anchor "${truncate(edit.anchor)}"` : "") +
|
|
273
|
+
`: "${truncate(edit.old_str)}".\n${diag}`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
248
276
|
}
|
|
249
277
|
|
|
250
|
-
// Check uniqueness
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
278
|
+
// Check uniqueness in anchor-narrowed / plain search path only when anchor was used normally
|
|
279
|
+
if (!anchorNotFoundMessage) {
|
|
280
|
+
const secondMatch = content.indexOf(oldNorm, matchIdx + 1);
|
|
281
|
+
if (secondMatch !== -1) {
|
|
282
|
+
const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
|
|
283
|
+
throw new ApplyError(
|
|
284
|
+
`${dupDiag}`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
257
287
|
}
|
|
258
288
|
|
|
259
289
|
// Compute line numbers in the original file for diff generation (O(log n) via binary search)
|
|
260
290
|
// matchIdx is in the modified content; subtract cumulative offset to map back to original
|
|
261
291
|
const origMatchIdx = matchIdx - cumulativeOffset;
|
|
262
|
-
const oldStartLine = lineAtOffset(
|
|
263
|
-
const oldEndLine = lineAtOffset(
|
|
292
|
+
const oldStartLine = lineAtOffset(lineOffsets, origMatchIdx);
|
|
293
|
+
const oldEndLine = lineAtOffset(lineOffsets, origMatchIdx + oldNorm.length - 1);
|
|
264
294
|
|
|
265
295
|
// Apply replacement
|
|
266
296
|
content =
|
|
@@ -283,10 +313,36 @@ async function applyEdits(
|
|
|
283
313
|
newEndLine,
|
|
284
314
|
oldLines: oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
285
315
|
newLines: newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
286
|
-
anchor:
|
|
316
|
+
anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined,
|
|
317
|
+
anchorMissing,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// Collect context range for this edit
|
|
321
|
+
neededRanges.push({
|
|
322
|
+
startLine: Math.max(1, oldStartLine - CONTEXT_LINES),
|
|
323
|
+
endLine: Math.min(totalLines, oldEndLine + CONTEXT_LINES),
|
|
287
324
|
});
|
|
288
325
|
}
|
|
289
326
|
|
|
327
|
+
// Generate diff using only needed context lines (no full-file split)
|
|
328
|
+
const mergedRanges = mergeRanges(neededRanges);
|
|
329
|
+
const currentLineOffsets = buildLineOffsets(content);
|
|
330
|
+
const neededLines: Map<number, string> = new Map();
|
|
331
|
+
for (const range of mergedRanges) {
|
|
332
|
+
const lines = extractLineRange(content, currentLineOffsets, range.startLine, range.endLine);
|
|
333
|
+
for (let i = 0; i < lines.length; i++) {
|
|
334
|
+
neededLines.set(range.startLine + i, lines[i]);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Build diff for this file and append to result
|
|
339
|
+
const fileDiff = generateLocalDiff(displayPath, replacements, neededLines, totalLines);
|
|
340
|
+
if (result.diff) {
|
|
341
|
+
result.diff += "\n" + fileDiff;
|
|
342
|
+
} else {
|
|
343
|
+
result.diff = fileDiff;
|
|
344
|
+
}
|
|
345
|
+
|
|
290
346
|
// Restore line endings
|
|
291
347
|
const finalContent = restoreLineEndings(content, lineEnding);
|
|
292
348
|
|
|
@@ -334,47 +390,99 @@ export async function computePatchPreview(
|
|
|
334
390
|
return { error: "File not found" };
|
|
335
391
|
}
|
|
336
392
|
|
|
337
|
-
const rawContent =
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
393
|
+
const rawContent = await fsPromises.readFile(absPath, "utf8");
|
|
394
|
+
const lineOffsets = buildLineOffsets(rawContent);
|
|
395
|
+
const totalLines = lineOffsets.length - 1;
|
|
396
|
+
let content = normalizeLineEndings(rawContent);
|
|
397
|
+
const allReplacements: ReplacementInfo[] = [];
|
|
398
|
+
const neededRanges: LineRange[] = [];
|
|
399
|
+
let cumulativeOffset = 0;
|
|
344
400
|
|
|
345
401
|
for (const edit of patch.edits) {
|
|
346
402
|
if (!edit.old_str) continue;
|
|
347
|
-
|
|
348
|
-
|
|
403
|
+
let oldNorm = normalizeLineEndings(edit.old_str);
|
|
404
|
+
let newNorm = normalizeLineEndings(edit.new_str);
|
|
349
405
|
|
|
350
406
|
let searchFrom = 0;
|
|
407
|
+
let displayAnchor: string | undefined;
|
|
408
|
+
let anchorMissing = false;
|
|
409
|
+
let anchorNotFoundMessage: string | undefined;
|
|
351
410
|
if (edit.anchor) {
|
|
352
411
|
const anchorNorm = normalizeLineEndings(edit.anchor);
|
|
353
412
|
const idx = content.indexOf(anchorNorm);
|
|
354
413
|
if (idx === -1) {
|
|
355
|
-
|
|
414
|
+
anchorNotFoundMessage = `Anchor not found: "${truncate(edit.anchor)}"`;
|
|
415
|
+
} else {
|
|
416
|
+
const secondAnchor = content.indexOf(anchorNorm, idx + 1);
|
|
417
|
+
if (secondAnchor !== -1) {
|
|
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;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
|
|
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);
|
|
434
|
+
if (secondGlobalMatch !== -1) {
|
|
435
|
+
const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
|
|
436
|
+
return { error: `${anchorNotFoundMessage}\n${dupDiag}` };
|
|
437
|
+
}
|
|
356
438
|
}
|
|
357
|
-
searchFrom = idx;
|
|
358
439
|
}
|
|
359
440
|
|
|
360
|
-
const matchIdx = content.indexOf(oldNorm, searchFrom);
|
|
361
441
|
if (matchIdx === -1) {
|
|
362
|
-
|
|
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}` };
|
|
451
|
+
} else {
|
|
452
|
+
const diag = diagnoseOldStrMismatch(oldNorm, content);
|
|
453
|
+
return { error: `old_str not found: "${truncate(edit.old_str)}".\n${diag}` };
|
|
454
|
+
}
|
|
363
455
|
}
|
|
364
456
|
|
|
365
457
|
const origMatchIdx = matchIdx - cumulativeOffset;
|
|
366
|
-
const oldStartLine = lineAtOffset(
|
|
367
|
-
const oldEndLine = lineAtOffset(
|
|
458
|
+
const oldStartLine = lineAtOffset(lineOffsets, origMatchIdx);
|
|
459
|
+
const oldEndLine = lineAtOffset(lineOffsets, origMatchIdx + oldNorm.length - 1);
|
|
368
460
|
const oldLines = oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === ""));
|
|
369
461
|
const newLines = newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === ""));
|
|
370
462
|
content = content.substring(0, matchIdx) + newNorm + content.substring(matchIdx + oldNorm.length);
|
|
371
463
|
const newStartLine = charOffsetToLine(content, matchIdx);
|
|
372
464
|
const newEndLine = charOffsetToLine(content, matchIdx + newNorm.length - 1);
|
|
373
|
-
|
|
465
|
+
// Record needed context range around this edit
|
|
466
|
+
neededRanges.push({
|
|
467
|
+
startLine: Math.max(1, oldStartLine - CONTEXT_LINES),
|
|
468
|
+
endLine: Math.min(totalLines, oldEndLine + CONTEXT_LINES),
|
|
469
|
+
});
|
|
470
|
+
allReplacements.push({ oldStartLine, oldEndLine, newStartLine, newEndLine, oldLines, newLines, anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined, anchorMissing });
|
|
374
471
|
cumulativeOffset += newNorm.length - oldNorm.length;
|
|
375
472
|
}
|
|
376
473
|
|
|
377
|
-
|
|
474
|
+
// Merge needed ranges and extract only those lines
|
|
475
|
+
const mergedRanges = mergeRanges(neededRanges);
|
|
476
|
+
const currentLineOffsets = buildLineOffsets(content);
|
|
477
|
+
const neededLines: Map<number, string> = new Map();
|
|
478
|
+
for (const range of mergedRanges) {
|
|
479
|
+
const lines = extractLineRange(content, currentLineOffsets, range.startLine, range.endLine);
|
|
480
|
+
for (let i = 0; i < lines.length; i++) {
|
|
481
|
+
neededLines.set(range.startLine + i, lines[i]);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const diff = generateLocalDiff(patch.path, allReplacements, neededLines, totalLines);
|
|
378
486
|
return { diff };
|
|
379
487
|
} else {
|
|
380
488
|
return { error: "Must provide edits[] or overwrite:true" };
|
|
@@ -400,6 +508,12 @@ export async function computePatchPreviewMulti(
|
|
|
400
508
|
|
|
401
509
|
|
|
402
510
|
export function generatePatchDiff(result: PatchResult): string {
|
|
511
|
+
// If applyEdits pre-generated the diff, use it directly (avoids re-reading files)
|
|
512
|
+
if (result.diff) {
|
|
513
|
+
return result.diff;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Fallback: reconstruct diff from stored originalLines (legacy path)
|
|
403
517
|
const parts: string[] = [];
|
|
404
518
|
for (const [filePath, reps] of result.replacements) {
|
|
405
519
|
const origLines = result.originalLines.get(filePath) ?? [];
|
|
@@ -438,14 +552,29 @@ function buildReplacementChunks(
|
|
|
438
552
|
return chunks;
|
|
439
553
|
}
|
|
440
554
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
555
|
+
interface ChunkAnchor {
|
|
556
|
+
text: string;
|
|
557
|
+
missing: boolean;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function getChunkAnchors(chunk: ReplacementChunk): ChunkAnchor[] {
|
|
561
|
+
const byText = new Map<string, ChunkAnchor>();
|
|
562
|
+
for (const rep of chunk.reps) {
|
|
563
|
+
const text = rep.anchor?.trim();
|
|
564
|
+
if (!text) continue;
|
|
565
|
+
const existing = byText.get(text);
|
|
566
|
+
if (!existing) {
|
|
567
|
+
byText.set(text, { text, missing: Boolean(rep.anchorMissing) });
|
|
568
|
+
} else if (!rep.anchorMissing) {
|
|
569
|
+
// If any replacement successfully used this anchor, do not mark it missing.
|
|
570
|
+
existing.missing = false;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return [...byText.values()];
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function formatAnchorLabel(anchor: ChunkAnchor): string {
|
|
577
|
+
return anchor.text + (anchor.missing ? " (missing)" : "");
|
|
449
578
|
}
|
|
450
579
|
|
|
451
580
|
function formatChunkHeader(chunk: ReplacementChunk): string {
|
|
@@ -459,7 +588,7 @@ function formatChunkHeader(chunk: ReplacementChunk): string {
|
|
|
459
588
|
}
|
|
460
589
|
|
|
461
590
|
if (anchors.length === 1) {
|
|
462
|
-
return `@@ lines ${range} @@ anchor: ${anchors[0]}`;
|
|
591
|
+
return `@@ lines ${range} @@ anchor: ${formatAnchorLabel(anchors[0]!)}`;
|
|
463
592
|
}
|
|
464
593
|
|
|
465
594
|
return `@@ lines ${range} @@`;
|
|
@@ -471,7 +600,7 @@ function formatChunkMetadataLines(chunk: ReplacementChunk): string[] {
|
|
|
471
600
|
|
|
472
601
|
const shown = anchors.slice(0, 2);
|
|
473
602
|
const remaining = anchors.length - shown.length;
|
|
474
|
-
const lines = ["anchors:", ...shown.map((anchor) => ` - ${anchor}`)];
|
|
603
|
+
const lines = ["anchors:", ...shown.map((anchor) => ` - ${formatAnchorLabel(anchor)}`)];
|
|
475
604
|
if (remaining > 0) {
|
|
476
605
|
lines.push(` - +${remaining} more`);
|
|
477
606
|
}
|
|
@@ -581,7 +710,18 @@ function restoreLineEndings(text: string, ending: string): string {
|
|
|
581
710
|
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
|
582
711
|
}
|
|
583
712
|
|
|
584
|
-
|
|
713
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
714
|
+
// Line range utilities (for partial file reading)
|
|
715
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
716
|
+
|
|
717
|
+
const CONTEXT_LINES = 3;
|
|
718
|
+
|
|
719
|
+
interface LineRange {
|
|
720
|
+
startLine: number;
|
|
721
|
+
endLine: number;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Build line offset table: offsets[i] = character offset of line i+1 (1-based) */
|
|
585
725
|
function buildLineOffsets(content: string): number[] {
|
|
586
726
|
const offsets = [0];
|
|
587
727
|
for (let i = 0; i < content.length; i++) {
|
|
@@ -590,8 +730,8 @@ function buildLineOffsets(content: string): number[] {
|
|
|
590
730
|
return offsets;
|
|
591
731
|
}
|
|
592
732
|
|
|
593
|
-
|
|
594
|
-
|
|
733
|
+
|
|
734
|
+
/** Binary search: find 1-based line number containing charOffset */
|
|
595
735
|
function lineAtOffset(lineOffsets: number[], charOffset: number): number {
|
|
596
736
|
let lo = 0, hi = lineOffsets.length - 1;
|
|
597
737
|
while (lo < hi) {
|
|
@@ -599,10 +739,51 @@ function lineAtOffset(lineOffsets: number[], charOffset: number): number {
|
|
|
599
739
|
if (lineOffsets[mid] <= charOffset) lo = mid;
|
|
600
740
|
else hi = mid - 1;
|
|
601
741
|
}
|
|
602
|
-
return lo + 1;
|
|
742
|
+
return lo + 1;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Binary search: find line start offset given 1-based line number */
|
|
746
|
+
function offsetAtLine(lineOffsets: number[], lineNum: number): number {
|
|
747
|
+
if (lineNum <= 1) return 0;
|
|
748
|
+
if (lineNum > lineOffsets.length) return lineOffsets[lineOffsets.length - 1];
|
|
749
|
+
return lineOffsets[lineNum - 1];
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** Extract a range of lines from content (1-based, inclusive) */
|
|
753
|
+
function extractLineRange(content: string, lineOffsets: number[], startLine: number, endLine: number): string[] {
|
|
754
|
+
const lines: string[] = [];
|
|
755
|
+
for (let i = startLine; i <= endLine; i++) {
|
|
756
|
+
const start = offsetAtLine(lineOffsets, i);
|
|
757
|
+
const end = offsetAtLine(lineOffsets, i + 1);
|
|
758
|
+
// Remove trailing \n from last line if present
|
|
759
|
+
const lineText = content.slice(start, end).replace(/\n$/, "");
|
|
760
|
+
lines.push(lineText);
|
|
761
|
+
}
|
|
762
|
+
return lines;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
/** Merge overlapping/adjacent line ranges */
|
|
767
|
+
function mergeRanges(ranges: LineRange[]): LineRange[] {
|
|
768
|
+
if (ranges.length === 0) return [];
|
|
769
|
+
const sorted = [...ranges].sort((a, b) => a.startLine - b.startLine);
|
|
770
|
+
const merged: LineRange[] = [];
|
|
771
|
+
for (const r of sorted) {
|
|
772
|
+
const last = merged[merged.length - 1];
|
|
773
|
+
if (last && r.startLine <= last.endLine + 1) {
|
|
774
|
+
last.endLine = Math.max(last.endLine, r.endLine);
|
|
775
|
+
} else {
|
|
776
|
+
merged.push({ ...r });
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return merged;
|
|
603
780
|
}
|
|
604
781
|
|
|
605
|
-
|
|
782
|
+
function randomId(): string {
|
|
783
|
+
return Math.random().toString(36).slice(2, 10);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Convert a character offset to a 1-based line number. */
|
|
606
787
|
function charOffsetToLine(content: string, offset: number): number {
|
|
607
788
|
let line = 1;
|
|
608
789
|
for (let i = 0; i < offset && i < content.length; i++) {
|
|
@@ -611,8 +792,225 @@ function charOffsetToLine(content: string, offset: number): number {
|
|
|
611
792
|
return line;
|
|
612
793
|
}
|
|
613
794
|
|
|
614
|
-
|
|
615
|
-
|
|
795
|
+
/**
|
|
796
|
+
* Generate diff using only the needed lines (partial file context).
|
|
797
|
+
*/
|
|
798
|
+
function generateLocalDiff(
|
|
799
|
+
filePath: string,
|
|
800
|
+
reps: ReplacementInfo[],
|
|
801
|
+
neededLines: Map<number, string>,
|
|
802
|
+
totalLines: number,
|
|
803
|
+
): string {
|
|
804
|
+
if (reps.length === 0) return "";
|
|
805
|
+
|
|
806
|
+
const parts: string[] = [];
|
|
807
|
+
parts.push(`--- ${filePath}`);
|
|
808
|
+
parts.push(`+++ ${filePath}`);
|
|
809
|
+
|
|
810
|
+
// Calculate dynamic width based on max line number
|
|
811
|
+
const maxLineNum = Math.max(totalLines, ...reps.map(r => r.oldEndLine));
|
|
812
|
+
const numWidth = String(maxLineNum).length;
|
|
813
|
+
|
|
814
|
+
// Merge replacement chunks
|
|
815
|
+
const chunks = buildReplacementChunks(reps, totalLines, CONTEXT_LINES);
|
|
816
|
+
for (let c = 0; c < chunks.length; c++) {
|
|
817
|
+
if (c > 0) parts.push("");
|
|
818
|
+
const chunk = chunks[c]!;
|
|
819
|
+
parts.push(formatChunkHeader(chunk));
|
|
820
|
+
parts.push(...formatChunkMetadataLines(chunk));
|
|
821
|
+
|
|
822
|
+
// Output context + removed + added
|
|
823
|
+
let cursor = chunk.startLine;
|
|
824
|
+
for (const rep of chunk.reps) {
|
|
825
|
+
// Context before this replacement
|
|
826
|
+
for (let i = cursor; i < rep.oldStartLine; i++) {
|
|
827
|
+
const lineText = neededLines.get(i);
|
|
828
|
+
if (lineText !== undefined) {
|
|
829
|
+
parts.push(` ${String(i).padStart(numWidth, " ")} ${lineText}`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
// Removed lines
|
|
833
|
+
for (let i = 0; i < rep.oldLines.length; i++) {
|
|
834
|
+
parts.push(`-${String(rep.oldStartLine + i).padStart(numWidth, " ")} ${rep.oldLines[i]}`);
|
|
835
|
+
}
|
|
836
|
+
// Added lines
|
|
837
|
+
for (let i = 0; i < rep.newLines.length; i++) {
|
|
838
|
+
parts.push(`+${String(rep.oldStartLine + i).padStart(numWidth, " ")} ${rep.newLines[i]}`);
|
|
839
|
+
}
|
|
840
|
+
cursor = rep.oldEndLine + 1;
|
|
841
|
+
}
|
|
842
|
+
// Trailing context
|
|
843
|
+
for (let i = cursor; i <= chunk.endLine; i++) {
|
|
844
|
+
const lineText = neededLines.get(i);
|
|
845
|
+
if (lineText !== undefined) {
|
|
846
|
+
parts.push(` ${String(i).padStart(numWidth, " ")} ${lineText}`);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
return parts.join("\n");
|
|
852
|
+
}
|
|
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");
|
|
616
1014
|
}
|
|
617
1015
|
|
|
618
1016
|
function truncate(s: string, maxLen = 60): string {
|