Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions scripts/build_component_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
53 changes: 52 additions & 1 deletion scripts/diff_to_mermaid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
# --------------------------------------------------------------------------- #
Expand Down
22 changes: 21 additions & 1 deletion tests/test_build_component_files.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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:
Expand Down
157 changes: 157 additions & 0 deletions tests/test_diff_to_mermaid.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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": []}
Expand Down
Loading