@oh-my-pi/pi-coding-agent 6.2.0 → 6.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/docs/sdk.md +1 -1
- package/package.json +5 -5
- package/scripts/generate-template.ts +6 -6
- package/src/cli/args.ts +3 -0
- package/src/core/agent-session.ts +39 -0
- package/src/core/bash-executor.ts +3 -3
- package/src/core/cursor/exec-bridge.ts +95 -88
- package/src/core/custom-commands/bundled/review/index.ts +142 -145
- package/src/core/custom-commands/bundled/wt/index.ts +68 -66
- package/src/core/custom-commands/loader.ts +4 -6
- package/src/core/custom-tools/index.ts +2 -2
- package/src/core/custom-tools/loader.ts +66 -61
- package/src/core/custom-tools/types.ts +4 -4
- package/src/core/custom-tools/wrapper.ts +61 -25
- package/src/core/event-bus.ts +19 -47
- package/src/core/extensions/index.ts +8 -4
- package/src/core/extensions/loader.ts +160 -120
- package/src/core/extensions/types.ts +4 -4
- package/src/core/extensions/wrapper.ts +149 -100
- package/src/core/hooks/index.ts +1 -1
- package/src/core/hooks/tool-wrapper.ts +96 -70
- package/src/core/hooks/types.ts +1 -2
- package/src/core/index.ts +1 -0
- package/src/core/mcp/index.ts +6 -2
- package/src/core/mcp/json-rpc.ts +88 -0
- package/src/core/mcp/loader.ts +22 -4
- package/src/core/mcp/manager.ts +202 -48
- package/src/core/mcp/tool-bridge.ts +143 -55
- package/src/core/mcp/tool-cache.ts +122 -0
- package/src/core/python-executor.ts +3 -9
- package/src/core/sdk.ts +33 -32
- package/src/core/session-manager.ts +30 -0
- package/src/core/settings-manager.ts +34 -1
- package/src/core/ssh/ssh-executor.ts +6 -84
- package/src/core/streaming-output.ts +107 -53
- package/src/core/tools/ask.ts +92 -93
- package/src/core/tools/bash.ts +103 -94
- package/src/core/tools/calculator.ts +41 -26
- package/src/core/tools/complete.ts +76 -66
- package/src/core/tools/context.ts +22 -24
- package/src/core/tools/exa/index.ts +1 -1
- package/src/core/tools/exa/mcp-client.ts +56 -101
- package/src/core/tools/find.ts +250 -253
- package/src/core/tools/git.ts +39 -33
- package/src/core/tools/grep.ts +440 -427
- package/src/core/tools/index.ts +62 -61
- package/src/core/tools/ls.ts +119 -114
- package/src/core/tools/lsp/clients/biome-client.ts +5 -7
- package/src/core/tools/lsp/clients/index.ts +4 -4
- package/src/core/tools/lsp/clients/lsp-linter-client.ts +5 -7
- package/src/core/tools/lsp/config.ts +2 -2
- package/src/core/tools/lsp/index.ts +604 -578
- package/src/core/tools/notebook.ts +121 -119
- package/src/core/tools/output.ts +163 -147
- package/src/core/tools/patch/applicator.ts +1100 -0
- package/src/core/tools/patch/diff.ts +362 -0
- package/src/core/tools/patch/fuzzy.ts +647 -0
- package/src/core/tools/patch/index.ts +430 -0
- package/src/core/tools/patch/normalize.ts +220 -0
- package/src/core/tools/patch/normative.ts +49 -0
- package/src/core/tools/patch/parser.ts +528 -0
- package/src/core/tools/patch/shared.ts +228 -0
- package/src/core/tools/patch/types.ts +244 -0
- package/src/core/tools/python.ts +139 -136
- package/src/core/tools/read.ts +237 -216
- package/src/core/tools/render-utils.ts +196 -77
- package/src/core/tools/renderers.ts +1 -1
- package/src/core/tools/ssh.ts +99 -80
- package/src/core/tools/task/executor.ts +11 -7
- package/src/core/tools/task/index.ts +352 -343
- package/src/core/tools/task/worker.ts +13 -23
- package/src/core/tools/todo-write.ts +74 -59
- package/src/core/tools/web-fetch.ts +54 -47
- package/src/core/tools/web-search/index.ts +27 -16
- package/src/core/tools/write.ts +73 -44
- package/src/core/ttsr.ts +106 -152
- package/src/core/voice.ts +49 -39
- package/src/index.ts +16 -12
- package/src/lib/worktree/index.ts +1 -9
- package/src/modes/interactive/components/diff.ts +15 -8
- package/src/modes/interactive/components/settings-defs.ts +24 -0
- package/src/modes/interactive/components/tool-execution.ts +34 -6
- package/src/modes/interactive/controllers/event-controller.ts +6 -19
- package/src/modes/interactive/controllers/input-controller.ts +1 -1
- package/src/modes/interactive/utils/ui-helpers.ts +5 -1
- package/src/modes/rpc/rpc-mode.ts +99 -81
- package/src/prompts/tools/patch.md +76 -0
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/{edit.md → replace.md} +1 -0
- package/src/utils/shell.ts +0 -40
- package/src/core/tools/edit-diff.ts +0 -574
- package/src/core/tools/edit.ts +0 -345
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff/patch parsing for the edit tool.
|
|
3
|
+
*
|
|
4
|
+
* Supports multiple input formats:
|
|
5
|
+
* - Simple +/- diffs
|
|
6
|
+
* - Unified diff format (@@ -X,Y +A,B @@)
|
|
7
|
+
* - Codex-style wrapped patches (*** Begin Patch / *** End Patch)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { DiffHunk } from "./types";
|
|
11
|
+
import { ApplyPatchError, ParseError } from "./types";
|
|
12
|
+
|
|
13
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
14
|
+
// Constants
|
|
15
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
16
|
+
|
|
17
|
+
const EOF_MARKER = "*** End of File";
|
|
18
|
+
const CHANGE_CONTEXT_MARKER = "@@ ";
|
|
19
|
+
const EMPTY_CHANGE_CONTEXT_MARKER = "@@";
|
|
20
|
+
|
|
21
|
+
/** Regex to match unified diff hunk headers: @@ -OLD,COUNT +NEW,COUNT @@ optional-context */
|
|
22
|
+
const UNIFIED_HUNK_HEADER_REGEX = /^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@(?:\s*(.*))?$/;
|
|
23
|
+
|
|
24
|
+
/** Regex to match @@ line/lines N or N-M pattern (model-generated line hints) */
|
|
25
|
+
const LINE_HINT_REGEX = /^lines?\s+(\d+)(?:\s*-\s*(\d+))?(?:\s*@@)?$/i;
|
|
26
|
+
const TOP_OF_FILE_REGEX = /^(top|start|beginning)\s+of\s+file$/i;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if a line is a diff content line (context, addition, or removal).
|
|
30
|
+
* These should never be treated as metadata even if their content looks like it.
|
|
31
|
+
* Note: `--- ` and `+++ ` are metadata headers, not content lines.
|
|
32
|
+
*/
|
|
33
|
+
function isDiffContentLine(line: string): boolean {
|
|
34
|
+
const firstChar = line[0];
|
|
35
|
+
if (firstChar === " ") return true;
|
|
36
|
+
if (firstChar === "+") {
|
|
37
|
+
// `+++ ` is metadata, single `+` followed by content is addition
|
|
38
|
+
return !line.startsWith("+++ ");
|
|
39
|
+
}
|
|
40
|
+
if (firstChar === "-") {
|
|
41
|
+
// `--- ` is metadata, single `-` followed by content is removal
|
|
42
|
+
return !line.startsWith("--- ");
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
48
|
+
// Normalization
|
|
49
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Normalize a diff by stripping various wrapper formats and metadata.
|
|
53
|
+
*
|
|
54
|
+
* Handles:
|
|
55
|
+
* - `*** Begin Patch` / `*** End Patch` markers (partial or complete)
|
|
56
|
+
* - Codex file markers: `*** Update File:`, `*** Add File:`, `*** Delete File:`, `*** End of File`
|
|
57
|
+
* - Unified diff metadata: `diff --git`, `index`, `---`, `+++`, mode changes, rename markers
|
|
58
|
+
*/
|
|
59
|
+
export function normalizeDiff(diff: string): string {
|
|
60
|
+
let lines = diff.split("\n");
|
|
61
|
+
|
|
62
|
+
// Strip trailing truly empty lines (not diff content lines like " " which represent blank context)
|
|
63
|
+
while (lines.length > 0) {
|
|
64
|
+
const lastLine = lines[lines.length - 1];
|
|
65
|
+
// Only strip if line is completely empty (no characters) OR
|
|
66
|
+
// if it's whitespace-only but NOT a diff content line (space prefix = context line)
|
|
67
|
+
if (lastLine === "" || (lastLine?.trim() === "" && !isDiffContentLine(lastLine ?? ""))) {
|
|
68
|
+
lines = lines.slice(0, -1);
|
|
69
|
+
} else {
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Layer 1: Strip *** Begin Patch / *** End Patch (may have only one or both)
|
|
75
|
+
if (lines[0]?.trim().startsWith("*** Begin Patch")) {
|
|
76
|
+
lines = lines.slice(1);
|
|
77
|
+
}
|
|
78
|
+
// Also strip bare *** at the beginning (model hallucination)
|
|
79
|
+
if (lines[0]?.trim() === "***") {
|
|
80
|
+
lines = lines.slice(1);
|
|
81
|
+
}
|
|
82
|
+
if (lines.length > 0 && lines[lines.length - 1]?.trim().startsWith("*** End Patch")) {
|
|
83
|
+
lines = lines.slice(0, -1);
|
|
84
|
+
}
|
|
85
|
+
// Also strip bare *** terminator (model hallucination)
|
|
86
|
+
if (lines.length > 0 && lines[lines.length - 1]?.trim() === "***") {
|
|
87
|
+
lines = lines.slice(0, -1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Layer 2: Strip Codex-style file operation markers and unified diff metadata
|
|
91
|
+
// NOTE: Do NOT strip "*** End of File" - that's a valid marker within hunks, not a wrapper
|
|
92
|
+
// IMPORTANT: Only strip actual metadata lines, NOT diff content lines (starting with space, +, or -)
|
|
93
|
+
lines = lines.filter((line) => {
|
|
94
|
+
// Preserve diff content lines even if their content looks like metadata
|
|
95
|
+
// Note: `--- ` and `+++ ` are metadata, not content lines
|
|
96
|
+
if (isDiffContentLine(line)) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const trimmed = line.trim();
|
|
101
|
+
|
|
102
|
+
// Codex file operation markers (these wrap multiple file changes)
|
|
103
|
+
if (trimmed.startsWith("*** Update File:")) return false;
|
|
104
|
+
if (trimmed.startsWith("*** Add File:")) return false;
|
|
105
|
+
if (trimmed.startsWith("*** Delete File:")) return false;
|
|
106
|
+
|
|
107
|
+
// Unified diff metadata
|
|
108
|
+
if (trimmed.startsWith("diff --git ")) return false;
|
|
109
|
+
if (trimmed.startsWith("index ")) return false;
|
|
110
|
+
if (trimmed.startsWith("--- ")) return false;
|
|
111
|
+
if (trimmed.startsWith("+++ ")) return false;
|
|
112
|
+
if (trimmed.startsWith("new file mode ")) return false;
|
|
113
|
+
if (trimmed.startsWith("deleted file mode ")) return false;
|
|
114
|
+
if (trimmed.startsWith("rename from ")) return false;
|
|
115
|
+
if (trimmed.startsWith("rename to ")) return false;
|
|
116
|
+
if (trimmed.startsWith("similarity index ")) return false;
|
|
117
|
+
if (trimmed.startsWith("dissimilarity index ")) return false;
|
|
118
|
+
if (trimmed.startsWith("old mode ")) return false;
|
|
119
|
+
if (trimmed.startsWith("new mode ")) return false;
|
|
120
|
+
|
|
121
|
+
return true;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return lines.join("\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Strip `+ ` prefix from file creation content if all non-empty lines have it.
|
|
129
|
+
* This handles diffs where file content is formatted as additions.
|
|
130
|
+
*/
|
|
131
|
+
export function normalizeCreateContent(content: string): string {
|
|
132
|
+
const lines = content.split("\n");
|
|
133
|
+
const nonEmptyLines = lines.filter((l) => l.length > 0);
|
|
134
|
+
|
|
135
|
+
// Check if all non-empty lines start with "+ " or "+"
|
|
136
|
+
if (nonEmptyLines.length > 0 && nonEmptyLines.every((l) => l.startsWith("+ ") || l.startsWith("+"))) {
|
|
137
|
+
return lines
|
|
138
|
+
.map((l) => {
|
|
139
|
+
if (l.startsWith("+ ")) return l.slice(2);
|
|
140
|
+
if (l.startsWith("+")) return l.slice(1);
|
|
141
|
+
return l;
|
|
142
|
+
})
|
|
143
|
+
.join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return content;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
150
|
+
// Header Parsing
|
|
151
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
152
|
+
|
|
153
|
+
interface UnifiedHunkHeader {
|
|
154
|
+
oldStartLine: number;
|
|
155
|
+
oldLineCount: number;
|
|
156
|
+
newStartLine: number;
|
|
157
|
+
newLineCount: number;
|
|
158
|
+
changeContext?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseUnifiedHunkHeader(line: string): UnifiedHunkHeader | undefined {
|
|
162
|
+
const match = line.match(UNIFIED_HUNK_HEADER_REGEX);
|
|
163
|
+
if (!match) return undefined;
|
|
164
|
+
|
|
165
|
+
const oldStartLine = Number(match[1]);
|
|
166
|
+
const oldLineCount = match[2] ? Number(match[2]) : 1;
|
|
167
|
+
const newStartLine = Number(match[3]);
|
|
168
|
+
const newLineCount = match[4] ? Number(match[4]) : 1;
|
|
169
|
+
const changeContext = match[5]?.trim();
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
oldStartLine,
|
|
173
|
+
oldLineCount,
|
|
174
|
+
newStartLine,
|
|
175
|
+
newLineCount,
|
|
176
|
+
changeContext: changeContext && changeContext.length > 0 ? changeContext : undefined,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isUnifiedDiffMetadataLine(line: string): boolean {
|
|
181
|
+
return (
|
|
182
|
+
line.startsWith("diff --git ") ||
|
|
183
|
+
line.startsWith("index ") ||
|
|
184
|
+
line.startsWith("--- ") ||
|
|
185
|
+
line.startsWith("+++ ") ||
|
|
186
|
+
line.startsWith("new file mode ") ||
|
|
187
|
+
line.startsWith("deleted file mode ") ||
|
|
188
|
+
line.startsWith("rename from ") ||
|
|
189
|
+
line.startsWith("rename to ") ||
|
|
190
|
+
line.startsWith("similarity index ") ||
|
|
191
|
+
line.startsWith("dissimilarity index ") ||
|
|
192
|
+
line.startsWith("old mode ") ||
|
|
193
|
+
line.startsWith("new mode ")
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
198
|
+
// Hunk Parsing
|
|
199
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
200
|
+
|
|
201
|
+
interface ParseHunkResult {
|
|
202
|
+
hunk: DiffHunk;
|
|
203
|
+
linesConsumed: number;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Parse a single hunk from lines starting at the current position.
|
|
208
|
+
*
|
|
209
|
+
* Handles several context formats:
|
|
210
|
+
* - Empty: `@@` (no context, match from current position)
|
|
211
|
+
* - Unified: `@@ -10,3 +10,3 @@` (line numbers as hints)
|
|
212
|
+
* - Context: `@@ function foo` (search for context line)
|
|
213
|
+
* - Line hint: `@@ line 125` (use line 125 as starting position)
|
|
214
|
+
* - Nested: `@@ class Foo\n@@ method` (hierarchical context search)
|
|
215
|
+
*/
|
|
216
|
+
function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext: boolean): ParseHunkResult {
|
|
217
|
+
if (lines.length === 0) {
|
|
218
|
+
throw new ParseError("Diff does not contain any lines", lineNumber);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const changeContexts: string[] = [];
|
|
222
|
+
let oldStartLine: number | undefined;
|
|
223
|
+
let newStartLine: number | undefined;
|
|
224
|
+
let startIndex: number;
|
|
225
|
+
|
|
226
|
+
const headerLine = lines[0];
|
|
227
|
+
const headerTrimmed = headerLine.trimEnd();
|
|
228
|
+
const isHeaderLine = headerLine.startsWith("@@");
|
|
229
|
+
const unifiedHeader = isHeaderLine ? parseUnifiedHunkHeader(headerTrimmed) : undefined;
|
|
230
|
+
const isEmptyContextMarker = /^@@\s*@@$/.test(headerTrimmed);
|
|
231
|
+
|
|
232
|
+
// Check for context marker
|
|
233
|
+
if (isHeaderLine && (headerTrimmed === EMPTY_CHANGE_CONTEXT_MARKER || isEmptyContextMarker)) {
|
|
234
|
+
startIndex = 1;
|
|
235
|
+
} else if (unifiedHeader) {
|
|
236
|
+
if (unifiedHeader.oldStartLine < 1 || unifiedHeader.newStartLine < 1) {
|
|
237
|
+
throw new ParseError("Line numbers in @@ header must be >= 1", lineNumber);
|
|
238
|
+
}
|
|
239
|
+
if (unifiedHeader.changeContext) {
|
|
240
|
+
changeContexts.push(unifiedHeader.changeContext);
|
|
241
|
+
}
|
|
242
|
+
oldStartLine = unifiedHeader.oldStartLine;
|
|
243
|
+
newStartLine = unifiedHeader.newStartLine;
|
|
244
|
+
startIndex = 1;
|
|
245
|
+
} else if (isHeaderLine && headerTrimmed.startsWith(CHANGE_CONTEXT_MARKER)) {
|
|
246
|
+
const contextValue = headerTrimmed.slice(CHANGE_CONTEXT_MARKER.length);
|
|
247
|
+
const trimmedContextValue = contextValue.trim();
|
|
248
|
+
const normalizedContextValue = trimmedContextValue.replace(/^@@\s*/u, "");
|
|
249
|
+
|
|
250
|
+
const lineHintMatch = normalizedContextValue.match(LINE_HINT_REGEX);
|
|
251
|
+
if (lineHintMatch) {
|
|
252
|
+
oldStartLine = Number(lineHintMatch[1]);
|
|
253
|
+
newStartLine = oldStartLine;
|
|
254
|
+
if (oldStartLine < 1) {
|
|
255
|
+
throw new ParseError("Line hint must be >= 1", lineNumber);
|
|
256
|
+
}
|
|
257
|
+
} else if (TOP_OF_FILE_REGEX.test(normalizedContextValue)) {
|
|
258
|
+
oldStartLine = 1;
|
|
259
|
+
newStartLine = 1;
|
|
260
|
+
} else if (trimmedContextValue.length > 0) {
|
|
261
|
+
changeContexts.push(contextValue);
|
|
262
|
+
}
|
|
263
|
+
startIndex = 1;
|
|
264
|
+
} else if (isHeaderLine) {
|
|
265
|
+
const contextValue = headerTrimmed.slice(2).trim();
|
|
266
|
+
if (contextValue.length > 0) {
|
|
267
|
+
changeContexts.push(contextValue);
|
|
268
|
+
}
|
|
269
|
+
startIndex = 1;
|
|
270
|
+
} else {
|
|
271
|
+
if (!allowMissingContext) {
|
|
272
|
+
throw new ParseError(`Expected hunk to start with @@ context marker, got: '${lines[0]}'`, lineNumber);
|
|
273
|
+
}
|
|
274
|
+
startIndex = 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (oldStartLine !== undefined && oldStartLine < 1) {
|
|
278
|
+
throw new ParseError(`Line numbers must be >= 1 (got ${oldStartLine})`, lineNumber);
|
|
279
|
+
}
|
|
280
|
+
if (newStartLine !== undefined && newStartLine < 1) {
|
|
281
|
+
throw new ParseError(`Line numbers must be >= 1 (got ${newStartLine})`, lineNumber);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Check for nested @@ anchors on subsequent lines
|
|
285
|
+
// Format: @@ class Foo
|
|
286
|
+
// @@ method
|
|
287
|
+
while (startIndex < lines.length) {
|
|
288
|
+
const nextLine = lines[startIndex];
|
|
289
|
+
if (!nextLine.startsWith("@@")) {
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
const trimmed = nextLine.trimEnd();
|
|
293
|
+
|
|
294
|
+
// Check if it's another @@ line (nested anchor)
|
|
295
|
+
if (trimmed.startsWith(CHANGE_CONTEXT_MARKER)) {
|
|
296
|
+
const nestedContext = trimmed.slice(CHANGE_CONTEXT_MARKER.length);
|
|
297
|
+
if (nestedContext.trim().length > 0) {
|
|
298
|
+
changeContexts.push(nestedContext);
|
|
299
|
+
}
|
|
300
|
+
startIndex++;
|
|
301
|
+
} else if (trimmed === EMPTY_CHANGE_CONTEXT_MARKER) {
|
|
302
|
+
// Empty @@ as separator - skip it
|
|
303
|
+
startIndex++;
|
|
304
|
+
} else {
|
|
305
|
+
// Not an @@ line, stop accumulating
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (startIndex >= lines.length) {
|
|
311
|
+
throw new ParseError("Hunk does not contain any lines", lineNumber + 1);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Combine contexts: if multiple, join with newline for hierarchical matching
|
|
315
|
+
const changeContext = changeContexts.length > 0 ? changeContexts.join("\n") : undefined;
|
|
316
|
+
|
|
317
|
+
const hunk: DiffHunk = {
|
|
318
|
+
changeContext,
|
|
319
|
+
oldStartLine,
|
|
320
|
+
newStartLine,
|
|
321
|
+
hasContextLines: false,
|
|
322
|
+
oldLines: [],
|
|
323
|
+
newLines: [],
|
|
324
|
+
isEndOfFile: false,
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
let parsedLines = 0;
|
|
328
|
+
|
|
329
|
+
for (let i = startIndex; i < lines.length; i++) {
|
|
330
|
+
const line = lines[i];
|
|
331
|
+
const trimmed = line.trim();
|
|
332
|
+
|
|
333
|
+
if (!isDiffContentLine(line) && line.trimEnd() === EOF_MARKER && line.startsWith(EOF_MARKER)) {
|
|
334
|
+
if (parsedLines === 0) {
|
|
335
|
+
throw new ParseError("Hunk does not contain any lines", lineNumber + 1);
|
|
336
|
+
}
|
|
337
|
+
hunk.isEndOfFile = true;
|
|
338
|
+
parsedLines++;
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (trimmed === "..." || trimmed === "…") {
|
|
343
|
+
hunk.hasContextLines = true;
|
|
344
|
+
parsedLines++;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const firstChar = line[0];
|
|
349
|
+
|
|
350
|
+
if (firstChar === undefined || firstChar === "") {
|
|
351
|
+
// Empty line - treat as context
|
|
352
|
+
hunk.hasContextLines = true;
|
|
353
|
+
hunk.oldLines.push("");
|
|
354
|
+
hunk.newLines.push("");
|
|
355
|
+
} else if (firstChar === " ") {
|
|
356
|
+
// Context line
|
|
357
|
+
hunk.hasContextLines = true;
|
|
358
|
+
hunk.oldLines.push(line.slice(1));
|
|
359
|
+
hunk.newLines.push(line.slice(1));
|
|
360
|
+
} else if (firstChar === "+") {
|
|
361
|
+
// Added line
|
|
362
|
+
hunk.newLines.push(line.slice(1));
|
|
363
|
+
} else if (firstChar === "-") {
|
|
364
|
+
// Removed line
|
|
365
|
+
hunk.oldLines.push(line.slice(1));
|
|
366
|
+
} else if (!line.startsWith("@@")) {
|
|
367
|
+
// Implicit context line (model omitted leading space)
|
|
368
|
+
hunk.hasContextLines = true;
|
|
369
|
+
hunk.oldLines.push(line);
|
|
370
|
+
hunk.newLines.push(line);
|
|
371
|
+
} else {
|
|
372
|
+
if (parsedLines === 0) {
|
|
373
|
+
throw new ParseError(
|
|
374
|
+
`Unexpected line in hunk: '${line}'. Lines must start with ' ' (context), '+' (add), or '-' (remove)`,
|
|
375
|
+
lineNumber + 1,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
// Assume start of next hunk
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
parsedLines++;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (parsedLines === 0) {
|
|
385
|
+
throw new ParseError("Hunk does not contain any lines", lineNumber + startIndex);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
stripLineNumberPrefixes(hunk);
|
|
389
|
+
return { hunk, linesConsumed: parsedLines + startIndex };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function stripLineNumberPrefixes(hunk: DiffHunk): void {
|
|
393
|
+
const allLines = [...hunk.oldLines, ...hunk.newLines].filter((line) => line.trim().length > 0);
|
|
394
|
+
if (allLines.length < 2) return;
|
|
395
|
+
|
|
396
|
+
const numberMatches = allLines
|
|
397
|
+
.map((line) => line.match(/^\s*(\d{1,6})\s+(.+)$/u))
|
|
398
|
+
.filter((match): match is RegExpMatchArray => match !== null);
|
|
399
|
+
|
|
400
|
+
if (numberMatches.length < Math.max(2, Math.ceil(allLines.length * 0.6))) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const numbers = numberMatches.map((match) => Number(match[1]));
|
|
405
|
+
let sequential = 0;
|
|
406
|
+
for (let i = 1; i < numbers.length; i++) {
|
|
407
|
+
if (numbers[i] === numbers[i - 1] + 1) {
|
|
408
|
+
sequential++;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (numbers.length >= 3 && sequential < Math.max(1, numbers.length - 2)) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const strip = (line: string): string => {
|
|
417
|
+
const match = line.match(/^\s*\d{1,6}\s+(.+)$/u);
|
|
418
|
+
return match ? match[1] : line;
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
hunk.oldLines = hunk.oldLines.map(strip);
|
|
422
|
+
hunk.newLines = hunk.newLines.map(strip);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Multi-file patch markers that indicate this is not a single-file patch */
|
|
426
|
+
const MULTI_FILE_MARKERS = ["*** Update File:", "*** Add File:", "*** Delete File:", "diff --git "];
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Count multi-file markers in a diff.
|
|
430
|
+
* Returns the count of file-level markers found.
|
|
431
|
+
* Only counts lines that are actual metadata (not diff content lines).
|
|
432
|
+
*/
|
|
433
|
+
function countMultiFileMarkers(diff: string): number {
|
|
434
|
+
const counts = new Map<string, number>();
|
|
435
|
+
const paths = new Set<string>();
|
|
436
|
+
const lines = diff.split("\n");
|
|
437
|
+
for (const line of lines) {
|
|
438
|
+
if (isDiffContentLine(line)) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const trimmed = line.trim();
|
|
442
|
+
for (const marker of MULTI_FILE_MARKERS) {
|
|
443
|
+
if (trimmed.startsWith(marker)) {
|
|
444
|
+
const path = extractMarkerPath(trimmed);
|
|
445
|
+
if (path) {
|
|
446
|
+
paths.add(path);
|
|
447
|
+
}
|
|
448
|
+
counts.set(marker, (counts.get(marker) ?? 0) + 1);
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (paths.size > 0) {
|
|
454
|
+
return paths.size;
|
|
455
|
+
}
|
|
456
|
+
let maxCount = 0;
|
|
457
|
+
for (const count of counts.values()) {
|
|
458
|
+
if (count > maxCount) {
|
|
459
|
+
maxCount = count;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return maxCount;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function extractMarkerPath(line: string): string | undefined {
|
|
466
|
+
if (line.startsWith("diff --git ")) {
|
|
467
|
+
const parts = line.split(/\s+/);
|
|
468
|
+
const candidate = parts[3] ?? parts[2];
|
|
469
|
+
if (!candidate) return undefined;
|
|
470
|
+
return candidate.replace(/^(a|b)\//, "");
|
|
471
|
+
}
|
|
472
|
+
if (line.startsWith("*** Update File:")) {
|
|
473
|
+
return line.slice("*** Update File:".length).trim();
|
|
474
|
+
}
|
|
475
|
+
if (line.startsWith("*** Add File:")) {
|
|
476
|
+
return line.slice("*** Add File:".length).trim();
|
|
477
|
+
}
|
|
478
|
+
if (line.startsWith("*** Delete File:")) {
|
|
479
|
+
return line.slice("*** Delete File:".length).trim();
|
|
480
|
+
}
|
|
481
|
+
return undefined;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Parse all diff hunks from a diff string.
|
|
486
|
+
*/
|
|
487
|
+
export function parseHunks(diff: string): DiffHunk[] {
|
|
488
|
+
const multiFileCount = countMultiFileMarkers(diff);
|
|
489
|
+
if (multiFileCount > 1) {
|
|
490
|
+
throw new ApplyPatchError(
|
|
491
|
+
`Diff contains ${multiFileCount} file markers. Single-file patches cannot contain multi-file markers.`,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const normalizedDiff = normalizeDiff(diff);
|
|
496
|
+
const lines = normalizedDiff.split("\n");
|
|
497
|
+
const hunks: DiffHunk[] = [];
|
|
498
|
+
let i = 0;
|
|
499
|
+
|
|
500
|
+
while (i < lines.length) {
|
|
501
|
+
const line = lines[i];
|
|
502
|
+
const trimmed = line.trim();
|
|
503
|
+
|
|
504
|
+
// Skip blank lines between hunks
|
|
505
|
+
if (trimmed === "") {
|
|
506
|
+
i++;
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Skip unified diff metadata lines, but only if they're not diff content lines
|
|
511
|
+
const firstChar = line[0];
|
|
512
|
+
const isDiffContent = firstChar === " " || firstChar === "+" || firstChar === "-";
|
|
513
|
+
if (!isDiffContent && isUnifiedDiffMetadataLine(trimmed)) {
|
|
514
|
+
i++;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (trimmed.startsWith("@@") && lines.slice(i + 1).every((l) => l.trim() === "")) {
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const { hunk, linesConsumed } = parseOneHunk(lines.slice(i), i + 1, true);
|
|
523
|
+
hunks.push(hunk);
|
|
524
|
+
i += linesConsumed;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return hunks;
|
|
528
|
+
}
|