From 498b76ba32aede9bc540d4178f5d0d96fe9991dd Mon Sep 17 00:00:00 2001 From: Bob Spryn Date: Sun, 26 Jul 2026 15:04:42 -0400 Subject: [PATCH] fix(watch): keep a visualization when the graph outgrows the viz cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crossing MAX_NODES_FOR_VIZ left the project with no graph.html at all: _rebuild_code unlinked the existing file and wrote nothing in its place. The delete on its own is defensible — a kept graph.html would describe an older, smaller graph — but the file is gone before the user reads the message, and the next incremental rebuild silently removes it again, so a repo that grows past the threshold just loses its visualization with no way to keep one. The export path already solved this (#1019): over the cap it re-renders the community-aggregation view rather than going without. Do the same here, so the artifact is current AND present instead of current OR present. GRAPHIFY_VIZ_NODE_LIMIT=0 still means "no HTML viz" (CI runners) rather than "aggregate", and if the aggregated render also fails the old skip-and-remove behaviour stands. --- graphify/watch.py | 34 ++++++++++++++++++++++++----- tests/test_watch.py | 53 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/graphify/watch.py b/graphify/watch.py index 08b6fdd4..45bd97b6 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1359,18 +1359,40 @@ def _rebuild_code( except Exception: pass - # to_html raises ValueError for graphs > MAX_NODES_FOR_VIZ (5000). + # to_html raises ValueError for graphs > the viz node limit. # Wrap so core outputs (graph.json + GRAPH_REPORT.md) always land. html_written = False if not no_change: + html_target = out / "graph.html" try: - to_html(G, communities, str(out / "graph.html"), community_labels=labels or None) + to_html(G, communities, str(html_target), community_labels=labels or None) html_written = True except ValueError as viz_err: - print(f"[graphify watch] Skipped graph.html: {viz_err}") - stale = out / "graph.html" - if stale.exists(): - stale.unlink() + # 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})") # 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_watch.py b/tests/test_watch.py index a9b3ebe8..f2bec415 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -267,6 +267,59 @@ def test_rebuild_code_drops_labels_whose_community_changed(tmp_path): ) +def test_rebuild_code_keeps_a_visualization_when_over_the_viz_cap(tmp_path, monkeypatch): + """Crossing the viz node limit must not leave the project with no graph.html. + _rebuild_code used to unlink the existing file and write nothing, so a repo + that grew past the cap silently lost its visualization — and the file was + already gone by the time the user read the message. The export path falls + back to the community-aggregation view in exactly this case; the incremental + path should too, so the artifact stays both current and present.""" + import json + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + # Several *disconnected* clusters: the aggregator declines to render a + # single-community meta-graph, so a ring of mutually-importing modules + # would collapse to one community and exercise the wrong path. + for g in range(4): + for i in range(3): + other = (i + 1) % 3 + (corpus / f"g{g}_m{i}.py").write_text( + f"import g{g}_m{other}\n\n" + + "".join(f"def g{g}_f{i}_{j}():\n return {j}\n\n" for j in range(4)), + encoding="utf-8", + ) + assert _rebuild_code(corpus, acquire_lock=False) is True + html = corpus / "graphify-out" / "graph.html" + assert html.exists(), "expected a normal (under-cap) rebuild to write graph.html" + before = html.read_text(encoding="utf-8") + + # Drop the cap below the graph's size but above its community count — the + # real shape of this bug. (A cap under the community count would make the + # aggregated meta-graph breach it too, which is a different situation.) + graph = json.loads((corpus / "graphify-out" / "graph.json").read_text(encoding="utf-8")) + communities = {n.get("community") for n in graph["nodes"] if n.get("community") is not None} + cap = (len(communities) + len(graph["nodes"])) // 2 + 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") + assert _rebuild_code(corpus, acquire_lock=False) is True + + assert html.exists(), ( + "graph.html was deleted when the graph exceeded the viz cap — the " + "incremental rebuild must fall back to the aggregated view like export does" + ) + after = html.read_text(encoding="utf-8") + assert after != before, "graph.html must be re-rendered, not left stale" + + # And the documented kill switch still means "no viz", not "aggregate". + 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_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