feat: implement full arch design dashboard #1
|
|
@ -4,7 +4,7 @@ from pathlib import Path
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app.shared.kernel.exceptions import NotFoundError, ValidationError
|
from app.shared.kernel.exceptions import FileSystemError, NotFoundError, ValidationError
|
||||||
from app.shared.infrastructure.config import Settings
|
from app.shared.infrastructure.config import Settings
|
||||||
from app.modules.project.infrastructure.json_repository import JsonProjectRepository
|
from app.modules.project.infrastructure.json_repository import JsonProjectRepository
|
||||||
from app.modules.project.application.services import ProjectService
|
from app.modules.project.application.services import ProjectService
|
||||||
|
|
@ -13,6 +13,8 @@ from app.modules.scanner.application.services import ScanService
|
||||||
from app.modules.scanner.interfaces.http.router import router as scanner_router, init_router as init_scanner_router
|
from app.modules.scanner.interfaces.http.router import router as scanner_router, init_router as init_scanner_router
|
||||||
from app.modules.graph.application.services import GraphService
|
from app.modules.graph.application.services import GraphService
|
||||||
from app.modules.graph.interfaces.http.router import router as graph_router, init_router as init_graph_router
|
from app.modules.graph.interfaces.http.router import router as graph_router, init_router as init_graph_router
|
||||||
|
from app.modules.editor.application.services import EditorService
|
||||||
|
from app.modules.editor.interfaces.http.router import router as editor_router, init_router as init_editor_router
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
|
@ -34,10 +36,15 @@ def create_app() -> FastAPI:
|
||||||
graph_service = GraphService()
|
graph_service = GraphService()
|
||||||
init_graph_router(project_service, scan_service, graph_service)
|
init_graph_router(project_service, scan_service, graph_service)
|
||||||
|
|
||||||
|
# Wire Editor module
|
||||||
|
editor_service = EditorService(scan_service)
|
||||||
|
init_editor_router(project_service, scan_service, editor_service)
|
||||||
|
|
||||||
# Register routers
|
# Register routers
|
||||||
app.include_router(project_router, prefix="/api")
|
app.include_router(project_router, prefix="/api")
|
||||||
app.include_router(scanner_router, prefix="/api")
|
app.include_router(scanner_router, prefix="/api")
|
||||||
app.include_router(graph_router, prefix="/api")
|
app.include_router(graph_router, prefix="/api")
|
||||||
|
app.include_router(editor_router, prefix="/api")
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|
@ -53,6 +60,10 @@ def create_app() -> FastAPI:
|
||||||
async def validation_handler(request: Request, exc: ValidationError):
|
async def validation_handler(request: Request, exc: ValidationError):
|
||||||
return JSONResponse(status_code=400, content={"detail": exc.message})
|
return JSONResponse(status_code=400, content={"detail": exc.message})
|
||||||
|
|
||||||
|
@app.exception_handler(FileSystemError)
|
||||||
|
async def filesystem_handler(request: Request, exc: FileSystemError):
|
||||||
|
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.modules.editor.domain.entities import AffectedFile, EditableFile, ImpactResult
|
||||||
|
from app.modules.editor.infrastructure.file_io import read_file, write_file
|
||||||
|
from app.modules.project.domain.entities import Project
|
||||||
|
from app.modules.scanner.application.services import ScanService
|
||||||
|
from app.modules.scanner.domain.entities import ScanResult
|
||||||
|
|
||||||
|
|
||||||
|
class EditorService:
|
||||||
|
def __init__(self, scan_service: ScanService) -> None:
|
||||||
|
self._scan_service = scan_service
|
||||||
|
|
||||||
|
def get_file(self, project: Project, relative_path: str) -> EditableFile:
|
||||||
|
return read_file(Path(project.design_dir), relative_path)
|
||||||
|
|
||||||
|
def save_file(self, project: Project, relative_path: str, content: str) -> ScanResult:
|
||||||
|
write_file(Path(project.design_dir), relative_path, content)
|
||||||
|
return self._scan_service.scan(project)
|
||||||
|
|
||||||
|
def get_impact(
|
||||||
|
self, project: Project, relative_path: str, scan_result: ScanResult,
|
||||||
|
) -> ImpactResult:
|
||||||
|
"""Walk DesignDocument.downstream + TraceabilityLink to find affected files."""
|
||||||
|
affected: list[AffectedFile] = []
|
||||||
|
|
||||||
|
# Find DesignDocument for this file
|
||||||
|
for doc in scan_result.design_documents:
|
||||||
|
if doc.file_path == relative_path or relative_path.endswith(doc.file_path):
|
||||||
|
for downstream in doc.downstream:
|
||||||
|
affected.append(
|
||||||
|
AffectedFile(path=downstream, reason=f"downstream of {doc.doc_id}")
|
||||||
|
)
|
||||||
|
|
||||||
|
return ImpactResult(source_file=relative_path, affected_files=affected)
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
"""Editor HTTP router — file read/write and impact analysis endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.modules.project.application.services import ProjectService
|
||||||
|
from app.modules.scanner.application.services import ScanService
|
||||||
|
from app.modules.editor.application.services import EditorService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}/files", tags=["editor"])
|
||||||
|
|
||||||
|
_project_service: ProjectService | None = None
|
||||||
|
_scan_service: ScanService | None = None
|
||||||
|
_editor_service: EditorService | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_router(
|
||||||
|
project_service: ProjectService,
|
||||||
|
scan_service: ScanService,
|
||||||
|
editor_service: EditorService,
|
||||||
|
) -> None:
|
||||||
|
global _project_service, _scan_service, _editor_service
|
||||||
|
_project_service = project_service
|
||||||
|
_scan_service = scan_service
|
||||||
|
_editor_service = editor_service
|
||||||
|
|
||||||
|
|
||||||
|
class SaveFileRequest(BaseModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_trigger_scan(project_id: str):
|
||||||
|
"""Get cached scan or trigger a new one."""
|
||||||
|
result = _scan_service.get_latest_scan(project_id)
|
||||||
|
if result is None:
|
||||||
|
project = _project_service.get_project(project_id)
|
||||||
|
result = _scan_service.scan(project)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{path:path}/impact")
|
||||||
|
def get_impact(project_id: str, path: str):
|
||||||
|
"""Return impact analysis for a given file."""
|
||||||
|
project = _project_service.get_project(project_id)
|
||||||
|
scan_result = _get_or_trigger_scan(project_id)
|
||||||
|
impact = _editor_service.get_impact(project, path, scan_result)
|
||||||
|
return asdict(impact)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{path:path}")
|
||||||
|
def get_file(project_id: str, path: str):
|
||||||
|
"""Read a design file and return its content."""
|
||||||
|
project = _project_service.get_project(project_id)
|
||||||
|
editable = _editor_service.get_file(project, path)
|
||||||
|
return {
|
||||||
|
"path": editable.path,
|
||||||
|
"format": editable.format,
|
||||||
|
"content": editable.content,
|
||||||
|
"last_modified": editable.last_modified.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{path:path}")
|
||||||
|
def save_file(project_id: str, path: str, body: SaveFileRequest):
|
||||||
|
"""Write content to a design file and re-scan."""
|
||||||
|
project = _project_service.get_project(project_id)
|
||||||
|
scan_result = _editor_service.save_file(project, path, body.content)
|
||||||
|
return {
|
||||||
|
"project_id": scan_result.project_id,
|
||||||
|
"scanned_at": scan_result.scanned_at.isoformat(),
|
||||||
|
"summary": {
|
||||||
|
"total_files": scan_result.summary.total_files,
|
||||||
|
"ok": scan_result.summary.ok,
|
||||||
|
"sparse": scan_result.summary.sparse,
|
||||||
|
"missing": scan_result.summary.missing,
|
||||||
|
"placeholder_heavy": scan_result.summary.placeholder_heavy,
|
||||||
|
"template_residue": scan_result.summary.template_residue,
|
||||||
|
},
|
||||||
|
}
|
||||||
32
backend/tests/test_api_editor.py
Normal file
32
backend/tests/test_api_editor.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
DESIGN_DIR = "/workspace/arch-design-agent-skill-dashboard/design"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def project_id(client):
|
||||||
|
r = client.post("/api/projects", json={"name": "test", "design_dir": DESIGN_DIR})
|
||||||
|
return r.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_file(client, project_id):
|
||||||
|
r = client.get(f"/api/projects/{project_id}/files/business-architecture/02-capability-map.csv")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["format"] == "csv"
|
||||||
|
assert "content" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_file_not_found(client, project_id):
|
||||||
|
r = client.get(f"/api/projects/{project_id}/files/nonexistent.csv")
|
||||||
|
assert r.status_code == 500 # FileSystemError
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_impact(client, project_id):
|
||||||
|
# Trigger scan first
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/files/business-architecture/01-scope-and-goals.md/impact")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert "source_file" in data
|
||||||
|
assert "affected_files" in data
|
||||||
52
backend/tests/test_editor_service.py
Normal file
52
backend/tests/test_editor_service.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.modules.project.domain.entities import Project
|
||||||
|
from app.modules.scanner.application.services import ScanService
|
||||||
|
from app.modules.editor.application.services import EditorService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def editor_service():
|
||||||
|
return EditorService(ScanService())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_project(tmp_path):
|
||||||
|
design = tmp_path / "design"
|
||||||
|
design.mkdir()
|
||||||
|
(design / "test.csv").write_text("col1,col2\nval1,val2\n")
|
||||||
|
(design / "test.md").write_text("---\ndoc_id: DOC-TEST\ntitle: Test\n---\n# Test\n")
|
||||||
|
return Project(
|
||||||
|
id="test", name="test",
|
||||||
|
design_dir=str(design), code_dir=None,
|
||||||
|
created_at=datetime(2026, 1, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_file_csv(editor_service, test_project):
|
||||||
|
f = editor_service.get_file(test_project, "test.csv")
|
||||||
|
assert f.format == "csv"
|
||||||
|
assert "col1" in f.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_file_md(editor_service, test_project):
|
||||||
|
f = editor_service.get_file(test_project, "test.md")
|
||||||
|
assert f.format == "md"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_file(editor_service, test_project):
|
||||||
|
result = editor_service.save_file(test_project, "test.csv", "a,b\n1,2\n")
|
||||||
|
assert result.project_id == "test"
|
||||||
|
# Verify file was actually written
|
||||||
|
content = (Path(test_project.design_dir) / "test.csv").read_text()
|
||||||
|
assert content == "a,b\n1,2\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_impact(editor_service, test_project):
|
||||||
|
scan_svc = ScanService()
|
||||||
|
scan_result = scan_svc.scan(test_project)
|
||||||
|
impact = editor_service.get_impact(test_project, "test.md", scan_result)
|
||||||
|
assert impact.source_file == "test.md"
|
||||||
|
assert isinstance(impact.affected_files, list)
|
||||||
Loading…
Reference in New Issue
Block a user