raggiecode 0.2.1__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 (93) hide show
  1. Agent/__init__.py +0 -0
  2. Agent/agent.py +891 -0
  3. Agent/chat_history_db.py +1500 -0
  4. Agent/command.py +49 -0
  5. Agent/config.py +46 -0
  6. Agent/effort_levels.py +33 -0
  7. Agent/git_manager.py +727 -0
  8. Agent/tools.py +35 -0
  9. Commands/__init__.py +18 -0
  10. Commands/effort.py +42 -0
  11. Commands/global_todo.py +23 -0
  12. Commands/help.py +22 -0
  13. Commands/reasoning.py +24 -0
  14. Commands/redo.py +11 -0
  15. Commands/reindex.py +27 -0
  16. Commands/shell.py +28 -0
  17. Commands/stream.py +24 -0
  18. Commands/undo.py +13 -0
  19. Commands/unlimited_effort.py +8 -0
  20. Commands/window_size.py +29 -0
  21. RAG/__init__.py +0 -0
  22. RAG/document.py +119 -0
  23. RAG/find.py +408 -0
  24. RAG/graph.py +231 -0
  25. Tools/GetFileCodeStructure.py +43 -0
  26. Tools/GetSymbolSourceCode.py +27 -0
  27. Tools/__init__.py +39 -0
  28. Tools/ask_user.py +102 -0
  29. Tools/dispatch_subagent.py +215 -0
  30. Tools/document.py +35 -0
  31. Tools/edit_symbol.py +250 -0
  32. Tools/fuzzy_search.py +119 -0
  33. Tools/list_dir.py +51 -0
  34. Tools/read.py +49 -0
  35. Tools/read_image.py +75 -0
  36. Tools/remove.py +75 -0
  37. Tools/replace.py +305 -0
  38. Tools/search.py +41 -0
  39. Tools/shell.py +149 -0
  40. Tools/shell_kill.py +87 -0
  41. Tools/temp_background_service.py +113 -0
  42. Tools/todo_list.py +481 -0
  43. Tools/utils.py +116 -0
  44. Tools/view_changes.py +179 -0
  45. Tools/walk_call_tree.py +30 -0
  46. Tools/web_fetch.py +175 -0
  47. Tools/web_search.py +69 -0
  48. Tools/write.py +48 -0
  49. cli.py +111 -0
  50. config/__init__.py +0 -0
  51. config/coder_system_prompt.md +119 -0
  52. config/roles.json +43 -0
  53. config/tools.json +709 -0
  54. indexing/__init__.py +0 -0
  55. indexing/cli.py +128 -0
  56. indexing/code_index_sdk.py +832 -0
  57. indexing/code_indexer.py +1763 -0
  58. indexing/db_schema.py +396 -0
  59. indexing/export_to_json.py +346 -0
  60. indexing/extractors.py +189 -0
  61. indexing/file_utils.py +97 -0
  62. indexing/frontend/__init__.py +0 -0
  63. indexing/frontend/css_extractor.py +195 -0
  64. indexing/frontend/css_parser.py +387 -0
  65. indexing/frontend/css_selector_utils.py +226 -0
  66. indexing/frontend/edit_safety.py +573 -0
  67. indexing/frontend/graph.py +838 -0
  68. indexing/frontend/html_extractor.py +496 -0
  69. indexing/frontend/html_parser.py +314 -0
  70. indexing/frontend/jsx_extractor.py +1204 -0
  71. indexing/frontend/location_lookup.py +247 -0
  72. indexing/frontend/resolver.py +485 -0
  73. indexing/frontend/runtime_resolver.py +862 -0
  74. indexing/frontend/semantic_output.py +705 -0
  75. indexing/frontend/source_location.py +69 -0
  76. indexing/frontend_config.py +72 -0
  77. indexing/frontend_models.py +347 -0
  78. indexing/language_config.py +360 -0
  79. indexing/models.py +284 -0
  80. indexing/node_utils.py +1112 -0
  81. indexing/parse_worker.py +1082 -0
  82. indexing/queries.py +1542 -0
  83. indexing/sdk_examples.py +426 -0
  84. interactive.py +248 -0
  85. raggie.py +673 -0
  86. raggiecode-0.2.1.dist-info/METADATA +944 -0
  87. raggiecode-0.2.1.dist-info/RECORD +93 -0
  88. raggiecode-0.2.1.dist-info/WHEEL +5 -0
  89. raggiecode-0.2.1.dist-info/entry_points.txt +2 -0
  90. raggiecode-0.2.1.dist-info/top_level.txt +10 -0
  91. skills/__init__.py +3 -0
  92. skills/manager.py +114 -0
  93. skills/tool.py +121 -0
