feat(graph): add document nodes, parent refs, and fixed doc edges (GAP-B3)
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
4d70df76fc
commit
50e38c64a6
|
|
@ -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,20 +182,27 @@ 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:
|
||||
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=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,
|
||||
target=down_doc_id,
|
||||
relation="documents",
|
||||
))
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user