codegraphy-lite 1.0.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.
- codegraphy_lite/__init__.py +7 -0
- codegraphy_lite/cli.py +113 -0
- codegraphy_lite/mcp.py +338 -0
- codegraphy_lite/models.py +204 -0
- codegraphy_lite/scanner.py +330 -0
- codegraphy_lite/server.py +414 -0
- codegraphy_lite/storage.py +201 -0
- codegraphy_lite/web/assets/codegraphy-lite-ui.css +323 -0
- codegraphy_lite/web/assets/codegraphy-lite-ui.js +305 -0
- codegraphy_lite/web/assets/graph-D7gaURIl.js +7 -0
- codegraphy_lite/web/assets/icons-CvV5lYnA.js +1 -0
- codegraphy_lite/web/assets/index-BH1KbUBa.css +1 -0
- codegraphy_lite/web/assets/index-BPD6wClD.js +50 -0
- codegraphy_lite/web/assets/markdown-Bj3RCaMp.js +14 -0
- codegraphy_lite/web/favicon.ico +0 -0
- codegraphy_lite/web/favicon.png +0 -0
- codegraphy_lite/web/favicon.svg +1 -0
- codegraphy_lite/web/index.html +22 -0
- codegraphy_lite-1.0.0.dist-info/METADATA +227 -0
- codegraphy_lite-1.0.0.dist-info/RECORD +24 -0
- codegraphy_lite-1.0.0.dist-info/WHEEL +5 -0
- codegraphy_lite-1.0.0.dist-info/entry_points.txt +3 -0
- codegraphy_lite-1.0.0.dist-info/licenses/LICENSE +21 -0
- codegraphy_lite-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""FastAPI server for CodeGraphy Lite.
|
|
2
|
+
|
|
3
|
+
Hosts the interactive React Flow visual graph, REST APIs, WebSocket live sync,
|
|
4
|
+
and STDIO/HTTP MCP configuration endpoints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional, Set
|
|
15
|
+
|
|
16
|
+
from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
|
|
17
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
18
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse
|
|
19
|
+
from fastapi.staticfiles import StaticFiles
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
|
|
22
|
+
from codegraphy_lite import __version__
|
|
23
|
+
from codegraphy_lite.models import (
|
|
24
|
+
GraphState,
|
|
25
|
+
NodeData,
|
|
26
|
+
ReactFlowNode,
|
|
27
|
+
ScanPreviewRequest,
|
|
28
|
+
ScanPreviewResponse,
|
|
29
|
+
TopicLane,
|
|
30
|
+
WorkspaceModel,
|
|
31
|
+
)
|
|
32
|
+
from codegraphy_lite.scanner import (
|
|
33
|
+
build_graph_and_workspace,
|
|
34
|
+
extract_ast_signatures,
|
|
35
|
+
scan_project,
|
|
36
|
+
)
|
|
37
|
+
from codegraphy_lite.storage import (
|
|
38
|
+
get_active_root,
|
|
39
|
+
get_storage_dir,
|
|
40
|
+
load_graph,
|
|
41
|
+
load_workspace,
|
|
42
|
+
parse_md_to_workspace,
|
|
43
|
+
save_graph,
|
|
44
|
+
save_workspace,
|
|
45
|
+
serialize_workspace_to_md,
|
|
46
|
+
set_active_root,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RootPathPayload(BaseModel):
|
|
51
|
+
root_path: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GeneratePromptRequest(BaseModel):
|
|
55
|
+
topic_ids: List[str]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class WorkspaceWsManager:
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
self.active_connections: Set[WebSocket] = set()
|
|
61
|
+
|
|
62
|
+
async def connect(self, websocket: WebSocket) -> None:
|
|
63
|
+
await websocket.accept()
|
|
64
|
+
self.active_connections.add(websocket)
|
|
65
|
+
|
|
66
|
+
def disconnect(self, websocket: WebSocket) -> None:
|
|
67
|
+
self.active_connections.discard(websocket)
|
|
68
|
+
|
|
69
|
+
async def broadcast_update(self, event_type: str = "workspace_updated", extra: Optional[Dict[str, Any]] = None) -> None:
|
|
70
|
+
if not self.active_connections:
|
|
71
|
+
return
|
|
72
|
+
payload = {"type": event_type, "ts": time.time(), **(extra or {})}
|
|
73
|
+
dead: List[WebSocket] = []
|
|
74
|
+
for ws in list(self.active_connections):
|
|
75
|
+
try:
|
|
76
|
+
await ws.send_json(payload)
|
|
77
|
+
except Exception:
|
|
78
|
+
dead.append(ws)
|
|
79
|
+
for d in dead:
|
|
80
|
+
self.active_connections.discard(d)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
ws_manager = WorkspaceWsManager()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_web_dir() -> Path:
|
|
87
|
+
"""Returns directory containing built static frontend assets."""
|
|
88
|
+
packaged = Path(__file__).resolve().parent / "web"
|
|
89
|
+
if packaged.exists() and (packaged / "index.html").exists():
|
|
90
|
+
return packaged
|
|
91
|
+
# Fallback to local paths if running from source checkout
|
|
92
|
+
candidates = [
|
|
93
|
+
Path.cwd() / "web",
|
|
94
|
+
Path.cwd() / "frontend" / "dist",
|
|
95
|
+
Path(__file__).resolve().parent.parent / "web",
|
|
96
|
+
]
|
|
97
|
+
for c in candidates:
|
|
98
|
+
if c.exists() and (c / "index.html").exists():
|
|
99
|
+
return c
|
|
100
|
+
return packaged
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@asynccontextmanager
|
|
104
|
+
async def lifespan(app: FastAPI):
|
|
105
|
+
# Initialize workspace and ensure storage folder exists
|
|
106
|
+
get_storage_dir()
|
|
107
|
+
web_dir = get_web_dir()
|
|
108
|
+
assets_dir = web_dir / "assets"
|
|
109
|
+
if assets_dir.exists():
|
|
110
|
+
has_assets = any(getattr(r, "path", None) == "/assets" for r in app.routes)
|
|
111
|
+
if not has_assets:
|
|
112
|
+
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
|
|
113
|
+
yield
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
app = FastAPI(title="CodeGraphy Lite API", version=__version__, lifespan=lifespan)
|
|
117
|
+
|
|
118
|
+
app.add_middleware(
|
|
119
|
+
CORSMiddleware,
|
|
120
|
+
allow_origins=["*"],
|
|
121
|
+
allow_credentials=True,
|
|
122
|
+
allow_methods=["*"],
|
|
123
|
+
allow_headers=["*"],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.websocket("/ws/workspace")
|
|
128
|
+
async def workspace_ws_endpoint(websocket: WebSocket):
|
|
129
|
+
await ws_manager.connect(websocket)
|
|
130
|
+
try:
|
|
131
|
+
while True:
|
|
132
|
+
data = await websocket.receive_text()
|
|
133
|
+
if data == "ping":
|
|
134
|
+
await websocket.send_text("pong")
|
|
135
|
+
except (WebSocketDisconnect, Exception):
|
|
136
|
+
ws_manager.disconnect(websocket)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@app.get("/health")
|
|
140
|
+
@app.get("/api/health")
|
|
141
|
+
def health_check():
|
|
142
|
+
return {
|
|
143
|
+
"status": "ok",
|
|
144
|
+
"edition": "lite",
|
|
145
|
+
"version": __version__,
|
|
146
|
+
"open_source": True,
|
|
147
|
+
"max_scan_files": 35,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.get("/api/workspace/root-path")
|
|
152
|
+
def get_root_path():
|
|
153
|
+
return {"root_path": str(get_active_root())}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.post("/api/workspace/root-path")
|
|
157
|
+
def set_root_path_endpoint(payload: RootPathPayload):
|
|
158
|
+
new_root = set_active_root(payload.root_path)
|
|
159
|
+
return {"status": "ok", "root_path": str(new_root)}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.get("/api/graph", response_model=GraphState)
|
|
163
|
+
def get_graph():
|
|
164
|
+
return load_graph()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@app.post("/api/graph")
|
|
168
|
+
def post_graph(graph: GraphState):
|
|
169
|
+
save_graph(graph)
|
|
170
|
+
return {"status": "ok", "nodes_count": len(graph.nodes), "edges_count": len(graph.edges)}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@app.get("/api/workspace", response_model=WorkspaceModel)
|
|
174
|
+
def get_workspace():
|
|
175
|
+
return load_workspace()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@app.post("/api/workspace")
|
|
179
|
+
def post_workspace(ws: WorkspaceModel):
|
|
180
|
+
save_workspace(ws)
|
|
181
|
+
return {"status": "ok", "topics_count": len(ws.topics)}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.get("/api/workspace/export-md", response_class=PlainTextResponse)
|
|
185
|
+
def export_workspace_md():
|
|
186
|
+
ws = load_workspace()
|
|
187
|
+
return serialize_workspace_to_md(ws)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@app.post("/api/workspace/import-md")
|
|
191
|
+
def import_workspace_md(payload: Dict[str, str]):
|
|
192
|
+
md = payload.get("markdown", "")
|
|
193
|
+
if not md.strip():
|
|
194
|
+
raise HTTPException(status_code=400, detail="Markdown content is required.")
|
|
195
|
+
ws = parse_md_to_workspace(md)
|
|
196
|
+
save_workspace(ws)
|
|
197
|
+
return {"status": "ok", "topics_count": len(ws.topics), "edges_count": len(ws.edges)}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.post("/api/workspace/scan-preview", response_model=ScanPreviewResponse)
|
|
201
|
+
def scan_preview_endpoint(req: ScanPreviewRequest):
|
|
202
|
+
try:
|
|
203
|
+
target_path = Path(req.root_path).resolve() if req.root_path else get_active_root()
|
|
204
|
+
res = scan_project(target_path)
|
|
205
|
+
return res
|
|
206
|
+
except Exception as e:
|
|
207
|
+
raise HTTPException(status_code=400, detail=f"Scanner error: {str(e)}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.post("/api/lanes")
|
|
211
|
+
def save_lanes_endpoint(lanes: List[TopicLane]):
|
|
212
|
+
ws = load_workspace()
|
|
213
|
+
ws.lanes = lanes
|
|
214
|
+
save_workspace(ws)
|
|
215
|
+
return {"status": "ok", "lanes_count": len(lanes)}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@app.post("/api/tasks")
|
|
219
|
+
def save_tasks_endpoint(tasks: List[Any]):
|
|
220
|
+
return {"status": "ok", "tasks_count": len(tasks)}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.post("/api/topics/assign")
|
|
224
|
+
def assign_topic_endpoint(payload: Dict[str, Any]):
|
|
225
|
+
return {"status": "ok"}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ─── AST Signature Context Compiler ─────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
def compile_topic_prompt(topic_id: str, graph: GraphState, root: Path) -> str:
|
|
231
|
+
target_node = next((n for n in graph.nodes if n.id == topic_id or n.data.id == topic_id), None)
|
|
232
|
+
if not target_node:
|
|
233
|
+
raise HTTPException(status_code=404, detail=f"Topic '{topic_id}' not found.")
|
|
234
|
+
|
|
235
|
+
# 1-hop connected neighbors
|
|
236
|
+
neighbor_ids = set()
|
|
237
|
+
for e in graph.edges:
|
|
238
|
+
if e.source == target_node.id:
|
|
239
|
+
neighbor_ids.add(e.target)
|
|
240
|
+
elif e.target == target_node.id:
|
|
241
|
+
neighbor_ids.add(e.source)
|
|
242
|
+
|
|
243
|
+
neighbors = []
|
|
244
|
+
for n in graph.nodes:
|
|
245
|
+
if n.id in neighbor_ids:
|
|
246
|
+
neighbors.append(f"- **{n.data.label}** (`{n.data.status}`): {n.data.description}")
|
|
247
|
+
|
|
248
|
+
neighbors_str = "\n".join(neighbors) if neighbors else "No 1-hop dependencies connected."
|
|
249
|
+
|
|
250
|
+
file_sections = []
|
|
251
|
+
for f in target_node.data.attached_files:
|
|
252
|
+
full_p = root / f.filepath
|
|
253
|
+
if full_p.exists() and full_p.is_file():
|
|
254
|
+
try:
|
|
255
|
+
code = full_p.read_text(encoding="utf-8", errors="ignore")
|
|
256
|
+
sigs = extract_ast_signatures(code)
|
|
257
|
+
except Exception as e:
|
|
258
|
+
sigs = f"# Error reading file: {e}"
|
|
259
|
+
else:
|
|
260
|
+
sigs = f"# File not found on disk: {f.filepath}"
|
|
261
|
+
|
|
262
|
+
file_sections.append(
|
|
263
|
+
f"### File: `{f.filepath}`\n```python\n{sigs}\n```"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
files_str = "\n\n".join(file_sections) if file_sections else "No attached files."
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
f"# Architecture Context: {target_node.data.label}\n"
|
|
270
|
+
f"**Status:** `{target_node.data.status}`\n\n"
|
|
271
|
+
f"## Description\n{target_node.data.description or 'No description'}\n\n"
|
|
272
|
+
f"## Connected Dependencies (1-Hop)\n{neighbors_str}\n\n"
|
|
273
|
+
f"## File Signatures (AST Extracted)\n{files_str}"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@app.get("/api/generate-prompt/{topic_id}")
|
|
278
|
+
def generate_prompt_get(topic_id: str):
|
|
279
|
+
graph = load_graph()
|
|
280
|
+
prompt = compile_topic_prompt(topic_id, graph, get_active_root())
|
|
281
|
+
return {"prompt": prompt}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@app.post("/api/generate-prompt")
|
|
285
|
+
def generate_prompt_post(req: GeneratePromptRequest):
|
|
286
|
+
graph = load_graph()
|
|
287
|
+
root = get_active_root()
|
|
288
|
+
prompts = [compile_topic_prompt(tid, graph, root) for tid in req.topic_ids]
|
|
289
|
+
return {"prompt": "\n\n---\n\n".join(prompts)}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ─── MCP Client Configurations ──────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
@app.get("/api/mcp/configs")
|
|
295
|
+
def get_mcp_configs():
|
|
296
|
+
py_exec = sys.executable or "python"
|
|
297
|
+
root_str = str(get_active_root())
|
|
298
|
+
return {
|
|
299
|
+
"claude_code": {
|
|
300
|
+
"mcpServers": {
|
|
301
|
+
"codegraphy": {
|
|
302
|
+
"command": py_exec,
|
|
303
|
+
"args": ["-m", "codegraphy_lite.mcp"],
|
|
304
|
+
"env": {"CODEGRAPHY_ROOT": root_str},
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
"cursor": {
|
|
309
|
+
"mcpServers": {
|
|
310
|
+
"codegraphy": {
|
|
311
|
+
"command": py_exec,
|
|
312
|
+
"args": ["-m", "codegraphy_lite.mcp"],
|
|
313
|
+
"env": {"CODEGRAPHY_ROOT": root_str},
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@app.get("/api/ai/discover")
|
|
321
|
+
def discover_ai_providers():
|
|
322
|
+
return {
|
|
323
|
+
"providers": [
|
|
324
|
+
{
|
|
325
|
+
"id": "ollama",
|
|
326
|
+
"name": "Ollama (Local)",
|
|
327
|
+
"models": ["llama3.2", "qwen2.5-coder", "mistral", "deepseek-r1"],
|
|
328
|
+
"status": "available",
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
"id": "lmstudio",
|
|
332
|
+
"name": "LM Studio (Local)",
|
|
333
|
+
"models": ["local-model"],
|
|
334
|
+
"status": "available",
|
|
335
|
+
},
|
|
336
|
+
]
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ─── Pro Feature Interceptors ───────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
def _pro_required_response(feature_name: str, desc: str) -> JSONResponse:
|
|
343
|
+
return JSONResponse(
|
|
344
|
+
status_code=402,
|
|
345
|
+
content={
|
|
346
|
+
"detail": f"{feature_name} is a CodeGraphy Pro feature.",
|
|
347
|
+
"pro_feature": True,
|
|
348
|
+
"feature": feature_name,
|
|
349
|
+
"description": desc,
|
|
350
|
+
"upgrade_url": "https://github.com/noname/codegraphy-pro",
|
|
351
|
+
},
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@app.get("/api/git/diff")
|
|
356
|
+
def get_git_diff_pro():
|
|
357
|
+
return _pro_required_response(
|
|
358
|
+
"Git Architecture Diff Overlay",
|
|
359
|
+
"Calculates visual architecture diffs and detects layer regressions between Git branches.",
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@app.post("/api/context/compile")
|
|
364
|
+
def post_context_compile_pro():
|
|
365
|
+
return _pro_required_response(
|
|
366
|
+
"AST Smart Context (-85% Tokens)",
|
|
367
|
+
"Proprietary multi-language AST compression that saves 85% of LLM token costs.",
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@app.get("/api/tests/impact")
|
|
372
|
+
@app.post("/api/tests/run")
|
|
373
|
+
def tests_impact_pro():
|
|
374
|
+
return _pro_required_response(
|
|
375
|
+
"Test Impact Analysis",
|
|
376
|
+
"Static dependency mapping maps changed files to affected test suites and runs selective tests.",
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@app.post("/api/linter/analyze")
|
|
381
|
+
@app.get("/api/linter/report")
|
|
382
|
+
@app.get("/api/linter/export")
|
|
383
|
+
def linter_pro():
|
|
384
|
+
return _pro_required_response(
|
|
385
|
+
"Enterprise Architecture Linter",
|
|
386
|
+
"Validates strict layer separation rules, cyclic dependency detection, and architectural constraints.",
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# ─── Static files & SPA fallback ───────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
@app.get("/", response_class=FileResponse)
|
|
393
|
+
async def serve_root_index():
|
|
394
|
+
web = get_web_dir()
|
|
395
|
+
index_file = web / "index.html"
|
|
396
|
+
if index_file.exists():
|
|
397
|
+
return FileResponse(index_file)
|
|
398
|
+
return HTMLResponse(
|
|
399
|
+
"<h1>CodeGraphy Lite</h1><p>Web UI bundle is initializing...</p>"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@app.get("/{full_path:path}", response_class=FileResponse)
|
|
404
|
+
async def serve_spa_fallback(full_path: str):
|
|
405
|
+
if full_path.startswith("api") or full_path.startswith("ws"):
|
|
406
|
+
raise HTTPException(status_code=404, detail="API endpoint not found")
|
|
407
|
+
web = get_web_dir()
|
|
408
|
+
target = web / full_path
|
|
409
|
+
if target.is_file():
|
|
410
|
+
return FileResponse(target)
|
|
411
|
+
index_file = web / "index.html"
|
|
412
|
+
if index_file.exists():
|
|
413
|
+
return FileResponse(index_file)
|
|
414
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Storage and markdown codec for CodeGraphy Lite.
|
|
2
|
+
|
|
3
|
+
Handles atomic persistence of `.codegraphy/graph.json`, `.codegraphy/workspace.json`,
|
|
4
|
+
and import/export of human-readable `.cg.md` workspace specifications.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
from codegraphy_lite.models import (
|
|
17
|
+
EdgeData,
|
|
18
|
+
FileReference,
|
|
19
|
+
GraphState,
|
|
20
|
+
NodeData,
|
|
21
|
+
Position,
|
|
22
|
+
ReactFlowNode,
|
|
23
|
+
TaskItem,
|
|
24
|
+
Topic,
|
|
25
|
+
TopicLane,
|
|
26
|
+
WorkspaceModel,
|
|
27
|
+
new_id,
|
|
28
|
+
utc_now_iso,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_ACTIVE_ROOT: Path = Path.cwd()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def set_active_root(path: str | Path) -> Path:
|
|
35
|
+
global _ACTIVE_ROOT
|
|
36
|
+
p = Path(path).resolve()
|
|
37
|
+
if p.is_file():
|
|
38
|
+
p = p.parent
|
|
39
|
+
_ACTIVE_ROOT = p
|
|
40
|
+
return _ACTIVE_ROOT
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_active_root() -> Path:
|
|
44
|
+
return _ACTIVE_ROOT
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_storage_dir(root: Optional[Path] = None) -> Path:
|
|
48
|
+
r = root or get_active_root()
|
|
49
|
+
d = r / ".codegraphy"
|
|
50
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
return d
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _atomic_write(target_path: Path, content: str) -> None:
|
|
55
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
tmp_path = None
|
|
57
|
+
try:
|
|
58
|
+
with tempfile.NamedTemporaryFile(
|
|
59
|
+
mode="w",
|
|
60
|
+
dir=str(target_path.parent),
|
|
61
|
+
delete=False,
|
|
62
|
+
encoding="utf-8",
|
|
63
|
+
suffix=".tmp",
|
|
64
|
+
) as tmp:
|
|
65
|
+
tmp_path = tmp.name
|
|
66
|
+
tmp.write(content)
|
|
67
|
+
tmp.flush()
|
|
68
|
+
os.fsync(tmp.fileno())
|
|
69
|
+
os.replace(tmp_path, str(target_path))
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
if tmp_path and Path(tmp_path).exists():
|
|
72
|
+
os.remove(tmp_path)
|
|
73
|
+
raise exc
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_graph(root: Optional[Path] = None) -> GraphState:
|
|
77
|
+
gfile = get_storage_dir(root) / "graph.json"
|
|
78
|
+
if gfile.exists():
|
|
79
|
+
try:
|
|
80
|
+
data = json.loads(gfile.read_text(encoding="utf-8"))
|
|
81
|
+
return GraphState.model_validate(data)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
return GraphState(nodes=[], edges=[])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def save_graph(graph: GraphState, root: Optional[Path] = None) -> None:
|
|
88
|
+
gfile = get_storage_dir(root) / "graph.json"
|
|
89
|
+
_atomic_write(gfile, graph.model_dump_json(indent=2))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_workspace(root: Optional[Path] = None) -> WorkspaceModel:
|
|
93
|
+
wfile = get_storage_dir(root) / "workspace.json"
|
|
94
|
+
if wfile.exists():
|
|
95
|
+
try:
|
|
96
|
+
data = json.loads(wfile.read_text(encoding="utf-8"))
|
|
97
|
+
return WorkspaceModel.model_validate(data)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
return WorkspaceModel(root_path=str(root or get_active_root()))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def save_workspace(ws: WorkspaceModel, root: Optional[Path] = None) -> None:
|
|
104
|
+
wfile = get_storage_dir(root) / "workspace.json"
|
|
105
|
+
ws.updated_at = utc_now_iso()
|
|
106
|
+
_atomic_write(wfile, ws.model_dump_json(indent=2))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ─── .cg.md Markdown Codec ──────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
def serialize_workspace_to_md(ws: WorkspaceModel) -> str:
|
|
112
|
+
"""Serializes WorkspaceModel to standard .cg.md format."""
|
|
113
|
+
lines = [
|
|
114
|
+
"---",
|
|
115
|
+
"format: codegraphy-workspace",
|
|
116
|
+
f"version: {ws.version}",
|
|
117
|
+
f"name: {ws.name}",
|
|
118
|
+
f"updated_at: {ws.updated_at}",
|
|
119
|
+
"---",
|
|
120
|
+
"",
|
|
121
|
+
f"# Workspace: {ws.name}",
|
|
122
|
+
"",
|
|
123
|
+
"## Lanes",
|
|
124
|
+
]
|
|
125
|
+
for lane in sorted(ws.lanes, key=lambda x: x.order):
|
|
126
|
+
collapsed = "collapsed" if lane.collapsed else "expanded"
|
|
127
|
+
lines.append(f"- [{lane.id}] **{lane.title}** | order={lane.order} | color={lane.color} | {collapsed}")
|
|
128
|
+
|
|
129
|
+
lines.extend(["", "## Tasks"])
|
|
130
|
+
if not ws.tasks:
|
|
131
|
+
lines.append("_No tasks defined._")
|
|
132
|
+
for task in ws.tasks:
|
|
133
|
+
topics_str = ", ".join(task.topic_ids) if task.topic_ids else "none"
|
|
134
|
+
lines.append(f"### Task: {task.title} `{task.id}`")
|
|
135
|
+
lines.append(f"- status: `{task.status}`")
|
|
136
|
+
lines.append(f"- linked_topics: `{topics_str}`")
|
|
137
|
+
if task.description:
|
|
138
|
+
lines.append(f"- description: {task.description}")
|
|
139
|
+
lines.append("")
|
|
140
|
+
|
|
141
|
+
lines.extend(["## Topics"])
|
|
142
|
+
for topic in ws.topics:
|
|
143
|
+
lines.append(f"### Topic: {topic.label} `{topic.id}`")
|
|
144
|
+
lines.append(f"- status: `{topic.status}`")
|
|
145
|
+
lines.append(f"- lane: `{topic.lane_id or 'none'}`")
|
|
146
|
+
if topic.description:
|
|
147
|
+
lines.append(f"- description: {topic.description}")
|
|
148
|
+
if topic.attached_files:
|
|
149
|
+
lines.append("- files:")
|
|
150
|
+
for f in topic.attached_files:
|
|
151
|
+
lines.append(f" - `{f.filepath}`")
|
|
152
|
+
lines.append("")
|
|
153
|
+
|
|
154
|
+
lines.extend(["## Dependencies"])
|
|
155
|
+
if not ws.edges:
|
|
156
|
+
lines.append("_No dependencies defined._")
|
|
157
|
+
for edge in ws.edges:
|
|
158
|
+
lines.append(f"- `{edge.source}` -> `{edge.target}`")
|
|
159
|
+
|
|
160
|
+
return "\n".join(lines)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def parse_md_to_workspace(markdown: str) -> WorkspaceModel:
|
|
164
|
+
"""Parses standard .cg.md into WorkspaceModel."""
|
|
165
|
+
ws = WorkspaceModel()
|
|
166
|
+
current_section = None
|
|
167
|
+
|
|
168
|
+
for line in markdown.splitlines():
|
|
169
|
+
line_str = line.strip()
|
|
170
|
+
if not line_str or line_str.startswith("---"):
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
if line_str.startswith("# Workspace:"):
|
|
174
|
+
ws.name = line_str.replace("# Workspace:", "").strip()
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
if line_str.startswith("## Lanes"):
|
|
178
|
+
current_section = "lanes"
|
|
179
|
+
continue
|
|
180
|
+
elif line_str.startswith("## Tasks"):
|
|
181
|
+
current_section = "tasks"
|
|
182
|
+
continue
|
|
183
|
+
elif line_str.startswith("## Topics"):
|
|
184
|
+
current_section = "topics"
|
|
185
|
+
continue
|
|
186
|
+
elif line_str.startswith("## Dependencies"):
|
|
187
|
+
current_section = "dependencies"
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
if current_section == "topics" and line_str.startswith("### Topic:"):
|
|
191
|
+
match = re.search(r"### Topic:\s+(.*?)\s+`([^`]+)`", line_str)
|
|
192
|
+
if match:
|
|
193
|
+
lbl, tid = match.group(1), match.group(2)
|
|
194
|
+
ws.topics.append(Topic(id=tid, label=lbl, description=""))
|
|
195
|
+
elif current_section == "dependencies" and "->" in line_str:
|
|
196
|
+
match = re.search(r"`([^`]+)`\s*->\s*`([^`]+)`", line_str)
|
|
197
|
+
if match:
|
|
198
|
+
src, tgt = match.group(1), match.group(2)
|
|
199
|
+
ws.edges.append(EdgeData(id=f"edge_{src}_{tgt}", source=src, target=tgt))
|
|
200
|
+
|
|
201
|
+
return ws
|