pi-edit-file 0.0.0-stage → 0.2.2
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/LICENSE +21 -0
- package/README.md +172 -2
- package/package.json +42 -4
- package/src/core.ts +1522 -0
- package/src/extension.ts +544 -0
package/src/core.ts
ADDED
|
@@ -0,0 +1,1522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* edit_file — pure core: patch parsing, hunk resolution, application, diff.
|
|
3
|
+
*
|
|
4
|
+
* No pi imports here so unit tests can load this file standalone (tsx --test).
|
|
5
|
+
* The extension wrapper lives in ../edit-file.ts.
|
|
6
|
+
*
|
|
7
|
+
* Patch grammar (one hunk or several concatenated):
|
|
8
|
+
*
|
|
9
|
+
* NNN @@@
|
|
10
|
+
* before line 1
|
|
11
|
+
* before line 2
|
|
12
|
+
* @@@
|
|
13
|
+
* after line 1
|
|
14
|
+
* @@@
|
|
15
|
+
* MMM @@@
|
|
16
|
+
* ...
|
|
17
|
+
*
|
|
18
|
+
* - NNN is a 1-based line-number hint (anchor), not a strict requirement.
|
|
19
|
+
* - Delimiter: 3+ repetitions of one char from @#%$~^=+. The character is
|
|
20
|
+
* established by the first delimiter and must stay consistent for the call.
|
|
21
|
+
* - Empty before → insert; empty after → delete; otherwise replace.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const DELIM_CHARS = "@#%$~^=+";
|
|
25
|
+
export const WINDOW = 20;
|
|
26
|
+
export const MAX_HUNKS = 20;
|
|
27
|
+
export const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
/** Result/context cap for the model-facing diff. */
|
|
30
|
+
export const MAX_DIFF_CHARS = 8000;
|
|
31
|
+
|
|
32
|
+
export class EditError extends Error {
|
|
33
|
+
/** Machine-readable failure class; "content-start" marks a patch that opens
|
|
34
|
+
* with content where a hunk header belongs — the extension turns that into
|
|
35
|
+
* a ready-to-paste numbered skeleton via chainSkeleton(). */
|
|
36
|
+
code?: string;
|
|
37
|
+
constructor(message: string, code?: string) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.code = code;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// "+" before the delimiter run = insert-after (NNN+ @@@); the space before the
|
|
44
|
+
// run is now optional (NNN@@@ also parses — a whole class of header typos gone).
|
|
45
|
+
const HEADER_RE = new RegExp(`^(\\d+)(\\+)?\\s*([${DELIM_CHARS}]{3,})\\s*$`);
|
|
46
|
+
const DELIM_RE = new RegExp(`^([${DELIM_CHARS}])\\1{2,}\\s*$`);
|
|
47
|
+
|
|
48
|
+
export interface Hunk {
|
|
49
|
+
/** 1-based line hint from the header; null when the header was a bare
|
|
50
|
+
* delimiter (then the before-block must match exactly once). */
|
|
51
|
+
hint: number | null;
|
|
52
|
+
/** Lines to find (empty → insert). */
|
|
53
|
+
before: string[];
|
|
54
|
+
/** Replacement lines (empty → delete). */
|
|
55
|
+
after: string[];
|
|
56
|
+
/** Header was "NNN+ @@@": insert goes AFTER line NNN (inserts only). */
|
|
57
|
+
insertAfter?: boolean;
|
|
58
|
+
/** The patch had no leading header at all — "old <delim> new" with exactly
|
|
59
|
+
* one delimiter line (see headerlessSingleHunk). */
|
|
60
|
+
leadingDelimiterOmitted?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type HunkKind = "replace" | "insert" | "delete";
|
|
64
|
+
|
|
65
|
+
/** How a before-block was found — models need this to trust the result. */
|
|
66
|
+
export type MatchTier = "exact" | "trim" | "collapse";
|
|
67
|
+
|
|
68
|
+
export interface MatchInfo {
|
|
69
|
+
tier: MatchTier | "hint";
|
|
70
|
+
/** 1-based first line of the match in the ORIGINAL file (insert: target line). */
|
|
71
|
+
from: number;
|
|
72
|
+
/** 1-based last line of the match in the ORIGINAL file (insert: from - 1). */
|
|
73
|
+
to: number;
|
|
74
|
+
hint: number | null;
|
|
75
|
+
/** Distance to the hint (0 when the hint is inside the block; 0 when
|
|
76
|
+
* there was no hint, since a hintless block must be unique). */
|
|
77
|
+
distance: number;
|
|
78
|
+
/** The block matched further than ±WINDOW from the hint: the hint number was
|
|
79
|
+
* stale. Reported as a fact ("hint 20 off by 49") — a unique block far from
|
|
80
|
+
* the hint is still applied (2026-10-02 feedback: no "low confidence"
|
|
81
|
+
* framing, which pushed the model to abandon the tool). */
|
|
82
|
+
farFromHint: boolean;
|
|
83
|
+
/** Set when the inserted lines use a different indent style than the file. */
|
|
84
|
+
indentMismatch?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ResolvedHunk {
|
|
88
|
+
hunk: Hunk;
|
|
89
|
+
kind: HunkKind;
|
|
90
|
+
/** 0-based inclusive start in the ORIGINAL file (insertion index for insert). */
|
|
91
|
+
start: number;
|
|
92
|
+
/** 0-based exclusive end in the ORIGINAL file (== start for insert). */
|
|
93
|
+
end: number;
|
|
94
|
+
match: MatchInfo;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Full delimiter run on a line — "####" for "N ####" and bare "####", not a
|
|
98
|
+
* single char. The call's delimiter is this EXACT run: shorter runs, longer
|
|
99
|
+
* ones and repeats of another character are legal content (that is what makes
|
|
100
|
+
* the documented "escalate to a longer run" advice actually work). */
|
|
101
|
+
function delimiterRun(line: string): string | null {
|
|
102
|
+
const m = DELIM_RE.exec(line);
|
|
103
|
+
if (!m) return null;
|
|
104
|
+
return m[0].trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The call's delimiter run: the first full-line 3+ run in the patch. Every
|
|
108
|
+
* header and every closing delimiter of the call repeats this exact run. */
|
|
109
|
+
export function patchDelimiter(patch: string): string | null {
|
|
110
|
+
for (const line of patch.split("\n")) {
|
|
111
|
+
const run = delimiterRun(line);
|
|
112
|
+
if (run !== null) return run;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A hunk header: "NNN @@@" (optional "+" = insert-after, optional space),
|
|
118
|
+
* or a bare "@@@" line (hint omitted — the block must then match exactly
|
|
119
|
+
* once in the file). */
|
|
120
|
+
export function parseHunkHeader(line: string): { hint: number | null; insertAfter?: boolean; delim: string } | null {
|
|
121
|
+
const hm = HEADER_RE.exec(line);
|
|
122
|
+
if (hm) {
|
|
123
|
+
return {
|
|
124
|
+
hint: Number.parseInt(hm[1], 10),
|
|
125
|
+
...(hm[2] === "+" ? { insertAfter: true } : {}),
|
|
126
|
+
delim: hm[3],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const run = delimiterRun(line);
|
|
130
|
+
if (run !== null) return { hint: null, delim: run };
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** "old <delim> new" with the leading bare delimiter left out. Exactly one
|
|
135
|
+
* delimiter line means exactly one hunk — a chain needs at least two — so the
|
|
136
|
+
* reading is unambiguous: it is one replace whose header was forgotten, not a
|
|
137
|
+
* chain patch (2026-10-02 feedback). Anything else (no delimiter, several
|
|
138
|
+
* delimiters, or a numbered header somewhere) keeps the strict path. */
|
|
139
|
+
export function headerlessSingleHunk(patch: string): Hunk | null {
|
|
140
|
+
const lines = patch.split("\n");
|
|
141
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
142
|
+
if (lines.some((line) => HEADER_RE.test(line))) return null;
|
|
143
|
+
const at = lines.findIndex((line) => delimiterRun(line) !== null);
|
|
144
|
+
if (at <= 0) return null; // no delimiter, or a leading bare delimiter (already legal)
|
|
145
|
+
if (lines.filter((line) => delimiterRun(line) !== null).length !== 1) return null;
|
|
146
|
+
const trimEdges = (xs: string[]) => {
|
|
147
|
+
const out = xs.slice();
|
|
148
|
+
while (out.length > 0 && out[0].trim() === "") out.shift();
|
|
149
|
+
while (out.length > 0 && out[out.length - 1].trim() === "") out.pop();
|
|
150
|
+
return out;
|
|
151
|
+
};
|
|
152
|
+
const before = trimEdges(lines.slice(0, at));
|
|
153
|
+
if (before.length === 0) return null;
|
|
154
|
+
return { hint: null, before, after: trimEdges(lines.slice(at + 1)), leadingDelimiterOmitted: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Parse a patch string into hunks.
|
|
159
|
+
* Throws EditError on grammar violations (with actionable messages).
|
|
160
|
+
*/
|
|
161
|
+
export function parsePatch(patch: string): Hunk[] {
|
|
162
|
+
const lines = patch.split("\n");
|
|
163
|
+
// Drop a single trailing empty line produced by a final newline.
|
|
164
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
165
|
+
|
|
166
|
+
const headerless = headerlessSingleHunk(patch);
|
|
167
|
+
if (headerless) return [headerless];
|
|
168
|
+
|
|
169
|
+
const hunks: Hunk[] = [];
|
|
170
|
+
// The call's delimiter is the EXACT full-line run from its first header
|
|
171
|
+
// ("@@@@"), not "3+ of the same char": a content line "@@@" stays content
|
|
172
|
+
// when the call uses "####", so escalation actually works.
|
|
173
|
+
let delimRun: string | null = null;
|
|
174
|
+
let i = 0;
|
|
175
|
+
|
|
176
|
+
const isOwnDelim = (line: string) => delimRun !== null && line.trim() === delimRun;
|
|
177
|
+
const hintLabel = (hint: number | null) => (hint === null ? "no NNN hint" : `hint ${hint}`);
|
|
178
|
+
|
|
179
|
+
// Chain detection: a patch whose first content line is not a header. The
|
|
180
|
+
// extension translates that error into a numbered skeleton (chainSkeleton).
|
|
181
|
+
let firstNonBlank = -1;
|
|
182
|
+
|
|
183
|
+
while (i < lines.length) {
|
|
184
|
+
// Skip blank separators between hunks.
|
|
185
|
+
if (lines[i].trim() === "") {
|
|
186
|
+
i++;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (firstNonBlank === -1) firstNonBlank = i;
|
|
190
|
+
|
|
191
|
+
const header = parseHunkHeader(lines[i]);
|
|
192
|
+
if (!header) {
|
|
193
|
+
throw new EditError(
|
|
194
|
+
`parse error at line ${i + 1}: expected a hunk header — either "NNN @@@ ", "NNN+ @@@ ", or a bare "${delimRun ?? "@@@ "}" line — got: ${JSON.stringify(lines[i])}`,
|
|
195
|
+
i === firstNonBlank ? "content-start" : undefined,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const hint = header.hint;
|
|
199
|
+
const headerDelim = header.delim;
|
|
200
|
+
if (delimRun === null) {
|
|
201
|
+
delimRun = headerDelim;
|
|
202
|
+
} else if (headerDelim !== delimRun) {
|
|
203
|
+
throw new EditError(
|
|
204
|
+
`delimiter mismatch: header uses "${headerDelim}" but this call already uses "${delimRun}" — write the same delimiter run on every header line`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
i++;
|
|
208
|
+
|
|
209
|
+
// Collect before-lines until the call's exact delimiter run. Marked lines
|
|
210
|
+
// ("-" / "+") are NOT rejected here: legitimate content can hold a "-" line
|
|
211
|
+
// followed by a "+" line (a markdown bullet and its "+18…" continuation did
|
|
212
|
+
// exactly that in the jup-degen session, 2026-10-07, and the up-front guess
|
|
213
|
+
// threw the whole patch away). The unified-diff hint is given at diagnosis
|
|
214
|
+
// time instead, when the block really does not match the file (see
|
|
215
|
+
// resolveOne — it can only help there).
|
|
216
|
+
const before: string[] = [];
|
|
217
|
+
while (i < lines.length && !isOwnDelim(lines[i])) {
|
|
218
|
+
if (HEADER_RE.test(lines[i])) {
|
|
219
|
+
throw new EditError(
|
|
220
|
+
`parse error at line ${i + 1}: hunk header found before the closing delimiter — missing "${delimRun}" separator?`,
|
|
221
|
+
"missing-separator",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
before.push(lines[i]);
|
|
225
|
+
i++;
|
|
226
|
+
}
|
|
227
|
+
if (i >= lines.length) {
|
|
228
|
+
throw new EditError(
|
|
229
|
+
`parse error: unterminated hunk (${hintLabel(hint)}) — missing closing "${delimRun}" between the old and new blocks` +
|
|
230
|
+
unifiedDiffHint(before),
|
|
231
|
+
"unterminated",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
i++; // consume the closing delimiter
|
|
235
|
+
|
|
236
|
+
// Collect after-lines until: next header, a terminator delimiter, or EOF.
|
|
237
|
+
const after: string[] = [];
|
|
238
|
+
let terminated = false;
|
|
239
|
+
while (i < lines.length) {
|
|
240
|
+
const line = lines[i];
|
|
241
|
+
const headerHere = parseHunkHeader(line);
|
|
242
|
+
if (headerHere && headerHere.hint !== null) break; // NNN header: next hunk starts
|
|
243
|
+
if (isOwnDelim(line)) {
|
|
244
|
+
// The call's own delimiter is either this hunk's terminator or the
|
|
245
|
+
// next hunk's hintless header. Look ahead: content right after it
|
|
246
|
+
// (that is not itself a header) means the next hunk starts here —
|
|
247
|
+
// leave the delimiter for the outer loop to read as its header.
|
|
248
|
+
let j = i + 1;
|
|
249
|
+
while (j < lines.length && lines[j].trim() === "") j++;
|
|
250
|
+
const nextIsContent = j < lines.length && !parseHunkHeader(lines[j]);
|
|
251
|
+
if (nextIsContent) break;
|
|
252
|
+
i++;
|
|
253
|
+
terminated = true;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
// A full-line run of ANOTHER character (or a different length) is
|
|
257
|
+
// legal content now — no more collision rejections.
|
|
258
|
+
after.push(line);
|
|
259
|
+
i++;
|
|
260
|
+
}
|
|
261
|
+
if (!terminated) {
|
|
262
|
+
// EOF closes the after-block; trailing blank lines are patch
|
|
263
|
+
// artifacts (the final "\n" of the JSON string), not content.
|
|
264
|
+
while (after.length > 0 && after[after.length - 1].trim() === "") after.pop();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (header.insertAfter) {
|
|
268
|
+
if (before.length > 0) {
|
|
269
|
+
throw new EditError(
|
|
270
|
+
`hunk ${hunks.length + 1}: "+" after the line number is only valid for inserts (empty before-block) — a replace anchors the matched block itself`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
// A bare delimiter header can never carry "+", so hint is never null here.
|
|
274
|
+
if (hint < 1) throw new EditError("insert-after needs NNN >= 1");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (before.length === 0 && after.length === 0) {
|
|
278
|
+
throw new EditError(`hunk ${hunks.length + 1} (${hintLabel(hint)}) is empty: both before and after blocks are blank`);
|
|
279
|
+
}
|
|
280
|
+
hunks.push({ hint, before, after, ...(header.insertAfter ? { insertAfter: true } : {}) });
|
|
281
|
+
|
|
282
|
+
if (terminated && i < lines.length && lines[i].trim() === "" && i === lines.length - 1) {
|
|
283
|
+
// trailing blank after the final terminator — harmless
|
|
284
|
+
i++;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (hunks.length === 0) throw new EditError("patch contains no hunks");
|
|
289
|
+
if (hunks.length > MAX_HUNKS) {
|
|
290
|
+
throw new EditError(`too many hunks: ${hunks.length} (max ${MAX_HUNKS} per call)`);
|
|
291
|
+
}
|
|
292
|
+
return hunks;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
type LineEq = (a: string, b: string) => boolean;
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Chain patches: the model wrote "old @@@ new @@@ old @@@ new" with no numbered
|
|
299
|
+
// headers (19 rejections in the 2026-09-30 session). The format is NOT legal
|
|
300
|
+
// input — the model must write line numbers — but instead of a dead-end
|
|
301
|
+
// "expected a hunk header" error, the blocks are located in the file and the
|
|
302
|
+
// response is a ready-to-paste skeleton with real NNN headers.
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
/** Locate a before-block in the file with the same exact→trim→collapse ladder
|
|
306
|
+
* the resolver uses; uniqueness is mandatory — an ambiguous block is NEVER
|
|
307
|
+
* auto-picked (same rule as hintless hunks). */
|
|
308
|
+
function locateBlock(fileLines: string[], before: string[]): { status: "ok"; line: number; tier: MatchTier } | { status: "ambiguous"; lines: number[] } | { status: "missing" } {
|
|
309
|
+
for (const level of LADDER) {
|
|
310
|
+
const cands: number[] = [];
|
|
311
|
+
for (let s = 0; s + before.length <= fileLines.length; s++) {
|
|
312
|
+
if (blockMatches(fileLines, s, before, level.eq)) cands.push(s);
|
|
313
|
+
}
|
|
314
|
+
if (cands.length === 1) return { status: "ok", line: cands[0] + 1, tier: level.name as MatchTier };
|
|
315
|
+
if (cands.length > 1) return { status: "ambiguous", lines: cands.map((c) => c + 1) };
|
|
316
|
+
}
|
|
317
|
+
return { status: "missing" };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Skeleton for a chain patch; fileLines are needed to number the headers.
|
|
321
|
+
* Always returns model-facing text — the file is never written from a chain
|
|
322
|
+
* patch; the model re-emits the skeleton with its own blocks. */
|
|
323
|
+
export function chainSkeleton(patch: string, fileLines: string[]): string {
|
|
324
|
+
const lines = patch.split("\n");
|
|
325
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
326
|
+
|
|
327
|
+
const delimRun = patchDelimiter(patch);
|
|
328
|
+
if (delimRun === null) {
|
|
329
|
+
return `parse error: no "${DELIM_CHARS[0]}${DELIM_CHARS[0]}${DELIM_CHARS[0]}" delimiter found — a replace patch needs the old block, a "${DELIM_CHARS[0]}${DELIM_CHARS[0]}${DELIM_CHARS[0]}" line, then the new block (see tool description)`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Split into blocks at own-delim lines and at explicit NNN headers (a mixed
|
|
333
|
+
// chain keeps its given numbers). Edge blank lines are separators.
|
|
334
|
+
interface ChainBlock {
|
|
335
|
+
lines: string[];
|
|
336
|
+
hint: number | null;
|
|
337
|
+
insertAfter: boolean;
|
|
338
|
+
}
|
|
339
|
+
const blocks: ChainBlock[] = [];
|
|
340
|
+
let cur: ChainBlock = { lines: [], hint: null, insertAfter: false };
|
|
341
|
+
const push = () => {
|
|
342
|
+
while (cur.lines.length > 0 && cur.lines[0].trim() === "") cur.lines.shift();
|
|
343
|
+
while (cur.lines.length > 0 && cur.lines[cur.lines.length - 1].trim() === "") cur.lines.pop();
|
|
344
|
+
blocks.push(cur);
|
|
345
|
+
};
|
|
346
|
+
for (const line of lines) {
|
|
347
|
+
const header = parseHunkHeader(line);
|
|
348
|
+
if (header && header.hint !== null) {
|
|
349
|
+
push();
|
|
350
|
+
cur = { lines: [], hint: header.hint, insertAfter: header.insertAfter === true };
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (line.trim() === delimRun) {
|
|
354
|
+
push();
|
|
355
|
+
cur = { lines: [], hint: null, insertAfter: false };
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
cur.lines.push(line);
|
|
359
|
+
}
|
|
360
|
+
push();
|
|
361
|
+
// A trailing empty block is the final terminator, not a hunk.
|
|
362
|
+
const open = blocks.filter((b) => b.lines.length > 0 || b.hint !== null);
|
|
363
|
+
|
|
364
|
+
// Pair blocks into hunks: (before, after), (before, after), …; an odd tail
|
|
365
|
+
// is a before-block without a replacement.
|
|
366
|
+
interface Pair {
|
|
367
|
+
before: ChainBlock;
|
|
368
|
+
after: ChainBlock | null;
|
|
369
|
+
}
|
|
370
|
+
const pairs: Pair[] = [];
|
|
371
|
+
for (let k = 0; k < open.length; k += 2) {
|
|
372
|
+
pairs.push({ before: open[k], after: open[k + 1] ?? null });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const out: string[] = [
|
|
376
|
+
`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
|
+
`A block that occurs exactly once in the file needs no number: start the patch with a bare "${delimRun}" line.`,
|
|
378
|
+
"Here is the same edit with the headers filled in — paste your blocks between the header and delimiter lines:",
|
|
379
|
+
"",
|
|
380
|
+
];
|
|
381
|
+
for (const pair of pairs) {
|
|
382
|
+
const before = pair.before;
|
|
383
|
+
let headerLine: string;
|
|
384
|
+
if (before.lines.length === 0 && before.hint === null) {
|
|
385
|
+
headerLine = `<line number here — this hunk inserts new lines, so the header needs NNN (or "NNN+" to insert AFTER line NNN)>`;
|
|
386
|
+
} else if (before.lines.length === 0) {
|
|
387
|
+
headerLine = `${before.hint}${before.insertAfter ? "+" : ""} ${delimRun}`;
|
|
388
|
+
} else {
|
|
389
|
+
const loc = locateBlock(fileLines, before.lines);
|
|
390
|
+
if (loc.status === "ok") {
|
|
391
|
+
const range = `src ${loc.line}-${loc.line + before.lines.length - 1}`;
|
|
392
|
+
headerLine = `${loc.line} ${delimRun}`;
|
|
393
|
+
out.push(headerLine);
|
|
394
|
+
out.push(`<your before block: ${before.lines.length} line${before.lines.length === 1 ? "" : "s"} — it sits at ${range}${loc.tier === "exact" ? "" : " (matched with whitespace differences)"}>`);
|
|
395
|
+
out.push(delimRun);
|
|
396
|
+
out.push(pair.after === null
|
|
397
|
+
? `<your after block — the patch ends here: write the delimiter line again to DELETE this block, or write "NNN ${delimRun}" then "${delimRun}" plus new lines to APPEND after it>`
|
|
398
|
+
: `<your after block: ${pair.after.lines.length} line${pair.after.lines.length === 1 ? "" : "s"}>`);
|
|
399
|
+
out.push(delimRun);
|
|
400
|
+
out.push("");
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (loc.status === "ambiguous") {
|
|
404
|
+
const capped = loc.lines.slice(0, 5);
|
|
405
|
+
const more = loc.lines.length > 5 ? ` … and ${loc.lines.length - 5} more` : "";
|
|
406
|
+
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
|
+
} else {
|
|
408
|
+
headerLine = `<NOT FOUND: this before-block does not appear in the file (exact, trimmed and whitespace-collapsed matching all failed) — re-read the file and copy the lines exactly>`;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
out.push(headerLine);
|
|
412
|
+
out.push(`<your before block: ${before.lines.length} line${before.lines.length === 1 ? "" : "s"}>`);
|
|
413
|
+
out.push(delimRun);
|
|
414
|
+
out.push(pair.after === null
|
|
415
|
+
? `<your after block — the patch ends here: write the delimiter line again to DELETE this block, or write "NNN ${delimRun}" then "${delimRun}" plus new lines to APPEND after it>`
|
|
416
|
+
: `<your after block: ${pair.after.lines.length} line${pair.after.lines.length === 1 ? "" : "s"}>`);
|
|
417
|
+
out.push(delimRun);
|
|
418
|
+
out.push("");
|
|
419
|
+
}
|
|
420
|
+
out.push(
|
|
421
|
+
`Rules: the header is "NNN ${delimRun}" — the line where the old block starts; "+" after the number inserts AFTER that line (inserts only); ` +
|
|
422
|
+
`inserts have an empty before-block (header line, then the delimiter line, then the new lines); a delete is an empty after-block. ` +
|
|
423
|
+
`Remove the "<- note" annotations — they are explanations, not patch lines.`,
|
|
424
|
+
);
|
|
425
|
+
return out.join("\n");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const exactEq: LineEq = (a, b) => a === b;
|
|
429
|
+
const trimEq: LineEq = (a, b) => a.trim() === b.trim();
|
|
430
|
+
const collapseEq: LineEq = (a, b) => a.replace(/\s+/g, " ").trim() === b.replace(/\s+/g, " ").trim();
|
|
431
|
+
|
|
432
|
+
const LADDER: Array<{ name: string; eq: LineEq }> = [
|
|
433
|
+
{ name: "exact", eq: exactEq },
|
|
434
|
+
{ name: "trim", eq: trimEq },
|
|
435
|
+
{ name: "collapse", eq: collapseEq },
|
|
436
|
+
];
|
|
437
|
+
|
|
438
|
+
function blockMatches(lines: string[], start: number, before: string[], eq: LineEq): boolean {
|
|
439
|
+
for (let k = 0; k < before.length; k++) {
|
|
440
|
+
if (!eq(lines[start + k], before[k])) return false;
|
|
441
|
+
}
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Unified-diff markers left inside a block. Returns the hint text when the lines
|
|
446
|
+
* look like a diff and "" otherwise. This is ONLY ever a diagnosis — "-" on one
|
|
447
|
+
* line and "+" on a later one is legal content, and treating it as a grammar
|
|
448
|
+
* error threw away a valid patch in the jup-degen session (2026-10-07: a markdown
|
|
449
|
+
* bullet plus its "+18…" continuation line). */
|
|
450
|
+
function unifiedDiffHint(block: string[]): string {
|
|
451
|
+
const minus = block.filter((l) => /^\s*-\s?/.test(l) || /^\s*-\s*$/.test(l)).length;
|
|
452
|
+
const plus = block.filter((l) => /^\s*\+/.test(l)).length;
|
|
453
|
+
const shape =
|
|
454
|
+
"A replace patch is: a header line, the old lines without \"-\", a delimiter line, the new lines without \"+\", a delimiter line.";
|
|
455
|
+
if (minus > 0 && plus > 0) {
|
|
456
|
+
return `\nThe block mixes ${minus} "-" marked line(s) with ${plus} "+" marked line(s) — that is a unified diff, not file content. ${shape}`;
|
|
457
|
+
}
|
|
458
|
+
if (plus > 0) {
|
|
459
|
+
return `\nThe block contains ${plus} line(s) starting with "+" — this looks like a unified diff. ${shape}`;
|
|
460
|
+
}
|
|
461
|
+
return "";
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Every place the block matches at this tier — always the WHOLE file. The hint
|
|
465
|
+
* is not a search filter: a copy outside the hint window still makes the block
|
|
466
|
+
* non-unique, and a non-unique block is never resolved by proximity
|
|
467
|
+
* (2026-10-02 feedback). The hint only measures how far the found block sits
|
|
468
|
+
* from where it was expected. */
|
|
469
|
+
function findCandidates(lines: string[], before: string[], eq: LineEq): number[] {
|
|
470
|
+
const cands: number[] = [];
|
|
471
|
+
for (let s = 0; s + before.length <= lines.length; s++) {
|
|
472
|
+
if (blockMatches(lines, s, before, eq)) cands.push(s);
|
|
473
|
+
}
|
|
474
|
+
return cands;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Explain WHY a before-block failed to match: which lines are absent from the
|
|
478
|
+
* file, or in what order/positions they actually appear. Models tend to write
|
|
479
|
+
* blocks with hallucinated line order or reference lines that no longer exist
|
|
480
|
+
* (e.g. removed earlier in the session) — a precise diagnosis saves retries. */
|
|
481
|
+
function diagnoseBlock(fileLines: string[], before: string[], hint: number | null): string {
|
|
482
|
+
const MAX_LISTED = 4;
|
|
483
|
+
const firstIndexOf = (needle: string): number => {
|
|
484
|
+
const exact = fileLines.indexOf(needle);
|
|
485
|
+
if (exact >= 0) return exact;
|
|
486
|
+
const trimmed = needle.trim();
|
|
487
|
+
if (trimmed === "") return -1;
|
|
488
|
+
return fileLines.findIndex((l) => l.trim() === trimmed);
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const positions = before.map((line) => firstIndexOf(line));
|
|
492
|
+
const missing = before.filter((_, i) => positions[i] < 0);
|
|
493
|
+
if (missing.length > 0) {
|
|
494
|
+
const shown = missing.slice(0, MAX_LISTED).map((l) => ` ${JSON.stringify(l)}`);
|
|
495
|
+
const more = missing.length > MAX_LISTED ? `\n … and ${missing.length - MAX_LISTED} more` : "";
|
|
496
|
+
// Character-level near-misses ("1000" vs "1001") read as absent lines;
|
|
497
|
+
// name the closest real line so the model needs no manual comparison.
|
|
498
|
+
const nearMisses = missing
|
|
499
|
+
.slice(0, 2)
|
|
500
|
+
.map((line) => closestLine(fileLines, line, hint))
|
|
501
|
+
.filter((x): x is { index: number; text: string; similarity: number } => x !== undefined)
|
|
502
|
+
.map((x) => ` closest line ${x.index + 1} (${Math.round(x.similarity * 100)}% similar): ${JSON.stringify(x.text)}`);
|
|
503
|
+
|
|
504
|
+
// Escaping mistake: the patch is a JSON string, so a real line break in
|
|
505
|
+
// the file is a real line break in the patch. Only claim this when
|
|
506
|
+
// expanding the literal backslash-n into line breaks actually matches the
|
|
507
|
+
// file — otherwise "\n" may be legitimate source content.
|
|
508
|
+
let escapeHint = "";
|
|
509
|
+
if (before.some((l) => /\\n/.test(l))) {
|
|
510
|
+
const expanded = before.flatMap((l) => l.split(/\\n/));
|
|
511
|
+
const allPresent = expanded.length !== before.length && expanded.every((l) => fileLines.includes(l));
|
|
512
|
+
if (allPresent) {
|
|
513
|
+
escapeHint =
|
|
514
|
+
`\nEscaping note: a literal "\\n" in a before-line is a backslash followed by "n", not a line break. ` +
|
|
515
|
+
`Splitting your line at those "\\n" gives the actual file lines, so write each file line on its own patch line ` +
|
|
516
|
+
`and never type \\n yourself (the patch is a JSON string; its line breaks are already real line breaks).`;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Before/after mix-up: the block is existing lines followed (or preceded)
|
|
521
|
+
// by lines that do not exist at all. Models write new content in the
|
|
522
|
+
// before block, leaving after empty — then the hunk reads as a delete.
|
|
523
|
+
const firstMissing = positions.findIndex((p) => p < 0);
|
|
524
|
+
const trailingOnly = firstMissing > 0 && positions.slice(firstMissing).every((p) => p < 0);
|
|
525
|
+
const lastMissing = positions.length - 1 - [...positions].reverse().findIndex((p) => p < 0);
|
|
526
|
+
const leadingOnly = lastMissing < positions.length - 1 && positions.slice(0, lastMissing + 1).every((p) => p < 0);
|
|
527
|
+
let swapHint = "";
|
|
528
|
+
if (trailingOnly) {
|
|
529
|
+
swapHint =
|
|
530
|
+
`\nThe first ${firstMissing} line(s) match the file and the trailing ${before.length - firstMissing} do not exist. ` +
|
|
531
|
+
`If those trailing lines are the NEW content you want to add, they belong after the closing delimiter (before = old lines, after = new lines).`;
|
|
532
|
+
} else if (leadingOnly) {
|
|
533
|
+
swapHint =
|
|
534
|
+
`\nThe trailing ${positions.length - 1 - lastMissing} line(s) match the file and the leading ${lastMissing + 1} do not exist. ` +
|
|
535
|
+
`If those leading lines are the NEW content, they belong after the closing delimiter (before = old lines, after = new lines).`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return (
|
|
539
|
+
`\nDiagnosis: ${missing.length} of ${before.length} before-block line(s) do not exist in the file at all:\n` +
|
|
540
|
+
shown.join("\n") +
|
|
541
|
+
more +
|
|
542
|
+
(nearMisses.length > 0 ? `\nDid you mean one of these? (typo-level differences count as absent)\n${nearMisses.join("\n")}` : "") +
|
|
543
|
+
escapeHint +
|
|
544
|
+
swapHint
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const outOfOrder = positions.some((p, i) => i > 0 && p <= positions[i - 1]);
|
|
549
|
+
if (outOfOrder) {
|
|
550
|
+
return (
|
|
551
|
+
`\nDiagnosis: every line exists, but NOT in the order written. Actual positions in the file:\n` +
|
|
552
|
+
before.map((l, i) => ` line ${positions[i] + 1}: ${l.trim().slice(0, 80)}`).join("\n") +
|
|
553
|
+
`\nRe-order the before-block to match the file.`
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return (
|
|
558
|
+
`\nDiagnosis: the lines exist in this order but are not contiguous — found at lines ` +
|
|
559
|
+
`${positions.map((p) => p + 1).join(", ")} (expected consecutive lines). Add or remove the gap line(s).`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Bounded Levenshtein similarity in [0, 1] for two single lines. */
|
|
564
|
+
function lineSimilarity(a: string, b: string): number {
|
|
565
|
+
const A = a.length > 200 ? a.slice(0, 200) : a;
|
|
566
|
+
const B = b.length > 200 ? b.slice(0, 200) : b;
|
|
567
|
+
const n = A.length;
|
|
568
|
+
const m = B.length;
|
|
569
|
+
if (n === 0 || m === 0) return 0;
|
|
570
|
+
let prev = new Uint16Array(m + 1);
|
|
571
|
+
let cur = new Uint16Array(m + 1);
|
|
572
|
+
for (let j = 0; j <= m; j++) prev[j] = j;
|
|
573
|
+
for (let i = 1; i <= n; i++) {
|
|
574
|
+
cur[0] = i;
|
|
575
|
+
for (let j = 1; j <= m; j++) {
|
|
576
|
+
const cost = A[i - 1] === B[j - 1] ? 0 : 1;
|
|
577
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
|
|
578
|
+
}
|
|
579
|
+
const swap = prev;
|
|
580
|
+
prev = cur;
|
|
581
|
+
cur = swap;
|
|
582
|
+
}
|
|
583
|
+
return 1 - prev[m] / Math.max(n, m);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Closest line to `needle` in the file (typo-level near misses). Searches near
|
|
587
|
+
* the hint first; a full scan is length-filtered and candidate-capped so a big
|
|
588
|
+
* file cannot make the failure path expensive. */
|
|
589
|
+
function closestLine(
|
|
590
|
+
lines: string[],
|
|
591
|
+
needle: string,
|
|
592
|
+
hint: number,
|
|
593
|
+
): { index: number; text: string; similarity: number } | undefined {
|
|
594
|
+
const trimmed = needle.trim();
|
|
595
|
+
if (trimmed.length < 4) return undefined;
|
|
596
|
+
const MIN_SIMILARITY = 0.7;
|
|
597
|
+
const lengthOk = (l: string) => Math.abs(l.length - needle.length) <= Math.max(8, needle.length * 0.4);
|
|
598
|
+
|
|
599
|
+
let best: { index: number; text: string; similarity: number } | undefined;
|
|
600
|
+
const consider = (i: number) => {
|
|
601
|
+
const line = lines[i];
|
|
602
|
+
if (!lengthOk(line)) return;
|
|
603
|
+
if (!line.trim()) return;
|
|
604
|
+
const sim = lineSimilarity(needle, line);
|
|
605
|
+
if (sim >= MIN_SIMILARITY && (!best || sim > best.similarity)) best = { index: i, text: line, similarity: sim };
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const lo = Math.max(0, hint - 1 - 100);
|
|
609
|
+
const hi = Math.min(lines.length, hint - 1 + 100);
|
|
610
|
+
for (let i = lo; i < hi; i++) consider(i);
|
|
611
|
+
if (best && best.similarity >= 0.9) return best;
|
|
612
|
+
|
|
613
|
+
let examined = 0;
|
|
614
|
+
const MAX_EXAMINED = 5000;
|
|
615
|
+
for (let i = 0; i < lines.length && examined < MAX_EXAMINED; i++) {
|
|
616
|
+
if (i >= lo && i < hi) continue;
|
|
617
|
+
if (!lengthOk(lines[i])) continue;
|
|
618
|
+
examined++;
|
|
619
|
+
consider(i);
|
|
620
|
+
}
|
|
621
|
+
return best;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Distance from the hint to a matched range; 0 when the hint is inside it.
|
|
625
|
+
* start/end are 0-based (end exclusive), hint is 1-based. */
|
|
626
|
+
function rangeDistance(start: number, end: number, hint: number): number {
|
|
627
|
+
const firstLine = start + 1;
|
|
628
|
+
const lastLine = end;
|
|
629
|
+
if (hint >= firstLine && hint <= lastLine) return 0;
|
|
630
|
+
if (hint < firstLine) return firstLine - hint;
|
|
631
|
+
return hint - lastLine;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Detect a silent indent-style change: the file is indented with tabs (or
|
|
635
|
+
* spaces) while the patch's indented lines use the other style. Judged against
|
|
636
|
+
* the whole file, because a matched block may contain top-level lines and would
|
|
637
|
+
* otherwise skew the majority. Only reported, never rewritten — the model may
|
|
638
|
+
* be re-indenting on purpose. */
|
|
639
|
+
/** A JSDoc continuation line (" * text") is not evidence of space indentation. */
|
|
640
|
+
const JSDOC_CONTINUATION_RE = /^\s\*[^/]/;
|
|
641
|
+
|
|
642
|
+
function indentMismatch(fileLines: string[], start: number, hunk: Hunk): string | undefined {
|
|
643
|
+
const indentedAfter = hunk.after.filter((l) => l.trim() !== "" && /^\s/.test(l) && !JSDOC_CONTINUATION_RE.test(l));
|
|
644
|
+
if (indentedAfter.length === 0) return undefined;
|
|
645
|
+
|
|
646
|
+
const tabsIn = (lines: string[]) => lines.filter((l) => /^\t/.test(l)).length;
|
|
647
|
+
let fileIndentedTabs = 0;
|
|
648
|
+
let fileIndentedSpaces = 0;
|
|
649
|
+
for (const line of fileLines) {
|
|
650
|
+
if (line.trim() === "" || !/^\s/.test(line) || JSDOC_CONTINUATION_RE.test(line)) continue;
|
|
651
|
+
if (/^\t/.test(line)) fileIndentedTabs++;
|
|
652
|
+
else fileIndentedSpaces++;
|
|
653
|
+
}
|
|
654
|
+
if (fileIndentedTabs === 0 && fileIndentedSpaces === 0) return undefined;
|
|
655
|
+
const fileUsesTabs = fileIndentedTabs > fileIndentedSpaces;
|
|
656
|
+
|
|
657
|
+
const patchIndentedTabs = tabsIn(indentedAfter);
|
|
658
|
+
const patchIndentedSpaces = indentedAfter.length - patchIndentedTabs;
|
|
659
|
+
const patchUsesTabs = patchIndentedTabs > patchIndentedSpaces;
|
|
660
|
+
if (fileUsesTabs === patchUsesTabs) return undefined;
|
|
661
|
+
return fileUsesTabs
|
|
662
|
+
? "file indents with tabs, the patch's new lines use spaces"
|
|
663
|
+
: "file indents with spaces, the patch's new lines use tabs";
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Seven lines of the file centred on the hunk's hint. Only called with a hint:
|
|
667
|
+
* without one there is no place to centre on, and the head of the file is not
|
|
668
|
+
* context for a block that sits 200 lines further down (2026-10-08: a batch in
|
|
669
|
+
* executor.py dumped the same six head lines under two different failures, while
|
|
670
|
+
* the blocks belonged around lines 200+). */
|
|
671
|
+
function contextDump(lines: string[], hint: number): string {
|
|
672
|
+
const center = Math.min(Math.max(hint, 1), lines.length);
|
|
673
|
+
const from = Math.max(0, center - 4);
|
|
674
|
+
const to = Math.min(lines.length, center + 3);
|
|
675
|
+
const pad = String(to).length;
|
|
676
|
+
const rows: string[] = [];
|
|
677
|
+
for (let n = from; n < to; n++) {
|
|
678
|
+
rows.push(`${String(n + 1).padStart(pad)} | ${lines[n]}`);
|
|
679
|
+
}
|
|
680
|
+
return rows.join("\n");
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export interface HunkFailure {
|
|
684
|
+
/** 0-based hunk index in the patch. */
|
|
685
|
+
index: number;
|
|
686
|
+
hint: number;
|
|
687
|
+
kind: HunkKind;
|
|
688
|
+
/** One-line reason. */
|
|
689
|
+
reason: string;
|
|
690
|
+
/** Extra multi-line detail (diagnosis, context dump). */
|
|
691
|
+
detail: string;
|
|
692
|
+
/** Ready-to-paste corrected hunk, when a close candidate was found. */
|
|
693
|
+
suggestion?: string;
|
|
694
|
+
/** Would this hunk have matched if it were alone? (failure causes: ambiguity) */
|
|
695
|
+
wouldMatch?: { from: number; to: number; tier: MatchTier };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export interface ResolveReport {
|
|
699
|
+
/** Filled only when every hunk resolved. */
|
|
700
|
+
resolved: ResolvedHunk[];
|
|
701
|
+
failures: HunkFailure[];
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** Resolve one hunk; never throws — failures are returned as data. */
|
|
705
|
+
function resolveOne(hunk: Hunk, fileLines: string[], index: number): { ok: ResolvedHunk } | { fail: HunkFailure } {
|
|
706
|
+
const hint = hunk.hint;
|
|
707
|
+
const kind: HunkKind = hunk.before.length === 0 ? "insert" : hunk.after.length === 0 ? "delete" : "replace";
|
|
708
|
+
|
|
709
|
+
if (hunk.before.length === 0) {
|
|
710
|
+
if (hint === null) {
|
|
711
|
+
return {
|
|
712
|
+
fail: {
|
|
713
|
+
index,
|
|
714
|
+
hint,
|
|
715
|
+
kind,
|
|
716
|
+
reason: "this hunk has no NNN hint, but an insert has no block to match — there is nothing that says where to insert",
|
|
717
|
+
detail: '\nWrite a line number in the header, e.g. "42 @@@", giving the line the new content goes before.',
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
// before: insert before line NNN → 0-based index NNN-1, anchor line NNN.
|
|
722
|
+
// after (NNN+): insert after line NNN → 0-based index NNN, anchor line NNN.
|
|
723
|
+
const idx = hunk.insertAfter
|
|
724
|
+
? Math.min(Math.max(hint, 0), fileLines.length)
|
|
725
|
+
: Math.min(Math.max(hint - 1, 0), fileLines.length);
|
|
726
|
+
return {
|
|
727
|
+
ok: {
|
|
728
|
+
hunk,
|
|
729
|
+
kind: "insert",
|
|
730
|
+
start: idx,
|
|
731
|
+
end: idx,
|
|
732
|
+
match: { tier: "hint", from: hunk.insertAfter ? idx : idx + 1, to: idx, hint, distance: 0, farFromHint: false },
|
|
733
|
+
},
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (hunk.before.length > fileLines.length) {
|
|
738
|
+
return {
|
|
739
|
+
fail: {
|
|
740
|
+
index,
|
|
741
|
+
hint,
|
|
742
|
+
kind,
|
|
743
|
+
reason: `before-block has ${hunk.before.length} lines but the file only has ${fileLines.length}`,
|
|
744
|
+
detail: "",
|
|
745
|
+
},
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
let candidates: number[] | null = null;
|
|
750
|
+
let tier: MatchTier = "exact";
|
|
751
|
+
for (const level of LADDER) {
|
|
752
|
+
const c = findCandidates(fileLines, hunk.before, level.eq);
|
|
753
|
+
if (c.length > 0) {
|
|
754
|
+
candidates = c;
|
|
755
|
+
tier = level.name as MatchTier;
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (!candidates || candidates.length === 0) {
|
|
761
|
+
// A unified-diff attempt is diagnosed here, AFTER the block failed to match
|
|
762
|
+
// — never as a parse-time guess: a "-" line followed by a "+" line is legal
|
|
763
|
+
// content (2026-10-07: a markdown bullet plus a "+18…" continuation line was
|
|
764
|
+
// mistaken for a diff and the whole patch was rejected).
|
|
765
|
+
const diffStyleHint = unifiedDiffHint(hunk.before);
|
|
766
|
+
const suggestion = suggestCorrection(fileLines, hunk, hint);
|
|
767
|
+
return {
|
|
768
|
+
fail: {
|
|
769
|
+
index,
|
|
770
|
+
hint,
|
|
771
|
+
kind,
|
|
772
|
+
reason: "before-block not found (exact, trim and whitespace-collapse matching all failed)",
|
|
773
|
+
detail:
|
|
774
|
+
`${diffStyleHint}${diagnoseBlock(fileLines, hunk.before, hint)}` +
|
|
775
|
+
// The hint is the only thing that says where the block was meant to be.
|
|
776
|
+
// Without one the diagnosis above (per-line closest candidates, and the
|
|
777
|
+
// candidate region when there is one) is all the location there is.
|
|
778
|
+
(hint === null ? "" : `\nLines around ${hint}:\n${contextDump(fileLines, hint)}`) +
|
|
779
|
+
(suggestion ? `\n${suggestion.text}` : ""),
|
|
780
|
+
suggestion: suggestion?.patch,
|
|
781
|
+
},
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// The before-block itself must be unique: a repeated block is NEVER resolved
|
|
786
|
+
// by the hint. Picking the copy nearest the hint is exactly the silent
|
|
787
|
+
// misapplication this tool exists to prevent (2026-10-02 feedback: "not
|
|
788
|
+
// unique" has to be an error, not a report line next to the applied ones).
|
|
789
|
+
if (candidates.length > 1) {
|
|
790
|
+
const shown = candidates.slice(0, 5);
|
|
791
|
+
const more = candidates.length > 5 ? ` … and ${candidates.length - 5} more` : "";
|
|
792
|
+
return {
|
|
793
|
+
fail: {
|
|
794
|
+
index,
|
|
795
|
+
hint,
|
|
796
|
+
kind,
|
|
797
|
+
reason: `not unique — the before-block matches at lines ${shown.map((c) => c + 1).join(", ")}${more} (${tier} match, ${candidates.length} copies)`,
|
|
798
|
+
detail:
|
|
799
|
+
"\nInclude more surrounding lines in the before-block so it matches exactly once — " +
|
|
800
|
+
"a line number cannot choose between identical blocks.",
|
|
801
|
+
wouldMatch: { from: candidates[0] + 1, to: candidates[0] + hunk.before.length, tier },
|
|
802
|
+
},
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Distance is measured to the matched RANGE (0 when the hint falls inside the
|
|
807
|
+
// block), not to its first line — that is what "how far is this block from
|
|
808
|
+
// where I asked" means to the caller, and what "hint N off by K" reports.
|
|
809
|
+
const best = candidates[0];
|
|
810
|
+
const bestDist = rangeDistance(best, best + hunk.before.length, hint);
|
|
811
|
+
|
|
812
|
+
return {
|
|
813
|
+
ok: {
|
|
814
|
+
hunk,
|
|
815
|
+
kind,
|
|
816
|
+
start: best,
|
|
817
|
+
end: best + hunk.before.length,
|
|
818
|
+
match: {
|
|
819
|
+
tier,
|
|
820
|
+
from: best + 1,
|
|
821
|
+
to: best + hunk.before.length,
|
|
822
|
+
hint,
|
|
823
|
+
distance: bestDist,
|
|
824
|
+
farFromHint: bestDist > WINDOW,
|
|
825
|
+
indentMismatch: indentMismatch(fileLines, best, hunk),
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Resolve every hunk; errors are collected instead of thrown. */
|
|
832
|
+
export function resolveAllHunks(hunks: Hunk[], fileLines: string[]): ResolveReport {
|
|
833
|
+
const resolved: ResolvedHunk[] = [];
|
|
834
|
+
const failures: HunkFailure[] = [];
|
|
835
|
+
hunks.forEach((hunk, index) => {
|
|
836
|
+
const out = resolveOne(hunk, fileLines, index);
|
|
837
|
+
if ("ok" in out) resolved.push(out.ok);
|
|
838
|
+
else failures.push(out.fail);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
// Resolved successes are kept even when other hunks fail, so the error can
|
|
842
|
+
// tell the model exactly which hunks would have matched (nothing is written).
|
|
843
|
+
if (failures.length > 0) return { resolved, failures };
|
|
844
|
+
|
|
845
|
+
// Overlap check (resolved positions are against the original).
|
|
846
|
+
const sorted = [...resolved].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
847
|
+
for (let k = 1; k < sorted.length; k++) {
|
|
848
|
+
if (sorted[k].start < sorted[k - 1].end) {
|
|
849
|
+
const a = sorted[k - 1];
|
|
850
|
+
const b = sorted[k];
|
|
851
|
+
return {
|
|
852
|
+
resolved: [],
|
|
853
|
+
failures: [
|
|
854
|
+
{
|
|
855
|
+
index: resolved.indexOf(b),
|
|
856
|
+
hint: b.hunk.hint,
|
|
857
|
+
kind: b.kind,
|
|
858
|
+
reason: `overlaps hunk ${resolved.indexOf(a) + 1}: lines ${a.start + 1}-${a.end} and ${b.start + 1}-${b.end}`,
|
|
859
|
+
detail: "\nMerge the overlapping hunks into one, or give them distinct non-overlapping blocks.",
|
|
860
|
+
wouldMatch: { from: b.start + 1, to: b.end, tier: b.match.tier as MatchTier },
|
|
861
|
+
},
|
|
862
|
+
],
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return { resolved, failures: [] };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Resolve every hunk against the ORIGINAL file content.
|
|
872
|
+
* Throws a single EditError describing the whole batch when anything fails —
|
|
873
|
+
* the caller never wrote anything, and the error says so (plus the fate of
|
|
874
|
+
* every other hunk), so a model can never mistake a rejected batch for an
|
|
875
|
+
* applied one.
|
|
876
|
+
*/
|
|
877
|
+
export function resolveHunks(hunks: Hunk[], fileLines: string[]): ResolvedHunk[] {
|
|
878
|
+
const report = resolveAllHunks(hunks, fileLines);
|
|
879
|
+
if (report.failures.length === 0) return report.resolved;
|
|
880
|
+
|
|
881
|
+
const lines: string[] = [
|
|
882
|
+
`batch rejected — 0 of ${hunks.length} hunk(s) applied, nothing was written to the file.`,
|
|
883
|
+
];
|
|
884
|
+
const byIndex = new Map<number, ResolvedHunk>();
|
|
885
|
+
for (const [i, hunk] of hunks.entries()) {
|
|
886
|
+
const r = report.resolved.find((x) => x.hunk === hunk);
|
|
887
|
+
if (r) byIndex.set(i, r);
|
|
888
|
+
}
|
|
889
|
+
// Full detail for the first couple of failures; the rest stay one-line to
|
|
890
|
+
// keep the error readable for large batches. An identical diagnosis is printed
|
|
891
|
+
// once — two failing hunks must not repeat the same six lines (2026-10-08).
|
|
892
|
+
const DETAIL_LIMIT = 2;
|
|
893
|
+
const printed = new Map<string, number>();
|
|
894
|
+
report.failures.forEach((f, k) => {
|
|
895
|
+
let detail = "";
|
|
896
|
+
if (k >= DETAIL_LIMIT) {
|
|
897
|
+
detail = f.detail ? "\n(details omitted — fix the failures above first)" : "";
|
|
898
|
+
} else if (f.detail) {
|
|
899
|
+
const seen = printed.get(f.detail);
|
|
900
|
+
if (seen === undefined) {
|
|
901
|
+
printed.set(f.detail, f.index + 1);
|
|
902
|
+
detail = f.detail;
|
|
903
|
+
} else {
|
|
904
|
+
detail = `\n(same diagnosis as hunk ${seen})`;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
lines.push(`hunk ${f.index + 1}: REJECTED — ${f.reason}${detail}`);
|
|
908
|
+
});
|
|
909
|
+
for (let i = 0; i < hunks.length; i++) {
|
|
910
|
+
if (report.failures.some((f) => f.index === i)) continue;
|
|
911
|
+
const r = byIndex.get(i);
|
|
912
|
+
if (r) {
|
|
913
|
+
lines.push(
|
|
914
|
+
`hunk ${i + 1}: would have matched (${r.kind} src lines ${r.match.from}-${r.match.to}, ${r.match.tier}${r.match.hint !== null && r.match.distance > 0 ? `, hint ${r.match.hint} off by ${r.match.distance}` : ""}) — NOT applied.`,
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
const withSuggestion = report.failures.find((f) => f.suggestion);
|
|
919
|
+
if (withSuggestion?.suggestion) {
|
|
920
|
+
lines.push(`\nSuggested corrected hunk for hunk ${withSuggestion.index + 1}:\n${withSuggestion.suggestion}`);
|
|
921
|
+
}
|
|
922
|
+
throw new EditError(lines.join("\n"));
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** Find the closest near-match region and build a ready-to-paste corrected hunk. */
|
|
926
|
+
function suggestCorrection(
|
|
927
|
+
fileLines: string[],
|
|
928
|
+
hunk: Hunk,
|
|
929
|
+
hint: number | null,
|
|
930
|
+
): { text: string; patch: string } | undefined {
|
|
931
|
+
const before = hunk.before;
|
|
932
|
+
if (before.length === 0) return undefined;
|
|
933
|
+
|
|
934
|
+
// Anchor on the longest line of the block (most distinctive) and score every
|
|
935
|
+
// alignment that contains it; also score alignments anchored on the first line.
|
|
936
|
+
const anchorIdx = before.reduce((best, l, i) => (l.trim().length > before[best].trim().length ? i : best), 0);
|
|
937
|
+
const anchors: Array<{ line: number; offset: number }> = [];
|
|
938
|
+
const starts = new Set<number>();
|
|
939
|
+
const anchorOffsets = new Set([anchorIdx, 0]);
|
|
940
|
+
for (const offset of anchorOffsets) {
|
|
941
|
+
const needle = before[offset];
|
|
942
|
+
fileLines.forEach((line, i) => {
|
|
943
|
+
if (line === needle) {
|
|
944
|
+
starts.add(i - offset);
|
|
945
|
+
anchors.push({ line: i, offset });
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
// Nothing matched exactly — the block is probably one typo away from a real
|
|
950
|
+
// line ("31_000" vs "30_000"). Anchor on the most similar line instead, so the
|
|
951
|
+
// model still gets a pasteable correction rather than just a hint.
|
|
952
|
+
if (starts.size === 0) {
|
|
953
|
+
for (const offset of anchorOffsets) {
|
|
954
|
+
const near = closestLine(fileLines, before[offset], hint);
|
|
955
|
+
if (near) starts.add(near.index - offset);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const scoreAt = (start: number): number => {
|
|
960
|
+
if (start < 0 || start + before.length > fileLines.length) return -1;
|
|
961
|
+
let score = 0;
|
|
962
|
+
for (let k = 0; k < before.length; k++) {
|
|
963
|
+
const a = fileLines[start + k];
|
|
964
|
+
const b = before[k];
|
|
965
|
+
if (a === b) score += 1;
|
|
966
|
+
else if (a.trim() === b.trim() && b.trim() !== "") score += 0.75;
|
|
967
|
+
else if (a.replace(/\s+/g, " ").trim() === b.replace(/\s+/g, " ").trim() && b.trim() !== "") score += 0.5;
|
|
968
|
+
else {
|
|
969
|
+
// Typo-level similarity ("31_000" vs "30_000") still makes this the
|
|
970
|
+
// right region to show, even though no line matches outright.
|
|
971
|
+
const sim = lineSimilarity(a, b);
|
|
972
|
+
if (sim >= 0.7) score += 0.75 * sim;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
return score;
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
let bestStart = -1;
|
|
979
|
+
let bestScore = -1;
|
|
980
|
+
for (const s of starts) {
|
|
981
|
+
const score = scoreAt(s);
|
|
982
|
+
if (score > bestScore) {
|
|
983
|
+
bestScore = score;
|
|
984
|
+
bestStart = s;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
// Any real evidence is enough: one exactly matching line scores 1, and a
|
|
988
|
+
// single-line typo scores ~0.7 through the similarity credit.
|
|
989
|
+
if (bestStart < 0 || bestScore < 0.5) return undefined;
|
|
990
|
+
|
|
991
|
+
const actual = fileLines.slice(bestStart, bestStart + before.length);
|
|
992
|
+
const matchesLoosely = (a: string, b: string) =>
|
|
993
|
+
a === b ||
|
|
994
|
+
a.trim() === b.trim() ||
|
|
995
|
+
a.replace(/\s+/g, " ").trim() === b.replace(/\s+/g, " ").trim() ||
|
|
996
|
+
lineSimilarity(a, b) >= 0.7;
|
|
997
|
+
const matched = actual.filter((a, k) => matchesLoosely(a, before[k])).length;
|
|
998
|
+
const exact = actual.filter((a, k) => a === before[k]).length;
|
|
999
|
+
const detail: string[] = [];
|
|
1000
|
+
detail.push(
|
|
1001
|
+
`\nClosest candidate: lines ${bestStart + 1}-${bestStart + before.length} — ` +
|
|
1002
|
+
`${matched} of ${before.length} line(s) match${exact < matched ? ` (${exact} exactly)` : ""}.`,
|
|
1003
|
+
);
|
|
1004
|
+
detail.push("Actual file content there:");
|
|
1005
|
+
detail.push(...actual.map((l, k) => ` ${String(bestStart + k + 1).padStart(4)} | ${l === before[k] ? "=" : "≠"} ${l}`));
|
|
1006
|
+
if (before.some((l, k) => l !== actual[k])) {
|
|
1007
|
+
detail.push("Lines your before-block has that the file does not (≠ above):");
|
|
1008
|
+
before.forEach((l, k) => {
|
|
1009
|
+
if (l !== actual[k]) detail.push(` ${JSON.stringify(l)}`);
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Ready-to-paste hunk: the real file lines as before, the model's after-block
|
|
1014
|
+
// as after (plus a before/after mix-up repair when applicable).
|
|
1015
|
+
const missingSuffix = before.findIndex((l) => !actual.includes(l));
|
|
1016
|
+
let afterLines = hunk.after;
|
|
1017
|
+
if (afterLines.length === 0 && missingSuffix > 0 && before.slice(missingSuffix).every((l) => !actual.includes(l))) {
|
|
1018
|
+
afterLines = [...actual, ...before.slice(missingSuffix)];
|
|
1019
|
+
}
|
|
1020
|
+
const patch =
|
|
1021
|
+
`${bestStart + 1} @@@\n` +
|
|
1022
|
+
actual.join("\n") +
|
|
1023
|
+
`\n@@@\n` +
|
|
1024
|
+
afterLines.join("\n") +
|
|
1025
|
+
(afterLines.length > 0 ? "\n@@@" : "");
|
|
1026
|
+
|
|
1027
|
+
return { text: detail.join("\n"), patch };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** Apply resolved hunks to the original lines. Positions must not overlap. */
|
|
1031
|
+
export function applyHunks(fileLines: string[], resolved: ResolvedHunk[]): string[] {
|
|
1032
|
+
const out = [...fileLines];
|
|
1033
|
+
// Bottom-up so earlier positions stay valid.
|
|
1034
|
+
const desc = [...resolved].sort((a, b) => b.start - a.start || b.end - a.end);
|
|
1035
|
+
for (const r of desc) {
|
|
1036
|
+
out.splice(r.start, r.end - r.start, ...r.hunk.after);
|
|
1037
|
+
}
|
|
1038
|
+
return out;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** Minimal LCS diff for small line arrays (used per-hunk with context). */
|
|
1042
|
+
function lcsDiff(oldLines: string[], newLines: string[]): string[] {
|
|
1043
|
+
const n = oldLines.length;
|
|
1044
|
+
const m = newLines.length;
|
|
1045
|
+
// Small chunks only; cap to keep memory sane.
|
|
1046
|
+
if (n * m > 1_000_000) {
|
|
1047
|
+
return [...oldLines.map((l) => "-" + l), ...newLines.map((l) => "+" + l)];
|
|
1048
|
+
}
|
|
1049
|
+
const dp: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
1050
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
1051
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
1052
|
+
dp[i][j] = oldLines[i] === newLines[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
const out: string[] = [];
|
|
1056
|
+
let i = 0;
|
|
1057
|
+
let j = 0;
|
|
1058
|
+
while (i < n && j < m) {
|
|
1059
|
+
if (oldLines[i] === newLines[j]) {
|
|
1060
|
+
out.push(" " + oldLines[i]);
|
|
1061
|
+
i++;
|
|
1062
|
+
j++;
|
|
1063
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
1064
|
+
out.push("-" + oldLines[i]);
|
|
1065
|
+
i++;
|
|
1066
|
+
} else {
|
|
1067
|
+
out.push("+" + newLines[j]);
|
|
1068
|
+
j++;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
while (i < n) out.push("-" + oldLines[i++]);
|
|
1072
|
+
while (j < m) out.push("+" + newLines[j++]);
|
|
1073
|
+
return out;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
const CONTEXT = 2;
|
|
1077
|
+
|
|
1078
|
+
export interface HunkReport {
|
|
1079
|
+
kind: HunkKind;
|
|
1080
|
+
/** 1-based line range in the ORIGINAL (source) file. */
|
|
1081
|
+
from: number;
|
|
1082
|
+
to: number;
|
|
1083
|
+
beforeCount: number;
|
|
1084
|
+
afterCount: number;
|
|
1085
|
+
tier: MatchTier | "hint";
|
|
1086
|
+
hint: number | null;
|
|
1087
|
+
distance: number;
|
|
1088
|
+
farFromHint: boolean;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
export function hunkSummary(r: ResolvedHunk, index: number): HunkReport {
|
|
1092
|
+
void index;
|
|
1093
|
+
return {
|
|
1094
|
+
kind: r.kind,
|
|
1095
|
+
from: r.start + 1,
|
|
1096
|
+
to: r.end,
|
|
1097
|
+
beforeCount: r.hunk.before.length,
|
|
1098
|
+
afterCount: r.hunk.after.length,
|
|
1099
|
+
tier: r.match.tier,
|
|
1100
|
+
hint: r.match.hint,
|
|
1101
|
+
distance: r.match.distance,
|
|
1102
|
+
farFromHint: r.match.farFromHint,
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** Model-facing one-line report per hunk: what happened, where it landed, how
|
|
1107
|
+
* confident the match was, and the unambiguous source → result line mapping. */
|
|
1108
|
+
export function formatHunkReport(r: ResolvedHunk, index: number, outFrom: number): string {
|
|
1109
|
+
// Direction word on inserts: "insert src line 12" was ambiguous about
|
|
1110
|
+
// BEFORE/AFTER — models landed code outside functions (session 2026-09-30).
|
|
1111
|
+
const label = r.kind === "insert"
|
|
1112
|
+
? r.hunk.insertAfter ? "insert AFTER" : "insert BEFORE"
|
|
1113
|
+
: r.kind === "delete" ? "delete" : "replace";
|
|
1114
|
+
const outTo = outFrom + Math.max(r.hunk.after.length - 1, 0);
|
|
1115
|
+
const srcRange = r.kind === "insert" ? `src line ${r.match.from}` : `src ${r.match.from}-${r.match.to}`;
|
|
1116
|
+
const outRange = r.hunk.after.length === 0 ? "removed" : r.kind === "insert" ? `out line ${outFrom}` : `out ${outFrom}-${outTo}`;
|
|
1117
|
+
const tier = r.match.tier === "hint" ? "" : `, ${r.match.tier} match`;
|
|
1118
|
+
const off =
|
|
1119
|
+
r.match.tier === "hint" || r.match.hint === null || r.match.distance === 0
|
|
1120
|
+
? ""
|
|
1121
|
+
: `, hint ${r.match.hint} off by ${r.match.distance}`;
|
|
1122
|
+
const hintless = r.match.hint === null && r.match.tier !== "hint" ? ", unique match (no hint given)" : "";
|
|
1123
|
+
const indent = r.match.indentMismatch ? ` [indentation: ${r.match.indentMismatch} — the new lines keep the patch's style]` : "";
|
|
1124
|
+
return `hunk ${index + 1}: ${label} ${srcRange} → ${outRange} (${r.hunk.before.length} → ${r.hunk.after.length} lines)${tier}${hintless}${off}${indent}`;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/** Per-hunk unified-style diff with a couple of context lines. */
|
|
1128
|
+
export function hunkDiff(original: string[], updated: string[], r: ResolvedHunk): string {
|
|
1129
|
+
const ctxFrom = Math.max(0, r.start - CONTEXT);
|
|
1130
|
+
const ctxTo = Math.min(original.length, r.end + CONTEXT);
|
|
1131
|
+
// Header must describe the BODY (context window), not just the hunk: the UI
|
|
1132
|
+
// renderer maps body lines onto the on-disk file via the +start number, so a
|
|
1133
|
+
// hunk-scoped header shifted every displayed line by the leading context.
|
|
1134
|
+
const head = original.length === 0
|
|
1135
|
+
? ""
|
|
1136
|
+
: `@@ -${ctxFrom + 1},${ctxTo - ctxFrom} +${ctxFrom + 1},${ctxTo - ctxFrom + r.hunk.after.length - (r.end - r.start)} @@`;
|
|
1137
|
+
if (r.kind === "insert" && original.length === 0) {
|
|
1138
|
+
return `@@ +1 @@ (new file content)\n` + r.hunk.after.map((l) => "+" + l).join("\n");
|
|
1139
|
+
}
|
|
1140
|
+
const oldSlice = original.slice(ctxFrom, ctxTo);
|
|
1141
|
+
// Rebuild the new slice for this region by splicing the hunk into the context window.
|
|
1142
|
+
const relStart = r.start - ctxFrom;
|
|
1143
|
+
const relEnd = r.end - ctxFrom;
|
|
1144
|
+
const newSlice = [
|
|
1145
|
+
...oldSlice.slice(0, relStart),
|
|
1146
|
+
...(r.kind === "insert" ? r.hunk.after : r.kind === "delete" ? [] : r.hunk.after),
|
|
1147
|
+
...oldSlice.slice(relEnd),
|
|
1148
|
+
];
|
|
1149
|
+
const body = lcsDiff(oldSlice, newSlice);
|
|
1150
|
+
return [head, ...body].join("\n");
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** Per-hunk diffs computed sequentially: each hunk is diffed against the
|
|
1154
|
+
* file state AFTER the previous hunks of the same call were applied, so
|
|
1155
|
+
* context lines always reflect the final content. */
|
|
1156
|
+
export function sequentialDiffs(original: string[], resolved: ResolvedHunk[]): string[] {
|
|
1157
|
+
const sorted = [...resolved].sort((a, b) => a.start - b.start);
|
|
1158
|
+
let cur = [...original];
|
|
1159
|
+
let delta = 0;
|
|
1160
|
+
const diffs: string[] = [];
|
|
1161
|
+
for (const r of sorted) {
|
|
1162
|
+
const rebased: ResolvedHunk = { ...r, start: r.start + delta, end: r.end + delta };
|
|
1163
|
+
const before = cur;
|
|
1164
|
+
cur = applyHunks(cur, [rebased]);
|
|
1165
|
+
diffs.push(hunkDiff(before, cur, rebased));
|
|
1166
|
+
delta += r.hunk.after.length - (r.end - r.start);
|
|
1167
|
+
}
|
|
1168
|
+
return diffs;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
export function truncateDiff(diff: string, max = MAX_DIFF_CHARS): { text: string; truncated: boolean } {
|
|
1172
|
+
if (diff.length <= max) return { text: diff, truncated: false };
|
|
1173
|
+
return { text: diff.slice(0, max) + "\n… (diff truncated)", truncated: true };
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
export interface EditOutcome {
|
|
1177
|
+
reports: HunkReport[];
|
|
1178
|
+
/** Model-facing one-line report per hunk (src → out line mapping). */
|
|
1179
|
+
lines: string[];
|
|
1180
|
+
totalLines: number;
|
|
1181
|
+
diff: string;
|
|
1182
|
+
diffTruncated: boolean;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** Convenience: parse + resolve + apply + summarize in one call. */
|
|
1186
|
+
export function runPatch(patch: string, fileLines: string[]): EditOutcome {
|
|
1187
|
+
const hunks = parsePatch(patch);
|
|
1188
|
+
const resolved = resolveHunks(hunks, fileLines);
|
|
1189
|
+
const updated = applyHunks(fileLines, resolved);
|
|
1190
|
+
const reports = resolved.map((r, i) => hunkSummary(r, i));
|
|
1191
|
+
const lines = formatHunkReports(resolved);
|
|
1192
|
+
const diff = sequentialDiffs(fileLines, resolved).join("\n");
|
|
1193
|
+
const { text, truncated } = truncateDiff(diff);
|
|
1194
|
+
return { reports, lines, totalLines: updated.length, diff: text, diffTruncated: truncated };
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/** Reports for the whole batch, with result-side line numbers resolved.
|
|
1198
|
+
* `numbers` (0-based, optional) restores ORIGINAL hunk numbers when some hunks
|
|
1199
|
+
* were filtered out before resolution (no-op hunks). */
|
|
1200
|
+
export function formatHunkReports(resolved: ResolvedHunk[], numbers?: number[]): string[] {
|
|
1201
|
+
const ascending = [...resolved].sort((a, b) => a.start - b.start);
|
|
1202
|
+
const outStart = new Map<ResolvedHunk, number>();
|
|
1203
|
+
let delta = 0;
|
|
1204
|
+
for (const r of ascending) {
|
|
1205
|
+
outStart.set(r, r.start + 1 + delta);
|
|
1206
|
+
delta += r.hunk.after.length - (r.end - r.start);
|
|
1207
|
+
}
|
|
1208
|
+
return resolved.map((r, i) => formatHunkReport(r, numbers ? numbers[i] : i, outStart.get(r) ?? r.start + 1));
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/** First line of a successful report. It mirrors the rejection line ("batch
|
|
1212
|
+
* rejected — 0 of N hunk(s) applied, nothing was written to the file"), so
|
|
1213
|
+
* "applied and unique" vs "not found / not unique" is the first thing read
|
|
1214
|
+
* (2026-10-02 feedback: that contrast is what keeps a model on this tool).
|
|
1215
|
+
* Skipped no-op hunks are not counted here — they have their own SKIPPED line. */
|
|
1216
|
+
export function appliedLine(applied: number, total: number): string {
|
|
1217
|
+
return `applied — ${applied} of ${total} hunk${total === 1 ? "" : "s"}, file written`;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/** Advisory lines printed ABOVE the per-hunk reports. A hunk whose anchor was off
|
|
1221
|
+
* was matched by CONTENT and by uniqueness, so the edit landed at the block
|
|
1222
|
+
* itself — the model's line number was stale (2026-10-01 feedback: a second hunk
|
|
1223
|
+
* numbered by the first hunk's result lines). Phrased as the fact it is, not as
|
|
1224
|
+
* a danger warning (2026-10-02 feedback). Distance-0 matches are silent. */
|
|
1225
|
+
export function reportCaveats(resolved: ResolvedHunk[], numbers?: number[]): string[] {
|
|
1226
|
+
const out: string[] = [];
|
|
1227
|
+
resolved.forEach((r, i) => {
|
|
1228
|
+
if (r.match.tier === "hint" || r.match.hint === null || r.match.distance === 0) return;
|
|
1229
|
+
const at = r.kind === "insert" ? `src line ${r.match.from}` : `src ${r.match.from}-${r.match.to}`;
|
|
1230
|
+
out.push(
|
|
1231
|
+
`note: hunk ${(numbers ? numbers[i] : i) + 1}'s hint ${r.match.hint} was off by ${r.match.distance} — the block was found by content and is unique, so the edit was applied at ${at}.`,
|
|
1232
|
+
);
|
|
1233
|
+
});
|
|
1234
|
+
if (resolved.length > 1) {
|
|
1235
|
+
out.push(
|
|
1236
|
+
"note: every hunk of one call matches the ORIGINAL file numbering — the out-numbers are for a follow-up call, not for the other hunks of this one.",
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
if (resolved.some((r) => r.hunk.leadingDelimiterOmitted)) {
|
|
1240
|
+
out.push(
|
|
1241
|
+
"note: the patch had no leading hunk header. One delimiter makes that unambiguous, so it was read as a single replace — write a bare delimiter line (or \"NNN <delimiter>\") first to say it explicitly.",
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
return out;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** The most common stylistic miss (2026-10-01 feedback): an insert written as a
|
|
1248
|
+
* replace that repeats the old block and only adds lines. Returns one advisory
|
|
1249
|
+
* line for the first such hunk — the insert forms need no old block at all. */
|
|
1250
|
+
export function insertTip(resolved: ResolvedHunk[], numbers?: number[], delim = "@@@"): string | null {
|
|
1251
|
+
for (let i = 0; i < resolved.length; i++) {
|
|
1252
|
+
const r = resolved[i];
|
|
1253
|
+
if (r.kind !== "replace" || r.hunk.before.length === 0) continue;
|
|
1254
|
+
const before = r.hunk.before;
|
|
1255
|
+
const after = r.hunk.after;
|
|
1256
|
+
if (after.length <= before.length) continue;
|
|
1257
|
+
const added = after.length - before.length;
|
|
1258
|
+
const lines = `${added} line${added === 1 ? "" : "s"}`;
|
|
1259
|
+
const body = `"${delim}", the new ${lines}, "${delim}"`;
|
|
1260
|
+
const n = (numbers ? numbers[i] : i) + 1;
|
|
1261
|
+
if (after.slice(0, before.length).every((l, k) => l === before[k])) {
|
|
1262
|
+
return `hunk ${n}: this replace only adds ${lines} AFTER src line ${r.match.to} — "${r.match.to}+ ${delim}", ${body} does that without repeating the old block.`;
|
|
1263
|
+
}
|
|
1264
|
+
if (after.slice(after.length - before.length).every((l, k) => l === before[k])) {
|
|
1265
|
+
return `hunk ${n}: this replace only adds ${lines} BEFORE src line ${r.match.from} — "${r.match.from} ${delim}", ${body} does that without repeating the old block.`;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/** A replace hunk whose before-block equals its after-block line for line
|
|
1272
|
+
* (exact): applying it changes nothing, so it is skipped with a note instead
|
|
1273
|
+
* of killing the batch or silently doing busywork. Trim-equal blocks still
|
|
1274
|
+
* apply — whitespace normalization is a real change. */
|
|
1275
|
+
export function isNoOpHunk(h: Hunk): boolean {
|
|
1276
|
+
return h.before.length > 0 && h.before.length === h.after.length && h.before.every((l, i) => l === h.after[i]);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// ---------------------------------------------------------------------------
|
|
1280
|
+
// Whole-file unified diff (used by the `write` override to show overwrites)
|
|
1281
|
+
// ---------------------------------------------------------------------------
|
|
1282
|
+
|
|
1283
|
+
/** Cell budget for the LCS table; larger middles fall back to one coarse block. */
|
|
1284
|
+
const LCS_CELL_LIMIT = 100_000;
|
|
1285
|
+
|
|
1286
|
+
const NO_NEWLINE = "\";
|
|
1287
|
+
|
|
1288
|
+
type DiffOp = { type: "=" | "-" | "+"; text: string };
|
|
1289
|
+
|
|
1290
|
+
/** LCS-based line edit script for the changed middle of two files. */
|
|
1291
|
+
function lcsOps(a: string[], b: string[]): DiffOp[] {
|
|
1292
|
+
const n = a.length;
|
|
1293
|
+
const m = b.length;
|
|
1294
|
+
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
|
1295
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
1296
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
1297
|
+
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
const ops: DiffOp[] = [];
|
|
1302
|
+
let i = 0;
|
|
1303
|
+
let j = 0;
|
|
1304
|
+
while (i < n && j < m) {
|
|
1305
|
+
if (a[i] === b[j]) {
|
|
1306
|
+
ops.push({ type: "=", text: a[i] });
|
|
1307
|
+
i++;
|
|
1308
|
+
j++;
|
|
1309
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
1310
|
+
ops.push({ type: "-", text: a[i] });
|
|
1311
|
+
i++;
|
|
1312
|
+
} else {
|
|
1313
|
+
ops.push({ type: "+", text: b[j] });
|
|
1314
|
+
j++;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
while (i < n) ops.push({ type: "-", text: a[i++] });
|
|
1318
|
+
while (j < m) ops.push({ type: "+", text: b[j++] });
|
|
1319
|
+
return ops;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* Unified diff of two text blobs, in the format pi's renderDiff parses.
|
|
1324
|
+
*
|
|
1325
|
+
* A trailing newline is a line terminator, not an extra empty line (GNU diff
|
|
1326
|
+
* semantics), so it is stripped before splitting; a file whose last line lacks
|
|
1327
|
+
* the newline gets the usual "" marker. Common
|
|
1328
|
+
* prefix/suffix lines are trimmed before diffing, so the LCS table stays small;
|
|
1329
|
+
* if the changed middle is still huge the diff degrades to one replace block
|
|
1330
|
+
* instead of allocating a giant table. Returns "" when the texts are equal.
|
|
1331
|
+
*/
|
|
1332
|
+
export function unifiedDiff(oldText: string, newText: string, filePath: string, context = 3): string {
|
|
1333
|
+
if (oldText === newText) return "";
|
|
1334
|
+
|
|
1335
|
+
const oldHasNL = oldText.endsWith("\n");
|
|
1336
|
+
const newHasNL = newText.endsWith("\n");
|
|
1337
|
+
const a = (oldHasNL ? oldText.slice(0, -1) : oldText).split("\n");
|
|
1338
|
+
const b = (newHasNL ? newText.slice(0, -1) : newText).split("\n");
|
|
1339
|
+
|
|
1340
|
+
let prefix = 0;
|
|
1341
|
+
while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) prefix++;
|
|
1342
|
+
let endA = a.length;
|
|
1343
|
+
let endB = b.length;
|
|
1344
|
+
while (endA > prefix && endB > prefix && a[endA - 1] === b[endB - 1]) {
|
|
1345
|
+
endA--;
|
|
1346
|
+
endB--;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
const midA = a.slice(prefix, endA);
|
|
1350
|
+
const midB = b.slice(prefix, endB);
|
|
1351
|
+
|
|
1352
|
+
const ops: DiffOp[] = [];
|
|
1353
|
+
for (let k = 0; k < prefix; k++) ops.push({ type: "=", text: a[k] });
|
|
1354
|
+
if (midA.length * midB.length <= LCS_CELL_LIMIT) {
|
|
1355
|
+
ops.push(...lcsOps(midA, midB));
|
|
1356
|
+
} else {
|
|
1357
|
+
for (const line of midA) ops.push({ type: "-", text: line });
|
|
1358
|
+
for (const line of midB) ops.push({ type: "+", text: line });
|
|
1359
|
+
}
|
|
1360
|
+
for (let k = endA; k < a.length; k++) ops.push({ type: "=", text: a[k] });
|
|
1361
|
+
|
|
1362
|
+
// Only the final newline differs: show it as a changed last line, like GNU diff.
|
|
1363
|
+
if (ops.every((op) => op.type === "=")) {
|
|
1364
|
+
const last = ops[ops.length - 1];
|
|
1365
|
+
if (last === undefined) return "";
|
|
1366
|
+
ops.splice(ops.length - 1, 1, { type: "-", text: last.text }, { type: "+", text: last.text });
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// Counts of a-side / b-side lines before each op index (1-based hunk starts).
|
|
1370
|
+
const aBefore: number[] = new Array(ops.length + 1).fill(0);
|
|
1371
|
+
const bBefore: number[] = new Array(ops.length + 1).fill(0);
|
|
1372
|
+
let lastA = -1;
|
|
1373
|
+
let lastB = -1;
|
|
1374
|
+
for (let k = 0; k < ops.length; k++) {
|
|
1375
|
+
aBefore[k + 1] = aBefore[k] + (ops[k].type === "+" ? 0 : 1);
|
|
1376
|
+
bBefore[k + 1] = bBefore[k] + (ops[k].type === "-" ? 0 : 1);
|
|
1377
|
+
if (ops[k].type !== "+") lastA = k;
|
|
1378
|
+
if (ops[k].type !== "-") lastB = k;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// Keep every changed op plus `context` lines around it.
|
|
1382
|
+
const keep: boolean[] = new Array(ops.length).fill(false);
|
|
1383
|
+
for (let k = 0; k < ops.length; k++) {
|
|
1384
|
+
if (ops[k].type === "=") continue;
|
|
1385
|
+
const from = Math.max(0, k - context);
|
|
1386
|
+
const to = Math.min(ops.length - 1, k + context);
|
|
1387
|
+
for (let d = from; d <= to; d++) keep[d] = true;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// git-style a/ b/ labels for relative paths; absolute paths stand alone
|
|
1391
|
+
// (otherwise "/tmp/x" would render as the misleading "a//tmp/x").
|
|
1392
|
+
const labels: [string, string] = filePath.startsWith("/")
|
|
1393
|
+
? [filePath, filePath]
|
|
1394
|
+
: [`a/${filePath}`, `b/${filePath}`];
|
|
1395
|
+
const out: string[] = [`--- ${labels[0]}`, `+++ ${labels[1]}`];
|
|
1396
|
+
let k = 0;
|
|
1397
|
+
while (k < ops.length) {
|
|
1398
|
+
if (!keep[k]) {
|
|
1399
|
+
k++;
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
let end = k;
|
|
1403
|
+
while (end + 1 < ops.length && keep[end + 1]) end++;
|
|
1404
|
+
|
|
1405
|
+
const aCount = aBefore[end + 1] - aBefore[k];
|
|
1406
|
+
const bCount = bBefore[end + 1] - bBefore[k];
|
|
1407
|
+
out.push(`@@ -${aBefore[k] + 1},${aCount} +${bBefore[k] + 1},${bCount} @@`);
|
|
1408
|
+
for (let d = k; d <= end; d++) {
|
|
1409
|
+
out.push(`${ops[d].type === "=" ? " " : ops[d].type}${ops[d].text}`);
|
|
1410
|
+
if (d === lastA && !oldHasNL) out.push(NO_NEWLINE);
|
|
1411
|
+
if (d === lastB && !newHasNL) out.push(NO_NEWLINE);
|
|
1412
|
+
}
|
|
1413
|
+
k = end + 1;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
return out.join("\n");
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// ---------------------------------------------------------------------------
|
|
1420
|
+
// Unified-diff parsing + word-level diff (for syntax-highlighted diff rendering)
|
|
1421
|
+
// ---------------------------------------------------------------------------
|
|
1422
|
+
|
|
1423
|
+
export type DiffLineKind = " " | "-" | "+";
|
|
1424
|
+
|
|
1425
|
+
export interface ParsedDiffLine {
|
|
1426
|
+
kind: DiffLineKind;
|
|
1427
|
+
text: string;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
export interface ParsedHunk {
|
|
1431
|
+
/** Original "@@ -a,b +c,d @@" header, for display. */
|
|
1432
|
+
header: string;
|
|
1433
|
+
/** 1-based first line of this hunk in the NEW file. */
|
|
1434
|
+
bStart: number;
|
|
1435
|
+
lines: ParsedDiffLine[];
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* Parse a unified diff produced by unifiedDiff(). File headers are dropped and
|
|
1440
|
+
* "" markers are ignored (they carry no content).
|
|
1441
|
+
*/
|
|
1442
|
+
export function parseUnifiedDiff(diffText: string): ParsedHunk[] {
|
|
1443
|
+
const hunks: ParsedHunk[] = [];
|
|
1444
|
+
let current: ParsedHunk | undefined;
|
|
1445
|
+
|
|
1446
|
+
for (const line of diffText.split("\n")) {
|
|
1447
|
+
if (line.startsWith("--- ") || line.startsWith("+++ ")) continue;
|
|
1448
|
+
if (line.startsWith("@@")) {
|
|
1449
|
+
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
1450
|
+
current = { header: line, bStart: m ? Number(m[1]) : 1, lines: [] };
|
|
1451
|
+
hunks.push(current);
|
|
1452
|
+
continue;
|
|
1453
|
+
}
|
|
1454
|
+
if (current === undefined) continue;
|
|
1455
|
+
if (line.startsWith("\\")) continue;
|
|
1456
|
+
const kind = line[0];
|
|
1457
|
+
if (kind !== " " && kind !== "-" && kind !== "+") continue;
|
|
1458
|
+
current.lines.push({ kind: kind as DiffLineKind, text: line.slice(1) });
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
return hunks;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
export interface WordSegment {
|
|
1465
|
+
text: string;
|
|
1466
|
+
/** True for the words this edit actually changed. */
|
|
1467
|
+
changed: boolean;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/** Split a line into coarse tokens: words, whitespace runs, single symbols. */
|
|
1471
|
+
function wordTokens(line: string): string[] {
|
|
1472
|
+
return line.match(/\s+|[A-Za-z0-9_]+|./g) ?? [];
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/**
|
|
1476
|
+
* Word-level diff of two lines, for the usual "one removed / one added" pair.
|
|
1477
|
+
* Returns segments for both sides that concatenate back to the input exactly;
|
|
1478
|
+
* `changed` marks tokens absent from the other side.
|
|
1479
|
+
*/
|
|
1480
|
+
export function wordDiffPair(oldLine: string, newLine: string): { old: WordSegment[]; new: WordSegment[] } {
|
|
1481
|
+
const a = wordTokens(oldLine);
|
|
1482
|
+
const b = wordTokens(newLine);
|
|
1483
|
+
const n = a.length;
|
|
1484
|
+
const m = b.length;
|
|
1485
|
+
|
|
1486
|
+
// LCS table over tokens (lines are short, so this stays cheap).
|
|
1487
|
+
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
|
1488
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
1489
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
1490
|
+
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
const oldSegments: WordSegment[] = [];
|
|
1495
|
+
const newSegments: WordSegment[] = [];
|
|
1496
|
+
const push = (arr: WordSegment[], text: string, changed: boolean) => {
|
|
1497
|
+
const last = arr[arr.length - 1];
|
|
1498
|
+
if (last && last.changed === changed) last.text += text;
|
|
1499
|
+
else arr.push({ text, changed });
|
|
1500
|
+
};
|
|
1501
|
+
|
|
1502
|
+
let i = 0;
|
|
1503
|
+
let j = 0;
|
|
1504
|
+
while (i < n && j < m) {
|
|
1505
|
+
if (a[i] === b[j]) {
|
|
1506
|
+
push(oldSegments, a[i], false);
|
|
1507
|
+
push(newSegments, b[j], false);
|
|
1508
|
+
i++;
|
|
1509
|
+
j++;
|
|
1510
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
1511
|
+
push(oldSegments, a[i], true);
|
|
1512
|
+
i++;
|
|
1513
|
+
} else {
|
|
1514
|
+
push(newSegments, b[j], true);
|
|
1515
|
+
j++;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
while (i < n) push(oldSegments, a[i++], true);
|
|
1519
|
+
while (j < m) push(newSegments, b[j++], true);
|
|
1520
|
+
|
|
1521
|
+
return { old: oldSegments, new: newSegments };
|
|
1522
|
+
}
|