diff --git a/docs/superpowers/specs/2026-03-24-v2-fix-gaps-design.md b/docs/superpowers/specs/2026-03-24-v2-fix-gaps-design.md index e09f895..48c464e 100644 --- a/docs/superpowers/specs/2026-03-24-v2-fix-gaps-design.md +++ b/docs/superpowers/specs/2026-03-24-v2-fix-gaps-design.md @@ -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