From 50e38c64a61407e37d252bc0f7d529f85daa77e6 Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 24 Mar 2026 08:27:06 +0000 Subject: [PATCH] feat(graph): add document nodes, parent refs, and fixed doc edges (GAP-B3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create document nodes from scan_result.design_documents with type="document" and group_id="cross-layer" - Set parent field on entity nodes (capability/module/entity/runtime_component) pointing to their containing document via directory-based lookup - Replace dead-code Step 9 with path-based resolution of downstream refs and edge deduplication - Add helper functions _to_rel_path and _resolve_ref_path for absolute→relative path conversion and ../ resolution Co-Authored-By: Claude Opus 4.6 --- .../app/modules/graph/application/services.py | 93 ++++++++++++++++--- backend/tests/test_graph_service.py | 20 ++++ 2 files changed, 98 insertions(+), 15 deletions(-) diff --git a/backend/app/modules/graph/application/services.py b/backend/app/modules/graph/application/services.py index a0c9a35..3bb051c 100644 --- a/backend/app/modules/graph/application/services.py +++ b/backend/app/modules/graph/application/services.py @@ -2,10 +2,34 @@ 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"), @@ -41,6 +65,34 @@ class GraphService: # 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 @@ -50,6 +102,7 @@ class GraphService: label=cap.name, status=file_status_map.get(_SOURCE_FILES["capability"], "unknown"), group_id="business", + parent=_parent_for("capability"), )) node_ids.add(node_id) @@ -62,6 +115,7 @@ class GraphService: label=mod.name, status=file_status_map.get(_SOURCE_FILES["module"], "unknown"), group_id="application", + parent=_parent_for("module"), )) node_ids.add(node_id) @@ -74,6 +128,7 @@ class GraphService: label=ent.name, status=file_status_map.get(_SOURCE_FILES["entity"], "unknown"), group_id="data", + parent=_parent_for("entity"), )) node_ids.add(node_id) @@ -86,6 +141,7 @@ class GraphService: 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) @@ -126,22 +182,29 @@ class GraphService: relation="depends_on", )) - # Step 9: DesignDocument.upstream/downstream → edges (if both are nodes) + # 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: - for upstream_id in doc.upstream: - if doc.doc_id in node_ids and upstream_id in node_ids: - edges.append(GraphEdge( - source=doc.doc_id, - target=upstream_id, - relation="documents", - )) - for downstream_id in doc.downstream: - if doc.doc_id in node_ids and downstream_id in node_ids: - edges.append(GraphEdge( - source=doc.doc_id, - target=downstream_id, - relation="documents", - )) + 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) diff --git a/backend/tests/test_graph_service.py b/backend/tests/test_graph_service.py index 5954838..f84c5ac 100644 --- a/backend/tests/test_graph_service.py +++ b/backend/tests/test_graph_service.py @@ -104,3 +104,23 @@ def test_panorama_status_values_are_valid(graph_service, scan_result, 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"