From ae86650f4bec49bad8aaca7df018c447b237f3f5 Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Wed, 19 Aug 2026 18:19:54 -0500 Subject: [PATCH] fix(export): restore graph.html for large graphs --- graphify/cli.py | 72 ++++++++--- graphify/exporters/html.py | 20 ++- graphify/watch.py | 139 +++++++++++++++----- tests/test_labeling.py | 257 +++++++++++++++++++++++++++++++++++++ tests/test_watch.py | 133 ++++++++++++++++++- 5 files changed, 563 insertions(+), 58 deletions(-) diff --git a/graphify/cli.py b/graphify/cli.py index cb30420473..6aefb66721 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -1831,22 +1831,19 @@ def dispatch_command(cmd: str) -> None: stages = _StageTimer(co_timing) print("Loading existing graph...") # Solution 3 (#1019): don't hard-exit on an oversized graph.json here. - # Core outputs (graph.json + GRAPH_REPORT.md) still get written; the - # graph.html render below falls back to the community-aggregation view - # (node_limit=5000) when over the cap. + # Core outputs (graph.json + GRAPH_REPORT.md) still get written. The + # visualization policy below uses its own node-count limit. from graphify.security import check_graph_file_size_cap as _check_cap - _over_cap = False try: _check_cap(graph_json) except ValueError: - _over_cap = True try: _over_cap_bytes = graph_json.stat().st_size except OSError: _over_cap_bytes = -1 print( f"warning: graph.json exceeds cap ({_over_cap_bytes} bytes); " - f"falling back to community-aggregation view (node_limit=5000)", + "continuing with best-effort visualization", file=sys.stderr, ) _raw = json.loads(graph_json.read_text(encoding="utf-8")) @@ -2038,12 +2035,31 @@ def dispatch_command(cmd: str) -> None: # Snapshot BEFORE any artifact is replaced: GRAPH_REPORT.md was written # first, so the dated folder held the NEW report, not the previous (#2402). from graphify.export import backup_if_protected as _backup + from graphify.exporters.html import _HTML_STALE_MARKER _backup(out) + html_stale_marker = out / _HTML_STALE_MARKER + + def _clear_html_stale_marker() -> None: + try: + html_stale_marker.unlink(missing_ok=True) + except OSError as exc: + print( + "warning: graph.html stale marker could not be cleared; " + f"regeneration may be retried: {exc}", + file=sys.stderr, + ) + + stale_marker_preexisted = html_stale_marker.exists() + # Mark before graph.json advances. Report/sidecar generation or process + # interruption must not leave an older HTML looking current. + html_stale_marker.touch() # The #479 guard can refuse this write, so it goes before the sidecars — # a report and labels describing a clustering graph.json does not contain # are worse than no run at all (#2436). if not to_json(G, communities, str(out / "graph.json"), community_labels=labels, built_at_commit=_commit): + if not stale_marker_preexisted: + _clear_html_stale_marker() print( "graph.json NOT written: refusing to overwrite (see warning above). " "GRAPH_REPORT.md, .graphify_labels.json and .graphify_analysis.json " @@ -2092,22 +2108,46 @@ def dispatch_command(cmd: str) -> None: if no_viz: if html_target.exists(): html_target.unlink() + _clear_html_stale_marker() stages.mark("export"); stages.total() print(f"Done - {len(communities)} communities. GRAPH_REPORT.md and graph.json updated (--no-viz; graph.html removed).") else: + html_written = False + skip_reason: str | None = None try: - # Over-cap fallback (#1019): force the community-aggregation - # path so an oversized graph still renders a usable graph.html. - _node_limit = 5000 if _over_cap else None - to_html(G, communities, str(html_target), community_labels=labels or None, - node_limit=_node_limit) - stages.mark("export"); stages.total() - print(f"Done - {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.") + from graphify.exporters.html import _viz_node_limit + viz_limit = _viz_node_limit() + if viz_limit <= 0: + html_target.unlink(missing_ok=True) + _clear_html_stale_marker() + skip_reason = "GRAPHIFY_VIZ_NODE_LIMIT=0 disables HTML visualization" + else: + # Passing the positive visualization limit explicitly selects + # the community meta-graph when the full graph is too large. + html_written = to_html( + G, + communities, + str(html_target), + community_labels=labels or None, + node_limit=viz_limit, + ) + if html_written: + _clear_html_stale_marker() + else: + skip_reason = "no useful community aggregation could be generated" + if html_target.exists(): + skip_reason += "; existing graph.html left unchanged" except ValueError as viz_err: + skip_reason = str(viz_err) if html_target.exists(): - html_target.unlink() - print(f"Skipped graph.html: {viz_err}") - stages.mark("export"); stages.total() + skip_reason += "; existing graph.html left unchanged" + + if skip_reason: + print(f"Skipped graph.html: {skip_reason}") + stages.mark("export"); stages.total() + if html_written: + print(f"Done - {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.") + else: print(f"Done - {len(communities)} communities. GRAPH_REPORT.md and graph.json updated.") elif cmd == "update": diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py index 0f0560b50d..62ba5b2a86 100644 --- a/graphify/exporters/html.py +++ b/graphify/exporters/html.py @@ -5,12 +5,14 @@ from pathlib import Path import html as _html from graphify.analyze import _node_community_map +from graphify.paths import write_text_atomic import json import networkx as nx from graphify.security import sanitize_label MAX_NODES_FOR_VIZ = 5_000 +_HTML_STALE_MARKER = ".graph.html.stale" def _viz_node_limit() -> int: """Return the effective viz node limit, honoring GRAPHIFY_VIZ_NODE_LIMIT env var. @@ -399,7 +401,7 @@ def to_html( member_counts: dict[int, int] | None = None, node_limit: int | None = None, learning_overlay: dict | None = None, -) -> None: +) -> bool: """Generate an interactive vis.js HTML visualization of the graph. Features: node size by degree, click-to-inspect panel, search box, @@ -411,6 +413,9 @@ def to_html( If node_limit is set and the graph exceeds it, automatically builds an aggregated community-level meta-graph instead of raising ValueError. + + Returns True when the output was written. Returns False when an aggregated + view would contain fewer than two communities and is intentionally skipped. """ limit = node_limit if node_limit is not None else _viz_node_limit() if G.number_of_nodes() > limit: @@ -433,7 +438,7 @@ def to_html( relation=f"{w} cross-community edges", confidence="AGGREGATED") if meta.number_of_nodes() <= 1: print("Single community - aggregated view not useful. Skipping graph.html.") - return + return False meta_communities = {cid: [str(cid)] for cid in communities} mc = {cid: len(members) for cid, members in communities.items()} # Remap hyperedges from semantic node IDs to community IDs @@ -460,11 +465,13 @@ def to_html( "nodes": comm_ids, }) meta.graph["hyperedges"] = remapped - to_html(meta, meta_communities, output_path, - community_labels=community_labels, member_counts=mc) + written = to_html(meta, meta_communities, output_path, + community_labels=community_labels, member_counts=mc) + if not written: + return False print(f"graph.html written (aggregated: {meta.number_of_nodes()} community nodes, {meta.number_of_edges()} cross-community edges)") print("Tip: run with --obsidian for full node-level detail.") - return + return True raise ValueError( f"Graph has {G.number_of_nodes()} nodes - too large for HTML viz " f"(limit: {limit}). Use --no-viz, raise GRAPHIFY_VIZ_NODE_LIMIT, " @@ -626,4 +633,5 @@ def _js_safe(obj) -> str: """ - Path(output_path).write_text(html, encoding="utf-8") # nosec + write_text_atomic(output_path, html) + return True diff --git a/graphify/watch.py b/graphify/watch.py index 69986ea86a..8ad02c4df8 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1001,6 +1001,88 @@ def _stabilize_rebuild_cwd(watch_path: Path) -> bool: return False +def _reconcile_graph_html(out: Path, graph_data: dict) -> str | None: + """Reconcile missing, stale, or explicitly disabled HTML visualization. + + The unchanged-topology update path deliberately avoids clustering and + rewriting core artifacts. HTML is independently derivable, so rebuild it + from the communities and names already stored in graph.json when absent or + marked stale by a prior failed render. + + Returns ``"rendered"`` or ``"removed"`` when it changed the HTML state, + otherwise ``None``. + """ + html_target = out / "graph.html" + from graphify.exporters.html import _HTML_STALE_MARKER, _viz_node_limit + stale_marker = out / _HTML_STALE_MARKER + + def clear_stale_marker() -> None: + try: + stale_marker.unlink(missing_ok=True) + except OSError as exc: + print( + "[graphify watch] graph.html stale marker could not be cleared; " + f"regeneration may be retried: {exc}" + ) + + limit = _viz_node_limit() + if limit <= 0: + changed = html_target.exists() or stale_marker.exists() + html_target.unlink(missing_ok=True) + clear_stale_marker() + return "removed" if changed else None + if html_target.exists() and not stale_marker.exists(): + return None + + had_html = html_target.exists() + + node_communities = _node_community_map(graph_data) + communities: dict[int, list[str]] = {} + for node_id, cid in node_communities.items(): + communities.setdefault(cid, []).append(node_id) + + labels: dict[int, str] = {} + for node in graph_data.get("nodes", []): + cid = node_communities.get(str(node.get("id"))) + name = node.get("community_name") + if cid is not None and isinstance(name, str) and name: + labels.setdefault(cid, name) + + try: + from graphify.export import to_html + from graphify.paths import load_node_link_graph + + persisted_graph = load_node_link_graph(graph_data) + written = to_html( + persisted_graph, + communities, + str(html_target), + community_labels=labels or None, + node_limit=limit, + ) + except Exception as exc: + if had_html: + print( + "[graphify watch] Stale graph.html left unchanged; " + f"regeneration will be retried: {exc}" + ) + else: + print(f"[graphify watch] Missing graph.html could not be regenerated: {exc}") + return None + + if not written or not html_target.exists(): + if had_html: + print( + "[graphify watch] Stale graph.html left unchanged; " + "no useful community view was generated." + ) + else: + print("[graphify watch] Missing graph.html has no useful community view; skipped.") + return None + clear_stale_marker() + return "rendered" + + def _rebuild_code( watch_path: Path, *, @@ -1103,7 +1185,7 @@ def _rebuild_code( from graphify.cluster import cluster, remap_communities_to_previous, score_all from graphify.analyze import god_nodes, surprising_connections, suggest_questions from graphify.report import generate - from graphify.export import to_json, to_html + from graphify.export import to_json from graphify.security import check_graph_file_size_cap # Re-apply the excludes the initial extract recorded, so an update/watch/ @@ -1596,7 +1678,19 @@ def _failed(f: str) -> bool: flag = out / "needs_update" if flag.exists(): flag.unlink() - print("[graphify watch] No code-graph topology changes detected; outputs left untouched.") + html_action = _reconcile_graph_html(out, existing_graph_data) + if html_action == "rendered": + print( + "[graphify watch] No code-graph topology changes detected; " + "regenerated missing or stale graph.html." + ) + elif html_action == "removed": + print( + "[graphify watch] No code-graph topology changes detected; " + "removed graph.html because HTML visualization is disabled." + ) + else: + print("[graphify watch] No code-graph topology changes detected; outputs left untouched.") return True communities = cluster(G) @@ -1717,6 +1811,10 @@ def _failed(f: str) -> bool: failed_sources=failed_sources, ): return False + from graphify.exporters.html import _HTML_STALE_MARKER + # Mark before graph.json advances so an interruption cannot leave a + # previous visualization looking current to the fast path. + (out / _HTML_STALE_MARKER).touch() from graphify.export import backup_if_protected as _backup _backup(out) graph_tmp.replace(existing_graph) @@ -1744,40 +1842,13 @@ def _failed(f: str) -> bool: except Exception: pass - # to_html raises ValueError for graphs > the viz node limit. - # Wrap so core outputs (graph.json + GRAPH_REPORT.md) always land. + # Reconcile from the persisted graph. The stale marker was written + # before graph.json advanced, so a failed or interrupted atomic render + # remains retryable from the unchanged-topology fast path. html_written = False if not no_change: - html_target = out / "graph.html" - try: - to_html(G, communities, str(html_target), community_labels=labels or None) - html_written = True - except ValueError as viz_err: - # Over the cap. Deleting was defensible on its own — a kept - # graph.html would describe an older, smaller graph — but it - # leaves a project that crossed the threshold with no - # visualization at all, and the file is gone before the user - # sees the message. The export path (#1019) already re-renders - # the community-aggregation view in exactly this case, so do - # the same here: current AND present beats current OR present. - from graphify.exporters.html import _viz_node_limit - if html_target.exists(): - html_target.unlink() - limit = _viz_node_limit() - if limit <= 0: - # GRAPHIFY_VIZ_NODE_LIMIT=0 means "no HTML viz" (CI runners), - # so honour it rather than aggregating around it. - print(f"[graphify watch] Skipped graph.html: {viz_err}") - else: - try: - to_html(G, communities, str(html_target), - community_labels=labels or None, node_limit=limit) - # The aggregator declines to write a single-community - # graph, so trust the file rather than the call. - html_written = html_target.exists() - except Exception as fallback_err: - print(f"[graphify watch] Skipped graph.html: {viz_err} " - f"(aggregated view also failed: {fallback_err})") + html_action = _reconcile_graph_html(out, candidate_graph_data) + html_written = html_action == "rendered" # Regenerate callflow HTML if the user previously generated one — # opt-in by existence so users who never ran callflow-html aren't affected. diff --git a/tests/test_labeling.py b/tests/test_labeling.py index 00de11dff8..9a5d9bc1dc 100644 --- a/tests/test_labeling.py +++ b/tests/test_labeling.py @@ -6,6 +6,7 @@ import json import re import sys +from pathlib import Path import networkx as nx import pytest @@ -502,6 +503,262 @@ def _two_community_graph(out): (out / "graph.json").write_text(json.dumps(graph), encoding="utf-8") +@pytest.mark.parametrize("command", ["cluster-only", "label"]) +def test_cluster_commands_render_aggregated_html_above_viz_limit( + tmp_path, monkeypatch, capsys, command, +): + """#2853: relabeling a large graph must keep a current aggregated HTML.""" + import graphify.__main__ as cli + + out = tmp_path / "graphify-out" + out.mkdir() + _two_community_graph(out) + html = out / "graph.html" + html.write_text("stale visualization", encoding="utf-8") + + monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", "3") + monkeypatch.setattr(cli, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + "graphify.llm.generate_community_labels", + lambda G, comms, **kwargs: ( + {cid: f"Fresh community {cid}" for cid in comms}, + "test", + ), + ) + + argv = ["graphify", command, str(tmp_path)] + if command == "cluster-only": + argv.append("--no-label") + monkeypatch.setattr(sys, "argv", argv) + + cli.main() + + output = capsys.readouterr().out + assert html.exists() + assert not (out / ".graph.html.stale").exists() + rendered = html.read_text(encoding="utf-8") + assert "stale visualization" not in rendered + if command == "label": + assert "Fresh community" in rendered + assert "aggregated" in output + assert "graph.html updated" in output + + +def test_cluster_only_preserves_but_does_not_claim_unusable_aggregate( + tmp_path, monkeypatch, capsys, +): + """A skipped aggregate must not race with or falsely claim an HTML write.""" + import importlib + import graphify.__main__ as cli + + out = tmp_path / "graphify-out" + out.mkdir() + _two_community_graph(out) + html = out / "graph.html" + html.write_text("stale visualization", encoding="utf-8") + + monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", "3") + monkeypatch.setattr(cli, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + importlib.import_module("graphify.cluster"), + "cluster", + lambda G, **kwargs: {0: list(G.nodes())}, + ) + monkeypatch.setattr( + sys, + "argv", + ["graphify", "cluster-only", str(tmp_path), "--no-label"], + ) + + cli.main() + + output = capsys.readouterr().out + assert html.read_text(encoding="utf-8") == "stale visualization" + assert (out / ".graph.html.stale").exists() + assert "Skipped graph.html" in output + assert "existing graph.html left unchanged" in output + assert "graph.html updated" not in output + + +def test_cluster_only_restores_html_after_unexpected_render_failure( + tmp_path, monkeypatch, +): + """A failed render must not destroy the previous HTML file.""" + import importlib + import graphify.__main__ as cli + + out = tmp_path / "graphify-out" + out.mkdir() + _two_community_graph(out) + html = out / "graph.html" + html.write_text("previous visualization", encoding="utf-8") + + def fail_render(*args, **kwargs): + raise OSError("simulated render failure") + + monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", "3") + monkeypatch.setattr(cli, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + importlib.import_module("graphify.export"), + "to_html", + fail_render, + ) + monkeypatch.setattr( + sys, + "argv", + ["graphify", "cluster-only", str(tmp_path), "--no-label"], + ) + + with pytest.raises(OSError, match="simulated render failure"): + cli.main() + + assert html.read_text(encoding="utf-8") == "previous visualization" + assert (out / ".graph.html.stale").exists() + assert not list(out.glob(".graph.html.*.previous")) + + +def test_cluster_only_marks_html_stale_before_report_generation( + tmp_path, monkeypatch, +): + """An interruption after graph.json advances must remain repairable.""" + import graphify.__main__ as cli + from graphify.watch import _reconcile_graph_html + + out = tmp_path / "graphify-out" + out.mkdir() + _two_community_graph(out) + html = out / "graph.html" + html.write_text("previous visualization", encoding="utf-8") + + def interrupt_report(*args, **kwargs): + raise KeyboardInterrupt + + monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", "3") + monkeypatch.setattr(cli, "_check_skill_version", lambda _: None) + monkeypatch.setattr("graphify.report.generate", interrupt_report) + monkeypatch.setattr( + sys, + "argv", + ["graphify", "cluster-only", str(tmp_path), "--no-label"], + ) + + with pytest.raises(KeyboardInterrupt): + cli.main() + + marker = out / ".graph.html.stale" + assert html.read_text(encoding="utf-8") == "previous visualization" + assert marker.exists() + + persisted = json.loads((out / "graph.json").read_text(encoding="utf-8")) + assert _reconcile_graph_html(out, persisted) == "rendered" + assert html.read_text(encoding="utf-8") != "previous visualization" + assert not marker.exists() + + +def test_cluster_only_refused_graph_write_preserves_existing_stale_marker( + tmp_path, monkeypatch, +): + """A refused write must not erase retry state owned by an earlier run.""" + import graphify.__main__ as cli + + out = tmp_path / "graphify-out" + out.mkdir() + _two_community_graph(out) + html = out / "graph.html" + html.write_text("known stale visualization", encoding="utf-8") + marker = out / ".graph.html.stale" + marker.touch() + + monkeypatch.setattr(cli, "_check_skill_version", lambda _: None) + monkeypatch.setattr("graphify.export.to_json", lambda *args, **kwargs: False) + monkeypatch.setattr( + sys, + "argv", + ["graphify", "cluster-only", str(tmp_path), "--no-label"], + ) + + with pytest.raises(SystemExit) as stopped: + cli.main() + + assert stopped.value.code == 1 + assert html.read_text(encoding="utf-8") == "known stale visualization" + assert marker.exists() + + +def test_cluster_only_succeeds_when_stale_marker_cleanup_fails( + tmp_path, monkeypatch, capsys, +): + """A completed HTML replacement must remain a successful command.""" + import graphify.__main__ as cli + + out = tmp_path / "graphify-out" + out.mkdir() + _two_community_graph(out) + html = out / "graph.html" + html.write_text("stale visualization", encoding="utf-8") + marker = out / ".graph.html.stale" + marker.touch() + original_unlink = Path.unlink + + def reject_marker_unlink(path, *args, **kwargs): + if path == marker: + raise PermissionError("simulated marker cleanup failure") + return original_unlink(path, *args, **kwargs) + + monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", "3") + monkeypatch.setattr(cli, "_check_skill_version", lambda _: None) + monkeypatch.setattr(Path, "unlink", reject_marker_unlink) + monkeypatch.setattr( + sys, + "argv", + ["graphify", "cluster-only", str(tmp_path), "--no-label"], + ) + + cli.main() + + captured = capsys.readouterr() + assert html.read_text(encoding="utf-8") != "stale visualization" + assert marker.exists() + assert "graph.html updated" in captured.out + assert "stale marker could not be cleared" in captured.err + + +def test_cluster_only_does_not_erase_concurrent_html_after_failure( + tmp_path, monkeypatch, +): + """A failing renderer must not roll back a concurrent successful writer.""" + import importlib + import graphify.__main__ as cli + + out = tmp_path / "graphify-out" + out.mkdir() + _two_community_graph(out) + html = out / "graph.html" + html.write_text("previous visualization", encoding="utf-8") + + def concurrent_then_fail(*args, **kwargs): + html.write_text("newer concurrent visualization", encoding="utf-8") + raise OSError("simulated render failure") + + monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", "3") + monkeypatch.setattr(cli, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + importlib.import_module("graphify.export"), + "to_html", + concurrent_then_fail, + ) + monkeypatch.setattr( + sys, + "argv", + ["graphify", "cluster-only", str(tmp_path), "--no-label"], + ) + + with pytest.raises(OSError, match="simulated render failure"): + cli.main() + + assert html.read_text(encoding="utf-8") == "newer concurrent visualization" + + def test_cluster_only_no_label_does_not_persist_placeholders(tmp_path, monkeypatch): """#2073: --no-label must not write .graphify_labels.json with 'Community N' placeholders (which the reuse path would then treat as fresh forever). A diff --git a/tests/test_watch.py b/tests/test_watch.py index 82526e4a1e..25d0cd9673 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -381,6 +381,25 @@ def test_rebuild_code_keeps_a_visualization_when_over_the_viz_cap(tmp_path, monk assert len(communities) < cap < len(graph["nodes"]), "test corpus cannot exercise the cap" monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", str(cap)) (corpus / "g9_extra.py").write_text("def extra():\n return 1\n", encoding="utf-8") + + real_replace = os.replace + + def fail_html_publish(src, dst): + if Path(dst).name == "graph.html": + raise OSError("simulated atomic HTML publish failure") + return real_replace(src, dst) + + with monkeypatch.context() as failed_render: + failed_render.setattr("graphify.paths.os.replace", fail_html_publish) + assert _rebuild_code(corpus, acquire_lock=False) is True + + assert html.read_text(encoding="utf-8") == before, ( + "a failed aggregate publish must preserve the previous complete HTML" + ) + assert (corpus / "graphify-out" / ".graph.html.stale").exists() + + # With no further code change, the fast path consumes the stale marker and + # retries the current aggregate rather than trusting the preserved old file. assert _rebuild_code(corpus, acquire_lock=False) is True assert html.exists(), ( @@ -389,14 +408,124 @@ def test_rebuild_code_keeps_a_visualization_when_over_the_viz_cap(tmp_path, monk ) after = html.read_text(encoding="utf-8") assert after != before, "graph.html must be re-rendered, not left stale" + assert not (corpus / "graphify-out" / ".graph.html.stale").exists() + + # Missing derived output must be repaired by the unchanged-topology path. + # The repair must reuse persisted communities rather than reclustering or + # rewriting the graph, report, or label sidecars. + stable_paths = [ + corpus / "graphify-out" / "graph.json", + corpus / "graphify-out" / "GRAPH_REPORT.md", + corpus / "graphify-out" / ".graphify_labels.json", + corpus / "graphify-out" / ".graphify_labels.json.sig", + ] + stable_bytes = {path: path.read_bytes() for path in stable_paths if path.exists()} + html.unlink() + + def fail_cluster(*args, **kwargs): + raise AssertionError("unchanged update must not recluster to restore graph.html") - # And the documented kill switch still means "no viz", not "aggregate". + with monkeypatch.context() as recovery_patch: + recovery_patch.setattr("graphify.cluster.cluster", fail_cluster) + assert _rebuild_code(corpus, acquire_lock=False) is True + + assert html.exists(), "unchanged update did not restore missing graph.html" + assert html.read_text(encoding="utf-8") == after + for path, expected in stable_bytes.items(): + assert path.read_bytes() == expected, f"recovery rewrote stable artifact {path.name}" + + # The documented kill switch also applies on the unchanged-topology path. monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", "0") - (corpus / "g9_extra2.py").write_text("def extra2():\n return 2\n", encoding="utf-8") assert _rebuild_code(corpus, acquire_lock=False) is True assert not html.exists(), "GRAPHIFY_VIZ_NODE_LIMIT=0 must disable the HTML viz outright" +def test_missing_html_recovery_preserves_multigraph_edge_counts(tmp_path, monkeypatch): + """Aggregated recovery must count every parallel edge in persisted graphs.""" + from graphify.watch import _reconcile_graph_html + + out = tmp_path / "graphify-out" + out.mkdir() + graph = { + "directed": True, + "multigraph": True, + "nodes": [ + {"id": "a", "label": "A", "community": 0, "community_name": "Left"}, + {"id": "b", "label": "B", "community": 1, "community_name": "Right"}, + {"id": "c", "label": "C", "community": 0, "community_name": "Left"}, + {"id": "d", "label": "D", "community": 1, "community_name": "Right"}, + ], + "links": [ + {"source": "a", "target": "b", "key": "calls", "relation": "calls"}, + {"source": "a", "target": "b", "key": "imports", "relation": "imports"}, + ], + } + monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", "3") + original_touch = Path.touch + + with monkeypatch.context() as failed_render: + def fail_replace(*args, **kwargs): + raise OSError("simulated atomic publish failure") + + def fail_marker_touch(path, *args, **kwargs): + if path == out / ".graph.html.stale": + raise PermissionError("simulated marker write failure") + return original_touch(path, *args, **kwargs) + + failed_render.setattr("graphify.paths.os.replace", fail_replace) + failed_render.setattr(Path, "touch", fail_marker_touch) + assert _reconcile_graph_html(out, graph) is None + + assert not (out / "graph.html").exists() + # Missing HTML is itself the retry signal; recovery must not depend on a + # writable marker file. + assert not (out / ".graph.html.stale").exists() + assert _reconcile_graph_html(out, graph) == "rendered" + assert not (out / ".graph.html.stale").exists() + + rendered = (out / "graph.html").read_text(encoding="utf-8") + assert "2 cross-community edges" in rendered + + +def test_html_recovery_succeeds_when_stale_marker_cleanup_fails( + tmp_path, monkeypatch, capsys, +): + """A current atomic HTML write must not be reported as a rebuild failure.""" + from graphify.watch import _reconcile_graph_html + + out = tmp_path / "graphify-out" + out.mkdir() + html = out / "graph.html" + html.write_text("stale visualization", encoding="utf-8") + marker = out / ".graph.html.stale" + marker.touch() + graph = { + "directed": False, + "multigraph": False, + "nodes": [ + {"id": "a", "label": "A", "community": 0}, + {"id": "b", "label": "B", "community": 0}, + {"id": "c", "label": "C", "community": 1}, + {"id": "d", "label": "D", "community": 1}, + ], + "links": [], + } + original_unlink = Path.unlink + + def reject_marker_unlink(path, *args, **kwargs): + if path == marker: + raise PermissionError("simulated marker cleanup failure") + return original_unlink(path, *args, **kwargs) + + monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", "3") + monkeypatch.setattr(Path, "unlink", reject_marker_unlink) + + assert _reconcile_graph_html(out, graph) == "rendered" + assert html.read_text(encoding="utf-8") != "stale visualization" + assert marker.exists() + assert "stale marker could not be cleared" in capsys.readouterr().out + + def test_update_rebuilds_with_nested_star_gitignore(tmp_path): """#1880: `graphify update` must not emit 0 nodes (and then refuse to overwrite) just because the source tree has a nested `.gitignore` with a