Skip to content

Commit 1f10a2f

Browse files
committed
feat(cap_graph): Merge similar nodes
1 parent a72a829 commit 1f10a2f

2 files changed

Lines changed: 264 additions & 1 deletion

File tree

src/analysis/cap_graph.py

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,89 @@ class CapEdge:
4242
details: Optional[str] = None # Additional info
4343

4444

45+
def _compute_edge_signature(
46+
node_name: str, edges: List[CapEdge]
47+
) -> Tuple[frozenset, frozenset]:
48+
"""Compute edge signature for a node (outgoing edges, incoming edges)."""
49+
outgoing = frozenset(
50+
(e.kind, e.target, e.guard, e.address_class, e.details)
51+
for e in edges
52+
if e.source == node_name
53+
)
54+
incoming = frozenset(
55+
(e.kind, e.source, e.guard, e.address_class, e.details)
56+
for e in edges
57+
if e.target == node_name
58+
)
59+
return (outgoing, incoming)
60+
61+
62+
def _merge_equivalent_functions(
63+
funcs: List[CapNode], edges: List[CapEdge]
64+
) -> Tuple[List[CapNode], List[CapEdge], Dict[str, str]]:
65+
"""
66+
Merge function nodes with identical edge signatures.
67+
68+
Returns:
69+
Tuple of (merged_nodes, updated_edges, name_mapping)
70+
where name_mapping maps original names to merged node names.
71+
"""
72+
if not funcs:
73+
return [], [], {}
74+
75+
sig_to_funcs: Dict[Tuple[frozenset, frozenset], List[CapNode]] = {}
76+
for func in funcs:
77+
sig = _compute_edge_signature(func.name, edges)
78+
if sig not in sig_to_funcs:
79+
sig_to_funcs[sig] = []
80+
sig_to_funcs[sig].append(func)
81+
82+
merged_nodes: List[CapNode] = []
83+
name_mapping: Dict[str, str] = {}
84+
85+
for sig, group in sig_to_funcs.items():
86+
if len(group) == 1:
87+
merged_nodes.append(group[0])
88+
name_mapping[group[0].name] = group[0].name
89+
else:
90+
sorted_group = sorted(group, key=lambda n: n.name)
91+
merged_name = sorted_group[0].name
92+
combined_props: Dict[str, Any] = {}
93+
for node in sorted_group:
94+
combined_props.update(node.properties)
95+
combined_props["_merged_names"] = [n.name for n in sorted_group]
96+
97+
merged_node = CapNode(
98+
kind="function",
99+
name=merged_name,
100+
properties=combined_props,
101+
)
102+
merged_nodes.append(merged_node)
103+
for node in group:
104+
name_mapping[node.name] = merged_name
105+
106+
updated_edges: List[CapEdge] = []
107+
seen: Set[Tuple] = set()
108+
for edge in edges:
109+
new_source = name_mapping.get(edge.source, edge.source)
110+
new_target = name_mapping.get(edge.target, edge.target)
111+
edge_key = (new_source, new_target, edge.kind, edge.guard, edge.address_class, edge.details)
112+
if edge_key not in seen:
113+
seen.add(edge_key)
114+
updated_edges.append(
115+
CapEdge(
116+
kind=edge.kind,
117+
source=new_source,
118+
target=new_target,
119+
guard=edge.guard,
120+
address_class=edge.address_class,
121+
details=edge.details,
122+
)
123+
)
124+
125+
return merged_nodes, updated_edges, name_mapping
126+
127+
45128
def build_cap_graph(ctx: "ProjectContext") -> Tuple[List[CapNode], List[CapEdge]]:
46129
"""
47130
Build capability graph from facts.
@@ -301,15 +384,23 @@ def _mermaid_node_id(name: str) -> str:
301384
def _render_mermaid_node(node: CapNode) -> str:
302385
"""Render a node in Mermaid syntax with appropriate shape."""
303386
node_id = _mermaid_node_id(node.name)
304-
label = node.name.replace('"', r"\"")
305387

306388
if node.kind == "capability":
389+
label = node.name.replace('"', r"\"")
307390
return f' {node_id}[["{label}"]]'
308391
elif node.kind == "object":
392+
label = node.name.replace('"', r"\"")
309393
return f' {node_id}(["{label}"])'
310394
elif node.kind == "address":
395+
label = node.name.replace('"', r"\"")
311396
return f' {node_id}{{{{"{label}"}}}}'
312397
else: # function
398+
merged_names = node.properties.get("_merged_names")
399+
if merged_names:
400+
func_labels = [f"ƒ {get_simple_name(n)}" for n in merged_names]
401+
label = "<br/>".join(func_labels)
402+
else:
403+
label = f"ƒ {get_simple_name(node.name)}"
313404
return f' {node_id}["{label}"]'
314405

315406

@@ -392,6 +483,10 @@ def dump_cap_graph_to_dir(ctx: "ProjectContext", output_dir: str) -> None:
392483
funcs = [n for n in nodes if n.kind == "function" and not n.properties.get("init")]
393484
addrs = [n for n in nodes if n.kind == "address"]
394485

486+
# Merge functions with identical edge signatures
487+
funcs, edges, _ = _merge_equivalent_functions(funcs, edges)
488+
init_funcs, edges, _ = _merge_equivalent_functions(init_funcs, edges)
489+
395490
output_path = os.path.join(output_dir, "capgraph.mmd")
396491
lines = ["flowchart LR"]
397492

test/test_cap_graph_merge.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
from analysis.cap_graph import (
2+
CapNode,
3+
CapEdge,
4+
_compute_edge_signature,
5+
_merge_equivalent_functions,
6+
_render_mermaid_node,
7+
)
8+
9+
10+
class TestCapGraphMerge:
11+
12+
def test_identical_edges_merged(self):
13+
"""Functions with identical edge patterns merge into one node."""
14+
funcs = [
15+
CapNode("function", "mod::foo", {"entry": True}),
16+
CapNode("function", "mod::bar", {"entry": True}),
17+
]
18+
edges = [
19+
CapEdge("mutates", "mod::foo", "SharedObj", guard="role:Admin"),
20+
CapEdge("mutates", "mod::bar", "SharedObj", guard="role:Admin"),
21+
CapEdge("requires_proof", "mod::foo", "AdminCap"),
22+
CapEdge("requires_proof", "mod::bar", "AdminCap"),
23+
]
24+
25+
merged_funcs, merged_edges, mapping = _merge_equivalent_functions(funcs, edges)
26+
27+
assert len(merged_funcs) == 1
28+
assert "_merged_names" in merged_funcs[0].properties
29+
assert set(merged_funcs[0].properties["_merged_names"]) == {"mod::foo", "mod::bar"}
30+
assert len(merged_edges) == 2
31+
32+
def test_different_edges_not_merged(self):
33+
"""Functions with different edge patterns stay separate."""
34+
funcs = [
35+
CapNode("function", "mod::foo", {}),
36+
CapNode("function", "mod::bar", {}),
37+
]
38+
edges = [
39+
CapEdge("mutates", "mod::foo", "ObjA"),
40+
CapEdge("mutates", "mod::bar", "ObjB"),
41+
]
42+
43+
merged_funcs, merged_edges, mapping = _merge_equivalent_functions(funcs, edges)
44+
45+
assert len(merged_funcs) == 2
46+
assert mapping["mod::foo"] == "mod::foo"
47+
assert mapping["mod::bar"] == "mod::bar"
48+
49+
def test_partial_overlap_not_merged(self):
50+
"""Functions with partially overlapping edges stay separate."""
51+
funcs = [
52+
CapNode("function", "mod::foo", {}),
53+
CapNode("function", "mod::bar", {}),
54+
]
55+
edges = [
56+
CapEdge("mutates", "mod::foo", "SharedObj"),
57+
CapEdge("mutates", "mod::bar", "SharedObj"),
58+
CapEdge("requires_proof", "mod::foo", "AdminCap"),
59+
]
60+
61+
merged_funcs, merged_edges, mapping = _merge_equivalent_functions(funcs, edges)
62+
63+
assert len(merged_funcs) == 2
64+
65+
def test_incoming_edges_differ_not_merged(self):
66+
"""Functions with same outgoing but different incoming edges stay separate."""
67+
funcs = [
68+
CapNode("function", "mod::foo", {}),
69+
CapNode("function", "mod::bar", {}),
70+
]
71+
edges = [
72+
CapEdge("mutates", "mod::foo", "SharedObj"),
73+
CapEdge("mutates", "mod::bar", "SharedObj"),
74+
CapEdge("calls", "mod::entry", "mod::foo"),
75+
]
76+
77+
merged_funcs, merged_edges, mapping = _merge_equivalent_functions(funcs, edges)
78+
79+
assert len(merged_funcs) == 2
80+
81+
def test_three_funcs_two_merge(self):
82+
"""Three functions, two with identical edges merge, one stays separate."""
83+
funcs = [
84+
CapNode("function", "mod::a", {}),
85+
CapNode("function", "mod::b", {}),
86+
CapNode("function", "mod::c", {}),
87+
]
88+
edges = [
89+
CapEdge("mutates", "mod::a", "Obj"),
90+
CapEdge("mutates", "mod::b", "Obj"),
91+
CapEdge("mutates", "mod::c", "OtherObj"),
92+
]
93+
94+
merged_funcs, merged_edges, mapping = _merge_equivalent_functions(funcs, edges)
95+
96+
assert len(merged_funcs) == 2
97+
merged_names = [f.properties.get("_merged_names") for f in merged_funcs if "_merged_names" in f.properties]
98+
assert len(merged_names) == 1
99+
assert set(merged_names[0]) == {"mod::a", "mod::b"}
100+
101+
def test_edge_remapping(self):
102+
"""Edges get remapped to merged node names."""
103+
funcs = [
104+
CapNode("function", "mod::foo", {}),
105+
CapNode("function", "mod::bar", {}),
106+
]
107+
edges = [
108+
CapEdge("mutates", "mod::foo", "Obj"),
109+
CapEdge("mutates", "mod::bar", "Obj"),
110+
]
111+
112+
merged_funcs, merged_edges, mapping = _merge_equivalent_functions(funcs, edges)
113+
114+
assert len(merged_edges) == 1
115+
assert merged_edges[0].source == merged_funcs[0].name
116+
117+
def test_no_funcs_empty_result(self):
118+
"""Empty input returns empty output."""
119+
merged_funcs, merged_edges, mapping = _merge_equivalent_functions([], [])
120+
121+
assert merged_funcs == []
122+
assert merged_edges == []
123+
assert mapping == {}
124+
125+
def test_single_func_no_merge(self):
126+
"""Single function stays as-is."""
127+
funcs = [CapNode("function", "mod::only", {"entry": True})]
128+
edges = [CapEdge("mutates", "mod::only", "Obj")]
129+
130+
merged_funcs, merged_edges, mapping = _merge_equivalent_functions(funcs, edges)
131+
132+
assert len(merged_funcs) == 1
133+
assert "_merged_names" not in merged_funcs[0].properties
134+
assert mapping["mod::only"] == "mod::only"
135+
136+
137+
class TestMermaidRendering:
138+
139+
def test_merged_node_renders_with_function_symbol(self):
140+
"""Merged node renders with ƒ prefix and <br/> separators."""
141+
node = CapNode("function", "pkg::mod::foo", {
142+
"_merged_names": ["pkg::mod::foo", "pkg::mod::bar", "pkg::mod::baz"]
143+
})
144+
145+
result = _render_mermaid_node(node)
146+
147+
assert "ƒ foo" in result
148+
assert "ƒ bar" in result
149+
assert "ƒ baz" in result
150+
assert "<br/>" in result
151+
152+
def test_single_func_renders_with_function_symbol(self):
153+
"""Single function node renders with ƒ prefix."""
154+
node = CapNode("function", "pkg::mod::my_func", {})
155+
156+
result = _render_mermaid_node(node)
157+
158+
assert "ƒ my_func" in result
159+
assert "<br/>" not in result
160+
161+
def test_capability_no_function_symbol(self):
162+
"""Capability nodes don't get ƒ symbol."""
163+
node = CapNode("capability", "pkg::mod::AdminCap", {})
164+
165+
result = _render_mermaid_node(node)
166+
167+
assert "ƒ" not in result
168+
assert "AdminCap" in result

0 commit comments

Comments
 (0)