Compare commits

..

No commits in common. "feat/v3-graph-redesign" and "main" have entirely different histories.

100 changed files with 0 additions and 13608 deletions

10
.gitignore vendored
View File

@ -1,10 +0,0 @@
__pycache__/
*.pyc
*.pyo
.venv/
node_modules/
dist/
*.tsbuildinfo
*.d.ts
!src/**/*.d.ts
frontend/vite.config.js

View File

@ -1 +0,0 @@
3.12

View File

@ -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"]

View File

@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
},
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
)

View File

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

View File

@ -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),
)
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
)

View File

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

View File

@ -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"),
)

View File

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

View File

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

View File

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

View File

@ -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],
}

View File

@ -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"
)

View File

@ -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()

View 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}")

View File

@ -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",
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 == {}

View File

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

View File

@ -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" },
]

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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 | MVPimpl-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` dataclassregistry_pathprojects.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 router4 个端点。
### 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 个 groupbusiness, 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-EDITORPhase 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-TRACKERPhase 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 → tooltipID, 类型, 状态, 名称)
- 单击 → 侧边 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-EDITORPhase 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 缺失,实现时补充 |

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

View File

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

View File

@ -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 Composebackend 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` 选择器 |

View File

@ -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/`

View File

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

View File

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

View File

@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

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

View File

@ -1,5 +0,0 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@ -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')

View File

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

View File

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

View File

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

View File

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

View File

@ -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 }
})

View File

@ -1 +0,0 @@
export type { EditableFile, ImpactResult, ImplProgress } from '@/shared/types/api'

View File

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

View File

@ -1,376 +0,0 @@
<template>
<div class="graph-detail" v-if="node">
<div class="detail-header">
<button class="close-btn" @click="emit('close')">&#10005; Close</button>
</div>
<div class="detail-identity">
<div class="identity-row">
<span class="node-id">&#9679; {{ node.id }}</span>
<button v-if="sourceFile" class="edit-btn" @click="openEditor">[Edit]</button>
</div>
<div class="node-meta">
{{ node.type }} &middot; <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">&mdash;&mdash; Attributes &mdash;&mdash;</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">&mdash;&mdash; Related Entities &mdash;&mdash;</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' ? '&rarr;' : '&larr;' }}</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>

View File

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

View File

@ -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">&larr; 返回全景图</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>

View File

@ -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 }
})

View File

@ -1 +0,0 @@
export type { GraphView, GraphNode, GraphEdge, GraphGroup, ScanResult, ScanSummary } from '@/shared/types/api'

View File

@ -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}`)
}

View File

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

View File

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

View File

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

View File

@ -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 }
})

View File

@ -1 +0,0 @@
export type { Project } from '@/shared/types/api'

View File

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

View File

@ -1,3 +0,0 @@
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
export default api

View File

@ -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[]
}

View File

@ -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;
}

View File

@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@ -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" }]
}

View File

@ -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"]
}

View File

@ -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,
},
},
},
})