fix(watch): keep a visualization when the graph outgrows the viz cap

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.
This commit is contained in:
Bob Spryn
2026-07-27 10:22:48 +01:00
committed by safishamsi
parent 6d42c48f4d
commit 498b76ba32
2 changed files with 81 additions and 6 deletions
+28 -6
View File
@@ -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.
+53
View File
@@ -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