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