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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
codeframe/core/editor.py
ADDED
|
@@ -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
|
+
)
|