Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
64 changes: 54 additions & 10 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
74 changes: 74 additions & 0 deletions tests/test_cli_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading