Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 56 additions & 16 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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":
Expand Down
20 changes: 14 additions & 6 deletions graphify/exporters/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
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.
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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, "
Expand Down Expand Up @@ -626,4 +633,5 @@ def _js_safe(obj) -> str:
</body>
</html>"""

Path(output_path).write_text(html, encoding="utf-8") # nosec
write_text_atomic(output_path, html)
return True
139 changes: 105 additions & 34 deletions graphify/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_reconcile_graph_html()

high coupling complexity (Ca·Ce = 20).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_reconcile_graph_html()

6 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_rebuild_code()

fans out to 50 callees (efferent coupling); 98 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_rebuild_code()

fans out to 50 callees (efferent coupling); 98 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

watch_path: Path,
*,
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -1596,7 +1678,19 @@ def _failed(f: str) -> bool:
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)
Expand Down Expand Up @@ -1717,6 +1811,10 @@ def _failed(f: str) -> bool:
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)
Expand Down Expand Up @@ -1744,40 +1842,13 @@ def _failed(f: str) -> bool:
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.
Expand Down
Loading
Loading