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.
@@ -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