codeframe-ai 0.9.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 (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,556 @@
1
+ """Search-and-replace editor with 4-level fuzzy matching.
2
+
3
+ Levels:
4
+ 1. Exact match
5
+ 2. Whitespace-normalized (collapse spaces/tabs)
6
+ 3. Indentation-agnostic (strip leading whitespace per line)
7
+ 4. Fuzzy (rapidfuzz line-boundary sliding window, threshold configurable)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import difflib
13
+ import os
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from rapidfuzz import fuzz
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Data models
22
+ # ---------------------------------------------------------------------------
23
+
24
+
25
+ @dataclass
26
+ class EditOperation:
27
+ search: str
28
+ replace: str
29
+ description: str | None = None
30
+
31
+
32
+ @dataclass
33
+ class MatchResult:
34
+ success: bool
35
+ match_level: int
36
+ match_level_name: str
37
+ start_pos: int
38
+ end_pos: int
39
+ matched_text: str
40
+ match_count: int = 1
41
+
42
+
43
+ @dataclass
44
+ class EditResult:
45
+ success: bool
46
+ file_path: str
47
+ diff: str | None = None
48
+ error: str | None = None
49
+ failed_edit: EditOperation | None = None
50
+ context: str | None = None
51
+ applied_edits: int = 0
52
+ match_results: list[MatchResult] | None = None
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Helpers
57
+ # ---------------------------------------------------------------------------
58
+
59
+ _LEVEL_NAMES = {0: "none", 1: "exact", 2: "whitespace-normalized",
60
+ 3: "indentation-agnostic", 4: "fuzzy"}
61
+
62
+
63
+ def _normalize_whitespace(text: str) -> tuple[str, list[int]]:
64
+ """Collapse runs of horizontal whitespace to single space.
65
+
66
+ Returns (normalized_text, position_map) where ``position_map[i]`` is the
67
+ index in *text* that produced ``normalized_text[i]``.
68
+ """
69
+ result: list[str] = []
70
+ pos_map: list[int] = []
71
+ in_run = False
72
+ for i, ch in enumerate(text):
73
+ if ch in (" ", "\t"):
74
+ if not in_run:
75
+ result.append(" ")
76
+ pos_map.append(i)
77
+ in_run = True
78
+ else:
79
+ result.append(ch)
80
+ pos_map.append(i)
81
+ in_run = False
82
+ return "".join(result), pos_map
83
+
84
+
85
+ def _strip_leading_per_line(text: str) -> str:
86
+ """Strip leading whitespace from every line."""
87
+ return "\n".join(line.lstrip() for line in text.splitlines())
88
+
89
+
90
+ def _leading_ws(line: str) -> str:
91
+ """Return the leading whitespace of *line*."""
92
+ return line[: len(line) - len(line.lstrip())]
93
+
94
+
95
+ def _count_occurrences(content: str, search: str) -> int:
96
+ """Count non-overlapping exact occurrences of *search* in *content*."""
97
+ count = 0
98
+ start = 0
99
+ while True:
100
+ idx = content.find(search, start)
101
+ if idx == -1:
102
+ break
103
+ count += 1
104
+ start = idx + len(search)
105
+ return count
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # SearchReplaceEditor
110
+ # ---------------------------------------------------------------------------
111
+
112
+
113
+ class SearchReplaceEditor:
114
+ """Apply search-and-replace edits with 4-level fuzzy matching."""
115
+
116
+ def __init__(
117
+ self,
118
+ preserve_indentation: bool = True,
119
+ fuzzy_threshold: float = 0.85,
120
+ ) -> None:
121
+ self.preserve_indentation = preserve_indentation
122
+ self.fuzzy_threshold = fuzzy_threshold
123
+
124
+ # -- public API --------------------------------------------------------
125
+
126
+ def apply_edits(
127
+ self, file_path: str, edits: list[EditOperation]
128
+ ) -> EditResult:
129
+ fp = Path(file_path)
130
+
131
+ # Validate inputs
132
+ if not fp.exists():
133
+ return EditResult(
134
+ success=False,
135
+ file_path=file_path,
136
+ error=f"File not found: {file_path}",
137
+ )
138
+
139
+ for op in edits:
140
+ if not op.search:
141
+ return EditResult(
142
+ success=False,
143
+ file_path=file_path,
144
+ error="Empty search string is not allowed",
145
+ failed_edit=op,
146
+ )
147
+
148
+ # No edits is a no-op success
149
+ if not edits:
150
+ return EditResult(success=True, file_path=file_path, applied_edits=0)
151
+
152
+ encoding = "utf-8"
153
+ try:
154
+ original_content = fp.read_text(encoding=encoding)
155
+ except UnicodeDecodeError:
156
+ encoding = "latin-1"
157
+ original_content = fp.read_text(encoding=encoding)
158
+
159
+ content = original_content
160
+ applied = 0
161
+ matches: list[MatchResult] = []
162
+
163
+ for op in edits:
164
+ match = self._find_match(content, op.search)
165
+ matches.append(match)
166
+ if not match.success:
167
+ ctx = self._generate_error_context(
168
+ content, op.search, os.path.basename(file_path)
169
+ )
170
+ return EditResult(
171
+ success=False,
172
+ file_path=file_path,
173
+ error="No match found",
174
+ failed_edit=op,
175
+ context=ctx,
176
+ applied_edits=applied,
177
+ match_results=matches,
178
+ )
179
+
180
+ # Reject ambiguous matches — search must be unique
181
+ if match.match_count > 1:
182
+ return EditResult(
183
+ success=False,
184
+ file_path=file_path,
185
+ error=(
186
+ f"Multiple matches found ({match.match_count} occurrences). "
187
+ "Include more surrounding context in the search block "
188
+ "to uniquely identify the target."
189
+ ),
190
+ failed_edit=op,
191
+ applied_edits=applied,
192
+ match_results=matches,
193
+ )
194
+
195
+ replacement = op.replace
196
+ if self.preserve_indentation and match.match_level >= 2:
197
+ replacement = self._apply_indentation(
198
+ match.matched_text, replacement, content, match.start_pos
199
+ )
200
+
201
+ content = (
202
+ content[: match.start_pos]
203
+ + replacement
204
+ + content[match.end_pos :]
205
+ )
206
+ applied += 1
207
+
208
+ # Generate diff
209
+ diff = "".join(
210
+ difflib.unified_diff(
211
+ original_content.splitlines(keepends=True),
212
+ content.splitlines(keepends=True),
213
+ fromfile=f"a/{os.path.basename(file_path)}",
214
+ tofile=f"b/{os.path.basename(file_path)}",
215
+ )
216
+ )
217
+
218
+ fp.write_text(content, encoding=encoding)
219
+
220
+ return EditResult(
221
+ success=True,
222
+ file_path=file_path,
223
+ diff=diff if diff else None,
224
+ applied_edits=applied,
225
+ match_results=matches,
226
+ )
227
+
228
+ # -- matching ----------------------------------------------------------
229
+
230
+ def _find_match(self, content: str, search: str) -> MatchResult:
231
+ """Try 4 match levels in order, returning the first success."""
232
+
233
+ # Level 1: exact
234
+ result = self._match_exact(content, search)
235
+ if result.success:
236
+ return result
237
+
238
+ # Level 2: whitespace-normalized
239
+ result = self._match_whitespace(content, search)
240
+ if result.success:
241
+ return result
242
+
243
+ # Level 3: indentation-agnostic
244
+ result = self._match_indentation(content, search)
245
+ if result.success:
246
+ return result
247
+
248
+ # Level 4: fuzzy (line-boundary sliding window)
249
+ result = self._match_fuzzy(content, search)
250
+ if result.success:
251
+ return result
252
+
253
+ return MatchResult(
254
+ success=False,
255
+ match_level=0,
256
+ match_level_name="none",
257
+ start_pos=-1,
258
+ end_pos=-1,
259
+ matched_text="",
260
+ )
261
+
262
+ def _match_exact(self, content: str, search: str) -> MatchResult:
263
+ idx = content.find(search)
264
+ if idx == -1:
265
+ return self._fail()
266
+ count = _count_occurrences(content, search)
267
+ return MatchResult(
268
+ success=True,
269
+ match_level=1,
270
+ match_level_name="exact",
271
+ start_pos=idx,
272
+ end_pos=idx + len(search),
273
+ matched_text=content[idx : idx + len(search)],
274
+ match_count=count,
275
+ )
276
+
277
+ def _match_whitespace(self, content: str, search: str) -> MatchResult:
278
+ norm_search, _ = _normalize_whitespace(search)
279
+ norm_content, pos_map = _normalize_whitespace(content)
280
+
281
+ idx = norm_content.find(norm_search)
282
+ if idx == -1:
283
+ return self._fail()
284
+
285
+ orig_start = pos_map[idx]
286
+ end_idx = idx + len(norm_search)
287
+ if end_idx < len(pos_map):
288
+ orig_end = pos_map[end_idx]
289
+ else:
290
+ orig_end = len(content)
291
+
292
+ matched = content[orig_start:orig_end]
293
+
294
+ # Count occurrences in normalized space
295
+ count = 0
296
+ start = 0
297
+ while True:
298
+ i = norm_content.find(norm_search, start)
299
+ if i == -1:
300
+ break
301
+ count += 1
302
+ start = i + len(norm_search)
303
+
304
+ return MatchResult(
305
+ success=True,
306
+ match_level=2,
307
+ match_level_name="whitespace-normalized",
308
+ start_pos=orig_start,
309
+ end_pos=orig_end,
310
+ matched_text=matched,
311
+ match_count=count,
312
+ )
313
+
314
+ def _match_indentation(self, content: str, search: str) -> MatchResult:
315
+ """Level 3: strip leading whitespace per line and compare."""
316
+ search_lines = search.splitlines()
317
+ content_lines = content.splitlines()
318
+ n = len(search_lines)
319
+
320
+ if n == 0:
321
+ return self._fail()
322
+
323
+ stripped_search = [line.lstrip() for line in search_lines]
324
+
325
+ for i in range(len(content_lines) - n + 1):
326
+ window = content_lines[i : i + n]
327
+ stripped_window = [line.lstrip() for line in window]
328
+ if stripped_window == stripped_search:
329
+ # Compute positions in original content
330
+ start_pos = self._line_offset(content, i)
331
+ end_pos = self._line_offset(content, i + n)
332
+ # Trim trailing newline from end_pos if at file end
333
+ if end_pos > len(content):
334
+ end_pos = len(content)
335
+ matched = content[start_pos:end_pos]
336
+ # Remove trailing newline from matched text for clean replacement
337
+ # (the newline after the last line is not part of the match)
338
+ if matched.endswith("\n") and not search.endswith("\n"):
339
+ end_pos -= 1
340
+ matched = content[start_pos:end_pos]
341
+
342
+ count = self._count_indentation_matches(
343
+ content_lines, stripped_search
344
+ )
345
+ return MatchResult(
346
+ success=True,
347
+ match_level=3,
348
+ match_level_name="indentation-agnostic",
349
+ start_pos=start_pos,
350
+ end_pos=end_pos,
351
+ matched_text=content[start_pos:end_pos],
352
+ match_count=count,
353
+ )
354
+
355
+ return self._fail()
356
+
357
+ def _match_fuzzy(self, content: str, search: str) -> MatchResult:
358
+ """Level 4: line-boundary sliding window with rapidfuzz."""
359
+ search_lines = search.splitlines()
360
+ content_lines = content.splitlines()
361
+ n = len(search_lines)
362
+
363
+ if n == 0 or n > len(content_lines):
364
+ return self._fail()
365
+
366
+ best_ratio = 0.0
367
+ best_i = -1
368
+
369
+ for i in range(len(content_lines) - n + 1):
370
+ window = "\n".join(content_lines[i : i + n])
371
+ ratio = fuzz.ratio(search, window) / 100.0
372
+ if ratio > best_ratio:
373
+ best_ratio = ratio
374
+ best_i = i
375
+
376
+ if best_ratio < self.fuzzy_threshold:
377
+ return self._fail()
378
+
379
+ start_pos = self._line_offset(content, best_i)
380
+ end_pos = self._line_offset(content, best_i + n)
381
+ if end_pos > len(content):
382
+ end_pos = len(content)
383
+ matched = content[start_pos:end_pos]
384
+ if matched.endswith("\n") and not search.endswith("\n"):
385
+ end_pos -= 1
386
+ matched = content[start_pos:end_pos]
387
+
388
+ return MatchResult(
389
+ success=True,
390
+ match_level=4,
391
+ match_level_name="fuzzy",
392
+ start_pos=start_pos,
393
+ end_pos=end_pos,
394
+ matched_text=content[start_pos:end_pos],
395
+ match_count=1,
396
+ )
397
+
398
+ # -- indentation -------------------------------------------------------
399
+
400
+ def _apply_indentation(
401
+ self,
402
+ matched_text: str,
403
+ replacement: str,
404
+ content: str = "",
405
+ start_pos: int = 0,
406
+ ) -> str:
407
+ """Adjust replacement indentation to match original context.
408
+
409
+ Detects the actual line indentation in the file by looking back from
410
+ *start_pos* to the beginning of the line. For the first replacement
411
+ line no indent is prepended (the file already has the leading
412
+ whitespace before *start_pos*). For subsequent lines the indent
413
+ delta between original and replacement base indentation is applied.
414
+ """
415
+ matched_lines = matched_text.splitlines()
416
+ replace_lines = replacement.splitlines()
417
+
418
+ if not matched_lines or not replace_lines:
419
+ return replacement
420
+
421
+ # Determine the indentation of the line in the file where the match
422
+ # starts. This is the whitespace between the last newline before
423
+ # start_pos and start_pos itself.
424
+ file_indent = ""
425
+ if content:
426
+ line_start = content.rfind("\n", 0, start_pos)
427
+ line_start = 0 if line_start == -1 else line_start + 1
428
+ file_indent = content[line_start:start_pos]
429
+ # file_indent is only valid if it is purely whitespace
430
+ if file_indent and not file_indent.isspace():
431
+ file_indent = ""
432
+
433
+ # The matched text's first line indent (from the matched span itself)
434
+ matched_first_indent = _leading_ws(matched_lines[0])
435
+ replace_first_indent = _leading_ws(replace_lines[0])
436
+
437
+ # The "original base" is the full indent that the first line has
438
+ # in the actual file. It combines the whitespace before start_pos
439
+ # (file_indent) with any leading whitespace in the matched text.
440
+ original_base = file_indent + matched_first_indent
441
+ replace_base = replace_first_indent
442
+
443
+ # The first line of the replacement is inserted at start_pos.
444
+ # If file_indent is non-empty, the file already has indentation
445
+ # BEFORE start_pos, so we must NOT duplicate it on the first line.
446
+ # However, if the match itself included leading whitespace
447
+ # (matched_first_indent is non-empty), we DO need to handle that.
448
+ #
449
+ # For subsequent lines, the full original_base indent is applied.
450
+
451
+ # Single-line case
452
+ if len(replace_lines) == 1:
453
+ stripped = replace_lines[0]
454
+ if stripped.startswith(replace_base):
455
+ stripped = stripped[len(replace_base):]
456
+ else:
457
+ stripped = stripped.lstrip()
458
+ if file_indent:
459
+ # File already has indent before start_pos
460
+ return matched_first_indent + stripped
461
+ return original_base + stripped
462
+
463
+ # Multi-line case
464
+ result_lines: list[str] = []
465
+ for i, line in enumerate(replace_lines):
466
+ if line.startswith(replace_base):
467
+ relative = line[len(replace_base):]
468
+ else:
469
+ relative = line.lstrip()
470
+ if i == 0 and file_indent:
471
+ # First line: file already has indent before start_pos
472
+ result_lines.append(matched_first_indent + relative)
473
+ else:
474
+ result_lines.append(original_base + relative)
475
+
476
+ result = "\n".join(result_lines)
477
+ # Preserve trailing newline if the original replacement had one
478
+ if replacement.endswith("\n") and not result.endswith("\n"):
479
+ result += "\n"
480
+ return result
481
+
482
+ # -- error context -----------------------------------------------------
483
+
484
+ def _generate_error_context(
485
+ self, content: str, search: str, filename: str
486
+ ) -> str:
487
+ """Generate helpful error context when a match fails."""
488
+ lines = content.splitlines()
489
+ if not lines:
490
+ return (
491
+ f"EDIT FAILED: No match found for search block in {filename}.\n"
492
+ "The file is empty.\n"
493
+ "Please retry with the actual content from the file."
494
+ )
495
+
496
+ # Try to find the most similar region using fuzzy matching
497
+ search_lines = search.splitlines()
498
+ n = max(len(search_lines), 1)
499
+ best_ratio = 0.0
500
+ best_i = 0
501
+
502
+ for i in range(max(len(lines) - n + 1, 1)):
503
+ window = "\n".join(lines[i : i + n])
504
+ ratio = fuzz.ratio(search, window) / 100.0
505
+ if ratio > best_ratio:
506
+ best_ratio = ratio
507
+ best_i = i
508
+
509
+ # Show ~5 lines around the best match
510
+ start = max(0, best_i - 1)
511
+ end = min(len(lines), best_i + n + 2)
512
+ nearby = []
513
+ for j in range(start, end):
514
+ nearby.append(f" Line {j + 1}: {lines[j]}")
515
+
516
+ return (
517
+ f"EDIT FAILED: No match found for search block in {filename}.\n"
518
+ "The file contains these similar lines near the expected location:\n"
519
+ + "\n".join(nearby)
520
+ + "\nPlease retry with the actual content from the file."
521
+ )
522
+
523
+ # -- utilities ---------------------------------------------------------
524
+
525
+ @staticmethod
526
+ def _line_offset(content: str, line_index: int) -> int:
527
+ """Return the character offset of *line_index* in *content*."""
528
+ offset = 0
529
+ for i, line in enumerate(content.splitlines(keepends=True)):
530
+ if i == line_index:
531
+ return offset
532
+ offset += len(line)
533
+ return offset
534
+
535
+ @staticmethod
536
+ def _count_indentation_matches(
537
+ content_lines: list[str], stripped_search: list[str]
538
+ ) -> int:
539
+ n = len(stripped_search)
540
+ count = 0
541
+ for i in range(len(content_lines) - n + 1):
542
+ window = [line.lstrip() for line in content_lines[i : i + n]]
543
+ if window == stripped_search:
544
+ count += 1
545
+ return count
546
+
547
+ @staticmethod
548
+ def _fail() -> MatchResult:
549
+ return MatchResult(
550
+ success=False,
551
+ match_level=0,
552
+ match_level_name="none",
553
+ start_pos=-1,
554
+ end_pos=-1,
555
+ matched_text="",
556
+ )