codeui-python 0.1.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.
- codeui/__init__.py +23 -0
- codeui/agent/__init__.py +15 -0
- codeui/agent/context.py +207 -0
- codeui/agent/contribution.py +224 -0
- codeui/agent/edit_tools.py +280 -0
- codeui/agent/integrations.py +243 -0
- codeui/agent/mcp_server.py +94 -0
- codeui/analysis/__init__.py +1 -0
- codeui/analysis/api_drift.py +79 -0
- codeui/analysis/base.py +42 -0
- codeui/analysis/circular.py +116 -0
- codeui/analysis/dead_code.py +63 -0
- codeui/analysis/duplicates.py +69 -0
- codeui/analysis/runner.py +57 -0
- codeui/analysis/shadowing.py +49 -0
- codeui/analysis/undefined.py +97 -0
- codeui/analysis/unreachable.py +28 -0
- codeui/analysis/unresolved_import.py +153 -0
- codeui/cli/__init__.py +1 -0
- codeui/cli/main.py +437 -0
- codeui/core/__init__.py +1 -0
- codeui/core/cache.py +135 -0
- codeui/core/graph.py +435 -0
- codeui/core/ir.py +218 -0
- codeui/core/override.py +230 -0
- codeui/core/repo.py +85 -0
- codeui/core/resolver.py +281 -0
- codeui/errors.py +98 -0
- codeui/lang/__init__.py +1 -0
- codeui/lang/base.py +117 -0
- codeui/lang/generic.py +90 -0
- codeui/lang/go.py +167 -0
- codeui/lang/python.py +294 -0
- codeui/lang/registry.py +83 -0
- codeui/lang/rust.py +185 -0
- codeui/lang/ts.py +396 -0
- codeui/plugins/__init__.py +1 -0
- codeui/plugins/registry.py +31 -0
- codeui/report/__init__.py +1 -0
- codeui/report/json_emitter.py +24 -0
- codeui/report/markdown_emitter.py +33 -0
- codeui/report/sarif_emitter.py +56 -0
- codeui/server/__init__.py +1 -0
- codeui/server/server.py +705 -0
- codeui/tracer/__init__.py +1 -0
- codeui/tracer/tracer.py +147 -0
- codeui_python-0.1.0.dist-info/METADATA +191 -0
- codeui_python-0.1.0.dist-info/RECORD +52 -0
- codeui_python-0.1.0.dist-info/WHEEL +5 -0
- codeui_python-0.1.0.dist-info/entry_points.txt +2 -0
- codeui_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- codeui_python-0.1.0.dist-info/top_level.txt +1 -0
codeui/server/server.py
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
"""Localhost HTTP server for codeui interactive UI and REST API."""
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import zipfile
|
|
9
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
12
|
+
from codeui.core.graph import Graph
|
|
13
|
+
from codeui.core.ir import SymbolKind
|
|
14
|
+
from codeui.core.override import OverrideStore, OverrideKind
|
|
15
|
+
from codeui.errors import SecurityError, SymbolNotFoundError
|
|
16
|
+
from codeui.report.json_emitter import JSONEmitter
|
|
17
|
+
from codeui.report.sarif_emitter import SARIFEmitter
|
|
18
|
+
|
|
19
|
+
class CodeUIHTTPRequestHandler(BaseHTTPRequestHandler):
|
|
20
|
+
"""HTTP Request Handler for codeui REST endpoints and web UI.
|
|
21
|
+
Example:
|
|
22
|
+
>>> handler = CodeUIHTTPRequestHandler
|
|
23
|
+
>>> handler.server_version
|
|
24
|
+
'BaseHTTP/0.6'
|
|
25
|
+
"""
|
|
26
|
+
CSRF_TOKEN = "codeui-local-csrf-token"
|
|
27
|
+
graph: Graph = None
|
|
28
|
+
override_store: OverrideStore = None
|
|
29
|
+
project_root: Path = None
|
|
30
|
+
rescan_fn: Optional[Callable[..., Graph]] = None
|
|
31
|
+
entry_points: Optional[List[str]] = None
|
|
32
|
+
layer_rules: Optional[Dict[str, Any]] = None
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def execute_rescan(cls) -> None:
|
|
36
|
+
"""Safely execute project rescan callback preserving entry points and layer rules."""
|
|
37
|
+
fn = getattr(cls, "rescan_fn", None)
|
|
38
|
+
if fn is None:
|
|
39
|
+
return
|
|
40
|
+
if isinstance(fn, staticmethod):
|
|
41
|
+
fn = fn.__func__
|
|
42
|
+
if cls.project_root:
|
|
43
|
+
existing_entries = list(cls.entry_points) if cls.entry_points else (list(cls.graph._entry_points) if cls.graph and hasattr(cls.graph, "_entry_points") else None)
|
|
44
|
+
existing_rules = dict(cls.layer_rules) if cls.layer_rules else (dict(cls.graph._layer_rules) if cls.graph and hasattr(cls.graph, "_layer_rules") else None)
|
|
45
|
+
try:
|
|
46
|
+
new_graph = fn(cls.project_root, entry_points=existing_entries, layer_rules=existing_rules)
|
|
47
|
+
except TypeError:
|
|
48
|
+
try:
|
|
49
|
+
new_graph = fn(cls.project_root, entry_points=existing_entries)
|
|
50
|
+
except TypeError:
|
|
51
|
+
new_graph = fn(cls.project_root)
|
|
52
|
+
if new_graph is not None:
|
|
53
|
+
if existing_entries:
|
|
54
|
+
new_graph.set_entry_points(existing_entries)
|
|
55
|
+
if existing_rules:
|
|
56
|
+
new_graph.set_layer_rules(existing_rules)
|
|
57
|
+
cls.graph = new_graph
|
|
58
|
+
|
|
59
|
+
def do_GET(self) -> None:
|
|
60
|
+
"""Handle GET requests for UI static assets and REST endpoints.
|
|
61
|
+
Example:
|
|
62
|
+
>>> handler = CodeUIHTTPRequestHandler
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
self._handle_get()
|
|
66
|
+
except Exception as e:
|
|
67
|
+
self._send_error(500, f"Internal server error: {e}")
|
|
68
|
+
|
|
69
|
+
def _handle_get(self) -> None:
|
|
70
|
+
parsed_url = urllib.parse.urlparse(self.path)
|
|
71
|
+
path = parsed_url.path
|
|
72
|
+
query = urllib.parse.parse_qs(parsed_url.query)
|
|
73
|
+
|
|
74
|
+
if path in ("/", "/index.html"):
|
|
75
|
+
self._serve_static_ui()
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
if path == "/health":
|
|
79
|
+
self._send_json({"status": "ok"})
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
if path.startswith("/static/"):
|
|
83
|
+
rel_path = path[len("/static/"):].split("?", 1)[0]
|
|
84
|
+
static_root = (Path(__file__).parent / "static").resolve()
|
|
85
|
+
try:
|
|
86
|
+
safe_path = self._safe_rel_path(rel_path)
|
|
87
|
+
if not safe_path:
|
|
88
|
+
raise SecurityError("Invalid static asset path")
|
|
89
|
+
full_path = (static_root / safe_path).resolve()
|
|
90
|
+
if not str(full_path).startswith(str(static_root)):
|
|
91
|
+
raise SecurityError("Asset path traversal outside static root blocked")
|
|
92
|
+
if not full_path.exists() or not full_path.is_file():
|
|
93
|
+
self._send_error(404, f"Static asset not found: {rel_path}")
|
|
94
|
+
return
|
|
95
|
+
data = full_path.read_bytes()
|
|
96
|
+
ctype = "application/octet-stream"
|
|
97
|
+
if safe_path.endswith(".css"):
|
|
98
|
+
ctype = "text/css; charset=utf-8"
|
|
99
|
+
elif safe_path.endswith(".js"):
|
|
100
|
+
ctype = "application/javascript; charset=utf-8"
|
|
101
|
+
elif safe_path.endswith(".html"):
|
|
102
|
+
ctype = "text/html; charset=utf-8"
|
|
103
|
+
elif safe_path.endswith(".json"):
|
|
104
|
+
ctype = "application/json; charset=utf-8"
|
|
105
|
+
elif safe_path.endswith(".svg"):
|
|
106
|
+
ctype = "image/svg+xml"
|
|
107
|
+
elif safe_path.endswith(".png"):
|
|
108
|
+
ctype = "image/png"
|
|
109
|
+
self._send_bytes(200, ctype, data)
|
|
110
|
+
except SecurityError as se:
|
|
111
|
+
self._send_error(403, str(se))
|
|
112
|
+
except Exception as e:
|
|
113
|
+
self._send_error(400, str(e))
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
if path.startswith("/asset/"):
|
|
117
|
+
rel_path = path[len("/asset/"):].split("?", 1)[0]
|
|
118
|
+
try:
|
|
119
|
+
safe_path = self._safe_rel_path(rel_path)
|
|
120
|
+
if not safe_path:
|
|
121
|
+
raise SecurityError("Invalid asset path")
|
|
122
|
+
full_path = (self.project_root / safe_path).resolve()
|
|
123
|
+
if not str(full_path).startswith(str(self.project_root.resolve())):
|
|
124
|
+
raise SecurityError("Asset path traversal outside project root blocked")
|
|
125
|
+
if not full_path.exists() or not full_path.is_file():
|
|
126
|
+
self._send_error(404, f"Asset not found: {rel_path}")
|
|
127
|
+
return
|
|
128
|
+
data = full_path.read_bytes()
|
|
129
|
+
ctype = "application/octet-stream"
|
|
130
|
+
if safe_path.endswith(".png"):
|
|
131
|
+
ctype = "image/png"
|
|
132
|
+
elif safe_path.endswith((".jpg", ".jpeg")):
|
|
133
|
+
ctype = "image/jpeg"
|
|
134
|
+
elif safe_path.endswith(".svg"):
|
|
135
|
+
ctype = "image/svg+xml"
|
|
136
|
+
elif safe_path.endswith(".json"):
|
|
137
|
+
ctype = "application/json"
|
|
138
|
+
elif safe_path.endswith(".css"):
|
|
139
|
+
ctype = "text/css"
|
|
140
|
+
elif safe_path.endswith(".js"):
|
|
141
|
+
ctype = "application/javascript"
|
|
142
|
+
self._send_bytes(200, ctype, data)
|
|
143
|
+
except SecurityError as se:
|
|
144
|
+
self._send_error(403, str(se))
|
|
145
|
+
except Exception as e:
|
|
146
|
+
self._send_error(400, str(e))
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
if path == "/api/v1/graph":
|
|
150
|
+
if query.get("refresh", ["0"])[0] in ("1", "true"):
|
|
151
|
+
try:
|
|
152
|
+
self.execute_rescan()
|
|
153
|
+
except Exception as e:
|
|
154
|
+
self._send_error(500, f"Rescan failed: {e}")
|
|
155
|
+
return
|
|
156
|
+
self._send_json(self.graph.to_dict() if self.graph else {})
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
if path == "/api/v1/subgraph":
|
|
160
|
+
file_id = query.get("file", [""])[0] or query.get("path", [""])[0]
|
|
161
|
+
symbol_id = query.get("symbol_id", [""])[0]
|
|
162
|
+
if not file_id and symbol_id and "::" in symbol_id:
|
|
163
|
+
file_id = symbol_id.split("::")[0]
|
|
164
|
+
if not file_id and symbol_id:
|
|
165
|
+
file_id = symbol_id
|
|
166
|
+
if "::" in file_id:
|
|
167
|
+
if not symbol_id:
|
|
168
|
+
symbol_id = file_id
|
|
169
|
+
file_id = file_id.split("::")[0]
|
|
170
|
+
if file_id.startswith("module::"):
|
|
171
|
+
file_id = file_id[len("module::"):]
|
|
172
|
+
if not file_id:
|
|
173
|
+
self._send_error(400, "Parameter 'file' or 'symbol_id' is required")
|
|
174
|
+
return
|
|
175
|
+
try:
|
|
176
|
+
raw_symbols = self.graph.get_symbols_by_file(file_id) if self.graph else []
|
|
177
|
+
excluded_kinds = {SymbolKind.VARIABLE}
|
|
178
|
+
symbols = [s for s in raw_symbols if s.kind not in excluded_kinds]
|
|
179
|
+
cls = self.graph.get_file_classification(file_id) if self.graph else classify_file(file_id)
|
|
180
|
+
sym_ids = {s.id for s in symbols}
|
|
181
|
+
intra_edges = [
|
|
182
|
+
e.to_dict() for e in (self.graph.get_edges() if self.graph else [])
|
|
183
|
+
if (e.source_id in sym_ids) and (e.target_id in sym_ids)
|
|
184
|
+
]
|
|
185
|
+
ext_outgoing = []
|
|
186
|
+
for s_id in sym_ids:
|
|
187
|
+
for e in (self.graph.get_outgoing_edges(s_id) if self.graph else []):
|
|
188
|
+
if e.target_id not in sym_ids:
|
|
189
|
+
ext_outgoing.append(e.to_dict())
|
|
190
|
+
ext_incoming = []
|
|
191
|
+
for s_id in sym_ids:
|
|
192
|
+
for e in (self.graph.get_incoming_edges(s_id) if self.graph else []):
|
|
193
|
+
if e.source_id not in sym_ids:
|
|
194
|
+
ext_incoming.append(e.to_dict())
|
|
195
|
+
ext_edges = ext_outgoing + ext_incoming
|
|
196
|
+
related_symbols = {}
|
|
197
|
+
for edge in ext_edges:
|
|
198
|
+
for endpoint_id in (edge["source_id"], edge["target_id"]):
|
|
199
|
+
if endpoint_id not in sym_ids and endpoint_id not in related_symbols:
|
|
200
|
+
if self.graph and self.graph.has_symbol(endpoint_id):
|
|
201
|
+
other_sym = self.graph.get_symbol(endpoint_id)
|
|
202
|
+
other_file = other_sym.location.file_id if other_sym.location else ""
|
|
203
|
+
other_cls = self.graph.get_file_classification(other_file) if other_file else None
|
|
204
|
+
related_symbols[endpoint_id] = {
|
|
205
|
+
"id": other_sym.id,
|
|
206
|
+
"name": other_sym.name,
|
|
207
|
+
"qualified_name": other_sym.qualified_name,
|
|
208
|
+
"kind": str(other_sym.kind),
|
|
209
|
+
"file_id": other_file,
|
|
210
|
+
"layer": str(other_cls.layer) if other_cls else "shared",
|
|
211
|
+
"feature": other_cls.feature if other_cls else "external",
|
|
212
|
+
"is_external": True,
|
|
213
|
+
"location": other_sym.location.to_dict() if other_sym.location else None,
|
|
214
|
+
"signature": other_sym.signature,
|
|
215
|
+
}
|
|
216
|
+
else:
|
|
217
|
+
raw_clean = endpoint_id.replace("module::", "")
|
|
218
|
+
other_file = raw_clean.split("::")[0] if "::" in raw_clean else raw_clean
|
|
219
|
+
other_name = raw_clean.split("::")[1] if "::" in raw_clean else (other_file.split("/")[-1] if "/" in other_file else other_file)
|
|
220
|
+
other_cls = self.graph.get_file_classification(other_file) if (self.graph and other_file) else None
|
|
221
|
+
related_symbols[endpoint_id] = {
|
|
222
|
+
"id": endpoint_id,
|
|
223
|
+
"name": other_name,
|
|
224
|
+
"qualified_name": endpoint_id,
|
|
225
|
+
"kind": "module" if endpoint_id.startswith("module::") else "symbol",
|
|
226
|
+
"file_id": other_file,
|
|
227
|
+
"layer": str(other_cls.layer) if other_cls else "shared",
|
|
228
|
+
"feature": other_cls.feature if other_cls else "external",
|
|
229
|
+
"is_external": True,
|
|
230
|
+
"location": {"file_id": other_file, "start_line": 1, "end_line": 1} if other_file else None,
|
|
231
|
+
"signature": other_name,
|
|
232
|
+
}
|
|
233
|
+
self._send_json({
|
|
234
|
+
"file_id": file_id,
|
|
235
|
+
"classification": cls.to_dict(),
|
|
236
|
+
"symbols": [s.to_dict() for s in symbols],
|
|
237
|
+
"related_symbols": list(related_symbols.values()),
|
|
238
|
+
"edges": intra_edges + ext_outgoing + ext_incoming,
|
|
239
|
+
})
|
|
240
|
+
except Exception as e:
|
|
241
|
+
self._send_error(400, str(e))
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
if path == "/api/v1/defects":
|
|
245
|
+
findings = [f.to_dict() for f in self.graph.get_findings()] if self.graph else []
|
|
246
|
+
self._send_json({"findings": findings, "count": len(findings)})
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
if path == "/api/v1/file":
|
|
250
|
+
rel_path = query.get("path", [""])[0]
|
|
251
|
+
if "::" in rel_path:
|
|
252
|
+
rel_path = rel_path.split("::")[0]
|
|
253
|
+
if rel_path.startswith("module::"):
|
|
254
|
+
rel_path = rel_path[len("module::"):]
|
|
255
|
+
try:
|
|
256
|
+
content = self._read_project_file(rel_path)
|
|
257
|
+
overridden_content = self.override_store.get_file_content(rel_path) if self.override_store else None
|
|
258
|
+
self._send_json({"path": rel_path, "content": overridden_content if overridden_content is not None else content})
|
|
259
|
+
except Exception as e:
|
|
260
|
+
self._send_error(400, str(e))
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
if path in ("/api/v1/export", "/__export_zip__"):
|
|
264
|
+
export_type = query.get("type", ["zip" if path == "/__export_zip__" else "json"])[0]
|
|
265
|
+
if export_type == "diff":
|
|
266
|
+
diff = self.override_store.to_diff()
|
|
267
|
+
self._send_response(200, "text/plain", diff)
|
|
268
|
+
elif export_type == "sarif":
|
|
269
|
+
sarif = SARIFEmitter().emit(self.graph)
|
|
270
|
+
self._send_response(200, "application/json", sarif)
|
|
271
|
+
elif export_type == "zip":
|
|
272
|
+
self._handle_zip_export()
|
|
273
|
+
else:
|
|
274
|
+
json_str = JSONEmitter().emit(self.graph)
|
|
275
|
+
self._send_response(200, "application/json", json_str)
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
if path in ("/api/v1/repo/info", "/api/v1/library/repo"):
|
|
279
|
+
from codeui.core.repo import get_library_repo_url
|
|
280
|
+
self._send_json({
|
|
281
|
+
"repo_url": get_library_repo_url(),
|
|
282
|
+
"version": "0.1.0",
|
|
283
|
+
})
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
self._send_error(404, "Endpoint Not Found")
|
|
287
|
+
|
|
288
|
+
def _get_allowed_origin(self) -> str:
|
|
289
|
+
"""Derive safe CORS allowed origin restricted to local origins."""
|
|
290
|
+
origin = self.headers.get("Origin", "") if self.headers else ""
|
|
291
|
+
host_hdr = self.headers.get("Host", "") if self.headers else ""
|
|
292
|
+
if origin:
|
|
293
|
+
parsed = urllib.parse.urlparse(origin)
|
|
294
|
+
if parsed.hostname in ("localhost", "127.0.0.1", "0.0.0.0") or (host_hdr and parsed.netloc == host_hdr):
|
|
295
|
+
return origin
|
|
296
|
+
return f"http://{host_hdr}" if host_hdr else "http://127.0.0.1:3000"
|
|
297
|
+
|
|
298
|
+
def do_OPTIONS(self) -> None:
|
|
299
|
+
"""Handle OPTIONS preflight CORS requests.
|
|
300
|
+
Example:
|
|
301
|
+
>>> handler = CodeUIHTTPRequestHandler
|
|
302
|
+
"""
|
|
303
|
+
self.send_response(204)
|
|
304
|
+
self.send_header("Access-Control-Allow-Origin", self._get_allowed_origin())
|
|
305
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
306
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
|
|
307
|
+
self.end_headers()
|
|
308
|
+
|
|
309
|
+
def do_POST(self) -> None:
|
|
310
|
+
"""Handle POST requests for state overrides and edits.
|
|
311
|
+
Example:
|
|
312
|
+
>>> handler = CodeUIHTTPRequestHandler
|
|
313
|
+
"""
|
|
314
|
+
parsed_url = urllib.parse.urlparse(self.path)
|
|
315
|
+
path = parsed_url.path
|
|
316
|
+
|
|
317
|
+
if path == "/__export_zip__":
|
|
318
|
+
self._handle_zip_export()
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
csrf_header = self.headers.get("X-CSRF-Token")
|
|
322
|
+
if csrf_header != self.CSRF_TOKEN:
|
|
323
|
+
self._send_error(403, "CSRF Token Validation Failed")
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
content_len = int(self.headers.get("Content-Length", 0))
|
|
327
|
+
body = self.rfile.read(content_len).decode("utf-8") if content_len > 0 else "{}"
|
|
328
|
+
try:
|
|
329
|
+
payload = json.loads(body)
|
|
330
|
+
except Exception:
|
|
331
|
+
self._send_error(400, "Invalid JSON payload")
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
if path == "/api/v1/clone":
|
|
335
|
+
repo = payload.get("repo", "").strip()
|
|
336
|
+
if not repo or repo.startswith("-"):
|
|
337
|
+
self._send_error(400, "Parameter 'repo' is invalid")
|
|
338
|
+
return
|
|
339
|
+
if not repo.startswith("http://") and not repo.startswith("https://") and not repo.startswith("git@"):
|
|
340
|
+
if not re.match(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", repo):
|
|
341
|
+
self._send_error(400, "Invalid repository name format")
|
|
342
|
+
return
|
|
343
|
+
repo_url = f"https://github.com/{repo}.git"
|
|
344
|
+
else:
|
|
345
|
+
if not (repo.startswith("https://") or repo.startswith("http://") or repo.startswith("git@")):
|
|
346
|
+
self._send_error(400, "Unsupported repository protocol")
|
|
347
|
+
return
|
|
348
|
+
repo_url = repo
|
|
349
|
+
active_dir = Path("/tmp/codeui_repos/active_repo")
|
|
350
|
+
if active_dir.exists():
|
|
351
|
+
import shutil
|
|
352
|
+
shutil.rmtree(active_dir, ignore_errors=True)
|
|
353
|
+
active_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
354
|
+
import subprocess
|
|
355
|
+
res = subprocess.run(["git", "clone", "--depth", "1", "--", repo_url, str(active_dir)], capture_output=True, text=True)
|
|
356
|
+
if res.returncode != 0:
|
|
357
|
+
self._send_error(500, f"Git clone failed: {res.stderr}")
|
|
358
|
+
return
|
|
359
|
+
self.__class__.project_root = active_dir
|
|
360
|
+
if self.override_store:
|
|
361
|
+
self.override_store.revert_all()
|
|
362
|
+
self.execute_rescan()
|
|
363
|
+
self._send_json({
|
|
364
|
+
"status": "ok",
|
|
365
|
+
"repo": repo,
|
|
366
|
+
"files_count": len(self.graph.get_all_files()) if self.graph else 0
|
|
367
|
+
})
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
if path == "/api/v1/refresh":
|
|
371
|
+
try:
|
|
372
|
+
self.execute_rescan()
|
|
373
|
+
except Exception as e:
|
|
374
|
+
self._send_error(500, f"Rescan failed: {e}")
|
|
375
|
+
return
|
|
376
|
+
self._send_json({"status": "ok", "files_count": len(self.graph.get_all_files()) if self.graph else 0})
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
if path == "/api/v1/edit":
|
|
380
|
+
kind = payload.get("kind", "replace_file")
|
|
381
|
+
target_id = payload.get("target_id")
|
|
382
|
+
new_val = payload.get("new_value", "")
|
|
383
|
+
dd_raw = payload.get("direct_disk", payload.get("directDisk", payload.get("write_to_disk", payload.get("is_direct", True))))
|
|
384
|
+
if isinstance(dd_raw, str):
|
|
385
|
+
direct_disk = dd_raw.lower() not in ("false", "0", "no", "off")
|
|
386
|
+
else:
|
|
387
|
+
direct_disk = bool(dd_raw)
|
|
388
|
+
if not target_id:
|
|
389
|
+
self._send_error(400, "target_id is required")
|
|
390
|
+
return
|
|
391
|
+
if kind == "replace_file":
|
|
392
|
+
full_path = self._resolve_disk_path(target_id)
|
|
393
|
+
print(f"[DISK_EDIT] Request target_id='{target_id}' resolved to full_path='{full_path}' (direct_disk={direct_disk})", file=sys.stderr, flush=True)
|
|
394
|
+
if not full_path:
|
|
395
|
+
print(f"[DISK_EDIT] ERROR: Could not resolve file path for target_id='{target_id}'", file=sys.stderr, flush=True)
|
|
396
|
+
self._send_error(404, f"Could not resolve file path: {target_id}")
|
|
397
|
+
return
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
rel_for_graph = str(full_path.relative_to(self.project_root.resolve())).replace("\\", "/")
|
|
401
|
+
except Exception:
|
|
402
|
+
rel_for_graph = str(full_path.name)
|
|
403
|
+
|
|
404
|
+
if direct_disk:
|
|
405
|
+
print(f"[DISK_EDIT] Attempting direct disk write to '{full_path}'...", file=sys.stderr, flush=True)
|
|
406
|
+
try:
|
|
407
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
408
|
+
with open(full_path, "w", encoding="utf-8") as f:
|
|
409
|
+
f.write(new_val)
|
|
410
|
+
f.flush()
|
|
411
|
+
os.fsync(f.fileno())
|
|
412
|
+
print(f"[DISK_EDIT] SUCCESS: Direct disk write completed for '{full_path}' ({len(new_val)} bytes)", file=sys.stderr, flush=True)
|
|
413
|
+
except Exception as err:
|
|
414
|
+
print(f"[DISK_EDIT] ERROR: Direct disk write failed for '{full_path}': {err}", file=sys.stderr, flush=True)
|
|
415
|
+
self._send_error(500, f"Disk write failed for {full_path}: {err}")
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
if self.override_store:
|
|
419
|
+
self.override_store.revert(rel_for_graph)
|
|
420
|
+
self.override_store.revert(target_id)
|
|
421
|
+
clean_norm = target_id.split("::")[0].replace("module::", "").lstrip("./")
|
|
422
|
+
self.override_store.revert(clean_norm)
|
|
423
|
+
self.execute_rescan()
|
|
424
|
+
self._send_json({
|
|
425
|
+
"status": "ok",
|
|
426
|
+
"target_id": target_id,
|
|
427
|
+
"direct_disk": True,
|
|
428
|
+
"saved_path": str(full_path),
|
|
429
|
+
"relative_path": rel_for_graph,
|
|
430
|
+
})
|
|
431
|
+
return
|
|
432
|
+
elif self.override_store:
|
|
433
|
+
orig_content = ""
|
|
434
|
+
try:
|
|
435
|
+
orig_content = full_path.read_text(encoding="utf-8", errors="replace") if full_path.exists() else ""
|
|
436
|
+
except Exception:
|
|
437
|
+
pass
|
|
438
|
+
self.override_store.set_file_override(
|
|
439
|
+
file_path=rel_for_graph,
|
|
440
|
+
original_content=orig_content,
|
|
441
|
+
new_content=new_val,
|
|
442
|
+
author="ui_user",
|
|
443
|
+
)
|
|
444
|
+
self._send_json({
|
|
445
|
+
"status": "ok",
|
|
446
|
+
"target_id": target_id,
|
|
447
|
+
"direct_disk": False,
|
|
448
|
+
"relative_path": rel_for_graph,
|
|
449
|
+
})
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
if path == "/api/v1/revert":
|
|
453
|
+
target_id = payload.get("target_id")
|
|
454
|
+
if target_id == "all":
|
|
455
|
+
self.override_store.revert_all()
|
|
456
|
+
elif target_id:
|
|
457
|
+
self.override_store.revert(target_id)
|
|
458
|
+
self._send_json({"status": "ok"})
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
if path in ("/api/v1/agent/contribute", "/api/v1/contribute"):
|
|
462
|
+
title = payload.get("title", "AI Agent Contribution")
|
|
463
|
+
description = payload.get("description", "")
|
|
464
|
+
file_changes = payload.get("file_changes", {})
|
|
465
|
+
branch_name = payload.get("branch_name")
|
|
466
|
+
token = payload.get("token")
|
|
467
|
+
push = payload.get("push", True)
|
|
468
|
+
try:
|
|
469
|
+
from codeui.agent.contribution import ContributionManager
|
|
470
|
+
mgr = ContributionManager(token=token)
|
|
471
|
+
res = mgr.submit_contribution(
|
|
472
|
+
title=title,
|
|
473
|
+
description=description,
|
|
474
|
+
file_changes=file_changes,
|
|
475
|
+
branch_name=branch_name,
|
|
476
|
+
token=token,
|
|
477
|
+
push=push,
|
|
478
|
+
)
|
|
479
|
+
self._send_json(res)
|
|
480
|
+
except Exception as e:
|
|
481
|
+
self._send_error(500, f"Contribution failed: {e}")
|
|
482
|
+
return
|
|
483
|
+
|
|
484
|
+
self._send_error(404, "Endpoint Not Found")
|
|
485
|
+
|
|
486
|
+
def _handle_zip_export(self) -> None:
|
|
487
|
+
"""Generate in-memory ZIP package of current project files, overrides, and metadata."""
|
|
488
|
+
buf = io.BytesIO()
|
|
489
|
+
written: set[str] = set()
|
|
490
|
+
ignored_dirs = {"node_modules", ".venv", "dist", ".git", ".codeui", "__pycache__", ".pytest_cache"}
|
|
491
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
492
|
+
for path in self.project_root.rglob("*"):
|
|
493
|
+
if path.is_file() and path.suffix.lower() not in (".pyc", ".pyo", ".pyd") and not any(part in path.parts for part in ignored_dirs):
|
|
494
|
+
try:
|
|
495
|
+
rel = str(path.relative_to(self.project_root))
|
|
496
|
+
safe = self._safe_rel_path(rel)
|
|
497
|
+
if not safe or safe in written:
|
|
498
|
+
continue
|
|
499
|
+
overridden = self.override_store.get_file_content(safe)
|
|
500
|
+
if overridden is not None:
|
|
501
|
+
zf.writestr(safe, overridden)
|
|
502
|
+
else:
|
|
503
|
+
zf.writestr(safe, path.read_bytes())
|
|
504
|
+
written.add(safe)
|
|
505
|
+
except Exception:
|
|
506
|
+
pass
|
|
507
|
+
for file_path in self.graph.get_all_files():
|
|
508
|
+
safe = self._safe_rel_path(file_path)
|
|
509
|
+
if not safe or safe in written:
|
|
510
|
+
continue
|
|
511
|
+
overridden = self.override_store.get_file_content(file_path)
|
|
512
|
+
if overridden is not None:
|
|
513
|
+
zf.writestr(safe, overridden)
|
|
514
|
+
written.add(safe)
|
|
515
|
+
overrides = [o.to_dict() for o in self.override_store.list()]
|
|
516
|
+
if overrides:
|
|
517
|
+
zf.writestr("_overrides.json", json.dumps({"version": 1, "overrides": overrides}, indent=2))
|
|
518
|
+
zf.writestr("project_full.json", json.dumps(self.graph.to_dict(), indent=2))
|
|
519
|
+
buf.seek(0)
|
|
520
|
+
self._send_bytes(
|
|
521
|
+
200,
|
|
522
|
+
"application/zip",
|
|
523
|
+
buf.read(),
|
|
524
|
+
{"Content-Disposition": 'attachment; filename="codeui_export.zip"'}
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
def _safe_rel_path(self, rel_path: str) -> Optional[str]:
|
|
528
|
+
"""Validate and sanitize relative path."""
|
|
529
|
+
if not rel_path:
|
|
530
|
+
return None
|
|
531
|
+
safe = rel_path.replace("\\", "/").lstrip("/")
|
|
532
|
+
parts = [p for p in safe.split("/") if p]
|
|
533
|
+
if any(p == ".." for p in parts):
|
|
534
|
+
return None
|
|
535
|
+
return "/".join(parts)
|
|
536
|
+
|
|
537
|
+
def _serve_static_ui(self) -> None:
|
|
538
|
+
"""Serve prebuilt embedded index.html visualizer asset."""
|
|
539
|
+
static_dir = Path(__file__).parent / "static" / "index.html"
|
|
540
|
+
if static_dir.exists():
|
|
541
|
+
html_content = static_dir.read_text(encoding="utf-8")
|
|
542
|
+
self._send_response(200, "text/html; charset=utf-8", html_content)
|
|
543
|
+
else:
|
|
544
|
+
self._send_error(500, "Static UI asset missing")
|
|
545
|
+
|
|
546
|
+
def _resolve_disk_path(self, rel_path: str) -> Optional[Path]:
|
|
547
|
+
"""Resolve a relative or symbol file path to the absolute Path on disk.
|
|
548
|
+
Safely locates project files across various directory layouts and prevents directory traversal.
|
|
549
|
+
"""
|
|
550
|
+
if not rel_path or not self.project_root:
|
|
551
|
+
return None
|
|
552
|
+
clean_rel = rel_path.split("::")[0].replace("module::", "").replace("\\", "/").strip().lstrip("./")
|
|
553
|
+
if not clean_rel:
|
|
554
|
+
return None
|
|
555
|
+
|
|
556
|
+
proj_root = self.project_root.resolve()
|
|
557
|
+
proj_str = str(proj_root)
|
|
558
|
+
|
|
559
|
+
if clean_rel.startswith(proj_str):
|
|
560
|
+
clean_rel = clean_rel[len(proj_str):].lstrip("/")
|
|
561
|
+
|
|
562
|
+
safe = self._safe_rel_path(clean_rel)
|
|
563
|
+
if safe:
|
|
564
|
+
cand = (proj_root / safe).resolve()
|
|
565
|
+
if str(cand).startswith(proj_str) and cand.exists() and cand.is_file():
|
|
566
|
+
return cand
|
|
567
|
+
|
|
568
|
+
if safe and proj_root.name and (safe.startswith(proj_root.name + "/") or safe == proj_root.name):
|
|
569
|
+
sub_safe = safe[len(proj_root.name):].lstrip("/")
|
|
570
|
+
if sub_safe:
|
|
571
|
+
cand = (proj_root / sub_safe).resolve()
|
|
572
|
+
if str(cand).startswith(proj_str) and cand.exists() and cand.is_file():
|
|
573
|
+
return cand
|
|
574
|
+
|
|
575
|
+
if self.graph:
|
|
576
|
+
for known_file in self.graph.get_all_files():
|
|
577
|
+
k_clean = known_file.replace("\\", "/").lstrip("./")
|
|
578
|
+
if (k_clean == safe or
|
|
579
|
+
(safe and (k_clean.endswith("/" + safe) or safe.endswith("/" + k_clean))) or
|
|
580
|
+
(safe and Path(k_clean).name == Path(safe).name)):
|
|
581
|
+
cand = (proj_root / k_clean).resolve()
|
|
582
|
+
if str(cand).startswith(proj_str) and cand.exists() and cand.is_file():
|
|
583
|
+
return cand
|
|
584
|
+
|
|
585
|
+
from codeui.lang.registry import LanguageRegistry
|
|
586
|
+
supported_exts = LanguageRegistry().get_supported_extensions()
|
|
587
|
+
if safe:
|
|
588
|
+
candidates = []
|
|
589
|
+
for ext in supported_exts:
|
|
590
|
+
candidates.append(safe + ext)
|
|
591
|
+
candidates.append(safe.replace(".", "/") + ext)
|
|
592
|
+
candidates.extend([
|
|
593
|
+
safe + "/index.ts",
|
|
594
|
+
safe + "/index.tsx",
|
|
595
|
+
safe + "/index.js",
|
|
596
|
+
safe + "/index.jsx",
|
|
597
|
+
safe + "/__init__.py",
|
|
598
|
+
safe + "/mod.rs",
|
|
599
|
+
safe + "/lib.rs",
|
|
600
|
+
safe.replace(".", "/") + "/__init__.py",
|
|
601
|
+
])
|
|
602
|
+
for c in candidates:
|
|
603
|
+
cand = (proj_root / c).resolve()
|
|
604
|
+
if str(cand).startswith(proj_str) and cand.exists() and cand.is_file():
|
|
605
|
+
return cand
|
|
606
|
+
|
|
607
|
+
parent_root = proj_root.parent.resolve()
|
|
608
|
+
if safe and parent_root != proj_root:
|
|
609
|
+
cand = (parent_root / safe).resolve()
|
|
610
|
+
if str(cand).startswith(str(parent_root)) and cand.exists() and cand.is_file():
|
|
611
|
+
return cand
|
|
612
|
+
|
|
613
|
+
if safe:
|
|
614
|
+
cand = (proj_root / safe).resolve()
|
|
615
|
+
if str(cand).startswith(proj_str):
|
|
616
|
+
return cand
|
|
617
|
+
return None
|
|
618
|
+
|
|
619
|
+
def _read_project_file(self, rel_path: str) -> str:
|
|
620
|
+
"""Read project file safely with path traversal protection and flexible extension resolution."""
|
|
621
|
+
full_path = self._resolve_disk_path(rel_path)
|
|
622
|
+
if full_path and full_path.exists() and full_path.is_file():
|
|
623
|
+
return full_path.read_text(encoding="utf-8", errors="replace")
|
|
624
|
+
raise FileNotFoundError(f"File not found: {rel_path}")
|
|
625
|
+
|
|
626
|
+
def _send_json(self, data: Dict[str, Any]) -> None:
|
|
627
|
+
"""Send JSON HTTP 200 response with security headers."""
|
|
628
|
+
body = json.dumps(data, sort_keys=True, indent=2)
|
|
629
|
+
self._send_response(200, "application/json; charset=utf-8", body)
|
|
630
|
+
|
|
631
|
+
def _send_bytes(self, code: int, content_type: str, body: bytes, extra_headers: Optional[Dict[str, str]] = None) -> None:
|
|
632
|
+
"""Helper to send raw binary HTTP response with security headers."""
|
|
633
|
+
self.send_response(code)
|
|
634
|
+
self.send_header("Content-Type", content_type)
|
|
635
|
+
self.send_header("Content-Length", str(len(body)))
|
|
636
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
637
|
+
self.send_header("Content-Security-Policy", "default-src 'self' 'unsafe-inline' data: blob:; object-src 'none'; frame-ancestors *;")
|
|
638
|
+
self.send_header("Access-Control-Allow-Origin", self._get_allowed_origin())
|
|
639
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
640
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
|
|
641
|
+
if extra_headers:
|
|
642
|
+
for k, v in extra_headers.items():
|
|
643
|
+
self.send_header(k, v)
|
|
644
|
+
self.end_headers()
|
|
645
|
+
self.wfile.write(body)
|
|
646
|
+
|
|
647
|
+
def _send_response(self, code: int, content_type: str, body: str) -> None:
|
|
648
|
+
"""Helper to send HTTP text response with strict CSP and security headers."""
|
|
649
|
+
self._send_bytes(code, content_type, body.encode("utf-8"))
|
|
650
|
+
|
|
651
|
+
def _send_error(self, code: int, message: str) -> None:
|
|
652
|
+
"""Send HTTP error payload."""
|
|
653
|
+
self._send_response(code, "application/json", json.dumps({"error": message, "code": code}))
|
|
654
|
+
|
|
655
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
656
|
+
"""Suppress standard access logs under 400 to prevent false positive error triggers."""
|
|
657
|
+
if args and len(args) > 1:
|
|
658
|
+
try:
|
|
659
|
+
code = int(args[1])
|
|
660
|
+
if code < 400:
|
|
661
|
+
return
|
|
662
|
+
except (ValueError, TypeError):
|
|
663
|
+
pass
|
|
664
|
+
sys.stderr.write(f"{self.address_string()} - - [{self.log_date_time_string()}] {format % args}\n")
|
|
665
|
+
sys.stderr.flush()
|
|
666
|
+
|
|
667
|
+
class ReusableThreadingHTTPServer(ThreadingHTTPServer):
|
|
668
|
+
allow_reuse_address = True
|
|
669
|
+
|
|
670
|
+
def create_server(
|
|
671
|
+
graph: Graph,
|
|
672
|
+
override_store: OverrideStore,
|
|
673
|
+
project_root: Path,
|
|
674
|
+
host: str = "127.0.0.1",
|
|
675
|
+
port: int = 3000,
|
|
676
|
+
rescan_fn: Optional[Callable[..., Graph]] = None,
|
|
677
|
+
entry_points: Optional[Iterable[str]] = None,
|
|
678
|
+
layer_rules: Optional[Dict[str, Any]] = None,
|
|
679
|
+
) -> ThreadingHTTPServer:
|
|
680
|
+
"""Instantiate and configure codeui localhost HTTP server.
|
|
681
|
+
Example:
|
|
682
|
+
>>> g = Graph()
|
|
683
|
+
>>> store = OverrideStore()
|
|
684
|
+
>>> srv = create_server(g, store, Path("."), port=0)
|
|
685
|
+
>>> srv.server_port > 0
|
|
686
|
+
True
|
|
687
|
+
>>> srv.server_close()
|
|
688
|
+
"""
|
|
689
|
+
CodeUIHTTPRequestHandler.graph = graph
|
|
690
|
+
CodeUIHTTPRequestHandler.override_store = override_store
|
|
691
|
+
CodeUIHTTPRequestHandler.project_root = project_root.resolve()
|
|
692
|
+
CodeUIHTTPRequestHandler.rescan_fn = staticmethod(rescan_fn) if rescan_fn is not None else None
|
|
693
|
+
if entry_points is not None:
|
|
694
|
+
CodeUIHTTPRequestHandler.entry_points = list(entry_points)
|
|
695
|
+
elif graph and hasattr(graph, "_entry_points") and graph._entry_points:
|
|
696
|
+
CodeUIHTTPRequestHandler.entry_points = list(graph._entry_points)
|
|
697
|
+
else:
|
|
698
|
+
CodeUIHTTPRequestHandler.entry_points = None
|
|
699
|
+
if layer_rules is not None:
|
|
700
|
+
CodeUIHTTPRequestHandler.layer_rules = dict(layer_rules)
|
|
701
|
+
elif graph and hasattr(graph, "_layer_rules") and graph._layer_rules:
|
|
702
|
+
CodeUIHTTPRequestHandler.layer_rules = dict(graph._layer_rules)
|
|
703
|
+
else:
|
|
704
|
+
CodeUIHTTPRequestHandler.layer_rules = None
|
|
705
|
+
return ReusableThreadingHTTPServer((host, port), CodeUIHTTPRequestHandler)
|