k-cli-for-devs 1.0.0__py3-none-any.whl
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.
- k_cli/__init__.py +77 -0
- k_cli/agents/__init__.py +0 -0
- k_cli/agents/adversarial_swarm.py +338 -0
- k_cli/agents/agent_core.py +255 -0
- k_cli/agents/background_daemon.py +141 -0
- k_cli/agents/orchestrator.py +376 -0
- k_cli/agents/persona.py +649 -0
- k_cli/agents/scaffold_engine.py +121 -0
- k_cli/agents/strands_agent.py +832 -0
- k_cli/agents/subagents.py +1496 -0
- k_cli/cli.py +3297 -0
- k_cli/core/__init__.py +0 -0
- k_cli/core/airgap.py +95 -0
- k_cli/core/credentials.py +548 -0
- k_cli/core/intent_sensor.py +177 -0
- k_cli/core/llm_driver.py +1028 -0
- k_cli/core/model_manager.py +1109 -0
- k_cli/core/models_hub.py +913 -0
- k_cli/core/prompting.py +41 -0
- k_cli/core/sdk.py +322 -0
- k_cli/core/session.py +826 -0
- k_cli/core/smart_router.py +230 -0
- k_cli/core/storage_manager.py +176 -0
- k_cli/core/viewport_engine.py +117 -0
- k_cli/demo/demo_runner.py +579 -0
- k_cli/git/__init__.py +0 -0
- k_cli/git/ai_bisect.py +208 -0
- k_cli/git/conflict_resolver.py +1039 -0
- k_cli/git/git_guard.py +417 -0
- k_cli/git/patcher.py +1175 -0
- k_cli/git/repo_map.py +1780 -0
- k_cli/git/smart_git.py +928 -0
- k_cli/git/verifier.py +969 -0
- k_cli/github/__init__.py +0 -0
- k_cli/github/dedup_engine.py +787 -0
- k_cli/github/github_client.py +1702 -0
- k_cli/github/github_engine.py +641 -0
- k_cli/github/local_hub.py +209 -0
- k_cli/github/pr_watcher.py +129 -0
- k_cli/github/trending.py +205 -0
- k_cli/tools/__init__.py +0 -0
- k_cli/tools/audit.py +79 -0
- k_cli/tools/chaos_immunity.py +377 -0
- k_cli/tools/codebase_qa.py +106 -0
- k_cli/tools/command_runner.py +256 -0
- k_cli/tools/diagram_generator.py +547 -0
- k_cli/tools/doc_retriever.py +1332 -0
- k_cli/tools/feature.py +105 -0
- k_cli/tools/ghost_daemon.py +122 -0
- k_cli/tools/incident_triage.py +1365 -0
- k_cli/tools/mcp_client.py +1846 -0
- k_cli/tools/repo_gardener.py +142 -0
- k_cli/tools/rules.py +109 -0
- k_cli/tools/security.py +52 -0
- k_cli/tools/security_healer.py +999 -0
- k_cli/tools/synapse_graph.py +155 -0
- k_cli/tui/__init__.py +0 -0
- k_cli/tui/diff_viewer.py +223 -0
- k_cli/tui/tui.py +1145 -0
- k_cli/tui/tui_animations.py +648 -0
- k_cli/tui/tui_app.py +2788 -0
- k_cli/ui/__init__.py +10 -0
- k_cli/ui/simple_repl.py +315 -0
- k_cli/web/__init__.py +7 -0
- k_cli/web/server.py +624 -0
- k_cli/web/static/app.js +830 -0
- k_cli/web/static/index.html +495 -0
- k_cli/web/static/monitor.html +189 -0
- k_cli/web/static/style.css +838 -0
- k_cli_for_devs-1.0.0.dist-info/METADATA +461 -0
- k_cli_for_devs-1.0.0.dist-info/RECORD +75 -0
- k_cli_for_devs-1.0.0.dist-info/WHEEL +5 -0
- k_cli_for_devs-1.0.0.dist-info/entry_points.txt +2 -0
- k_cli_for_devs-1.0.0.dist-info/licenses/LICENSE +21 -0
- k_cli_for_devs-1.0.0.dist-info/top_level.txt +1 -0
k_cli/git/patcher.py
ADDED
|
@@ -0,0 +1,1175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patcher.py - SEARCH/REPLACE Surgical Patch Engine for K-CLI
|
|
3
|
+
|
|
4
|
+
Provides unified search/replace block parsing, exact and indentation-tolerant
|
|
5
|
+
fuzzy matching, AST-based multi-line structural matching, transactional multi-file
|
|
6
|
+
batching with all-or-nothing rollback, and clean CLI diff preview rendering.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import difflib
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import textwrap
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class FilePatch:
|
|
23
|
+
"""Represents a single search/replace block associated with an optional file path."""
|
|
24
|
+
file_path: Optional[str]
|
|
25
|
+
search_block: str
|
|
26
|
+
replace_block: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class PatchResult:
|
|
31
|
+
"""Result of a single patch application."""
|
|
32
|
+
success: bool
|
|
33
|
+
patched_code: str
|
|
34
|
+
error_message: str = ""
|
|
35
|
+
diff: str = ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class BatchPatchResult:
|
|
40
|
+
"""Result of a transactional multi-file batch patch application."""
|
|
41
|
+
success: bool
|
|
42
|
+
modified_files: List[str] = field(default_factory=list)
|
|
43
|
+
error_message: str = ""
|
|
44
|
+
diff_summary: str = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Patcher:
|
|
48
|
+
"""
|
|
49
|
+
Surgical patch engine that parses SEARCH/REPLACE blocks, applies exact or
|
|
50
|
+
fuzzy/AST-matched edits to strings or files, validates Python AST syntax before
|
|
51
|
+
disk writes, manages transactional multi-file batches with rollback, and renders
|
|
52
|
+
clean CLI diff previews.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
# Regex to extract standard SEARCH / REPLACE blocks:
|
|
56
|
+
# <<<<<<< SEARCH [optional file / info]
|
|
57
|
+
# ... search block ...
|
|
58
|
+
# =======
|
|
59
|
+
# ... replace block ...
|
|
60
|
+
# >>>>>>> [REPLACE] [optional info]
|
|
61
|
+
SEARCH_REPLACE_PATTERN = re.compile(
|
|
62
|
+
r"^[ \t]*<{7}(?![<])(?:[ \t]*SEARCH(?::?[ \t]+[^\r\n]*)?|[ \t]*)[ \t]*\r?\n"
|
|
63
|
+
r"([\s\S]*?)\r?\n"
|
|
64
|
+
r"^[ \t]*={7}(?![=])[ \t]*\r?\n"
|
|
65
|
+
r"([\s\S]*?)\r?\n"
|
|
66
|
+
r"^[ \t]*>{7}(?![>])(?:[ \t]*REPLACE(?::?[ \t]+[^\r\n]*)?|[ \t]*[^\r\n]*)",
|
|
67
|
+
re.MULTILINE,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# ANSI Color constants for diff terminal rendering
|
|
71
|
+
ANSI_RESET = "\033[0m"
|
|
72
|
+
ANSI_BOLD = "\033[1m"
|
|
73
|
+
ANSI_CYAN = "\033[36m"
|
|
74
|
+
ANSI_GREEN = "\033[32m"
|
|
75
|
+
ANSI_RED = "\033[31m"
|
|
76
|
+
ANSI_DIM = "\033[2m"
|
|
77
|
+
ANSI_MAGENTA = "\033[35m"
|
|
78
|
+
|
|
79
|
+
# =========================================================================
|
|
80
|
+
# 1. Parsing SEARCH/REPLACE Blocks
|
|
81
|
+
# =========================================================================
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def _clean_filepath_token(cls, raw: str) -> Optional[str]:
|
|
85
|
+
"""Cleans and validates a potential file path token extracted from patch text."""
|
|
86
|
+
if not raw:
|
|
87
|
+
return None
|
|
88
|
+
cleaned = raw.strip()
|
|
89
|
+
# Remove common markdown, header prefixes, and quote enclosures
|
|
90
|
+
cleaned = re.sub(r"^(?:#+|\*+|`+|---|--- a/|\+\+\+ b/|(?:File|FILE|file):\s*)", "", cleaned).strip()
|
|
91
|
+
cleaned = cleaned.strip("`'\"*#:-> \t")
|
|
92
|
+
if cleaned.startswith("a/") or cleaned.startswith("b/"):
|
|
93
|
+
cleaned = cleaned[2:]
|
|
94
|
+
if (
|
|
95
|
+
cleaned
|
|
96
|
+
and ("." in cleaned or "/" in cleaned or "\\" in cleaned)
|
|
97
|
+
and " " not in cleaned
|
|
98
|
+
and len(cleaned) < 250
|
|
99
|
+
):
|
|
100
|
+
return cleaned
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def parse_search_replace_blocks(cls, text: str) -> List[Tuple[str, str]]:
|
|
105
|
+
"""
|
|
106
|
+
Parses `<<<<<<< SEARCH ... ======= ... >>>>>>> [REPLACE]` blocks from text.
|
|
107
|
+
|
|
108
|
+
Handles multiple blocks, trailing whitespace on marker lines, varying indentation,
|
|
109
|
+
and standard `>>>>>>> REPLACE` variants. Malformed blocks without matching dividers
|
|
110
|
+
or end markers are ignored safely.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
text: Raw patch text or LLM response containing one or more blocks.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
List of (search_block, replace_block) tuples.
|
|
117
|
+
"""
|
|
118
|
+
if not text:
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
blocks: List[Tuple[str, str]] = []
|
|
122
|
+
for match in cls.SEARCH_REPLACE_PATTERN.finditer(text):
|
|
123
|
+
search_part = match.group(1)
|
|
124
|
+
replace_part = match.group(2)
|
|
125
|
+
blocks.append((search_part, replace_part))
|
|
126
|
+
|
|
127
|
+
return blocks
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def parse_multi_file_patches(cls, text: str) -> List[Tuple[Optional[str], str, str]]:
|
|
131
|
+
"""
|
|
132
|
+
Parses SEARCH/REPLACE blocks along with their associated target file paths.
|
|
133
|
+
|
|
134
|
+
Supports file paths specified on marker lines (e.g. `<<<<<<< SEARCH: file.py`),
|
|
135
|
+
markdown headers immediately preceding blocks (e.g. `### `file.py``),
|
|
136
|
+
or unified diff style headers (e.g. `--- a/file.py`).
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
text: Raw patch text containing one or more file blocks.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
List of (file_path_or_none, search_block, replace_block) tuples.
|
|
143
|
+
"""
|
|
144
|
+
if not text:
|
|
145
|
+
return []
|
|
146
|
+
|
|
147
|
+
results: List[Tuple[Optional[str], str, str]] = []
|
|
148
|
+
|
|
149
|
+
for match in cls.SEARCH_REPLACE_PATTERN.finditer(text):
|
|
150
|
+
search_part = match.group(1)
|
|
151
|
+
replace_part = match.group(2)
|
|
152
|
+
|
|
153
|
+
# 1. Check opening marker line for inline filename
|
|
154
|
+
matched_str = match.group(0)
|
|
155
|
+
first_line = matched_str.split("\n", 1)[0]
|
|
156
|
+
file_path: Optional[str] = None
|
|
157
|
+
|
|
158
|
+
m_inline = re.search(r"<{7}\s*SEARCH(?::|\s)\s*([^\r\n]+)", first_line)
|
|
159
|
+
if m_inline:
|
|
160
|
+
candidate = cls._clean_filepath_token(m_inline.group(1))
|
|
161
|
+
if candidate and not candidate.startswith("<") and not candidate.startswith("="):
|
|
162
|
+
file_path = candidate
|
|
163
|
+
|
|
164
|
+
# 2. Check preceding lines if not found on marker line
|
|
165
|
+
if not file_path:
|
|
166
|
+
preceding_text = text[: match.start()]
|
|
167
|
+
prec_lines = [line.strip() for line in preceding_text.split("\n") if line.strip()]
|
|
168
|
+
if prec_lines:
|
|
169
|
+
last_line = prec_lines[-1]
|
|
170
|
+
candidate = cls._clean_filepath_token(last_line)
|
|
171
|
+
if candidate:
|
|
172
|
+
file_path = candidate
|
|
173
|
+
|
|
174
|
+
results.append((file_path, search_part, replace_part))
|
|
175
|
+
|
|
176
|
+
return results
|
|
177
|
+
|
|
178
|
+
# =========================================================================
|
|
179
|
+
# 2. Patch Application with Indentation & AST Fuzzy Tolerance
|
|
180
|
+
# =========================================================================
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def apply_patch(
|
|
184
|
+
cls,
|
|
185
|
+
original_code: str,
|
|
186
|
+
search_block: str,
|
|
187
|
+
replace_block: str,
|
|
188
|
+
fuzzy: bool = True,
|
|
189
|
+
) -> Tuple[bool, str, str]:
|
|
190
|
+
"""
|
|
191
|
+
Applies a single search/replace patch to code.
|
|
192
|
+
|
|
193
|
+
Supports exact matching as well as indentation-tolerant, newline-tolerant,
|
|
194
|
+
whitespace-normalized, and AST structural fuzzy matching.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
original_code: The original source code string.
|
|
198
|
+
search_block: The block of code to search for and replace.
|
|
199
|
+
replace_block: The replacement block of code.
|
|
200
|
+
fuzzy: Whether to allow fuzzy matching if exact matching fails.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Tuple of (success: bool, patched_code: str, error_message: str).
|
|
204
|
+
"""
|
|
205
|
+
if not search_block:
|
|
206
|
+
return False, original_code, "Search block cannot be empty"
|
|
207
|
+
|
|
208
|
+
# 1. Exact match
|
|
209
|
+
if search_block in original_code:
|
|
210
|
+
patched = original_code.replace(search_block, replace_block, 1)
|
|
211
|
+
return True, patched, ""
|
|
212
|
+
|
|
213
|
+
if not fuzzy:
|
|
214
|
+
return False, original_code, "Search block not found in original code (exact mode)"
|
|
215
|
+
|
|
216
|
+
# 2. Fuzzy match strategies
|
|
217
|
+
# Normalize CRLF / LF line endings for search & original
|
|
218
|
+
orig_is_crlf = "\r\n" in original_code
|
|
219
|
+
norm_orig = original_code.replace("\r\n", "\n")
|
|
220
|
+
norm_search = search_block.replace("\r\n", "\n")
|
|
221
|
+
norm_replace = replace_block.replace("\r\n", "\n")
|
|
222
|
+
|
|
223
|
+
# Strategy A: Line-ending normalized exact match
|
|
224
|
+
if norm_search in norm_orig:
|
|
225
|
+
patched_norm = norm_orig.replace(norm_search, norm_replace, 1)
|
|
226
|
+
if orig_is_crlf:
|
|
227
|
+
patched_norm = patched_norm.replace("\n", "\r\n")
|
|
228
|
+
return True, patched_norm, ""
|
|
229
|
+
|
|
230
|
+
orig_lines = norm_orig.split("\n")
|
|
231
|
+
search_lines = norm_search.split("\n")
|
|
232
|
+
|
|
233
|
+
# If last line of search block is empty due to trailing newline, drop it if search has multiple lines
|
|
234
|
+
if len(search_lines) > 1 and search_lines[-1] == "":
|
|
235
|
+
search_lines = search_lines[:-1]
|
|
236
|
+
|
|
237
|
+
# Strategy B: Trailing whitespace tolerance on each line
|
|
238
|
+
res = cls._match_trailing_ws(orig_lines, search_lines, norm_replace, orig_is_crlf)
|
|
239
|
+
if res is not None:
|
|
240
|
+
return True, res, ""
|
|
241
|
+
|
|
242
|
+
# Strategy C: Indentation shift tolerance (uniform delta)
|
|
243
|
+
res = cls._match_indentation_shift(orig_lines, search_lines, norm_replace, orig_is_crlf)
|
|
244
|
+
if res is not None:
|
|
245
|
+
return True, res, ""
|
|
246
|
+
|
|
247
|
+
# Strategy D: Relative indentation tolerance (non-uniform or proportional shift)
|
|
248
|
+
res = cls._match_relative_indentation(orig_lines, search_lines, norm_replace, orig_is_crlf)
|
|
249
|
+
if res is not None:
|
|
250
|
+
return True, res, ""
|
|
251
|
+
|
|
252
|
+
# Strategy E: Whitespace-normalized token sequence matching
|
|
253
|
+
res = cls._match_whitespace_normalized(orig_lines, search_lines, norm_replace, orig_is_crlf)
|
|
254
|
+
if res is not None:
|
|
255
|
+
return True, res, ""
|
|
256
|
+
|
|
257
|
+
# Strategy F: Python AST structural multi-line matching
|
|
258
|
+
res = cls._match_ast_multiline(orig_lines, search_lines, norm_replace, orig_is_crlf)
|
|
259
|
+
if res is not None:
|
|
260
|
+
return True, res, ""
|
|
261
|
+
|
|
262
|
+
# Strategy G: Interspersed blank lines tolerance
|
|
263
|
+
res = cls._match_blank_lines_tolerance(orig_lines, search_lines, norm_replace, orig_is_crlf)
|
|
264
|
+
if res is not None:
|
|
265
|
+
return True, res, ""
|
|
266
|
+
|
|
267
|
+
# Strategy H: Normalized stripped line sequence match
|
|
268
|
+
res = cls._match_stripped_lines(orig_lines, search_lines, norm_replace, orig_is_crlf)
|
|
269
|
+
if res is not None:
|
|
270
|
+
return True, res, ""
|
|
271
|
+
|
|
272
|
+
# Strategy I: Fuzzy similarity window match
|
|
273
|
+
res = cls._match_fuzzy_similarity(orig_lines, search_lines, norm_replace, orig_is_crlf)
|
|
274
|
+
if res is not None:
|
|
275
|
+
return True, res, ""
|
|
276
|
+
|
|
277
|
+
return False, original_code, "Search block not found in original code"
|
|
278
|
+
|
|
279
|
+
@classmethod
|
|
280
|
+
def _match_trailing_ws(
|
|
281
|
+
cls,
|
|
282
|
+
orig_lines: List[str],
|
|
283
|
+
search_lines: List[str],
|
|
284
|
+
replace_block: str,
|
|
285
|
+
orig_is_crlf: bool,
|
|
286
|
+
) -> Optional[str]:
|
|
287
|
+
"""Matches when line content matches after rstrip() on each line."""
|
|
288
|
+
n_search = len(search_lines)
|
|
289
|
+
n_orig = len(orig_lines)
|
|
290
|
+
if n_search == 0 or n_search > n_orig:
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
for i in range(n_orig - n_search + 1):
|
|
294
|
+
match = True
|
|
295
|
+
for k in range(n_search):
|
|
296
|
+
if orig_lines[i + k].rstrip() != search_lines[k].rstrip():
|
|
297
|
+
match = False
|
|
298
|
+
break
|
|
299
|
+
if match:
|
|
300
|
+
replace_lines = replace_block.split("\n")
|
|
301
|
+
new_lines = orig_lines[:i] + replace_lines + orig_lines[i + n_search :]
|
|
302
|
+
joined = "\n".join(new_lines)
|
|
303
|
+
return joined.replace("\n", "\r\n") if orig_is_crlf else joined
|
|
304
|
+
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
def _match_indentation_shift(
|
|
309
|
+
cls,
|
|
310
|
+
orig_lines: List[str],
|
|
311
|
+
search_lines: List[str],
|
|
312
|
+
replace_block: str,
|
|
313
|
+
orig_is_crlf: bool,
|
|
314
|
+
) -> Optional[str]:
|
|
315
|
+
"""Matches when code structure matches with uniform indentation shift."""
|
|
316
|
+
n_search = len(search_lines)
|
|
317
|
+
n_orig = len(orig_lines)
|
|
318
|
+
if n_search == 0 or n_search > n_orig:
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
for i in range(n_orig - n_search + 1):
|
|
322
|
+
delta: Optional[int] = None
|
|
323
|
+
match = True
|
|
324
|
+
for k in range(n_search):
|
|
325
|
+
s_line = search_lines[k]
|
|
326
|
+
o_line = orig_lines[i + k]
|
|
327
|
+
|
|
328
|
+
if not s_line.strip() and not o_line.strip():
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
if s_line.strip() != o_line.strip():
|
|
332
|
+
match = False
|
|
333
|
+
break
|
|
334
|
+
|
|
335
|
+
s_indent = len(s_line) - len(s_line.lstrip())
|
|
336
|
+
o_indent = len(o_line) - len(o_line.lstrip())
|
|
337
|
+
curr_delta = o_indent - s_indent
|
|
338
|
+
|
|
339
|
+
if delta is None:
|
|
340
|
+
delta = curr_delta
|
|
341
|
+
elif delta != curr_delta:
|
|
342
|
+
match = False
|
|
343
|
+
break
|
|
344
|
+
|
|
345
|
+
if match and delta is not None:
|
|
346
|
+
replace_lines = replace_block.split("\n")
|
|
347
|
+
shifted_replace_lines: List[str] = []
|
|
348
|
+
for r_line in replace_lines:
|
|
349
|
+
if not r_line.strip():
|
|
350
|
+
shifted_replace_lines.append(r_line)
|
|
351
|
+
elif delta > 0:
|
|
352
|
+
shifted_replace_lines.append(" " * delta + r_line)
|
|
353
|
+
elif delta < 0:
|
|
354
|
+
strip_count = min(-delta, len(r_line) - len(r_line.lstrip()))
|
|
355
|
+
shifted_replace_lines.append(r_line[strip_count:])
|
|
356
|
+
else:
|
|
357
|
+
shifted_replace_lines.append(r_line)
|
|
358
|
+
|
|
359
|
+
new_lines = orig_lines[:i] + shifted_replace_lines + orig_lines[i + n_search :]
|
|
360
|
+
joined = "\n".join(new_lines)
|
|
361
|
+
return joined.replace("\n", "\r\n") if orig_is_crlf else joined
|
|
362
|
+
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
@classmethod
|
|
366
|
+
def _match_relative_indentation(
|
|
367
|
+
cls,
|
|
368
|
+
orig_lines: List[str],
|
|
369
|
+
search_lines: List[str],
|
|
370
|
+
replace_block: str,
|
|
371
|
+
orig_is_crlf: bool,
|
|
372
|
+
) -> Optional[str]:
|
|
373
|
+
"""Matches when stripped lines match and adjusts replacement by relative indentation."""
|
|
374
|
+
n_search = len(search_lines)
|
|
375
|
+
n_orig = len(orig_lines)
|
|
376
|
+
if n_search == 0 or n_search > n_orig:
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
# Find first non-empty search line indent
|
|
380
|
+
first_nonempty_search = next((l for l in search_lines if l.strip()), None)
|
|
381
|
+
if not first_nonempty_search:
|
|
382
|
+
return None
|
|
383
|
+
search_base_indent = len(first_nonempty_search) - len(first_nonempty_search.lstrip())
|
|
384
|
+
|
|
385
|
+
for i in range(n_orig - n_search + 1):
|
|
386
|
+
match = True
|
|
387
|
+
for k in range(n_search):
|
|
388
|
+
s_line = search_lines[k]
|
|
389
|
+
o_line = orig_lines[i + k]
|
|
390
|
+
if s_line.strip() != o_line.strip():
|
|
391
|
+
match = False
|
|
392
|
+
break
|
|
393
|
+
if match:
|
|
394
|
+
first_orig_line = next((l for l in orig_lines[i : i + n_search] if l.strip()), orig_lines[i])
|
|
395
|
+
orig_base_indent = len(first_orig_line) - len(first_orig_line.lstrip())
|
|
396
|
+
delta = orig_base_indent - search_base_indent
|
|
397
|
+
|
|
398
|
+
replace_lines = replace_block.split("\n")
|
|
399
|
+
shifted_replace_lines: List[str] = []
|
|
400
|
+
for r_line in replace_lines:
|
|
401
|
+
if not r_line.strip():
|
|
402
|
+
shifted_replace_lines.append("")
|
|
403
|
+
elif delta > 0:
|
|
404
|
+
shifted_replace_lines.append(" " * delta + r_line)
|
|
405
|
+
elif delta < 0:
|
|
406
|
+
strip_count = min(-delta, len(r_line) - len(r_line.lstrip()))
|
|
407
|
+
shifted_replace_lines.append(r_line[strip_count:])
|
|
408
|
+
else:
|
|
409
|
+
shifted_replace_lines.append(r_line)
|
|
410
|
+
|
|
411
|
+
new_lines = orig_lines[:i] + shifted_replace_lines + orig_lines[i + n_search :]
|
|
412
|
+
joined = "\n".join(new_lines)
|
|
413
|
+
return joined.replace("\n", "\r\n") if orig_is_crlf else joined
|
|
414
|
+
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
@classmethod
|
|
418
|
+
def _match_whitespace_normalized(
|
|
419
|
+
cls,
|
|
420
|
+
orig_lines: List[str],
|
|
421
|
+
search_lines: List[str],
|
|
422
|
+
replace_block: str,
|
|
423
|
+
orig_is_crlf: bool,
|
|
424
|
+
) -> Optional[str]:
|
|
425
|
+
"""Matches when internal consecutive whitespace / quotes are normalized."""
|
|
426
|
+
def _norm_line(s: str) -> str:
|
|
427
|
+
# Collapse multiple spaces and normalize quotes
|
|
428
|
+
cleaned = re.sub(r"[ \t]+", " ", s.strip())
|
|
429
|
+
cleaned = cleaned.replace('"', "'")
|
|
430
|
+
return cleaned
|
|
431
|
+
|
|
432
|
+
n_search = len(search_lines)
|
|
433
|
+
n_orig = len(orig_lines)
|
|
434
|
+
if n_search == 0 or n_search > n_orig:
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
norm_search_lines = [_norm_line(l) for l in search_lines]
|
|
438
|
+
|
|
439
|
+
for i in range(n_orig - n_search + 1):
|
|
440
|
+
match = True
|
|
441
|
+
for k in range(n_search):
|
|
442
|
+
if _norm_line(orig_lines[i + k]) != norm_search_lines[k]:
|
|
443
|
+
match = False
|
|
444
|
+
break
|
|
445
|
+
if match:
|
|
446
|
+
first_orig_line = next((l for l in orig_lines[i : i + n_search] if l.strip()), orig_lines[i])
|
|
447
|
+
orig_base_indent = len(first_orig_line) - len(first_orig_line.lstrip())
|
|
448
|
+
|
|
449
|
+
first_search_line = next((l for l in search_lines if l.strip()), search_lines[0])
|
|
450
|
+
search_base_indent = len(first_search_line) - len(first_search_line.lstrip())
|
|
451
|
+
delta = orig_base_indent - search_base_indent
|
|
452
|
+
|
|
453
|
+
replace_lines = replace_block.split("\n")
|
|
454
|
+
shifted_replace_lines: List[str] = []
|
|
455
|
+
for r_line in replace_lines:
|
|
456
|
+
if not r_line.strip():
|
|
457
|
+
shifted_replace_lines.append("")
|
|
458
|
+
elif delta > 0:
|
|
459
|
+
shifted_replace_lines.append(" " * delta + r_line)
|
|
460
|
+
elif delta < 0:
|
|
461
|
+
strip_count = min(-delta, len(r_line) - len(r_line.lstrip()))
|
|
462
|
+
shifted_replace_lines.append(r_line[strip_count:])
|
|
463
|
+
else:
|
|
464
|
+
shifted_replace_lines.append(r_line)
|
|
465
|
+
|
|
466
|
+
new_lines = orig_lines[:i] + shifted_replace_lines + orig_lines[i + n_search :]
|
|
467
|
+
joined = "\n".join(new_lines)
|
|
468
|
+
return joined.replace("\n", "\r\n") if orig_is_crlf else joined
|
|
469
|
+
|
|
470
|
+
return None
|
|
471
|
+
|
|
472
|
+
@classmethod
|
|
473
|
+
def _get_ast_body_lists(cls, node: ast.AST) -> List[List[ast.AST]]:
|
|
474
|
+
"""Recursively collects all statement lists (body, orelse, finalbody, etc.) from an AST."""
|
|
475
|
+
lists: List[List[ast.AST]] = []
|
|
476
|
+
for _, value in ast.iter_fields(node):
|
|
477
|
+
if isinstance(value, list) and value and isinstance(value[0], ast.AST):
|
|
478
|
+
lists.append(value)
|
|
479
|
+
for item in value:
|
|
480
|
+
lists.extend(cls._get_ast_body_lists(item))
|
|
481
|
+
elif isinstance(value, ast.AST):
|
|
482
|
+
lists.extend(cls._get_ast_body_lists(value))
|
|
483
|
+
return lists
|
|
484
|
+
|
|
485
|
+
@classmethod
|
|
486
|
+
def _ast_structures_match(cls, node1: ast.AST, node2: ast.AST) -> bool:
|
|
487
|
+
"""Compares two AST nodes for structural equivalence ignoring attributes and formatting."""
|
|
488
|
+
if type(node1) is not type(node2):
|
|
489
|
+
return False
|
|
490
|
+
if isinstance(node1, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
491
|
+
if node1.name != getattr(node2, "name", None):
|
|
492
|
+
return False
|
|
493
|
+
elif isinstance(node1, ast.Name):
|
|
494
|
+
if node1.id != getattr(node2, "id", None):
|
|
495
|
+
return False
|
|
496
|
+
elif isinstance(node1, ast.Attribute):
|
|
497
|
+
if node1.attr != getattr(node2, "attr", None):
|
|
498
|
+
return False
|
|
499
|
+
|
|
500
|
+
dump1 = ast.dump(node1, include_attributes=False)
|
|
501
|
+
dump2 = ast.dump(node2, include_attributes=False)
|
|
502
|
+
return dump1 == dump2
|
|
503
|
+
|
|
504
|
+
@classmethod
|
|
505
|
+
def _match_ast_multiline(
|
|
506
|
+
cls,
|
|
507
|
+
orig_lines: List[str],
|
|
508
|
+
search_lines: List[str],
|
|
509
|
+
replace_block: str,
|
|
510
|
+
orig_is_crlf: bool,
|
|
511
|
+
) -> Optional[str]:
|
|
512
|
+
"""Matches search block against Python AST structure and replaces target statements."""
|
|
513
|
+
orig_code = "\n".join(orig_lines)
|
|
514
|
+
try:
|
|
515
|
+
orig_ast = ast.parse(orig_code)
|
|
516
|
+
except Exception:
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
search_code = "\n".join(search_lines)
|
|
520
|
+
search_ast: Optional[ast.AST] = None
|
|
521
|
+
try:
|
|
522
|
+
search_ast = ast.parse(search_code)
|
|
523
|
+
except Exception:
|
|
524
|
+
try:
|
|
525
|
+
search_ast = ast.parse(textwrap.dedent(search_code))
|
|
526
|
+
except Exception:
|
|
527
|
+
try:
|
|
528
|
+
wrapped = "def _dummy():\n" + textwrap.indent(textwrap.dedent(search_code), " ")
|
|
529
|
+
dummy_ast = ast.parse(wrapped)
|
|
530
|
+
search_ast = dummy_ast.body[0]
|
|
531
|
+
except Exception:
|
|
532
|
+
return None
|
|
533
|
+
|
|
534
|
+
if search_ast is None:
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
if isinstance(search_ast, ast.Module):
|
|
538
|
+
search_targets = search_ast.body
|
|
539
|
+
elif isinstance(search_ast, (ast.FunctionDef, ast.AsyncFunctionDef)) and getattr(search_ast, "name", "") == "_dummy":
|
|
540
|
+
search_targets = search_ast.body
|
|
541
|
+
else:
|
|
542
|
+
search_targets = [search_ast]
|
|
543
|
+
|
|
544
|
+
if not search_targets:
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
all_body_lists = cls._get_ast_body_lists(orig_ast)
|
|
548
|
+
if orig_ast.body not in all_body_lists:
|
|
549
|
+
all_body_lists.insert(0, orig_ast.body)
|
|
550
|
+
|
|
551
|
+
n_targets = len(search_targets)
|
|
552
|
+
for body_list in all_body_lists:
|
|
553
|
+
if len(body_list) < n_targets:
|
|
554
|
+
continue
|
|
555
|
+
for i in range(len(body_list) - n_targets + 1):
|
|
556
|
+
cand_slice = body_list[i : i + n_targets]
|
|
557
|
+
matched = True
|
|
558
|
+
for k in range(n_targets):
|
|
559
|
+
if not cls._ast_structures_match(cand_slice[k], search_targets[k]):
|
|
560
|
+
matched = False
|
|
561
|
+
break
|
|
562
|
+
if matched:
|
|
563
|
+
first_node = cand_slice[0]
|
|
564
|
+
last_node = cand_slice[-1]
|
|
565
|
+
start_lineno = first_node.lineno # 1-indexed
|
|
566
|
+
end_lineno = getattr(last_node, "end_lineno", last_node.lineno)
|
|
567
|
+
|
|
568
|
+
orig_target_line = orig_lines[start_lineno - 1]
|
|
569
|
+
target_base_indent = len(orig_target_line) - len(orig_target_line.lstrip())
|
|
570
|
+
|
|
571
|
+
search_first_nonempty = next((l for l in search_lines if l.strip()), search_lines[0])
|
|
572
|
+
search_base_indent = len(search_first_nonempty) - len(search_first_nonempty.lstrip())
|
|
573
|
+
delta = target_base_indent - search_base_indent
|
|
574
|
+
|
|
575
|
+
replace_lines = replace_block.split("\n")
|
|
576
|
+
shifted_replace_lines: List[str] = []
|
|
577
|
+
for r_line in replace_lines:
|
|
578
|
+
if not r_line.strip():
|
|
579
|
+
shifted_replace_lines.append("")
|
|
580
|
+
elif delta > 0:
|
|
581
|
+
shifted_replace_lines.append(" " * delta + r_line)
|
|
582
|
+
elif delta < 0:
|
|
583
|
+
strip_count = min(-delta, len(r_line) - len(r_line.lstrip()))
|
|
584
|
+
shifted_replace_lines.append(r_line[strip_count:])
|
|
585
|
+
else:
|
|
586
|
+
shifted_replace_lines.append(r_line)
|
|
587
|
+
|
|
588
|
+
new_lines = orig_lines[: start_lineno - 1] + shifted_replace_lines + orig_lines[end_lineno:]
|
|
589
|
+
joined = "\n".join(new_lines)
|
|
590
|
+
return joined.replace("\n", "\r\n") if orig_is_crlf else joined
|
|
591
|
+
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
@classmethod
|
|
595
|
+
def _match_blank_lines_tolerance(
|
|
596
|
+
cls,
|
|
597
|
+
orig_lines: List[str],
|
|
598
|
+
search_lines: List[str],
|
|
599
|
+
replace_block: str,
|
|
600
|
+
orig_is_crlf: bool,
|
|
601
|
+
) -> Optional[str]:
|
|
602
|
+
"""Matches search lines against original lines allowing extra blank lines in between."""
|
|
603
|
+
non_empty_search = [(idx, s.strip()) for idx, s in enumerate(search_lines) if s.strip()]
|
|
604
|
+
if not non_empty_search:
|
|
605
|
+
return None
|
|
606
|
+
|
|
607
|
+
n_orig = len(orig_lines)
|
|
608
|
+
for start_i in range(n_orig):
|
|
609
|
+
if orig_lines[start_i].strip() != non_empty_search[0][1]:
|
|
610
|
+
continue
|
|
611
|
+
|
|
612
|
+
curr_orig = start_i
|
|
613
|
+
matched_all = True
|
|
614
|
+
for _, s_text in non_empty_search[1:]:
|
|
615
|
+
curr_orig += 1
|
|
616
|
+
while curr_orig < n_orig and not orig_lines[curr_orig].strip():
|
|
617
|
+
curr_orig += 1
|
|
618
|
+
if curr_orig >= n_orig or orig_lines[curr_orig].strip() != s_text:
|
|
619
|
+
matched_all = False
|
|
620
|
+
break
|
|
621
|
+
|
|
622
|
+
if matched_all:
|
|
623
|
+
replace_lines = replace_block.split("\n")
|
|
624
|
+
new_lines = orig_lines[:start_i] + replace_lines + orig_lines[curr_orig + 1 :]
|
|
625
|
+
joined = "\n".join(new_lines)
|
|
626
|
+
return joined.replace("\n", "\r\n") if orig_is_crlf else joined
|
|
627
|
+
|
|
628
|
+
return None
|
|
629
|
+
|
|
630
|
+
@classmethod
|
|
631
|
+
def _match_stripped_lines(
|
|
632
|
+
cls,
|
|
633
|
+
orig_lines: List[str],
|
|
634
|
+
search_lines: List[str],
|
|
635
|
+
replace_block: str,
|
|
636
|
+
orig_is_crlf: bool,
|
|
637
|
+
) -> Optional[str]:
|
|
638
|
+
"""Matches when stripped non-empty lines are identical."""
|
|
639
|
+
n_search = len(search_lines)
|
|
640
|
+
n_orig = len(orig_lines)
|
|
641
|
+
if n_search == 0 or n_search > n_orig:
|
|
642
|
+
return None
|
|
643
|
+
|
|
644
|
+
for i in range(n_orig - n_search + 1):
|
|
645
|
+
match = True
|
|
646
|
+
for k in range(n_search):
|
|
647
|
+
if orig_lines[i + k].strip() != search_lines[k].strip():
|
|
648
|
+
match = False
|
|
649
|
+
break
|
|
650
|
+
if match:
|
|
651
|
+
replace_lines = replace_block.split("\n")
|
|
652
|
+
new_lines = orig_lines[:i] + replace_lines + orig_lines[i + n_search :]
|
|
653
|
+
joined = "\n".join(new_lines)
|
|
654
|
+
return joined.replace("\n", "\r\n") if orig_is_crlf else joined
|
|
655
|
+
|
|
656
|
+
return None
|
|
657
|
+
|
|
658
|
+
@classmethod
|
|
659
|
+
def _match_fuzzy_similarity(
|
|
660
|
+
cls,
|
|
661
|
+
orig_lines: List[str],
|
|
662
|
+
search_lines: List[str],
|
|
663
|
+
replace_block: str,
|
|
664
|
+
orig_is_crlf: bool,
|
|
665
|
+
) -> Optional[str]:
|
|
666
|
+
"""Fuzzy line-sequence similarity matching using SequenceMatcher."""
|
|
667
|
+
n_search = len(search_lines)
|
|
668
|
+
n_orig = len(orig_lines)
|
|
669
|
+
if n_search == 0 or n_search > n_orig:
|
|
670
|
+
return None
|
|
671
|
+
|
|
672
|
+
clean_search = "\n".join([l.strip() for l in search_lines if l.strip()])
|
|
673
|
+
if not clean_search:
|
|
674
|
+
return None
|
|
675
|
+
|
|
676
|
+
best_ratio = 0.0
|
|
677
|
+
best_index = -1
|
|
678
|
+
best_len = n_search
|
|
679
|
+
|
|
680
|
+
for window_len in (n_search, max(1, n_search - 1), n_search + 1):
|
|
681
|
+
if window_len > n_orig:
|
|
682
|
+
continue
|
|
683
|
+
for i in range(n_orig - window_len + 1):
|
|
684
|
+
candidate_lines = orig_lines[i : i + window_len]
|
|
685
|
+
clean_cand = "\n".join([l.strip() for l in candidate_lines if l.strip()])
|
|
686
|
+
ratio = difflib.SequenceMatcher(None, clean_search, clean_cand).ratio()
|
|
687
|
+
if ratio > best_ratio:
|
|
688
|
+
best_ratio = ratio
|
|
689
|
+
best_index = i
|
|
690
|
+
best_len = window_len
|
|
691
|
+
|
|
692
|
+
# Require high similarity threshold (>= 0.88) to prevent false edits
|
|
693
|
+
if best_ratio >= 0.88 and best_index >= 0:
|
|
694
|
+
first_orig = next((l for l in orig_lines[best_index : best_index + best_len] if l.strip()), orig_lines[best_index])
|
|
695
|
+
orig_base_indent = len(first_orig) - len(first_orig.lstrip())
|
|
696
|
+
|
|
697
|
+
first_search = next((l for l in search_lines if l.strip()), search_lines[0])
|
|
698
|
+
search_base_indent = len(first_search) - len(first_search.lstrip())
|
|
699
|
+
delta = orig_base_indent - search_base_indent
|
|
700
|
+
|
|
701
|
+
replace_lines = replace_block.split("\n")
|
|
702
|
+
shifted_replace_lines: List[str] = []
|
|
703
|
+
for r_line in replace_lines:
|
|
704
|
+
if not r_line.strip():
|
|
705
|
+
shifted_replace_lines.append("")
|
|
706
|
+
elif delta > 0:
|
|
707
|
+
shifted_replace_lines.append(" " * delta + r_line)
|
|
708
|
+
elif delta < 0:
|
|
709
|
+
strip_count = min(-delta, len(r_line) - len(r_line.lstrip()))
|
|
710
|
+
shifted_replace_lines.append(r_line[strip_count:])
|
|
711
|
+
else:
|
|
712
|
+
shifted_replace_lines.append(r_line)
|
|
713
|
+
|
|
714
|
+
new_lines = orig_lines[:best_index] + shifted_replace_lines + orig_lines[best_index + best_len :]
|
|
715
|
+
joined = "\n".join(new_lines)
|
|
716
|
+
return joined.replace("\n", "\r\n") if orig_is_crlf else joined
|
|
717
|
+
|
|
718
|
+
return None
|
|
719
|
+
|
|
720
|
+
# =========================================================================
|
|
721
|
+
# 3. File Patching and Transactional Multi-File Batching
|
|
722
|
+
# =========================================================================
|
|
723
|
+
|
|
724
|
+
@classmethod
|
|
725
|
+
def apply_file_patches(
|
|
726
|
+
cls,
|
|
727
|
+
file_path: str,
|
|
728
|
+
patch_text: str,
|
|
729
|
+
validate_ast: bool = True,
|
|
730
|
+
) -> Tuple[bool, str]:
|
|
731
|
+
"""
|
|
732
|
+
Parses SEARCH/REPLACE blocks and applies them sequentially to a single file.
|
|
733
|
+
|
|
734
|
+
Validates Python syntax with `ast.parse` before writing to disk if the file is `.py`.
|
|
735
|
+
Maintains atomic safety: if any block fails to match or AST validation fails,
|
|
736
|
+
the target file on disk remains completely untouched.
|
|
737
|
+
|
|
738
|
+
Args:
|
|
739
|
+
file_path: Target file path on disk.
|
|
740
|
+
patch_text: Raw text containing one or more SEARCH/REPLACE blocks.
|
|
741
|
+
validate_ast: Whether to perform pre-write AST parsing for .py files.
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
Tuple of (success: bool, error_message: str).
|
|
745
|
+
"""
|
|
746
|
+
path = Path(file_path)
|
|
747
|
+
if not path.exists() or not path.is_file():
|
|
748
|
+
return False, f"Target file not found: {file_path}"
|
|
749
|
+
|
|
750
|
+
try:
|
|
751
|
+
original_code = path.read_text(encoding="utf-8")
|
|
752
|
+
except Exception as e:
|
|
753
|
+
return False, f"Failed to read file {file_path}: {e}"
|
|
754
|
+
|
|
755
|
+
blocks = cls.parse_search_replace_blocks(patch_text)
|
|
756
|
+
if not blocks:
|
|
757
|
+
return False, "No valid SEARCH/REPLACE blocks found in patch"
|
|
758
|
+
|
|
759
|
+
current_code = original_code
|
|
760
|
+
for idx, (search_block, replace_block) in enumerate(blocks, start=1):
|
|
761
|
+
success, patched_code, err = cls.apply_patch(
|
|
762
|
+
current_code,
|
|
763
|
+
search_block,
|
|
764
|
+
replace_block,
|
|
765
|
+
fuzzy=True,
|
|
766
|
+
)
|
|
767
|
+
if not success:
|
|
768
|
+
return False, f"Block {idx} failed to apply: {err}"
|
|
769
|
+
current_code = patched_code
|
|
770
|
+
|
|
771
|
+
# AST syntax validation for Python files
|
|
772
|
+
if validate_ast and path.suffix.lower() == ".py":
|
|
773
|
+
try:
|
|
774
|
+
ast.parse(current_code, filename=str(file_path))
|
|
775
|
+
except SyntaxError as e:
|
|
776
|
+
return (
|
|
777
|
+
False,
|
|
778
|
+
f"AST SyntaxError validation failed on line {e.lineno}: {e.msg} - File unchanged",
|
|
779
|
+
)
|
|
780
|
+
except Exception as e:
|
|
781
|
+
return False, f"AST validation error: {e} - File unchanged"
|
|
782
|
+
|
|
783
|
+
# Atomically write updated code to file
|
|
784
|
+
try:
|
|
785
|
+
path.write_text(current_code, encoding="utf-8")
|
|
786
|
+
return True, ""
|
|
787
|
+
except Exception as e:
|
|
788
|
+
return False, f"Failed to write patched file {file_path}: {e}"
|
|
789
|
+
|
|
790
|
+
@classmethod
|
|
791
|
+
def _resolve_multi_file_target(cls, base: Path, file_path: Union[str, Path]) -> Tuple[Optional[Path], str]:
|
|
792
|
+
"""Resolve a patch target while keeping it inside the declared workspace."""
|
|
793
|
+
raw_path = Path(file_path)
|
|
794
|
+
if raw_path.is_absolute():
|
|
795
|
+
return None, f"Absolute patch paths are not allowed: {file_path}"
|
|
796
|
+
target_path = (base.resolve() / raw_path).resolve()
|
|
797
|
+
try:
|
|
798
|
+
target_path.relative_to(base.resolve())
|
|
799
|
+
except ValueError:
|
|
800
|
+
return None, f"Patch target escapes base directory: {file_path}"
|
|
801
|
+
return target_path, ""
|
|
802
|
+
|
|
803
|
+
@classmethod
|
|
804
|
+
def apply_multi_file_patches(
|
|
805
|
+
cls,
|
|
806
|
+
patches: Union[str, Dict[str, Union[str, List[Tuple[str, str]]]], Sequence[Union[Tuple[Optional[str], str, str], Tuple[str, str], FilePatch]], Sequence[Dict[str, Any]]],
|
|
807
|
+
base_dir: Optional[Union[str, Path]] = None,
|
|
808
|
+
validate_ast: bool = True,
|
|
809
|
+
) -> Tuple[bool, List[str], str]:
|
|
810
|
+
"""
|
|
811
|
+
Applies a batch of patches across multiple files with transactional rollback.
|
|
812
|
+
|
|
813
|
+
If any block in any file fails to match, or any Python file fails AST syntax
|
|
814
|
+
validation, the entire batch transaction is aborted and zero files are modified
|
|
815
|
+
on disk (all files are restored to their exact original contents).
|
|
816
|
+
|
|
817
|
+
Args:
|
|
818
|
+
patches: Either:
|
|
819
|
+
- Raw multi-file patch text containing file headers and blocks.
|
|
820
|
+
- Dict mapping file paths to patch strings or lists of (search, replace) tuples.
|
|
821
|
+
- List of (file_path, search_block, replace_block) tuples or FilePatch objects.
|
|
822
|
+
base_dir: Base directory to resolve relative file paths against (defaults to cwd).
|
|
823
|
+
validate_ast: Whether to perform pre-write AST validation on .py files.
|
|
824
|
+
|
|
825
|
+
Returns:
|
|
826
|
+
Tuple of (success: bool, modified_files: List[str], error_message: str).
|
|
827
|
+
"""
|
|
828
|
+
base = Path(base_dir) if base_dir else Path.cwd()
|
|
829
|
+
file_to_blocks: Dict[Path, List[Tuple[str, str]]] = {}
|
|
830
|
+
|
|
831
|
+
# 1. Normalize input into file_to_blocks mapping
|
|
832
|
+
if isinstance(patches, str):
|
|
833
|
+
parsed_mf = cls.parse_multi_file_patches(patches)
|
|
834
|
+
if not parsed_mf:
|
|
835
|
+
return False, [], "No valid SEARCH/REPLACE blocks found in patch text"
|
|
836
|
+
|
|
837
|
+
for fp, s_part, r_part in parsed_mf:
|
|
838
|
+
if not fp:
|
|
839
|
+
return False, [], "Multi-file patch contains blocks without target file paths"
|
|
840
|
+
target_path, path_error = cls._resolve_multi_file_target(base, fp)
|
|
841
|
+
if target_path is None:
|
|
842
|
+
return False, [], path_error
|
|
843
|
+
file_to_blocks.setdefault(target_path, []).append((s_part, r_part))
|
|
844
|
+
|
|
845
|
+
elif isinstance(patches, dict):
|
|
846
|
+
for fp, val in patches.items():
|
|
847
|
+
target_path, path_error = cls._resolve_multi_file_target(base, fp)
|
|
848
|
+
if target_path is None:
|
|
849
|
+
return False, [], path_error
|
|
850
|
+
if isinstance(val, str):
|
|
851
|
+
b_list = cls.parse_search_replace_blocks(val)
|
|
852
|
+
elif isinstance(val, list):
|
|
853
|
+
b_list = val
|
|
854
|
+
else:
|
|
855
|
+
return False, [], f"Invalid patch format for file: {fp}"
|
|
856
|
+
file_to_blocks[target_path] = b_list
|
|
857
|
+
|
|
858
|
+
elif isinstance(patches, (list, tuple)):
|
|
859
|
+
for item in patches:
|
|
860
|
+
if isinstance(item, FilePatch):
|
|
861
|
+
if not item.file_path:
|
|
862
|
+
return False, [], "FilePatch item missing file_path"
|
|
863
|
+
target_path, path_error = cls._resolve_multi_file_target(base, item.file_path)
|
|
864
|
+
if target_path is None:
|
|
865
|
+
return False, [], path_error
|
|
866
|
+
file_to_blocks.setdefault(target_path, []).append((item.search_block, item.replace_block))
|
|
867
|
+
elif isinstance(item, dict):
|
|
868
|
+
fp = item.get("file_path") or item.get("file") or item.get("path")
|
|
869
|
+
if not fp:
|
|
870
|
+
return False, [], "Dictionary patch item missing file path"
|
|
871
|
+
target_path, path_error = cls._resolve_multi_file_target(base, fp)
|
|
872
|
+
if target_path is None:
|
|
873
|
+
return False, [], path_error
|
|
874
|
+
s_part = item.get("search_block") or item.get("search") or ""
|
|
875
|
+
r_part = item.get("replace_block") or item.get("replace") or ""
|
|
876
|
+
file_to_blocks.setdefault(target_path, []).append((s_part, r_part))
|
|
877
|
+
elif isinstance(item, (list, tuple)):
|
|
878
|
+
if len(item) == 3:
|
|
879
|
+
fp, s_part, r_part = item
|
|
880
|
+
if not fp:
|
|
881
|
+
return False, [], "Patch tuple missing file path"
|
|
882
|
+
target_path, path_error = cls._resolve_multi_file_target(base, fp)
|
|
883
|
+
if target_path is None:
|
|
884
|
+
return False, [], path_error
|
|
885
|
+
file_to_blocks.setdefault(target_path, []).append((s_part, r_part))
|
|
886
|
+
elif len(item) == 2:
|
|
887
|
+
fp, patch_val = item
|
|
888
|
+
target_path, path_error = cls._resolve_multi_file_target(base, fp)
|
|
889
|
+
if target_path is None:
|
|
890
|
+
return False, [], path_error
|
|
891
|
+
if isinstance(patch_val, str):
|
|
892
|
+
b_list = cls.parse_search_replace_blocks(patch_val)
|
|
893
|
+
else:
|
|
894
|
+
b_list = patch_val
|
|
895
|
+
file_to_blocks[target_path] = b_list
|
|
896
|
+
else:
|
|
897
|
+
return False, [], f"Invalid patch tuple length: {len(item)}"
|
|
898
|
+
else:
|
|
899
|
+
return False, [], f"Unsupported patch item type: {type(item)}"
|
|
900
|
+
else:
|
|
901
|
+
return False, [], f"Unsupported patches type: {type(patches)}"
|
|
902
|
+
|
|
903
|
+
if not file_to_blocks:
|
|
904
|
+
return False, [], "No valid patches or target files provided"
|
|
905
|
+
|
|
906
|
+
# 2. In-memory execution stage (Zero disk writes)
|
|
907
|
+
original_contents: Dict[Path, Optional[str]] = {}
|
|
908
|
+
patched_contents: Dict[Path, str] = {}
|
|
909
|
+
|
|
910
|
+
for target_path, blocks in file_to_blocks.items():
|
|
911
|
+
if not target_path.exists():
|
|
912
|
+
# Allow new file creation if first block has empty search
|
|
913
|
+
if blocks and not blocks[0][0]:
|
|
914
|
+
orig_code = ""
|
|
915
|
+
original_contents[target_path] = None
|
|
916
|
+
else:
|
|
917
|
+
return False, [], f"Target file not found: {target_path}"
|
|
918
|
+
else:
|
|
919
|
+
try:
|
|
920
|
+
orig_code = target_path.read_text(encoding="utf-8")
|
|
921
|
+
except Exception as e:
|
|
922
|
+
return False, [], f"Failed to read target file {target_path}: {e}"
|
|
923
|
+
original_contents[target_path] = orig_code
|
|
924
|
+
|
|
925
|
+
curr_code = orig_code
|
|
926
|
+
for idx, (search_block, replace_block) in enumerate(blocks, start=1):
|
|
927
|
+
if not search_block and not curr_code:
|
|
928
|
+
curr_code = replace_block
|
|
929
|
+
else:
|
|
930
|
+
success, next_code, err = cls.apply_patch(
|
|
931
|
+
curr_code,
|
|
932
|
+
search_block,
|
|
933
|
+
replace_block,
|
|
934
|
+
fuzzy=True,
|
|
935
|
+
)
|
|
936
|
+
if not success:
|
|
937
|
+
return False, [], f"Patch block {idx} failed for {target_path.name}: {err} - Transaction aborted"
|
|
938
|
+
curr_code = next_code
|
|
939
|
+
|
|
940
|
+
# AST Syntax validation for Python files
|
|
941
|
+
if validate_ast and target_path.suffix.lower() == ".py" and curr_code.strip():
|
|
942
|
+
try:
|
|
943
|
+
ast.parse(curr_code, filename=str(target_path))
|
|
944
|
+
except SyntaxError as e:
|
|
945
|
+
return (
|
|
946
|
+
False,
|
|
947
|
+
[],
|
|
948
|
+
f"AST SyntaxError in {target_path.name} on line {e.lineno}: {e.msg} - All changes rolled back",
|
|
949
|
+
)
|
|
950
|
+
except Exception as e:
|
|
951
|
+
return (
|
|
952
|
+
False,
|
|
953
|
+
[],
|
|
954
|
+
f"AST validation error in {target_path.name}: {e} - All changes rolled back",
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
patched_contents[target_path] = curr_code
|
|
958
|
+
|
|
959
|
+
# 3. Transactional commit stage with automatic rollback on I/O error
|
|
960
|
+
written_files: List[Path] = []
|
|
961
|
+
modified_file_paths: List[str] = []
|
|
962
|
+
|
|
963
|
+
try:
|
|
964
|
+
for target_path, new_code in patched_contents.items():
|
|
965
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
966
|
+
target_path.write_text(new_code, encoding="utf-8")
|
|
967
|
+
written_files.append(target_path)
|
|
968
|
+
modified_file_paths.append(str(target_path))
|
|
969
|
+
|
|
970
|
+
return True, modified_file_paths, ""
|
|
971
|
+
|
|
972
|
+
except Exception as e:
|
|
973
|
+
# Transactional Rollback: Restore all written files to their exact initial state
|
|
974
|
+
for written_path in written_files:
|
|
975
|
+
orig = original_contents.get(written_path)
|
|
976
|
+
if orig is None:
|
|
977
|
+
if written_path.exists():
|
|
978
|
+
written_path.unlink(missing_ok=True)
|
|
979
|
+
else:
|
|
980
|
+
written_path.write_text(orig, encoding="utf-8")
|
|
981
|
+
|
|
982
|
+
return False, [], f"Transactional write error: {e} - All changes rolled back"
|
|
983
|
+
|
|
984
|
+
@classmethod
|
|
985
|
+
def apply_batch_patches(
|
|
986
|
+
cls,
|
|
987
|
+
patches: Union[str, Dict[str, Union[str, List[Tuple[str, str]]]], Sequence[Any]],
|
|
988
|
+
base_dir: Optional[Union[str, Path]] = None,
|
|
989
|
+
validate_ast: bool = True,
|
|
990
|
+
) -> Tuple[bool, List[str], str]:
|
|
991
|
+
"""Convenience alias for `apply_multi_file_patches`."""
|
|
992
|
+
return cls.apply_multi_file_patches(patches, base_dir=base_dir, validate_ast=validate_ast)
|
|
993
|
+
|
|
994
|
+
# =========================================================================
|
|
995
|
+
# 4. Clean Diff Generation & CLI Preview Rendering
|
|
996
|
+
# =========================================================================
|
|
997
|
+
|
|
998
|
+
@classmethod
|
|
999
|
+
def generate_diff(
|
|
1000
|
+
cls,
|
|
1001
|
+
original_code: str,
|
|
1002
|
+
patched_code: str,
|
|
1003
|
+
file_path: str = "file",
|
|
1004
|
+
) -> str:
|
|
1005
|
+
"""
|
|
1006
|
+
Generates standard unified diff format between original and patched code.
|
|
1007
|
+
|
|
1008
|
+
Args:
|
|
1009
|
+
original_code: Original code string.
|
|
1010
|
+
patched_code: Patched code string.
|
|
1011
|
+
file_path: Target filename label for headers.
|
|
1012
|
+
|
|
1013
|
+
Returns:
|
|
1014
|
+
Unified diff string.
|
|
1015
|
+
"""
|
|
1016
|
+
orig_lines = original_code.splitlines(keepends=True)
|
|
1017
|
+
patched_lines = patched_code.splitlines(keepends=True)
|
|
1018
|
+
diff = difflib.unified_diff(
|
|
1019
|
+
orig_lines,
|
|
1020
|
+
patched_lines,
|
|
1021
|
+
fromfile=f"a/{file_path}",
|
|
1022
|
+
tofile=f"b/{file_path}",
|
|
1023
|
+
lineterm="",
|
|
1024
|
+
)
|
|
1025
|
+
return "\n".join([line.rstrip("\r\n") for line in diff])
|
|
1026
|
+
|
|
1027
|
+
@classmethod
|
|
1028
|
+
def get_diff_stats(cls, diff_text: str) -> Tuple[int, int]:
|
|
1029
|
+
"""
|
|
1030
|
+
Computes the number of additions and deletions in a unified diff.
|
|
1031
|
+
|
|
1032
|
+
Args:
|
|
1033
|
+
diff_text: Unified diff text.
|
|
1034
|
+
|
|
1035
|
+
Returns:
|
|
1036
|
+
Tuple of (additions_count: int, deletions_count: int).
|
|
1037
|
+
"""
|
|
1038
|
+
additions = 0
|
|
1039
|
+
deletions = 0
|
|
1040
|
+
for line in diff_text.splitlines():
|
|
1041
|
+
if line.startswith("+++") or line.startswith("---"):
|
|
1042
|
+
continue
|
|
1043
|
+
if line.startswith("+"):
|
|
1044
|
+
additions += 1
|
|
1045
|
+
elif line.startswith("-"):
|
|
1046
|
+
deletions += 1
|
|
1047
|
+
return additions, deletions
|
|
1048
|
+
|
|
1049
|
+
@classmethod
|
|
1050
|
+
def render_diff(
|
|
1051
|
+
cls,
|
|
1052
|
+
diff_text: str,
|
|
1053
|
+
colorize: bool = True,
|
|
1054
|
+
) -> str:
|
|
1055
|
+
"""
|
|
1056
|
+
Renders a unified diff with clean terminal ANSI color highlighting.
|
|
1057
|
+
|
|
1058
|
+
Args:
|
|
1059
|
+
diff_text: Raw unified diff string.
|
|
1060
|
+
colorize: Whether to include ANSI escape color sequences.
|
|
1061
|
+
|
|
1062
|
+
Returns:
|
|
1063
|
+
Formatted diff string ready for terminal printing.
|
|
1064
|
+
"""
|
|
1065
|
+
if not diff_text.strip():
|
|
1066
|
+
return ""
|
|
1067
|
+
if not colorize:
|
|
1068
|
+
return diff_text
|
|
1069
|
+
|
|
1070
|
+
rendered_lines: List[str] = []
|
|
1071
|
+
for line in diff_text.splitlines():
|
|
1072
|
+
if line.startswith("---") or line.startswith("+++"):
|
|
1073
|
+
rendered_lines.append(f"{cls.ANSI_BOLD}{cls.ANSI_CYAN}{line}{cls.ANSI_RESET}")
|
|
1074
|
+
elif line.startswith("@@"):
|
|
1075
|
+
rendered_lines.append(f"{cls.ANSI_CYAN}{line}{cls.ANSI_RESET}")
|
|
1076
|
+
elif line.startswith("+"):
|
|
1077
|
+
rendered_lines.append(f"{cls.ANSI_GREEN}{line}{cls.ANSI_RESET}")
|
|
1078
|
+
elif line.startswith("-"):
|
|
1079
|
+
rendered_lines.append(f"{cls.ANSI_RED}{line}{cls.ANSI_RESET}")
|
|
1080
|
+
else:
|
|
1081
|
+
rendered_lines.append(f"{cls.ANSI_DIM}{line}{cls.ANSI_RESET}")
|
|
1082
|
+
|
|
1083
|
+
return "\n".join(rendered_lines)
|
|
1084
|
+
|
|
1085
|
+
@classmethod
|
|
1086
|
+
def render_diff_preview(
|
|
1087
|
+
cls,
|
|
1088
|
+
original_code: str,
|
|
1089
|
+
patched_code: str,
|
|
1090
|
+
file_path: str = "file",
|
|
1091
|
+
colorize: bool = True,
|
|
1092
|
+
) -> str:
|
|
1093
|
+
"""
|
|
1094
|
+
Generates and renders a clean diff preview for a single file edit.
|
|
1095
|
+
|
|
1096
|
+
Args:
|
|
1097
|
+
original_code: Original code string.
|
|
1098
|
+
patched_code: Patched code string.
|
|
1099
|
+
file_path: File path label.
|
|
1100
|
+
colorize: Whether to colorize output.
|
|
1101
|
+
|
|
1102
|
+
Returns:
|
|
1103
|
+
Rendered diff string or '[No changes]' message.
|
|
1104
|
+
"""
|
|
1105
|
+
diff = cls.generate_diff(original_code, patched_code, file_path=file_path)
|
|
1106
|
+
if not diff.strip():
|
|
1107
|
+
return "[No changes]"
|
|
1108
|
+
return cls.render_diff(diff, colorize=colorize)
|
|
1109
|
+
|
|
1110
|
+
@classmethod
|
|
1111
|
+
def render_batch_diff_preview(
|
|
1112
|
+
cls,
|
|
1113
|
+
file_diffs: Dict[str, Tuple[str, str]],
|
|
1114
|
+
colorize: bool = True,
|
|
1115
|
+
) -> str:
|
|
1116
|
+
"""
|
|
1117
|
+
Renders a clean summary and detailed diff preview for a batch of multi-file edits.
|
|
1118
|
+
|
|
1119
|
+
Args:
|
|
1120
|
+
file_diffs: Dictionary mapping file_path -> (original_code, patched_code).
|
|
1121
|
+
colorize: Whether to colorize output.
|
|
1122
|
+
|
|
1123
|
+
Returns:
|
|
1124
|
+
Formatted multi-file diff summary and file-by-file preview string.
|
|
1125
|
+
"""
|
|
1126
|
+
if not file_diffs:
|
|
1127
|
+
return "[No changes]"
|
|
1128
|
+
|
|
1129
|
+
total_added = 0
|
|
1130
|
+
total_deleted = 0
|
|
1131
|
+
file_sections: List[str] = []
|
|
1132
|
+
|
|
1133
|
+
for file_path, (orig, patched) in file_diffs.items():
|
|
1134
|
+
diff = cls.generate_diff(orig, patched, file_path=file_path)
|
|
1135
|
+
if not diff.strip():
|
|
1136
|
+
continue
|
|
1137
|
+
adds, dels = cls.get_diff_stats(diff)
|
|
1138
|
+
total_added += adds
|
|
1139
|
+
total_deleted += dels
|
|
1140
|
+
file_sections.append(cls.render_diff(diff, colorize=colorize))
|
|
1141
|
+
|
|
1142
|
+
if not file_sections:
|
|
1143
|
+
return "[No changes detected]"
|
|
1144
|
+
|
|
1145
|
+
summary_title = f"=== Diff Preview: {len(file_sections)} file(s) changed (+{total_added}, -{total_deleted} lines) ==="
|
|
1146
|
+
if colorize:
|
|
1147
|
+
summary_title = f"{cls.ANSI_BOLD}{cls.ANSI_MAGENTA}{summary_title}{cls.ANSI_RESET}"
|
|
1148
|
+
|
|
1149
|
+
divider = "=" * 60
|
|
1150
|
+
return f"{summary_title}\n" + f"\n{divider}\n".join(file_sections)
|
|
1151
|
+
|
|
1152
|
+
@classmethod
|
|
1153
|
+
def get_rich_diff(
|
|
1154
|
+
cls,
|
|
1155
|
+
original_code: str,
|
|
1156
|
+
patched_code: str,
|
|
1157
|
+
file_path: str = "file",
|
|
1158
|
+
) -> Any:
|
|
1159
|
+
"""
|
|
1160
|
+
Returns a rich Syntax or Panel object for terminal rendering if `rich` is installed.
|
|
1161
|
+
|
|
1162
|
+
Args:
|
|
1163
|
+
original_code: Original code string.
|
|
1164
|
+
patched_code: Patched code string.
|
|
1165
|
+
file_path: File path label.
|
|
1166
|
+
|
|
1167
|
+
Returns:
|
|
1168
|
+
Rich renderable or colored diff string.
|
|
1169
|
+
"""
|
|
1170
|
+
diff = cls.generate_diff(original_code, patched_code, file_path=file_path)
|
|
1171
|
+
try:
|
|
1172
|
+
from rich.syntax import Syntax
|
|
1173
|
+
return Syntax(diff, "diff", theme="monokai", line_numbers=True)
|
|
1174
|
+
except Exception:
|
|
1175
|
+
return cls.render_diff(diff, colorize=True)
|