Skip to content

Commit 5ec96f0

Browse files
committed
Fix #563 (HTML export): restore arrow direction in graph.html
to_json was patched to consult _src/_tgt before serializing edge endpoints, but to_html still read endpoints directly from G.edges(), so calls and rationale_for arrows in the rendered graph.html still pointed in canonicalized (often inverted) order. Apply the same direction restore in to_html when building vis.js edges. Use data.get (not pop) since G.edges(data=True) yields the live attribute dict and other exporters may run after to_html. Adds test_to_html_preserves_calls_and_rationale_for_direction: parses RAW_EDGES out of the rendered HTML and pins both the caller->callee assertions from #563 and the rationale->parent direction. Without the fix, the test reports 11 flipped arrows on the 3-file repro.
1 parent e636e35 commit 5ec96f0

2 files changed

Lines changed: 77 additions & 3 deletions

File tree

graphify/export.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -424,14 +424,19 @@ def to_html(
424424
"degree": deg,
425425
})
426426

427-
# Build edges list
427+
# Build edges list. Restore original edge direction from _src/_tgt
428+
# (stashed by build.py for exactly this reason): undirected NetworkX
429+
# canonicalizes endpoint order, which would otherwise flip the arrow
430+
# for `calls` and `rationale_for` in the rendered graph (#563).
428431
vis_edges = []
429432
for u, v, data in G.edges(data=True):
430433
confidence = data.get("confidence", "EXTRACTED")
431434
relation = data.get("relation", "")
435+
true_src = data.get("_src", u)
436+
true_tgt = data.get("_tgt", v)
432437
vis_edges.append({
433-
"from": u,
434-
"to": v,
438+
"from": true_src,
439+
"to": true_tgt,
435440
"label": relation,
436441
"title": _html.escape(f"{relation} [{confidence}]"),
437442
"dashes": confidence != "EXTRACTED",

tests/test_issue_563.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,72 @@ def test_src_tgt_metadata_not_leaked_into_graph_json(repro_graph):
255255
assert leaks == [], (
256256
f"_src/_tgt are internal; must not appear in graph.json. Leaks: {leaks[:3]}"
257257
)
258+
259+
260+
# ─────────────────────────────────────────────────────────────────────────────
261+
# Bug 2 (HTML export): graph.html arrows must respect direction too
262+
# ─────────────────────────────────────────────────────────────────────────────
263+
264+
265+
def _vis_edges_from_html(html: str) -> list[dict]:
266+
"""Extract the vis.js edges array embedded in graph.html.
267+
268+
`_html_script` emits `const RAW_EDGES = <json>;` — pull that JSON out
269+
directly rather than parsing the surrounding script.
270+
"""
271+
import re
272+
m = re.search(r"const RAW_EDGES = (\[.*?\]);", html, re.DOTALL)
273+
assert m, "could not locate RAW_EDGES in graph.html"
274+
return json.loads(m.group(1))
275+
276+
277+
def test_to_html_preserves_calls_and_rationale_for_direction(tmp_path: Path):
278+
"""to_html must read _src/_tgt before assigning vis.js `from`/`to`,
279+
otherwise undirected NetworkX storage flips arrows on render (#563)."""
280+
from graphify.build import build_from_json
281+
from graphify.export import to_html
282+
283+
_write_repro(tmp_path)
284+
repro_graph = _run_graphify_update(tmp_path)
285+
286+
G = build_from_json(repro_graph)
287+
out_html = tmp_path / "graph.html"
288+
to_html(G, communities={0: list(G.nodes())}, output_path=str(out_html))
289+
290+
vis_edges = _vis_edges_from_html(out_html.read_text(encoding="utf-8"))
291+
pairs = {(e["from"], e["to"]): e.get("label") for e in vis_edges}
292+
293+
# rationale_for: source must be the rationale node (file_type metadata
294+
# isn't on vis_edges, so we check via the json graph's node map).
295+
ftype = _file_type_map(repro_graph)
296+
rats_html = [
297+
(frm, to) for (frm, to), rel in pairs.items() if rel == "rationale_for"
298+
]
299+
assert rats_html, "expected rationale_for edges in graph.html"
300+
flipped_rats = [
301+
(frm, to) for (frm, to) in rats_html if ftype.get(frm) != "rationale"
302+
]
303+
assert flipped_rats == [], (
304+
f"rationale_for arrows in graph.html must point rationale->parent. "
305+
f"Flipped: {flipped_rats[:3]}"
306+
)
307+
308+
# calls: pin the same caller->callee directions asserted on graph.json.
309+
assert (
310+
"contact_form_contact_form",
311+
"jobq_sqlitequeue_check_idempotency",
312+
) in pairs, "contact_form->check_idempotency arrow missing in graph.html"
313+
assert (
314+
"contact_form_contact_form",
315+
"jobq_sqlitequeue_enqueue",
316+
) in pairs, "contact_form->enqueue arrow missing in graph.html"
317+
318+
# And the inversions must not be present.
319+
inversions = {
320+
("jobq_sqlitequeue_check_idempotency", "contact_form_contact_form"),
321+
("jobq_sqlitequeue_enqueue", "contact_form_contact_form"),
322+
}
323+
leaked = inversions & {
324+
(frm, to) for (frm, to), rel in pairs.items() if rel == "calls"
325+
}
326+
assert not leaked, f"Inverted calls arrows in graph.html: {leaked}"

0 commit comments

Comments
 (0)