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.
Files changed (75) hide show
  1. k_cli/__init__.py +77 -0
  2. k_cli/agents/__init__.py +0 -0
  3. k_cli/agents/adversarial_swarm.py +338 -0
  4. k_cli/agents/agent_core.py +255 -0
  5. k_cli/agents/background_daemon.py +141 -0
  6. k_cli/agents/orchestrator.py +376 -0
  7. k_cli/agents/persona.py +649 -0
  8. k_cli/agents/scaffold_engine.py +121 -0
  9. k_cli/agents/strands_agent.py +832 -0
  10. k_cli/agents/subagents.py +1496 -0
  11. k_cli/cli.py +3297 -0
  12. k_cli/core/__init__.py +0 -0
  13. k_cli/core/airgap.py +95 -0
  14. k_cli/core/credentials.py +548 -0
  15. k_cli/core/intent_sensor.py +177 -0
  16. k_cli/core/llm_driver.py +1028 -0
  17. k_cli/core/model_manager.py +1109 -0
  18. k_cli/core/models_hub.py +913 -0
  19. k_cli/core/prompting.py +41 -0
  20. k_cli/core/sdk.py +322 -0
  21. k_cli/core/session.py +826 -0
  22. k_cli/core/smart_router.py +230 -0
  23. k_cli/core/storage_manager.py +176 -0
  24. k_cli/core/viewport_engine.py +117 -0
  25. k_cli/demo/demo_runner.py +579 -0
  26. k_cli/git/__init__.py +0 -0
  27. k_cli/git/ai_bisect.py +208 -0
  28. k_cli/git/conflict_resolver.py +1039 -0
  29. k_cli/git/git_guard.py +417 -0
  30. k_cli/git/patcher.py +1175 -0
  31. k_cli/git/repo_map.py +1780 -0
  32. k_cli/git/smart_git.py +928 -0
  33. k_cli/git/verifier.py +969 -0
  34. k_cli/github/__init__.py +0 -0
  35. k_cli/github/dedup_engine.py +787 -0
  36. k_cli/github/github_client.py +1702 -0
  37. k_cli/github/github_engine.py +641 -0
  38. k_cli/github/local_hub.py +209 -0
  39. k_cli/github/pr_watcher.py +129 -0
  40. k_cli/github/trending.py +205 -0
  41. k_cli/tools/__init__.py +0 -0
  42. k_cli/tools/audit.py +79 -0
  43. k_cli/tools/chaos_immunity.py +377 -0
  44. k_cli/tools/codebase_qa.py +106 -0
  45. k_cli/tools/command_runner.py +256 -0
  46. k_cli/tools/diagram_generator.py +547 -0
  47. k_cli/tools/doc_retriever.py +1332 -0
  48. k_cli/tools/feature.py +105 -0
  49. k_cli/tools/ghost_daemon.py +122 -0
  50. k_cli/tools/incident_triage.py +1365 -0
  51. k_cli/tools/mcp_client.py +1846 -0
  52. k_cli/tools/repo_gardener.py +142 -0
  53. k_cli/tools/rules.py +109 -0
  54. k_cli/tools/security.py +52 -0
  55. k_cli/tools/security_healer.py +999 -0
  56. k_cli/tools/synapse_graph.py +155 -0
  57. k_cli/tui/__init__.py +0 -0
  58. k_cli/tui/diff_viewer.py +223 -0
  59. k_cli/tui/tui.py +1145 -0
  60. k_cli/tui/tui_animations.py +648 -0
  61. k_cli/tui/tui_app.py +2788 -0
  62. k_cli/ui/__init__.py +10 -0
  63. k_cli/ui/simple_repl.py +315 -0
  64. k_cli/web/__init__.py +7 -0
  65. k_cli/web/server.py +624 -0
  66. k_cli/web/static/app.js +830 -0
  67. k_cli/web/static/index.html +495 -0
  68. k_cli/web/static/monitor.html +189 -0
  69. k_cli/web/static/style.css +838 -0
  70. k_cli_for_devs-1.0.0.dist-info/METADATA +461 -0
  71. k_cli_for_devs-1.0.0.dist-info/RECORD +75 -0
  72. k_cli_for_devs-1.0.0.dist-info/WHEEL +5 -0
  73. k_cli_for_devs-1.0.0.dist-info/entry_points.txt +2 -0
  74. k_cli_for_devs-1.0.0.dist-info/licenses/LICENSE +21 -0
  75. 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)