@@ -0,0 +1,573 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Safe editing of frontend semantic entities (Phase 11).
4
+
5
+ Provides validation and application of edits to frontend entities
6
+ (markup elements, CSS rules, custom properties, event handlers,
7
+ property bindings, JSX subtrees) using the source ranges stored
8
+ in the index.
9
+
10
+ Validation checks:
11
+ 1. File has not changed since indexing (content hash comparison)
12
+ 2. Source range is not ambiguous
13
+ 3. Entity does not share an unsafe range with unrelated syntax
14
+ 4. Entity was not only heuristically identified
15
+ 5. Parser recovery does not prevent trustworthy boundaries
16
+ """
17
+
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import sqlite3
22
+ from dataclasses import dataclass, field
23
+ from typing import Dict, List, Optional, Any, Tuple
24
+
25
+
26
+ # ──────────────────────────────────────────────────────────────
27
+ # Data structures
28
+ # ──────────────────────────────────────────────────────────────
29
+
30
+ @dataclass
31
+ class EditValidation:
32
+ """Result of validating an edit range."""
33
+ safe: bool
34
+ reason: Optional[str] = None
35
+ file_path: Optional[str] = None
36
+ source_range: Optional[Dict[str, int]] = None
37
+ entity_type: Optional[str] = None
38
+ entity_id: Optional[int] = None
39
+
40
+ def to_dict(self) -> Dict[str, Any]:
41
+ return {
42
+ "safe": self.safe,
43
+ "reason": self.reason,
44
+ "file_path": self.file_path,
45
+ "source_range": self.source_range,
46
+ "entity_type": self.entity_type,
47
+ "entity_id": self.entity_id,
48
+ }
49
+
50
+
51
+ @dataclass
52
+ class EditResult:
53
+ """Result of applying a frontend edit."""
54
+ success: bool
55
+ reason: Optional[str] = None
56
+ file_path: Optional[str] = None
57
+ old_source: Optional[str] = None
58
+ new_source: Optional[str] = None
59
+ diff: Optional[str] = None
60
+ source_range: Optional[Dict[str, int]] = None
61
+
62
+ def to_dict(self) -> Dict[str, Any]:
63
+ return {
64
+ "success": self.success,
65
+ "reason": self.reason,
66
+ "file_path": self.file_path,
67
+ "old_source": self.old_source,
68
+ "new_source": self.new_source,
69
+ "diff": self.diff,
70
+ "source_range": self.source_range,
71
+ }
72
+
73
+
74
+ # ──────────────────────────────────────────────────────────────
75
+ # Entity type → table mapping
76
+ # ──────────────────────────────────────────────────────────────
77
+
78
+ _ENTITY_TABLES = {
79
+ "markup_element": "markup_elements",
80
+ "css_rule": "style_selectors",
81
+ "custom_property": "style_custom_properties",
82
+ "event_binding": "frontend_events",
83
+ "property_binding": "frontend_bindings",
84
+ "jsx_subtree": "markup_elements",
85
+ }
86
+
87
+ # Entity types that use the markup_elements table but need special handling
88
+ _MARKUP_ENTITY_TYPES = {"markup_element", "jsx_subtree"}
89
+
90
+
91
+ # ──────────────────────────────────────────────────────────────
92
+ # Helpers
93
+ # ──────────────────────────────────────────────────────────────
94
+
95
+ def _parse_json(raw: Optional[str]) -> Optional[Any]:
96
+ if not raw:
97
+ return None
98
+ try:
99
+ return json.loads(raw)
100
+ except (json.JSONDecodeError, TypeError):
101
+ return None
102
+
103
+
104
+ def _compute_file_hash(file_path: str) -> Optional[str]:
105
+ """Compute xxhash-like hash of file content for staleness check.
106
+
107
+ Uses the same algorithm as the indexer (xxhash if available, else sha256 fallback).
108
+ """
109
+ try:
110
+ import xxhash
111
+ with open(file_path, "rb") as f:
112
+ return xxhash.xxh64(f.read()).hexdigest()
113
+ except ImportError:
114
+ with open(file_path, "rb") as f:
115
+ return hashlib.sha256(f.read()).hexdigest()[:16]
116
+
117
+
118
+ def _get_entity_row(conn, entity_type: str, entity_id: int) -> Optional[sqlite3.Row]:
119
+ """Fetch the entity row from the appropriate table."""
120
+ table = _ENTITY_TABLES.get(entity_type)
121
+ if not table:
122
+ return None
123
+
124
+ row = conn.execute(
125
+ f"SELECT * FROM {table} WHERE id = ?",
126
+ (entity_id,)
127
+ ).fetchone()
128
+ return row
129
+
130
+
131
+ def _get_file_info(conn, file_path: str) -> Optional[Dict[str, Any]]:
132
+ """Get file info from the index by path."""
133
+ normalized = file_path[2:] if file_path.startswith("./") else file_path
134
+ row = conn.execute(
135
+ "SELECT id, path, absolute_path, language, content_hash, mtime FROM files WHERE path = ?",
136
+ (normalized,)
137
+ ).fetchone()
138
+ if not row:
139
+ # Fallback: match by filename
140
+ filename = os.path.basename(normalized)
141
+ row = conn.execute(
142
+ "SELECT id, path, absolute_path, language, content_hash, mtime FROM files WHERE path LIKE ?",
143
+ (f"%{filename}",)
144
+ ).fetchone()
145
+ return dict(row) if row else None
146
+
147
+
148
+ def _get_file_info_by_id(conn, file_id: int) -> Optional[Dict[str, Any]]:
149
+ """Get file info from the index by ID."""
150
+ row = conn.execute(
151
+ "SELECT id, path, absolute_path, language, content_hash, mtime FROM files WHERE id = ?",
152
+ (file_id,)
153
+ ).fetchone()
154
+ return dict(row) if row else None
155
+
156
+
157
+ def _resolve_file_path(file_info: Dict[str, Any]) -> Optional[str]:
158
+ """Resolve the actual file path from file info."""
159
+ abs_path = file_info.get("absolute_path", "")
160
+ if abs_path and os.path.exists(abs_path):
161
+ return abs_path
162
+
163
+ # Fallback: try cwd / relative path
164
+ rel_path = file_info.get("path", "")
165
+ for base in [os.getcwd(), os.path.join(os.getcwd(), "src")]:
166
+ candidate = os.path.join(base, rel_path)
167
+ if os.path.exists(candidate):
168
+ return candidate
169
+
170
+ return None
171
+
172
+
173
+ def _read_source_at_range(file_path: str, source_range: Dict[str, int]) -> Optional[str]:
174
+ """Read source text at a given line range from a file."""
175
+ try:
176
+ with open(file_path, "r", encoding="utf-8") as f:
177
+ lines = f.read().split("\n")
178
+ start = source_range.get("start_line", 1)
179
+ end = source_range.get("end_line", start)
180
+ if 1 <= start <= len(lines):
181
+ return "\n".join(lines[start - 1:end])
182
+ except Exception:
183
+ pass
184
+ return None
185
+
186
+
187
+ # ──────────────────────────────────────────────────────────────
188
+ # validate_edit_range
189
+ # ──────────────────────────────────────────────────────────────
190
+
191
+ def validate_edit_range(
192
+ conn: sqlite3.Connection,
193
+ entity_type: str,
194
+ entity_id: int,
195
+ file_path: str,
196
+ ) -> EditValidation:
197
+ """Validate that an edit to a frontend entity is safe.
198
+
199
+ Checks:
200
+ 1. File has not changed since indexing (content hash)
201
+ 2. Source range is not ambiguous
202
+ 3. Entity does not share an unsafe range with unrelated syntax
203
+ 4. Entity was not only heuristically identified
204
+ 5. Parser recovery does not prevent trustworthy boundaries
205
+
206
+ Args:
207
+ conn: SQLite connection to the index database.
208
+ entity_type: Type of entity ("markup_element", "css_rule", etc.)
209
+ entity_id: ID of the entity in its table.
210
+ file_path: Path to the file (project-relative or absolute).
211
+
212
+ Returns:
213
+ EditValidation with safe=True or safe=False + reason.
214
+ """
215
+ # 1. Fetch the entity
216
+ entity = _get_entity_row(conn, entity_type, entity_id)
217
+ if not entity:
218
+ return EditValidation(
219
+ safe=False,
220
+ reason=f"Entity not found: {entity_type}#{entity_id}",
221
+ entity_type=entity_type,
222
+ entity_id=entity_id,
223
+ )
224
+
225
+ # 2. Get file info
226
+ file_info = _get_file_info(conn, file_path)
227
+ if not file_info:
228
+ return EditValidation(
229
+ safe=False,
230
+ reason=f"File not found in index: {file_path}",
231
+ file_path=file_path,
232
+ entity_type=entity_type,
233
+ entity_id=entity_id,
234
+ )
235
+
236
+ # Resolve actual file path on disk
237
+ actual_path = _resolve_file_path(file_info)
238
+ if not actual_path or not os.path.exists(actual_path):
239
+ return EditValidation(
240
+ safe=False,
241
+ reason=f"File does not exist on disk: {file_path}",
242
+ file_path=file_path,
243
+ entity_type=entity_type,
244
+ entity_id=entity_id,
245
+ )
246
+
247
+ # 3. Check file has not changed since indexing (content hash)
248
+ current_hash = _compute_file_hash(actual_path)
249
+ indexed_hash = file_info.get("content_hash")
250
+ if indexed_hash and current_hash != indexed_hash:
251
+ return EditValidation(
252
+ safe=False,
253
+ reason=(
254
+ f"File has changed since indexing (indexed hash: {indexed_hash}, "
255
+ f"current hash: {current_hash}). Re-index before editing."
256
+ ),
257
+ file_path=actual_path,
258
+ entity_type=entity_type,
259
+ entity_id=entity_id,
260
+ )
261
+
262
+ # 4. Get source range
263
+ source_range = _parse_json(entity["source_range"]) if "source_range" in entity.keys() else None
264
+ if not source_range:
265
+ return EditValidation(
266
+ safe=False,
267
+ reason=f"Entity has no source range recorded: {entity_type}#{entity_id}",
268
+ file_path=actual_path,
269
+ entity_type=entity_type,
270
+ entity_id=entity_id,
271
+ )
272
+
273
+ # 5. Check source range is not ambiguous
274
+ start_line = source_range.get("start_line")
275
+ end_line = source_range.get("end_line")
276
+ if start_line is None or end_line is None:
277
+ return EditValidation(
278
+ safe=False,
279
+ reason=f"Source range is missing line boundaries: {source_range}",
280
+ file_path=actual_path,
281
+ source_range=source_range,
282
+ entity_type=entity_type,
283
+ entity_id=entity_id,
284
+ )
285
+
286
+ if end_line < start_line:
287
+ return EditValidation(
288
+ safe=False,
289
+ reason=f"Source range is inverted (end < start): {source_range}",
290
+ file_path=actual_path,
291
+ source_range=source_range,
292
+ entity_type=entity_type,
293
+ entity_id=entity_id,
294
+ )
295
+
296
+ # 6. Check entity was not only heuristically identified
297
+ # For events and bindings, check resolution_status
298
+ if entity_type == "event_binding" and "resolution_status" in entity.keys():
299
+ status = entity["resolution_status"]
300
+ if status == "heuristic":
301
+ return EditValidation(
302
+ safe=False,
303
+ reason=f"Event binding was only heuristically identified (resolution_status='heuristic')",
304
+ file_path=actual_path,
305
+ source_range=source_range,
306
+ entity_type=entity_type,
307
+ entity_id=entity_id,
308
+ )
309
+
310
+ if entity_type == "property_binding" and "resolution_status" in entity.keys():
311
+ status = entity["resolution_status"]
312
+ if status == "heuristic":
313
+ return EditValidation(
314
+ safe=False,
315
+ reason=f"Property binding was only heuristically identified (resolution_status='heuristic')",
316
+ file_path=actual_path,
317
+ source_range=source_range,
318
+ entity_type=entity_type,
319
+ entity_id=entity_id,
320
+ )
321
+
322
+ # For style_selector_matches, check match_type
323
+ if entity_type == "css_rule" and "match_type" in entity.keys():
324
+ match_type = entity["match_type"]
325
+ if match_type == "heuristic":
326
+ return EditValidation(
327
+ safe=False,
328
+ reason=f"CSS rule match was only heuristically identified (match_type='heuristic')",
329
+ file_path=actual_path,
330
+ source_range=source_range,
331
+ entity_type=entity_type,
332
+ entity_id=entity_id,
333
+ )
334
+
335
+ # 7. Check for parser recovery / diagnostics on this file
336
+ file_id = file_info["id"]
337
+ diagnostics = conn.execute(
338
+ "SELECT severity, message, source_range FROM frontend_diagnostics WHERE file_id = ? AND severity = 'error'",
339
+ (file_id,)
340
+ ).fetchall()
341
+
342
+ # Check if any error diagnostic overlaps with our entity's source range
343
+ for diag in diagnostics:
344
+ diag_range = _parse_json(diag["source_range"])
345
+ if diag_range and _ranges_overlap(source_range, diag_range):
346
+ return EditValidation(
347
+ safe=False,
348
+ reason=(
349
+ f"Parser recovery detected: file has error diagnostics overlapping "
350
+ f"this entity's source range. Message: {diag['message']}"
351
+ ),
352
+ file_path=actual_path,
353
+ source_range=source_range,
354
+ entity_type=entity_type,
355
+ entity_id=entity_id,
356
+ )
357
+
358
+ # 8. Check entity does not share an unsafe range with unrelated syntax
359
+ # For markup elements, check if the range is too broad (spans multiple elements)
360
+ if entity_type in _MARKUP_ENTITY_TYPES:
361
+ # Check if other markup elements share the same source range
362
+ elem_file_id = entity["file_id"] if "file_id" in entity.keys() else None
363
+ if elem_file_id:
364
+ overlapping = conn.execute(
365
+ """SELECT id, tag_name, element_type FROM markup_elements
366
+ WHERE file_id = ? AND id != ? AND source_range IS NOT NULL""",
367
+ (elem_file_id, entity_id)
368
+ ).fetchall()
369
+
370
+ for other in overlapping:
371
+ other_range = _parse_json(
372
+ conn.execute(
373
+ "SELECT source_range FROM markup_elements WHERE id = ?",
374
+ (other["id"],)
375
+ ).fetchone()["source_range"]
376
+ )
377
+ if other_range and _ranges_overlap(source_range, other_range):
378
+ # Overlap is expected for parent/child elements, but if the ranges
379
+ # are identical, that's ambiguous
380
+ if (source_range.get("start_line") == other_range.get("start_line") and
381
+ source_range.get("end_line") == other_range.get("end_line") and
382
+ source_range.get("start_column") == other_range.get("start_column") and
383
+ source_range.get("end_column") == other_range.get("end_column")):
384
+ return EditValidation(
385
+ safe=False,
386
+ reason=(
387
+ f"Source range is ambiguous: another markup element "
388
+ f"({other['tag_name']}#{other['id']}) has an identical range"
389
+ ),
390
+ file_path=actual_path,
391
+ source_range=source_range,
392
+ entity_type=entity_type,
393
+ entity_id=entity_id,
394
+ )
395
+
396
+ # All checks passed
397
+ return EditValidation(
398
+ safe=True,
399
+ file_path=actual_path,
400
+ source_range=source_range,
401
+ entity_type=entity_type,
402
+ entity_id=entity_id,
403
+ )
404
+
405
+
406
+ def _ranges_overlap(a: Dict[str, int], b: Dict[str, int]) -> bool:
407
+ """Check if two source ranges overlap (line-based)."""
408
+ a_start = a.get("start_line", 0)
409
+ a_end = a.get("end_line", 0)
410
+ b_start = b.get("start_line", 0)
411
+ b_end = b.get("end_line", 0)
412
+ return not (a_end < b_start or b_end < a_start)
413
+
414
+
415
+ # ──────────────────────────────────────────────────────────────
416
+ # apply_frontend_edit
417
+ # ──────────────────────────────────────────────────────────────
418
+
419
+ def apply_frontend_edit(
420
+ conn: sqlite3.Connection,
421
+ entity_type: str,
422
+ entity_id: int,
423
+ new_source: str,
424
+ file_path: str,
425
+ ) -> EditResult:
426
+ """Apply an edit to a frontend semantic entity.
427
+
428
+ 1. Validates the edit range (calls validate_edit_range)
429
+ 2. Reads current source text at the recorded range
430
+ 3. Replaces the source range with new text
431
+ 4. Verifies surrounding source is unchanged
432
+ 5. Returns diff + result
433
+
434
+ Args:
435
+ conn: SQLite connection to the index database.
436
+ entity_type: Type of entity ("markup_element", "css_rule", etc.)
437
+ entity_id: ID of the entity in its table.
438
+ new_source: New source text to replace the entity's range with.
439
+ file_path: Path to the file (project-relative or absolute).
440
+
441
+ Returns:
442
+ EditResult with success=True/False and details.
443
+ """
444
+ # 1. Validate
445
+ validation = validate_edit_range(conn, entity_type, entity_id, file_path)
446
+ if not validation.safe:
447
+ return EditResult(
448
+ success=False,
449
+ reason=validation.reason,
450
+ file_path=validation.file_path or file_path,
451
+ )
452
+
453
+ actual_path = validation.file_path
454
+ source_range = validation.source_range
455
+
456
+ # 2. Read the full file
457
+ try:
458
+ with open(actual_path, "r", encoding="utf-8") as f:
459
+ content = f.read()
460
+ except Exception as e:
461
+ return EditResult(
462
+ success=False,
463
+ reason=f"Failed to read file: {e}",
464
+ file_path=actual_path,
465
+ )
466
+
467
+ lines = content.split("\n")
468
+ start_line = source_range["start_line"]
469
+ end_line = source_range["end_line"]
470
+
471
+ # 3. Extract old source text
472
+ old_lines = lines[start_line - 1:end_line]
473
+ old_source = "\n".join(old_lines)
474
+
475
+ # 4. Replace lines
476
+ new_lines = new_source.split("\n")
477
+ new_file_lines = lines[:start_line - 1] + new_lines + lines[end_line:]
478
+ new_content = "\n".join(new_file_lines)
479
+
480
+ # 5. Verify surrounding source is unchanged
481
+ # Check lines before the edit
482
+ before_old = lines[:start_line - 1]
483
+ before_new = new_file_lines[:start_line - 1]
484
+ if before_old != before_new:
485
+ return EditResult(
486
+ success=False,
487
+ reason="Safety check failed: source before edit range was corrupted",
488
+ file_path=actual_path,
489
+ source_range=source_range,
490
+ )
491
+
492
+ # Check lines after the edit
493
+ after_old = lines[end_line:]
494
+ after_new = new_file_lines[start_line - 1 + len(new_lines):]
495
+ if after_old != after_new:
496
+ return EditResult(
497
+ success=False,
498
+ reason="Safety check failed: source after edit range was corrupted",
499
+ file_path=actual_path,
500
+ source_range=source_range,
501
+ )
502
+
503
+ # 6. Write the file (atomic-ish: write to temp then rename)
504
+ try:
505
+ tmp_path = actual_path + ".raggie_tmp"
506
+ with open(tmp_path, "w", encoding="utf-8") as f:
507
+ f.write(new_content)
508
+ os.replace(tmp_path, actual_path)
509
+ except Exception as e:
510
+ # Clean up temp file if write failed
511
+ try:
512
+ if os.path.exists(tmp_path):
513
+ os.remove(tmp_path)
514
+ except Exception:
515
+ pass
516
+ return EditResult(
517
+ success=False,
518
+ reason=f"Failed to write file: {e}",
519
+ file_path=actual_path,
520
+ source_range=source_range,
521
+ )
522
+
523
+ # 7. Verify the file was written correctly
524
+ try:
525
+ with open(actual_path, "r", encoding="utf-8") as f:
526
+ written = f.read()
527
+ if written != new_content:
528
+ return EditResult(
529
+ success=False,
530
+ reason="Write verification failed: file content does not match expected",
531
+ file_path=actual_path,
532
+ source_range=source_range,
533
+ )
534
+ except Exception as e:
535
+ return EditResult(
536
+ success=False,
537
+ reason=f"Write verification failed: {e}",
538
+ file_path=actual_path,
539
+ source_range=source_range,
540
+ )
541
+
542
+ # 8. Build diff
543
+ CONTEXT = 5
544
+ ctx_start = max(0, start_line - 1 - CONTEXT)
545
+ ctx_end = min(len(lines), end_line + CONTEXT)
546
+
547
+ diff_lines = [
548
+ f"@@ {actual_path}:{start_line}-{end_line} ({len(old_lines)} lines "
549
+ f"-> {len(new_lines)} lines) @@",
550
+ ]
551
+
552
+ for i in range(ctx_start, start_line - 1):
553
+ diff_lines.append(f" {lines[i]}")
554
+
555
+ for line in old_lines:
556
+ diff_lines.append(f" - {line}")
557
+
558
+ for line in new_lines:
559
+ diff_lines.append(f" + {line}")
560
+
561
+ for i in range(end_line, ctx_end):
562
+ diff_lines.append(f" {lines[i]}")
563
+
564
+ diff_text = "\n".join(diff_lines)
565
+
566
+ return EditResult(
567
+ success=True,
568
+ file_path=actual_path,
569
+ old_source=old_source,
570
+ new_source=new_source,
571
+ diff=diff_text,
572
+ source_range=source_range,
573
+ )