Skip to content

Commit 0a8328b

Browse files
fix: render global analysis relations in PR diagrams (#56)
1 parent d879b1a commit 0a8328b

4 files changed

Lines changed: 233 additions & 2 deletions

File tree

‎scripts/build_component_files.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ def main() -> int:
200200
sys.exit(f"::error::Could not read changed-files list at {args.changed_files}: {exc}")
201201
changed = {line.strip() for line in raw.splitlines() if line.strip()}
202202

203+
# Use the same projected relation view as Mermaid so relation-only changes
204+
# agree on which parent component changed. Projection does not alter the
205+
# component file membership consumed below.
203206
base = dm.load_analysis(args.base)
204207
head = dm.load_analysis(args.head)
205208
text, meta = render_component_files(dm.build_diff(base, head), base, changed)

‎scripts/diff_to_mermaid.py‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,64 @@
4747
# --------------------------------------------------------------------------- #
4848
# load
4949
# --------------------------------------------------------------------------- #
50-
def load_analysis(path: Path) -> dict:
50+
def _relation_dict(relation) -> dict:
51+
"""Keep the relation fields used by the diff after Core projects an edge."""
52+
return {
53+
"relation": relation.relation,
54+
"src_name": relation.src_name,
55+
"dst_name": relation.dst_name,
56+
"src_id": relation.src_id,
57+
"dst_id": relation.dst_id,
58+
}
59+
60+
61+
def _project_analysis(data: dict, root_analysis, sub_analyses: dict, id_to_name: dict, projector) -> dict:
62+
"""Attach Core's global-relation projection to every nested JSON level."""
63+
global_relations = list(root_analysis.components_relations)
64+
65+
def project_level(container: dict, analysis) -> None:
66+
level_ids = {component.component_id for component in analysis.components}
67+
container["components_relations"] = [
68+
_relation_dict(relation) for relation in projector(global_relations, level_ids, id_to_name)
69+
]
70+
for component in container.get("components") or []:
71+
component_id = component.get("component_id", "")
72+
sub_analysis = sub_analyses.get(component_id)
73+
if sub_analysis is not None:
74+
project_level(component, sub_analysis)
75+
76+
project_level(data, root_analysis)
77+
return data
78+
79+
80+
def read_analysis_json(path: Path) -> dict:
5181
try:
5282
return json.loads(path.read_text())
5383
except (OSError, json.JSONDecodeError) as exc:
5484
sys.exit(f"::error::Could not read analysis JSON at {path}: {exc}")
5585

5686

87+
def load_analysis(path: Path) -> dict:
88+
data = read_analysis_json(path)
89+
try:
90+
from codeboarding_workflows.rendering import project_relations_to_level
91+
from diagram_analysis.analysis_json import build_id_to_name_map, parse_unified_analysis
92+
except ImportError as exc:
93+
sys.exit(f"::error::Could not load the installed CodeBoarding analysis reader: {exc}")
94+
95+
try:
96+
root_analysis, sub_analyses = parse_unified_analysis(data)
97+
except (KeyError, TypeError, ValueError) as exc:
98+
sys.exit(f"::error::Could not parse analysis JSON at {path}: {exc}")
99+
return _project_analysis(
100+
data,
101+
root_analysis,
102+
sub_analyses,
103+
build_id_to_name_map(root_analysis, sub_analyses),
104+
project_relations_to_level,
105+
)
106+
107+
57108
# --------------------------------------------------------------------------- #
58109
# diff (ported from compute_diff.py; relation diff extended with 'modified')
59110
# --------------------------------------------------------------------------- #

‎tests/test_build_component_files.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Unit tests for scripts/build_component_files.py — per-component changed-file dropdowns."""
22

33
import json
4+
import os
45
import re
56
import subprocess
67
import sys
@@ -219,6 +220,24 @@ def _analyses(self, d):
219220

220221
def _run(self, d, *extra):
221222
out = d / "out.md"
223+
core = d / "fake-core"
224+
(core / "codeboarding_workflows").mkdir(parents=True)
225+
(core / "diagram_analysis").mkdir()
226+
(core / "codeboarding_workflows" / "__init__.py").write_text("")
227+
(core / "diagram_analysis" / "__init__.py").write_text("")
228+
(core / "codeboarding_workflows" / "rendering.py").write_text(
229+
"def project_relations_to_level(relations, level_ids, id_to_name):\n"
230+
" return [r for r in relations if r.src_id in level_ids and r.dst_id in level_ids]\n"
231+
)
232+
(core / "diagram_analysis" / "analysis_json.py").write_text(
233+
"from types import SimpleNamespace\n"
234+
"def parse_unified_analysis(data):\n"
235+
" components = [SimpleNamespace(component_id=c['component_id']) for c in data.get('components', [])]\n"
236+
" relations = [SimpleNamespace(**r) for r in data.get('components_relations', [])]\n"
237+
" return SimpleNamespace(components=components, components_relations=relations), {}\n"
238+
"def build_id_to_name_map(root, subs):\n"
239+
" return {}\n"
240+
)
222241
args = [
223242
sys.executable,
224243
str(SCRIPT),
@@ -229,7 +248,8 @@ def _run(self, d, *extra):
229248
"--out",
230249
str(out),
231250
]
232-
return out, subprocess.run([*args, *extra], capture_output=True, text=True)
251+
env = {**os.environ, "PYTHONPATH": str(core)}
252+
return out, subprocess.run([*args, *extra], capture_output=True, text=True, env=env)
233253

234254
def test_main_writes_out_file_and_prints_meta(self):
235255
with tempfile.TemporaryDirectory() as tmp:

‎tests/test_diff_to_mermaid.py‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
"""Unit tests for scripts/diff_to_mermaid.py — diff logic + Mermaid rendering."""
22

3+
import json
34
import re
45
import sys
6+
import tempfile
57
import unittest
68
from pathlib import Path
9+
from types import ModuleType, SimpleNamespace
10+
from unittest.mock import patch
711

812
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
913
import diff_to_mermaid as dm # noqa: E402
@@ -33,6 +37,159 @@ def linkstyle_indices_in_range(text):
3337

3438

3539
class TestDiff(unittest.TestCase):
40+
def test_core_loader_projects_global_relation_into_rendered_mermaid(self):
41+
root_a = SimpleNamespace(component_id="1")
42+
root_b = SimpleNamespace(component_id="2")
43+
child_a = SimpleNamespace(component_id="1.1")
44+
child_b = SimpleNamespace(component_id="2.1")
45+
46+
def parse_unified_analysis(data):
47+
relations = [SimpleNamespace(**relation) for relation in data.get("components_relations", [])]
48+
root = SimpleNamespace(components=[root_a, root_b], components_relations=relations)
49+
return root, {
50+
"1": SimpleNamespace(components=[child_a]),
51+
"2": SimpleNamespace(components=[child_b]),
52+
}
53+
54+
def build_id_to_name_map(_root, _subs):
55+
return {"1": "Root A", "2": "Root B", "1.1": "Child A", "2.1": "Child B"}
56+
57+
def project_relations_to_level(relations, level_ids, id_to_name):
58+
projected = []
59+
for relation in relations:
60+
src_id = next(
61+
(cid for cid in level_ids if relation.src_id == cid or relation.src_id.startswith(cid + ".")), None
62+
)
63+
dst_id = next(
64+
(cid for cid in level_ids if relation.dst_id == cid or relation.dst_id.startswith(cid + ".")), None
65+
)
66+
if src_id is None or dst_id is None or src_id == dst_id:
67+
continue
68+
projected.append(
69+
SimpleNamespace(
70+
relation=relation.relation,
71+
src_name=id_to_name[src_id],
72+
dst_name=id_to_name[dst_id],
73+
src_id=src_id,
74+
dst_id=dst_id,
75+
)
76+
)
77+
return projected
78+
79+
rendering = ModuleType("codeboarding_workflows.rendering")
80+
rendering.project_relations_to_level = project_relations_to_level
81+
analysis_json = ModuleType("diagram_analysis.analysis_json")
82+
analysis_json.parse_unified_analysis = parse_unified_analysis
83+
analysis_json.build_id_to_name_map = build_id_to_name_map
84+
modules = {
85+
"codeboarding_workflows": ModuleType("codeboarding_workflows"),
86+
"codeboarding_workflows.rendering": rendering,
87+
"diagram_analysis": ModuleType("diagram_analysis"),
88+
"diagram_analysis.analysis_json": analysis_json,
89+
}
90+
analysis = {
91+
"components": [
92+
{"name": "Root A", "component_id": "1", "components": [{"name": "Child A", "component_id": "1.1"}]},
93+
{"name": "Root B", "component_id": "2", "components": [{"name": "Child B", "component_id": "2.1"}]},
94+
],
95+
"components_relations": [],
96+
}
97+
98+
with tempfile.TemporaryDirectory() as tmp, patch.dict(sys.modules, modules):
99+
base_path = Path(tmp) / "base.json"
100+
head_path = Path(tmp) / "head.json"
101+
base_path.write_text(json.dumps(analysis))
102+
head_path.write_text(
103+
json.dumps(
104+
{
105+
**analysis,
106+
"components_relations": [
107+
{
108+
"relation": "calls",
109+
"src_name": "Child A",
110+
"dst_name": "Child B",
111+
"src_id": "1.1",
112+
"dst_id": "2.1",
113+
}
114+
],
115+
}
116+
)
117+
)
118+
diff = dm.build_diff(dm.load_analysis(base_path), dm.load_analysis(head_path))
119+
120+
text, meta = dm.render_mermaid(diff)
121+
self.assertIn('n_Root_A -- "calls" --> n_Root_B', text)
122+
self.assertEqual(meta["n_edges"], 1)
123+
self.assertTrue(meta["changed"])
124+
125+
def test_projects_global_relations_at_every_component_level(self):
126+
child = SimpleNamespace(component_id="1.1")
127+
peer = SimpleNamespace(component_id="1.2")
128+
root_a = SimpleNamespace(component_id="1")
129+
root_b = SimpleNamespace(component_id="2")
130+
global_relation = SimpleNamespace(
131+
relation="calls",
132+
src_name="Child",
133+
dst_name="Root B",
134+
src_id="1.1",
135+
dst_id="2",
136+
)
137+
sibling_relation = SimpleNamespace(
138+
relation="delegates",
139+
src_name="Child",
140+
dst_name="Peer",
141+
src_id="1.1",
142+
dst_id="1.2",
143+
)
144+
root_analysis = SimpleNamespace(
145+
components=[root_a, root_b], components_relations=[global_relation, sibling_relation]
146+
)
147+
sub_analysis = SimpleNamespace(components=[child, peer])
148+
data = {
149+
"components": [
150+
{
151+
"name": "Root A",
152+
"component_id": "1",
153+
"components": [
154+
{"name": "Child", "component_id": "1.1"},
155+
{"name": "Peer", "component_id": "1.2"},
156+
],
157+
"components_relations": [{"relation": "stale"}],
158+
},
159+
{"name": "Root B", "component_id": "2"},
160+
],
161+
"components_relations": [],
162+
}
163+
164+
def projector(relations, level_ids, _id_to_name):
165+
self.assertEqual(relations, [global_relation, sibling_relation])
166+
if level_ids == {"1", "2"}:
167+
return [
168+
SimpleNamespace(
169+
relation="calls",
170+
src_name="Root A",
171+
dst_name="Root B",
172+
src_id="1",
173+
dst_id="2",
174+
)
175+
]
176+
if level_ids == {"1.1", "1.2"}:
177+
return [sibling_relation]
178+
self.fail(f"unexpected component level: {level_ids}")
179+
180+
projected = dm._project_analysis(
181+
data,
182+
root_analysis,
183+
{"1": sub_analysis},
184+
{"1": "Root A", "2": "Root B", "1.1": "Child", "1.2": "Peer"},
185+
projector,
186+
)
187+
188+
self.assertEqual(projected["components_relations"][0]["src_id"], "1")
189+
nested_relations = projected["components"][0]["components_relations"]
190+
self.assertEqual(nested_relations[0]["src_id"], "1.1")
191+
self.assertEqual(nested_relations[0]["dst_id"], "1.2")
192+
36193
def test_added_modified_deleted_unchanged(self):
37194
base = {"components": [comp("A", {"a.py": ["f"]}), comp("B"), comp("D")], "components_relations": []}
38195
head = {"components": [comp("A", {"a.py": ["f", "g"]}), comp("B"), comp("C")], "components_relations": []}

0 commit comments

Comments
 (0)