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.
- Agent/__init__.py +0 -0
- Agent/agent.py +891 -0
- Agent/chat_history_db.py +1500 -0
- Agent/command.py +49 -0
- Agent/config.py +46 -0
- Agent/effort_levels.py +33 -0
- Agent/git_manager.py +727 -0
- Agent/tools.py +35 -0
- Commands/__init__.py +18 -0
- Commands/effort.py +42 -0
- Commands/global_todo.py +23 -0
- Commands/help.py +22 -0
- Commands/reasoning.py +24 -0
- Commands/redo.py +11 -0
- Commands/reindex.py +27 -0
- Commands/shell.py +28 -0
- Commands/stream.py +24 -0
- Commands/undo.py +13 -0
- Commands/unlimited_effort.py +8 -0
- Commands/window_size.py +29 -0
- RAG/__init__.py +0 -0
- RAG/document.py +119 -0
- RAG/find.py +408 -0
- RAG/graph.py +231 -0
- Tools/GetFileCodeStructure.py +43 -0
- Tools/GetSymbolSourceCode.py +27 -0
- Tools/__init__.py +39 -0
- Tools/ask_user.py +102 -0
- Tools/dispatch_subagent.py +215 -0
- Tools/document.py +35 -0
- Tools/edit_symbol.py +250 -0
- Tools/fuzzy_search.py +119 -0
- Tools/list_dir.py +51 -0
- Tools/read.py +49 -0
- Tools/read_image.py +75 -0
- Tools/remove.py +75 -0
- Tools/replace.py +305 -0
- Tools/search.py +41 -0
- Tools/shell.py +149 -0
- Tools/shell_kill.py +87 -0
- Tools/temp_background_service.py +113 -0
- Tools/todo_list.py +481 -0
- Tools/utils.py +116 -0
- Tools/view_changes.py +179 -0
- Tools/walk_call_tree.py +30 -0
- Tools/web_fetch.py +175 -0
- Tools/web_search.py +69 -0
- Tools/write.py +48 -0
- cli.py +111 -0
- config/__init__.py +0 -0
- config/coder_system_prompt.md +119 -0
- config/roles.json +43 -0
- config/tools.json +709 -0
- indexing/__init__.py +0 -0
- indexing/cli.py +128 -0
- indexing/code_index_sdk.py +832 -0
- indexing/code_indexer.py +1763 -0
- indexing/db_schema.py +396 -0
- indexing/export_to_json.py +346 -0
- indexing/extractors.py +189 -0
- indexing/file_utils.py +97 -0
- indexing/frontend/__init__.py +0 -0
- indexing/frontend/css_extractor.py +195 -0
- indexing/frontend/css_parser.py +387 -0
- indexing/frontend/css_selector_utils.py +226 -0
- indexing/frontend/edit_safety.py +573 -0
- indexing/frontend/graph.py +838 -0
- indexing/frontend/html_extractor.py +496 -0
- indexing/frontend/html_parser.py +314 -0
- indexing/frontend/jsx_extractor.py +1204 -0
- indexing/frontend/location_lookup.py +247 -0
- indexing/frontend/resolver.py +485 -0
- indexing/frontend/runtime_resolver.py +862 -0
- indexing/frontend/semantic_output.py +705 -0
- indexing/frontend/source_location.py +69 -0
- indexing/frontend_config.py +72 -0
- indexing/frontend_models.py +347 -0
- indexing/language_config.py +360 -0
- indexing/models.py +284 -0
- indexing/node_utils.py +1112 -0
- indexing/parse_worker.py +1082 -0
- indexing/queries.py +1542 -0
- indexing/sdk_examples.py +426 -0
- interactive.py +248 -0
- raggie.py +673 -0
- raggiecode-0.2.1.dist-info/METADATA +944 -0
- raggiecode-0.2.1.dist-info/RECORD +93 -0
- raggiecode-0.2.1.dist-info/WHEEL +5 -0
- raggiecode-0.2.1.dist-info/entry_points.txt +2 -0
- raggiecode-0.2.1.dist-info/top_level.txt +10 -0
- skills/__init__.py +3 -0
- skills/manager.py +114 -0
- 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
|
+
)
|