mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-28 17:26:48 +00:00
fix(cli): reconstruct communities from per-node attribute when sidecar missing (#1001)
`graphify export html|obsidian|wiki|svg|graphml|neo4j` reads `communities`
exclusively from `.graphify_analysis.json` (set to `{}` if missing). The
post-commit / watch rebuild path doesn't regenerate that sidecar — only
graph.json + GRAPH_REPORT.md. Several skill workflows also delete temp
files at the end of `graphify extract`. In both cases the per-node
`community` attribute (`to_json` writes it on every node) is intact, but
the CLI ignores it.
Observed failure: `graphify export html` on a graph that exceeds the
viz node limit prints
Graph has 64703 nodes (above 5000 limit). Building aggregated community view...
Single community - aggregated view not useful. Skipping graph.html.
even though the same graph.json has 2,026 distinct `community` values
on its nodes — `to_html` just received an empty `communities` dict and
the aggregator collapsed to a single meta-node.
Fix: when the analysis sidecar is absent (or its `communities` field is
empty), reconstruct the `cid -> [node_ids]` mapping from the per-node
attribute in graph.json. The sidecar remains the canonical source of
truth when present; the reconstruction is a strict fallback. Every
downstream subcommand (`html`, `obsidian`, `wiki`, `svg`, `graphml`,
`neo4j`) sees the same shape it always did, just populated from the
graph itself instead of an externally-cached sidecar.
Tests added (tests/test_cli_export.py):
- `test_export_html_falls_back_to_node_community_attribute` —
delete the sidecar, run export html, confirm `graph.html` exists
and the "Single community" bail-out path does NOT fire.
- `test_export_html_fallback_recovers_multiple_communities` —
stronger guarantee that the reconstructed community count equals
what the sidecar would have provided (no silent data loss).
- `test_export_html_no_community_data_at_all_still_succeeds` —
hand-build a graph.json with no per-node `community` attribute
(older `to_json` versions, manually-constructed graphs); the
command must still exit cleanly rather than crash.
All 26 tests in test_cli_export.py pass; ruff clean on both files.
This commit is contained in:
@@ -2624,6 +2624,29 @@ def main() -> None:
|
||||
cohesion = {}
|
||||
gods_data = []
|
||||
|
||||
# Fallback: graph.json carries the per-node community as a node attribute
|
||||
# (`to_json` writes it on every node). The analysis sidecar is the
|
||||
# canonical source — but the post-commit / watch rebuild path doesn't
|
||||
# regenerate it, and `extract` may have its temp files cleaned up. When
|
||||
# that happens, `graphify export html` previously bailed with
|
||||
# "Single community - aggregated view not useful." even though the
|
||||
# per-node attribute had the right data all along. Reconstruct from
|
||||
# the graph itself so downstream subcommands (html, obsidian, wiki,
|
||||
# svg, graphml, neo4j) don't silently produce a degraded artifact.
|
||||
if not communities:
|
||||
reconstructed: dict[int, list[str]] = {}
|
||||
for node_id, data in G.nodes(data=True):
|
||||
cid_raw = data.get("community")
|
||||
if cid_raw is None:
|
||||
continue
|
||||
try:
|
||||
cid = int(cid_raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
reconstructed.setdefault(cid, []).append(str(node_id))
|
||||
if reconstructed:
|
||||
communities = reconstructed
|
||||
|
||||
labels: dict[int, str] = {}
|
||||
if labels_path.exists():
|
||||
labels = {int(k): v for k, v in json.loads(labels_path.read_text(encoding="utf-8")).items()}
|
||||
|
||||
@@ -286,3 +286,86 @@ def test_cluster_only_creates_output_dir_when_missing(tmp_path):
|
||||
r = _run(["cluster-only", ".", "--graph", str(graph_src), "--no-viz"], tmp_path)
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert (tmp_path / "graphify-out" / "GRAPH_REPORT.md").exists()
|
||||
|
||||
|
||||
# ── communities-fallback when .graphify_analysis.json is absent ──────────────
|
||||
# The watch / post-commit rebuild path only writes graph.json + GRAPH_REPORT.md;
|
||||
# it does NOT regenerate .graphify_analysis.json. The full `graphify extract`
|
||||
# pipeline also removes its temp files at the end of the run on some skill
|
||||
# workflows. In both cases the per-node `community` attribute is intact on
|
||||
# every node in graph.json — that's the source of truth `to_json` writes.
|
||||
# Without these tests, `graphify export html|obsidian|wiki|svg|graphml|neo4j`
|
||||
# silently bails or generates a degraded artifact whenever the sidecar is
|
||||
# missing, even though the data is right there.
|
||||
|
||||
def test_export_html_falls_back_to_node_community_attribute(tmp_path):
|
||||
"""When .graphify_analysis.json is absent, export html should reconstruct
|
||||
communities from the per-node attribute in graph.json rather than bailing
|
||||
out with 'Single community - aggregated view not useful.'.
|
||||
"""
|
||||
out = _make_graph(tmp_path)
|
||||
# Simulate the watch-rebuild / cleanup case: graph.json + labels survive,
|
||||
# analysis sidecar is gone.
|
||||
(out / ".graphify_analysis.json").unlink()
|
||||
|
||||
r = _run(["export", "html"], tmp_path)
|
||||
assert r.returncode == 0, r.stderr
|
||||
html = out / "graph.html"
|
||||
assert html.exists(), "graph.html should be generated from the fallback"
|
||||
assert html.stat().st_size > 0
|
||||
# The success message comes from to_html — confirm we're not hitting the
|
||||
# "Single community" bail-out path.
|
||||
assert "Single community" not in r.stdout
|
||||
assert "Single community" not in r.stderr
|
||||
|
||||
|
||||
def test_export_html_fallback_recovers_multiple_communities(tmp_path):
|
||||
"""Stronger assertion: the reconstructed `communities` dict should have the
|
||||
SAME community count as the analysis sidecar would, so downstream code
|
||||
(aggregation thresholds, member counts) sees identical input.
|
||||
"""
|
||||
out = _make_graph(tmp_path)
|
||||
|
||||
# Read the canonical community count from the analysis sidecar
|
||||
analysis = json.loads((out / ".graphify_analysis.json").read_text(encoding="utf-8"))
|
||||
expected_count = len(analysis["communities"])
|
||||
|
||||
# And the count we'd reconstruct from graph.json's node attributes
|
||||
graph = json.loads((out / "graph.json").read_text(encoding="utf-8"))
|
||||
reconstructed_cids = {
|
||||
n["community"] for n in graph.get("nodes", [])
|
||||
if n.get("community") is not None
|
||||
}
|
||||
assert len(reconstructed_cids) == expected_count, (
|
||||
f"reconstruction would lose communities: sidecar={expected_count} vs "
|
||||
f"graph.json={len(reconstructed_cids)}"
|
||||
)
|
||||
|
||||
# Now remove the sidecar and confirm the CLI still succeeds end-to-end.
|
||||
(out / ".graphify_analysis.json").unlink()
|
||||
r = _run(["export", "html"], tmp_path)
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert (out / "graph.html").exists()
|
||||
|
||||
|
||||
def test_export_html_no_community_data_at_all_still_succeeds(tmp_path):
|
||||
"""If a graph.json was somehow written without any per-node `community`
|
||||
attribute (older versions of to_json, hand-built graphs), the fallback
|
||||
should produce an empty communities dict and the renderer should still
|
||||
not crash. Whether the aggregated view is useful is a separate question.
|
||||
"""
|
||||
out = _make_graph(tmp_path)
|
||||
(out / ".graphify_analysis.json").unlink()
|
||||
|
||||
# Strip the community attribute from every node
|
||||
graph_path = out / "graph.json"
|
||||
graph = json.loads(graph_path.read_text(encoding="utf-8"))
|
||||
for n in graph.get("nodes", []):
|
||||
n.pop("community", None)
|
||||
graph_path.write_text(json.dumps(graph), encoding="utf-8")
|
||||
|
||||
r = _run(["export", "html"], tmp_path)
|
||||
# Should NOT crash. It may print a warning and skip rendering, but exit
|
||||
# code stays clean — same behaviour as the pre-fallback empty-communities
|
||||
# path, just no longer silently failing on the common case.
|
||||
assert r.returncode == 0, r.stderr
|
||||
|
||||
Reference in New Issue
Block a user