docs: fix spec issues from review — path normalization, field names, dedup

- Fix doc.source_file → doc.file_path (actual DesignDocument field name)
- Replace lstrip("./") with proper path resolution helpers
- Add deduplication for doc→doc edges (process only downstream)
- Add frontend/src/shared/types/api.ts to files changed
- Add fallback for unknown group_id in forceX/forceY
- Note build_panorama needs design_dir parameter
- Clarify runtime_component/document detail panel behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
openclaw 2026-03-24 07:39:33 +00:00
parent b67780007c
commit c97f201a9c

View File

@ -52,6 +52,8 @@ cross-layer 0.50 0.85
**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}
@ -105,16 +107,45 @@ class GraphNode:
**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:
file_to_doc[doc.source_file] = doc.doc_id
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.source_file, "unknown"),
status=file_status_map.get(doc_rel, "unknown"),
group_id="cross-layer",
))
node_ids.add(doc.doc_id)
@ -126,20 +157,27 @@ 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:
Step 9 — Fix doc→doc edges using file path mapping (with deduplication):
```python
path_to_doc = {doc.source_file: doc.doc_id for doc in scan_result.design_documents}
path_to_doc = {}
doc_rel_paths = {}
for doc in scan_result.design_documents:
for up_path in doc.upstream:
normalized = up_path.lstrip("./")
up_doc_id = path_to_doc.get(normalized)
if up_doc_id and up_doc_id in node_ids:
edges.append(GraphEdge(source=up_doc_id, target=doc.doc_id, relation="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:
normalized = down_path.lstrip("./")
down_doc_id = path_to_doc.get(normalized)
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:
edges.append(GraphEdge(source=doc.doc_id, target=down_doc_id, relation="documents"))
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:**
@ -174,8 +212,8 @@ for doc in scan_result.design_documents:
}
simulation
.force('x', d3.forceX(d => groupPositions[d.group_id].x * width).strength(0.4))
.force('y', d3.forceY(d => groupPositions[d.group_id].y * height).strength(0.4))
.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))
```
@ -269,6 +307,8 @@ Edge styles:
| 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 ──────────────────────────┐
@ -411,6 +451,7 @@ Edge styles:
| `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