From d778e2c36bf46868f487a31e788984bc4f41ba09 Mon Sep 17 00:00:00 2001 From: mohamadkattan12 <65044962+mohamadkattan12@users.noreply.github.com> Date: Sun, 24 May 2026 22:35:21 +0300 Subject: [PATCH] fix(cli): reconstruct communities from per-node attribute when sidecar missing (#1001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- graphify/__main__.py | 23 +++++++++++ tests/test_cli_export.py | 83 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/graphify/__main__.py b/graphify/__main__.py index 910f5164..c68a1f28 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -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()} diff --git a/tests/test_cli_export.py b/tests/test_cli_export.py index 87b98334..e23c987f 100644 --- a/tests/test_cli_export.py +++ b/tests/test_cli_export.py @@ -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