Skip to content
Closed
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
38 changes: 32 additions & 6 deletions graphify/callflow_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,14 @@ def _node_link_payload(data: dict) -> tuple[list, list] | None:
# though the shape check above accepts "edges" (#2212).
from graphify.paths import load_node_link_graph

graph = load_node_link_graph(data)
# Force directed/multigraph so the stored caller->callee direction and
# parallel edges survive the round-trip; mirrors affected.py:263,
# serve.py:42 and cli.py:1229 (#1174). graph.json is written with
# "directed": false for backward compatibility (build.py:658), so
# without this networkx returns an undirected Graph and edge
# orientation becomes arbitrary -- which silently swaps the Caller and
# Callee columns of the call table and drops parallel edges.
graph = load_node_link_graph({**data, "directed": True, "multigraph": True})
except Exception:
return None

Expand Down Expand Up @@ -1207,16 +1214,35 @@ def format_node_refs(node_ids: set, node_by_id: dict, lang: str, empty_text: str
return "<br>".join(parts)


def generate_call_table_rows(nodes: list, section_edges: list, lang: str) -> str:
"""Generate call table row scaffolding for a section's nodes."""
def generate_call_table_rows(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiongenerate_call_table_rows()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

nodes: list,
section_edges: list,
lang: str,
all_edges: list | None = None,
all_nodes: list | None = None,
) -> str:
"""Generate call table row scaffolding for a section's nodes.

The Caller/Callee columns make a claim about the whole graph ("External
entry / no inbound edge"), so they must be computed from the whole graph.
Built from ``section_edges`` alone they only see edges whose *both*
endpoints sit in this section, so a node called from anywhere else is
mislabelled an entry point. Section coverage makes that the common case
rather than a corner case: only ``max_sections`` communities are rendered,
so most callers are not in any rendered section at all.

``all_edges``/``all_nodes`` are the full graph; ``all_nodes`` also lets
out-of-section callers render as labels instead of raw node ids. Both
default to None, preserving the previous behaviour for other callers.
"""
if not nodes:
return ""

# Build source/target lookup from edges
node_by_id = {n.get("id"): n for n in nodes}
node_by_id = {n.get("id"): n for n in (all_nodes or nodes)}
callers = defaultdict(set)
callees = defaultdict(set)
for e in section_edges:
for e in (all_edges if all_edges is not None else section_edges):
src = e.get("source", "")
tgt = e.get("target", "")
if e.get("relation") in ("calls", "imports", "imports_from", "uses", "method"):
Expand Down Expand Up @@ -1743,7 +1769,7 @@ def write_callflow_html(
<th style="width:20%">{callee_header}</th>
<th style="width:20%">{desc_header}</th>
</tr>
{generate_call_table_rows(sec_nodes, sec_edges, lang)}
{generate_call_table_rows(sec_nodes, sec_edges, lang, edges, nodes)}
</table>

{generate_section_cards(sec, sec_nodes, sec_edges, lang)}
Expand Down
32 changes: 32 additions & 0 deletions tests/test_callflow_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,35 @@ def test_load_graph_rejects_oversized_file(monkeypatch, tmp_path):
with pytest.raises(SystemExit) as excinfo:
load_graph(graph_path)
assert "exceeds" in str(excinfo.value)


def test_load_graph_preserves_edge_direction(tmp_path):
"""#1174: graph.json is written with "directed": false, so the node-link
parser must be told otherwise or networkx returns an undirected Graph and
caller->callee orientation becomes arbitrary."""
from graphify.callflow_html import load_graph

out = _make_graphify_out(tmp_path)
_nodes, edges, _hyper, _meta = load_graph(out / "graph.json")

directed = {(e["source"], e["target"]) for e in edges}
assert ("run", "api") in directed
assert ("api", "run") not in directed, "edge direction was lost (undirected load)"


def test_call_table_caller_column_sees_other_sections(tmp_path):
"""A node called from a different section is not an "External entry".

``export`` is used by ``api``, which lives in another community. Computing
the Caller column from section-local edges alone mislabels it an entry
point -- a whole-graph claim made from partial data.
"""
from graphify.callflow_html import generate_call_table_rows, load_graph

out = _make_graphify_out(tmp_path)
nodes, edges, _hyper, _meta = load_graph(out / "graph.json")
export_node = [n for n in nodes if n["id"] == "export"]

rows = generate_call_table_rows(export_node, [], "en", edges, nodes)
assert "External entry" not in rows
assert "ApiClient" in rows, "cross-section caller should render as a label"