diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0454cff --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +*.pyo +.venv/ +node_modules/ +dist/ +*.tsbuildinfo +*.d.ts +!src/**/*.d.ts +frontend/vite.config.js diff --git a/backend/.python-version b/backend/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/backend/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..36b47f2 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,7 @@ +FROM localhost:8082/python:3.12-slim +WORKDIR /app +COPY pyproject.toml uv.lock ./ +RUN pip install --index-url http://localhost:8081/repository/pypi-group/simple --trusted-host localhost uv && \ + uv sync --frozen --no-dev --index-url http://localhost:8081/repository/pypi-group/simple +COPY app/ app/ +CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8900"] diff --git a/backend/app/main.py b/backend/app/main.py index e69de29..84c707b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -0,0 +1,79 @@ +import os +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +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 +from app.modules.project.interfaces.http.router import router as project_router, init_router as init_project_router +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 +from app.modules.impl_tracker.application.services import ImplTrackerService +from app.modules.impl_tracker.interfaces.http.router import router as impl_tracker_router, init_router as init_impl_tracker_router + + +def create_app() -> FastAPI: + app = FastAPI(title="Arch Design Dashboard API", version="0.1.0") + + # Settings + registry_path = Path(os.environ.get("REGISTRY_PATH", str(Settings().registry_path))) + + # Wire Project module + project_repo = JsonProjectRepository(registry_path) + project_service = ProjectService(project_repo) + init_project_router(project_service) + + # Wire Scanner module + scan_service = ScanService() + init_scanner_router(project_service, scan_service) + + # Wire Graph module + 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) + + # Wire Impl-tracker module + impl_tracker_service = ImplTrackerService() + init_impl_tracker_router(project_service, scan_service, impl_tracker_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") + app.include_router(impl_tracker_router, prefix="/api") + + # Health check + @app.get("/api/health") + def health(): + return {"status": "ok"} + + # Exception handlers + @app.exception_handler(NotFoundError) + async def not_found_handler(request: Request, exc: NotFoundError): + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + @app.exception_handler(ValidationError) + 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 + + +# For uvicorn: use `uvicorn app.main:create_app --factory` +# or for simple usage: +app = create_app() diff --git a/backend/app/modules/design/domain/entities.py b/backend/app/modules/design/domain/entities.py index e69de29..9c3bd72 100644 --- a/backend/app/modules/design/domain/entities.py +++ b/backend/app/modules/design/domain/entities.py @@ -0,0 +1,304 @@ +from dataclasses import dataclass + + +# ── Business Layer ── + +@dataclass +class Capability: + capability_id: str + name: str + description: str + priority: str # must / should / could + phase: str + related_value_flows: list[str] + + +@dataclass +class ValueFlow: + value_flow_id: str + name: str + trigger: str + actor: str + steps: str + outcome: str + phase: str + related_capabilities: list[str] + + +@dataclass +class UserJourney: + journey_id: str + name: str + actor: str + precondition: str + steps: str + postcondition: str + phase: str + related_value_flows: list[str] + + +@dataclass +class ScopeAndGoals: + doc_id: str + title: str + core_problem: str + users: str + constraints: str + + +# ── Application Layer ── + +@dataclass +class Module: + module_id: str + name: str + layer: str # backend / frontend + description: str + phase: str + depends_on: list[str] + capabilities: list[str] + + +@dataclass +class Integration: + integration_id: str + source_id: str + target_id: str + target_type: str + direction: str + protocol: str + trigger: str + phase: str + description: str + + +@dataclass +class ExternalSystem: + system_id: str + name: str + type: str + protocol: str + direction: str + phase: str + description: str + + +@dataclass +class ApiContract: + doc_id: str + path: str + method: str + operation_id: str + summary: str + + +@dataclass +class CodebaseAlignment: + module_id: str + repo_root: str + code_root: str + package_name: str + + +@dataclass +class ModuleBoundaryRule: + doc_id: str + title: str + content: str + + +@dataclass +class SystemContext: + doc_id: str + title: str + content: str + + +@dataclass +class SolutionLayer: + doc_id: str + title: str + content: str + + +# ── Data Layer ── + +@dataclass +class Entity: + entity_id: str + name: str + domain: str + owner_module: str + description: str + phase: str + source_file: str + + +@dataclass +class DataFlow: + data_flow_id: str + source: str + target: str + data_content: str + trigger: str + protocol: str + phase: str + description: str + + +@dataclass +class DataSecurity: + security_id: str + sensitivity: str + entities: str + protection: str + + +# ── Technology Layer ── + +@dataclass +class TechSelection: + category: str + technology: str + version: str + purpose: str + rationale: str + alternatives_considered: str + phase: str + + +@dataclass +class RuntimeComponent: + component_id: str + name: str + type: str + technology: str + port: str + + +@dataclass +class RuntimeTopology: + doc_id: str + title: str + content: str + + +@dataclass +class Environment: + env_id: str + name: str + purpose: str + infra: str + + +@dataclass +class OperationalBaseline: + doc_id: str + title: str + content: str + + +@dataclass +class ReleasePlan: + doc_id: str + title: str + content: str + + +# ── Cross-Layer ── + +@dataclass +class TraceabilityLink: + trace_id: str + capability_id: str + module_id: str + entity_ids: list[str] + value_flow_ids: list[str] + notes: str + + +@dataclass +class ChangeLogEntry: + change_id: str + date: str + scope: str + description: str + + +@dataclass +class ADR: + adr_id: str + title: str + status: str + context: str + decision: str + + +@dataclass +class DesignDocument: + doc_id: str + title: str + version: str + status: str + owners: list[str] + upstream: list[str] + downstream: list[str] + file_path: str + + +# ── Domain Layer ── + +@dataclass +class Domain: + domain_name: str + overview: str + modules: list[str] + entities: list[str] + + +@dataclass +class UbiquitousTerm: + term_id: str + term: str + english_term: str + code_symbol: str + domain: str + definition: str + + +@dataclass +class SharedTerm: + term_id: str + term: str + english_term: str + definition: str + used_by_domains: list[str] + + +@dataclass +class Scenario: + scenario_id: str + name: str + trigger: str + actors: str + steps: str + outcome: str + related_capabilities: list[str] + + +@dataclass +class DomainModule: + module_id: str + module_name: str + domain: str + description: str + layer_in_code: str + + +@dataclass +class DomainEntity: + entity_id: str + entity_name: str + type: str + description: str + key_attributes: str diff --git a/backend/app/modules/design/domain/services.py b/backend/app/modules/design/domain/services.py index e69de29..38da6a7 100644 --- a/backend/app/modules/design/domain/services.py +++ b/backend/app/modules/design/domain/services.py @@ -0,0 +1,128 @@ +from dataclasses import dataclass + +from app.modules.design.domain.entities import ( + Capability, + Entity, + Module, + TraceabilityLink, +) +from app.modules.design.domain.value_objects import FileStatus + +TEMPLATE_MARKERS = ["TODO", "EXAMPLE", " FileStatus: + if not content or not content.strip(): + return FileStatus.MISSING + + lines = [l for l in content.strip().splitlines() if l.strip()] + is_csv = file_path.endswith(".csv") + + # Sparse check + if is_csv and len(lines) < 2: + return FileStatus.SPARSE + if not is_csv and len(lines) < 5: + return FileStatus.SPARSE + + # Count placeholder tokens + total_cells = 0 + placeholder_cells = 0 + for line in lines: + cells = line.split(",") if is_csv else [line] + for cell in cells: + total_cells += 1 + if any(m.lower() in cell.lower() for m in TEMPLATE_MARKERS): + placeholder_cells += 1 + + # Placeholder heavy: >50% + if total_cells > 0 and placeholder_cells / total_cells > 0.5: + return FileStatus.PLACEHOLDER_HEAVY + + # Template residue: any marker present but <=50% + if placeholder_cells > 0: + return FileStatus.TEMPLATE_RESIDUE + + return FileStatus.OK + + @staticmethod + def check_capability_module_linkage( + capabilities: list[Capability], + traceability_links: list[TraceabilityLink], + ) -> list[ConstraintViolation]: + linked_caps = {link.capability_id for link in traceability_links} + return [ + ConstraintViolation( + rule="capability_module_linkage", + entity_id=cap.capability_id, + message=f"Capability {cap.capability_id} has no TraceabilityLink to any module", + ) + for cap in capabilities + if cap.capability_id not in linked_caps + ] + + @staticmethod + def check_entity_owner(entities: list[Entity]) -> list[ConstraintViolation]: + return [ + ConstraintViolation( + rule="entity_owner", + entity_id=ent.entity_id, + message=f"Entity {ent.entity_id} has no owner module", + ) + for ent in entities + if not ent.owner_module + ] + + @staticmethod + def check_traceability_references( + links: list[TraceabilityLink], + capabilities: list[Capability], + modules: list[Module], + entities: list[Entity], + ) -> list[ConstraintViolation]: + cap_ids = {c.capability_id for c in capabilities} + mod_ids = {m.module_id for m in modules} + ent_ids = {e.entity_id for e in entities} + violations: list[ConstraintViolation] = [] + for link in links: + if link.capability_id not in cap_ids: + violations.append(ConstraintViolation( + "traceability_ref", link.trace_id, + f"References unknown capability {link.capability_id}", + )) + if link.module_id not in mod_ids: + violations.append(ConstraintViolation( + "traceability_ref", link.trace_id, + f"References unknown module {link.module_id}", + )) + for eid in link.entity_ids: + if eid not in ent_ids: + violations.append(ConstraintViolation( + "traceability_ref", link.trace_id, + f"References unknown entity {eid}", + )) + return violations + + @classmethod + def validate_all( + cls, + capabilities: list[Capability], + modules: list[Module], + entities: list[Entity], + traceability_links: list[TraceabilityLink], + ) -> list[ConstraintViolation]: + violations: list[ConstraintViolation] = [] + violations.extend(cls.check_capability_module_linkage(capabilities, traceability_links)) + violations.extend(cls.check_entity_owner(entities)) + violations.extend(cls.check_traceability_references( + traceability_links, capabilities, modules, entities, + )) + return violations diff --git a/backend/app/modules/design/domain/value_objects.py b/backend/app/modules/design/domain/value_objects.py index e69de29..fa826e5 100644 --- a/backend/app/modules/design/domain/value_objects.py +++ b/backend/app/modules/design/domain/value_objects.py @@ -0,0 +1,23 @@ +from enum import Enum + + +class FileStatus(str, Enum): + OK = "ok" + SPARSE = "sparse" + MISSING = "missing" + TEMPLATE_RESIDUE = "template-residue" + PLACEHOLDER_HEAVY = "placeholder-heavy" + + +class ArchitectureLayer(str, Enum): + BUSINESS = "business" + APPLICATION = "application" + DATA = "data" + TECHNOLOGY = "technology" + + +class ModuleLayer(str, Enum): + DOMAIN = "domain" + APPLICATION = "application" + INFRASTRUCTURE = "infrastructure" + INTERFACES = "interfaces" 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/domain/entities.py b/backend/app/modules/editor/domain/entities.py index e69de29..f98c57c 100644 --- a/backend/app/modules/editor/domain/entities.py +++ b/backend/app/modules/editor/domain/entities.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class EditableFile: + path: str + format: str # csv, md, yaml, openapi + content: str + last_modified: datetime + + +@dataclass +class AffectedFile: + path: str + reason: str + + +@dataclass +class ImpactResult: + source_file: str + affected_files: list[AffectedFile] diff --git a/backend/app/modules/editor/infrastructure/file_io.py b/backend/app/modules/editor/infrastructure/file_io.py new file mode 100644 index 0000000..7a41a2f --- /dev/null +++ b/backend/app/modules/editor/infrastructure/file_io.py @@ -0,0 +1,35 @@ +from datetime import datetime, timezone +from pathlib import Path + +from app.modules.editor.domain.entities import EditableFile +from app.shared.infrastructure.filesystem import read_text, write_text + + +def detect_format(file_path: Path) -> str: + suffix = file_path.suffix.lower() + if suffix == ".csv": + return "csv" + elif suffix == ".md": + return "md" + elif suffix in (".yaml", ".yml"): + if "openapi" in file_path.name or "api-contracts" in file_path.name: + return "openapi" + return "yaml" + return "unknown" + + +def read_file(base_dir: Path, relative_path: str) -> EditableFile: + full_path = base_dir / relative_path + content = read_text(full_path) + stat = full_path.stat() + return EditableFile( + path=relative_path, + format=detect_format(full_path), + content=content, + last_modified=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), + ) + + +def write_file(base_dir: Path, relative_path: str, content: str) -> None: + full_path = base_dir / relative_path + write_text(full_path, content) 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/app/modules/graph/application/services.py b/backend/app/modules/graph/application/services.py index e69de29..2a6a0cc 100644 --- a/backend/app/modules/graph/application/services.py +++ b/backend/app/modules/graph/application/services.py @@ -0,0 +1,161 @@ +"""GraphService — builds a relationship graph from ScanResult entities.""" + +from __future__ import annotations + +from app.modules.graph.domain.entities import GraphEdge, GraphGroup, GraphNode, GraphView +from app.modules.scanner.domain.entities import ScanResult + + +# Fixed set of groups +_GROUPS = [ + GraphGroup(id="business", label="Business", layer="business"), + GraphGroup(id="application", label="Application", layer="application"), + GraphGroup(id="data", label="Data", layer="data"), + GraphGroup(id="technology", label="Technology", layer="technology"), + GraphGroup(id="cross-layer", label="Cross-Layer", layer="cross-layer"), +] + + +class GraphService: + """Constructs a panorama graph and supports neighbor queries.""" + + def build_panorama(self, scan_result: ScanResult) -> GraphView: + """Build a full panorama GraphView from a ScanResult (9-step algorithm).""" + nodes: list[GraphNode] = [] + edges: list[GraphEdge] = [] + node_ids: set[str] = set() + + # Step 1: groups are always the fixed 5 + groups = list(_GROUPS) + + # Step 2: Capability → node(type="capability", group="business") + for cap in scan_result.capabilities: + node_id = cap.capability_id + nodes.append(GraphNode( + id=node_id, + type="capability", + label=cap.name, + status="unknown", + group_id="business", + )) + node_ids.add(node_id) + + # Step 3: Module → node(type="module", group="application") + for mod in scan_result.modules: + node_id = mod.module_id + nodes.append(GraphNode( + id=node_id, + type="module", + label=mod.name, + status="unknown", + group_id="application", + )) + node_ids.add(node_id) + + # Step 4: Entity → node(type="entity", group="data") + for ent in scan_result.entities: + node_id = ent.entity_id + nodes.append(GraphNode( + id=node_id, + type="entity", + label=ent.name, + status="unknown", + group_id="data", + )) + node_ids.add(node_id) + + # Step 5: RuntimeComponent → node(type="runtime_component", group="technology") + for rc in scan_result.runtime_components: + node_id = rc.component_id + nodes.append(GraphNode( + id=node_id, + type="runtime_component", + label=rc.name, + status="unknown", + group_id="technology", + )) + node_ids.add(node_id) + + # Step 6: TraceabilityLink → edges + for link in scan_result.traceability_links: + # capability_id → module_id + if link.capability_id in node_ids and link.module_id in node_ids: + edges.append(GraphEdge( + source=link.capability_id, + target=link.module_id, + relation="traces_to", + )) + # module_id → each entity_id + for entity_id in link.entity_ids: + if link.module_id in node_ids and entity_id in node_ids: + edges.append(GraphEdge( + source=link.module_id, + target=entity_id, + relation="traces_to", + )) + + # Step 7: Integration → edges: source_id → target_id + for intg in scan_result.integrations: + if intg.source_id in node_ids and intg.target_id in node_ids: + edges.append(GraphEdge( + source=intg.source_id, + target=intg.target_id, + relation="integrates_with", + )) + + # Step 8: Module.depends_on → edges + for mod in scan_result.modules: + for dep_id in mod.depends_on: + if mod.module_id in node_ids and dep_id in node_ids: + edges.append(GraphEdge( + source=mod.module_id, + target=dep_id, + relation="depends_on", + )) + + # Step 9: DesignDocument.upstream/downstream → edges (if both are nodes) + for doc in scan_result.design_documents: + for upstream_id in doc.upstream: + if doc.doc_id in node_ids and upstream_id in node_ids: + edges.append(GraphEdge( + source=doc.doc_id, + target=upstream_id, + relation="documents", + )) + for downstream_id in doc.downstream: + if doc.doc_id in node_ids and downstream_id in node_ids: + edges.append(GraphEdge( + source=doc.doc_id, + target=downstream_id, + relation="documents", + )) + + return GraphView(nodes=nodes, edges=edges, groups=groups) + + def get_neighbors(self, graph_view: GraphView, node_id: str) -> GraphView: + """Return a subgraph containing the given node and all its direct neighbors.""" + # Check if node_id exists + node_exists = any(n.id == node_id for n in graph_view.nodes) + if not node_exists: + return GraphView(nodes=[], edges=[], groups=[]) + + # Find all edges where source==node_id or target==node_id + relevant_edges = [ + e for e in graph_view.edges + if e.source == node_id or e.target == node_id + ] + + # Collect all neighbor node IDs from those edges + the target node itself + neighbor_ids: set[str] = {node_id} + for edge in relevant_edges: + neighbor_ids.add(edge.source) + neighbor_ids.add(edge.target) + + # Filter nodes + relevant_nodes = [n for n in graph_view.nodes if n.id in neighbor_ids] + + # Filter groups to only those referenced by relevant nodes + relevant_group_ids = {n.group_id for n in relevant_nodes} + relevant_groups = [g for g in graph_view.groups if g.id in relevant_group_ids] + + return GraphView(nodes=relevant_nodes, edges=relevant_edges, groups=relevant_groups) diff --git a/backend/app/modules/graph/domain/entities.py b/backend/app/modules/graph/domain/entities.py index e69de29..e1582f4 100644 --- a/backend/app/modules/graph/domain/entities.py +++ b/backend/app/modules/graph/domain/entities.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass + + +@dataclass +class GraphNode: + id: str + type: str # capability, module, entity, integration, ... + label: str + status: str # FileStatus or "unknown" + group_id: str + + +@dataclass +class GraphEdge: + source: str + target: str + relation: str # traces_to, depends_on, owns, integrates_with, ... + + +@dataclass +class GraphGroup: + id: str + label: str + layer: str # business, application, data, technology, cross-layer + + +@dataclass +class GraphView: + nodes: list[GraphNode] + edges: list[GraphEdge] + groups: list[GraphGroup] diff --git a/backend/app/modules/graph/interfaces/http/router.py b/backend/app/modules/graph/interfaces/http/router.py index e69de29..400e8bb 100644 --- a/backend/app/modules/graph/interfaces/http/router.py +++ b/backend/app/modules/graph/interfaces/http/router.py @@ -0,0 +1,56 @@ +"""Graph HTTP router — panorama and neighbor query endpoints.""" + +from __future__ import annotations + +from dataclasses import asdict + +from fastapi import APIRouter + +from app.modules.project.application.services import ProjectService +from app.modules.scanner.application.services import ScanService +from app.modules.graph.application.services import GraphService + +router = APIRouter(prefix="/projects/{project_id}/graph", tags=["graph"]) + +_project_service: ProjectService | None = None +_scan_service: ScanService | None = None +_graph_service: GraphService | None = None + + +def init_router( + project_service: ProjectService, + scan_service: ScanService, + graph_service: GraphService, +) -> None: + global _project_service, _scan_service, _graph_service + _project_service = project_service + _scan_service = scan_service + _graph_service = graph_service + + +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("") +def get_graph(project_id: str): + """Build and return the full panorama graph for a project.""" + _project_service.get_project(project_id) # Ensure project exists (raises 404) + scan_result = _get_or_trigger_scan(project_id) + view = _graph_service.build_panorama(scan_result) + return asdict(view) + + +@router.get("/nodes/{node_id}/neighbors") +def get_neighbors(project_id: str, node_id: str): + """Return the subgraph of neighbors for a given node.""" + _project_service.get_project(project_id) # Ensure project exists (raises 404) + scan_result = _get_or_trigger_scan(project_id) + view = _graph_service.build_panorama(scan_result) + neighbors = _graph_service.get_neighbors(view, node_id) + return asdict(neighbors) diff --git a/backend/app/modules/impl_tracker/application/services.py b/backend/app/modules/impl_tracker/application/services.py index e69de29..74f3b2b 100644 --- a/backend/app/modules/impl_tracker/application/services.py +++ b/backend/app/modules/impl_tracker/application/services.py @@ -0,0 +1,58 @@ +from datetime import datetime, timezone + +from app.modules.impl_tracker.domain.entities import ImplProgress +from app.modules.impl_tracker.infrastructure.code_scanner import scan_code_directory +from app.modules.project.domain.entities import Project +from app.modules.scanner.domain.entities import ScanResult + + +class ImplTrackerService: + def __init__(self) -> None: + self._cache: dict[str, list[ImplProgress]] = {} + self._manual_overrides: dict[str, dict[str, float]] = {} # project_id -> {module_id: percentage} + + def evaluate(self, project: Project, scan_result: ScanResult) -> list[ImplProgress]: + progress_list: list[ImplProgress] = [] + now = datetime.now(timezone.utc) + + if not project.code_dir: + # No code dir -> all modules at 0% + for mod in scan_result.modules: + progress_list.append(ImplProgress( + module_id=mod.module_id, percentage=0.0, source="auto", evaluated_at=now, + )) + else: + code_structure = scan_code_directory(project.code_dir, scan_result) + for mod in scan_result.modules: + if mod.module_id in code_structure.matched_modules: + percentage = 50.0 # Basic: module directory exists + else: + percentage = 0.0 + progress_list.append(ImplProgress( + module_id=mod.module_id, percentage=percentage, source="auto", evaluated_at=now, + )) + + # Apply manual overrides + overrides = self._manual_overrides.get(project.id, {}) + for p in progress_list: + if p.module_id in overrides: + p.percentage = overrides[p.module_id] + p.source = "manual" + + self._cache[project.id] = progress_list + return progress_list + + def get_progress(self, project_id: str) -> list[ImplProgress] | None: + return self._cache.get(project_id) + + def set_manual_progress(self, project_id: str, module_id: str, percentage: float) -> None: + if project_id not in self._manual_overrides: + self._manual_overrides[project_id] = {} + self._manual_overrides[project_id][module_id] = percentage + # Update cache if exists + if project_id in self._cache: + for p in self._cache[project_id]: + if p.module_id == module_id: + p.percentage = percentage + p.source = "manual" + p.evaluated_at = datetime.now(timezone.utc) diff --git a/backend/app/modules/impl_tracker/domain/entities.py b/backend/app/modules/impl_tracker/domain/entities.py index e69de29..cb3db3f 100644 --- a/backend/app/modules/impl_tracker/domain/entities.py +++ b/backend/app/modules/impl_tracker/domain/entities.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class ImplProgress: + module_id: str + percentage: float # 0-100 + source: str # auto, llm, manual + evaluated_at: datetime + + +@dataclass +class CodeStructure: + root_path: str + directories: list[str] + files: list[str] + matched_modules: list[str] diff --git a/backend/app/modules/impl_tracker/infrastructure/code_scanner.py b/backend/app/modules/impl_tracker/infrastructure/code_scanner.py new file mode 100644 index 0000000..d759725 --- /dev/null +++ b/backend/app/modules/impl_tracker/infrastructure/code_scanner.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from app.modules.impl_tracker.domain.entities import CodeStructure +from app.modules.scanner.domain.entities import ScanResult + + +def scan_code_directory(code_dir: str, scan_result: ScanResult) -> CodeStructure: + root = Path(code_dir) + if not root.is_dir(): + return CodeStructure(root_path=code_dir, directories=[], files=[], matched_modules=[]) + + directories = [] + files = [] + for p in sorted(root.rglob("*")): + rel = str(p.relative_to(root)) + if p.is_dir(): + directories.append(rel) + elif p.is_file(): + files.append(rel) + + # Match modules by checking if code_root from CodebaseAlignment exists + matched = [] + for alignment in scan_result.codebase_alignments: + code_root = alignment.code_root + if (root / code_root).exists(): + matched.append(alignment.module_id) + + return CodeStructure( + root_path=code_dir, + directories=directories, + files=files, + matched_modules=matched, + ) diff --git a/backend/app/modules/impl_tracker/infrastructure/llm_client.py b/backend/app/modules/impl_tracker/infrastructure/llm_client.py new file mode 100644 index 0000000..477a9af --- /dev/null +++ b/backend/app/modules/impl_tracker/infrastructure/llm_client.py @@ -0,0 +1,4 @@ +class LlmClient: + def evaluate_module(self, module_design: str, module_code: str) -> float: + """Stub: returns 0.0. Real implementation would call LLM API.""" + return 0.0 diff --git a/backend/app/modules/impl_tracker/interfaces/http/router.py b/backend/app/modules/impl_tracker/interfaces/http/router.py index e69de29..f3ab3b6 100644 --- a/backend/app/modules/impl_tracker/interfaces/http/router.py +++ b/backend/app/modules/impl_tracker/interfaces/http/router.py @@ -0,0 +1,93 @@ +"""Impl-tracker HTTP router — progress evaluation and manual override endpoints.""" + +from __future__ import annotations + +from dataclasses import asdict +from datetime import datetime, timezone + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from app.modules.impl_tracker.application.services import ImplTrackerService +from app.modules.impl_tracker.domain.entities import ImplProgress +from app.modules.project.application.services import ProjectService +from app.modules.scanner.application.services import ScanService + +router = APIRouter(prefix="/projects/{project_id}/impl-progress", tags=["impl-tracker"]) + +_project_service: ProjectService | None = None +_scan_service: ScanService | None = None +_impl_tracker_service: ImplTrackerService | None = None + + +def init_router( + project_service: ProjectService, + scan_service: ScanService, + impl_tracker_service: ImplTrackerService, +) -> None: + global _project_service, _scan_service, _impl_tracker_service + _project_service = project_service + _scan_service = scan_service + _impl_tracker_service = impl_tracker_service + + +class ManualProgressRequest(BaseModel): + percentage: float + + +def _progress_to_dict(p) -> dict: + d = asdict(p) + d["evaluated_at"] = p.evaluated_at.isoformat() + return d + + +@router.post("") +def evaluate_progress(project_id: str): + """Evaluate implementation progress for all modules.""" + project = _project_service.get_project(project_id) + scan_result = _scan_service.get_latest_scan(project_id) + if scan_result is None: + return JSONResponse( + status_code=404, + content={"detail": "No scan available. Run POST /scan first."}, + ) + progress = _impl_tracker_service.evaluate(project, scan_result) + return [_progress_to_dict(p) for p in progress] + + +@router.get("") +def get_progress(project_id: str): + """Get cached implementation progress.""" + _project_service.get_project(project_id) # Ensure project exists (raises 404) + progress = _impl_tracker_service.get_progress(project_id) + if progress is None: + return JSONResponse( + status_code=404, + content={"detail": "No progress evaluated yet. Run POST /impl-progress first."}, + ) + return [_progress_to_dict(p) for p in progress] + + +@router.put("/{module_id}") +def set_manual_progress(project_id: str, module_id: str, body: ManualProgressRequest): + """Set manual progress override for a module.""" + _project_service.get_project(project_id) # Ensure project exists (raises 404) + _impl_tracker_service.set_manual_progress(project_id, module_id, body.percentage) + + # Return the updated progress entry + progress = _impl_tracker_service.get_progress(project_id) + if progress: + for p in progress: + if p.module_id == module_id: + return _progress_to_dict(p) + + # If no cached progress, return a constructed response + return _progress_to_dict( + ImplProgress( + module_id=module_id, + percentage=body.percentage, + source="manual", + evaluated_at=datetime.now(timezone.utc), + ) + ) diff --git a/backend/app/modules/project/application/services.py b/backend/app/modules/project/application/services.py index e69de29..2e9ae9b 100644 --- a/backend/app/modules/project/application/services.py +++ b/backend/app/modules/project/application/services.py @@ -0,0 +1,42 @@ +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from app.modules.project.domain.entities import Project +from app.modules.project.domain.repositories import ProjectRepository +from app.shared.kernel.exceptions import NotFoundError, ValidationError + + +class ProjectService: + def __init__(self, repository: ProjectRepository) -> None: + self._repo = repository + + def list_projects(self) -> list[Project]: + return self._repo.list_all() + + def create_project( + self, name: str, design_dir: str, code_dir: str | None = None, + ) -> Project: + if not Path(design_dir).is_dir(): + raise ValidationError(f"Design directory does not exist: {design_dir}") + project = Project( + id=str(uuid.uuid4()), + name=name, + design_dir=design_dir, + code_dir=code_dir, + created_at=datetime.now(timezone.utc), + ) + self._repo.save(project) + return project + + def get_project(self, project_id: str) -> Project: + project = self._repo.get_by_id(project_id) + if project is None: + raise NotFoundError("Project", project_id) + return project + + def delete_project(self, project_id: str) -> None: + project = self._repo.get_by_id(project_id) + if project is None: + raise NotFoundError("Project", project_id) + self._repo.delete(project_id) diff --git a/backend/app/modules/project/domain/entities.py b/backend/app/modules/project/domain/entities.py index e69de29..afa12f6 100644 --- a/backend/app/modules/project/domain/entities.py +++ b/backend/app/modules/project/domain/entities.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class Project: + id: str + name: str + design_dir: str + code_dir: str | None + created_at: datetime diff --git a/backend/app/modules/project/domain/repositories.py b/backend/app/modules/project/domain/repositories.py index e69de29..5d9ac4a 100644 --- a/backend/app/modules/project/domain/repositories.py +++ b/backend/app/modules/project/domain/repositories.py @@ -0,0 +1,21 @@ +from abc import ABC, abstractmethod + +from app.modules.project.domain.entities import Project + + +class ProjectRepository(ABC): + @abstractmethod + def list_all(self) -> list[Project]: + ... + + @abstractmethod + def get_by_id(self, project_id: str) -> Project | None: + ... + + @abstractmethod + def save(self, project: Project) -> None: + ... + + @abstractmethod + def delete(self, project_id: str) -> None: + ... diff --git a/backend/app/modules/project/infrastructure/json_repository.py b/backend/app/modules/project/infrastructure/json_repository.py index e69de29..810349f 100644 --- a/backend/app/modules/project/infrastructure/json_repository.py +++ b/backend/app/modules/project/infrastructure/json_repository.py @@ -0,0 +1,59 @@ +import json +from datetime import datetime +from pathlib import Path + +from app.modules.project.domain.entities import Project +from app.modules.project.domain.repositories import ProjectRepository + + +class JsonProjectRepository(ProjectRepository): + def __init__(self, path: Path) -> None: + self._path = path + + def _load(self) -> list[dict]: + if not self._path.exists(): + return [] + return json.loads(self._path.read_text(encoding="utf-8")) + + def _save(self, data: list[dict]) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + @staticmethod + def _to_dict(p: Project) -> dict: + return { + "id": p.id, + "name": p.name, + "design_dir": p.design_dir, + "code_dir": p.code_dir, + "created_at": p.created_at.isoformat(), + } + + @staticmethod + def _from_dict(d: dict) -> Project: + return Project( + id=d["id"], + name=d["name"], + design_dir=d["design_dir"], + code_dir=d.get("code_dir"), + created_at=datetime.fromisoformat(d["created_at"]), + ) + + def list_all(self) -> list[Project]: + return [self._from_dict(d) for d in self._load()] + + def get_by_id(self, project_id: str) -> Project | None: + for d in self._load(): + if d["id"] == project_id: + return self._from_dict(d) + return None + + def save(self, project: Project) -> None: + data = self._load() + data = [d for d in data if d["id"] != project.id] + data.append(self._to_dict(project)) + self._save(data) + + def delete(self, project_id: str) -> None: + data = [d for d in self._load() if d["id"] != project_id] + self._save(data) diff --git a/backend/app/modules/project/interfaces/http/router.py b/backend/app/modules/project/interfaces/http/router.py index e69de29..75e7a8b 100644 --- a/backend/app/modules/project/interfaces/http/router.py +++ b/backend/app/modules/project/interfaces/http/router.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, Response +from pydantic import BaseModel + +from app.modules.project.application.services import ProjectService + +router = APIRouter(prefix="/projects", tags=["project"]) + +_service: ProjectService | None = None + + +def init_router(service: ProjectService) -> None: + global _service + _service = service + + +class CreateProjectRequest(BaseModel): + name: str + design_dir: str + code_dir: str | None = None + + +class ProjectResponse(BaseModel): + id: str + name: str + design_dir: str + code_dir: str | None + created_at: str + + +def _to_response(p) -> dict: + return { + "id": p.id, + "name": p.name, + "design_dir": p.design_dir, + "code_dir": p.code_dir, + "created_at": p.created_at.isoformat(), + } + + +@router.get("") +def list_projects(): + return [_to_response(p) for p in _service.list_projects()] + + +@router.post("", status_code=201) +def create_project(req: CreateProjectRequest): + p = _service.create_project(req.name, req.design_dir, req.code_dir) + return _to_response(p) + + +@router.get("/{project_id}") +def get_project(project_id: str): + p = _service.get_project(project_id) + return _to_response(p) + + +@router.delete("/{project_id}", status_code=204) +def delete_project(project_id: str): + _service.delete_project(project_id) + return Response(status_code=204) diff --git a/backend/app/modules/scanner/application/services.py b/backend/app/modules/scanner/application/services.py index e69de29..eafe0ad 100644 --- a/backend/app/modules/scanner/application/services.py +++ b/backend/app/modules/scanner/application/services.py @@ -0,0 +1,127 @@ +"""ScanService — orchestrates parsers, file status detection, and entity collection.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from app.modules.design.domain.services import DesignValidationService +from app.modules.design.domain.value_objects import FileStatus +from app.modules.project.domain.entities import Project +from app.modules.scanner.domain.entities import ( + FileStatusEntry, + ScanResult, + ScanSummary, +) +from app.modules.scanner.infrastructure.parsers.csv_parser import CsvParser +from app.modules.scanner.infrastructure.parsers.md_parser import MdParser +from app.modules.scanner.infrastructure.parsers.openapi_parser import OpenapiParser + + +class ScanService: + """Scan a project's design directory and produce a ScanResult.""" + + def __init__(self) -> None: + self._csv_parser = CsvParser() + self._md_parser = MdParser() + self._openapi_parser = OpenapiParser() + self._cache: dict[str, ScanResult] = {} + + def scan(self, project: Project) -> ScanResult: + design_dir = Path(project.design_dir) + file_statuses: list[FileStatusEntry] = [] + all_entities: dict[str, list[Any]] = {} + + # Walk design directory recursively + for file_path in sorted(design_dir.rglob("*")): + if not file_path.is_file(): + continue + + # Determine file status + try: + content = file_path.read_text(encoding="utf-8") + except Exception: + content = "" + + status = DesignValidationService.determine_file_status( + content, str(file_path) + ) + lines = len(content.splitlines()) if content else 0 + rel_path = str(file_path.relative_to(design_dir)) + + file_statuses.append(FileStatusEntry( + path=rel_path, + status=status, + content_lines=lines, + )) + + # Dispatch to appropriate parser + parsed: dict[str, list[Any]] = {} + suffix = file_path.suffix.lower() + fname = file_path.name.lower() + + if suffix == ".csv": + parsed = self._csv_parser.parse(file_path) + elif suffix == ".md": + parsed = self._md_parser.parse(file_path) + elif suffix == ".yaml" or suffix == ".yml": + if "openapi" in fname or "api-contracts" in fname: + parsed = self._openapi_parser.parse(file_path) + + # Merge parsed entities + for key, entities in parsed.items(): + if key not in all_entities: + all_entities[key] = [] + all_entities[key].extend(entities) + + # Build summary + summary = self._build_summary(file_statuses) + + # Assemble ScanResult + # Singleton fields (take first item from list or None) + singleton_keys = { + "scope_and_goals", "system_context", "solution_layer", + "module_boundary_rule", "runtime_topology", + "operational_baseline", "release_plan", + } + + kwargs: dict[str, Any] = { + "project_id": project.id, + "scanned_at": datetime.now(timezone.utc), + "file_statuses": file_statuses, + "summary": summary, + } + + for key, entities in all_entities.items(): + if key in singleton_keys: + kwargs[key] = entities[0] if entities else None + else: + kwargs[key] = entities + + result = ScanResult(**kwargs) + self._cache[project.id] = result + return result + + def get_latest_scan(self, project_id: str) -> ScanResult | None: + return self._cache.get(project_id) + + @staticmethod + def _build_summary(file_statuses: list[FileStatusEntry]) -> ScanSummary: + ok = sum(1 for fs in file_statuses if fs.status == FileStatus.OK) + sparse = sum(1 for fs in file_statuses if fs.status == FileStatus.SPARSE) + missing = sum(1 for fs in file_statuses if fs.status == FileStatus.MISSING) + placeholder_heavy = sum( + 1 for fs in file_statuses if fs.status == FileStatus.PLACEHOLDER_HEAVY + ) + template_residue = sum( + 1 for fs in file_statuses if fs.status == FileStatus.TEMPLATE_RESIDUE + ) + return ScanSummary( + total_files=len(file_statuses), + ok=ok, + sparse=sparse, + missing=missing, + placeholder_heavy=placeholder_heavy, + template_residue=template_residue, + ) diff --git a/backend/app/modules/scanner/domain/entities.py b/backend/app/modules/scanner/domain/entities.py index e69de29..8b93918 100644 --- a/backend/app/modules/scanner/domain/entities.py +++ b/backend/app/modules/scanner/domain/entities.py @@ -0,0 +1,102 @@ +from dataclasses import dataclass, field +from datetime import datetime + +from app.modules.design.domain.entities import ( + ADR, + ApiContract, + Capability, + ChangeLogEntry, + CodebaseAlignment, + DataFlow, + DataSecurity, + DesignDocument, + Domain, + DomainEntity, + DomainModule, + Entity, + Environment, + ExternalSystem, + Integration, + Module, + ModuleBoundaryRule, + OperationalBaseline, + ReleasePlan, + RuntimeComponent, + RuntimeTopology, + Scenario, + SharedTerm, + ScopeAndGoals, + SolutionLayer, + SystemContext, + TechSelection, + TraceabilityLink, + UbiquitousTerm, + UserJourney, + ValueFlow, +) +from app.modules.design.domain.value_objects import FileStatus + + +@dataclass +class FileStatusEntry: + path: str + status: FileStatus + content_lines: int + + +@dataclass +class ScanSummary: + total_files: int + ok: int + sparse: int + missing: int + placeholder_heavy: int + template_residue: int + + +@dataclass +class ScanResult: + """Internal domain object carrying all parsed entities. + + API response (ScanResultResponse) only includes project_id, scanned_at, + file_statuses, summary. Entity data is exposed through separate + /entities/* endpoints. + """ + + project_id: str + scanned_at: datetime + file_statuses: list[FileStatusEntry] + summary: ScanSummary + # All parsed Design entities + capabilities: list[Capability] = field(default_factory=list) + modules: list[Module] = field(default_factory=list) + entities: list[Entity] = field(default_factory=list) + value_flows: list[ValueFlow] = field(default_factory=list) + user_journeys: list[UserJourney] = field(default_factory=list) + integrations: list[Integration] = field(default_factory=list) + data_flows: list[DataFlow] = field(default_factory=list) + traceability_links: list[TraceabilityLink] = field(default_factory=list) + external_systems: list[ExternalSystem] = field(default_factory=list) + runtime_components: list[RuntimeComponent] = field(default_factory=list) + tech_selections: list[TechSelection] = field(default_factory=list) + environments: list[Environment] = field(default_factory=list) + design_documents: list[DesignDocument] = field(default_factory=list) + change_log_entries: list[ChangeLogEntry] = field(default_factory=list) + adrs: list[ADR] = field(default_factory=list) + shared_terms: list[SharedTerm] = field(default_factory=list) + domains: list[Domain] = field(default_factory=list) + ubiquitous_terms: list[UbiquitousTerm] = field(default_factory=list) + scenarios: list[Scenario] = field(default_factory=list) + domain_modules: list[DomainModule] = field(default_factory=list) + domain_entities: list[DomainEntity] = field(default_factory=list) + data_securities: list[DataSecurity] = field(default_factory=list) + codebase_alignments: list[CodebaseAlignment] = field(default_factory=list) + api_contracts: list[ApiContract] = field(default_factory=list) + # MD file-specific (singleton or None) + scope_and_goals: ScopeAndGoals | None = None + system_context: SystemContext | None = None + solution_layer: SolutionLayer | None = None + module_boundary_rule: ModuleBoundaryRule | None = None + runtime_topology: RuntimeTopology | None = None + operational_baseline: OperationalBaseline | None = None + release_plan: ReleasePlan | None = None diff --git a/backend/app/modules/scanner/infrastructure/parsers/csv_parser.py b/backend/app/modules/scanner/infrastructure/parsers/csv_parser.py index e69de29..ae0f23d 100644 --- a/backend/app/modules/scanner/infrastructure/parsers/csv_parser.py +++ b/backend/app/modules/scanner/infrastructure/parsers/csv_parser.py @@ -0,0 +1,353 @@ +"""CSV parser — maps design CSV files to Design entity instances.""" + +from __future__ import annotations + +import csv +from pathlib import Path +from typing import Any + +from app.modules.design.domain.entities import ( + Capability, + ChangeLogEntry, + CodebaseAlignment, + DataFlow, + DataSecurity, + DomainEntity, + DomainModule, + Entity, + Environment, + ExternalSystem, + Integration, + Module, + RuntimeComponent, + Scenario, + SharedTerm, + TechSelection, + TraceabilityLink, + UbiquitousTerm, + UserJourney, + ValueFlow, +) + + +def _split_space(value: str) -> list[str]: + """Split a space-delimited string into a list, filtering empty strings.""" + if not value or not value.strip(): + return [] + return value.strip().split() + + +class CsvParser: + """Parse CSV file and return dict mapping entity type name to list of instances. + + Keys match ScanResult field names (e.g., 'capabilities', 'modules', etc.) + """ + + def parse(self, file_path: Path) -> dict[str, list[Any]]: + fname = file_path.name.lower() + stem = file_path.stem.lower() + + # Skip api-contracts CSV (handled by OpenAPI parser) + if "api-contracts" in fname or "api_contracts" in fname: + return {} + + # Skip module-boundary (this is an MD file concept) + if "module-boundary" in fname or "module_boundary" in fname: + return {} + + try: + with open(file_path, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + rows = list(reader) + except Exception: + return {} + + if not rows: + return {} + + return self._dispatch(fname, stem, rows) + + def _dispatch(self, fname: str, stem: str, rows: list[dict[str, str]]) -> dict[str, list[Any]]: + if "capability-map" in fname or "capability_map" in fname: + return {"capabilities": [self._parse_capability(r) for r in rows]} + + if "value-flows" in fname or "value_flows" in fname: + return {"value_flows": [self._parse_value_flow(r) for r in rows]} + + if "user-journeys" in fname or "user_journeys" in fname: + return {"user_journeys": [self._parse_user_journey(r) for r in rows]} + + if "integrations" in fname: + return {"integrations": [self._parse_integration(r) for r in rows]} + + if "external-systems" in fname or "external_systems" in fname: + return {"external_systems": [self._parse_external_system(r) for r in rows]} + + if "codebase-alignment" in fname or "codebase_alignment" in fname: + return {"codebase_alignments": [self._parse_codebase_alignment(r) for r in rows]} + + if "codebase-mapping" in fname or "codebase_mapping" in fname: + return {"codebase_alignments": [self._parse_codebase_mapping(r) for r in rows]} + + # entities.csv in data-architecture (not domain-entities) + if stem == "01-entities" or (fname.endswith("entities.csv") and "domain" not in fname): + return {"entities": [self._parse_entity(r) for r in rows]} + + if "data-flows" in fname or "data_flows" in fname: + return {"data_flows": [self._parse_data_flow(r) for r in rows]} + + if "data-security" in fname or "data_security" in fname: + return {"data_securities": [self._parse_data_security(r) for r in rows]} + + if "technology-selection" in fname or "technology_selection" in fname: + return {"tech_selections": [self._parse_tech_selection(r) for r in rows]} + + if "runtime-components" in fname or "runtime_components" in fname: + return {"runtime_components": [self._parse_runtime_component(r) for r in rows]} + + if "environments" in fname: + return {"environments": [self._parse_environment(r) for r in rows]} + + if fname == "traceability.csv": + return {"traceability_links": [self._parse_traceability_link(r) for r in rows]} + + if "change-log" in fname or "change_log" in fname: + return {"change_log_entries": [self._parse_change_log_entry(r) for r in rows]} + + if "shared-terminology" in fname or "shared_terminology" in fname: + return {"shared_terms": [self._parse_shared_term(r) for r in rows]} + + if "ubiquitous-language" in fname or "ubiquitous_language" in fname: + return {"ubiquitous_terms": [self._parse_ubiquitous_term(r) for r in rows]} + + if "scenarios-and-flows" in fname or "scenarios_and_flows" in fname: + return {"scenarios": [self._parse_scenario(r) for r in rows]} + + if "domain-modules" in fname or "domain_modules" in fname: + return {"domain_modules": [self._parse_domain_module(r) for r in rows]} + + if "domain-entities" in fname or "domain_entities" in fname: + return {"domain_entities": [self._parse_domain_entity(r) for r in rows]} + + # modules.csv in application-architecture + if fname.endswith("modules.csv"): + return {"modules": [self._parse_module(r) for r in rows]} + + return {} + + # ── Individual entity parsers ── + + @staticmethod + def _g(row: dict[str, str], key: str) -> str: + """Get a value from a row, defaulting to empty string.""" + return (row.get(key) or "").strip() + + def _parse_capability(self, row: dict[str, str]) -> Capability: + return Capability( + capability_id=self._g(row, "capability_id"), + name=self._g(row, "capability_name"), + description=self._g(row, "description"), + priority=self._g(row, "priority"), + phase=self._g(row, "phase"), + related_value_flows=_split_space(self._g(row, "related_value_flows")), + ) + + def _parse_value_flow(self, row: dict[str, str]) -> ValueFlow: + return ValueFlow( + value_flow_id=self._g(row, "value_flow_id"), + name=self._g(row, "value_flow_name"), + trigger=self._g(row, "trigger"), + actor=self._g(row, "actor"), + steps=self._g(row, "steps"), + outcome=self._g(row, "outcome"), + phase=self._g(row, "phase"), + related_capabilities=_split_space(self._g(row, "related_capabilities")), + ) + + def _parse_user_journey(self, row: dict[str, str]) -> UserJourney: + return UserJourney( + journey_id=self._g(row, "journey_id"), + name=self._g(row, "journey_name"), + actor=self._g(row, "actor"), + precondition=self._g(row, "precondition"), + steps=self._g(row, "steps"), + postcondition=self._g(row, "postcondition"), + phase=self._g(row, "phase"), + related_value_flows=_split_space(self._g(row, "related_value_flows")), + ) + + def _parse_module(self, row: dict[str, str]) -> Module: + return Module( + module_id=self._g(row, "module_id"), + name=self._g(row, "module_name"), + layer=self._g(row, "layer"), + description=self._g(row, "description"), + phase=self._g(row, "phase"), + depends_on=_split_space(self._g(row, "depends_on")), + capabilities=_split_space(self._g(row, "capabilities")), + ) + + def _parse_integration(self, row: dict[str, str]) -> Integration: + return Integration( + integration_id=self._g(row, "integration_id"), + source_id=self._g(row, "source_id"), + target_id=self._g(row, "target_id"), + target_type=self._g(row, "target_type"), + direction=self._g(row, "direction"), + protocol=self._g(row, "protocol"), + trigger=self._g(row, "trigger"), + phase=self._g(row, "phase"), + description=self._g(row, "description"), + ) + + def _parse_external_system(self, row: dict[str, str]) -> ExternalSystem: + return ExternalSystem( + system_id=self._g(row, "system_id"), + name=self._g(row, "system_name"), + type=self._g(row, "type"), + protocol=self._g(row, "protocol"), + direction=self._g(row, "direction"), + phase=self._g(row, "phase"), + description=self._g(row, "description"), + ) + + def _parse_codebase_alignment(self, row: dict[str, str]) -> CodebaseAlignment: + return CodebaseAlignment( + module_id=self._g(row, "module_id"), + repo_root=self._g(row, "repo_root"), + code_root=self._g(row, "code_root"), + package_name=self._g(row, "package_name"), + ) + + def _parse_codebase_mapping(self, row: dict[str, str]) -> CodebaseAlignment: + return CodebaseAlignment( + module_id=self._g(row, "module_id"), + repo_root="", + code_root=self._g(row, "code_path"), + package_name=self._g(row, "package"), + ) + + def _parse_entity(self, row: dict[str, str]) -> Entity: + return Entity( + entity_id=self._g(row, "entity_id"), + name=self._g(row, "entity_name"), + domain=self._g(row, "domain"), + owner_module=self._g(row, "owner_module"), + description=self._g(row, "description"), + phase=self._g(row, "phase"), + source_file=self._g(row, "source_file"), + ) + + def _parse_data_flow(self, row: dict[str, str]) -> DataFlow: + return DataFlow( + data_flow_id=self._g(row, "data_flow_id"), + source=self._g(row, "source"), + target=self._g(row, "target"), + data_content=self._g(row, "data_content"), + trigger=self._g(row, "trigger"), + protocol=self._g(row, "protocol"), + phase=self._g(row, "phase"), + description=self._g(row, "description"), + ) + + def _parse_data_security(self, row: dict[str, str]) -> DataSecurity: + return DataSecurity( + security_id=self._g(row, "security_id"), + sensitivity=self._g(row, "sensitivity"), + entities=self._g(row, "entities"), + protection=self._g(row, "protection_strategy"), + ) + + def _parse_tech_selection(self, row: dict[str, str]) -> TechSelection: + return TechSelection( + category=self._g(row, "category"), + technology=self._g(row, "technology"), + version=self._g(row, "version"), + purpose=self._g(row, "purpose"), + rationale=self._g(row, "rationale"), + alternatives_considered=self._g(row, "alternatives_considered"), + phase=self._g(row, "phase"), + ) + + def _parse_runtime_component(self, row: dict[str, str]) -> RuntimeComponent: + return RuntimeComponent( + component_id=self._g(row, "component_id"), + name=self._g(row, "component_name"), + type=self._g(row, "type"), + technology=self._g(row, "technology"), + port=self._g(row, "port"), + ) + + def _parse_environment(self, row: dict[str, str]) -> Environment: + return Environment( + env_id=self._g(row, "env_id"), + name=self._g(row, "env_name"), + purpose=self._g(row, "purpose"), + infra=self._g(row, "infra"), + ) + + def _parse_traceability_link(self, row: dict[str, str]) -> TraceabilityLink: + return TraceabilityLink( + trace_id=self._g(row, "trace_id"), + capability_id=self._g(row, "capability_id"), + module_id=self._g(row, "module_id"), + entity_ids=_split_space(self._g(row, "entity_ids")), + value_flow_ids=_split_space(self._g(row, "value_flow_ids")), + notes=self._g(row, "notes"), + ) + + def _parse_change_log_entry(self, row: dict[str, str]) -> ChangeLogEntry: + return ChangeLogEntry( + change_id=self._g(row, "change_id"), + date=self._g(row, "date"), + scope=self._g(row, "scope"), + description=self._g(row, "description"), + ) + + def _parse_shared_term(self, row: dict[str, str]) -> SharedTerm: + return SharedTerm( + term_id=self._g(row, "term_id"), + term=self._g(row, "term"), + english_term=self._g(row, "english_term"), + definition=self._g(row, "definition"), + used_by_domains=_split_space(self._g(row, "used_by_modules")), + ) + + def _parse_ubiquitous_term(self, row: dict[str, str]) -> UbiquitousTerm: + return UbiquitousTerm( + term_id=self._g(row, "term_id"), + term=self._g(row, "term"), + english_term=self._g(row, "english_term"), + code_symbol=self._g(row, "code_symbol"), + domain=self._g(row, "domain"), + definition=self._g(row, "definition"), + ) + + def _parse_scenario(self, row: dict[str, str]) -> Scenario: + return Scenario( + scenario_id=self._g(row, "scenario_id"), + name=self._g(row, "scenario_name"), + trigger=self._g(row, "trigger"), + actors=self._g(row, "actors"), + steps=self._g(row, "steps"), + outcome=self._g(row, "outcome"), + related_capabilities=_split_space(self._g(row, "related_capabilities")), + ) + + def _parse_domain_module(self, row: dict[str, str]) -> DomainModule: + return DomainModule( + module_id=self._g(row, "module_id"), + module_name=self._g(row, "module_name"), + domain=self._g(row, "domain"), + description=self._g(row, "description"), + layer_in_code=self._g(row, "layer_in_code"), + ) + + def _parse_domain_entity(self, row: dict[str, str]) -> DomainEntity: + return DomainEntity( + entity_id=self._g(row, "entity_id"), + entity_name=self._g(row, "entity_name"), + type=self._g(row, "type"), + description=self._g(row, "description"), + key_attributes=self._g(row, "key_attributes"), + ) diff --git a/backend/app/modules/scanner/infrastructure/parsers/md_parser.py b/backend/app/modules/scanner/infrastructure/parsers/md_parser.py index e69de29..8e873f2 100644 --- a/backend/app/modules/scanner/infrastructure/parsers/md_parser.py +++ b/backend/app/modules/scanner/infrastructure/parsers/md_parser.py @@ -0,0 +1,160 @@ +"""Markdown parser — extracts YAML frontmatter and produces DesignDocument + specialized entities.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import yaml + +from app.modules.design.domain.entities import ( + ADR, + DesignDocument, + Domain, + ModuleBoundaryRule, + OperationalBaseline, + ReleasePlan, + RuntimeTopology, + ScopeAndGoals, + SolutionLayer, + SystemContext, +) + + +_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL) + + +class MdParser: + """Parse Markdown file and return dict mapping entity type name to list of instances. + + Keys: 'design_documents', 'scope_and_goals', 'system_context', etc. + """ + + def parse(self, file_path: Path) -> dict[str, list[Any]]: + try: + content = file_path.read_text(encoding="utf-8") + except Exception: + return {} + + match = _FRONTMATTER_RE.match(content) + if not match: + return {} + + try: + frontmatter = yaml.safe_load(match.group(1)) + except Exception: + return {} + + if not isinstance(frontmatter, dict): + return {} + + doc_id = frontmatter.get("doc_id", "") + if not doc_id: + return {} + + title = frontmatter.get("title", "") + version = frontmatter.get("version", "") + status = frontmatter.get("status", "") + owners = frontmatter.get("owners", []) or [] + upstream = frontmatter.get("upstream", []) or [] + downstream = frontmatter.get("downstream", []) or [] + + # Ensure list types + if not isinstance(owners, list): + owners = [owners] + if not isinstance(upstream, list): + upstream = [upstream] + if not isinstance(downstream, list): + downstream = [downstream] + + design_doc = DesignDocument( + doc_id=doc_id, + title=title, + version=str(version), + status=status, + owners=owners, + upstream=upstream, + downstream=downstream, + file_path=str(file_path), + ) + + result: dict[str, list[Any]] = {"design_documents": [design_doc]} + + # Body content after frontmatter + body = content[match.end():].strip() + fname = file_path.name.lower() + fpath_str = str(file_path).lower() + + # Specialized entity detection + if "scope-and-goals" in fname or "scope_and_goals" in fname: + result["scope_and_goals"] = [ScopeAndGoals( + doc_id=doc_id, + title=title, + core_problem="", + users="", + constraints="", + )] + + elif "system-context" in fname or "system_context" in fname: + result["system_context"] = [SystemContext( + doc_id=doc_id, + title=title, + content=body, + )] + + elif "solution-layering" in fname or "solution_layering" in fname: + result["solution_layer"] = [SolutionLayer( + doc_id=doc_id, + title=title, + content=body, + )] + + elif "module-boundary" in fname or "module_boundary" in fname: + result["module_boundary_rule"] = [ModuleBoundaryRule( + doc_id=doc_id, + title=title, + content=body, + )] + + elif "runtime-topology" in fname or "runtime_topology" in fname: + result["runtime_topology"] = [RuntimeTopology( + doc_id=doc_id, + title=title, + content=body, + )] + + elif "operational-baseline" in fname or "operational_baseline" in fname: + result["operational_baseline"] = [OperationalBaseline( + doc_id=doc_id, + title=title, + content=body, + )] + + elif "release-and-rollback" in fname or "release_and_rollback" in fname: + result["release_plan"] = [ReleasePlan( + doc_id=doc_id, + title=title, + content=body, + )] + + elif "domain-overview" in fname or "domain_overview" in fname: + # Extract domain name from parent directory + domain_name = file_path.parent.name + result["domains"] = [Domain( + domain_name=domain_name, + overview=body, + modules=[], + entities=[], + )] + + elif fname.startswith("adr-") and "template" not in fname.lower(): + result["adrs"] = [ADR( + adr_id=doc_id, + title=title, + status=status, + context=body, + decision="", + )] + + return result diff --git a/backend/app/modules/scanner/infrastructure/parsers/openapi_parser.py b/backend/app/modules/scanner/infrastructure/parsers/openapi_parser.py index e69de29..cb940e9 100644 --- a/backend/app/modules/scanner/infrastructure/parsers/openapi_parser.py +++ b/backend/app/modules/scanner/infrastructure/parsers/openapi_parser.py @@ -0,0 +1,62 @@ +"""OpenAPI parser — extracts ApiContract entities from OpenAPI YAML specifications.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +from app.modules.design.domain.entities import ApiContract + + +class OpenapiParser: + """Parse OpenAPI YAML file and return dict mapping entity type name to list of instances. + + Returns {'api_contracts': [ApiContract, ...]} + """ + + def parse(self, file_path: Path) -> dict[str, list[Any]]: + try: + with open(file_path, encoding="utf-8") as f: + spec = yaml.safe_load(f) + except Exception: + return {} + + if not isinstance(spec, dict) or "paths" not in spec: + return {} + + contracts: list[ApiContract] = [] + paths = spec["paths"] + if not isinstance(paths, dict): + return {} + + for path, path_item in paths.items(): + if not isinstance(path_item, dict): + continue + for method, operation in path_item.items(): + # Skip non-HTTP-method keys (e.g., 'parameters', 'summary') + if method.lower() not in ( + "get", "post", "put", "delete", "patch", "options", "head", "trace", + ): + continue + if not isinstance(operation, dict): + continue + + operation_id = operation.get("operationId", "") + summary = operation.get("summary", "") + + doc_id = f"API-{operation_id or method.upper()}-{path}" + + contracts.append(ApiContract( + doc_id=doc_id, + path=path, + method=method.upper(), + operation_id=operation_id or "", + summary=summary or "", + )) + + if not contracts: + return {} + + return {"api_contracts": contracts} diff --git a/backend/app/modules/scanner/infrastructure/parsers/yaml_parser.py b/backend/app/modules/scanner/infrastructure/parsers/yaml_parser.py index e69de29..fe34e13 100644 --- a/backend/app/modules/scanner/infrastructure/parsers/yaml_parser.py +++ b/backend/app/modules/scanner/infrastructure/parsers/yaml_parser.py @@ -0,0 +1,19 @@ +"""YAML parser — simple wrapper around yaml.safe_load for configuration files.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + + +class YamlParser: + """Load a YAML file and return its contents as a Python dict/list.""" + + def load(self, file_path: Path) -> Any: + try: + with open(file_path, encoding="utf-8") as f: + return yaml.safe_load(f) + except Exception: + return None diff --git a/backend/app/modules/scanner/interfaces/http/router.py b/backend/app/modules/scanner/interfaces/http/router.py index e69de29..bacb77f 100644 --- a/backend/app/modules/scanner/interfaces/http/router.py +++ b/backend/app/modules/scanner/interfaces/http/router.py @@ -0,0 +1,253 @@ +"""Scanner HTTP router — scan trigger, entity query endpoints.""" + +from __future__ import annotations + +from dataclasses import asdict + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from app.modules.project.application.services import ProjectService +from app.modules.scanner.application.services import ScanService +from app.modules.scanner.domain.entities import ScanResult + +router = APIRouter(prefix="/projects/{project_id}", tags=["scanner"]) + +_project_service: ProjectService | None = None +_scan_service: ScanService | None = None + + +def init_router(project_service: ProjectService, scan_service: ScanService) -> None: + global _project_service, _scan_service + _project_service = project_service + _scan_service = scan_service + + +def _scan_result_response(result: ScanResult) -> dict: + """Build API response for ScanResult (no entity lists).""" + return { + "project_id": result.project_id, + "scanned_at": result.scanned_at.isoformat(), + "file_statuses": [ + { + "path": fs.path, + "status": fs.status.value, + "content_lines": fs.content_lines, + } + for fs in result.file_statuses + ], + "summary": { + "total_files": result.summary.total_files, + "ok": result.summary.ok, + "sparse": result.summary.sparse, + "missing": result.summary.missing, + "placeholder_heavy": result.summary.placeholder_heavy, + "template_residue": result.summary.template_residue, + }, + } + + +def _entity_to_dict(entity) -> dict: + """Convert a dataclass entity to a dict using asdict.""" + return asdict(entity) + + +def _integration_to_dict(integration) -> dict: + """Convert Integration to dict with source_id/target_id mapped to source/target per spec.""" + d = asdict(integration) + d["source"] = d.pop("source_id") + d["target"] = d.pop("target_id") + return d + + +def _get_scan_or_404(project_id: str) -> ScanResult | None: + """Get cached scan result or return None.""" + return _scan_service.get_latest_scan(project_id) + + +# ── Scan endpoints ── + + +@router.post("/scan") +def trigger_scan(project_id: str): + project = _project_service.get_project(project_id) + result = _scan_service.scan(project) + return _scan_result_response(result) + + +@router.get("/scan") +def get_scan(project_id: str): + _project_service.get_project(project_id) # Ensure project exists (raises 404) + result = _get_scan_or_404(project_id) + if result is None: + return JSONResponse(status_code=404, content={"detail": "No scan available"}) + return _scan_result_response(result) + + +# ── Entity list endpoints ── + + +@router.get("/entities/capabilities") +def list_capabilities(project_id: str): + result = _get_scan_or_404(project_id) + if result is None: + return JSONResponse(status_code=404, content={"detail": "No scan available"}) + return [_entity_to_dict(c) for c in result.capabilities] + + +@router.get("/entities/modules") +def list_modules(project_id: str): + result = _get_scan_or_404(project_id) + if result is None: + return JSONResponse(status_code=404, content={"detail": "No scan available"}) + return [_entity_to_dict(m) for m in result.modules] + + +@router.get("/entities/entities") +def list_entities(project_id: str): + result = _get_scan_or_404(project_id) + if result is None: + return JSONResponse(status_code=404, content={"detail": "No scan available"}) + return [_entity_to_dict(e) for e in result.entities] + + +@router.get("/entities/integrations") +def list_integrations(project_id: str): + result = _get_scan_or_404(project_id) + if result is None: + return JSONResponse(status_code=404, content={"detail": "No scan available"}) + return [_integration_to_dict(i) for i in result.integrations] + + +@router.get("/entities/value-flows") +def list_value_flows(project_id: str): + result = _get_scan_or_404(project_id) + if result is None: + return JSONResponse(status_code=404, content={"detail": "No scan available"}) + return [_entity_to_dict(v) for v in result.value_flows] + + +@router.get("/entities/user-journeys") +def list_user_journeys(project_id: str): + result = _get_scan_or_404(project_id) + if result is None: + return JSONResponse(status_code=404, content={"detail": "No scan available"}) + return [_entity_to_dict(j) for j in result.user_journeys] + + +@router.get("/entities/data-flows") +def list_data_flows(project_id: str): + result = _get_scan_or_404(project_id) + if result is None: + return JSONResponse(status_code=404, content={"detail": "No scan available"}) + return [_entity_to_dict(d) for d in result.data_flows] + + +@router.get("/entities/external-systems") +def list_external_systems(project_id: str): + result = _get_scan_or_404(project_id) + if result is None: + return JSONResponse(status_code=404, content={"detail": "No scan available"}) + return [_entity_to_dict(s) for s in result.external_systems] + + +@router.get("/entities/traceability-links") +def list_traceability_links(project_id: str): + result = _get_scan_or_404(project_id) + if result is None: + return JSONResponse(status_code=404, content={"detail": "No scan available"}) + return [_entity_to_dict(t) for t in result.traceability_links] + + +@router.get("/entities/runtime-components") +def list_runtime_components(project_id: str): + result = _get_scan_or_404(project_id) + if result is None: + return JSONResponse(status_code=404, content={"detail": "No scan available"}) + return [_entity_to_dict(c) for c in result.runtime_components] + + +# ── Detail endpoints ── + + +@router.get("/entities/capabilities/{capability_id}") +def get_capability_detail(project_id: str, capability_id: str): + result = _get_scan_or_404(project_id) + if result is None: + return JSONResponse(status_code=404, content={"detail": "No scan available"}) + + cap = next((c for c in result.capabilities if c.capability_id == capability_id), None) + if cap is None: + return JSONResponse(status_code=404, content={"detail": f"Capability not found: {capability_id}"}) + + # Find related modules via traceability links + related_module_ids = { + link.module_id + for link in result.traceability_links + if link.capability_id == capability_id + } + related_modules = [m for m in result.modules if m.module_id in related_module_ids] + + # Find related value flows + related_vf_ids = set(cap.related_value_flows) + related_value_flows = [v for v in result.value_flows if v.value_flow_id in related_vf_ids] + + return { + "capability": _entity_to_dict(cap), + "modules": [_entity_to_dict(m) for m in related_modules], + "value_flows": [_entity_to_dict(v) for v in related_value_flows], + } + + +@router.get("/entities/modules/{module_id}") +def get_module_detail(project_id: str, module_id: str): + result = _get_scan_or_404(project_id) + if result is None: + return JSONResponse(status_code=404, content={"detail": "No scan available"}) + + mod = next((m for m in result.modules if m.module_id == module_id), None) + if mod is None: + return JSONResponse(status_code=404, content={"detail": f"Module not found: {module_id}"}) + + # Find owned entities (entity.owner_module matches) + owned_entities = [e for e in result.entities if e.owner_module == module_id] + + # Find integrations where source or target matches + related_integrations = [ + i for i in result.integrations + if i.source_id == module_id or i.target_id == module_id + ] + + # Find codebase alignment + alignment = next( + (a for a in result.codebase_alignments if a.module_id == module_id), None + ) + + return { + "module": _entity_to_dict(mod), + "entities": [_entity_to_dict(e) for e in owned_entities], + "integrations": [_integration_to_dict(i) for i in related_integrations], + "codebase_alignment": _entity_to_dict(alignment) if alignment else None, + } + + +@router.get("/entities/entities/{entity_id}") +def get_entity_detail(project_id: str, entity_id: str): + result = _get_scan_or_404(project_id) + if result is None: + return JSONResponse(status_code=404, content={"detail": "No scan available"}) + + entity = next((e for e in result.entities if e.entity_id == entity_id), None) + if entity is None: + return JSONResponse(status_code=404, content={"detail": f"Entity not found: {entity_id}"}) + + # Find data flows where source or target matches entity name or entity_id + related_data_flows = [ + d for d in result.data_flows + if entity_id in d.source or entity_id in d.target + ] + + return { + "entity": _entity_to_dict(entity), + "data_flows": [_entity_to_dict(d) for d in related_data_flows], + } diff --git a/backend/app/shared/infrastructure/config.py b/backend/app/shared/infrastructure/config.py index e69de29..bb4ca8f 100644 --- a/backend/app/shared/infrastructure/config.py +++ b/backend/app/shared/infrastructure/config.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class Settings: + registry_path: Path = field( + default_factory=lambda: Path.home() / ".arch-design-dashboard" / "projects.json" + ) diff --git a/backend/app/shared/infrastructure/filesystem.py b/backend/app/shared/infrastructure/filesystem.py index e69de29..bcae001 100644 --- a/backend/app/shared/infrastructure/filesystem.py +++ b/backend/app/shared/infrastructure/filesystem.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from app.shared.kernel.exceptions import FileSystemError + + +def read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except OSError as e: + raise FileSystemError(str(path), str(e)) from e + + +def write_text(path: Path, content: str) -> None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + except OSError as e: + raise FileSystemError(str(path), str(e)) from e + + +def list_files(directory: Path, extensions: list[str] | None = None) -> list[Path]: + if not directory.is_dir(): + raise FileSystemError(str(directory), "Not a directory") + files: list[Path] = [] + for p in sorted(directory.rglob("*")): + if p.is_file(): + if extensions is None or p.suffix in extensions: + files.append(p) + return files + + +def file_exists(path: Path) -> bool: + return path.is_file() diff --git a/backend/app/shared/kernel/exceptions.py b/backend/app/shared/kernel/exceptions.py index e69de29..b40ebb5 100644 --- a/backend/app/shared/kernel/exceptions.py +++ b/backend/app/shared/kernel/exceptions.py @@ -0,0 +1,18 @@ +class NotFoundError(Exception): + def __init__(self, entity: str, entity_id: str) -> None: + self.entity = entity + self.entity_id = entity_id + super().__init__(f"{entity} not found: {entity_id}") + + +class ValidationError(Exception): + def __init__(self, message: str) -> None: + self.message = message + super().__init__(message) + + +class FileSystemError(Exception): + def __init__(self, path: str, message: str) -> None: + self.path = path + self.message = message + super().__init__(f"Filesystem error at {path}: {message}") diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..10888bc --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "arch-design-dashboard" +version = "0.1.0" +description = "Architecture Design Dashboard Backend" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "pyyaml>=6.0", + "python-multipart>=0.0.9", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "httpx>=0.27.0", +] diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..cb118fe --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,30 @@ +import os +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def tmp_registry(tmp_path: Path): + """Set REGISTRY_PATH env var to a temp file for test isolation.""" + registry = str(tmp_path / "projects.json") + os.environ["REGISTRY_PATH"] = registry + yield registry + os.environ.pop("REGISTRY_PATH", None) + + +@pytest.fixture +def client(tmp_registry): + """Create a test client with isolated registry.""" + from app.main import create_app + app = create_app() + return TestClient(app) + + +@pytest.fixture +def design_dir(tmp_path: Path) -> Path: + """Create a minimal design directory for testing.""" + d = tmp_path / "design" + d.mkdir() + return d 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_api_graph.py b/backend/tests/test_api_graph.py new file mode 100644 index 0000000..431fc2e --- /dev/null +++ b/backend/tests/test_api_graph.py @@ -0,0 +1,44 @@ +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_graph(client, project_id): + r = client.get(f"/api/projects/{project_id}/graph") + assert r.status_code == 200 + data = r.json() + assert "nodes" in data + assert "edges" in data + assert "groups" in data + assert len(data["nodes"]) > 0 + assert len(data["groups"]) == 5 + + +def test_get_graph_auto_scans(client, project_id): + """Graph endpoint should auto-scan if no cached scan exists.""" + r = client.get(f"/api/projects/{project_id}/graph") + assert r.status_code == 200 + + +def test_get_neighbors(client, project_id): + # First trigger a scan via graph endpoint + client.get(f"/api/projects/{project_id}/graph") + r = client.get(f"/api/projects/{project_id}/graph/nodes/CAP-PROJ-REG/neighbors") + assert r.status_code == 200 + data = r.json() + assert "nodes" in data + assert len(data["nodes"]) > 0 + + +def test_get_neighbors_unknown_node(client, project_id): + client.get(f"/api/projects/{project_id}/graph") + r = client.get(f"/api/projects/{project_id}/graph/nodes/NONEXISTENT/neighbors") + assert r.status_code == 200 + data = r.json() + assert len(data["nodes"]) == 0 diff --git a/backend/tests/test_api_impl_tracker.py b/backend/tests/test_api_impl_tracker.py new file mode 100644 index 0000000..f524871 --- /dev/null +++ b/backend/tests/test_api_impl_tracker.py @@ -0,0 +1,46 @@ +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_evaluate_progress(client, project_id): + # Need to scan first + client.post(f"/api/projects/{project_id}/scan") + r = client.post(f"/api/projects/{project_id}/impl-progress") + assert r.status_code == 200 + data = r.json() + assert isinstance(data, list) + assert len(data) > 0 + assert "module_id" in data[0] + assert "percentage" in data[0] + + +def test_get_progress_not_evaluated(client, project_id): + r = client.get(f"/api/projects/{project_id}/impl-progress") + assert r.status_code == 404 + + +def test_get_progress_after_evaluate(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + client.post(f"/api/projects/{project_id}/impl-progress") + r = client.get(f"/api/projects/{project_id}/impl-progress") + assert r.status_code == 200 + assert isinstance(r.json(), list) + + +def test_set_manual_progress(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + client.post(f"/api/projects/{project_id}/impl-progress") + r = client.put( + f"/api/projects/{project_id}/impl-progress/MOD-PROJECT", + json={"percentage": 80.0}, + ) + assert r.status_code == 200 + assert r.json()["percentage"] == 80.0 + assert r.json()["source"] == "manual" diff --git a/backend/tests/test_api_project.py b/backend/tests/test_api_project.py new file mode 100644 index 0000000..314e9d6 --- /dev/null +++ b/backend/tests/test_api_project.py @@ -0,0 +1,44 @@ +import pytest + + +def test_health(client): + r = client.get("/api/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + +def test_list_projects_empty(client): + r = client.get("/api/projects") + assert r.status_code == 200 + assert r.json() == [] + + +def test_create_and_get_project(client, design_dir): + r = client.post("/api/projects", json={"name": "test", "design_dir": str(design_dir)}) + assert r.status_code == 201 + pid = r.json()["id"] + + r = client.get(f"/api/projects/{pid}") + assert r.status_code == 200 + assert r.json()["name"] == "test" + + +def test_create_project_invalid_dir(client): + r = client.post("/api/projects", json={"name": "test", "design_dir": "/nonexistent"}) + assert r.status_code == 400 + + +def test_delete_project(client, design_dir): + r = client.post("/api/projects", json={"name": "test", "design_dir": str(design_dir)}) + pid = r.json()["id"] + + r = client.delete(f"/api/projects/{pid}") + assert r.status_code == 204 + + r = client.get(f"/api/projects/{pid}") + assert r.status_code == 404 + + +def test_get_nonexistent_project(client): + r = client.get("/api/projects/nonexistent") + assert r.status_code == 404 diff --git a/backend/tests/test_api_scanner.py b/backend/tests/test_api_scanner.py new file mode 100644 index 0000000..38a5525 --- /dev/null +++ b/backend/tests/test_api_scanner.py @@ -0,0 +1,189 @@ +"""Tests for scanner REST API endpoints.""" + +import pytest +from pathlib import Path + + +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_trigger_scan(client, project_id): + r = client.post(f"/api/projects/{project_id}/scan") + assert r.status_code == 200 + data = r.json() + assert data["project_id"] == project_id + assert "file_statuses" in data + assert "summary" in data + # Should NOT have entity lists in response + assert "capabilities" not in data + assert "modules" not in data + + +def test_get_scan(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + r = client.get(f"/api/projects/{project_id}/scan") + assert r.status_code == 200 + data = r.json() + assert data["project_id"] == project_id + + +def test_get_scan_not_scanned(client, project_id): + r = client.get(f"/api/projects/{project_id}/scan") + assert r.status_code == 404 + + +def test_list_capabilities(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + r = client.get(f"/api/projects/{project_id}/entities/capabilities") + assert r.status_code == 200 + caps = r.json() + assert len(caps) > 0 + assert "capability_id" in caps[0] + + +def test_list_modules(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + r = client.get(f"/api/projects/{project_id}/entities/modules") + assert r.status_code == 200 + mods = r.json() + assert len(mods) > 0 + assert "module_id" in mods[0] + + +def test_list_entities(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + r = client.get(f"/api/projects/{project_id}/entities/entities") + assert r.status_code == 200 + ents = r.json() + assert len(ents) > 0 + + +def test_list_integrations(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + r = client.get(f"/api/projects/{project_id}/entities/integrations") + assert r.status_code == 200 + ints = r.json() + assert len(ints) > 0 + # Integration should have source/target (not source_id/target_id) + assert "source" in ints[0] + assert "target" in ints[0] + assert "source_id" not in ints[0] + assert "target_id" not in ints[0] + + +def test_list_value_flows(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + r = client.get(f"/api/projects/{project_id}/entities/value-flows") + assert r.status_code == 200 + vfs = r.json() + assert len(vfs) > 0 + + +def test_list_user_journeys(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + r = client.get(f"/api/projects/{project_id}/entities/user-journeys") + assert r.status_code == 200 + ujs = r.json() + assert len(ujs) > 0 + + +def test_list_data_flows(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + r = client.get(f"/api/projects/{project_id}/entities/data-flows") + assert r.status_code == 200 + dfs = r.json() + assert len(dfs) > 0 + + +def test_list_external_systems(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + r = client.get(f"/api/projects/{project_id}/entities/external-systems") + assert r.status_code == 200 + ess = r.json() + assert len(ess) > 0 + + +def test_list_traceability_links(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + r = client.get(f"/api/projects/{project_id}/entities/traceability-links") + assert r.status_code == 200 + tls = r.json() + assert len(tls) > 0 + + +def test_list_runtime_components(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + r = client.get(f"/api/projects/{project_id}/entities/runtime-components") + assert r.status_code == 200 + rcs = r.json() + assert len(rcs) > 0 + + +def test_capability_detail(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + r = client.get(f"/api/projects/{project_id}/entities/capabilities/CAP-PROJ-REG") + assert r.status_code == 200 + detail = r.json() + assert "capability" in detail + assert "modules" in detail + assert "value_flows" in detail + assert detail["capability"]["capability_id"] == "CAP-PROJ-REG" + + +def test_capability_detail_not_found(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + r = client.get(f"/api/projects/{project_id}/entities/capabilities/NONEXISTENT") + assert r.status_code == 404 + + +def test_module_detail(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + r = client.get(f"/api/projects/{project_id}/entities/modules/MOD-PROJECT") + assert r.status_code == 200 + detail = r.json() + assert "module" in detail + assert "entities" in detail + assert "integrations" in detail + assert "codebase_alignment" in detail + assert detail["module"]["module_id"] == "MOD-PROJECT" + + +def test_module_detail_not_found(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + r = client.get(f"/api/projects/{project_id}/entities/modules/NONEXISTENT") + assert r.status_code == 404 + + +def test_entity_detail(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + r = client.get(f"/api/projects/{project_id}/entities/entities/ENT-PROJECT") + assert r.status_code == 200 + detail = r.json() + assert "entity" in detail + assert "data_flows" in detail + assert detail["entity"]["entity_id"] == "ENT-PROJECT" + + +def test_entity_detail_not_found(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + r = client.get(f"/api/projects/{project_id}/entities/entities/NONEXISTENT") + assert r.status_code == 404 + + +def test_entities_before_scan_returns_404(client, project_id): + r = client.get(f"/api/projects/{project_id}/entities/capabilities") + assert r.status_code == 404 + + +def test_scan_summary_totals(client, project_id): + r = client.post(f"/api/projects/{project_id}/scan") + data = r.json() + s = data["summary"] + assert s["total_files"] == len(data["file_statuses"]) + assert s["total_files"] == s["ok"] + s["sparse"] + s["missing"] + s["placeholder_heavy"] + s["template_residue"] diff --git a/backend/tests/test_design_entities.py b/backend/tests/test_design_entities.py new file mode 100644 index 0000000..d49ecda --- /dev/null +++ b/backend/tests/test_design_entities.py @@ -0,0 +1,129 @@ +from app.modules.design.domain.value_objects import ( + ArchitectureLayer, + FileStatus, + ModuleLayer, +) + + +def test_file_status_values(): + assert FileStatus.OK == "ok" + assert FileStatus.SPARSE == "sparse" + assert FileStatus.MISSING == "missing" + assert FileStatus.TEMPLATE_RESIDUE == "template-residue" + assert FileStatus.PLACEHOLDER_HEAVY == "placeholder-heavy" + + +def test_architecture_layer_values(): + assert ArchitectureLayer.BUSINESS == "business" + assert ArchitectureLayer.APPLICATION == "application" + assert ArchitectureLayer.DATA == "data" + assert ArchitectureLayer.TECHNOLOGY == "technology" + + +def test_module_layer_values(): + assert ModuleLayer.DOMAIN == "domain" + assert ModuleLayer.APPLICATION == "application" + assert ModuleLayer.INFRASTRUCTURE == "infrastructure" + assert ModuleLayer.INTERFACES == "interfaces" + + +from app.modules.design.domain.entities import ( + ADR, + ApiContract, + Capability, + ChangeLogEntry, + CodebaseAlignment, + DataFlow, + DataSecurity, + DesignDocument, + Domain, + DomainEntity, + DomainModule, + Entity, + Environment, + ExternalSystem, + Integration, + Module, + ModuleBoundaryRule, + OperationalBaseline, + ReleasePlan, + RuntimeComponent, + RuntimeTopology, + Scenario, + ScopeAndGoals, + SharedTerm, + SolutionLayer, + SystemContext, + TechSelection, + TraceabilityLink, + UbiquitousTerm, + UserJourney, + ValueFlow, +) + + +def test_capability_creation(): + cap = Capability( + capability_id="CAP-01", + name="test", + description="desc", + priority="must", + phase="MVP", + related_value_flows=["VF-01"], + ) + assert cap.capability_id == "CAP-01" + assert cap.related_value_flows == ["VF-01"] + + +def test_module_creation(): + mod = Module( + module_id="MOD-01", + name="test", + layer="backend", + description="desc", + phase="MVP", + depends_on=["MOD-02"], + capabilities=["CAP-01"], + ) + assert mod.depends_on == ["MOD-02"] + + +def test_traceability_link_list_fields(): + tl = TraceabilityLink( + trace_id="TR-01", + capability_id="CAP-01", + module_id="MOD-01", + entity_ids=["ENT-01", "ENT-02"], + value_flow_ids=["VF-01"], + notes="test", + ) + assert len(tl.entity_ids) == 2 + + +def test_design_document_list_fields(): + dd = DesignDocument( + doc_id="DOC-01", + title="test", + version="0.1", + status="draft", + owners=["owner1"], + upstream=["a.md"], + downstream=["b.md"], + file_path="test.md", + ) + assert dd.owners == ["owner1"] + + +def test_all_31_entities_importable(): + """Verify all 31 entity classes can be imported.""" + entities = [ + Capability, ValueFlow, UserJourney, ScopeAndGoals, + Module, Integration, ExternalSystem, ApiContract, + CodebaseAlignment, ModuleBoundaryRule, SystemContext, SolutionLayer, + Entity, DataFlow, DataSecurity, + TechSelection, RuntimeComponent, RuntimeTopology, Environment, + OperationalBaseline, ReleasePlan, + TraceabilityLink, ChangeLogEntry, ADR, DesignDocument, + Domain, UbiquitousTerm, SharedTerm, Scenario, DomainModule, DomainEntity, + ] + assert len(entities) == 31 diff --git a/backend/tests/test_design_services.py b/backend/tests/test_design_services.py new file mode 100644 index 0000000..f163134 --- /dev/null +++ b/backend/tests/test_design_services.py @@ -0,0 +1,77 @@ +import pytest +from app.modules.design.domain.entities import ( + Capability, + Entity, + Module, + TraceabilityLink, +) +from app.modules.design.domain.services import DesignValidationService +from app.modules.design.domain.value_objects import FileStatus + + +class TestFileStatusDetermination: + def test_empty_content_is_missing(self): + assert DesignValidationService.determine_file_status("", "test.csv") == FileStatus.MISSING + + def test_csv_header_only_is_sparse(self): + assert DesignValidationService.determine_file_status("id,name\n", "test.csv") == FileStatus.SPARSE + + def test_csv_with_data_is_ok(self): + content = "id,name\n1,foo\n2,bar\n" + assert DesignValidationService.determine_file_status(content, "test.csv") == FileStatus.OK + + def test_md_too_short_is_sparse(self): + assert DesignValidationService.determine_file_status("# Title\n\nShort.\n", "test.md") == FileStatus.SPARSE + + def test_template_residue_detected(self): + content = "id,name\nTODO,\nreal,data\n" + assert DesignValidationService.determine_file_status(content, "test.csv") == FileStatus.TEMPLATE_RESIDUE + + def test_placeholder_heavy(self): + content = "id,name,desc\nTODO,TODO,TODO\nTODO,TODO,TODO\n" + assert DesignValidationService.determine_file_status(content, "test.csv") == FileStatus.PLACEHOLDER_HEAVY + + def test_ok_md_file(self): + content = "---\ndoc_id: DOC-01\ntitle: Test\n---\n\n# Title\n\nThis is a real document with enough content.\nLine 4.\nLine 5.\nLine 6.\n" + assert DesignValidationService.determine_file_status(content, "test.md") == FileStatus.OK + + +class TestConstraintValidation: + def _make_cap(self, cap_id: str) -> Capability: + return Capability(cap_id, "n", "d", "must", "MVP", []) + + def _make_mod(self, mod_id: str) -> Module: + return Module(mod_id, "n", "backend", "d", "MVP", [], []) + + def _make_ent(self, ent_id: str, owner: str) -> Entity: + return Entity(ent_id, "n", "d", owner, "d", "MVP", "f.csv") + + def _make_link(self, trace_id: str, cap: str, mod: str, ents: list[str]) -> TraceabilityLink: + return TraceabilityLink(trace_id, cap, mod, ents, [], "") + + def test_capability_without_module_link_is_violation(self): + caps = [self._make_cap("CAP-01")] + links = [] + violations = DesignValidationService.check_capability_module_linkage(caps, links) + assert len(violations) == 1 + + def test_entity_without_owner_is_violation(self): + entities = [self._make_ent("ENT-01", "")] + violations = DesignValidationService.check_entity_owner(entities) + assert len(violations) == 1 + + def test_valid_traceability_passes(self): + caps = [self._make_cap("CAP-01")] + mods = [self._make_mod("MOD-01")] + ents = [self._make_ent("ENT-01", "MOD-01")] + links = [self._make_link("TR-01", "CAP-01", "MOD-01", ["ENT-01"])] + violations = DesignValidationService.check_traceability_references(links, caps, mods, ents) + assert len(violations) == 0 + + def test_broken_traceability_reference_is_violation(self): + caps = [self._make_cap("CAP-01")] + mods = [self._make_mod("MOD-01")] + ents = [self._make_ent("ENT-01", "MOD-01")] + links = [self._make_link("TR-01", "CAP-MISSING", "MOD-01", ["ENT-01"])] + violations = DesignValidationService.check_traceability_references(links, caps, mods, ents) + assert len(violations) >= 1 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) diff --git a/backend/tests/test_graph_service.py b/backend/tests/test_graph_service.py new file mode 100644 index 0000000..7a0a5ca --- /dev/null +++ b/backend/tests/test_graph_service.py @@ -0,0 +1,82 @@ +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.graph.application.services import GraphService + + +@pytest.fixture +def scan_result(): + svc = ScanService() + project = Project( + id="test", name="test", + design_dir="/workspace/arch-design-agent-skill-dashboard/design", + code_dir=None, created_at=datetime(2026, 1, 1), + ) + return svc.scan(project) + + +@pytest.fixture +def graph_service(): + return GraphService() + + +def test_panorama_has_groups(graph_service, scan_result): + view = graph_service.build_panorama(scan_result) + group_ids = {g.id for g in view.groups} + assert "business" in group_ids + assert "application" in group_ids + assert "data" in group_ids + assert "technology" in group_ids + assert "cross-layer" in group_ids + + +def test_panorama_has_capability_nodes(graph_service, scan_result): + view = graph_service.build_panorama(scan_result) + cap_nodes = [n for n in view.nodes if n.type == "capability"] + assert len(cap_nodes) > 0 + assert all(n.group_id == "business" for n in cap_nodes) + + +def test_panorama_has_module_nodes(graph_service, scan_result): + view = graph_service.build_panorama(scan_result) + mod_nodes = [n for n in view.nodes if n.type == "module"] + assert len(mod_nodes) > 0 + assert all(n.group_id == "application" for n in mod_nodes) + + +def test_panorama_has_entity_nodes(graph_service, scan_result): + view = graph_service.build_panorama(scan_result) + ent_nodes = [n for n in view.nodes if n.type == "entity"] + assert len(ent_nodes) > 0 + assert all(n.group_id == "data" for n in ent_nodes) + + +def test_panorama_has_edges(graph_service, scan_result): + view = graph_service.build_panorama(scan_result) + assert len(view.edges) > 0 + relations = {e.relation for e in view.edges} + assert "traces_to" in relations + + +def test_panorama_depends_on_edges(graph_service, scan_result): + view = graph_service.build_panorama(scan_result) + dep_edges = [e for e in view.edges if e.relation == "depends_on"] + assert len(dep_edges) > 0 + + +def test_neighbors_returns_subgraph(graph_service, scan_result): + view = graph_service.build_panorama(scan_result) + # Use a known capability node + neighbors = graph_service.get_neighbors(view, "CAP-PROJ-REG") + assert len(neighbors.nodes) > 0 + assert any(n.id == "CAP-PROJ-REG" for n in neighbors.nodes) + assert len(neighbors.edges) > 0 + + +def test_neighbors_unknown_node(graph_service, scan_result): + view = graph_service.build_panorama(scan_result) + neighbors = graph_service.get_neighbors(view, "NONEXISTENT") + assert len(neighbors.nodes) == 0 + assert len(neighbors.edges) == 0 diff --git a/backend/tests/test_impl_tracker.py b/backend/tests/test_impl_tracker.py new file mode 100644 index 0000000..dafc2e9 --- /dev/null +++ b/backend/tests/test_impl_tracker.py @@ -0,0 +1,59 @@ +import pytest +from datetime import datetime + +from app.modules.impl_tracker.application.services import ImplTrackerService +from app.modules.project.domain.entities import Project +from app.modules.scanner.application.services import ScanService + + +@pytest.fixture +def impl_service(): + return ImplTrackerService() + + +@pytest.fixture +def scan_result(): + svc = ScanService() + project = Project( + id="test", name="test", + design_dir="/workspace/arch-design-agent-skill-dashboard/design", + code_dir=None, created_at=datetime(2026, 1, 1), + ) + return svc.scan(project) + + +@pytest.fixture +def test_project(): + return Project( + id="test", name="test", + design_dir="/workspace/arch-design-agent-skill-dashboard/design", + code_dir=None, created_at=datetime(2026, 1, 1), + ) + + +def test_evaluate_no_code_dir(impl_service, test_project, scan_result): + progress = impl_service.evaluate(test_project, scan_result) + assert len(progress) > 0 + assert all(p.percentage == 0.0 for p in progress) + assert all(p.source == "auto" for p in progress) + + +def test_get_progress_before_evaluate(impl_service): + assert impl_service.get_progress("nonexistent") is None + + +def test_get_progress_after_evaluate(impl_service, test_project, scan_result): + impl_service.evaluate(test_project, scan_result) + cached = impl_service.get_progress("test") + assert cached is not None + assert len(cached) > 0 + + +def test_set_manual_progress(impl_service, test_project, scan_result): + impl_service.evaluate(test_project, scan_result) + impl_service.set_manual_progress("test", "MOD-PROJECT", 75.0) + cached = impl_service.get_progress("test") + mod_progress = [p for p in cached if p.module_id == "MOD-PROJECT"] + assert len(mod_progress) == 1 + assert mod_progress[0].percentage == 75.0 + assert mod_progress[0].source == "manual" diff --git a/backend/tests/test_project.py b/backend/tests/test_project.py new file mode 100644 index 0000000..8af535a --- /dev/null +++ b/backend/tests/test_project.py @@ -0,0 +1,83 @@ +import json +from pathlib import Path + +import pytest +from app.modules.project.domain.entities import Project +from app.modules.project.infrastructure.json_repository import JsonProjectRepository + + +@pytest.fixture +def repo(tmp_path: Path) -> JsonProjectRepository: + return JsonProjectRepository(tmp_path / "projects.json") + + +def test_empty_repo_returns_empty_list(repo: JsonProjectRepository): + assert repo.list_all() == [] + + +def test_save_and_get(repo: JsonProjectRepository): + from datetime import datetime + p = Project(id="id1", name="test", design_dir="/tmp/d", code_dir=None, created_at=datetime(2026, 1, 1)) + repo.save(p) + assert repo.get_by_id("id1") is not None + assert repo.get_by_id("id1").name == "test" + + +def test_list_all(repo: JsonProjectRepository): + from datetime import datetime + p1 = Project(id="id1", name="a", design_dir="/d1", code_dir=None, created_at=datetime(2026, 1, 1)) + p2 = Project(id="id2", name="b", design_dir="/d2", code_dir=None, created_at=datetime(2026, 1, 2)) + repo.save(p1) + repo.save(p2) + assert len(repo.list_all()) == 2 + + +def test_delete(repo: JsonProjectRepository): + from datetime import datetime + p = Project(id="id1", name="test", design_dir="/d", code_dir=None, created_at=datetime(2026, 1, 1)) + repo.save(p) + repo.delete("id1") + assert repo.get_by_id("id1") is None + + +def test_get_nonexistent_returns_none(repo: JsonProjectRepository): + assert repo.get_by_id("nope") is None + + +# --- Service tests --- + +from app.modules.project.application.services import ProjectService +from app.shared.kernel.exceptions import NotFoundError, ValidationError + + +@pytest.fixture +def service(tmp_path: Path) -> ProjectService: + repo = JsonProjectRepository(tmp_path / "projects.json") + return ProjectService(repo) + + +def test_create_project_validates_design_dir(service: ProjectService, tmp_path: Path): + design_dir = tmp_path / "design" + design_dir.mkdir() + project = service.create_project("test", str(design_dir)) + assert project.name == "test" + assert project.id # UUID generated + + +def test_create_project_rejects_missing_dir(service: ProjectService): + with pytest.raises(ValidationError): + service.create_project("test", "/nonexistent/path") + + +def test_get_project_not_found(service: ProjectService): + with pytest.raises(NotFoundError): + service.get_project("nonexistent") + + +def test_delete_project(service: ProjectService, tmp_path: Path): + design_dir = tmp_path / "design" + design_dir.mkdir() + p = service.create_project("test", str(design_dir)) + service.delete_project(p.id) + with pytest.raises(NotFoundError): + service.get_project(p.id) diff --git a/backend/tests/test_scanner_parsers.py b/backend/tests/test_scanner_parsers.py new file mode 100644 index 0000000..908fbda --- /dev/null +++ b/backend/tests/test_scanner_parsers.py @@ -0,0 +1,376 @@ +"""Tests for scanner parsers (CSV, MD, YAML, OpenAPI).""" + +from pathlib import Path + +import pytest + +from app.modules.scanner.infrastructure.parsers.csv_parser import CsvParser + +DESIGN_DIR = Path("/workspace/arch-design-agent-skill-dashboard/design") + + +@pytest.fixture +def csv_parser(): + return CsvParser() + + +# ── CSV Parser Tests ── + + +class TestCsvParserCapabilities: + def test_parse_capability_map(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "business-architecture" / "02-capability-map.csv") + assert "capabilities" in result + caps = result["capabilities"] + assert len(caps) > 0 + cap = caps[0] + assert cap.capability_id.startswith("CAP-") + assert cap.name # should have a name + assert isinstance(cap.related_value_flows, list) + + def test_capability_related_value_flows_split(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "business-architecture" / "02-capability-map.csv") + caps = result["capabilities"] + # CAP-PROGRESS-DESIGN has "VF-02 VF-03" which should be split + progress_cap = [c for c in caps if c.capability_id == "CAP-PROGRESS-DESIGN"] + assert len(progress_cap) == 1 + assert progress_cap[0].related_value_flows == ["VF-02", "VF-03"] + + +class TestCsvParserModules: + def test_parse_modules(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "application-architecture" / "02-modules.csv") + assert "modules" in result + mods = result["modules"] + assert len(mods) > 0 + mod = mods[0] + assert mod.module_id.startswith("MOD-") + assert isinstance(mod.depends_on, list) + assert isinstance(mod.capabilities, list) + + def test_module_list_fields(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "application-architecture" / "02-modules.csv") + mods = result["modules"] + scanner = [m for m in mods if m.module_id == "MOD-SCANNER"] + assert len(scanner) == 1 + assert "MOD-DESIGN" in scanner[0].depends_on + assert len(scanner[0].capabilities) > 0 + + +class TestCsvParserTraceability: + def test_parse_traceability(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "traceability.csv") + assert "traceability_links" in result + links = result["traceability_links"] + assert len(links) > 0 + link = links[0] + assert link.trace_id.startswith("TR-") + assert isinstance(link.entity_ids, list) + assert isinstance(link.value_flow_ids, list) + + def test_traceability_space_split(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "traceability.csv") + links = result["traceability_links"] + # TR-04 has many entity_ids space-separated + tr04 = [l for l in links if l.trace_id == "TR-04"] + assert len(tr04) == 1 + assert len(tr04[0].entity_ids) > 5 + + +class TestCsvParserOtherTypes: + def test_parse_value_flows(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "business-architecture" / "03-value-flows.csv") + assert "value_flows" in result + assert len(result["value_flows"]) > 0 + + def test_parse_user_journeys(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "business-architecture" / "04-user-journeys.csv") + assert "user_journeys" in result + assert len(result["user_journeys"]) > 0 + + def test_parse_integrations(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "application-architecture" / "03-integrations.csv") + assert "integrations" in result + assert len(result["integrations"]) > 0 + + def test_parse_external_systems(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "application-architecture" / "01-external-systems.csv") + assert "external_systems" in result + assert len(result["external_systems"]) > 0 + + def test_parse_entities(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "data-architecture" / "01-entities.csv") + assert "entities" in result + assert len(result["entities"]) > 0 + + def test_parse_data_flows(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "data-architecture" / "02-data-flows.csv") + assert "data_flows" in result + assert len(result["data_flows"]) > 0 + + def test_parse_data_security(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "data-architecture" / "03-data-security.csv") + assert "data_securities" in result + assert len(result["data_securities"]) > 0 + + def test_parse_tech_selections(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "technology-architecture" / "00-technology-selection.csv") + assert "tech_selections" in result + assert len(result["tech_selections"]) > 0 + + def test_parse_runtime_components(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "technology-architecture" / "01-runtime-components.csv") + assert "runtime_components" in result + assert len(result["runtime_components"]) > 0 + + def test_parse_environments(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "technology-architecture" / "02-environments.csv") + assert "environments" in result + assert len(result["environments"]) > 0 + + def test_parse_change_log(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "change-log.csv") + assert "change_log_entries" in result + assert len(result["change_log_entries"]) > 0 + + def test_parse_shared_terminology(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "domains" / "_shared" / "01-shared-terminology.csv") + assert "shared_terms" in result + terms = result["shared_terms"] + assert len(terms) > 0 + # Check used_by_domains is a list (space-split) + assert isinstance(terms[0].used_by_domains, list) + assert len(terms[0].used_by_domains) > 0 + + def test_parse_ubiquitous_language(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "domains" / "design" / "02-ubiquitous-language.csv") + assert "ubiquitous_terms" in result + assert len(result["ubiquitous_terms"]) > 0 + + def test_parse_scenarios(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "domains" / "design" / "03-scenarios-and-flows.csv") + assert "scenarios" in result + assert len(result["scenarios"]) > 0 + + def test_parse_domain_modules(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "domains" / "design" / "04-domain-modules.csv") + assert "domain_modules" in result + assert len(result["domain_modules"]) > 0 + + def test_parse_domain_entities(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "domains" / "design" / "05-domain-entities.csv") + assert "domain_entities" in result + assert len(result["domain_entities"]) > 0 + + def test_parse_codebase_alignment(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "application-architecture" / "06-codebase-alignment.csv") + assert "codebase_alignments" in result + assert len(result["codebase_alignments"]) > 0 + + def test_parse_codebase_mapping(self, csv_parser): + result = csv_parser.parse(DESIGN_DIR / "domains" / "design" / "07-codebase-mapping.csv") + assert "codebase_alignments" in result + assert len(result["codebase_alignments"]) > 0 + + +class TestCsvParserUnknown: + def test_unknown_csv_returns_empty(self, csv_parser, tmp_path): + unknown = tmp_path / "unknown-file.csv" + unknown.write_text("col1,col2\nval1,val2\n") + result = csv_parser.parse(unknown) + assert result == {} + + def test_nonexistent_file_returns_empty(self, csv_parser): + result = csv_parser.parse(Path("/nonexistent/file.csv")) + assert result == {} + + +# ── MD Parser Tests ── + +from app.modules.scanner.infrastructure.parsers.md_parser import MdParser + + +@pytest.fixture +def md_parser(): + return MdParser() + + +class TestMdParserScopeAndGoals: + def test_parse_scope_and_goals(self, md_parser): + result = md_parser.parse(DESIGN_DIR / "business-architecture" / "01-scope-and-goals.md") + assert "design_documents" in result + docs = result["design_documents"] + assert len(docs) == 1 + assert docs[0].doc_id == "DOC-BA-001" + assert docs[0].title == "范围与目标" + assert isinstance(docs[0].owners, list) + assert isinstance(docs[0].downstream, list) + assert "scope_and_goals" in result + sag = result["scope_and_goals"] + assert len(sag) == 1 + assert sag[0].doc_id == "DOC-BA-001" + + +class TestMdParserDomainOverview: + def test_parse_domain_overview(self, md_parser): + result = md_parser.parse(DESIGN_DIR / "domains" / "design" / "01-domain-overview.md") + # domain-overview.md has no frontmatter in this repo, so it produces no DesignDocument + # If it has no frontmatter, it returns empty + # Check: this file does not have frontmatter + content = (DESIGN_DIR / "domains" / "design" / "01-domain-overview.md").read_text() + if content.startswith("---"): + assert "design_documents" in result + assert "domains" in result + assert result["domains"][0].domain_name == "design" + else: + # No frontmatter, so empty result and Domain produced from filename + assert result == {} or "domains" in result + + +class TestMdParserSystemContext: + def test_parse_system_context(self, md_parser): + result = md_parser.parse(DESIGN_DIR / "application-architecture" / "01-system-context.md") + assert "design_documents" in result + assert "system_context" in result + sc = result["system_context"] + assert len(sc) == 1 + assert sc[0].doc_id == "DOC-AA-001" + assert sc[0].title == "系统上下文" + assert len(sc[0].content) > 0 + + +class TestMdParserAdrTemplate: + def test_adr_template_no_adr_entity(self, md_parser): + result = md_parser.parse(DESIGN_DIR / "adr" / "ADR-000-template.md") + # ADR-000-template has no frontmatter, so empty + content = (DESIGN_DIR / "adr" / "ADR-000-template.md").read_text() + if not content.startswith("---"): + assert result == {} + else: + # If it has frontmatter, should NOT produce ADR (it's a template) + assert "adrs" not in result + + +class TestMdParserNoFrontmatter: + def test_no_frontmatter_returns_empty(self, md_parser, tmp_path): + md = tmp_path / "test.md" + md.write_text("# Just a heading\n\nSome content.\n") + result = md_parser.parse(md) + assert result == {} + + def test_nonexistent_md_returns_empty(self, md_parser): + result = md_parser.parse(Path("/nonexistent/file.md")) + assert result == {} + + +class TestMdParserSolutionLayering: + def test_parse_solution_layering(self, md_parser): + result = md_parser.parse(DESIGN_DIR / "application-architecture" / "02b-solution-layering.md") + assert "design_documents" in result + assert "solution_layer" in result + sl = result["solution_layer"] + assert len(sl) == 1 + assert sl[0].doc_id == "DOC-AA-003" + + +class TestMdParserModuleBoundary: + def test_parse_module_boundary(self, md_parser): + result = md_parser.parse(DESIGN_DIR / "application-architecture" / "07-module-boundary-rules.md") + assert "design_documents" in result + assert "module_boundary_rule" in result + + +class TestMdParserRuntimeTopology: + def test_parse_runtime_topology(self, md_parser): + result = md_parser.parse(DESIGN_DIR / "technology-architecture" / "01-runtime-topology.md") + assert "design_documents" in result + assert "runtime_topology" in result + + +class TestMdParserOperationalBaseline: + def test_parse_operational_baseline(self, md_parser): + result = md_parser.parse(DESIGN_DIR / "technology-architecture" / "03-operational-baseline.md") + assert "design_documents" in result + assert "operational_baseline" in result + + +class TestMdParserReleasePlan: + def test_parse_release_plan(self, md_parser): + result = md_parser.parse(DESIGN_DIR / "technology-architecture" / "04-release-and-rollback.md") + assert "design_documents" in result + assert "release_plan" in result + + +# ── YAML Parser Tests ── + +from app.modules.scanner.infrastructure.parsers.yaml_parser import YamlParser + + +@pytest.fixture +def yaml_parser(): + return YamlParser() + + +class TestYamlParser: + def test_load_openapi_yaml(self, yaml_parser): + data = yaml_parser.load( + DESIGN_DIR / "application-architecture" / "04-api-contracts.openapi.yaml" + ) + assert data is not None + assert "openapi" in data + assert "paths" in data + + def test_load_nonexistent_returns_none(self, yaml_parser): + result = yaml_parser.load(Path("/nonexistent/file.yaml")) + assert result is None + + def test_load_plain_yaml(self, yaml_parser, tmp_path): + f = tmp_path / "test.yaml" + f.write_text("key: value\nlist:\n - one\n - two\n") + data = yaml_parser.load(f) + assert data == {"key": "value", "list": ["one", "two"]} + + +# ── OpenAPI Parser Tests ── + +from app.modules.scanner.infrastructure.parsers.openapi_parser import OpenapiParser + + +@pytest.fixture +def openapi_parser(): + return OpenapiParser() + + +class TestOpenapiParser: + def test_parse_api_contracts(self, openapi_parser): + result = openapi_parser.parse( + DESIGN_DIR / "application-architecture" / "04-api-contracts.openapi.yaml" + ) + assert "api_contracts" in result + contracts = result["api_contracts"] + assert len(contracts) > 0 + # Check that contracts have correct fields + contract = contracts[0] + assert contract.path.startswith("/") + assert contract.method in ("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD") + assert contract.doc_id.startswith("API-") + + def test_parse_health_endpoint(self, openapi_parser): + result = openapi_parser.parse( + DESIGN_DIR / "application-architecture" / "04-api-contracts.openapi.yaml" + ) + contracts = result["api_contracts"] + health = [c for c in contracts if c.path == "/api/health"] + assert len(health) == 1 + assert health[0].method == "GET" + assert health[0].operation_id == "healthCheck" + + def test_parse_nonexistent_returns_empty(self, openapi_parser): + result = openapi_parser.parse(Path("/nonexistent/file.yaml")) + assert result == {} + + def test_parse_non_openapi_yaml_returns_empty(self, openapi_parser, tmp_path): + f = tmp_path / "not-openapi.yaml" + f.write_text("key: value\n") + result = openapi_parser.parse(f) + assert result == {} diff --git a/backend/tests/test_scanner_service.py b/backend/tests/test_scanner_service.py new file mode 100644 index 0000000..eea459e --- /dev/null +++ b/backend/tests/test_scanner_service.py @@ -0,0 +1,110 @@ +"""Tests for ScanService — integration with real design directory.""" + +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 + + +@pytest.fixture +def scan_service(): + return ScanService() + + +@pytest.fixture +def test_project(): + return Project( + id="test-proj", + name="test", + design_dir="/workspace/arch-design-agent-skill-dashboard/design", + code_dir=None, + created_at=datetime(2026, 1, 1), + ) + + +def test_scan_produces_result(scan_service, test_project): + result = scan_service.scan(test_project) + assert result.project_id == "test-proj" + assert result.scanned_at is not None + assert len(result.file_statuses) > 0 + assert result.summary.total_files > 0 + + +def test_scan_has_capabilities(scan_service, test_project): + result = scan_service.scan(test_project) + assert len(result.capabilities) > 0 + assert result.capabilities[0].capability_id.startswith("CAP-") + + +def test_scan_has_modules(scan_service, test_project): + result = scan_service.scan(test_project) + assert len(result.modules) > 0 + + +def test_scan_has_traceability_links(scan_service, test_project): + result = scan_service.scan(test_project) + assert len(result.traceability_links) > 0 + # entity_ids should be a list (space-split) + assert isinstance(result.traceability_links[0].entity_ids, list) + + +def test_scan_has_design_documents(scan_service, test_project): + result = scan_service.scan(test_project) + assert len(result.design_documents) > 0 + + +def test_scan_has_api_contracts(scan_service, test_project): + result = scan_service.scan(test_project) + assert len(result.api_contracts) > 0 + assert result.api_contracts[0].path.startswith("/") + + +def test_scan_has_value_flows(scan_service, test_project): + result = scan_service.scan(test_project) + assert len(result.value_flows) > 0 + + +def test_scan_has_integrations(scan_service, test_project): + result = scan_service.scan(test_project) + assert len(result.integrations) > 0 + + +def test_scan_has_external_systems(scan_service, test_project): + result = scan_service.scan(test_project) + assert len(result.external_systems) > 0 + + +def test_scan_has_entities(scan_service, test_project): + result = scan_service.scan(test_project) + assert len(result.entities) > 0 + + +def test_scan_summary_counts_match(scan_service, test_project): + result = scan_service.scan(test_project) + s = result.summary + assert s.total_files == len(result.file_statuses) + assert s.total_files == s.ok + s.sparse + s.missing + s.placeholder_heavy + s.template_residue + + +def test_get_latest_scan_none_before_scan(scan_service): + assert scan_service.get_latest_scan("nonexistent") is None + + +def test_get_latest_scan_cached(scan_service, test_project): + scan_service.scan(test_project) + cached = scan_service.get_latest_scan("test-proj") + assert cached is not None + assert cached.project_id == "test-proj" + + +def test_scan_has_singleton_fields(scan_service, test_project): + result = scan_service.scan(test_project) + # These MD files have frontmatter and should produce singleton entities + assert result.system_context is not None + assert result.solution_layer is not None + assert result.module_boundary_rule is not None + assert result.runtime_topology is not None + assert result.operational_baseline is not None + assert result.release_plan is not None diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..8800c6d --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,593 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "arch-design-dashboard" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", specifier = ">=8.0" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.135.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..047e63f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + backend: + build: ./backend + ports: + - "8900:8900" + volumes: + - ${DESIGN_DIR:-.}:/data/design:rw + - ${CODE_DIR:-/dev/null}:/data/code:ro + - registry-data:/data/registry + environment: + - REGISTRY_PATH=/data/registry/projects.json + + frontend: + build: ./frontend + ports: + - "8899:80" + depends_on: + - backend + +volumes: + registry-data: diff --git a/docs/superpowers/plans/2026-03-23-full-implementation.md b/docs/superpowers/plans/2026-03-23-full-implementation.md new file mode 100644 index 0000000..2b118a8 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-full-implementation.md @@ -0,0 +1,2316 @@ +# Arch Design Dashboard — Full Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the complete Arch Design Dashboard — a web app for visualizing and managing architecture design documents, from empty scaffold to fully working application. + +**Architecture:** DDD-layered backend (FastAPI) with 6 modules (design, project, scanner, graph, editor, impl_tracker), Vue 3 SPA frontend with 3 modules (project, graph, editor), D3.js graph visualization. No database — design files are single source of truth, projects persisted as JSON. + +**Tech Stack:** Python 3.12 / FastAPI / Uvicorn / uv (backend), Vue 3 / TypeScript / Vite / Pinia / D3.js / Axios (frontend), Docker Compose / Nginx (deployment) + +**Spec:** `docs/superpowers/specs/2026-03-23-full-implementation-design.md` + +--- + +## File Map + +### Files to Create (new) + +``` +backend/pyproject.toml +backend/.python-version +backend/tests/__init__.py +backend/tests/test_design_entities.py +backend/tests/test_design_services.py +backend/tests/test_project.py +backend/tests/test_scanner_parsers.py +backend/tests/test_scanner_service.py +backend/tests/test_graph_service.py +backend/tests/test_editor_service.py +backend/tests/test_impl_tracker.py +backend/tests/test_api_project.py +backend/tests/test_api_scanner.py +backend/tests/test_api_graph.py +backend/tests/test_api_editor.py +backend/tests/test_api_impl_tracker.py +backend/app/modules/editor/infrastructure/file_io.py +backend/app/modules/impl_tracker/infrastructure/code_scanner.py +backend/app/modules/impl_tracker/infrastructure/llm_client.py +frontend/package.json +frontend/vite.config.ts +frontend/tsconfig.json +frontend/tsconfig.node.json +frontend/index.html +frontend/src/style.css +docker-compose.yml +backend/Dockerfile +frontend/Dockerfile +frontend/nginx.conf +``` + +### Files to Populate (exist as 0-byte stubs) + +All 86 files under `backend/app/` and `frontend/src/` listed in the scaffold. + +--- + +## Task 1: Backend Build Configuration + +**Files:** +- Create: `backend/pyproject.toml` +- Create: `backend/.python-version` +- Create: `backend/tests/__init__.py` +- Create: `backend/tests/conftest.py` + +**Note on `__init__.py` files:** The scaffold contains ~25 empty `__init__.py` files across all modules. These remain as empty files unless a task explicitly specifies content for them. They are included in `git add` via directory-level adds. + +- [ ] **Step 1: Create pyproject.toml** + +```toml +[project] +name = "arch-design-dashboard" +version = "0.1.0" +description = "Architecture Design Dashboard Backend" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "pyyaml>=6.0", + "python-multipart>=0.0.9", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "httpx>=0.27.0", +] +``` + +- [ ] **Step 2: Create .python-version** + +``` +3.12 +``` + +- [ ] **Step 3: Create tests/__init__.py (empty)** + +- [ ] **Step 4: Create tests/conftest.py (shared fixtures)** + +```python +import os +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def tmp_registry(tmp_path: Path): + """Set REGISTRY_PATH env var to a temp file for test isolation.""" + registry = str(tmp_path / "projects.json") + os.environ["REGISTRY_PATH"] = registry + yield registry + os.environ.pop("REGISTRY_PATH", None) + + +@pytest.fixture +def client(tmp_registry): + """Create a test client with isolated registry.""" + from app.main import create_app + app = create_app() + return TestClient(app) + + +@pytest.fixture +def design_dir(tmp_path: Path) -> Path: + """Create a minimal design directory for testing.""" + d = tmp_path / "design" + d.mkdir() + return d +``` + +- [ ] **Step 4: Install dependencies and verify** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv sync` +Expected: Dependencies installed successfully. + +- [ ] **Step 5: Verify pytest runs (no tests yet)** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run pytest --co -q` +Expected: "no tests ran" or similar (no errors). + +- [ ] **Step 6: Commit** + +```bash +git add backend/pyproject.toml backend/.python-version backend/tests/__init__.py backend/uv.lock +git commit -m "build: add backend pyproject.toml and test infrastructure" +``` + +--- + +## Task 2: Shared Kernel — Exceptions + +**Files:** +- Modify: `backend/app/shared/__init__.py` +- Modify: `backend/app/shared/kernel/__init__.py` +- Modify: `backend/app/shared/kernel/exceptions.py` + +- [ ] **Step 1: Write exceptions.py** + +```python +class NotFoundError(Exception): + def __init__(self, entity: str, entity_id: str) -> None: + self.entity = entity + self.entity_id = entity_id + super().__init__(f"{entity} not found: {entity_id}") + + +class ValidationError(Exception): + def __init__(self, message: str) -> None: + self.message = message + super().__init__(message) + + +class FileSystemError(Exception): + def __init__(self, path: str, message: str) -> None: + self.path = path + self.message = message + super().__init__(f"Filesystem error at {path}: {message}") +``` + +- [ ] **Step 2: Verify import works** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run python -c "from app.shared.kernel.exceptions import NotFoundError, ValidationError, FileSystemError; print('OK')"` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/shared/ +git commit -m "feat(shared): add kernel exceptions" +``` + +--- + +## Task 3: Shared Infrastructure — Config & Filesystem + +**Files:** +- Modify: `backend/app/shared/infrastructure/__init__.py` +- Modify: `backend/app/shared/infrastructure/config.py` +- Modify: `backend/app/shared/infrastructure/filesystem.py` + +- [ ] **Step 1: Write config.py** + +```python +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class Settings: + registry_path: Path = field( + default_factory=lambda: Path.home() / ".arch-design-dashboard" / "projects.json" + ) +``` + +- [ ] **Step 2: Write filesystem.py** + +```python +from pathlib import Path + +from app.shared.kernel.exceptions import FileSystemError + + +def read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except OSError as e: + raise FileSystemError(str(path), str(e)) from e + + +def write_text(path: Path, content: str) -> None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + except OSError as e: + raise FileSystemError(str(path), str(e)) from e + + +def list_files(directory: Path, extensions: list[str] | None = None) -> list[Path]: + if not directory.is_dir(): + raise FileSystemError(str(directory), "Not a directory") + files: list[Path] = [] + for p in sorted(directory.rglob("*")): + if p.is_file(): + if extensions is None or p.suffix in extensions: + files.append(p) + return files + + +def file_exists(path: Path) -> bool: + return path.is_file() +``` + +- [ ] **Step 3: Verify imports** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run python -c "from app.shared.infrastructure.config import Settings; from app.shared.infrastructure.filesystem import read_text, write_text, list_files, file_exists; print('OK')"` +Expected: `OK` + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/shared/ +git commit -m "feat(shared): add config and filesystem utilities" +``` + +--- + +## Task 4: MOD-DESIGN Domain — Value Objects + +**Files:** +- Modify: `backend/app/modules/design/__init__.py` +- Modify: `backend/app/modules/design/domain/__init__.py` +- Modify: `backend/app/modules/design/domain/value_objects.py` + +- [ ] **Step 1: Write the test** + +Create: `backend/tests/test_design_entities.py` + +```python +from app.modules.design.domain.value_objects import ( + ArchitectureLayer, + FileStatus, + ModuleLayer, +) + + +def test_file_status_values(): + assert FileStatus.OK == "ok" + assert FileStatus.SPARSE == "sparse" + assert FileStatus.MISSING == "missing" + assert FileStatus.TEMPLATE_RESIDUE == "template-residue" + assert FileStatus.PLACEHOLDER_HEAVY == "placeholder-heavy" + + +def test_architecture_layer_values(): + assert ArchitectureLayer.BUSINESS == "business" + assert ArchitectureLayer.APPLICATION == "application" + assert ArchitectureLayer.DATA == "data" + assert ArchitectureLayer.TECHNOLOGY == "technology" + + +def test_module_layer_values(): + assert ModuleLayer.DOMAIN == "domain" + assert ModuleLayer.APPLICATION == "application" + assert ModuleLayer.INFRASTRUCTURE == "infrastructure" + assert ModuleLayer.INTERFACES == "interfaces" +``` + +- [ ] **Step 2: Run test — should fail** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run pytest tests/test_design_entities.py -v` +Expected: FAIL (empty module) + +- [ ] **Step 3: Write value_objects.py** + +```python +from enum import Enum + + +class FileStatus(str, Enum): + OK = "ok" + SPARSE = "sparse" + MISSING = "missing" + TEMPLATE_RESIDUE = "template-residue" + PLACEHOLDER_HEAVY = "placeholder-heavy" + + +class ArchitectureLayer(str, Enum): + BUSINESS = "business" + APPLICATION = "application" + DATA = "data" + TECHNOLOGY = "technology" + + +class ModuleLayer(str, Enum): + DOMAIN = "domain" + APPLICATION = "application" + INFRASTRUCTURE = "infrastructure" + INTERFACES = "interfaces" +``` + +- [ ] **Step 4: Run test — should pass** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run pytest tests/test_design_entities.py -v` +Expected: 3 passed + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/modules/design/ backend/tests/test_design_entities.py +git commit -m "feat(design): add value objects — FileStatus, ArchitectureLayer, ModuleLayer" +``` + +--- + +## Task 5: MOD-DESIGN Domain — Entities (31 dataclasses) + +**Files:** +- Modify: `backend/app/modules/design/domain/entities.py` +- Modify: `backend/tests/test_design_entities.py` (append tests) + +- [ ] **Step 1: Add entity creation tests** + +Append to `backend/tests/test_design_entities.py`: + +```python +from app.modules.design.domain.entities import ( + ADR, + ApiContract, + Capability, + ChangeLogEntry, + CodebaseAlignment, + DataFlow, + DataSecurity, + DesignDocument, + Domain, + DomainEntity, + DomainModule, + Entity, + Environment, + ExternalSystem, + Integration, + Module, + ModuleBoundaryRule, + OperationalBaseline, + ReleasePlan, + RuntimeComponent, + RuntimeTopology, + Scenario, + ScopeAndGoals, + SharedTerm, + SolutionLayer, + SystemContext, + TechSelection, + TraceabilityLink, + UbiquitousTerm, + UserJourney, + ValueFlow, +) + + +def test_capability_creation(): + cap = Capability( + capability_id="CAP-01", + name="test", + description="desc", + priority="must", + phase="MVP", + related_value_flows=["VF-01"], + ) + assert cap.capability_id == "CAP-01" + assert cap.related_value_flows == ["VF-01"] + + +def test_module_creation(): + mod = Module( + module_id="MOD-01", + name="test", + layer="backend", + description="desc", + phase="MVP", + depends_on=["MOD-02"], + capabilities=["CAP-01"], + ) + assert mod.depends_on == ["MOD-02"] + + +def test_traceability_link_list_fields(): + tl = TraceabilityLink( + trace_id="TR-01", + capability_id="CAP-01", + module_id="MOD-01", + entity_ids=["ENT-01", "ENT-02"], + value_flow_ids=["VF-01"], + notes="test", + ) + assert len(tl.entity_ids) == 2 + + +def test_design_document_list_fields(): + dd = DesignDocument( + doc_id="DOC-01", + title="test", + version="0.1", + status="draft", + owners=["owner1"], + upstream=["a.md"], + downstream=["b.md"], + file_path="test.md", + ) + assert dd.owners == ["owner1"] + + +def test_all_31_entities_importable(): + """Verify all 31 entity classes can be imported.""" + entities = [ + Capability, ValueFlow, UserJourney, ScopeAndGoals, + Module, Integration, ExternalSystem, ApiContract, + CodebaseAlignment, ModuleBoundaryRule, SystemContext, SolutionLayer, + Entity, DataFlow, DataSecurity, + TechSelection, RuntimeComponent, RuntimeTopology, Environment, + OperationalBaseline, ReleasePlan, + TraceabilityLink, ChangeLogEntry, ADR, DesignDocument, + Domain, UbiquitousTerm, SharedTerm, Scenario, DomainModule, DomainEntity, + ] + assert len(entities) == 31 +``` + +- [ ] **Step 2: Run tests — should fail** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run pytest tests/test_design_entities.py -v` +Expected: FAIL (empty entities.py) + +- [ ] **Step 3: Write entities.py with all 31 dataclasses** + +Write `backend/app/modules/design/domain/entities.py` with all 31 `@dataclass` definitions exactly as specified in the spec (Section 3.2). Every `list[str]` field that comes from CSV space-delimited values must use `list[str]` type. + +Dataclasses to implement (grouped by architecture layer): +- **Business:** Capability, ValueFlow, UserJourney, ScopeAndGoals +- **Application:** Module, Integration, ExternalSystem, ApiContract, CodebaseAlignment, ModuleBoundaryRule, SystemContext, SolutionLayer +- **Data:** Entity, DataFlow, DataSecurity +- **Technology:** TechSelection, RuntimeComponent, RuntimeTopology, Environment, OperationalBaseline, ReleasePlan +- **Cross-layer:** TraceabilityLink, ChangeLogEntry, ADR, DesignDocument +- **Domain:** Domain, UbiquitousTerm, SharedTerm, Scenario, DomainModule, DomainEntity + +Refer to spec Section 3.2 for exact field definitions of each class. + +- [ ] **Step 4: Run tests — should pass** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run pytest tests/test_design_entities.py -v` +Expected: All passed + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/modules/design/domain/entities.py backend/tests/test_design_entities.py +git commit -m "feat(design): add 31 design entity dataclasses" +``` + +--- + +## Task 6: MOD-DESIGN Domain — Validation Service + +**Files:** +- Modify: `backend/app/modules/design/domain/services.py` +- Create: `backend/tests/test_design_services.py` + +- [ ] **Step 1: Write the tests** + +```python +import pytest +from app.modules.design.domain.entities import ( + Capability, + Entity, + Module, + TraceabilityLink, +) +from app.modules.design.domain.services import DesignValidationService +from app.modules.design.domain.value_objects import FileStatus + + +class TestFileStatusDetermination: + def test_empty_content_is_missing(self): + assert DesignValidationService.determine_file_status("", "test.csv") == FileStatus.MISSING + + def test_csv_header_only_is_sparse(self): + assert DesignValidationService.determine_file_status("id,name\n", "test.csv") == FileStatus.SPARSE + + def test_csv_with_data_is_ok(self): + content = "id,name\n1,foo\n2,bar\n" + assert DesignValidationService.determine_file_status(content, "test.csv") == FileStatus.OK + + def test_md_too_short_is_sparse(self): + assert DesignValidationService.determine_file_status("# Title\n\nShort.\n", "test.md") == FileStatus.SPARSE + + def test_template_residue_detected(self): + content = "id,name\nTODO,\nreal,data\n" + assert DesignValidationService.determine_file_status(content, "test.csv") == FileStatus.TEMPLATE_RESIDUE + + def test_placeholder_heavy(self): + content = "id,name,desc\nTODO,TODO,TODO\nTODO,TODO,TODO\n" + assert DesignValidationService.determine_file_status(content, "test.csv") == FileStatus.PLACEHOLDER_HEAVY + + def test_ok_md_file(self): + content = "---\ndoc_id: DOC-01\ntitle: Test\n---\n\n# Title\n\nThis is a real document with enough content.\nLine 4.\nLine 5.\nLine 6.\n" + assert DesignValidationService.determine_file_status(content, "test.md") == FileStatus.OK + + +class TestConstraintValidation: + def _make_cap(self, cap_id: str) -> Capability: + return Capability(cap_id, "n", "d", "must", "MVP", []) + + def _make_mod(self, mod_id: str) -> Module: + return Module(mod_id, "n", "backend", "d", "MVP", [], []) + + def _make_ent(self, ent_id: str, owner: str) -> Entity: + return Entity(ent_id, "n", "d", owner, "d", "MVP", "f.csv") + + def _make_link(self, trace_id: str, cap: str, mod: str, ents: list[str]) -> TraceabilityLink: + return TraceabilityLink(trace_id, cap, mod, ents, [], "") + + def test_capability_without_module_link_is_violation(self): + caps = [self._make_cap("CAP-01")] + links = [] # no link references CAP-01 + violations = DesignValidationService.check_capability_module_linkage(caps, links) + assert len(violations) == 1 + + def test_entity_without_owner_is_violation(self): + entities = [self._make_ent("ENT-01", "")] + violations = DesignValidationService.check_entity_owner(entities) + assert len(violations) == 1 + + def test_valid_traceability_passes(self): + caps = [self._make_cap("CAP-01")] + mods = [self._make_mod("MOD-01")] + ents = [self._make_ent("ENT-01", "MOD-01")] + links = [self._make_link("TR-01", "CAP-01", "MOD-01", ["ENT-01"])] + violations = DesignValidationService.check_traceability_references(links, caps, mods, ents) + assert len(violations) == 0 + + def test_broken_traceability_reference_is_violation(self): + caps = [self._make_cap("CAP-01")] + mods = [self._make_mod("MOD-01")] + ents = [self._make_ent("ENT-01", "MOD-01")] + links = [self._make_link("TR-01", "CAP-MISSING", "MOD-01", ["ENT-01"])] + violations = DesignValidationService.check_traceability_references(links, caps, mods, ents) + assert len(violations) >= 1 +``` + +- [ ] **Step 2: Run tests — should fail** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run pytest tests/test_design_services.py -v` +Expected: FAIL + +- [ ] **Step 3: Implement services.py** + +```python +from dataclasses import dataclass + +from app.modules.design.domain.entities import ( + Capability, + Entity, + Module, + TraceabilityLink, +) +from app.modules.design.domain.value_objects import FileStatus + +TEMPLATE_MARKERS = ["TODO", "EXAMPLE", " FileStatus: + if not content or not content.strip(): + return FileStatus.MISSING + + lines = [l for l in content.strip().splitlines() if l.strip()] + is_csv = file_path.endswith(".csv") + + # Sparse check + if is_csv and len(lines) < 2: + return FileStatus.SPARSE + if not is_csv and len(lines) < 5: + return FileStatus.SPARSE + + # Count placeholder tokens + total_cells = 0 + placeholder_cells = 0 + for line in lines: + cells = line.split(",") if is_csv else [line] + for cell in cells: + total_cells += 1 + if any(m.lower() in cell.lower() for m in TEMPLATE_MARKERS): + placeholder_cells += 1 + + # Template residue: any marker present + if placeholder_cells > 0 and placeholder_cells / total_cells <= 0.3: + return FileStatus.TEMPLATE_RESIDUE + + # Placeholder heavy: >30% + if total_cells > 0 and placeholder_cells / total_cells > 0.3: + return FileStatus.PLACEHOLDER_HEAVY + + return FileStatus.OK + + @staticmethod + def check_capability_module_linkage( + capabilities: list[Capability], + traceability_links: list[TraceabilityLink], + ) -> list[ConstraintViolation]: + linked_caps = {link.capability_id for link in traceability_links} + return [ + ConstraintViolation( + rule="capability_module_linkage", + entity_id=cap.capability_id, + message=f"Capability {cap.capability_id} has no TraceabilityLink to any module", + ) + for cap in capabilities + if cap.capability_id not in linked_caps + ] + + @staticmethod + def check_entity_owner(entities: list[Entity]) -> list[ConstraintViolation]: + return [ + ConstraintViolation( + rule="entity_owner", + entity_id=ent.entity_id, + message=f"Entity {ent.entity_id} has no owner module", + ) + for ent in entities + if not ent.owner_module + ] + + @staticmethod + def check_traceability_references( + links: list[TraceabilityLink], + capabilities: list[Capability], + modules: list[Module], + entities: list[Entity], + ) -> list[ConstraintViolation]: + cap_ids = {c.capability_id for c in capabilities} + mod_ids = {m.module_id for m in modules} + ent_ids = {e.entity_id for e in entities} + violations: list[ConstraintViolation] = [] + for link in links: + if link.capability_id not in cap_ids: + violations.append(ConstraintViolation( + "traceability_ref", link.trace_id, + f"References unknown capability {link.capability_id}", + )) + if link.module_id not in mod_ids: + violations.append(ConstraintViolation( + "traceability_ref", link.trace_id, + f"References unknown module {link.module_id}", + )) + for eid in link.entity_ids: + if eid not in ent_ids: + violations.append(ConstraintViolation( + "traceability_ref", link.trace_id, + f"References unknown entity {eid}", + )) + return violations + + @classmethod + def validate_all( + cls, + capabilities: list[Capability], + modules: list[Module], + entities: list[Entity], + traceability_links: list[TraceabilityLink], + ) -> list[ConstraintViolation]: + violations: list[ConstraintViolation] = [] + violations.extend(cls.check_capability_module_linkage(capabilities, traceability_links)) + violations.extend(cls.check_entity_owner(entities)) + violations.extend(cls.check_traceability_references( + traceability_links, capabilities, modules, entities, + )) + return violations +``` + +- [ ] **Step 4: Run tests — should pass** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run pytest tests/test_design_services.py -v` +Expected: All passed + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/modules/design/domain/services.py backend/tests/test_design_services.py +git commit -m "feat(design): add DesignValidationService — file status detection and constraint rules" +``` + +--- + +## Task 7: MOD-PROJECT Domain + +**Files:** +- Modify: `backend/app/modules/project/domain/entities.py` +- Modify: `backend/app/modules/project/domain/repositories.py` +- Modify: `backend/app/modules/project/__init__.py` +- Modify: `backend/app/modules/project/domain/__init__.py` + +- [ ] **Step 1: Write entities.py** + +```python +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class Project: + id: str + name: str + design_dir: str + code_dir: str | None + created_at: datetime +``` + +- [ ] **Step 2: Write repositories.py** + +```python +from abc import ABC, abstractmethod + +from app.modules.project.domain.entities import Project + + +class ProjectRepository(ABC): + @abstractmethod + def list_all(self) -> list[Project]: + ... + + @abstractmethod + def get_by_id(self, project_id: str) -> Project | None: + ... + + @abstractmethod + def save(self, project: Project) -> None: + ... + + @abstractmethod + def delete(self, project_id: str) -> None: + ... +``` + +- [ ] **Step 3: Verify import** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run python -c "from app.modules.project.domain.entities import Project; from app.modules.project.domain.repositories import ProjectRepository; print('OK')"` +Expected: `OK` + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/modules/project/ +git commit -m "feat(project): add Project entity and ProjectRepository interface" +``` + +--- + +## Task 8: MOD-PROJECT Infrastructure — JsonProjectRepository + +**Files:** +- Modify: `backend/app/modules/project/infrastructure/__init__.py` +- Modify: `backend/app/modules/project/infrastructure/json_repository.py` +- Create: `backend/tests/test_project.py` + +- [ ] **Step 1: Write the test** + +```python +import json +from pathlib import Path + +import pytest +from app.modules.project.domain.entities import Project +from app.modules.project.infrastructure.json_repository import JsonProjectRepository + + +@pytest.fixture +def repo(tmp_path: Path) -> JsonProjectRepository: + return JsonProjectRepository(tmp_path / "projects.json") + + +def test_empty_repo_returns_empty_list(repo: JsonProjectRepository): + assert repo.list_all() == [] + + +def test_save_and_get(repo: JsonProjectRepository): + from datetime import datetime + p = Project(id="id1", name="test", design_dir="/tmp/d", code_dir=None, created_at=datetime(2026, 1, 1)) + repo.save(p) + assert repo.get_by_id("id1") is not None + assert repo.get_by_id("id1").name == "test" + + +def test_list_all(repo: JsonProjectRepository): + from datetime import datetime + p1 = Project(id="id1", name="a", design_dir="/d1", code_dir=None, created_at=datetime(2026, 1, 1)) + p2 = Project(id="id2", name="b", design_dir="/d2", code_dir=None, created_at=datetime(2026, 1, 2)) + repo.save(p1) + repo.save(p2) + assert len(repo.list_all()) == 2 + + +def test_delete(repo: JsonProjectRepository): + from datetime import datetime + p = Project(id="id1", name="test", design_dir="/d", code_dir=None, created_at=datetime(2026, 1, 1)) + repo.save(p) + repo.delete("id1") + assert repo.get_by_id("id1") is None + + +def test_get_nonexistent_returns_none(repo: JsonProjectRepository): + assert repo.get_by_id("nope") is None +``` + +- [ ] **Step 2: Run test — should fail** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run pytest tests/test_project.py -v` +Expected: FAIL + +- [ ] **Step 3: Implement json_repository.py** + +```python +import json +from datetime import datetime +from pathlib import Path + +from app.modules.project.domain.entities import Project +from app.modules.project.domain.repositories import ProjectRepository + + +class JsonProjectRepository(ProjectRepository): + def __init__(self, path: Path) -> None: + self._path = path + + def _load(self) -> list[dict]: + if not self._path.exists(): + return [] + return json.loads(self._path.read_text(encoding="utf-8")) + + def _save(self, data: list[dict]) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + @staticmethod + def _to_dict(p: Project) -> dict: + return { + "id": p.id, + "name": p.name, + "design_dir": p.design_dir, + "code_dir": p.code_dir, + "created_at": p.created_at.isoformat(), + } + + @staticmethod + def _from_dict(d: dict) -> Project: + return Project( + id=d["id"], + name=d["name"], + design_dir=d["design_dir"], + code_dir=d.get("code_dir"), + created_at=datetime.fromisoformat(d["created_at"]), + ) + + def list_all(self) -> list[Project]: + return [self._from_dict(d) for d in self._load()] + + def get_by_id(self, project_id: str) -> Project | None: + for d in self._load(): + if d["id"] == project_id: + return self._from_dict(d) + return None + + def save(self, project: Project) -> None: + data = self._load() + data = [d for d in data if d["id"] != project.id] + data.append(self._to_dict(project)) + self._save(data) + + def delete(self, project_id: str) -> None: + data = [d for d in self._load() if d["id"] != project_id] + self._save(data) +``` + +- [ ] **Step 4: Run tests — should pass** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run pytest tests/test_project.py -v` +Expected: All passed + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/modules/project/infrastructure/ backend/tests/test_project.py +git commit -m "feat(project): add JsonProjectRepository with JSON file persistence" +``` + +--- + +## Task 9: MOD-PROJECT Application — ProjectService + +**Files:** +- Modify: `backend/app/modules/project/application/__init__.py` +- Modify: `backend/app/modules/project/application/services.py` +- Modify: `backend/tests/test_project.py` (append) + +- [ ] **Step 1: Add service tests** + +Append to `backend/tests/test_project.py`: + +```python +from app.modules.project.application.services import ProjectService +from app.shared.kernel.exceptions import NotFoundError, ValidationError + + +@pytest.fixture +def service(tmp_path: Path) -> ProjectService: + repo = JsonProjectRepository(tmp_path / "projects.json") + return ProjectService(repo) + + +def test_create_project_validates_design_dir(service: ProjectService, tmp_path: Path): + design_dir = tmp_path / "design" + design_dir.mkdir() + project = service.create_project("test", str(design_dir)) + assert project.name == "test" + assert project.id # UUID generated + + +def test_create_project_rejects_missing_dir(service: ProjectService): + with pytest.raises(ValidationError): + service.create_project("test", "/nonexistent/path") + + +def test_get_project_not_found(service: ProjectService): + with pytest.raises(NotFoundError): + service.get_project("nonexistent") + + +def test_delete_project(service: ProjectService, tmp_path: Path): + design_dir = tmp_path / "design" + design_dir.mkdir() + p = service.create_project("test", str(design_dir)) + service.delete_project(p.id) + with pytest.raises(NotFoundError): + service.get_project(p.id) +``` + +- [ ] **Step 2: Run tests — should fail** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run pytest tests/test_project.py -v -k "service"` +Expected: FAIL + +- [ ] **Step 3: Implement services.py** + +```python +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from app.modules.project.domain.entities import Project +from app.modules.project.domain.repositories import ProjectRepository +from app.shared.kernel.exceptions import NotFoundError, ValidationError + + +class ProjectService: + def __init__(self, repository: ProjectRepository) -> None: + self._repo = repository + + def list_projects(self) -> list[Project]: + return self._repo.list_all() + + def create_project( + self, name: str, design_dir: str, code_dir: str | None = None, + ) -> Project: + if not Path(design_dir).is_dir(): + raise ValidationError(f"Design directory does not exist: {design_dir}") + project = Project( + id=str(uuid.uuid4()), + name=name, + design_dir=design_dir, + code_dir=code_dir, + created_at=datetime.now(timezone.utc), + ) + self._repo.save(project) + return project + + def get_project(self, project_id: str) -> Project: + project = self._repo.get_by_id(project_id) + if project is None: + raise NotFoundError("Project", project_id) + return project + + def delete_project(self, project_id: str) -> None: + project = self._repo.get_by_id(project_id) + if project is None: + raise NotFoundError("Project", project_id) + self._repo.delete(project_id) +``` + +- [ ] **Step 4: Run tests — should pass** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run pytest tests/test_project.py -v` +Expected: All passed + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/modules/project/application/ backend/tests/test_project.py +git commit -m "feat(project): add ProjectService with CRUD and path validation" +``` + +--- + +## Task 10: MOD-PROJECT Interfaces — HTTP Router + +**Files:** +- Modify: `backend/app/modules/project/interfaces/http/router.py` +- Modify: `backend/app/modules/project/interfaces/__init__.py` +- Modify: `backend/app/modules/project/interfaces/http/__init__.py` +- Modify: `backend/app/main.py` +- Create: `backend/tests/test_api_project.py` + +- [ ] **Step 1: Write API tests** + +Uses `client` and `design_dir` fixtures from `conftest.py`. + +```python +import pytest + + +def test_health(client): + r = client.get("/api/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + +def test_list_projects_empty(client): + r = client.get("/api/projects") + assert r.status_code == 200 + assert r.json() == [] + + +def test_create_and_get_project(client, design_dir): + r = client.post("/api/projects", json={"name": "test", "design_dir": str(design_dir)}) + assert r.status_code == 201 + pid = r.json()["id"] + + r = client.get(f"/api/projects/{pid}") + assert r.status_code == 200 + assert r.json()["name"] == "test" + + +def test_create_project_invalid_dir(client): + r = client.post("/api/projects", json={"name": "test", "design_dir": "/nonexistent"}) + assert r.status_code == 400 + + +def test_delete_project(client, design_dir): + r = client.post("/api/projects", json={"name": "test", "design_dir": str(design_dir)}) + pid = r.json()["id"] + + r = client.delete(f"/api/projects/{pid}") + assert r.status_code == 204 + + r = client.get(f"/api/projects/{pid}") + assert r.status_code == 404 + + +def test_get_nonexistent_project(client): + r = client.get("/api/projects/nonexistent") + assert r.status_code == 404 +``` + +- [ ] **Step 2: Write router.py** + +```python +from fastapi import APIRouter, Response +from pydantic import BaseModel + +from app.modules.project.application.services import ProjectService + +router = APIRouter(prefix="/projects", tags=["project"]) + +_service: ProjectService | None = None + + +def init_router(service: ProjectService) -> None: + global _service + _service = service + + +class CreateProjectRequest(BaseModel): + name: str + design_dir: str + code_dir: str | None = None + + +class ProjectResponse(BaseModel): + id: str + name: str + design_dir: str + code_dir: str | None + created_at: str + + +def _to_response(p) -> dict: + return { + "id": p.id, + "name": p.name, + "design_dir": p.design_dir, + "code_dir": p.code_dir, + "created_at": p.created_at.isoformat(), + } + + +@router.get("") +def list_projects(): + return [_to_response(p) for p in _service.list_projects()] + + +@router.post("", status_code=201) +def create_project(req: CreateProjectRequest): + p = _service.create_project(req.name, req.design_dir, req.code_dir) + return _to_response(p) + + +@router.get("/{project_id}") +def get_project(project_id: str): + p = _service.get_project(project_id) + return _to_response(p) + + +@router.delete("/{project_id}", status_code=204) +def delete_project(project_id: str): + _service.delete_project(project_id) + return Response(status_code=204) +``` + +- [ ] **Step 3: Write main.py with app factory** + +```python +import os +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.infrastructure.config import Settings +from app.modules.project.infrastructure.json_repository import JsonProjectRepository +from app.modules.project.application.services import ProjectService +from app.modules.project.interfaces.http.router import router as project_router, init_router as init_project_router + + +def create_app() -> FastAPI: + app = FastAPI(title="Arch Design Dashboard API", version="0.1.0") + + # Settings + registry_path = Path(os.environ.get("REGISTRY_PATH", str(Settings().registry_path))) + + # Wire Project module + project_repo = JsonProjectRepository(registry_path) + project_service = ProjectService(project_repo) + init_project_router(project_service) + + # Register routers + app.include_router(project_router, prefix="/api") + + # Health check + @app.get("/api/health") + def health(): + return {"status": "ok"} + + # Exception handlers + @app.exception_handler(NotFoundError) + async def not_found_handler(request: Request, exc: NotFoundError): + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + @app.exception_handler(ValidationError) + async def validation_handler(request: Request, exc: ValidationError): + return JSONResponse(status_code=400, content={"detail": exc.message}) + + return app + + +# For uvicorn: use `uvicorn app.main:create_app --factory` +# or for simple usage: +app = create_app() +``` + +Note: Tests must call `create_app()` directly to get a fresh app instance with test-specific config. The module-level `app` is only used for `uvicorn app.main:app` convenience. Tests override `REGISTRY_PATH` env var before calling `create_app()`. + +- [ ] **Step 4: Run tests — should pass** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run pytest tests/test_api_project.py -v` +Expected: All passed + +- [ ] **Step 5: Verify server starts** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && timeout 5 uv run uvicorn app.main:app --port 8900 || true` +Expected: Server starts (timeout kills it after 5s) + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/modules/project/interfaces/ backend/app/main.py backend/tests/test_api_project.py +git commit -m "feat(project): add REST API — CRUD endpoints with FastAPI" +``` + +--- + +## Task 11: MOD-SCANNER Domain — Entities + +**Files:** +- Modify: `backend/app/modules/scanner/domain/__init__.py` +- Modify: `backend/app/modules/scanner/domain/entities.py` +- Modify: `backend/app/modules/scanner/__init__.py` + +- [ ] **Step 1: Write scanner domain entities** + +Write `backend/app/modules/scanner/domain/entities.py` with `FileStatusEntry`, `ScanSummary`, and `ScanResult` exactly as specified in spec Section 3.4. ScanResult carries all parsed Design entity lists plus file status info. + +- [ ] **Step 2: Verify import** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run python -c "from app.modules.scanner.domain.entities import ScanResult, FileStatusEntry, ScanSummary; print('OK')"` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/modules/scanner/ +git commit -m "feat(scanner): add ScanResult, FileStatusEntry, ScanSummary domain entities" +``` + +--- + +## Task 12: MOD-SCANNER Infrastructure — CSV Parser + +**Files:** +- Modify: `backend/app/modules/scanner/infrastructure/parsers/csv_parser.py` +- Modify: `backend/app/modules/scanner/infrastructure/parsers/__init__.py` +- Modify: `backend/app/modules/scanner/infrastructure/__init__.py` +- Create: `backend/tests/test_scanner_parsers.py` + +- [ ] **Step 1: Write CSV parser tests** + +Test that the CSV parser can read the real design CSV files and produce the correct Design entity types. Use the actual `design/business-architecture/02-capability-map.csv` as test input. + +Key tests: +- Parse capability-map.csv → list[Capability] with correct field mapping +- Parse modules.csv → list[Module] with list[str] fields split from spaces +- Parse traceability.csv → list[TraceabilityLink] with space-split entity_ids +- Parse unknown CSV filename → returns empty list (graceful) + +- [ ] **Step 2: Run tests — should fail** + +- [ ] **Step 3: Implement csv_parser.py** + +The parser must: +1. Read CSV with Python's `csv.DictReader` +2. Map file basename to entity type (e.g., `02-capability-map.csv` → Capability) +3. Split space-delimited fields into `list[str]` +4. Return a dict of entity type → list of instances + +File-to-entity mapping: +- `*capability-map*` → Capability +- `*modules*` → Module (application-architecture only) +- `*value-flows*` → ValueFlow +- `*user-journeys*` → UserJourney +- `*integrations*` → Integration +- `*external-systems*` → ExternalSystem +- `*entities*` → Entity (data-architecture only) +- `*data-flows*` → DataFlow +- `*data-security*` → DataSecurity +- `*technology-selection*` → TechSelection +- `*runtime-components*` → RuntimeComponent +- `*environments*` → Environment +- `*codebase-alignment*` → CodebaseAlignment +- `*change-log*` → ChangeLogEntry +- `traceability.csv` → TraceabilityLink +- `*shared-terminology*` → SharedTerm +- `*ubiquitous-language*` → UbiquitousTerm +- `*scenarios-and-flows*` → Scenario +- `*domain-modules*` → DomainModule +- `*domain-entities*` → DomainEntity + +- [ ] **Step 4: Run tests — should pass** + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/modules/scanner/infrastructure/parsers/csv_parser.py backend/tests/test_scanner_parsers.py +git commit -m "feat(scanner): add CSV parser — maps 20 CSV file types to Design entities" +``` + +--- + +## Task 13: MOD-SCANNER Infrastructure — MD Parser + +**Files:** +- Modify: `backend/app/modules/scanner/infrastructure/parsers/md_parser.py` +- Modify: `backend/tests/test_scanner_parsers.py` (append) + +- [ ] **Step 1: Write MD parser tests** + +Test that: +- Parse MD with YAML frontmatter → DesignDocument (doc_id, title, status, upstream, downstream, file_path) +- Parse scope-and-goals.md → ScopeAndGoals +- Parse system-context.md → SystemContext +- Parse MD without frontmatter → DesignDocument with minimal fields +- Parse ADR template → ADR entity +- Parse domain-overview.md → Domain entity + +- [ ] **Step 2: Run tests — should fail** + +- [ ] **Step 3: Implement md_parser.py** + +The parser must: +1. Extract YAML frontmatter between `---` markers using `yaml.safe_load` +2. Produce a DesignDocument for every MD file that has frontmatter +3. For specific files, also produce specialized entities: + - `*scope-and-goals*` → ScopeAndGoals + - `*system-context*` → SystemContext + - `*solution-layering*` → SolutionLayer + - `*module-boundary*` → ModuleBoundaryRule + - `*runtime-topology*` → RuntimeTopology + - `*operational-baseline*` → OperationalBaseline + - `*release-and-rollback*` → ReleasePlan + - `*domain-overview*` → Domain + - `*domain-decisions*` → (skip, no entity needed beyond DesignDocument) + - `ADR-*` (not template) → ADR + +- [ ] **Step 4: Run tests — should pass** + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/modules/scanner/infrastructure/parsers/md_parser.py backend/tests/test_scanner_parsers.py +git commit -m "feat(scanner): add Markdown parser — frontmatter extraction and specialized entity mapping" +``` + +--- + +## Task 14: MOD-SCANNER Infrastructure — YAML & OpenAPI Parsers + +**Files:** +- Modify: `backend/app/modules/scanner/infrastructure/parsers/yaml_parser.py` +- Modify: `backend/app/modules/scanner/infrastructure/parsers/openapi_parser.py` +- Modify: `backend/tests/test_scanner_parsers.py` (append) + +- [ ] **Step 1: Write tests for OpenAPI parser** + +Test that parsing `04-api-contracts.openapi.yaml` produces a list of ApiContract with correct path/method/operationId. + +- [ ] **Step 2: Implement yaml_parser.py** (thin wrapper around yaml.safe_load) + +- [ ] **Step 3: Implement openapi_parser.py** + +Parse OpenAPI YAML → iterate `paths` → for each path+method, create an `ApiContract(doc_id, path, method, operation_id, summary)`. + +- [ ] **Step 4: Run tests — should pass** + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/modules/scanner/infrastructure/parsers/ backend/tests/test_scanner_parsers.py +git commit -m "feat(scanner): add YAML and OpenAPI parsers" +``` + +--- + +## Task 15: MOD-SCANNER Application — ScanService + +**Files:** +- Modify: `backend/app/modules/scanner/application/__init__.py` +- Modify: `backend/app/modules/scanner/application/services.py` +- Create: `backend/tests/test_scanner_service.py` + +- [ ] **Step 1: Write ScanService tests** + +Test using the real `design/` directory from the project: +- `scan()` produces a ScanResult with populated entity lists +- `scan()` produces file_statuses for all design files +- `scan()` summary counts match file_statuses +- `get_latest_scan()` returns None before first scan +- `get_latest_scan()` returns cached result after scan + +- [ ] **Step 2: Run tests — should fail** + +- [ ] **Step 3: Implement services.py** + +```python +from datetime import datetime, timezone +from pathlib import Path + +from app.modules.design.domain.entities import * # all 31 entities +from app.modules.design.domain.services import DesignValidationService +from app.modules.design.domain.value_objects import FileStatus +from app.modules.project.domain.entities import Project +from app.modules.scanner.domain.entities import FileStatusEntry, ScanResult, ScanSummary +from app.modules.scanner.infrastructure.parsers.csv_parser import CsvParser +from app.modules.scanner.infrastructure.parsers.md_parser import MdParser +from app.modules.scanner.infrastructure.parsers.openapi_parser import OpenapiParser + + +class ScanService: + def __init__(self) -> None: + self._cache: dict[str, ScanResult] = {} + + def scan(self, project: Project) -> ScanResult: + design_dir = Path(project.design_dir) + csv_parser = CsvParser() + md_parser = MdParser() + openapi_parser = OpenapiParser() + + # Collect all parsed entities + all_entities: dict[str, list] = {} # entity_type_name -> list + + # Scan all files and collect file statuses + file_statuses: list[FileStatusEntry] = [] + + for file_path in sorted(design_dir.rglob("*")): + if not file_path.is_file(): + continue + if file_path.name.startswith("."): + continue + + relative = str(file_path.relative_to(design_dir)) + content = file_path.read_text(encoding="utf-8") + status = DesignValidationService.determine_file_status(content, file_path.name) + lines = len([l for l in content.splitlines() if l.strip()]) + file_statuses.append(FileStatusEntry(path=relative, status=status, content_lines=lines)) + + # Parse by type + if file_path.suffix == ".csv": + parsed = csv_parser.parse(file_path) + for key, items in parsed.items(): + all_entities.setdefault(key, []).extend(items) + elif file_path.suffix == ".md": + parsed = md_parser.parse(file_path) + for key, items in parsed.items(): + all_entities.setdefault(key, []).extend(items) + elif file_path.suffix in (".yaml", ".yml"): + if "openapi" in file_path.name or "api-contracts" in file_path.name: + parsed = openapi_parser.parse(file_path) + for key, items in parsed.items(): + all_entities.setdefault(key, []).extend(items) + + # Build summary + summary = ScanSummary( + total_files=len(file_statuses), + ok=sum(1 for f in file_statuses if f.status == FileStatus.OK), + sparse=sum(1 for f in file_statuses if f.status == FileStatus.SPARSE), + missing=sum(1 for f in file_statuses if f.status == FileStatus.MISSING), + placeholder_heavy=sum(1 for f in file_statuses if f.status == FileStatus.PLACEHOLDER_HEAVY), + template_residue=sum(1 for f in file_statuses if f.status == FileStatus.TEMPLATE_RESIDUE), + ) + + # Assemble ScanResult — map entity type names to ScanResult fields + result = ScanResult( + project_id=project.id, + scanned_at=datetime.now(timezone.utc), + file_statuses=file_statuses, + summary=summary, + capabilities=all_entities.get("capabilities", []), + modules=all_entities.get("modules", []), + entities=all_entities.get("entities", []), + value_flows=all_entities.get("value_flows", []), + user_journeys=all_entities.get("user_journeys", []), + integrations=all_entities.get("integrations", []), + data_flows=all_entities.get("data_flows", []), + traceability_links=all_entities.get("traceability_links", []), + external_systems=all_entities.get("external_systems", []), + runtime_components=all_entities.get("runtime_components", []), + tech_selections=all_entities.get("tech_selections", []), + environments=all_entities.get("environments", []), + design_documents=all_entities.get("design_documents", []), + change_log_entries=all_entities.get("change_log_entries", []), + adrs=all_entities.get("adrs", []), + shared_terms=all_entities.get("shared_terms", []), + domains=all_entities.get("domains", []), + ubiquitous_terms=all_entities.get("ubiquitous_terms", []), + scenarios=all_entities.get("scenarios", []), + domain_modules=all_entities.get("domain_modules", []), + domain_entities=all_entities.get("domain_entities", []), + data_securities=all_entities.get("data_securities", []), + codebase_alignments=all_entities.get("codebase_alignments", []), + api_contracts=all_entities.get("api_contracts", []), + scope_and_goals=next(iter(all_entities.get("scope_and_goals", [])), None), + system_context=next(iter(all_entities.get("system_context", [])), None), + solution_layer=next(iter(all_entities.get("solution_layer", [])), None), + module_boundary_rule=next(iter(all_entities.get("module_boundary_rule", [])), None), + runtime_topology=next(iter(all_entities.get("runtime_topology", [])), None), + operational_baseline=next(iter(all_entities.get("operational_baseline", [])), None), + release_plan=next(iter(all_entities.get("release_plan", [])), None), + ) + + self._cache[project.id] = result + return result + + def get_latest_scan(self, project_id: str) -> ScanResult | None: + return self._cache.get(project_id) +``` + +- [ ] **Step 4: Run tests — should pass** + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/modules/scanner/application/ backend/tests/test_scanner_service.py +git commit -m "feat(scanner): add ScanService — orchestrates parsers, file status, and entity collection" +``` + +--- + +## Task 16: MOD-SCANNER Interfaces — HTTP Router + +**Files:** +- Modify: `backend/app/modules/scanner/interfaces/http/router.py` +- Modify: `backend/app/modules/scanner/interfaces/__init__.py` +- Modify: `backend/app/modules/scanner/interfaces/http/__init__.py` +- Modify: `backend/app/main.py` (wire scanner) +- Create: `backend/tests/test_api_scanner.py` + +- [ ] **Step 1: Write API tests** + +Test scan trigger, get latest scan, and all 13 entity query endpoints (10 list + 3 detail). Use a real design/ directory as fixture. + +- [ ] **Step 2: Implement router.py** + +The scanner router provides: +- `POST /projects/{project_id}/scan` → trigger scan → return ScanResultResponse (trimmed: no entity lists) +- `GET /projects/{project_id}/scan` → get cached scan → return ScanResultResponse +- 10 list endpoints for entity types +- 3 detail endpoints (CapabilityDetail, ModuleDetail, EntityDetail) with join logic + +The router needs access to both ProjectService (to resolve project_id → Project) and ScanService. + +- [ ] **Step 3: Wire scanner into main.py** + +Add ScanService instantiation and wire the scanner router in `create_app()`. + +- [ ] **Step 4: Run tests — should pass** + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/modules/scanner/interfaces/ backend/app/main.py backend/tests/test_api_scanner.py +git commit -m "feat(scanner): add REST API — scan trigger, entity query endpoints" +``` + +--- + +## Task 17: MOD-GRAPH Domain — Entities + +**Files:** +- Modify: `backend/app/modules/graph/domain/entities.py` +- Modify: `backend/app/modules/graph/domain/__init__.py` +- Modify: `backend/app/modules/graph/__init__.py` + +- [ ] **Step 1: Write graph domain entities** + +Write `GraphNode`, `GraphEdge`, `GraphGroup`, `GraphView` exactly as specified in spec Section 3.5. + +- [ ] **Step 2: Verify import** + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/modules/graph/ +git commit -m "feat(graph): add GraphNode, GraphEdge, GraphGroup, GraphView domain entities" +``` + +--- + +## Task 18: MOD-GRAPH Application — GraphService + +**Files:** +- Modify: `backend/app/modules/graph/application/services.py` +- Modify: `backend/app/modules/graph/application/__init__.py` +- Create: `backend/tests/test_graph_service.py` + +- [ ] **Step 1: Write tests** + +Test `build_panorama()`: +- Creates 5 groups (business, application, data, technology, cross-layer) +- Capabilities become nodes in business group +- Modules become nodes in application group +- Entities become nodes in data group +- RuntimeComponents become nodes in technology group +- TraceabilityLinks generate edges (capability→module, module→entity) +- Module.depends_on generates edges +- Integration generates edges + +Test `get_neighbors()`: +- Returns only direct neighbor nodes and connecting edges +- Returns empty GraphView for unknown node_id + +- [ ] **Step 2: Run tests — should fail** + +- [ ] **Step 3: Implement services.py** + +Build the panorama by iterating over ScanResult entities and constructing nodes, edges, and groups. See spec Section 3.5 for the 9-step algorithm. + +For `get_neighbors()`: filter the full graph to only nodes that share an edge with the target node. + +- [ ] **Step 4: Run tests — should pass** + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/modules/graph/application/ backend/tests/test_graph_service.py +git commit -m "feat(graph): add GraphService — panorama construction and neighbor query" +``` + +--- + +## Task 19: MOD-GRAPH Interfaces — HTTP Router + +**Files:** +- Modify: `backend/app/modules/graph/interfaces/http/router.py` +- Modify: `backend/app/main.py` (wire graph) +- Create: `backend/tests/test_api_graph.py` + +- [ ] **Step 1: Write API tests** + +- [ ] **Step 2: Implement router.py** + +- `GET /projects/{project_id}/graph` → trigger scan if needed → build panorama → return GraphView JSON +- `GET /projects/{project_id}/graph/nodes/{node_id}/neighbors` → get neighbors → return GraphView JSON + +- [ ] **Step 3: Wire graph into main.py** + +- [ ] **Step 4: Run tests — should pass** + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/modules/graph/ backend/app/main.py backend/tests/test_api_graph.py +git commit -m "feat(graph): add REST API — panorama and neighbor query endpoints" +``` + +--- + +## Task 20: MOD-EDITOR Domain + Infrastructure + +**Files:** +- Modify: `backend/app/modules/editor/domain/entities.py` +- Create: `backend/app/modules/editor/infrastructure/file_io.py` + +- [ ] **Step 1: Write editor domain entities** + +`EditableFile`, `AffectedFile`, `ImpactResult` as specified in spec Section 3.6. + +- [ ] **Step 2: Write file_io.py** + +Read/write design files using `app.shared.infrastructure.filesystem`. + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/modules/editor/ +git commit -m "feat(editor): add domain entities and file I/O infrastructure" +``` + +--- + +## Task 21: MOD-EDITOR Application + Interfaces + +**Files:** +- Modify: `backend/app/modules/editor/application/services.py` +- Modify: `backend/app/modules/editor/interfaces/http/router.py` +- Modify: `backend/app/main.py` (wire editor) +- Create: `backend/tests/test_editor_service.py` +- Create: `backend/tests/test_api_editor.py` + +- [ ] **Step 1: Write EditorService tests** + +Test: +- `get_file()` returns EditableFile with correct format detection +- `save_file()` writes content and triggers rescan +- `get_impact()` finds downstream affected files via DesignDocument relationships + +- [ ] **Step 2: Implement EditorService** + +```python +class EditorService: + def __init__(self, scan_service: ScanService) -> None: + self._scan_service = scan_service + + def get_file(self, project: Project, relative_path: str) -> EditableFile: + ... # Read file, detect format from extension, return EditableFile + + def save_file(self, project: Project, relative_path: str, content: str) -> ScanResult: + ... # Write file, trigger rescan, return new ScanResult + + def get_impact(self, project: Project, relative_path: str, scan_result: ScanResult) -> ImpactResult: + ... # Walk DesignDocument.downstream + TraceabilityLink to find affected files +``` + +- [ ] **Step 3: Write router.py and API tests** + +- [ ] **Step 4: Wire into main.py** + +- [ ] **Step 5: Run all tests — should pass** + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/modules/editor/ backend/app/main.py backend/tests/test_editor_service.py backend/tests/test_api_editor.py +git commit -m "feat(editor): add EditorService and REST API — file read/write and impact analysis" +``` + +--- + +## Task 22: MOD-IMPL-TRACKER Domain + Infrastructure + +**Files:** +- Modify: `backend/app/modules/impl_tracker/domain/entities.py` +- Create: `backend/app/modules/impl_tracker/infrastructure/code_scanner.py` +- Create: `backend/app/modules/impl_tracker/infrastructure/llm_client.py` + +- [ ] **Step 1: Write domain entities** + +`ImplProgress`, `CodeStructure` as specified in spec Section 3.7. + +- [ ] **Step 2: Write code_scanner.py** + +Scan a code directory → return CodeStructure (directories, files, matched modules by cross-referencing CodebaseAlignment). + +- [ ] **Step 3: Write llm_client.py** + +Stub LLM client that returns a placeholder response. Real LLM integration is optional — the system works without it (falls back to auto-scan only). + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/modules/impl_tracker/ +git commit -m "feat(impl_tracker): add domain entities and infrastructure — code scanner, LLM client stub" +``` + +--- + +## Task 23: MOD-IMPL-TRACKER Application + Interfaces + +**Files:** +- Modify: `backend/app/modules/impl_tracker/application/services.py` +- Modify: `backend/app/modules/impl_tracker/interfaces/http/router.py` +- Modify: `backend/app/main.py` (wire impl_tracker) +- Create: `backend/tests/test_impl_tracker.py` +- Create: `backend/tests/test_api_impl_tracker.py` + +- [ ] **Step 1: Write ImplTrackerService tests** + +Test: +- `evaluate()` with code_dir=None returns empty list +- `evaluate()` with real code dir returns ImplProgress per module +- `set_manual_progress()` overrides auto value +- `get_progress()` returns cached results + +- [ ] **Step 2: Implement ImplTrackerService** + +Three-tier evaluation as per spec Section 3.7. + +- [ ] **Step 3: Write router.py and API tests** + +Endpoints: +- `POST /projects/{project_id}/impl-progress` → evaluate +- `GET /projects/{project_id}/impl-progress` → get cached +- `PUT /projects/{project_id}/impl-progress/{module_id}` → manual override + +- [ ] **Step 4: Wire into main.py** + +- [ ] **Step 5: Run all tests — should pass** + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/modules/impl_tracker/ backend/app/main.py backend/tests/test_impl_tracker.py backend/tests/test_api_impl_tracker.py +git commit -m "feat(impl_tracker): add ImplTrackerService and REST API — auto/llm/manual progress evaluation" +``` + +--- + +## Task 24: Backend Integration Test — Full API + +**Files:** +- No new files, run existing tests + +- [ ] **Step 1: Run all backend tests** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run pytest -v` +Expected: All tests pass. + +- [ ] **Step 2: Verify API starts and health responds** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && timeout 3 uv run uvicorn app.main:app --port 8900 &; sleep 2; curl -s http://localhost:8900/api/health; kill %1` +Expected: `{"status":"ok"}` + +- [ ] **Step 3: Commit (if any fixes needed)** + +--- + +## Task 25: Frontend Build Configuration + +**Files:** +- Create: `frontend/package.json` +- Create: `frontend/vite.config.ts` +- Create: `frontend/tsconfig.json` +- Create: `frontend/tsconfig.node.json` +- Create: `frontend/index.html` + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "arch-design-dashboard", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.0", + "d3": "^7.9.0", + "pinia": "^2.2.0", + "vue": "^3.5.0", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@types/d3": "^7.4.0", + "@vitejs/plugin-vue": "^5.1.0", + "typescript": "~5.6.0", + "vite": "^6.0.0", + "vue-tsc": "^2.1.0" + } +} +``` + +- [ ] **Step 2: Create vite.config.ts** + +```typescript +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8900', + changeOrigin: true, + }, + }, + }, +}) +``` + +- [ ] **Step 3: Create tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "noEmit": true, + "paths": { + "@/*": ["./src/*"] + }, + "baseUrl": "." + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} +``` + +- [ ] **Step 4: Create tsconfig.node.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["vite.config.ts"] +} +``` + +- [ ] **Step 5: Create index.html** + +```html + + + + + + Arch Design Dashboard + + +
+ + + +``` + +- [ ] **Step 6: Install dependencies** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npm install` +Expected: Dependencies installed successfully. + +- [ ] **Step 7: Commit** + +```bash +git add frontend/package.json frontend/vite.config.ts frontend/tsconfig.json frontend/tsconfig.node.json frontend/index.html frontend/package-lock.json +git commit -m "build: add frontend configuration — Vite, TypeScript, Vue 3" +``` + +--- + +## Task 26: Frontend Shared Layer + +**Files:** +- Modify: `frontend/src/main.ts` +- Modify: `frontend/src/App.vue` +- Modify: `frontend/src/shared/api.ts` +- Modify: `frontend/src/shared/types/api.ts` +- Modify: `frontend/src/router/index.ts` +- Create: `frontend/src/style.css` + +- [ ] **Step 1: Write shared/types/api.ts** + +Define all TypeScript interfaces matching the backend API response schemas: +`Project`, `ScanResult`, `ScanSummary`, `FileStatusEntry`, `GraphView`, `GraphNode`, `GraphEdge`, `GraphGroup`, `Capability`, `Module`, `Entity`, `Integration`, `ValueFlow`, `UserJourney`, `DataFlow`, `ExternalSystem`, `TraceabilityLink`, `RuntimeComponent`, `FileContent`, `ImpactResult`, `ImplProgress`, `CapabilityDetail`, `ModuleDetail`, `EntityDetail`. + +- [ ] **Step 2: Write shared/api.ts** + +```typescript +import axios from 'axios' +const api = axios.create({ baseURL: '/api' }) +export default api +``` + +- [ ] **Step 3: Write router/index.ts** + +3 routes: `/` → ProjectList, `/projects/:id` → GraphPanorama, `/projects/:id/editor` → EditorPage (Phase 2). + +- [ ] **Step 4: Write main.ts** + +Create Vue app, install Pinia and Router. + +- [ ] **Step 5: Write App.vue** + +Layout with sidebar (always visible project list) and main content area with ``. + +- [ ] **Step 6: Write style.css** + +Basic CSS for app layout (sidebar + content grid), colors, typography. + +- [ ] **Step 7: Verify build** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit && npx vite build` +Expected: Build succeeds (components are empty stubs, but types/config should work). + +- [ ] **Step 8: Commit** + +```bash +git add frontend/src/ +git commit -m "feat(frontend): add shared layer — types, API client, router, app layout" +``` + +--- + +## Task 27: MOD-FE-PROJECT — Project Management UI + +**Files:** +- Modify: `frontend/src/modules/project/types/index.ts` +- Modify: `frontend/src/modules/project/api/index.ts` +- Modify: `frontend/src/modules/project/composables/useProject.ts` +- Modify: `frontend/src/modules/project/components/ProjectList.vue` +- Modify: `frontend/src/modules/project/components/ProjectOverview.vue` + +- [ ] **Step 1: Write project types** + +Re-export or extend shared Project type. + +- [ ] **Step 2: Write project API functions** + +`listProjects`, `createProject`, `getProject`, `deleteProject` — all calling backend via shared api client. + +- [ ] **Step 3: Write useProject Pinia store** + +State: `projects: Project[]`, `currentProject: Project | null`, `loading: boolean`, `error: string | null`. +Actions: `fetchProjects`, `createProject`, `selectProject`, `deleteProject`. + +- [ ] **Step 4: Write ProjectOverview.vue** + +Card component showing project name, design_dir, created_at. Click selects project. Delete button with confirmation. + +- [ ] **Step 5: Write ProjectList.vue** + +- List of ProjectOverview cards +- "Add Project" button → inline form (name + design_dir + optional code_dir) +- On create success → navigate to `/projects/:id` +- Empty state message when no projects + +- [ ] **Step 6: Verify frontend builds** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit` + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/modules/project/ +git commit -m "feat(fe-project): add project management UI — list, create, delete" +``` + +--- + +## Task 28: MOD-FE-GRAPH — Graph Visualization + +**Files:** +- Modify: `frontend/src/modules/graph/types/index.ts` +- Modify: `frontend/src/modules/graph/api/index.ts` +- Modify: `frontend/src/modules/graph/composables/useGraph.ts` +- Modify: `frontend/src/modules/graph/components/GraphPanorama.vue` +- Modify: `frontend/src/modules/graph/components/GraphDetail.vue` + +- [ ] **Step 1: Write graph types** + +Re-export GraphView, GraphNode, GraphEdge, GraphGroup types. + +- [ ] **Step 2: Write graph API functions** + +`triggerScan`, `getLatestScan`, `getGraph`, `getNodeNeighbors`, plus all entity query endpoints. + +- [ ] **Step 3: Write useGraph Pinia store** + +State: `graphView`, `selectedNode`, `scanResult`, `loading`. +Actions: `loadGraph` (triggerScan → getGraph), `selectNode`, `loadNeighbors`, `clearSelection`. + +- [ ] **Step 4: Write GraphPanorama.vue** + +This is the core visualization component: +- On mount: call `loadGraph(projectId)` → trigger scan → get graph data +- D3.js force simulation with group clustering +- Node rendering: + - Colors: ok=#4CAF50, sparse=#FFC107, missing=#F44336, template-residue=#FF9800, placeholder-heavy=#9C27B0, unknown=#9E9E9E + - Shapes: capability=circle, module=rect, entity=diamond, other=circle +- Edge rendering: + - traces_to=solid, depends_on=dashed, owns=thick solid +- Interactions: + - Hover → tooltip (id, type, status, label) + - Click → select node → show GraphDetail + - Double-click → load neighbors (drill-down) + - Zoom/pan via D3 zoom behavior +- Scan summary panel (top-right corner): total/ok/sparse/missing counts + +- [ ] **Step 5: Write GraphDetail.vue** + +Slide-out side panel: +- Node properties (all fields) +- Related entities list (clickable) +- Phase 2 placeholders: Edit button, Impact Analysis button + +- [ ] **Step 6: Verify frontend builds** + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/modules/graph/ +git commit -m "feat(fe-graph): add D3.js graph visualization — panorama, drill-down, status colors" +``` + +--- + +## Task 29: MOD-FE-EDITOR — File Editor UI (Phase 2) + +**Files:** +- Modify: `frontend/src/modules/editor/types/index.ts` +- Modify: `frontend/src/modules/editor/api/index.ts` +- Modify: `frontend/src/modules/editor/composables/useEditor.ts` +- Modify: `frontend/src/modules/editor/components/CsvEditor.vue` +- Modify: `frontend/src/modules/editor/components/MdEditor.vue` + +- [ ] **Step 1: Write editor types** + +FileContent, ImpactResult, ImplProgress types. + +- [ ] **Step 2: Write editor API functions** + +`getFile`, `saveFile`, `getFileImpact`. + +- [ ] **Step 3: Write useEditor Pinia store** + +State: `currentFile`, `impactResult`, `saving`, `error`. +Actions: `loadFile`, `saveFile`, `analyzeImpact`. + +- [ ] **Step 4: Write CsvEditor.vue** + +- Parse CSV content into rows/columns +- Render as HTML `` with contenteditable cells +- Add/remove row buttons +- Save button → serialize back to CSV string → call saveFile API +- On save success → trigger graph refresh + +- [ ] **Step 5: Write MdEditor.vue** + +- Split view: left textarea, right preview +- Render Markdown preview (basic: headers, lists, code blocks — use simple regex or a lightweight lib) +- Save button → call saveFile API + +- [ ] **Step 6: Update router to add editor route** + +Add `/projects/:id/editor` route pointing to an EditorPage that conditionally renders CsvEditor or MdEditor based on file format. + +- [ ] **Step 7: Verify frontend builds** + +- [ ] **Step 8: Commit** + +```bash +git add frontend/src/modules/editor/ frontend/src/router/ +git commit -m "feat(fe-editor): add CSV table editor and Markdown editor components" +``` + +--- + +## Task 30: Frontend Full Build Verification + +- [ ] **Step 1: Run TypeScript check** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit` +Expected: No errors + +- [ ] **Step 2: Run production build** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vite build` +Expected: Build succeeds, output in `dist/` + +- [ ] **Step 3: Commit any fixes** + +--- + +## Task 31: Docker Deployment Configuration + +**Files:** +- Create: `docker-compose.yml` +- Create: `backend/Dockerfile` +- Create: `frontend/Dockerfile` +- Create: `frontend/nginx.conf` + +- [ ] **Step 1: Create backend/Dockerfile** + +```dockerfile +FROM python:3.12-slim +WORKDIR /app +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev +COPY app/ app/ +CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8900"] +``` + +- [ ] **Step 2: Create frontend/Dockerfile** + +```dockerfile +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +``` + +- [ ] **Step 3: Create frontend/nginx.conf** + +```nginx +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8900; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location / { + try_files $uri $uri/ /index.html; + } +} +``` + +- [ ] **Step 4: Create docker-compose.yml** + +```yaml +services: + backend: + build: ./backend + ports: + - "8900:8900" + volumes: + - ${DESIGN_DIR:-.}:/data/design:rw + - ${CODE_DIR:-/dev/null}:/data/code:ro + - registry-data:/data/registry + environment: + - REGISTRY_PATH=/data/registry/projects.json + + frontend: + build: ./frontend + ports: + - "80:80" + depends_on: + - backend + +volumes: + registry-data: +``` + +- [ ] **Step 5: Commit** + +```bash +git add docker-compose.yml backend/Dockerfile frontend/Dockerfile frontend/nginx.conf +git commit -m "build: add Docker deployment — Compose, Dockerfiles, Nginx config" +``` + +--- + +## Task 32: End-to-End Verification + +- [ ] **Step 1: Run all backend tests** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && uv run pytest -v` +Expected: All tests pass + +- [ ] **Step 2: Run frontend build** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npm run build` +Expected: Build succeeds + +- [ ] **Step 3: Start backend and verify key API flows** + +Start server, create a project pointing to this repo's own `design/` directory, trigger scan, get graph, verify entities are populated. + +- [ ] **Step 4: Final commit if any fixes needed** diff --git a/docs/superpowers/specs/2026-03-23-full-implementation-design.md b/docs/superpowers/specs/2026-03-23-full-implementation-design.md new file mode 100644 index 0000000..4fad3c2 --- /dev/null +++ b/docs/superpowers/specs/2026-03-23-full-implementation-design.md @@ -0,0 +1,804 @@ +# Arch Design Dashboard — 全量实现设计规格 + +## 1. 概述 + +将 `design/` 目录下完整的架构设计文档转化为可运行的 Web 应用。系统是单体前后端分离架构,单人使用(无认证),设计文件为 single source of truth(无数据库)。 + +**实现范围:** MVP + Phase 2 全部功能。 + +**技术栈:** +- 后端:Python 3.12 + FastAPI + Uvicorn,包管理用 uv +- 前端:Vue 3 + TypeScript + Vite + Pinia + D3.js +- 部署:Docker Compose + Nginx 反代 + +## 2. 模块清单与实现顺序 + +全局顺序(自底向上):构建配置 → shared → design → project → scanner → graph → editor → impl_tracker → 前端 + +每个后端模块内部顺序:**Domain → Infrastructure → Application → Interfaces** + +| # | 模块 | 层 | 依赖 | 阶段 | +|---|------|------|------|------| +| 0 | 构建配置 | - | - | MVP | +| 1 | shared | backend | - | MVP | +| 2 | MOD-DESIGN | backend domain-only | - | MVP | +| 3 | MOD-PROJECT | backend full | - | MVP | +| 4 | MOD-SCANNER | backend full | MOD-DESIGN | MVP | +| 5 | MOD-GRAPH | backend full | MOD-DESIGN | MVP | +| 6 | MOD-EDITOR | backend full | MOD-DESIGN, MOD-SCANNER, MOD-GRAPH | Phase 2 | +| 7 | MOD-IMPL-TRACKER | backend full | MOD-DESIGN, MOD-SCANNER | Phase 2 | +| 8 | 前端基础设施 | frontend | - | MVP | +| 9 | MOD-FE-PROJECT | frontend | 后端 MOD-PROJECT | MVP | +| 10 | MOD-FE-GRAPH | frontend | 后端 MOD-SCANNER, MOD-GRAPH | MVP(impl-progress 功能延至 Phase 2,依赖 MOD-IMPL-TRACKER) | +| 11 | MOD-FE-EDITOR | frontend | 后端 MOD-EDITOR, MOD-IMPL-TRACKER | Phase 2 | +| 12 | Docker 部署 | infra | 前后端完成 | MVP | + +## 3. 后端详细设计 + +### 3.0 构建配置 + +- `backend/pyproject.toml`:Python 3.12+,依赖 fastapi, uvicorn, pyyaml, python-multipart +- `backend/.python-version`:3.12 +- `frontend/package.json`:vue 3, vue-router, pinia, d3, axios, vite, typescript +- `frontend/vite.config.ts`:dev proxy `/api` → `localhost:8900` +- `frontend/tsconfig.json` +- `frontend/index.html` + +### 3.1 shared 层 + +**shared/kernel/exceptions.py**: +- `NotFoundError(entity: str, id: str)` — 404 +- `ValidationError(message: str)` — 400 +- `FileSystemError(path: str, message: str)` — 500 + +**shared/infrastructure/config.py**: +- `Settings` dataclass:registry_path(projects.json 路径,默认 `~/.arch-design-dashboard/projects.json`) + +**shared/infrastructure/filesystem.py**: +- `read_text(path) -> str` +- `write_text(path, content)` +- `list_files(directory, extensions) -> list[Path]` +- `file_exists(path) -> bool` + +### 3.2 MOD-DESIGN(核心领域,纯 Python) + +**序列化策略:** CSV 中的空格分隔字段在 Python domain 层存储为 `list[str]`。API 响应中也返回为 JSON 数组(`list[str]`),由 router 层负责转换。OpenAPI 契约中这些字段定义为 `string` 是文档缺陷,实现时以 `list[str]` 为准。涉及的字段包括: +- Capability.related_value_flows, Module.depends_on, Module.capabilities +- TraceabilityLink.entity_ids, TraceabilityLink.value_flow_ids +- ValueFlow.related_capabilities, UserJourney.related_value_flows +- Scenario.related_capabilities, SharedTerm.used_by_domains +- Domain.modules, Domain.entities +- DesignDocument.owners, DesignDocument.upstream, DesignDocument.downstream + +**domain/entities.py** — 31 种设计实体,用 `@dataclass` 定义: + +```python +# 业务层 +@dataclass +class Capability: + capability_id: str + name: str + description: str + priority: str # must / should / could + phase: str + related_value_flows: list[str] # CSV 中空格分隔,解析为列表 + +@dataclass +class ValueFlow: + value_flow_id: str + name: str + trigger: str + actor: str + steps: str + outcome: str + phase: str + related_capabilities: list[str] + +@dataclass +class UserJourney: + journey_id: str + name: str + actor: str + precondition: str + steps: str + postcondition: str + phase: str + related_value_flows: list[str] + +@dataclass +class ScopeAndGoals: + doc_id: str + title: str + core_problem: str + users: str + constraints: str + +# 应用层 +@dataclass +class Module: + module_id: str + name: str + layer: str # backend / frontend + description: str + phase: str + depends_on: list[str] + capabilities: list[str] + +@dataclass +class Integration: + integration_id: str + source_id: str # API 响应中序列化为 "source"(兼容 OpenAPI) + target_id: str # API 响应中序列化为 "target"(兼容 OpenAPI) + target_type: str + direction: str + protocol: str + trigger: str + phase: str + description: str + +@dataclass +class ExternalSystem: + system_id: str + name: str + type: str + protocol: str + direction: str + phase: str + description: str + +@dataclass +class ApiContract: + doc_id: str + path: str + method: str + operation_id: str + summary: str + +@dataclass +class CodebaseAlignment: + module_id: str + repo_root: str + code_root: str + package_name: str + +@dataclass +class ModuleBoundaryRule: + doc_id: str + title: str + content: str + +@dataclass +class SystemContext: + doc_id: str + title: str + content: str + +@dataclass +class SolutionLayer: + doc_id: str + title: str + content: str + +# 数据层 +@dataclass +class Entity: + entity_id: str + name: str + domain: str + owner_module: str + description: str + phase: str + source_file: str + +@dataclass +class DataFlow: + data_flow_id: str + source: str + target: str + data_content: str + trigger: str + protocol: str + phase: str + description: str + +@dataclass +class DataSecurity: + security_id: str + sensitivity: str + entities: str + protection: str + +# 技术层 +@dataclass +class TechSelection: + category: str + technology: str + version: str + purpose: str + rationale: str + alternatives_considered: str + phase: str + +@dataclass +class RuntimeComponent: + component_id: str + name: str + type: str + technology: str + port: str + +@dataclass +class RuntimeTopology: + doc_id: str + title: str + content: str + +@dataclass +class Environment: + env_id: str + name: str + purpose: str + infra: str + +@dataclass +class OperationalBaseline: + doc_id: str + title: str + content: str + +@dataclass +class ReleasePlan: + doc_id: str + title: str + content: str + +# 跨层 +@dataclass +class TraceabilityLink: + trace_id: str + capability_id: str + module_id: str + entity_ids: list[str] + value_flow_ids: list[str] + notes: str + +@dataclass +class ChangeLogEntry: + change_id: str + date: str + scope: str + description: str + +@dataclass +class ADR: + adr_id: str + title: str + status: str + context: str + decision: str + +@dataclass +class DesignDocument: + doc_id: str + title: str + version: str + status: str + owners: list[str] + upstream: list[str] + downstream: list[str] + file_path: str + +# 领域层 +@dataclass +class Domain: + domain_name: str + overview: str + modules: list[str] + entities: list[str] + +@dataclass +class UbiquitousTerm: + term_id: str + term: str + english_term: str + code_symbol: str + domain: str + definition: str + +@dataclass +class SharedTerm: + term_id: str + term: str + english_term: str + definition: str + used_by_domains: list[str] + +@dataclass +class Scenario: + scenario_id: str + name: str + trigger: str + actors: str + steps: str + outcome: str + related_capabilities: list[str] + +@dataclass +class DomainModule: + module_id: str + module_name: str + domain: str + description: str + layer_in_code: str + +@dataclass +class DomainEntity: + entity_id: str + entity_name: str + type: str + description: str + key_attributes: str +``` + +**domain/value_objects.py** — 3 种值对象: + +```python +class FileStatus(str, Enum): + OK = "ok" + SPARSE = "sparse" + MISSING = "missing" + TEMPLATE_RESIDUE = "template-residue" + PLACEHOLDER_HEAVY = "placeholder-heavy" + +class ArchitectureLayer(str, Enum): + BUSINESS = "business" + APPLICATION = "application" + DATA = "data" + TECHNOLOGY = "technology" + +class ModuleLayer(str, Enum): + DOMAIN = "domain" + APPLICATION = "application" + INFRASTRUCTURE = "infrastructure" + INTERFACES = "interfaces" +``` + +**domain/services.py** — 约束规则校验 + FileStatus 判定: + +```python +class DesignValidationService: + def validate_all(entities) -> list[ConstraintViolation]: + """执行所有约束规则,返回违规列表""" + def check_capability_module_linkage(capabilities, traceability_links) -> list + def check_entity_owner(entities) -> list + def check_traceability_references(links, capabilities, modules, entities) -> list + + @staticmethod + def determine_file_status(content: str, file_path: str) -> FileStatus: + """根据文件内容特征判定 FileStatus: + - missing: 文件不存在或空 + - sparse: 行数 < 阈值(CSV < 2行含头, MD < 5行) + - template-residue: 检测到模板占位文本(TODO、EXAMPLE、) + - placeholder-heavy: 占位符比例 > 30% + - ok: 通过所有检查 + """ +``` + +### 3.3 MOD-PROJECT + +**domain/entities.py**: +```python +@dataclass +class Project: + id: str # UUID + name: str + design_dir: str # 绝对路径 + code_dir: str | None # Phase 2 + created_at: datetime +``` + +**domain/repositories.py**: +```python +class ProjectRepository(ABC): + def list_all() -> list[Project] + def get_by_id(id: str) -> Project | None + def save(project: Project) -> None + def delete(id: str) -> None +``` + +**infrastructure/json_repository.py**:实现 ProjectRepository,读写 JSON 文件。 + +**application/services.py**: +```python +class ProjectService: + def list_projects() -> list[Project] + def create_project(name, design_dir, code_dir=None) -> Project # 验证 design_dir 存在 + def get_project(id) -> Project + def delete_project(id) -> None +``` + +**interfaces/http/router.py**:FastAPI router,4 个端点。 + +### 3.4 MOD-SCANNER + +**domain/entities.py**: +```python +@dataclass +class FileStatusEntry: + path: str + status: FileStatus + content_lines: int + +@dataclass +class ScanSummary: + total_files: int + ok: int + sparse: int + missing: int + placeholder_heavy: int + template_residue: int + +@dataclass +class ScanResult: + """内部 domain 对象,携带所有解析出的实体。 + API 响应(ScanResultResponse)只包含 project_id, scanned_at, file_statuses, summary。 + 实体数据通过独立的 /entities/* 端点暴露。""" + project_id: str + scanned_at: datetime + file_statuses: list[FileStatusEntry] + summary: ScanSummary + # 所有解析出的 Design 实体 + capabilities: list[Capability] + modules: list[Module] + entities: list[Entity] + value_flows: list[ValueFlow] + user_journeys: list[UserJourney] + integrations: list[Integration] + data_flows: list[DataFlow] + traceability_links: list[TraceabilityLink] + external_systems: list[ExternalSystem] + runtime_components: list[RuntimeComponent] + tech_selections: list[TechSelection] + environments: list[Environment] + design_documents: list[DesignDocument] + change_log_entries: list[ChangeLogEntry] + adrs: list[ADR] + shared_terms: list[SharedTerm] + domains: list[Domain] + ubiquitous_terms: list[UbiquitousTerm] + scenarios: list[Scenario] + domain_modules: list[DomainModule] + domain_entities: list[DomainEntity] + data_securities: list[DataSecurity] + codebase_alignments: list[CodebaseAlignment] + # MD 文件特有 + scope_and_goals: ScopeAndGoals | None + system_context: SystemContext | None + solution_layer: SolutionLayer | None + module_boundary_rule: ModuleBoundaryRule | None + runtime_topology: RuntimeTopology | None + operational_baseline: OperationalBaseline | None + release_plan: ReleasePlan | None +``` + +**infrastructure/parsers/**: + +- `csv_parser.py`:解析 CSV 文件,按文件名映射到对应实体类型(capability-map.csv → Capability 列表) +- `md_parser.py`:解析 Markdown frontmatter (YAML),提取 doc_id/title/status/upstream/downstream → DesignDocument;特定文件解析为对应实体 +- `yaml_parser.py`:解析 YAML 配置文件 +- `openapi_parser.py`:解析 OpenAPI YAML → ApiContract 列表 + +**application/services.py**: +```python +class ScanService: + def scan(project: Project) -> ScanResult: + """遍历 design/ → 按类型分派 Parser → 汇总实体 → 调用约束校验 → 计算 FileStatus → 组装 ScanResult""" + + def get_latest_scan(project_id: str) -> ScanResult | None: + """返回内存缓存的最近一次扫描结果""" +``` + +**interfaces/http/router.py**:POST/GET scan + 13 个实体查询端点: + +列表端点(10个): +- GET `.../entities/capabilities` → `list[Capability]` +- GET `.../entities/modules` → `list[Module]` +- GET `.../entities/entities` → `list[Entity]` +- GET `.../entities/integrations` → `list[Integration]` +- GET `.../entities/value-flows` → `list[ValueFlow]` +- GET `.../entities/user-journeys` → `list[UserJourney]` +- GET `.../entities/data-flows` → `list[DataFlow]` +- GET `.../entities/external-systems` → `list[ExternalSystem]` +- GET `.../entities/traceability-links` → `list[TraceabilityLink]` +- GET `.../entities/runtime-components` → `list[RuntimeComponent]` + +详情端点(3个,返回关联数据): +- GET `.../entities/capabilities/{capability_id}` → `CapabilityDetail`(含关联 modules + value_flows) +- GET `.../entities/modules/{module_id}` → `ModuleDetail`(含 owned_entities + integrations + codebase_alignment) +- GET `.../entities/entities/{entity_id}` → `EntityDetail`(含 data_flows) + +### 3.5 MOD-GRAPH + +**domain/entities.py**: +```python +@dataclass +class GraphNode: + id: str + type: str # capability, module, entity, integration, ... + label: str + status: str # FileStatus or "unknown" + group_id: str + +@dataclass +class GraphEdge: + source: str + target: str + relation: str # traces_to, depends_on, owns, integrates_with, ... + +@dataclass +class GraphGroup: + id: str + label: str + layer: str # business, application, data, technology, cross-layer + +@dataclass +class GraphView: + nodes: list[GraphNode] + edges: list[GraphEdge] + groups: list[GraphGroup] +``` + +**application/services.py**: +```python +class GraphService: + def build_panorama(scan_result: ScanResult) -> GraphView: + """从 ScanResult 构建全景图: + 1. 创建 5 个 group(business, application, data, technology, cross-layer) + 2. 每个 Capability → node(group=business) + 3. 每个 Module → node(group=application) + 4. 每个 Entity → node(group=data) + 5. 每个 RuntimeComponent → node(group=technology) + 6. TraceabilityLink → edges(capability→module, module→entity) + 7. Integration → edges(source→target) + 8. Module.depends_on → edges + 9. DesignDocument.upstream/downstream → edges + """ + + def get_neighbors(graph_view: GraphView, node_id: str) -> GraphView: + """返回指定节点的所有直接邻居组成的子图""" +``` + +**interfaces/http/router.py**:GET graph, GET neighbors。 + +### 3.6 MOD-EDITOR(Phase 2) + +**domain/entities.py**: +```python +@dataclass +class EditableFile: + path: str + format: str # csv, md, yaml, openapi + content: str + last_modified: datetime + +@dataclass +class AffectedFile: + path: str + reason: str + +@dataclass +class ImpactResult: + source_file: str + affected_files: list[AffectedFile] +``` + +**infrastructure/file_io.py**:读写设计文件。 + +**application/services.py**: +```python +class EditorService: + def get_file(project: Project, relative_path: str) -> EditableFile + def save_file(project: Project, relative_path: str, content: str) -> ScanResult: + """写文件 → 触发重新扫描 → 返回新 ScanResult""" + def get_impact(project: Project, relative_path: str, scan_result: ScanResult) -> ImpactResult: + """沿 DesignDocument.downstream + TraceabilityLink 遍历""" +``` + +**interfaces/http/router.py**:GET/PUT files, GET files/impact。 + +### 3.7 MOD-IMPL-TRACKER(Phase 2) + +**domain/entities.py**: +```python +@dataclass +class ImplProgress: + module_id: str + percentage: float # 0-100 + source: str # auto, llm, manual + evaluated_at: datetime + +@dataclass +class CodeStructure: + root_path: str + directories: list[str] + files: list[str] + matched_modules: list[str] +``` + +**infrastructure/code_scanner.py**:扫描代码目录结构。 +**infrastructure/llm_client.py**:可选 LLM API 调用。 + +**application/services.py**: +```python +class ImplTrackerService: + def evaluate(project: Project, scan_result: ScanResult) -> list[ImplProgress]: + """三级评估: + 1. auto:扫描 code_dir,对照 CodebaseAlignment 检查目录/文件存在性 + 2. llm(可选):将模块设计+代码发送 LLM 评估覆盖率 + 3. manual:合并用户手动覆盖 + """ + def get_progress(project_id: str) -> list[ImplProgress] + def set_manual_progress(project_id: str, module_id: str, percentage: float) +``` + +**interfaces/http/router.py**:POST/GET impl-progress + PUT impl-progress/{module_id}(手动覆盖,OpenAPI 契约中缺失此端点,实现时补充)。 + +### 3.8 main.py(应用入口) + +```python +app = FastAPI(title="Arch Design Dashboard API") + +# 注册所有 router +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") +app.include_router(impl_tracker_router, prefix="/api") + +# 健康检查 +@app.get("/api/health") +def health(): return {"status": "ok"} + +# 全局异常处理 +@app.exception_handler(NotFoundError) → 404 +@app.exception_handler(ValidationError) → 400 +``` + +## 4. 前端详细设计 + +### 4.0 基础设施 + +**index.html**:SPA 入口页。 + +**main.ts**:创建 Vue app,安装 Pinia、Router。 + +**App.vue**: +``` + +``` + +**router/index.ts**: +```typescript +routes: [ + { path: '/', component: ProjectList }, + { path: '/projects/:id', component: GraphPanorama }, + { path: '/projects/:id/editor', component: EditorPage }, // Phase 2 +] +``` + +**shared/api.ts**:Axios 实例,baseURL = `/api`。 + +**shared/types/api.ts**:TypeScript 类型定义,与 OpenAPI schema 对齐(Project, ScanResult, GraphView, GraphNode, GraphEdge, GraphGroup, Capability, Module, Entity, FileContent, ImpactResult, ImplProgress 等)。 + +### 4.1 MOD-FE-PROJECT + +**components/ProjectList.vue**: +- 展示已注册项目列表(卡片式) +- "添加项目"按钮 → 展开表单(名称 + 设计目录路径 + 可选代码目录) +- 点击项目卡片 → router.push(`/projects/${id}`) +- 删除按钮(确认后删除) + +**components/ProjectOverview.vue**:项目卡片组件。 + +**composables/useProject.ts**:Pinia store — projects[], currentProject, loading, error, CRUD actions。 + +**api/index.ts**:listProjects, createProject, getProject, deleteProject。 + +### 4.2 MOD-FE-GRAPH + +**components/GraphPanorama.vue**: +- 进入时调用 `triggerScan` → `getGraph` 获取数据 +- D3.js 力导向图,节点按 group 分区布局 +- 节点颜色映射:ok=`#4CAF50`, sparse=`#FFC107`, missing=`#F44336`, template-residue=`#FF9800`, placeholder-heavy=`#9C27B0`, unknown=`#9E9E9E` +- 节点形状:capability=圆, module=方, entity=菱形, 其他=圆 +- 边线条:traces_to=实线, depends_on=虚线, owns=粗实线 +- 交互: + - hover → tooltip(ID, 类型, 状态, 名称) + - 单击 → 侧边 GraphDetail 面板 + - 双击 → 调用 getNodeNeighbors 展示子图(下钻) + - 缩放/平移(D3 zoom behavior) + - 扫描摘要面板(total/ok/sparse/missing 统计) + +**components/GraphDetail.vue**: +- 侧边滑出面板 +- 显示节点属性(所有字段) +- 关联实体列表(可点击跳转) +- Phase 2:编辑按钮 → 跳转编辑器 +- Phase 2:影响分析按钮 → 高亮下游节点 + +**composables/useGraph.ts**:Pinia store — graphView, selectedNode, scanResult, loading。 + +**api/index.ts**:triggerScan, getLatestScan, getGraph, getNodeNeighbors, + 实体查询 API, + Phase 2: getImplProgress, triggerImplProgress。 + +### 4.3 MOD-FE-EDITOR(Phase 2) + +**components/CsvEditor.vue**: +- 可编辑 HTML 表格 +- 增删行 +- 保存按钮 → saveFile → 图自动刷新 + +**components/MdEditor.vue**: +- textarea 左编辑 + 右侧 Markdown 预览 +- 支持 frontmatter 高亮 +- 保存按钮 + +**composables/useEditor.ts**:Pinia store — currentFile, impactResult, saving。 + +**api/index.ts**:getFile, saveFile, getFileImpact。 + +## 5. 部署配置 + +**docker-compose.yml**: +```yaml +services: + backend: + build: ./backend + ports: ["8900:8900"] + volumes: + - ${DESIGN_DIR}:/data/design:rw + - ${CODE_DIR:-/dev/null}:/data/code:ro + - registry-data:/data/registry + frontend: + build: ./frontend + ports: ["80:80"] + depends_on: [backend] + +volumes: + registry-data: +``` + +**backend/Dockerfile**:Python 3.12 + uv install + uvicorn 启动。 + +**frontend/Dockerfile**:Node 构建 + Nginx 服务静态文件 + 反代 `/api`。 + +## 6. 设计约束与边界规则 + +- Design 模块是纯 Python,零框架依赖 +- Scanner 依赖 Design,不依赖 Graph +- Graph 依赖 Design,不依赖 Scanner(通过 ScanResult 数据传递) +- Editor 依赖 Design + Scanner + Graph +- Impl-Tracker 依赖 Design + Scanner +- 前端模块只通过 REST API 与后端通信 +- 设计文件是 single source of truth,不引入额外数据库 +- 代码仓库只读 + +## 7. OpenAPI 契约偏差说明 + +实现时以本 spec 和 CSV 源数据为准,以下是与 OpenAPI 契约的已知偏差(实现时在 API 响应中包含这些额外字段): + +| 实体 | 本 spec 有但 OpenAPI 缺失的字段 | 处理方式 | +|------|------|------| +| Capability | description | API 响应中包含 | +| Module | description | API 响应中包含 | +| Entity | description, phase, source_file | API 响应中包含 | +| ValueFlow | actor, phase, related_capabilities | API 响应中包含 | +| UserJourney | actor, phase, related_value_flows | API 响应中包含 | +| ExternalSystem | direction, description, phase | API 响应中包含 | +| Integration | target_type, trigger(字段名 source_id/target_id → API 中用 source/target) | API 响应中包含,字段名用 source/target | +| 多字段 list[str] | related_value_flows, depends_on, capabilities, entity_ids, value_flow_ids | OpenAPI 定义为 string,实现为 JSON array | +| ImplProgress | PUT /{module_id} 端点 | OpenAPI 缺失,实现时补充 | diff --git a/features/full-implementation/test-images/01-homepage.png b/features/full-implementation/test-images/01-homepage.png new file mode 100644 index 0000000..d2df427 Binary files /dev/null and b/features/full-implementation/test-images/01-homepage.png differ diff --git a/features/full-implementation/test-images/02-project-list.png b/features/full-implementation/test-images/02-project-list.png new file mode 100644 index 0000000..c26a715 Binary files /dev/null and b/features/full-implementation/test-images/02-project-list.png differ diff --git a/features/full-implementation/test-images/03-project-overview-graph.png b/features/full-implementation/test-images/03-project-overview-graph.png new file mode 100644 index 0000000..30fe746 Binary files /dev/null and b/features/full-implementation/test-images/03-project-overview-graph.png differ diff --git a/features/full-implementation/test-images/04-graph-drilldown.png b/features/full-implementation/test-images/04-graph-drilldown.png new file mode 100644 index 0000000..a962082 Binary files /dev/null and b/features/full-implementation/test-images/04-graph-drilldown.png differ diff --git a/features/full-implementation/test-images/05-editor-page.png b/features/full-implementation/test-images/05-editor-page.png new file mode 100644 index 0000000..22eacad Binary files /dev/null and b/features/full-implementation/test-images/05-editor-page.png differ diff --git a/features/full-implementation/test-images/06-csv-editor.png b/features/full-implementation/test-images/06-csv-editor.png new file mode 100644 index 0000000..ae2b141 Binary files /dev/null and b/features/full-implementation/test-images/06-csv-editor.png differ diff --git a/features/full-implementation/test-images/07-md-editor.png b/features/full-implementation/test-images/07-md-editor.png new file mode 100644 index 0000000..c542387 Binary files /dev/null and b/features/full-implementation/test-images/07-md-editor.png differ diff --git a/features/full-implementation/works/progress.md b/features/full-implementation/works/progress.md new file mode 100644 index 0000000..6f21f51 --- /dev/null +++ b/features/full-implementation/works/progress.md @@ -0,0 +1,35 @@ +# arch-design-agent-skill-dashboard — full-implementation 进度 + +## 步骤 + +1. ✅ Pre-flight: 确认容器运行、项目可见 +2. ✅ 项目移入 develops/ 目录 +3. ✅ 创建 feature branch: feat/full-implementation +4. ✅ 启动桥接 (bridge-supervisor): run 20260323-134055-524432 +5. ✅ CC 开发完成(桥接交互式) +6. ✅ CC 完成,进入后续流程 +7. ✅ QA 测试(宿主机 agent-browser) +8. ✅ 工作记录 +9. ✅ 提交 PR +10. ✅ 更新项目索引 (projects.md) +11. ⏳ 回复报告 + +## 桥接信息 +- Run ID: 20260323-134055-524432 +- Session: arch-dashboard-full-impl +- CC Talk Room: p94wpqsv +- Trigger Room: sj2fua7t + +## 测试结果摘要 +- 前端地址: http://192.168.0.150:8899 +- 后端地址: http://192.168.0.150:8900 +- 全景关系图: ✅(63 nodes / 94 edges) +- 图节点下钻: ✅ +- CSV 编辑器: ✅ +- Markdown 编辑器: ✅ +- 设计扫描: ✅ +- 实现进度评估: ✅ +- Health API: ✅ + +## PR +- https://git.aboydfd.com/openclaw/arch-design-agent-skill-dashboard/pulls/1 diff --git a/features/full-implementation/works/summary.md b/features/full-implementation/works/summary.md new file mode 100644 index 0000000..ff2d1ea --- /dev/null +++ b/features/full-implementation/works/summary.md @@ -0,0 +1,67 @@ +# arch-design-agent-skill-dashboard — full-implementation 开发记录 + +## 基本信息 +- 项目: arch-design-agent-skill-dashboard +- 功能: full-implementation(全功能实现) +- 日期: 2026-03-23 +- 状态: ✅ 完成 + +## 需求描述 +根据项目中 design/ 目录下的架构设计文档(业务架构、应用架构、数据架构、技术架构、领域设计等),使用 Superpowers 结构化开发流程,实现 dashboard 的所有功能。项目框架和目录结构已搭建好,需补全所有模块的实际业务逻辑代码。 + +## 技术方案 +- **后端**: Python 3.12 + FastAPI + uvicorn + Pydantic,模块化 DDD 架构 +- **前端**: Vue 3 + TypeScript + Vite + D3.js(图谱可视化) +- **部署**: Docker Compose(backend 8900 / frontend 8899) +- **数据**: JSON 文件持久化项目注册表,设计文件直接读写 + +## 实现细节 + +### 后端模块 (30+ commits) +- **project**: CRUD + JSON 文件持久化 +- **scanner**: CSV/MD/YAML/OpenAPI 四种解析器,支持 20+ CSV 文件类型映射为 Design 实体 +- **editor**: 文件读写 + 影响分析(通过 frontmatter upstream/downstream 关系) +- **graph**: 全景关系图构建 + 邻居查询(63 nodes, 94 edges) +- **impl_tracker**: 实现进度评估 + 手动覆盖(9 modules) +- **design**: 31 个设计实体 dataclass + FileStatus 检测 + 验证规则 +- **shared**: 配置管理 + 文件系统工具 + +### 前端模块 +- **project**: 项目列表 + 创建/删除 +- **graph**: D3.js 力导向图全景 + 节点下钻详情面板(右侧抽屉) +- **editor**: CSV 表格编辑器(添加行/删除行/保存)+ Markdown 双栏编辑器(源码 + 实时预览) +- **router**: 3 个路由(首页/项目图谱/编辑器) + +### 部署 +- Docker Compose: backend (8900) + frontend via nginx (8899) +- DESIGN_DIR 挂载项目根目录 → 容器内 /data/design +- Dockerfile 使用 Nexus 代理(PyPI + npm) + +## 测试结果 +| 序号 | 功能 | 结果 | 截图 | +|------|------|------|------| +| 1 | 首页空状态 | ✅ | test-images/01-homepage.png | +| 2 | 项目列表 | ✅ | test-images/02-project-list.png | +| 3 | 全景关系图(63 nodes, 94 edges) | ✅ | test-images/03-project-overview-graph.png | +| 4 | 图节点下钻详情 | ✅ | test-images/04-graph-drilldown.png | +| 5 | 编辑器页面 | ✅ | test-images/05-editor-page.png | +| 6 | CSV 表格编辑器 | ✅ | test-images/06-csv-editor.png | +| 7 | Markdown 双栏编辑器 | ✅ | test-images/07-md-editor.png | +| 8 | API /health | ✅ | (API response: {"status": "ok"}) | +| 9 | 设计扫描 (POST /scan) | ✅ | (94 files scanned) | +| 10 | 实体查询 (capabilities) | ✅ | (10 capabilities) | +| 11 | 实体查询 (modules) | ✅ | (9 modules) | +| 12 | 影响分析 | ✅ | (API 200 OK) | +| 13 | 实现进度评估 | ✅ | (9 modules evaluated) | + +## API 端点 (23 个) +- Projects CRUD, Scan, Entities (capabilities/modules/entities/integrations/value-flows/user-journeys/data-flows/external-systems/traceability-links/runtime-components + detail), Graph (panorama/neighbors), Editor (read/write + impact), Impl Progress (evaluate/override), Health + +## 遇到的问题及解决方案 +| 问题 | 解决方案 | +|------|----------| +| 宿主机 8900 端口被旧 uvicorn 占用 | kill 旧进程 | +| .git 权限不匹配(vagrant vs dev:1001) | chown 修复 | +| host.docker.internal 在 Linux 不可用 | 改用 --network host 构建 + localhost | +| nginx:alpine 不在 Nexus 代理中 | dpull 通过代理下载后 docker tag | +| Playwright input 选择器不匹配 | 检查 Vue 模板,使用正确的 `input` 选择器 | diff --git a/features/works.md b/features/works.md new file mode 100644 index 0000000..d5d1561 --- /dev/null +++ b/features/works.md @@ -0,0 +1,14 @@ +# arch-design-agent-skill-dashboard 工作记录 + +## full-implementation +- **日期**: 2026-03-23 +- **状态**: ✅ 完成 +- **分支**: feat/full-implementation +- **摘要**: + - 基于 design/ 架构文档完成前后端全功能实现 + - 后端实现 project / scanner / editor / graph / impl_tracker 五大模块 API + - 前端实现项目管理、D3 全景关系图、图节点下钻、CSV 编辑器、Markdown 编辑器 + - 增加 Docker Compose 部署能力,服务地址:前端 `http://192.168.0.150:8899`,后端 `http://192.168.0.150:8900` + - QA 测试通过:全景图(63 nodes / 94 edges)、扫描、编辑、进度评估、health API 全部正常 +- **详细记录**: `features/full-implementation/works/summary.md` +- **测试截图**: `features/full-implementation/test-images/` diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..acb2302 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM localhost:8082/node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm --registry http://localhost:8081/repository/npm-group/ ci +COPY . . +RUN npm run build + +FROM localhost:8082/nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b22b662 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Arch Design Dashboard + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..dfa7a89 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,15 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8900; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..50867c0 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2620 @@ +{ + "name": "arch-design-dashboard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "arch-design-dashboard", + "version": "0.1.0", + "dependencies": { + "axios": "^1.7.0", + "d3": "^7.9.0", + "pinia": "^2.2.0", + "vue": "^3.5.0", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@types/d3": "^7.4.0", + "@types/node": "^25.5.0", + "@vitejs/plugin-vue": "^5.1.0", + "typescript": "~5.6.0", + "vite": "^6.0.0", + "vue-tsc": "^2.1.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..74087e9 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "arch-design-dashboard", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.0", + "d3": "^7.9.0", + "pinia": "^2.2.0", + "vue": "^3.5.0", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@types/d3": "^7.4.0", + "@types/node": "^25.5.0", + "@vitejs/plugin-vue": "^5.1.0", + "typescript": "~5.6.0", + "vite": "^6.0.0", + "vue-tsc": "^2.1.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e69de29..d687428 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..2b97bd9 --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,5 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index e69de29..501dee0 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -0,0 +1,10 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './style.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/frontend/src/modules/editor/api/index.ts b/frontend/src/modules/editor/api/index.ts index e69de29..e7ebf37 100644 --- a/frontend/src/modules/editor/api/index.ts +++ b/frontend/src/modules/editor/api/index.ts @@ -0,0 +1,19 @@ +import api from '@/shared/api' +import type { EditableFile, ImpactResult, ScanResult } from '@/shared/types/api' + +export async function getFile(projectId: string, path: string): Promise { + const { data } = await api.get(`/projects/${projectId}/files/${path}`) + return data +} + +export async function saveFile(projectId: string, path: string, content: string): Promise { + const { data } = await api.put(`/projects/${projectId}/files/${path}`, content, { + headers: { 'Content-Type': 'text/plain' }, + }) + return data +} + +export async function getFileImpact(projectId: string, path: string): Promise { + const { data } = await api.get(`/projects/${projectId}/files/${path}/impact`) + return data +} diff --git a/frontend/src/modules/editor/components/CsvEditor.vue b/frontend/src/modules/editor/components/CsvEditor.vue index e69de29..2fa3a10 100644 --- a/frontend/src/modules/editor/components/CsvEditor.vue +++ b/frontend/src/modules/editor/components/CsvEditor.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/frontend/src/modules/editor/components/EditorPage.vue b/frontend/src/modules/editor/components/EditorPage.vue new file mode 100644 index 0000000..5956af1 --- /dev/null +++ b/frontend/src/modules/editor/components/EditorPage.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/frontend/src/modules/editor/components/MdEditor.vue b/frontend/src/modules/editor/components/MdEditor.vue index e69de29..de3be8f 100644 --- a/frontend/src/modules/editor/components/MdEditor.vue +++ b/frontend/src/modules/editor/components/MdEditor.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/frontend/src/modules/editor/composables/useEditor.ts b/frontend/src/modules/editor/composables/useEditor.ts index e69de29..5a6f046 100644 --- a/frontend/src/modules/editor/composables/useEditor.ts +++ b/frontend/src/modules/editor/composables/useEditor.ts @@ -0,0 +1,40 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { EditableFile, ImpactResult } from '@/shared/types/api' +import * as editorApi from '../api' + +export const useEditorStore = defineStore('editor', () => { + const currentFile = ref(null) + const impactResult = ref(null) + const saving = ref(false) + const error = ref(null) + + async function loadFile(projectId: string, path: string) { + try { + currentFile.value = await editorApi.getFile(projectId, path) + } catch (e: any) { + error.value = e.message + } + } + + async function saveFile(projectId: string, path: string, content: string) { + saving.value = true + try { + await editorApi.saveFile(projectId, path, content) + } catch (e: any) { + error.value = e.message + } finally { + saving.value = false + } + } + + async function analyzeImpact(projectId: string, path: string) { + try { + impactResult.value = await editorApi.getFileImpact(projectId, path) + } catch (e: any) { + error.value = e.message + } + } + + return { currentFile, impactResult, saving, error, loadFile, saveFile, analyzeImpact } +}) diff --git a/frontend/src/modules/editor/types/index.ts b/frontend/src/modules/editor/types/index.ts index e69de29..0c6cc0d 100644 --- a/frontend/src/modules/editor/types/index.ts +++ b/frontend/src/modules/editor/types/index.ts @@ -0,0 +1 @@ +export type { EditableFile, ImpactResult, ImplProgress } from '@/shared/types/api' diff --git a/frontend/src/modules/graph/api/index.ts b/frontend/src/modules/graph/api/index.ts index e69de29..dc19f02 100644 --- a/frontend/src/modules/graph/api/index.ts +++ b/frontend/src/modules/graph/api/index.ts @@ -0,0 +1,62 @@ +import api from '@/shared/api' +import type { ScanResult, GraphView, Capability, Module as DesignModule, Entity, Integration, ValueFlow, UserJourney, DataFlow, ExternalSystem, TraceabilityLink, RuntimeComponent, CapabilityDetail, ModuleDetail, EntityDetail, ImplProgress } from '@/shared/types/api' + +export async function triggerScan(projectId: string): Promise { + const { data } = await api.post(`/projects/${projectId}/scan`) + return data +} + +export async function getLatestScan(projectId: string): Promise { + const { data } = await api.get(`/projects/${projectId}/scan`) + return data +} + +export async function getGraph(projectId: string): Promise { + const { data } = await api.get(`/projects/${projectId}/graph`) + return data +} + +export async function getNodeNeighbors(projectId: string, nodeId: string): Promise { + const { data } = await api.get(`/projects/${projectId}/graph/nodes/${nodeId}/neighbors`) + return data +} + +export async function listCapabilities(projectId: string): Promise { + const { data } = await api.get(`/projects/${projectId}/entities/capabilities`) + return data +} + +export async function listModules(projectId: string): Promise { + const { data } = await api.get(`/projects/${projectId}/entities/modules`) + return data +} + +export async function listEntities(projectId: string): Promise { + const { data } = await api.get(`/projects/${projectId}/entities/entities`) + return data +} + +export async function getCapabilityDetail(projectId: string, capId: string): Promise { + const { data } = await api.get(`/projects/${projectId}/entities/capabilities/${capId}`) + return data +} + +export async function getModuleDetail(projectId: string, modId: string): Promise { + const { data } = await api.get(`/projects/${projectId}/entities/modules/${modId}`) + return data +} + +export async function getEntityDetail(projectId: string, entId: string): Promise { + const { data } = await api.get(`/projects/${projectId}/entities/entities/${entId}`) + return data +} + +export async function evaluateImplProgress(projectId: string): Promise { + const { data } = await api.post(`/projects/${projectId}/impl-progress`) + return data +} + +export async function getImplProgress(projectId: string): Promise { + const { data } = await api.get(`/projects/${projectId}/impl-progress`) + return data +} diff --git a/frontend/src/modules/graph/components/GraphDetail.vue b/frontend/src/modules/graph/components/GraphDetail.vue index e69de29..9d4f7a8 100644 --- a/frontend/src/modules/graph/components/GraphDetail.vue +++ b/frontend/src/modules/graph/components/GraphDetail.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/frontend/src/modules/graph/components/GraphPanorama.vue b/frontend/src/modules/graph/components/GraphPanorama.vue index e69de29..53f4dfe 100644 --- a/frontend/src/modules/graph/components/GraphPanorama.vue +++ b/frontend/src/modules/graph/components/GraphPanorama.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/frontend/src/modules/graph/composables/useGraph.ts b/frontend/src/modules/graph/composables/useGraph.ts index e69de29..766c8cd 100644 --- a/frontend/src/modules/graph/composables/useGraph.ts +++ b/frontend/src/modules/graph/composables/useGraph.ts @@ -0,0 +1,40 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { GraphView, GraphNode, ScanResult } from '@/shared/types/api' +import * as graphApi from '../api' + +export const useGraphStore = defineStore('graph', () => { + const graphView = ref(null) + const selectedNode = ref(null) + const scanResult = ref(null) + const loading = ref(false) + + async function loadGraph(projectId: string) { + loading.value = true + try { + scanResult.value = await graphApi.triggerScan(projectId) + graphView.value = await graphApi.getGraph(projectId) + } finally { + loading.value = false + } + } + + function selectNode(node: GraphNode | null) { + selectedNode.value = node + } + + async function loadNeighbors(projectId: string, nodeId: string) { + loading.value = true + try { + graphView.value = await graphApi.getNodeNeighbors(projectId, nodeId) + } finally { + loading.value = false + } + } + + function clearSelection() { + selectedNode.value = null + } + + return { graphView, selectedNode, scanResult, loading, loadGraph, selectNode, loadNeighbors, clearSelection } +}) diff --git a/frontend/src/modules/graph/types/index.ts b/frontend/src/modules/graph/types/index.ts index e69de29..390978c 100644 --- a/frontend/src/modules/graph/types/index.ts +++ b/frontend/src/modules/graph/types/index.ts @@ -0,0 +1 @@ +export type { GraphView, GraphNode, GraphEdge, GraphGroup, ScanResult, ScanSummary } from '@/shared/types/api' diff --git a/frontend/src/modules/project/api/index.ts b/frontend/src/modules/project/api/index.ts index e69de29..aa06d75 100644 --- a/frontend/src/modules/project/api/index.ts +++ b/frontend/src/modules/project/api/index.ts @@ -0,0 +1,25 @@ +import api from '@/shared/api' +import type { Project } from '@/shared/types/api' + +export async function listProjects(): Promise { + const { data } = await api.get('/projects') + return data +} + +export async function createProject(name: string, designDir: string, codeDir?: string): Promise { + const { data } = await api.post('/projects', { + name, + design_dir: designDir, + code_dir: codeDir || null, + }) + return data +} + +export async function getProject(id: string): Promise { + const { data } = await api.get(`/projects/${id}`) + return data +} + +export async function deleteProject(id: string): Promise { + await api.delete(`/projects/${id}`) +} diff --git a/frontend/src/modules/project/components/ProjectList.vue b/frontend/src/modules/project/components/ProjectList.vue index e69de29..c28ba19 100644 --- a/frontend/src/modules/project/components/ProjectList.vue +++ b/frontend/src/modules/project/components/ProjectList.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/modules/project/components/ProjectOverview.vue b/frontend/src/modules/project/components/ProjectOverview.vue index e69de29..b5ffb48 100644 --- a/frontend/src/modules/project/components/ProjectOverview.vue +++ b/frontend/src/modules/project/components/ProjectOverview.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/frontend/src/modules/project/components/ProjectSidebar.vue b/frontend/src/modules/project/components/ProjectSidebar.vue new file mode 100644 index 0000000..2e7da79 --- /dev/null +++ b/frontend/src/modules/project/components/ProjectSidebar.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/frontend/src/modules/project/composables/useProject.ts b/frontend/src/modules/project/composables/useProject.ts index e69de29..ea9f514 100644 --- a/frontend/src/modules/project/composables/useProject.ts +++ b/frontend/src/modules/project/composables/useProject.ts @@ -0,0 +1,59 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { Project } from '@/shared/types/api' +import * as projectApi from '../api' + +export const useProjectStore = defineStore('project', () => { + const projects = ref([]) + const currentProject = ref(null) + const loading = ref(false) + const error = ref(null) + + async function fetchProjects() { + loading.value = true + error.value = null + try { + projects.value = await projectApi.listProjects() + } catch (e: any) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function createProject(name: string, designDir: string, codeDir?: string) { + loading.value = true + error.value = null + try { + const project = await projectApi.createProject(name, designDir, codeDir) + projects.value.push(project) + return project + } catch (e: any) { + error.value = e.response?.data?.detail || e.message + throw e + } finally { + loading.value = false + } + } + + async function selectProject(id: string) { + loading.value = true + try { + currentProject.value = await projectApi.getProject(id) + } catch (e: any) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function removeProject(id: string) { + await projectApi.deleteProject(id) + projects.value = projects.value.filter(p => p.id !== id) + if (currentProject.value?.id === id) { + currentProject.value = null + } + } + + return { projects, currentProject, loading, error, fetchProjects, createProject, selectProject, removeProject } +}) diff --git a/frontend/src/modules/project/types/index.ts b/frontend/src/modules/project/types/index.ts index e69de29..4a938e7 100644 --- a/frontend/src/modules/project/types/index.ts +++ b/frontend/src/modules/project/types/index.ts @@ -0,0 +1 @@ +export type { Project } from '@/shared/types/api' diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index e69de29..7c742de 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -0,0 +1,12 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', component: () => import('@/modules/project/components/ProjectList.vue') }, + { path: '/projects/:id', component: () => import('@/modules/graph/components/GraphPanorama.vue') }, + { path: '/projects/:id/editor', component: () => import('@/modules/editor/components/EditorPage.vue') }, + ], +}) + +export default router diff --git a/frontend/src/shared/api.ts b/frontend/src/shared/api.ts index e69de29..a60e00f 100644 --- a/frontend/src/shared/api.ts +++ b/frontend/src/shared/api.ts @@ -0,0 +1,3 @@ +import axios from 'axios' +const api = axios.create({ baseURL: '/api' }) +export default api diff --git a/frontend/src/shared/types/api.ts b/frontend/src/shared/types/api.ts index e69de29..43fa19d 100644 --- a/frontend/src/shared/types/api.ts +++ b/frontend/src/shared/types/api.ts @@ -0,0 +1,198 @@ +export interface Project { + id: string + name: string + design_dir: string + code_dir: string | null + created_at: string +} + +export interface FileStatusEntry { + path: string + status: string + content_lines: number +} + +export interface ScanSummary { + total_files: number + ok: number + sparse: number + missing: number + placeholder_heavy: number + template_residue: number +} + +export interface ScanResult { + project_id: string + scanned_at: string + file_statuses: FileStatusEntry[] + summary: ScanSummary +} + +export interface GraphNode { + id: string + type: string + label: string + status: string + group_id: string +} + +export interface GraphEdge { + source: string + target: string + relation: string +} + +export interface GraphGroup { + id: string + label: string + layer: string +} + +export interface GraphView { + nodes: GraphNode[] + edges: GraphEdge[] + groups: GraphGroup[] +} + +export interface Capability { + capability_id: string + name: string + description: string + priority: string + phase: string + related_value_flows: string[] +} + +export interface Module { + module_id: string + name: string + layer: string + description: string + phase: string + depends_on: string[] + capabilities: string[] +} + +export interface Entity { + entity_id: string + name: string + domain: string + owner_module: string + description: string + phase: string + source_file: string +} + +export interface Integration { + integration_id: string + source: string + target: string + target_type: string + direction: string + protocol: string + trigger: string + phase: string + description: string +} + +export interface ValueFlow { + value_flow_id: string + name: string + trigger: string + actor: string + steps: string + outcome: string + phase: string + related_capabilities: string[] +} + +export interface UserJourney { + journey_id: string + name: string + actor: string + precondition: string + steps: string + postcondition: string + phase: string + related_value_flows: string[] +} + +export interface DataFlow { + data_flow_id: string + source: string + target: string + data_content: string + trigger: string + protocol: string + phase: string + description: string +} + +export interface ExternalSystem { + system_id: string + name: string + type: string + protocol: string + direction: string + phase: string + description: string +} + +export interface TraceabilityLink { + trace_id: string + capability_id: string + module_id: string + entity_ids: string[] + value_flow_ids: string[] + notes: string +} + +export interface RuntimeComponent { + component_id: string + name: string + type: string + technology: string + port: string +} + +export interface EditableFile { + path: string + format: string + content: string + last_modified: string +} + +export interface AffectedFile { + path: string + reason: string +} + +export interface ImpactResult { + source_file: string + affected_files: AffectedFile[] +} + +export interface ImplProgress { + module_id: string + percentage: number + source: string + evaluated_at: string +} + +export interface CapabilityDetail { + capability: Capability + modules: Module[] + value_flows: ValueFlow[] +} + +export interface ModuleDetail { + module: Module + entities: Entity[] + integrations: Integration[] + codebase_alignment: Record | null +} + +export interface EntityDetail { + entity: Entity + data_flows: DataFlow[] +} diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..ef40b86 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,83 @@ +:root { + --sidebar-width: 260px; + --color-primary: #1976D2; + --color-bg: #f5f5f5; + --color-sidebar: #fff; + --color-border: #e0e0e0; + --color-ok: #4CAF50; + --color-sparse: #FFC107; + --color-missing: #F44336; + --color-template-residue: #FF9800; + --color-placeholder-heavy: #9C27B0; + --color-unknown: #9E9E9E; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--color-bg); + color: #333; +} + +.app-layout { + display: grid; + grid-template-columns: var(--sidebar-width) 1fr; + min-height: 100vh; +} + +.sidebar { + background: var(--color-sidebar); + border-right: 1px solid var(--color-border); + padding: 16px; + overflow-y: auto; +} + +.sidebar h2 { + font-size: 16px; + margin-bottom: 16px; + color: var(--color-primary); +} + +.content { + padding: 24px; + overflow-y: auto; +} + +button { + cursor: pointer; + border: none; + border-radius: 4px; + padding: 8px 16px; + font-size: 14px; +} + +button.primary { + background: var(--color-primary); + color: white; +} + +button.danger { + background: var(--color-missing); + color: white; +} + +input, textarea { + border: 1px solid var(--color-border); + border-radius: 4px; + padding: 8px; + font-size: 14px; + width: 100%; +} + +.card { + background: white; + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..545c639 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "noEmit": true, + "paths": { + "@/*": ["./src/*"] + }, + "baseUrl": "." + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..61cb998 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "emitDeclarationOnly": true, + "declaration": true, + "strict": true, + "composite": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..6eca31b --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:8900', + changeOrigin: true, + }, + }, + }, +})