From 09167cfe82b3fe99df2016e402facc64a2ede347 Mon Sep 17 00:00:00 2001 From: openclaw Date: Mon, 23 Mar 2026 17:02:04 +0000 Subject: [PATCH] =?UTF-8?q?feat(editor):=20add=20EditorService=20and=20RES?= =?UTF-8?q?T=20API=20=E2=80=94=20file=20read/write=20and=20impact=20analys?= =?UTF-8?q?is?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- backend/app/main.py | 13 ++- .../modules/editor/application/services.py | 35 ++++++++ .../modules/editor/interfaces/http/router.py | 84 +++++++++++++++++++ backend/tests/test_api_editor.py | 32 +++++++ backend/tests/test_editor_service.py | 52 ++++++++++++ 5 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_api_editor.py create mode 100644 backend/tests/test_editor_service.py diff --git a/backend/app/main.py b/backend/app/main.py index f1f76a2..940b57e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ from pathlib import Path from fastapi import FastAPI, Request 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.modules.project.infrastructure.json_repository import JsonProjectRepository 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.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.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: @@ -34,10 +36,15 @@ def create_app() -> FastAPI: graph_service = GraphService() 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 app.include_router(project_router, prefix="/api") app.include_router(scanner_router, prefix="/api") app.include_router(graph_router, prefix="/api") + app.include_router(editor_router, prefix="/api") # Health check @app.get("/api/health") @@ -53,6 +60,10 @@ def create_app() -> FastAPI: async def validation_handler(request: Request, exc: ValidationError): 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 diff --git a/backend/app/modules/editor/application/services.py b/backend/app/modules/editor/application/services.py index e69de29..30996c2 100644 --- a/backend/app/modules/editor/application/services.py +++ b/backend/app/modules/editor/application/services.py @@ -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) diff --git a/backend/app/modules/editor/interfaces/http/router.py b/backend/app/modules/editor/interfaces/http/router.py index e69de29..aea451d 100644 --- a/backend/app/modules/editor/interfaces/http/router.py +++ b/backend/app/modules/editor/interfaces/http/router.py @@ -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, + }, + } diff --git a/backend/tests/test_api_editor.py b/backend/tests/test_api_editor.py new file mode 100644 index 0000000..2dfc240 --- /dev/null +++ b/backend/tests/test_api_editor.py @@ -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 diff --git a/backend/tests/test_editor_service.py b/backend/tests/test_editor_service.py new file mode 100644 index 0000000..921414f --- /dev/null +++ b/backend/tests/test_editor_service.py @@ -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)