fix(export): restore graph.html for large graphs (#2853)

The label and cluster-only commands did not pass the viz node limit to to_html, so a
graph over the node limit raised and the except branch silently unlinked the graph.html
that update had produced. Always pass the limit (the aggregated community meta-graph
renders instead of raising), preserve the existing file on a failed render via an atomic
publish plus a stale marker, and regenerate a missing graph.html on the no-topology-change
fast path without reclustering.
This commit is contained in:
oleksii-tumanov
2026-08-20 16:10:36 +01:00
committed by safishamsi
parent 917e331ab4
commit 05f90f1682
5 changed files with 563 additions and 58 deletions
+56 -16
View File
@@ -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":
+14 -6
View File
@@ -5,12 +5,14 @@ from graphify.exporters.base import COMMUNITY_COLORS # noqa: E402,F401
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 to_html(
</body>
</html>"""
Path(output_path).write_text(html, encoding="utf-8") # nosec
write_text_atomic(output_path, html)
return True
+105 -34
View File
@@ -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 _rebuild_code(
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 _rebuild_code(
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 _rebuild_code(
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.
+257
View File
@@ -6,6 +6,7 @@ malformed replies, and the no-backend fallback.
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
+131 -2
View File
@@ -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()
# And the documented kill switch still means "no viz", not "aggregate".
# 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")
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