diff --git a/CHANGELOG.md b/CHANGELOG.md index ae58e4c560..a20ed30436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.61 (2026-09-12) +- Fix: `graphify export html/wiki/obsidian/...` now detects a stale `.graphify_analysis.json` (written only by `extract`/`cluster-only`, never by `update`/watch) and reconstructs communities and cohesion from the fresh data already in `graph.json` instead of silently exporting against a fossil partition, with a warning naming the exact command to refresh the sidecar (#2386, thanks @ayushcodes10). - Fix: `graphify.serve` now imports cleanly on Python 3.12 and 3.13. The `chinese` extra pins `jieba-py` from 3.12 onward (0.9.60 mistakenly kept the old `jieba` until 3.14, and its invalid regex escapes are a hard error on 3.12+), and the jieba import now suppresses the tokenizer's `SyntaxWarning` regardless of message or line so it never escalates under `-W error`. - Fix: the git hook's rebuild-root guard now rejects a symlink-loop or dangling `.graphify_root` on Python 3.13, whose `Path.resolve()` no longer raises on a loop — the saved root must resolve to a real directory inside the repo before it is adopted. diff --git a/graphify/cli.py b/graphify/cli.py index feecee3841..19bb0eddb6 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -2983,19 +2983,63 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": # 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. + # + # Computed unconditionally now (#2386), not just when the sidecar is + # missing: the sidecar can also be STALE (present but describing an + # earlier clustering pass, since update/watch never regenerate it), + # which looks identical from the outside but used to take the other + # branch below and silently keep the fossil. + 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 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 + elif reconstructed: + # #2386: the sidecar EXISTS but can still be stale, since + # update/watch advance graph.json's per-node community attribute + # without ever regenerating .graphify_analysis.json. Cheap, + # unambiguous signal: compare each side's partition (its set of + # community blocks), not the community ids themselves (those can + # renumber run to run even for the same partition, #1667) and not + # just the flat node-id set either (a merge, split, or a node + # moving between communities can leave the overall node set + # unchanged while still describing a different partition). A + # mismatch means the sidecar was written by an earlier + # clustering pass, so prefer the fresh reconstruction instead of + # silently exporting a degraded artifact against a clustering + # that no longer agrees with it. + sidecar_partition = {frozenset(str(n) for n in nodes) for nodes in communities.values()} + fresh_partition = {frozenset(nodes) for nodes in reconstructed.values()} + if sidecar_partition != fresh_partition: + sidecar_node_count = len({n for block in sidecar_partition for n in block}) + fresh_node_count = len({n for block in fresh_partition for n in block}) + print( + f"warning: {analysis_path} is stale ({sidecar_node_count} node(s) " + f"recorded vs {fresh_node_count} in graph.json) — reconstructing " + "communities from graph.json instead. Run `graphify cluster-only .` " + "to refresh the sidecar and its cohesion/god-node data.", + file=sys.stderr, + ) + communities = reconstructed + from graphify.cluster import score_all as _score_all_export + from graphify.analyze import god_nodes as _god_nodes_export + cohesion = _score_all_export(G, communities) + # god_nodes ranks purely by graph degree, independent of the + # community partition, so recompute it directly here instead + # of clearing it to an empty list and relying on the wiki + # subcommand's own "if not gods_data: recompute" fallback + # further down — that fallback happens to cover the only + # current consumer, but silently drops real data for any + # future one that reads gods_data without the same guard. + gods_data = _god_nodes_export(G) labels: dict[int, str] = {} if labels_path.exists(): diff --git a/tests/test_cli_export.py b/tests/test_cli_export.py index 956598d633..61160b700c 100644 --- a/tests/test_cli_export.py +++ b/tests/test_cli_export.py @@ -659,6 +659,80 @@ def test_export_html_no_community_data_at_all_still_succeeds(tmp_path): assert r.returncode == 0, r.stderr +# ── #2386: sidecar exists but is STALE, not just absent ────────────────────── +# update/watch advance graph.json's per-node community attribute but never +# regenerate .graphify_analysis.json, so it can describe an earlier +# clustering pass while still being present. That looked identical to a +# fresh sidecar from the outside and kept winning over the correct data +# sitting in graph.json. + +def test_export_html_prefers_fresh_data_when_sidecar_is_stale(tmp_path): + out = _make_graph(tmp_path) + analysis_path = out / ".graphify_analysis.json" + analysis = json.loads(analysis_path.read_text(encoding="utf-8")) + # Simulate staleness the way the issue describes: the sidecar's node id + # set no longer matches graph.json's (a node the sidecar never saw, or + # one it references that graph.json no longer has). + analysis["communities"] = {"0": ["a_ghost_node_id_not_in_the_graph"]} + analysis_path.write_text(json.dumps(analysis)) + + r = _run(["export", "html"], tmp_path) + assert r.returncode == 0, r.stderr + assert "is stale" in r.stderr + assert (out / "graph.html").exists() + + +def test_export_wiki_recomputes_cohesion_when_sidecar_is_stale(tmp_path): + out = _make_graph(tmp_path) + analysis_path = out / ".graphify_analysis.json" + analysis = json.loads(analysis_path.read_text(encoding="utf-8")) + analysis["communities"] = {"0": ["a_ghost_node_id_not_in_the_graph"]} + # A cohesion value that could never be a real score (score_all returns + # values in a bounded range), so if it survives into the wiki output + # unchanged, the stale sidecar won instead of being recomputed. + analysis["cohesion"] = {"0": 999999.0} + analysis_path.write_text(json.dumps(analysis)) + + r = _run(["export", "wiki"], tmp_path) + assert r.returncode == 0, r.stderr + assert "is stale" in r.stderr + wiki_dir = out / "wiki" + assert wiki_dir.exists() + combined = "\n".join(p.read_text(encoding="utf-8") for p in wiki_dir.glob("*.md")) + assert "999999" not in combined, "stale cohesion value leaked into the wiki export" + + +def test_export_html_uses_sidecar_when_it_still_matches(tmp_path): + """Negative control: an up to date sidecar must not trigger the stale + path or its warning.""" + out = _make_graph(tmp_path) + + r = _run(["export", "html"], tmp_path) + assert r.returncode == 0, r.stderr + assert "is stale" not in r.stderr + assert (out / "graph.html").exists() + + +def test_export_html_detects_stale_sidecar_with_same_nodes_different_partition(tmp_path): + """A merge, a split, or a node moving from one community to another can + leave the overall node id set unchanged while still describing a + different partition -- comparing only the flat node-id set missed this + exact shape of staleness.""" + out = _make_graph(tmp_path) + analysis_path = out / ".graphify_analysis.json" + analysis = json.loads(analysis_path.read_text(encoding="utf-8")) + communities = analysis["communities"] + assert len(communities) >= 2, "fixture must have at least two communities to prove this" + all_nodes = [n for nodes in communities.values() for n in nodes] + analysis["communities"] = {"0": all_nodes} + analysis_path.write_text(json.dumps(analysis)) + + r = _run(["export", "html"], tmp_path) + assert r.returncode == 0, r.stderr + assert "is stale" in r.stderr + assert (out / "graph.html").exists() + + def test_graph_json_node_ids_are_portable_across_checkout_paths(tmp_path): """#1789: the committed graph.json's node ids must be relative to the scan root — not embed the absolute path — so the same repo yields identical ids