Compare commits
No commits in common. "feat/v2-fix-gaps" and "main" have entirely different histories.
feat/v2-fi
...
main
10
.gitignore
vendored
|
|
@ -1,10 +0,0 @@
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
*.pyo
|
|
||||||
.venv/
|
|
||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
*.tsbuildinfo
|
|
||||||
*.d.ts
|
|
||||||
!src/**/*.d.ts
|
|
||||||
frontend/vite.config.js
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
3.12
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
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"]
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
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()
|
|
||||||
|
|
@ -1,304 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
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", "<replace", "{{", "Lorem ipsum"]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ConstraintViolation:
|
|
||||||
rule: str
|
|
||||||
entity_id: str
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class DesignValidationService:
|
|
||||||
@staticmethod
|
|
||||||
def determine_file_status(content: str, file_path: str) -> 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
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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"
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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]
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
"""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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
"""GraphService — builds a relationship graph from ScanResult entities."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
|
|
||||||
from app.modules.graph.domain.entities import GraphEdge, GraphGroup, GraphNode, GraphView
|
|
||||||
from app.modules.scanner.domain.entities import ScanResult
|
|
||||||
|
|
||||||
|
|
||||||
def _to_rel_path(doc_file_path: str, design_dir: str) -> str:
|
|
||||||
"""Convert absolute doc.file_path to design-dir-relative path."""
|
|
||||||
try:
|
|
||||||
return str(PurePosixPath(doc_file_path).relative_to(design_dir))
|
|
||||||
except ValueError:
|
|
||||||
return doc_file_path
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_ref_path(ref_path: str, doc_rel_path: str) -> str:
|
|
||||||
"""Resolve a relative upstream/downstream ref against the doc's directory."""
|
|
||||||
doc_dir = str(PurePosixPath(doc_rel_path).parent)
|
|
||||||
resolved = str(PurePosixPath(doc_dir) / ref_path)
|
|
||||||
parts: list[str] = []
|
|
||||||
for part in PurePosixPath(resolved).parts:
|
|
||||||
if part == '..':
|
|
||||||
if parts:
|
|
||||||
parts.pop()
|
|
||||||
else:
|
|
||||||
parts.append(part)
|
|
||||||
return str(PurePosixPath(*parts)) if parts else ""
|
|
||||||
|
|
||||||
|
|
||||||
# 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"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
_SOURCE_FILES: dict[str, str] = {
|
|
||||||
"capability": "business-architecture/02-capability-map.csv",
|
|
||||||
"module": "application-architecture/02-modules.csv",
|
|
||||||
"entity": "data-architecture/01-entities.csv",
|
|
||||||
"runtime_component": "technology-architecture/01-runtime-components.csv",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class GraphService:
|
|
||||||
"""Constructs a panorama graph and supports neighbor queries."""
|
|
||||||
|
|
||||||
def build_panorama(self, scan_result: ScanResult, *, design_dir: str = "") -> GraphView:
|
|
||||||
"""Build a full panorama GraphView from a ScanResult (9-step algorithm)."""
|
|
||||||
nodes: list[GraphNode] = []
|
|
||||||
edges: list[GraphEdge] = []
|
|
||||||
node_ids: set[str] = set()
|
|
||||||
|
|
||||||
# Build file-status lookup from ScanResult
|
|
||||||
file_status_map: dict[str, str] = {
|
|
||||||
fs.path: fs.status.value for fs in scan_result.file_statuses
|
|
||||||
}
|
|
||||||
|
|
||||||
# Step 1: groups are always the fixed 5
|
|
||||||
groups = list(_GROUPS)
|
|
||||||
|
|
||||||
# Step 1.5: Build document nodes FIRST (needed for parent refs in Steps 2-5)
|
|
||||||
file_to_doc: dict[str, str] = {}
|
|
||||||
dir_to_doc: dict[str, str] = {}
|
|
||||||
for doc in scan_result.design_documents:
|
|
||||||
doc_rel = _to_rel_path(doc.file_path, design_dir)
|
|
||||||
file_to_doc[doc_rel] = doc.doc_id
|
|
||||||
# Map directory to first doc found there (for parent lookups by CSV path)
|
|
||||||
doc_dir = str(PurePosixPath(doc_rel).parent)
|
|
||||||
if doc_dir not in dir_to_doc:
|
|
||||||
dir_to_doc[doc_dir] = doc.doc_id
|
|
||||||
nodes.append(GraphNode(
|
|
||||||
id=doc.doc_id,
|
|
||||||
type="document",
|
|
||||||
label=doc.title or doc.doc_id,
|
|
||||||
status=file_status_map.get(doc_rel, "unknown"),
|
|
||||||
group_id="cross-layer",
|
|
||||||
))
|
|
||||||
node_ids.add(doc.doc_id)
|
|
||||||
|
|
||||||
def _parent_for(entity_type: str) -> str | None:
|
|
||||||
"""Find parent doc for an entity type via its source CSV directory."""
|
|
||||||
csv_path = _SOURCE_FILES.get(entity_type)
|
|
||||||
if not csv_path:
|
|
||||||
return None
|
|
||||||
return file_to_doc.get(csv_path) or dir_to_doc.get(
|
|
||||||
str(PurePosixPath(csv_path).parent)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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=file_status_map.get(_SOURCE_FILES["capability"], "unknown"),
|
|
||||||
group_id="business",
|
|
||||||
parent=_parent_for("capability"),
|
|
||||||
))
|
|
||||||
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=file_status_map.get(_SOURCE_FILES["module"], "unknown"),
|
|
||||||
group_id="application",
|
|
||||||
parent=_parent_for("module"),
|
|
||||||
))
|
|
||||||
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=file_status_map.get(_SOURCE_FILES["entity"], "unknown"),
|
|
||||||
group_id="data",
|
|
||||||
parent=_parent_for("entity"),
|
|
||||||
))
|
|
||||||
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=file_status_map.get(_SOURCE_FILES["runtime_component"], "unknown"),
|
|
||||||
group_id="technology",
|
|
||||||
parent=_parent_for("runtime_component"),
|
|
||||||
))
|
|
||||||
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.downstream → doc-to-doc edges (deduplicated)
|
|
||||||
path_to_doc: dict[str, str] = {}
|
|
||||||
doc_rel_paths: dict[str, str] = {}
|
|
||||||
for doc in scan_result.design_documents:
|
|
||||||
doc_rel = _to_rel_path(doc.file_path, design_dir)
|
|
||||||
path_to_doc[doc_rel] = doc.doc_id
|
|
||||||
doc_rel_paths[doc.doc_id] = doc_rel
|
|
||||||
|
|
||||||
seen_edges: set[tuple[str, str]] = set()
|
|
||||||
for doc in scan_result.design_documents:
|
|
||||||
doc_rel = doc_rel_paths[doc.doc_id]
|
|
||||||
for down_path in doc.downstream:
|
|
||||||
resolved = _resolve_ref_path(down_path, doc_rel)
|
|
||||||
down_doc_id = path_to_doc.get(resolved)
|
|
||||||
if down_doc_id and down_doc_id in node_ids:
|
|
||||||
edge_key = (doc.doc_id, down_doc_id)
|
|
||||||
if edge_key not in seen_edges:
|
|
||||||
seen_edges.add(edge_key)
|
|
||||||
edges.append(GraphEdge(
|
|
||||||
source=doc.doc_id,
|
|
||||||
target=down_doc_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)
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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
|
|
||||||
parent: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@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]
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
"""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 = _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, design_dir=project.design_dir)
|
|
||||||
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 = _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, design_dir=project.design_dir)
|
|
||||||
neighbors = _graph_service.get_neighbors(view, node_id)
|
|
||||||
return asdict(neighbors)
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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]
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
"""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),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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:
|
|
||||||
...
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
"""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,
|
|
||||||
)
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,353 +0,0 @@
|
||||||
"""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"),
|
|
||||||
)
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
"""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}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,253 +0,0 @@
|
||||||
"""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],
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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()
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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}")
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
[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",
|
|
||||||
]
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
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"
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
"""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"]
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
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,<replace this>\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
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
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 design_dir():
|
|
||||||
return "/workspace/arch-design-agent-skill-dashboard/design"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def graph_service():
|
|
||||||
return GraphService()
|
|
||||||
|
|
||||||
|
|
||||||
def test_panorama_has_groups(graph_service, scan_result, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
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, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
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, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
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, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
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, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
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, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
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, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
# 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, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
neighbors = graph_service.get_neighbors(view, "NONEXISTENT")
|
|
||||||
assert len(neighbors.nodes) == 0
|
|
||||||
assert len(neighbors.edges) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_graph_node_has_parent_field(graph_service, scan_result, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
for node in view.nodes:
|
|
||||||
assert hasattr(node, 'parent')
|
|
||||||
|
|
||||||
|
|
||||||
def test_panorama_nodes_have_real_status(graph_service, scan_result, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
statuses = {n.status for n in view.nodes}
|
|
||||||
assert statuses != {"unknown"}, "All nodes still have status='unknown' — status mapping not working"
|
|
||||||
|
|
||||||
|
|
||||||
def test_panorama_status_values_are_valid(graph_service, scan_result, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
valid_statuses = {"ok", "sparse", "missing", "template-residue", "placeholder-heavy", "unknown"}
|
|
||||||
for node in view.nodes:
|
|
||||||
assert node.status in valid_statuses, f"Node {node.id} has invalid status '{node.status}'"
|
|
||||||
|
|
||||||
|
|
||||||
def test_panorama_has_document_nodes(graph_service, scan_result, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
doc_nodes = [n for n in view.nodes if n.type == "document"]
|
|
||||||
assert len(doc_nodes) > 0, "No document nodes found"
|
|
||||||
assert all(n.group_id == "cross-layer" for n in doc_nodes)
|
|
||||||
|
|
||||||
|
|
||||||
def test_panorama_document_edges(graph_service, scan_result, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
doc_edges = [e for e in view.edges if e.relation == "documents"]
|
|
||||||
assert len(doc_edges) > 0, "No document edges found"
|
|
||||||
|
|
||||||
|
|
||||||
def test_panorama_capability_nodes_have_parent(graph_service, scan_result, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
cap_nodes = [n for n in view.nodes if n.type == "capability"]
|
|
||||||
nodes_with_parent = [n for n in cap_nodes if n.parent is not None]
|
|
||||||
assert len(nodes_with_parent) > 0, "No capability nodes have a parent document"
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
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"
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,376 +0,0 @@
|
||||||
"""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 == {}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
"""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
|
|
||||||
593
backend/uv.lock
|
|
@ -1,593 +0,0 @@
|
||||||
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" },
|
|
||||||
]
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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:
|
|
||||||
|
|
@ -1,847 +0,0 @@
|
||||||
# V2 Gap Fix 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:** Fix 7 gaps (P0+P1) so the graph visualization shows grouped layout, real status colors, working document edges, rich detail panel, legend, back button, and edit shortcut.
|
|
||||||
|
|
||||||
**Architecture:** Backend changes add `parent` field to `GraphNode`, status mapping from `FileStatusEntry`, and document nodes with proper edge resolution. Frontend changes replace the single-center D3 layout with per-group forceX/forceY, add compound document view toggle, enrich the detail panel with API calls, and add legend/back-button UI.
|
|
||||||
|
|
||||||
**Tech Stack:** Python 3.12 / FastAPI / dataclasses (backend), Vue 3 / TypeScript / D3.js v7 / Pinia (frontend), pytest (backend tests)
|
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-03-24-v2-fix-gaps-design.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
### Files to Modify
|
|
||||||
| File | Responsibility | Tasks |
|
|
||||||
|------|---------------|-------|
|
|
||||||
| `backend/app/modules/graph/domain/entities.py` | GraphNode dataclass — add `parent` field | 1 |
|
|
||||||
| `backend/app/modules/graph/application/services.py` | build_panorama — status mapping, document nodes, edge fix | 2, 3 |
|
|
||||||
| `backend/app/modules/graph/interfaces/http/router.py` | Pass `design_dir` to build_panorama | 4 |
|
|
||||||
| `backend/tests/test_graph_service.py` | Update existing tests + add new tests | 1, 2, 3, 4 |
|
|
||||||
| `frontend/src/shared/types/api.ts` | Add `parent` to GraphNode interface | 5 |
|
|
||||||
| `frontend/src/modules/graph/components/GraphPanorama.vue` | Group layout, compound layout, toggle, back button | 6, 8 |
|
|
||||||
| `frontend/src/modules/graph/components/GraphDetail.vue` | Rich detail panel, edit button | 7 |
|
|
||||||
|
|
||||||
### Files to Create
|
|
||||||
| File | Responsibility | Tasks |
|
|
||||||
|------|---------------|-------|
|
|
||||||
| `frontend/src/modules/graph/components/GraphLegend.vue` | Legend component | 9 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Domain — Add `parent` field to GraphNode
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `backend/app/modules/graph/domain/entities.py:4-10`
|
|
||||||
- Test: `backend/tests/test_graph_service.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Add to `backend/tests/test_graph_service.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_graph_node_has_parent_field(graph_service, scan_result):
|
|
||||||
view = graph_service.build_panorama(scan_result)
|
|
||||||
# All nodes should have a parent attribute (None for most, doc_id for some)
|
|
||||||
for node in view.nodes:
|
|
||||||
assert hasattr(node, 'parent')
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_graph_node_has_parent_field -v`
|
|
||||||
Expected: FAIL — `GraphNode` has no `parent` attribute
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add `parent` field to GraphNode**
|
|
||||||
|
|
||||||
Edit `backend/app/modules/graph/domain/entities.py` — add to the GraphNode dataclass:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@dataclass
|
|
||||||
class GraphNode:
|
|
||||||
id: str
|
|
||||||
type: str # capability, module, entity, runtime_component, document
|
|
||||||
label: str
|
|
||||||
status: str # FileStatus value or "unknown"
|
|
||||||
group_id: str
|
|
||||||
parent: str | None = None # doc_id of containing document, if any
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run test to verify it passes**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_graph_node_has_parent_field -v`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run all existing graph tests to verify no regression**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py -v`
|
|
||||||
Expected: All 9 tests PASS (existing 8 + new 1)
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add backend/app/modules/graph/domain/entities.py backend/tests/test_graph_service.py
|
|
||||||
git commit -m "feat(graph): add parent field to GraphNode domain entity"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Application — Status mapping in build_panorama (GAP-B1)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `backend/app/modules/graph/application/services.py:22-77`
|
|
||||||
- Test: `backend/tests/test_graph_service.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add design_dir fixture and write the failing tests**
|
|
||||||
|
|
||||||
First, add a `design_dir` fixture to `backend/tests/test_graph_service.py` (will be used by all subsequent tests):
|
|
||||||
|
|
||||||
```python
|
|
||||||
@pytest.fixture
|
|
||||||
def design_dir():
|
|
||||||
return "/workspace/arch-design-agent-skill-dashboard/design"
|
|
||||||
```
|
|
||||||
|
|
||||||
Then add the new tests (note: all new tests from this point forward take `design_dir` and pass it to `build_panorama`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_panorama_nodes_have_real_status(graph_service, scan_result, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
statuses = {n.status for n in view.nodes}
|
|
||||||
# At least some nodes should NOT be "unknown" since we have real file statuses
|
|
||||||
assert statuses != {"unknown"}, "All nodes still have status='unknown' — status mapping not working"
|
|
||||||
|
|
||||||
|
|
||||||
def test_panorama_status_values_are_valid(graph_service, scan_result, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
valid_statuses = {"ok", "sparse", "missing", "template-residue", "placeholder-heavy", "unknown"}
|
|
||||||
for node in view.nodes:
|
|
||||||
assert node.status in valid_statuses, f"Node {node.id} has invalid status '{node.status}'"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_panorama_nodes_have_real_status tests/test_graph_service.py::test_panorama_status_values_are_valid -v`
|
|
||||||
Expected: `test_panorama_nodes_have_real_status` FAILS (all statuses are "unknown")
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement status mapping**
|
|
||||||
|
|
||||||
Edit `backend/app/modules/graph/application/services.py`. Add the `_SOURCE_FILES` constant after `_GROUPS` and modify `build_panorama` to build the file status map:
|
|
||||||
|
|
||||||
```python
|
|
||||||
_SOURCE_FILES: dict[str, str] = {
|
|
||||||
"capability": "business-architecture/02-capability-map.csv",
|
|
||||||
"module": "application-architecture/02-modules.csv",
|
|
||||||
"entity": "data-architecture/01-entities.csv",
|
|
||||||
"runtime_component": "technology-architecture/01-runtime-components.csv",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
At the top of `build_panorama()`, add `design_dir` parameter with default so existing tests still work:
|
|
||||||
```python
|
|
||||||
def build_panorama(self, scan_result: ScanResult, design_dir: str = "") -> GraphView:
|
|
||||||
# Build file path -> status mapping
|
|
||||||
file_status_map: dict[str, str] = {
|
|
||||||
fs.path: fs.status.value for fs in scan_result.file_statuses
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace each `status="unknown"` with:
|
|
||||||
```python
|
|
||||||
status=file_status_map.get(_SOURCE_FILES.get("capability", ""), "unknown"), # for caps
|
|
||||||
status=file_status_map.get(_SOURCE_FILES.get("module", ""), "unknown"), # for modules
|
|
||||||
status=file_status_map.get(_SOURCE_FILES.get("entity", ""), "unknown"), # for entities
|
|
||||||
status=file_status_map.get(_SOURCE_FILES.get("runtime_component", ""), "unknown"), # for runtime
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py -v`
|
|
||||||
Expected: All tests PASS including the two new status tests
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add backend/app/modules/graph/application/services.py backend/tests/test_graph_service.py
|
|
||||||
git commit -m "feat(graph): map node status from FileStatus via source_file (GAP-B1)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Application — Document nodes + doc→doc edges (GAP-B3)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `backend/app/modules/graph/application/services.py`
|
|
||||||
- Test: `backend/tests/test_graph_service.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Add to `backend/tests/test_graph_service.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_panorama_has_document_nodes(graph_service, scan_result, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
doc_nodes = [n for n in view.nodes if n.type == "document"]
|
|
||||||
assert len(doc_nodes) > 0, "No document nodes found"
|
|
||||||
assert all(n.group_id == "cross-layer" for n in doc_nodes)
|
|
||||||
|
|
||||||
|
|
||||||
def test_panorama_document_edges(graph_service, scan_result, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
doc_edges = [e for e in view.edges if e.relation == "documents"]
|
|
||||||
assert len(doc_edges) > 0, "No document edges found"
|
|
||||||
|
|
||||||
|
|
||||||
def test_panorama_capability_nodes_have_parent(graph_service, scan_result, design_dir):
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
cap_nodes = [n for n in view.nodes if n.type == "capability"]
|
|
||||||
# Capability nodes should have parent pointing to a document
|
|
||||||
nodes_with_parent = [n for n in cap_nodes if n.parent is not None]
|
|
||||||
assert len(nodes_with_parent) > 0, "No capability nodes have a parent document"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_panorama_has_document_nodes tests/test_graph_service.py::test_panorama_document_edges tests/test_graph_service.py::test_panorama_capability_nodes_have_parent -v`
|
|
||||||
Expected: All 3 FAIL
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement document nodes and edge resolution**
|
|
||||||
|
|
||||||
Edit `backend/app/modules/graph/application/services.py`.
|
|
||||||
|
|
||||||
Add path helper functions before the class:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
|
|
||||||
|
|
||||||
def _to_rel_path(doc_file_path: str, design_dir: str) -> str:
|
|
||||||
"""Convert absolute doc.file_path to design-dir-relative path."""
|
|
||||||
try:
|
|
||||||
return str(PurePosixPath(doc_file_path).relative_to(design_dir))
|
|
||||||
except ValueError:
|
|
||||||
return doc_file_path
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_ref_path(ref_path: str, doc_rel_path: str) -> str:
|
|
||||||
"""Resolve a relative upstream/downstream ref against the doc's directory."""
|
|
||||||
doc_dir = str(PurePosixPath(doc_rel_path).parent)
|
|
||||||
resolved = str(PurePosixPath(doc_dir) / ref_path)
|
|
||||||
parts: list[str] = []
|
|
||||||
for part in PurePosixPath(resolved).parts:
|
|
||||||
if part == '..':
|
|
||||||
if parts:
|
|
||||||
parts.pop()
|
|
||||||
else:
|
|
||||||
parts.append(part)
|
|
||||||
return str(PurePosixPath(*parts)) if parts else ""
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important ordering:** Build `file_to_doc` mapping BEFORE Steps 2-5 so entity nodes can get their `parent`. Restructure `build_panorama` to:
|
|
||||||
|
|
||||||
1. Build `file_status_map` (already done in Task 2)
|
|
||||||
2. Build `file_to_doc` from `scan_result.design_documents` + create document nodes
|
|
||||||
3. Then create entity nodes (Steps 2-5) with `parent` set via `file_to_doc`
|
|
||||||
4. Then create edges (Steps 6-9) with fixed Step 9
|
|
||||||
|
|
||||||
Step 5.5 (now moved before Steps 2-5):
|
|
||||||
```python
|
|
||||||
# Build document node mapping first (needed for parent refs)
|
|
||||||
file_to_doc: dict[str, str] = {}
|
|
||||||
for doc in scan_result.design_documents:
|
|
||||||
doc_rel = _to_rel_path(doc.file_path, design_dir)
|
|
||||||
file_to_doc[doc_rel] = doc.doc_id
|
|
||||||
nodes.append(GraphNode(
|
|
||||||
id=doc.doc_id,
|
|
||||||
type="document",
|
|
||||||
label=doc.title or doc.doc_id,
|
|
||||||
status=file_status_map.get(doc_rel, "unknown"),
|
|
||||||
group_id="cross-layer",
|
|
||||||
))
|
|
||||||
node_ids.add(doc.doc_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
In each entity creation (Steps 2-5), add parent:
|
|
||||||
```python
|
|
||||||
# e.g. for capability:
|
|
||||||
parent_doc_id = file_to_doc.get(_SOURCE_FILES.get("capability"))
|
|
||||||
nodes.append(GraphNode(
|
|
||||||
id=node_id, type="capability", label=cap.name,
|
|
||||||
status=file_status_map.get(_SOURCE_FILES.get("capability", ""), "unknown"),
|
|
||||||
group_id="business",
|
|
||||||
parent=parent_doc_id,
|
|
||||||
))
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace Step 9 with path resolution + deduplication:
|
|
||||||
```python
|
|
||||||
# Step 9: DesignDocument.downstream → doc-to-doc edges (deduplicated)
|
|
||||||
path_to_doc: dict[str, str] = {}
|
|
||||||
doc_rel_paths: dict[str, str] = {}
|
|
||||||
for doc in scan_result.design_documents:
|
|
||||||
doc_rel = _to_rel_path(doc.file_path, design_dir)
|
|
||||||
path_to_doc[doc_rel] = doc.doc_id
|
|
||||||
doc_rel_paths[doc.doc_id] = doc_rel
|
|
||||||
|
|
||||||
seen_edges: set[tuple[str, str]] = set()
|
|
||||||
for doc in scan_result.design_documents:
|
|
||||||
doc_rel = doc_rel_paths[doc.doc_id]
|
|
||||||
for down_path in doc.downstream:
|
|
||||||
resolved = _resolve_ref_path(down_path, doc_rel)
|
|
||||||
down_doc_id = path_to_doc.get(resolved)
|
|
||||||
if down_doc_id and down_doc_id in node_ids:
|
|
||||||
edge_key = (doc.doc_id, down_doc_id)
|
|
||||||
if edge_key not in seen_edges:
|
|
||||||
seen_edges.add(edge_key)
|
|
||||||
edges.append(GraphEdge(
|
|
||||||
source=doc.doc_id, target=down_doc_id,
|
|
||||||
relation="documents",
|
|
||||||
))
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run new tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_panorama_has_document_nodes tests/test_graph_service.py::test_panorama_document_edges tests/test_graph_service.py::test_panorama_capability_nodes_have_parent -v`
|
|
||||||
Expected: All 3 PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run ALL graph tests to check no regression**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py tests/test_api_graph.py -v`
|
|
||||||
Expected: All tests PASS
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add backend/app/modules/graph/application/services.py backend/tests/test_graph_service.py
|
|
||||||
git commit -m "feat(graph): add document nodes, parent refs, and fixed doc edges (GAP-B3)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Interfaces — Pass design_dir from router to build_panorama
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `backend/app/modules/graph/interfaces/http/router.py:40-56`
|
|
||||||
- Test: `backend/tests/test_api_graph.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update the router to pass design_dir**
|
|
||||||
|
|
||||||
Edit `backend/app/modules/graph/interfaces/http/router.py`:
|
|
||||||
|
|
||||||
In `get_graph()`:
|
|
||||||
```python
|
|
||||||
@router.get("")
|
|
||||||
def get_graph(project_id: str):
|
|
||||||
"""Build and return the full panorama graph for a project."""
|
|
||||||
project = _project_service.get_project(project_id)
|
|
||||||
scan_result = _get_or_trigger_scan(project_id)
|
|
||||||
view = _graph_service.build_panorama(scan_result, design_dir=project.design_dir)
|
|
||||||
return asdict(view)
|
|
||||||
```
|
|
||||||
|
|
||||||
In `get_neighbors()`:
|
|
||||||
```python
|
|
||||||
@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 = _project_service.get_project(project_id)
|
|
||||||
scan_result = _get_or_trigger_scan(project_id)
|
|
||||||
view = _graph_service.build_panorama(scan_result, design_dir=project.design_dir)
|
|
||||||
neighbors = _graph_service.get_neighbors(view, node_id)
|
|
||||||
return asdict(neighbors)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update test_graph_service.py to pass design_dir**
|
|
||||||
|
|
||||||
Add a `design_dir` fixture and update all `build_panorama` calls:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@pytest.fixture
|
|
||||||
def design_dir():
|
|
||||||
return "/workspace/arch-design-agent-skill-dashboard/design"
|
|
||||||
```
|
|
||||||
|
|
||||||
Update all test function signatures to include `design_dir` parameter. Update all calls from:
|
|
||||||
```python
|
|
||||||
view = graph_service.build_panorama(scan_result)
|
|
||||||
```
|
|
||||||
to:
|
|
||||||
```python
|
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run ALL backend tests**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/ -v`
|
|
||||||
Expected: All tests PASS
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add backend/app/modules/graph/interfaces/http/router.py backend/tests/test_graph_service.py
|
|
||||||
git commit -m "feat(graph): pass design_dir from router to build_panorama"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Frontend types — Add `parent` to GraphNode interface
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/shared/types/api.ts:31-37`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add parent field**
|
|
||||||
|
|
||||||
Edit `frontend/src/shared/types/api.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface GraphNode {
|
|
||||||
id: string
|
|
||||||
type: string
|
|
||||||
label: string
|
|
||||||
status: string
|
|
||||||
group_id: string
|
|
||||||
parent: string | null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run type check**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit`
|
|
||||||
Expected: No errors
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/shared/types/api.ts
|
|
||||||
git commit -m "feat(graph): add parent field to GraphNode TypeScript interface"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: Frontend — Group-partitioned layout + compound layout + toggle (GAP-F1)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/modules/graph/components/GraphPanorama.vue`
|
|
||||||
|
|
||||||
This is the largest task. The entire `drawGraph()` function needs rewriting.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add state variables and update constants**
|
|
||||||
|
|
||||||
Add after existing refs in `<script setup>`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const showDocumentView = ref(false)
|
|
||||||
const isDrillDown = ref(false)
|
|
||||||
const drillDownNodeLabel = ref('')
|
|
||||||
|
|
||||||
const GROUP_POSITIONS: Record<string, { x: number; y: number }> = {
|
|
||||||
business: { x: 0.50, y: 0.15 },
|
|
||||||
application: { x: 0.50, y: 0.38 },
|
|
||||||
data: { x: 0.30, y: 0.65 },
|
|
||||||
technology: { x: 0.70, y: 0.65 },
|
|
||||||
'cross-layer': { x: 0.50, y: 0.85 },
|
|
||||||
}
|
|
||||||
|
|
||||||
const EDGE_COLORS: Record<string, string> = {
|
|
||||||
traces_to: '#666',
|
|
||||||
depends_on: '#999',
|
|
||||||
documents: '#42A5F5',
|
|
||||||
integrates_with: '#AB47BC',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Also update the existing `EDGE_STYLES` constant to fix `documents` from dashed to solid:
|
|
||||||
```typescript
|
|
||||||
const EDGE_STYLES: Record<string, string> = {
|
|
||||||
traces_to: '0', depends_on: '6,3', integrates_with: '4,2', documents: '0',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Rewrite drawGraph() for default mode**
|
|
||||||
|
|
||||||
Replace the `drawGraph()` function. Key changes:
|
|
||||||
- Filter out `type === "document"` nodes and `relation === "documents"` edges when `showDocumentView.value` is false
|
|
||||||
- Replace `d3.forceCenter` with `d3.forceX`/`d3.forceY` per group with strength 0.4
|
|
||||||
- Correct node shapes: circle r=18 for capability, rect 28x28 for module, diamond 24x24 for entity, circle r=14 for runtime_component
|
|
||||||
- Apply `STATUS_COLORS` for fill, `EDGE_STYLES` + `EDGE_COLORS` for edges
|
|
||||||
|
|
||||||
Core simulation setup:
|
|
||||||
```typescript
|
|
||||||
const simulation = d3.forceSimulation(simNodes)
|
|
||||||
.force('link', d3.forceLink(simEdges).id((d: any) => d.id).distance(60))
|
|
||||||
.force('charge', d3.forceManyBody().strength(-150))
|
|
||||||
.force('x', d3.forceX((d: any) =>
|
|
||||||
(GROUP_POSITIONS[d.group_id] || GROUP_POSITIONS['cross-layer']).x * width
|
|
||||||
).strength(0.4))
|
|
||||||
.force('y', d3.forceY((d: any) =>
|
|
||||||
(GROUP_POSITIONS[d.group_id] || GROUP_POSITIONS['cross-layer']).y * height
|
|
||||||
).strength(0.4))
|
|
||||||
.force('collide', d3.forceCollide(30))
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add compound layout mode (document view)**
|
|
||||||
|
|
||||||
When `showDocumentView.value` is true:
|
|
||||||
- Include document nodes, render as large `<rect>` containers
|
|
||||||
- Size by child count: `width = Math.max(150, childCount * 60)`, `height = Math.max(100, childCount * 40)`
|
|
||||||
- On each tick, clamp entity nodes with `parent` inside parent's bounds (pad=20):
|
|
||||||
```typescript
|
|
||||||
if (n.parent) {
|
|
||||||
const p = nodeMap[n.parent]
|
|
||||||
const pad = 20
|
|
||||||
n.x = Math.max(p.x - p.w/2 + pad, Math.min(p.x + p.w/2 - pad, n.x))
|
|
||||||
n.y = Math.max(p.y - p.h/2 + pad, Math.min(p.y + p.h/2 - pad, n.y))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Add toggle button and group labels in template**
|
|
||||||
|
|
||||||
Add after scan-summary div:
|
|
||||||
```html
|
|
||||||
<div class="toolbar">
|
|
||||||
<button class="toggle-btn" :class="{ active: showDocumentView }" @click="toggleDocumentView">
|
|
||||||
{{ showDocumentView ? '默认视图' : '文档视图' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Add toggle function:
|
|
||||||
```typescript
|
|
||||||
function toggleDocumentView() {
|
|
||||||
showDocumentView.value = !showDocumentView.value
|
|
||||||
drawGraph()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Render group labels at each group position as static text in the SVG.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run type check**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit`
|
|
||||||
Expected: No errors
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/modules/graph/components/GraphPanorama.vue
|
|
||||||
git commit -m "feat(graph): group-partitioned layout with document view toggle (GAP-F1)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 7: Frontend — Rich GraphDetail panel + edit button (GAP-F2 + GAP-F5)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/modules/graph/components/GraphDetail.vue`
|
|
||||||
- Modify: `frontend/src/modules/graph/components/GraphPanorama.vue` (update props)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Rewrite GraphDetail.vue**
|
|
||||||
|
|
||||||
Full rewrite to:
|
|
||||||
|
|
||||||
1. Accept additional props: `graphView: GraphView | null`, `projectId: string`
|
|
||||||
2. Fetch detail API on node selection based on `node.type`:
|
|
||||||
- `capability` → `getCapabilityDetail(projectId, node.id)`
|
|
||||||
- `module` → `getModuleDetail(projectId, node.id)`
|
|
||||||
- `entity` → `getEntityDetail(projectId, node.id)`
|
|
||||||
- others → show basic fields only
|
|
||||||
3. Display attributes section (iterate over response fields dynamically)
|
|
||||||
4. Display related entities section (from `graphView.edges`)
|
|
||||||
5. Edit button (navigates to `/projects/${projectId}/editor?file=${sourceFile}`)
|
|
||||||
|
|
||||||
Key imports:
|
|
||||||
```typescript
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { getCapabilityDetail, getModuleDetail, getEntityDetail } from '../api'
|
|
||||||
import type { GraphNode, GraphView } from '@/shared/types/api'
|
|
||||||
```
|
|
||||||
|
|
||||||
Define `SOURCE_FILES` constant:
|
|
||||||
```typescript
|
|
||||||
const SOURCE_FILES: Record<string, string> = {
|
|
||||||
capability: 'business-architecture/02-capability-map.csv',
|
|
||||||
module: 'application-architecture/02-modules.csv',
|
|
||||||
entity: 'data-architecture/01-entities.csv',
|
|
||||||
runtime_component: 'technology-architecture/01-runtime-components.csv',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Emit a `selectNode` event when a related entity is clicked. The handler looks up the full `GraphNode` from `graphView.nodes` by ID before emitting:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const emit = defineEmits<{ close: []; selectNode: [node: GraphNode] }>()
|
|
||||||
|
|
||||||
function onRelatedEntityClick(nodeId: string) {
|
|
||||||
const found = props.graphView?.nodes.find(n => n.id === nodeId)
|
|
||||||
if (found) emit('selectNode', found)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Edit button click handler:
|
|
||||||
```typescript
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
function openEditor() {
|
|
||||||
if (!sourceFile.value) return
|
|
||||||
router.push({
|
|
||||||
path: `/projects/${props.projectId}/editor`,
|
|
||||||
query: { file: sourceFile.value }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update GraphPanorama.vue to pass new props**
|
|
||||||
|
|
||||||
```html
|
|
||||||
<GraphDetail
|
|
||||||
:node="selectedNode"
|
|
||||||
:graphView="graphView"
|
|
||||||
:projectId="route.params.id as string"
|
|
||||||
@close="clearSelection"
|
|
||||||
@selectNode="store.selectNode"
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run type check**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit`
|
|
||||||
Expected: No errors
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/modules/graph/components/GraphDetail.vue frontend/src/modules/graph/components/GraphPanorama.vue
|
|
||||||
git commit -m "feat(graph): rich detail panel with API fetch and edit button (GAP-F2, GAP-F5)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 8: Frontend — Back to panorama button (GAP-F4)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/modules/graph/components/GraphPanorama.vue`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Wire up drill-down state**
|
|
||||||
|
|
||||||
The `isDrillDown` and `drillDownNodeLabel` refs were added in Task 6. Now wire them:
|
|
||||||
|
|
||||||
Update double-click handler:
|
|
||||||
```typescript
|
|
||||||
node.on('dblclick', (_event: any, d: any) => {
|
|
||||||
const projectId = route.params.id as string
|
|
||||||
isDrillDown.value = true
|
|
||||||
drillDownNodeLabel.value = d.label
|
|
||||||
store.loadNeighbors(projectId, d.id)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Add `returnToPanorama`:
|
|
||||||
```typescript
|
|
||||||
async function returnToPanorama() {
|
|
||||||
const projectId = route.params.id as string
|
|
||||||
isDrillDown.value = false
|
|
||||||
drillDownNodeLabel.value = ''
|
|
||||||
await store.loadGraph(projectId)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add back button in template**
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div v-if="isDrillDown" class="drill-down-bar">
|
|
||||||
<button @click="returnToPanorama" class="back-btn">← 返回全景图</button>
|
|
||||||
<span class="drill-down-label">当前: {{ drillDownNodeLabel }}</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add styles**
|
|
||||||
|
|
||||||
```css
|
|
||||||
.drill-down-bar {
|
|
||||||
position: absolute; top: 12px; left: 12px;
|
|
||||||
display: flex; align-items: center; gap: 12px;
|
|
||||||
background: white; padding: 8px 16px; border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 10;
|
|
||||||
}
|
|
||||||
.back-btn {
|
|
||||||
background: #1976D2; color: white; border: none;
|
|
||||||
padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px;
|
|
||||||
}
|
|
||||||
.back-btn:hover { background: #1565C0; }
|
|
||||||
.drill-down-label { font-size: 13px; color: #666; }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run type check**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit`
|
|
||||||
Expected: No errors
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/modules/graph/components/GraphPanorama.vue
|
|
||||||
git commit -m "feat(graph): add back-to-panorama button for drill-down mode (GAP-F4)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 9: Frontend — Graph Legend component (GAP-F3)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `frontend/src/modules/graph/components/GraphLegend.vue`
|
|
||||||
- Modify: `frontend/src/modules/graph/components/GraphPanorama.vue`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create GraphLegend.vue**
|
|
||||||
|
|
||||||
Create `frontend/src/modules/graph/components/GraphLegend.vue` with:
|
|
||||||
- SVG shapes matching the graph (circle, rect, diamond, small-circle, container-rect)
|
|
||||||
- Status color swatches with labels
|
|
||||||
- Edge style samples with labels
|
|
||||||
- Collapsible (expanded by default)
|
|
||||||
- Positioned bottom-right, semi-transparent background
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<div class="graph-legend" :class="{ collapsed: !expanded }">
|
|
||||||
<div class="legend-header" @click="expanded = !expanded">
|
|
||||||
<span>图例</span>
|
|
||||||
<span class="toggle">{{ expanded ? '▼' : '▶' }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="expanded" class="legend-body">
|
|
||||||
<div class="legend-section">
|
|
||||||
<div class="legend-title">形状</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><circle cx="10" cy="10" r="8" fill="#9E9E9E"/></svg>
|
|
||||||
<span>Capability</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><rect x="2" y="2" width="16" height="16" rx="2" fill="#9E9E9E"/></svg>
|
|
||||||
<span>Module</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><polygon points="10,2 18,10 10,18 2,10" fill="#9E9E9E"/></svg>
|
|
||||||
<span>Entity</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><circle cx="10" cy="10" r="6" fill="#9E9E9E"/></svg>
|
|
||||||
<span>Runtime</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><rect x="1" y="4" width="18" height="12" rx="2" fill="none" stroke="#9E9E9E" stroke-width="2"/></svg>
|
|
||||||
<span>Document</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="legend-section">
|
|
||||||
<div class="legend-title">状态</div>
|
|
||||||
<div class="legend-item" v-for="s in statuses" :key="s.label">
|
|
||||||
<svg width="20" height="20"><circle cx="10" cy="10" r="6" :fill="s.color"/></svg>
|
|
||||||
<span>{{ s.label }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="legend-section">
|
|
||||||
<div class="legend-title">边线</div>
|
|
||||||
<div class="legend-item" v-for="e in edgeTypes" :key="e.label">
|
|
||||||
<svg width="40" height="12">
|
|
||||||
<line x1="0" y1="6" x2="40" y2="6" :stroke="e.color" stroke-width="2" :stroke-dasharray="e.dash" />
|
|
||||||
</svg>
|
|
||||||
<span>{{ e.label }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const expanded = ref(true)
|
|
||||||
|
|
||||||
const statuses = [
|
|
||||||
{ label: 'OK', color: '#4CAF50' },
|
|
||||||
{ label: 'Sparse', color: '#FFC107' },
|
|
||||||
{ label: 'Missing', color: '#F44336' },
|
|
||||||
{ label: 'Template Residue', color: '#FF9800' },
|
|
||||||
{ label: 'Placeholder Heavy', color: '#9C27B0' },
|
|
||||||
{ label: 'Unknown', color: '#9E9E9E' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const edgeTypes = [
|
|
||||||
{ label: 'traces_to', color: '#666', dash: '0' },
|
|
||||||
{ label: 'depends_on', color: '#999', dash: '6,3' },
|
|
||||||
{ label: 'documents', color: '#42A5F5', dash: '0' },
|
|
||||||
{ label: 'integrates_with', color: '#AB47BC', dash: '4,2' },
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.graph-legend {
|
|
||||||
position: absolute; bottom: 16px; right: 16px;
|
|
||||||
background: rgba(255,255,255,0.92); border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
|
||||||
padding: 8px 12px; z-index: 10; min-width: 180px;
|
|
||||||
font-size: 12px; pointer-events: auto;
|
|
||||||
}
|
|
||||||
.legend-header {
|
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
|
||||||
cursor: pointer; font-weight: 600; font-size: 13px; padding-bottom: 4px;
|
|
||||||
}
|
|
||||||
.toggle { font-size: 10px; color: #999; }
|
|
||||||
.legend-body { display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
|
|
||||||
.legend-section { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.legend-title { font-weight: 600; color: #666; margin-bottom: 2px; }
|
|
||||||
.legend-item { display: flex; align-items: center; gap: 6px; }
|
|
||||||
.legend-item span { color: #333; }
|
|
||||||
</style>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Import GraphLegend in GraphPanorama.vue**
|
|
||||||
|
|
||||||
Add import:
|
|
||||||
```typescript
|
|
||||||
import GraphLegend from './GraphLegend.vue'
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to template (inside `.graph-panorama` div, after `<svg>`):
|
|
||||||
```html
|
|
||||||
<GraphLegend />
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run type check**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit`
|
|
||||||
Expected: No errors
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/modules/graph/components/GraphLegend.vue frontend/src/modules/graph/components/GraphPanorama.vue
|
|
||||||
git commit -m "feat(graph): add collapsible legend component (GAP-F3)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 10: Final verification
|
|
||||||
|
|
||||||
- [ ] **Step 1: Run all backend tests**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/ -v`
|
|
||||||
Expected: All tests PASS
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run frontend type check**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit`
|
|
||||||
Expected: No errors
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run frontend build**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npm run build`
|
|
||||||
Expected: Build succeeds
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify git status is clean**
|
|
||||||
|
|
||||||
Run: `git status`
|
|
||||||
Expected: No uncommitted changes
|
|
||||||
|
|
@ -1,804 +0,0 @@
|
||||||
# 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、<replace>)
|
|
||||||
- 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**:
|
|
||||||
```
|
|
||||||
<template>
|
|
||||||
<div class="app-layout">
|
|
||||||
<aside class="sidebar">项目列表(始终可见)</aside>
|
|
||||||
<main class="content"><router-view /></main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
**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 缺失,实现时补充 |
|
|
||||||
|
|
@ -1,471 +0,0 @@
|
||||||
# V2 Gap Fix Design — 7 Gaps (P0+P1)
|
|
||||||
|
|
||||||
Date: 2026-03-24
|
|
||||||
Branch: `feat/v2-fix-gaps`
|
|
||||||
Status: Design
|
|
||||||
|
|
||||||
## 1. Problem Statement
|
|
||||||
|
|
||||||
The current graph visualization has three critical issues:
|
|
||||||
1. All 63 nodes share a single `forceCenter`, producing a "hairball" layout with no group separation
|
|
||||||
2. All node statuses are hardcoded to `"unknown"` — node colors are meaningless grey
|
|
||||||
3. DesignDocument upstream/downstream edges are dead code (file paths vs node IDs mismatch)
|
|
||||||
|
|
||||||
Additionally, the detail panel is nearly empty, there's no way to return from drill-down, no edit shortcut, and no legend.
|
|
||||||
|
|
||||||
## 2. Design Decisions
|
|
||||||
|
|
||||||
### DD-1: Architecture Layer Layout (方案 A)
|
|
||||||
Y-axis represents abstraction level, matching TOGAF/ArchiMate conventions:
|
|
||||||
|
|
||||||
```
|
|
||||||
group_id targetX(ratio) targetY(ratio)
|
|
||||||
business 0.50 0.15
|
|
||||||
application 0.50 0.38
|
|
||||||
data 0.30 0.65
|
|
||||||
technology 0.70 0.65
|
|
||||||
cross-layer 0.50 0.85
|
|
||||||
```
|
|
||||||
|
|
||||||
### DD-2: Compound Graph for Document View
|
|
||||||
- Document nodes are containers; entity nodes nest inside their parent document
|
|
||||||
- Toggle between default (group-partitioned) and document view modes
|
|
||||||
- Both doc→doc edges and entity→entity edges coexist
|
|
||||||
|
|
||||||
### DD-3: Status Mapping via source_file
|
|
||||||
- Entity type → source CSV file → FileStatus from ScanResult
|
|
||||||
- Same-CSV entities share that file's status (one CSV = one entity type's completeness)
|
|
||||||
|
|
||||||
## 3. Gap Specifications
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.1 GAP-B1: Node Status Mapping
|
|
||||||
|
|
||||||
**Layer:** Backend
|
|
||||||
**Priority:** P0
|
|
||||||
**File:** `backend/app/modules/graph/application/services.py`
|
|
||||||
|
|
||||||
**Current behavior:** `GraphService.build_panorama()` hardcodes `status="unknown"` for all nodes.
|
|
||||||
|
|
||||||
**Target behavior:** Each node gets its status from the Scanner's `FileStatus` for the source CSV file that contains that entity type.
|
|
||||||
|
|
||||||
**Implementation:**
|
|
||||||
|
|
||||||
**Note:** `build_panorama()` will need an additional `design_dir: str` parameter to convert `DesignDocument.file_path` (absolute) to design-dir-relative paths. This parameter is passed from the route handler which already has access to the project's design directory.
|
|
||||||
|
|
||||||
1. Build `file_status_map: dict[str, str]` from `scan_result.file_statuses`:
|
|
||||||
```python
|
|
||||||
file_status_map = {fs.path: fs.status.value for fs in scan_result.file_statuses}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Define entity-type-to-source-file mapping (from `design/data-architecture/01-entities.csv`):
|
|
||||||
```python
|
|
||||||
_SOURCE_FILES = {
|
|
||||||
"capability": "business-architecture/02-capability-map.csv",
|
|
||||||
"module": "application-architecture/02-modules.csv",
|
|
||||||
"entity": "data-architecture/01-entities.csv",
|
|
||||||
"runtime_component": "technology-architecture/01-runtime-components.csv",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. When creating each node:
|
|
||||||
```python
|
|
||||||
status = file_status_map.get(_SOURCE_FILES.get(node_type, ""), "unknown")
|
|
||||||
```
|
|
||||||
|
|
||||||
**Acceptance criteria:**
|
|
||||||
- Nodes have real status values (ok/sparse/missing/template-residue/placeholder-heavy) instead of "unknown"
|
|
||||||
- Nodes without a known source file retain "unknown"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.2 GAP-B3: DesignDocument Edges — Compound Graph
|
|
||||||
|
|
||||||
**Layer:** Backend
|
|
||||||
**Priority:** P0
|
|
||||||
**File:** `backend/app/modules/graph/domain/entities.py`, `backend/app/modules/graph/application/services.py`
|
|
||||||
|
|
||||||
**Current behavior:** Step 9 in `build_panorama()` tries to match `doc.upstream`/`doc.downstream` (file paths like `./02-capability-map.csv`) against node IDs. Always fails because (a) paths ≠ IDs and (b) DesignDocuments are never added as nodes.
|
|
||||||
|
|
||||||
**Target behavior:**
|
|
||||||
1. DesignDocument objects become graph nodes (type="document", group="cross-layer")
|
|
||||||
2. Entity nodes get a `parent` field pointing to their containing document's `doc_id`
|
|
||||||
3. Two edge types: doc→doc (relation="documents") and entity→entity (existing relations unchanged)
|
|
||||||
|
|
||||||
**Domain model change — GraphNode:**
|
|
||||||
```python
|
|
||||||
@dataclass
|
|
||||||
class GraphNode:
|
|
||||||
id: str
|
|
||||||
type: str # capability | module | entity | runtime_component | document
|
|
||||||
label: str
|
|
||||||
status: str
|
|
||||||
group_id: str
|
|
||||||
parent: str | None = None # doc_id of containing document, if any
|
|
||||||
```
|
|
||||||
|
|
||||||
**Implementation in build_panorama():**
|
|
||||||
|
|
||||||
**Path normalization note:** `DesignDocument.file_path` is an **absolute** path (e.g., `/home/user/project/design/business-architecture/01-scope-and-goals.md`), while `FileStatusEntry.path` is **relative** to the design directory (e.g., `business-architecture/01-scope-and-goals.md`). The `upstream`/`downstream` fields in frontmatter are relative paths (e.g., `../business-architecture/01-scope-and-goals.md`). All paths must be normalized to design-dir-relative format before comparison.
|
|
||||||
|
|
||||||
Helper function:
|
|
||||||
```python
|
|
||||||
def _to_rel_path(doc_file_path: str, design_dir: str) -> str:
|
|
||||||
"""Convert absolute doc.file_path to design-dir-relative path."""
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
try:
|
|
||||||
return str(PurePosixPath(doc_file_path).relative_to(design_dir))
|
|
||||||
except ValueError:
|
|
||||||
return doc_file_path
|
|
||||||
|
|
||||||
def _resolve_ref_path(ref_path: str, doc_rel_path: str) -> str:
|
|
||||||
"""Resolve a relative upstream/downstream ref against the doc's directory."""
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
doc_dir = str(PurePosixPath(doc_rel_path).parent)
|
|
||||||
resolved = str(PurePosixPath(doc_dir) / ref_path)
|
|
||||||
# Normalize away ../ segments
|
|
||||||
parts = []
|
|
||||||
for part in PurePosixPath(resolved).parts:
|
|
||||||
if part == '..':
|
|
||||||
if parts:
|
|
||||||
parts.pop()
|
|
||||||
else:
|
|
||||||
parts.append(part)
|
|
||||||
return str(PurePosixPath(*parts)) if parts else ""
|
|
||||||
```
|
|
||||||
|
|
||||||
Step 5.5 — Create document nodes:
|
|
||||||
```python
|
|
||||||
file_to_doc: dict[str, str] = {}
|
|
||||||
for doc in scan_result.design_documents:
|
|
||||||
doc_rel = _to_rel_path(doc.file_path, design_dir)
|
|
||||||
file_to_doc[doc_rel] = doc.doc_id
|
|
||||||
nodes.append(GraphNode(
|
|
||||||
id=doc.doc_id,
|
|
||||||
type="document",
|
|
||||||
label=doc.title or doc.doc_id,
|
|
||||||
status=file_status_map.get(doc_rel, "unknown"),
|
|
||||||
group_id="cross-layer",
|
|
||||||
))
|
|
||||||
node_ids.add(doc.doc_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
Entity nodes get parent:
|
|
||||||
```python
|
|
||||||
parent_doc_id = file_to_doc.get(_SOURCE_FILES.get(node_type))
|
|
||||||
node = GraphNode(..., parent=parent_doc_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
Step 9 — Fix doc→doc edges using file path mapping (with deduplication):
|
|
||||||
```python
|
|
||||||
path_to_doc = {}
|
|
||||||
doc_rel_paths = {}
|
|
||||||
for doc in scan_result.design_documents:
|
|
||||||
doc_rel = _to_rel_path(doc.file_path, design_dir)
|
|
||||||
path_to_doc[doc_rel] = doc.doc_id
|
|
||||||
doc_rel_paths[doc.doc_id] = doc_rel
|
|
||||||
|
|
||||||
seen_edges: set[tuple[str, str]] = set()
|
|
||||||
for doc in scan_result.design_documents:
|
|
||||||
doc_rel = doc_rel_paths[doc.doc_id]
|
|
||||||
for down_path in doc.downstream:
|
|
||||||
resolved = _resolve_ref_path(down_path, doc_rel)
|
|
||||||
down_doc_id = path_to_doc.get(resolved)
|
|
||||||
if down_doc_id and down_doc_id in node_ids:
|
|
||||||
edge_key = (doc.doc_id, down_doc_id)
|
|
||||||
if edge_key not in seen_edges:
|
|
||||||
seen_edges.add(edge_key)
|
|
||||||
edges.append(GraphEdge(source=doc.doc_id, target=down_doc_id, relation="documents"))
|
|
||||||
# Only process downstream to avoid duplicates (A.downstream=B ↔ B.upstream=A)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Acceptance criteria:**
|
|
||||||
- Document nodes appear in the graph (type="document", group="cross-layer")
|
|
||||||
- Entity nodes have correct `parent` references
|
|
||||||
- doc→doc edges are created based on upstream/downstream file path resolution
|
|
||||||
- API response includes `parent` field (null for nodes without a parent)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.3 GAP-F1: Group-Partitioned Layout + Compound Layout
|
|
||||||
|
|
||||||
**Layer:** Frontend
|
|
||||||
**Priority:** P0
|
|
||||||
**File:** `frontend/src/modules/graph/components/GraphPanorama.vue`
|
|
||||||
|
|
||||||
**Current behavior:** Single `d3.forceCenter()` for all nodes — all groups overlap.
|
|
||||||
|
|
||||||
**Target behavior:** Two layout modes controlled by a toggle.
|
|
||||||
|
|
||||||
#### Default Mode (Document View OFF)
|
|
||||||
|
|
||||||
- Filter out nodes where `type === "document"` and edges where `relation === "documents"`
|
|
||||||
- Use `d3.forceX` / `d3.forceY` per group with strength ~0.3-0.5:
|
|
||||||
```js
|
|
||||||
const groupPositions = {
|
|
||||||
business: { x: 0.50, y: 0.15 },
|
|
||||||
application: { x: 0.50, y: 0.38 },
|
|
||||||
data: { x: 0.30, y: 0.65 },
|
|
||||||
technology: { x: 0.70, y: 0.65 },
|
|
||||||
'cross-layer': { x: 0.50, y: 0.85 },
|
|
||||||
}
|
|
||||||
|
|
||||||
simulation
|
|
||||||
.force('x', d3.forceX(d => (groupPositions[d.group_id] || groupPositions['cross-layer']).x * width).strength(0.4))
|
|
||||||
.force('y', d3.forceY(d => (groupPositions[d.group_id] || groupPositions['cross-layer']).y * height).strength(0.4))
|
|
||||||
.force('collide', d3.forceCollide(30))
|
|
||||||
.force('link', d3.forceLink(edges).id(d => d.id).distance(60))
|
|
||||||
```
|
|
||||||
- Remove the single `forceCenter` — forceX/forceY provide the centering per group
|
|
||||||
|
|
||||||
#### Document View Mode (Toggle ON)
|
|
||||||
|
|
||||||
- Show all nodes including documents
|
|
||||||
- Document nodes rendered as large `<rect>` containers, sized by child count:
|
|
||||||
```js
|
|
||||||
const childCount = nodes.filter(n => n.parent === doc.id).length
|
|
||||||
doc.width = Math.max(150, childCount * 60)
|
|
||||||
doc.height = Math.max(100, childCount * 40)
|
|
||||||
```
|
|
||||||
- Document containers participate in force simulation (with high mass to resist movement)
|
|
||||||
- Entity nodes with `parent` are constrained inside their parent's bounds on each tick:
|
|
||||||
```js
|
|
||||||
simulation.on('tick', () => {
|
|
||||||
nodes.forEach(n => {
|
|
||||||
if (n.parent) {
|
|
||||||
const p = nodeMap[n.parent]
|
|
||||||
const pad = 20
|
|
||||||
n.x = clamp(n.x, p.x - p.width/2 + pad, p.x + p.width/2 - pad)
|
|
||||||
n.y = clamp(n.y, p.y - p.height/2 + pad, p.y + p.height/2 - pad)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
```
|
|
||||||
- doc→doc edges rendered between container borders
|
|
||||||
- entity→entity edges rendered normally (may cross container boundaries)
|
|
||||||
|
|
||||||
#### Toggle
|
|
||||||
|
|
||||||
- State: `showDocumentView: ref(false)`
|
|
||||||
- UI: Toggle button in toolbar area
|
|
||||||
- Switching destroys current simulation and reinitializes with the appropriate mode
|
|
||||||
|
|
||||||
#### Node Rendering
|
|
||||||
|
|
||||||
Shapes (both modes):
|
|
||||||
| type | shape | size |
|
|
||||||
|------|-------|------|
|
|
||||||
| capability | circle | r=18 |
|
|
||||||
| module | rect | 28×28 |
|
|
||||||
| entity | diamond (rotated rect) | 24×24 |
|
|
||||||
| runtime_component | circle | r=14 |
|
|
||||||
| document | large rect container | dynamic |
|
|
||||||
|
|
||||||
Colors (by status):
|
|
||||||
| status | color |
|
|
||||||
|--------|-------|
|
|
||||||
| ok | #4CAF50 |
|
|
||||||
| sparse | #FFC107 |
|
|
||||||
| missing | #F44336 |
|
|
||||||
| template-residue | #FF9800 |
|
|
||||||
| placeholder-heavy | #9C27B0 |
|
|
||||||
| unknown | #9E9E9E |
|
|
||||||
|
|
||||||
Edge styles:
|
|
||||||
| relation | style |
|
|
||||||
|----------|-------|
|
|
||||||
| traces_to | solid, #666 |
|
|
||||||
| depends_on | dashed, #999 |
|
|
||||||
| documents | solid, #42A5F5 (blue) |
|
|
||||||
| integrates_with | dotted, #AB47BC |
|
|
||||||
|
|
||||||
**Acceptance criteria:**
|
|
||||||
- Default mode: nodes cluster by group in 5 distinct regions, no "hairball"
|
|
||||||
- Document view: documents render as containers with entities nested inside
|
|
||||||
- Toggle smoothly switches between modes
|
|
||||||
- Nodes have correct shapes and colors
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.4 GAP-F2: Rich GraphDetail Panel
|
|
||||||
|
|
||||||
**Layer:** Frontend
|
|
||||||
**Priority:** P1
|
|
||||||
**File:** `frontend/src/modules/graph/components/GraphDetail.vue`
|
|
||||||
|
|
||||||
**Current behavior:** Shows only `id, type, status, group_id`.
|
|
||||||
|
|
||||||
**Target behavior:** Full attribute display + related entity list, fetched from detail APIs.
|
|
||||||
|
|
||||||
**API calls by node type:**
|
|
||||||
| node.type | endpoint | response type |
|
|
||||||
|-----------|----------|---------------|
|
|
||||||
| capability | `GET /entities/capabilities/{id}` | CapabilityDetail |
|
|
||||||
| module | `GET /entities/modules/{id}` | ModuleDetail |
|
|
||||||
| entity | `GET /entities/entities/{id}` | EntityDetail |
|
|
||||||
| document | no API call, use node properties | — |
|
|
||||||
| runtime_component | no API call, use node properties | — |
|
|
||||||
|
|
||||||
**Note on non-API types:** For `document` and `runtime_component` nodes that have no detail API, the panel displays the basic `GraphNode` fields (`id`, `type`, `label`, `status`, `group_id`, `parent`). This is acceptable — these types have limited attributes. A detail API for these types can be added later if needed.
|
|
||||||
|
|
||||||
**Panel layout:**
|
|
||||||
```
|
|
||||||
┌─ GraphDetail ──────────────────────────┐
|
|
||||||
│ ✕ Close │
|
|
||||||
│ │
|
|
||||||
│ ● CAP-001 [Edit] │
|
|
||||||
│ capability · business │
|
|
||||||
│ │
|
|
||||||
│ ── Attributes ── │
|
|
||||||
│ name: 项目管理 │
|
|
||||||
│ description: 管理项目生命周期... │
|
|
||||||
│ owner: ... │
|
|
||||||
│ │
|
|
||||||
│ ── Related Entities ── │
|
|
||||||
│ → MOD-001 项目管理模块 (traces_to) │
|
|
||||||
│ → MOD-003 扫描服务 (traces_to) │
|
|
||||||
│ ← DOC-005 scope文档 (documents) │
|
|
||||||
│ │
|
|
||||||
└────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Behavior:**
|
|
||||||
- On node selection: if type has detail API, fetch it (show spinner while loading)
|
|
||||||
- Error fallback: show basic 4 fields
|
|
||||||
- Clicking a related entity → updates selected node → panel refreshes
|
|
||||||
- Related entities are derived from `graphView.edges` where source or target matches the selected node
|
|
||||||
|
|
||||||
**Acceptance criteria:**
|
|
||||||
- Detail API is called for capability/module/entity nodes
|
|
||||||
- All attributes from the API response are displayed
|
|
||||||
- Related entities list is clickable and navigates the graph
|
|
||||||
- Graceful fallback on API error
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.5 GAP-F5: Edit Button in GraphDetail
|
|
||||||
|
|
||||||
**Layer:** Frontend
|
|
||||||
**Priority:** P1
|
|
||||||
**File:** `frontend/src/modules/graph/components/GraphDetail.vue`
|
|
||||||
|
|
||||||
**Current behavior:** No way to jump from graph node to editor.
|
|
||||||
|
|
||||||
**Target behavior:** "Edit" button in the detail panel header, navigating to the editor page.
|
|
||||||
|
|
||||||
**Implementation:**
|
|
||||||
- Button visible only when `source_file` is available:
|
|
||||||
- From detail API response (CapabilityDetail, ModuleDetail, EntityDetail have this field)
|
|
||||||
- For document nodes: use node properties
|
|
||||||
- For runtime_component: use static `_SOURCE_FILES` mapping
|
|
||||||
- Click action:
|
|
||||||
```js
|
|
||||||
router.push({
|
|
||||||
path: `/projects/${projectId}/editor`,
|
|
||||||
query: { file: sourceFilePath }
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Acceptance criteria:**
|
|
||||||
- Edit button appears for nodes with known source files
|
|
||||||
- Clicking navigates to editor with correct file pre-selected
|
|
||||||
- Button hidden for nodes without source file info
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.6 GAP-F4: Back to Panorama Button
|
|
||||||
|
|
||||||
**Layer:** Frontend
|
|
||||||
**Priority:** P1
|
|
||||||
**File:** `frontend/src/modules/graph/components/GraphPanorama.vue`
|
|
||||||
|
|
||||||
**Current behavior:** Double-click drills down to neighbor subgraph; no way to return.
|
|
||||||
|
|
||||||
**Target behavior:** Floating "back" button in drill-down mode.
|
|
||||||
|
|
||||||
**Implementation:**
|
|
||||||
- State: `isDrillDown: ref(false)`, `drillDownNodeLabel: ref('')`
|
|
||||||
- On double-click drill-down: set `isDrillDown = true`, store the node label
|
|
||||||
- Render (conditionally):
|
|
||||||
```html
|
|
||||||
<div v-if="isDrillDown" class="drill-down-bar">
|
|
||||||
<button @click="returnToPanorama">← 返回全景图</button>
|
|
||||||
<span>当前: {{ drillDownNodeLabel }}</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
- `returnToPanorama()`: calls existing `loadGraph()` method, sets `isDrillDown = false`
|
|
||||||
|
|
||||||
**Acceptance criteria:**
|
|
||||||
- Button appears only in drill-down mode
|
|
||||||
- Shows which node was drilled into
|
|
||||||
- Clicking restores the full panorama graph
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.7 GAP-F3: Graph Legend
|
|
||||||
|
|
||||||
**Layer:** Frontend
|
|
||||||
**Priority:** P1
|
|
||||||
**File:** New `frontend/src/modules/graph/components/GraphLegend.vue`, imported in `GraphPanorama.vue`
|
|
||||||
|
|
||||||
**Design:**
|
|
||||||
```
|
|
||||||
┌─ Legend ────────────────────┐
|
|
||||||
│ Shapes Status │
|
|
||||||
│ ○ Capability ● OK │
|
|
||||||
│ ■ Module ● Sparse │
|
|
||||||
│ ◇ Entity ● Missing │
|
|
||||||
│ ○ Runtime Comp ● Template│
|
|
||||||
│ ▭ Document ● Placeholder │
|
|
||||||
│ ● Unknown │
|
|
||||||
│ Edges │
|
|
||||||
│ ── traces_to │
|
|
||||||
│ -- depends_on │
|
|
||||||
│ ━━ documents │
|
|
||||||
│ ·· integrates_with │
|
|
||||||
└────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Implementation:**
|
|
||||||
- Positioned bottom-right of the graph canvas, `position: absolute`
|
|
||||||
- Semi-transparent background (`rgba(255,255,255,0.9)`)
|
|
||||||
- Collapsible: click title to toggle expand/collapse
|
|
||||||
- Default: expanded
|
|
||||||
- Uses actual SVG shapes/colors matching the graph rendering
|
|
||||||
|
|
||||||
**Acceptance criteria:**
|
|
||||||
- Legend renders in bottom-right corner
|
|
||||||
- Shows all node shapes, status colors, and edge styles
|
|
||||||
- Collapsible
|
|
||||||
- Does not block graph interaction (pointer-events on legend only)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Files Changed
|
|
||||||
|
|
||||||
| File | Change Type | Gaps |
|
|
||||||
|------|-------------|------|
|
|
||||||
| `backend/app/modules/graph/domain/entities.py` | Modify | B3 |
|
|
||||||
| `backend/app/modules/graph/application/services.py` | Modify | B1, B3 |
|
|
||||||
| `frontend/src/modules/graph/components/GraphPanorama.vue` | Modify | F1, F4 |
|
|
||||||
| `frontend/src/modules/graph/components/GraphDetail.vue` | Modify | F2, F5 |
|
|
||||||
| `frontend/src/modules/graph/components/GraphLegend.vue` | New | F3 |
|
|
||||||
| `frontend/src/shared/types/api.ts` | Modify | B3 (add `parent` field to `GraphNode` interface) |
|
|
||||||
|
|
||||||
## 5. Out of Scope
|
|
||||||
|
|
||||||
The following gaps from the full analysis are NOT addressed in this spec:
|
|
||||||
- GAP-X1 (layered drill-down) — Phase 2
|
|
||||||
- GAP-X2 (implementation progress on nodes) — Phase 2
|
|
||||||
- GAP-B2 (more entity types as nodes) — separate effort
|
|
||||||
- GAP-B4 (recursive impact traversal) — separate effort
|
|
||||||
- GAP-B5 (LLM-based impl tracking) — separate effort
|
|
||||||
- GAP-F6 (route path alignment) — cosmetic
|
|
||||||
- GAP-F7 (panel position conflict) — cosmetic
|
|
||||||
|
|
||||||
## 6. Constraints
|
|
||||||
|
|
||||||
1. Do not modify files under `design/` — those are the source of truth
|
|
||||||
2. All changes must pass `vue-tsc` (frontend) and Python type checking (backend)
|
|
||||||
3. Implementation order follows DDD layers: Domain → Infrastructure → Application → Interfaces
|
|
||||||
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 198 KiB |
|
|
@ -1,35 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
# 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` 选择器 |
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
# 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/`
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Arch Design Dashboard</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2620
frontend/package-lock.json
generated
|
|
@ -1,26 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="app-layout">
|
|
||||||
<aside class="sidebar">
|
|
||||||
<h2>Arch Design Dashboard</h2>
|
|
||||||
<ProjectSidebar />
|
|
||||||
</aside>
|
|
||||||
<main class="content">
|
|
||||||
<router-view />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import ProjectSidebar from '@/modules/project/components/ProjectSidebar.vue'
|
|
||||||
</script>
|
|
||||||
5
frontend/src/env.d.ts
vendored
|
|
@ -1,5 +0,0 @@
|
||||||
declare module '*.vue' {
|
|
||||||
import type { DefineComponent } from 'vue'
|
|
||||||
const component: DefineComponent<{}, {}, any>
|
|
||||||
export default component
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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')
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import api from '@/shared/api'
|
|
||||||
import type { EditableFile, ImpactResult, ScanResult } from '@/shared/types/api'
|
|
||||||
|
|
||||||
export async function getFile(projectId: string, path: string): Promise<EditableFile> {
|
|
||||||
const { data } = await api.get<EditableFile>(`/projects/${projectId}/files/${path}`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveFile(projectId: string, path: string, content: string): Promise<ScanResult> {
|
|
||||||
const { data } = await api.put<ScanResult>(`/projects/${projectId}/files/${path}`, content, {
|
|
||||||
headers: { 'Content-Type': 'text/plain' },
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getFileImpact(projectId: string, path: string): Promise<ImpactResult> {
|
|
||||||
const { data } = await api.get<ImpactResult>(`/projects/${projectId}/files/${path}/impact`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="csv-editor">
|
|
||||||
<div class="toolbar">
|
|
||||||
<button class="primary" @click="addRow">添加行</button>
|
|
||||||
<button class="primary" @click="$emit('save', serialize())">保存</button>
|
|
||||||
</div>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th v-for="(h, i) in headers" :key="i">{{ h }}</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="(row, ri) in rows" :key="ri">
|
|
||||||
<td v-for="(cell, ci) in row" :key="ci" contenteditable @blur="updateCell(ri, ci, $event)">{{ cell }}</td>
|
|
||||||
<td><button class="danger" @click="removeRow(ri)">删除</button></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps<{ content: string }>()
|
|
||||||
defineEmits<{ save: [content: string] }>()
|
|
||||||
|
|
||||||
const headers = ref<string[]>([])
|
|
||||||
const rows = ref<string[][]>([])
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const lines = props.content.trim().split('\n')
|
|
||||||
if (lines.length > 0) {
|
|
||||||
headers.value = lines[0].split(',')
|
|
||||||
rows.value = lines.slice(1).map(l => l.split(','))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function updateCell(ri: number, ci: number, event: Event) {
|
|
||||||
rows.value[ri][ci] = (event.target as HTMLElement).textContent || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRow() {
|
|
||||||
rows.value.push(headers.value.map(() => ''))
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeRow(index: number) {
|
|
||||||
rows.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function serialize(): string {
|
|
||||||
return [headers.value.join(','), ...rows.value.map(r => r.join(','))].join('\n') + '\n'
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.toolbar { margin-bottom: 12px; display: flex; gap: 8px; }
|
|
||||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
||||||
th, td { border: 1px solid #e0e0e0; padding: 6px 8px; text-align: left; }
|
|
||||||
th { background: #f5f5f5; font-weight: 600; }
|
|
||||||
td[contenteditable] { cursor: text; }
|
|
||||||
td[contenteditable]:focus { outline: 2px solid #1976D2; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="editor-page">
|
|
||||||
<h1>文件编辑器</h1>
|
|
||||||
<div v-if="!currentFile" class="empty">
|
|
||||||
<p>选择一个文件开始编辑</p>
|
|
||||||
<input v-model="filePath" placeholder="输入相对路径,如 business-architecture/02-capability-map.csv" />
|
|
||||||
<button class="primary" @click="load">打开</button>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<p class="meta">{{ currentFile.path }} ({{ currentFile.format }})</p>
|
|
||||||
<CsvEditor v-if="currentFile.format === 'csv'" :content="currentFile.content" @save="handleSave" />
|
|
||||||
<MdEditor v-else :initial-content="currentFile.content" @save="handleSave" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import { useEditorStore } from '../composables/useEditor'
|
|
||||||
import CsvEditor from './CsvEditor.vue'
|
|
||||||
import MdEditor from './MdEditor.vue'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const store = useEditorStore()
|
|
||||||
const { currentFile } = storeToRefs(store)
|
|
||||||
|
|
||||||
const filePath = ref('')
|
|
||||||
|
|
||||||
function load() {
|
|
||||||
if (filePath.value) {
|
|
||||||
store.loadFile(route.params.id as string, filePath.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSave(content: string) {
|
|
||||||
store.saveFile(route.params.id as string, currentFile.value!.path, content)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
h1 { margin-bottom: 16px; }
|
|
||||||
.meta { font-size: 13px; color: #666; margin-bottom: 12px; }
|
|
||||||
.empty { text-align: center; padding: 48px; }
|
|
||||||
.empty input { margin: 12px 0; max-width: 500px; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="md-editor">
|
|
||||||
<div class="toolbar">
|
|
||||||
<button class="primary" @click="$emit('save', content)">保存</button>
|
|
||||||
</div>
|
|
||||||
<div class="editor-panes">
|
|
||||||
<textarea v-model="content" class="editor-input"></textarea>
|
|
||||||
<div class="editor-preview" v-html="preview"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps<{ initialContent: string }>()
|
|
||||||
defineEmits<{ save: [content: string] }>()
|
|
||||||
|
|
||||||
const content = ref(props.initialContent)
|
|
||||||
|
|
||||||
const preview = computed(() => {
|
|
||||||
return content.value
|
|
||||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
|
||||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
|
||||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
||||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
||||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
|
||||||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
|
||||||
.replace(/\n/g, '<br>')
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.toolbar { margin-bottom: 12px; }
|
|
||||||
.editor-panes { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; height: calc(100vh - 200px); }
|
|
||||||
.editor-input { font-family: monospace; font-size: 14px; resize: none; padding: 12px; border: 1px solid #e0e0e0; border-radius: 4px; }
|
|
||||||
.editor-preview { padding: 12px; border: 1px solid #e0e0e0; border-radius: 4px; overflow-y: auto; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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<EditableFile | null>(null)
|
|
||||||
const impactResult = ref<ImpactResult | null>(null)
|
|
||||||
const saving = ref(false)
|
|
||||||
const error = ref<string | null>(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 }
|
|
||||||
})
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export type { EditableFile, ImpactResult, ImplProgress } from '@/shared/types/api'
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
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<ScanResult> {
|
|
||||||
const { data } = await api.post<ScanResult>(`/projects/${projectId}/scan`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getLatestScan(projectId: string): Promise<ScanResult> {
|
|
||||||
const { data } = await api.get<ScanResult>(`/projects/${projectId}/scan`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getGraph(projectId: string): Promise<GraphView> {
|
|
||||||
const { data } = await api.get<GraphView>(`/projects/${projectId}/graph`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getNodeNeighbors(projectId: string, nodeId: string): Promise<GraphView> {
|
|
||||||
const { data } = await api.get<GraphView>(`/projects/${projectId}/graph/nodes/${nodeId}/neighbors`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listCapabilities(projectId: string): Promise<Capability[]> {
|
|
||||||
const { data } = await api.get<Capability[]>(`/projects/${projectId}/entities/capabilities`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listModules(projectId: string): Promise<DesignModule[]> {
|
|
||||||
const { data } = await api.get<DesignModule[]>(`/projects/${projectId}/entities/modules`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listEntities(projectId: string): Promise<Entity[]> {
|
|
||||||
const { data } = await api.get<Entity[]>(`/projects/${projectId}/entities/entities`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCapabilityDetail(projectId: string, capId: string): Promise<CapabilityDetail> {
|
|
||||||
const { data } = await api.get<CapabilityDetail>(`/projects/${projectId}/entities/capabilities/${capId}`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getModuleDetail(projectId: string, modId: string): Promise<ModuleDetail> {
|
|
||||||
const { data } = await api.get<ModuleDetail>(`/projects/${projectId}/entities/modules/${modId}`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getEntityDetail(projectId: string, entId: string): Promise<EntityDetail> {
|
|
||||||
const { data } = await api.get<EntityDetail>(`/projects/${projectId}/entities/entities/${entId}`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function evaluateImplProgress(projectId: string): Promise<ImplProgress[]> {
|
|
||||||
const { data } = await api.post<ImplProgress[]>(`/projects/${projectId}/impl-progress`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getImplProgress(projectId: string): Promise<ImplProgress[]> {
|
|
||||||
const { data } = await api.get<ImplProgress[]>(`/projects/${projectId}/impl-progress`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
@ -1,376 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="graph-detail" v-if="node">
|
|
||||||
<div class="detail-header">
|
|
||||||
<button class="close-btn" @click="emit('close')">✕ Close</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-identity">
|
|
||||||
<div class="identity-row">
|
|
||||||
<span class="node-id">● {{ node.id }}</span>
|
|
||||||
<button v-if="sourceFile" class="edit-btn" @click="openEditor">[Edit]</button>
|
|
||||||
</div>
|
|
||||||
<div class="node-meta">
|
|
||||||
{{ node.type }} · <span :style="{ color: statusColor }">{{ node.status }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading spinner -->
|
|
||||||
<div v-if="loading" class="loading-section">
|
|
||||||
<span class="spinner"></span> Loading details...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error message -->
|
|
||||||
<div v-if="error" class="error-section">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Attributes section -->
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-title">—— Attributes ——</div>
|
|
||||||
<div v-for="(value, key) in displayAttributes" :key="key" class="field">
|
|
||||||
<span class="label">{{ key }}:</span> {{ formatValue(value) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Related Entities section -->
|
|
||||||
<div v-if="relatedEntities.length > 0" class="section">
|
|
||||||
<div class="section-title">—— Related Entities ——</div>
|
|
||||||
<div
|
|
||||||
v-for="rel in relatedEntities"
|
|
||||||
:key="rel.nodeId + rel.relation + rel.direction"
|
|
||||||
class="related-entity"
|
|
||||||
@click="onRelatedEntityClick(rel.nodeId)"
|
|
||||||
>
|
|
||||||
<span class="direction">{{ rel.direction === 'outgoing' ? '→' : '←' }}</span>
|
|
||||||
{{ rel.label }}
|
|
||||||
<span class="relation-tag">({{ rel.relation }})</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch, computed } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { getCapabilityDetail, getModuleDetail, getEntityDetail } from '../api'
|
|
||||||
import type { GraphNode, GraphView } from '@/shared/types/api'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
node: GraphNode | null
|
|
||||||
graphView: GraphView | null
|
|
||||||
projectId: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{ close: []; selectNode: [node: GraphNode] }>()
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const SOURCE_FILES: Record<string, string> = {
|
|
||||||
capability: 'business-architecture/02-capability-map.csv',
|
|
||||||
module: 'application-architecture/02-modules.csv',
|
|
||||||
entity: 'data-architecture/01-entities.csv',
|
|
||||||
runtime_component: 'technology-architecture/01-runtime-components.csv',
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
|
||||||
ok: '#4CAF50',
|
|
||||||
sparse: '#FFC107',
|
|
||||||
missing: '#F44336',
|
|
||||||
'template-residue': '#FF9800',
|
|
||||||
'placeholder-heavy': '#9C27B0',
|
|
||||||
unknown: '#9E9E9E',
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
const detailData = ref<Record<string, unknown> | null>(null)
|
|
||||||
|
|
||||||
const statusColor = computed(() =>
|
|
||||||
STATUS_COLORS[props.node?.status || 'unknown'] || '#9E9E9E',
|
|
||||||
)
|
|
||||||
|
|
||||||
const sourceFile = computed(() => {
|
|
||||||
if (!props.node) return null
|
|
||||||
return SOURCE_FILES[props.node.type] || null
|
|
||||||
})
|
|
||||||
|
|
||||||
const displayAttributes = computed<Record<string, unknown>>(() => {
|
|
||||||
if (detailData.value) {
|
|
||||||
return detailData.value
|
|
||||||
}
|
|
||||||
// Fallback: basic fields from node
|
|
||||||
if (!props.node) return {}
|
|
||||||
return {
|
|
||||||
id: props.node.id,
|
|
||||||
type: props.node.type,
|
|
||||||
status: props.node.status,
|
|
||||||
group_id: props.node.group_id,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
interface RelatedEntity {
|
|
||||||
nodeId: string
|
|
||||||
label: string
|
|
||||||
relation: string
|
|
||||||
direction: 'outgoing' | 'incoming'
|
|
||||||
}
|
|
||||||
|
|
||||||
const relatedEntities = computed<RelatedEntity[]>(() => {
|
|
||||||
if (!props.node || !props.graphView) return []
|
|
||||||
|
|
||||||
const nodeId = props.node.id
|
|
||||||
const result: RelatedEntity[] = []
|
|
||||||
|
|
||||||
for (const edge of props.graphView.edges) {
|
|
||||||
if (edge.source === nodeId) {
|
|
||||||
const targetNode = props.graphView.nodes.find(n => n.id === edge.target)
|
|
||||||
result.push({
|
|
||||||
nodeId: edge.target,
|
|
||||||
label: targetNode ? `${targetNode.id} ${targetNode.label}` : edge.target,
|
|
||||||
relation: edge.relation,
|
|
||||||
direction: 'outgoing',
|
|
||||||
})
|
|
||||||
} else if (edge.target === nodeId) {
|
|
||||||
const sourceNode = props.graphView.nodes.find(n => n.id === edge.source)
|
|
||||||
result.push({
|
|
||||||
nodeId: edge.source,
|
|
||||||
label: sourceNode ? `${sourceNode.id} ${sourceNode.label}` : edge.source,
|
|
||||||
relation: edge.relation,
|
|
||||||
direction: 'incoming',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatValue(value: unknown): string {
|
|
||||||
if (value === null || value === undefined) return '-'
|
|
||||||
if (Array.isArray(value)) return value.join(', ')
|
|
||||||
if (typeof value === 'object') return JSON.stringify(value)
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractAttributes(data: Record<string, unknown>): Record<string, unknown> {
|
|
||||||
// The detail API returns a wrapper like { capability: {...}, modules: [...] }
|
|
||||||
// We want to extract the primary entity's fields as attributes
|
|
||||||
const attrs: Record<string, unknown> = {}
|
|
||||||
for (const [key, val] of Object.entries(data)) {
|
|
||||||
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
|
|
||||||
// This is the primary entity object — flatten its fields
|
|
||||||
for (const [innerKey, innerVal] of Object.entries(val as Record<string, unknown>)) {
|
|
||||||
attrs[innerKey] = innerVal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Skip arrays (they are related collections, shown in related entities)
|
|
||||||
}
|
|
||||||
// If no nested objects found, just use all fields
|
|
||||||
if (Object.keys(attrs).length === 0) {
|
|
||||||
Object.assign(attrs, data)
|
|
||||||
}
|
|
||||||
return attrs
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchDetail() {
|
|
||||||
if (!props.node || !props.projectId) return
|
|
||||||
|
|
||||||
const nodeType = props.node.type
|
|
||||||
const nodeId = props.node.id
|
|
||||||
|
|
||||||
// Only fetch detail for types that have detail APIs
|
|
||||||
if (!['capability', 'module', 'entity'].includes(nodeType)) {
|
|
||||||
detailData.value = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
detailData.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
let data: Record<string, unknown>
|
|
||||||
switch (nodeType) {
|
|
||||||
case 'capability':
|
|
||||||
data = await getCapabilityDetail(props.projectId, nodeId) as unknown as Record<string, unknown>
|
|
||||||
break
|
|
||||||
case 'module':
|
|
||||||
data = await getModuleDetail(props.projectId, nodeId) as unknown as Record<string, unknown>
|
|
||||||
break
|
|
||||||
case 'entity':
|
|
||||||
data = await getEntityDetail(props.projectId, nodeId) as unknown as Record<string, unknown>
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
detailData.value = extractAttributes(data)
|
|
||||||
} catch (err) {
|
|
||||||
error.value = 'Failed to load detail. Showing basic fields.'
|
|
||||||
detailData.value = null
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.node,
|
|
||||||
() => {
|
|
||||||
fetchDetail()
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
function onRelatedEntityClick(nodeId: string) {
|
|
||||||
const found = props.graphView?.nodes.find(n => n.id === nodeId)
|
|
||||||
if (found) emit('selectNode', found)
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditor() {
|
|
||||||
if (!sourceFile.value) return
|
|
||||||
router.push({
|
|
||||||
path: `/projects/${props.projectId}/editor`,
|
|
||||||
query: { file: sourceFile.value },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.graph-detail {
|
|
||||||
position: fixed;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 360px;
|
|
||||||
background: white;
|
|
||||||
border-left: 1px solid #e0e0e0;
|
|
||||||
padding: 16px;
|
|
||||||
z-index: 100;
|
|
||||||
box-shadow: -4px 0 8px rgba(0, 0, 0, 0.1);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-header {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn:hover {
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-identity {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.identity-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-id {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-btn {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid #1976d2;
|
|
||||||
color: #1976d2;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-btn:hover {
|
|
||||||
background: #e3f2fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-meta {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-section {
|
|
||||||
padding: 12px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: inline-block;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border: 2px solid #ccc;
|
|
||||||
border-top-color: #1976d2;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-right: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-section {
|
|
||||||
padding: 8px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #e65100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #999;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field {
|
|
||||||
margin-bottom: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field .label {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.related-entity {
|
|
||||||
padding: 6px 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.related-entity:hover {
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.direction {
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relation-tag {
|
|
||||||
color: #999;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="graph-legend" :class="{ collapsed: !expanded }">
|
|
||||||
<div class="legend-header" @click="expanded = !expanded">
|
|
||||||
<span>图例</span>
|
|
||||||
<span class="toggle">{{ expanded ? '▼' : '▶' }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="expanded" class="legend-body">
|
|
||||||
<div class="legend-section">
|
|
||||||
<div class="legend-title">形状</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><circle cx="10" cy="10" r="8" fill="#9E9E9E"/></svg>
|
|
||||||
<span>Capability</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><rect x="2" y="2" width="16" height="16" rx="2" fill="#9E9E9E"/></svg>
|
|
||||||
<span>Module</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><polygon points="10,2 18,10 10,18 2,10" fill="#9E9E9E"/></svg>
|
|
||||||
<span>Entity</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><circle cx="10" cy="10" r="6" fill="#9E9E9E"/></svg>
|
|
||||||
<span>Runtime</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><rect x="1" y="4" width="18" height="12" rx="2" fill="none" stroke="#9E9E9E" stroke-width="2"/></svg>
|
|
||||||
<span>Document</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="legend-section">
|
|
||||||
<div class="legend-title">状态</div>
|
|
||||||
<div class="legend-item" v-for="s in statuses" :key="s.label">
|
|
||||||
<svg width="20" height="20"><circle cx="10" cy="10" r="6" :fill="s.color"/></svg>
|
|
||||||
<span>{{ s.label }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="legend-section">
|
|
||||||
<div class="legend-title">边线</div>
|
|
||||||
<div class="legend-item" v-for="e in edgeTypes" :key="e.label">
|
|
||||||
<svg width="40" height="12">
|
|
||||||
<line x1="0" y1="6" x2="40" y2="6" :stroke="e.color" stroke-width="2" :stroke-dasharray="e.dash" />
|
|
||||||
</svg>
|
|
||||||
<span>{{ e.label }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const expanded = ref(true)
|
|
||||||
|
|
||||||
const statuses = [
|
|
||||||
{ label: 'OK', color: '#4CAF50' },
|
|
||||||
{ label: 'Sparse', color: '#FFC107' },
|
|
||||||
{ label: 'Missing', color: '#F44336' },
|
|
||||||
{ label: 'Template Residue', color: '#FF9800' },
|
|
||||||
{ label: 'Placeholder Heavy', color: '#9C27B0' },
|
|
||||||
{ label: 'Unknown', color: '#9E9E9E' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const edgeTypes = [
|
|
||||||
{ label: 'traces_to', color: '#666', dash: '0' },
|
|
||||||
{ label: 'depends_on', color: '#999', dash: '6,3' },
|
|
||||||
{ label: 'documents', color: '#42A5F5', dash: '0' },
|
|
||||||
{ label: 'integrates_with', color: '#AB47BC', dash: '4,2' },
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.graph-legend {
|
|
||||||
position: absolute; bottom: 16px; right: 16px;
|
|
||||||
background: rgba(255,255,255,0.92); border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
|
||||||
padding: 8px 12px; z-index: 10; min-width: 180px;
|
|
||||||
font-size: 12px; pointer-events: auto;
|
|
||||||
}
|
|
||||||
.legend-header {
|
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
|
||||||
cursor: pointer; font-weight: 600; font-size: 13px; padding-bottom: 4px;
|
|
||||||
}
|
|
||||||
.toggle { font-size: 10px; color: #999; }
|
|
||||||
.legend-body { display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
|
|
||||||
.legend-section { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.legend-title { font-weight: 600; color: #666; margin-bottom: 2px; }
|
|
||||||
.legend-item { display: flex; align-items: center; gap: 6px; }
|
|
||||||
.legend-item span { color: #333; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,359 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="graph-panorama">
|
|
||||||
<div v-if="loading" class="loading-overlay">扫描中...</div>
|
|
||||||
|
|
||||||
<div v-if="scanResult" class="scan-summary">
|
|
||||||
<div class="summary-item">文件 <strong>{{ scanResult.summary.total_files }}</strong></div>
|
|
||||||
<div class="summary-item" style="color: var(--color-ok)">OK <strong>{{ scanResult.summary.ok }}</strong></div>
|
|
||||||
<div class="summary-item" style="color: var(--color-sparse)">Sparse <strong>{{ scanResult.summary.sparse }}</strong></div>
|
|
||||||
<div class="summary-item" style="color: var(--color-missing)">Missing <strong>{{ scanResult.summary.missing }}</strong></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!isDrillDown" class="toolbar">
|
|
||||||
<button class="toggle-btn" :class="{ active: showDocumentView }" @click="toggleDocumentView">
|
|
||||||
{{ showDocumentView ? '默认视图' : '文档视图' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<svg ref="svgRef" class="graph-svg"></svg>
|
|
||||||
|
|
||||||
<GraphLegend />
|
|
||||||
|
|
||||||
<div v-if="isDrillDown" class="drill-down-bar">
|
|
||||||
<button @click="returnToPanorama" class="back-btn">← 返回全景图</button>
|
|
||||||
<span class="drill-down-label">当前: {{ drillDownNodeLabel }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GraphDetail
|
|
||||||
:node="selectedNode"
|
|
||||||
:graphView="graphView"
|
|
||||||
:projectId="(route.params.id as string)"
|
|
||||||
@close="clearSelection"
|
|
||||||
@selectNode="store.selectNode"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, watch } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import * as d3 from 'd3'
|
|
||||||
import { useGraphStore } from '../composables/useGraph'
|
|
||||||
import GraphDetail from './GraphDetail.vue'
|
|
||||||
import GraphLegend from './GraphLegend.vue'
|
|
||||||
import type { GraphNode, GraphEdge } from '@/shared/types/api'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const store = useGraphStore()
|
|
||||||
const { graphView, selectedNode, scanResult, loading } = storeToRefs(store)
|
|
||||||
const { clearSelection } = store
|
|
||||||
|
|
||||||
const svgRef = ref<SVGSVGElement | null>(null)
|
|
||||||
|
|
||||||
const showDocumentView = ref(false)
|
|
||||||
const isDrillDown = ref(false)
|
|
||||||
const drillDownNodeLabel = ref('')
|
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
|
||||||
ok: '#4CAF50', sparse: '#FFC107', missing: '#F44336',
|
|
||||||
'template-residue': '#FF9800', 'placeholder-heavy': '#9C27B0', unknown: '#9E9E9E',
|
|
||||||
}
|
|
||||||
|
|
||||||
const GROUP_POSITIONS: Record<string, { x: number; y: number }> = {
|
|
||||||
business: { x: 0.50, y: 0.15 },
|
|
||||||
application: { x: 0.50, y: 0.38 },
|
|
||||||
data: { x: 0.30, y: 0.65 },
|
|
||||||
technology: { x: 0.70, y: 0.65 },
|
|
||||||
'cross-layer': { x: 0.50, y: 0.85 },
|
|
||||||
}
|
|
||||||
|
|
||||||
const EDGE_COLORS: Record<string, string> = {
|
|
||||||
traces_to: '#666',
|
|
||||||
depends_on: '#999',
|
|
||||||
documents: '#42A5F5',
|
|
||||||
integrates_with: '#AB47BC',
|
|
||||||
}
|
|
||||||
|
|
||||||
const EDGE_STYLES: Record<string, string> = {
|
|
||||||
traces_to: '0', depends_on: '6,3', integrates_with: '4,2', documents: '0',
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNodeColor(status: string): string {
|
|
||||||
return STATUS_COLORS[status] || '#9E9E9E'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SimNode extends GraphNode, d3.SimulationNodeDatum {
|
|
||||||
w?: number
|
|
||||||
h?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleDocumentView() {
|
|
||||||
showDocumentView.value = !showDocumentView.value
|
|
||||||
drawGraph()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function returnToPanorama() {
|
|
||||||
const projectId = route.params.id as string
|
|
||||||
isDrillDown.value = false
|
|
||||||
drillDownNodeLabel.value = ''
|
|
||||||
await store.loadGraph(projectId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawGraph() {
|
|
||||||
if (!svgRef.value || !graphView.value) return
|
|
||||||
|
|
||||||
const svg = d3.select(svgRef.value)
|
|
||||||
svg.selectAll('*').remove()
|
|
||||||
|
|
||||||
const width = svgRef.value.clientWidth || 800
|
|
||||||
const height = svgRef.value.clientHeight || 600
|
|
||||||
|
|
||||||
svg.attr('width', width).attr('height', height)
|
|
||||||
|
|
||||||
const g = svg.append('g')
|
|
||||||
|
|
||||||
// Zoom behavior
|
|
||||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
|
||||||
.scaleExtent([0.1, 4])
|
|
||||||
.on('zoom', (event) => { g.attr('transform', event.transform) })
|
|
||||||
svg.call(zoom)
|
|
||||||
|
|
||||||
// Filter nodes and edges based on view mode
|
|
||||||
let filteredNodes: GraphNode[]
|
|
||||||
let filteredEdges: GraphEdge[]
|
|
||||||
|
|
||||||
if (showDocumentView.value) {
|
|
||||||
filteredNodes = graphView.value.nodes.map(n => ({ ...n }))
|
|
||||||
filteredEdges = graphView.value.edges.map(e => ({ ...e }))
|
|
||||||
} else {
|
|
||||||
filteredNodes = graphView.value.nodes.filter(n => n.type !== 'document').map(n => ({ ...n }))
|
|
||||||
filteredEdges = graphView.value.edges.filter(e => e.relation !== 'documents').map(e => ({ ...e }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const simNodes: SimNode[] = filteredNodes.map(n => ({ ...n } as SimNode))
|
|
||||||
|
|
||||||
// Build node map for parent lookups
|
|
||||||
const nodeMap: Record<string, SimNode> = {}
|
|
||||||
for (const n of simNodes) {
|
|
||||||
nodeMap[n.id] = n
|
|
||||||
}
|
|
||||||
|
|
||||||
// For document view, compute document container sizes
|
|
||||||
if (showDocumentView.value) {
|
|
||||||
for (const n of simNodes) {
|
|
||||||
if (n.type === 'document') {
|
|
||||||
const childCount = simNodes.filter(c => c.parent === n.id).length
|
|
||||||
n.w = Math.max(150, childCount * 60)
|
|
||||||
n.h = Math.max(100, childCount * 40)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const simEdges = filteredEdges.map(e => ({
|
|
||||||
...e,
|
|
||||||
source: e.source,
|
|
||||||
target: e.target,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Force simulation with group-partitioned layout
|
|
||||||
const simulation = d3.forceSimulation(simNodes as any)
|
|
||||||
.force('link', d3.forceLink(simEdges as any).id((d: any) => d.id).distance(60))
|
|
||||||
.force('charge', d3.forceManyBody().strength(-150))
|
|
||||||
.force('x', d3.forceX((d: any) =>
|
|
||||||
(GROUP_POSITIONS[d.group_id] || GROUP_POSITIONS['cross-layer']).x * width
|
|
||||||
).strength(0.4))
|
|
||||||
.force('y', d3.forceY((d: any) =>
|
|
||||||
(GROUP_POSITIONS[d.group_id] || GROUP_POSITIONS['cross-layer']).y * height
|
|
||||||
).strength(0.4))
|
|
||||||
.force('collide', d3.forceCollide(30))
|
|
||||||
|
|
||||||
// Render group labels
|
|
||||||
const groupLabels = g.append('g').attr('class', 'group-labels')
|
|
||||||
for (const [groupId, pos] of Object.entries(GROUP_POSITIONS)) {
|
|
||||||
groupLabels.append('text')
|
|
||||||
.attr('x', pos.x * width)
|
|
||||||
.attr('y', pos.y * height - 40)
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('font-size', '14px')
|
|
||||||
.attr('font-weight', 'bold')
|
|
||||||
.attr('fill', '#aaa')
|
|
||||||
.attr('pointer-events', 'none')
|
|
||||||
.text(groupId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw edges
|
|
||||||
const link = g.append('g')
|
|
||||||
.selectAll('line')
|
|
||||||
.data(simEdges)
|
|
||||||
.join('line')
|
|
||||||
.attr('stroke', (d: any) => EDGE_COLORS[d.relation] || '#999')
|
|
||||||
.attr('stroke-opacity', 0.6)
|
|
||||||
.attr('stroke-width', 1.5)
|
|
||||||
.attr('stroke-dasharray', (d: any) => EDGE_STYLES[d.relation] || '0')
|
|
||||||
|
|
||||||
// Draw nodes
|
|
||||||
const node = g.append('g')
|
|
||||||
.selectAll('g')
|
|
||||||
.data(simNodes)
|
|
||||||
.join('g')
|
|
||||||
.attr('cursor', 'pointer')
|
|
||||||
.call(d3.drag<any, any>()
|
|
||||||
.on('start', (event, d: any) => {
|
|
||||||
if (!event.active) simulation.alphaTarget(0.3).restart()
|
|
||||||
d.fx = d.x; d.fy = d.y
|
|
||||||
})
|
|
||||||
.on('drag', (event, d: any) => { d.fx = event.x; d.fy = event.y })
|
|
||||||
.on('end', (event, d: any) => {
|
|
||||||
if (!event.active) simulation.alphaTarget(0)
|
|
||||||
d.fx = null; d.fy = null
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Node shapes
|
|
||||||
node.each(function(this: any, d: any) {
|
|
||||||
const el = d3.select(this)
|
|
||||||
const color = getNodeColor(d.status)
|
|
||||||
|
|
||||||
if (d.type === 'document') {
|
|
||||||
// Document container node (only in document view)
|
|
||||||
el.append('rect')
|
|
||||||
.attr('width', d.w || 150)
|
|
||||||
.attr('height', d.h || 100)
|
|
||||||
.attr('x', -(d.w || 150) / 2)
|
|
||||||
.attr('y', -(d.h || 100) / 2)
|
|
||||||
.attr('fill', 'rgba(66, 165, 245, 0.08)')
|
|
||||||
.attr('stroke', '#42A5F5')
|
|
||||||
.attr('stroke-width', 1.5)
|
|
||||||
.attr('stroke-dasharray', '6,3')
|
|
||||||
.attr('rx', 8)
|
|
||||||
el.append('text').text(d.label)
|
|
||||||
.attr('x', 0)
|
|
||||||
.attr('y', -(d.h || 100) / 2 + 16)
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('font-size', '12px')
|
|
||||||
.attr('font-weight', 'bold')
|
|
||||||
.attr('fill', '#42A5F5')
|
|
||||||
} else if (d.type === 'capability') {
|
|
||||||
el.append('circle').attr('r', 18).attr('fill', color)
|
|
||||||
el.append('text').text(d.label).attr('x', 22).attr('y', 4)
|
|
||||||
.attr('font-size', '11px').attr('fill', '#333')
|
|
||||||
} else if (d.type === 'module') {
|
|
||||||
el.append('rect').attr('width', 28).attr('height', 28).attr('x', -14).attr('y', -14)
|
|
||||||
.attr('fill', color).attr('rx', 3)
|
|
||||||
el.append('text').text(d.label).attr('x', 18).attr('y', 4)
|
|
||||||
.attr('font-size', '11px').attr('fill', '#333')
|
|
||||||
} else if (d.type === 'entity') {
|
|
||||||
el.append('polygon').attr('points', '0,-24 24,0 0,24 -24,0').attr('fill', color)
|
|
||||||
el.append('text').text(d.label).attr('x', 28).attr('y', 4)
|
|
||||||
.attr('font-size', '11px').attr('fill', '#333')
|
|
||||||
} else if (d.type === 'runtime_component') {
|
|
||||||
el.append('circle').attr('r', 14).attr('fill', color)
|
|
||||||
el.append('text').text(d.label).attr('x', 18).attr('y', 4)
|
|
||||||
.attr('font-size', '11px').attr('fill', '#333')
|
|
||||||
} else {
|
|
||||||
// fallback
|
|
||||||
el.append('circle').attr('r', 14).attr('fill', color)
|
|
||||||
el.append('text').text(d.label).attr('x', 18).attr('y', 4)
|
|
||||||
.attr('font-size', '11px').attr('fill', '#333')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Tooltip on hover
|
|
||||||
node.append('title').text((d: any) => `${d.id}\n类型: ${d.type}\n状态: ${d.status}`)
|
|
||||||
|
|
||||||
// Click -> select node
|
|
||||||
node.on('click', (_event: any, d: any) => { store.selectNode(d) })
|
|
||||||
|
|
||||||
// Double-click -> drill down
|
|
||||||
node.on('dblclick', (_event: any, d: any) => {
|
|
||||||
const projectId = route.params.id as string
|
|
||||||
isDrillDown.value = true
|
|
||||||
drillDownNodeLabel.value = d.label
|
|
||||||
store.loadNeighbors(projectId, d.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
simulation.on('tick', () => {
|
|
||||||
// In document view, clamp child nodes inside parent bounds
|
|
||||||
if (showDocumentView.value) {
|
|
||||||
for (const n of simNodes) {
|
|
||||||
if (n.parent) {
|
|
||||||
const p = nodeMap[n.parent]
|
|
||||||
if (p && p.w && p.h) {
|
|
||||||
const pad = 20
|
|
||||||
n.x = Math.max(p.x! - p.w / 2 + pad, Math.min(p.x! + p.w / 2 - pad, n.x!))
|
|
||||||
n.y = Math.max(p.y! - p.h / 2 + pad, Math.min(p.y! + p.h / 2 - pad, n.y!))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
link
|
|
||||||
.attr('x1', (d: any) => d.source.x)
|
|
||||||
.attr('y1', (d: any) => d.source.y)
|
|
||||||
.attr('x2', (d: any) => d.target.x)
|
|
||||||
.attr('y2', (d: any) => d.target.y)
|
|
||||||
|
|
||||||
node.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const projectId = route.params.id as string
|
|
||||||
await store.loadGraph(projectId)
|
|
||||||
drawGraph()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(graphView, () => { drawGraph() })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.graph-panorama { position: relative; height: calc(100vh - 48px); }
|
|
||||||
.graph-svg { width: 100%; height: 100%; }
|
|
||||||
.loading-overlay {
|
|
||||||
position: absolute; inset: 0;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
background: rgba(255,255,255,0.8); font-size: 18px; color: #666;
|
|
||||||
}
|
|
||||||
.scan-summary {
|
|
||||||
position: absolute; top: 12px; right: 340px;
|
|
||||||
display: flex; gap: 12px;
|
|
||||||
background: white; padding: 8px 16px; border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 10;
|
|
||||||
}
|
|
||||||
.summary-item { font-size: 13px; }
|
|
||||||
.toolbar {
|
|
||||||
position: absolute; top: 12px; left: 12px;
|
|
||||||
display: flex; gap: 8px;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
.toggle-btn {
|
|
||||||
padding: 6px 16px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: white;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
.toggle-btn:hover {
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
.toggle-btn.active {
|
|
||||||
background: #42A5F5;
|
|
||||||
color: white;
|
|
||||||
border-color: #42A5F5;
|
|
||||||
}
|
|
||||||
.drill-down-bar {
|
|
||||||
position: absolute; top: 12px; left: 12px;
|
|
||||||
display: flex; align-items: center; gap: 12px;
|
|
||||||
background: white; padding: 8px 16px; border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 10;
|
|
||||||
}
|
|
||||||
.back-btn {
|
|
||||||
background: #1976D2; color: white; border: none;
|
|
||||||
padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px;
|
|
||||||
}
|
|
||||||
.back-btn:hover { background: #1565C0; }
|
|
||||||
.drill-down-label { font-size: 13px; color: #666; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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<GraphView | null>(null)
|
|
||||||
const selectedNode = ref<GraphNode | null>(null)
|
|
||||||
const scanResult = ref<ScanResult | null>(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 }
|
|
||||||
})
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export type { GraphView, GraphNode, GraphEdge, GraphGroup, ScanResult, ScanSummary } from '@/shared/types/api'
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import api from '@/shared/api'
|
|
||||||
import type { Project } from '@/shared/types/api'
|
|
||||||
|
|
||||||
export async function listProjects(): Promise<Project[]> {
|
|
||||||
const { data } = await api.get<Project[]>('/projects')
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createProject(name: string, designDir: string, codeDir?: string): Promise<Project> {
|
|
||||||
const { data } = await api.post<Project>('/projects', {
|
|
||||||
name,
|
|
||||||
design_dir: designDir,
|
|
||||||
code_dir: codeDir || null,
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProject(id: string): Promise<Project> {
|
|
||||||
const { data } = await api.get<Project>(`/projects/${id}`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteProject(id: string): Promise<void> {
|
|
||||||
await api.delete(`/projects/${id}`)
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="project-list">
|
|
||||||
<h1>项目管理</h1>
|
|
||||||
|
|
||||||
<div class="add-section">
|
|
||||||
<button class="primary" @click="showForm = !showForm">
|
|
||||||
{{ showForm ? '取消' : '添加项目' }}
|
|
||||||
</button>
|
|
||||||
<form v-if="showForm" class="card add-form" @submit.prevent="handleCreate">
|
|
||||||
<input v-model="form.name" placeholder="项目名称" required />
|
|
||||||
<input v-model="form.designDir" placeholder="设计目录路径" required />
|
|
||||||
<input v-model="form.codeDir" placeholder="代码目录路径(可选)" />
|
|
||||||
<button type="submit" class="primary" :disabled="loading">创建</button>
|
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loading && projects.length === 0" class="loading">加载中...</div>
|
|
||||||
<div v-if="!loading && projects.length === 0" class="empty">暂无项目,点击"添加项目"开始</div>
|
|
||||||
|
|
||||||
<ProjectOverview
|
|
||||||
v-for="p in projects"
|
|
||||||
:key="p.id"
|
|
||||||
:project="p"
|
|
||||||
@click="goToProject(p.id)"
|
|
||||||
@delete="handleDelete"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import { useProjectStore } from '../composables/useProject'
|
|
||||||
import ProjectOverview from './ProjectOverview.vue'
|
|
||||||
|
|
||||||
const store = useProjectStore()
|
|
||||||
const { projects, loading, error } = storeToRefs(store)
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const showForm = ref(false)
|
|
||||||
const form = ref({ name: '', designDir: '', codeDir: '' })
|
|
||||||
|
|
||||||
onMounted(() => { store.fetchProjects() })
|
|
||||||
|
|
||||||
async function handleCreate() {
|
|
||||||
try {
|
|
||||||
const project = await store.createProject(form.value.name, form.value.designDir, form.value.codeDir || undefined)
|
|
||||||
showForm.value = false
|
|
||||||
form.value = { name: '', designDir: '', codeDir: '' }
|
|
||||||
router.push(`/projects/${project.id}`)
|
|
||||||
} catch { /* error handled in store */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToProject(id: string) {
|
|
||||||
store.selectProject(id)
|
|
||||||
router.push(`/projects/${id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
|
||||||
if (confirm('确认删除该项目?')) {
|
|
||||||
await store.removeProject(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
h1 { margin-bottom: 16px; }
|
|
||||||
.add-section { margin-bottom: 16px; }
|
|
||||||
.add-form { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.empty { color: #999; padding: 24px; text-align: center; }
|
|
||||||
.loading { color: #999; padding: 24px; text-align: center; }
|
|
||||||
.error { color: #F44336; font-size: 13px; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="card project-card">
|
|
||||||
<div class="project-header">
|
|
||||||
<h3>{{ project.name }}</h3>
|
|
||||||
<button class="danger" @click.stop="$emit('delete', project.id)">删除</button>
|
|
||||||
</div>
|
|
||||||
<p class="meta">{{ project.design_dir }}</p>
|
|
||||||
<p class="meta">创建于 {{ new Date(project.created_at).toLocaleDateString() }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Project } from '@/shared/types/api'
|
|
||||||
|
|
||||||
defineProps<{ project: Project }>()
|
|
||||||
defineEmits<{ delete: [id: string] }>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.project-card { cursor: pointer; }
|
|
||||||
.project-card:hover { border-color: #1976D2; }
|
|
||||||
.project-header { display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
.project-header h3 { font-size: 16px; }
|
|
||||||
.meta { font-size: 13px; color: #666; margin-top: 4px; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="project-sidebar">
|
|
||||||
<div v-if="loading" class="loading">加载中...</div>
|
|
||||||
<div v-for="p in projects" :key="p.id" class="project-item" :class="{ active: currentProject?.id === p.id }" @click="goToProject(p.id)">
|
|
||||||
<span class="project-name">{{ p.name }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="!loading && projects.length === 0" class="empty">暂无项目</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import { useProjectStore } from '../composables/useProject'
|
|
||||||
|
|
||||||
const store = useProjectStore()
|
|
||||||
const { projects, currentProject, loading } = storeToRefs(store)
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
onMounted(() => { store.fetchProjects() })
|
|
||||||
|
|
||||||
function goToProject(id: string) {
|
|
||||||
store.selectProject(id)
|
|
||||||
router.push(`/projects/${id}`)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.project-item {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.project-item:hover { background: #f0f0f0; }
|
|
||||||
.project-item.active { background: #e3f2fd; color: #1976D2; }
|
|
||||||
.project-name { font-size: 14px; }
|
|
||||||
.empty { color: #999; font-size: 13px; padding: 8px; }
|
|
||||||
.loading { color: #999; font-size: 13px; padding: 8px; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
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<Project[]>([])
|
|
||||||
const currentProject = ref<Project | null>(null)
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref<string | null>(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 }
|
|
||||||
})
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export type { Project } from '@/shared/types/api'
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import axios from 'axios'
|
|
||||||
const api = axios.create({ baseURL: '/api' })
|
|
||||||
export default api
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
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
|
|
||||||
parent: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, unknown> | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EntityDetail {
|
|
||||||
entity: Entity
|
|
||||||
data_flows: DataFlow[]
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
: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;
|
|
||||||
}
|
|
||||||
1
frontend/src/vite-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
{
|
|
||||||
"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" }]
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||