mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-28 09:17:02 +00:00
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:
committed by
safishamsi
parent
917e331ab4
commit
05f90f1682
+56
-16
@@ -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":
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user