From 980eb531d735f440837962aed322e7371dc92e33 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Wed, 15 Jul 2026 17:48:29 +0200 Subject: [PATCH] fix: render global analysis relations in PR diagrams --- scripts/build_component_files.py | 3 + scripts/diff_to_mermaid.py | 53 +++++++++- tests/test_build_component_files.py | 22 +++- tests/test_diff_to_mermaid.py | 157 ++++++++++++++++++++++++++++ 4 files changed, 233 insertions(+), 2 deletions(-) diff --git a/scripts/build_component_files.py b/scripts/build_component_files.py index b06f37d..8939d73 100644 --- a/scripts/build_component_files.py +++ b/scripts/build_component_files.py @@ -200,6 +200,9 @@ def main() -> int: sys.exit(f"::error::Could not read changed-files list at {args.changed_files}: {exc}") changed = {line.strip() for line in raw.splitlines() if line.strip()} + # Use the same projected relation view as Mermaid so relation-only changes + # agree on which parent component changed. Projection does not alter the + # component file membership consumed below. base = dm.load_analysis(args.base) head = dm.load_analysis(args.head) text, meta = render_component_files(dm.build_diff(base, head), base, changed) diff --git a/scripts/diff_to_mermaid.py b/scripts/diff_to_mermaid.py index 1197475..0d86bf8 100644 --- a/scripts/diff_to_mermaid.py +++ b/scripts/diff_to_mermaid.py @@ -47,13 +47,64 @@ # --------------------------------------------------------------------------- # # load # --------------------------------------------------------------------------- # -def load_analysis(path: Path) -> dict: +def _relation_dict(relation) -> dict: + """Keep the relation fields used by the diff after Core projects an edge.""" + return { + "relation": relation.relation, + "src_name": relation.src_name, + "dst_name": relation.dst_name, + "src_id": relation.src_id, + "dst_id": relation.dst_id, + } + + +def _project_analysis(data: dict, root_analysis, sub_analyses: dict, id_to_name: dict, projector) -> dict: + """Attach Core's global-relation projection to every nested JSON level.""" + global_relations = list(root_analysis.components_relations) + + def project_level(container: dict, analysis) -> None: + level_ids = {component.component_id for component in analysis.components} + container["components_relations"] = [ + _relation_dict(relation) for relation in projector(global_relations, level_ids, id_to_name) + ] + for component in container.get("components") or []: + component_id = component.get("component_id", "") + sub_analysis = sub_analyses.get(component_id) + if sub_analysis is not None: + project_level(component, sub_analysis) + + project_level(data, root_analysis) + return data + + +def read_analysis_json(path: Path) -> dict: try: return json.loads(path.read_text()) except (OSError, json.JSONDecodeError) as exc: sys.exit(f"::error::Could not read analysis JSON at {path}: {exc}") +def load_analysis(path: Path) -> dict: + data = read_analysis_json(path) + try: + from codeboarding_workflows.rendering import project_relations_to_level + from diagram_analysis.analysis_json import build_id_to_name_map, parse_unified_analysis + except ImportError as exc: + sys.exit(f"::error::Could not load the installed CodeBoarding analysis reader: {exc}") + + try: + root_analysis, sub_analyses = parse_unified_analysis(data) + except (KeyError, TypeError, ValueError) as exc: + sys.exit(f"::error::Could not parse analysis JSON at {path}: {exc}") + return _project_analysis( + data, + root_analysis, + sub_analyses, + build_id_to_name_map(root_analysis, sub_analyses), + project_relations_to_level, + ) + + # --------------------------------------------------------------------------- # # diff (ported from compute_diff.py; relation diff extended with 'modified') # --------------------------------------------------------------------------- # diff --git a/tests/test_build_component_files.py b/tests/test_build_component_files.py index 9dc7307..d4f36fc 100644 --- a/tests/test_build_component_files.py +++ b/tests/test_build_component_files.py @@ -1,6 +1,7 @@ """Unit tests for scripts/build_component_files.py — per-component changed-file dropdowns.""" import json +import os import re import subprocess import sys @@ -219,6 +220,24 @@ def _analyses(self, d): def _run(self, d, *extra): out = d / "out.md" + core = d / "fake-core" + (core / "codeboarding_workflows").mkdir(parents=True) + (core / "diagram_analysis").mkdir() + (core / "codeboarding_workflows" / "__init__.py").write_text("") + (core / "diagram_analysis" / "__init__.py").write_text("") + (core / "codeboarding_workflows" / "rendering.py").write_text( + "def project_relations_to_level(relations, level_ids, id_to_name):\n" + " return [r for r in relations if r.src_id in level_ids and r.dst_id in level_ids]\n" + ) + (core / "diagram_analysis" / "analysis_json.py").write_text( + "from types import SimpleNamespace\n" + "def parse_unified_analysis(data):\n" + " components = [SimpleNamespace(component_id=c['component_id']) for c in data.get('components', [])]\n" + " relations = [SimpleNamespace(**r) for r in data.get('components_relations', [])]\n" + " return SimpleNamespace(components=components, components_relations=relations), {}\n" + "def build_id_to_name_map(root, subs):\n" + " return {}\n" + ) args = [ sys.executable, str(SCRIPT), @@ -229,7 +248,8 @@ def _run(self, d, *extra): "--out", str(out), ] - return out, subprocess.run([*args, *extra], capture_output=True, text=True) + env = {**os.environ, "PYTHONPATH": str(core)} + return out, subprocess.run([*args, *extra], capture_output=True, text=True, env=env) def test_main_writes_out_file_and_prints_meta(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_diff_to_mermaid.py b/tests/test_diff_to_mermaid.py index 4b05d26..4ad22bb 100644 --- a/tests/test_diff_to_mermaid.py +++ b/tests/test_diff_to_mermaid.py @@ -1,9 +1,13 @@ """Unit tests for scripts/diff_to_mermaid.py — diff logic + Mermaid rendering.""" +import json import re import sys +import tempfile import unittest from pathlib import Path +from types import ModuleType, SimpleNamespace +from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) import diff_to_mermaid as dm # noqa: E402 @@ -33,6 +37,159 @@ def linkstyle_indices_in_range(text): class TestDiff(unittest.TestCase): + def test_core_loader_projects_global_relation_into_rendered_mermaid(self): + root_a = SimpleNamespace(component_id="1") + root_b = SimpleNamespace(component_id="2") + child_a = SimpleNamespace(component_id="1.1") + child_b = SimpleNamespace(component_id="2.1") + + def parse_unified_analysis(data): + relations = [SimpleNamespace(**relation) for relation in data.get("components_relations", [])] + root = SimpleNamespace(components=[root_a, root_b], components_relations=relations) + return root, { + "1": SimpleNamespace(components=[child_a]), + "2": SimpleNamespace(components=[child_b]), + } + + def build_id_to_name_map(_root, _subs): + return {"1": "Root A", "2": "Root B", "1.1": "Child A", "2.1": "Child B"} + + def project_relations_to_level(relations, level_ids, id_to_name): + projected = [] + for relation in relations: + src_id = next( + (cid for cid in level_ids if relation.src_id == cid or relation.src_id.startswith(cid + ".")), None + ) + dst_id = next( + (cid for cid in level_ids if relation.dst_id == cid or relation.dst_id.startswith(cid + ".")), None + ) + if src_id is None or dst_id is None or src_id == dst_id: + continue + projected.append( + SimpleNamespace( + relation=relation.relation, + src_name=id_to_name[src_id], + dst_name=id_to_name[dst_id], + src_id=src_id, + dst_id=dst_id, + ) + ) + return projected + + rendering = ModuleType("codeboarding_workflows.rendering") + rendering.project_relations_to_level = project_relations_to_level + analysis_json = ModuleType("diagram_analysis.analysis_json") + analysis_json.parse_unified_analysis = parse_unified_analysis + analysis_json.build_id_to_name_map = build_id_to_name_map + modules = { + "codeboarding_workflows": ModuleType("codeboarding_workflows"), + "codeboarding_workflows.rendering": rendering, + "diagram_analysis": ModuleType("diagram_analysis"), + "diagram_analysis.analysis_json": analysis_json, + } + analysis = { + "components": [ + {"name": "Root A", "component_id": "1", "components": [{"name": "Child A", "component_id": "1.1"}]}, + {"name": "Root B", "component_id": "2", "components": [{"name": "Child B", "component_id": "2.1"}]}, + ], + "components_relations": [], + } + + with tempfile.TemporaryDirectory() as tmp, patch.dict(sys.modules, modules): + base_path = Path(tmp) / "base.json" + head_path = Path(tmp) / "head.json" + base_path.write_text(json.dumps(analysis)) + head_path.write_text( + json.dumps( + { + **analysis, + "components_relations": [ + { + "relation": "calls", + "src_name": "Child A", + "dst_name": "Child B", + "src_id": "1.1", + "dst_id": "2.1", + } + ], + } + ) + ) + diff = dm.build_diff(dm.load_analysis(base_path), dm.load_analysis(head_path)) + + text, meta = dm.render_mermaid(diff) + self.assertIn('n_Root_A -- "calls" --> n_Root_B', text) + self.assertEqual(meta["n_edges"], 1) + self.assertTrue(meta["changed"]) + + def test_projects_global_relations_at_every_component_level(self): + child = SimpleNamespace(component_id="1.1") + peer = SimpleNamespace(component_id="1.2") + root_a = SimpleNamespace(component_id="1") + root_b = SimpleNamespace(component_id="2") + global_relation = SimpleNamespace( + relation="calls", + src_name="Child", + dst_name="Root B", + src_id="1.1", + dst_id="2", + ) + sibling_relation = SimpleNamespace( + relation="delegates", + src_name="Child", + dst_name="Peer", + src_id="1.1", + dst_id="1.2", + ) + root_analysis = SimpleNamespace( + components=[root_a, root_b], components_relations=[global_relation, sibling_relation] + ) + sub_analysis = SimpleNamespace(components=[child, peer]) + data = { + "components": [ + { + "name": "Root A", + "component_id": "1", + "components": [ + {"name": "Child", "component_id": "1.1"}, + {"name": "Peer", "component_id": "1.2"}, + ], + "components_relations": [{"relation": "stale"}], + }, + {"name": "Root B", "component_id": "2"}, + ], + "components_relations": [], + } + + def projector(relations, level_ids, _id_to_name): + self.assertEqual(relations, [global_relation, sibling_relation]) + if level_ids == {"1", "2"}: + return [ + SimpleNamespace( + relation="calls", + src_name="Root A", + dst_name="Root B", + src_id="1", + dst_id="2", + ) + ] + if level_ids == {"1.1", "1.2"}: + return [sibling_relation] + self.fail(f"unexpected component level: {level_ids}") + + projected = dm._project_analysis( + data, + root_analysis, + {"1": sub_analysis}, + {"1": "Root A", "2": "Root B", "1.1": "Child", "1.2": "Peer"}, + projector, + ) + + self.assertEqual(projected["components_relations"][0]["src_id"], "1") + nested_relations = projected["components"][0]["components_relations"] + self.assertEqual(nested_relations[0]["src_id"], "1.1") + self.assertEqual(nested_relations[0]["dst_id"], "1.2") + def test_added_modified_deleted_unchanged(self): base = {"components": [comp("A", {"a.py": ["f"]}), comp("B"), comp("D")], "components_relations": []} head = {"components": [comp("A", {"a.py": ["f", "g"]}), comp("B"), comp("C")], "components_relations": []}