From 0932abc4e0a10ccbc4f27a8dac23b1258091eabe Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 17:25:00 +0530 Subject: [PATCH 1/8] Compute the graph.json community reconstruction unconditionally Toward issue 2386: this hoists the reconstruction from graph.json's per node community attribute out of the "sidecar is missing" branch so it runs every time, with no behavior change yet since it is still only used when the sidecar produced nothing. The next commit compares it against the sidecar to also catch a sidecar that exists but is stale. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/cli.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/graphify/cli.py b/graphify/cli.py index feecee3841..f9662807d6 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -2983,17 +2983,23 @@ 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 From d4eb7af599524c5f97aee609cfa19dc043001f06 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 17:25:48 +0530 Subject: [PATCH 2/8] Prefer the fresh reconstruction when the sidecar is stale Fixes issue 2386. The existing fallback only reconstructed communities from graph.json when the sidecar was missing entirely; a sidecar that exists but was written by an earlier clustering pass looked identical from the outside and kept winning, since update and watch never regenerate .graphify_analysis.json. This compares the node id set each side covers, cheap and already in memory, rather than the community ids themselves, since those can renumber run to run even for the same partition. A mismatch means the sidecar is stale: prefer the fresh reconstruction, recompute cohesion with score_all so wiki articles and the HTML export do not swap one silent degradation for a smaller one, reset gods_data so the existing self heal at the wiki export site recomputes it, and print a warning naming the exact command that refreshes the sidecar, since the whole point of this issue is that nothing currently signals the divergence. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/cli.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/graphify/cli.py b/graphify/cli.py index f9662807d6..ab4f2a09fa 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3002,6 +3002,30 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": if not communities: 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 the node-id set each side covers, + # not the community ids themselves (those can renumber run to + # run even for the same partition, #1667). 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 nodes that no longer agree with it. + sidecar_nodes = {str(n) for nodes in communities.values() for n in nodes} + fresh_nodes = {n for nodes in reconstructed.values() for n in nodes} + if sidecar_nodes != fresh_nodes: + print( + f"warning: {analysis_path} is stale ({len(sidecar_nodes)} node(s) " + f"recorded vs {len(fresh_nodes)} 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 + cohesion = _score_all_export(G, communities) + gods_data = [] labels: dict[int, str] = {} if labels_path.exists(): From 4518bae439158488e12e78b288f3a99a2b769021 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 17:26:16 +0530 Subject: [PATCH 3/8] Add regression test for the stale sidecar html export path Corrupts the analysis sidecar community node id set (the exact staleness signature the issue describes) and confirms export html still succeeds, still renders graph.html, and prints the new warning instead of silently exporting against the fossil. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_cli_export.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_cli_export.py b/tests/test_cli_export.py index 956598d633..3754d81d2e 100644 --- a/tests/test_cli_export.py +++ b/tests/test_cli_export.py @@ -659,6 +659,29 @@ 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_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 From 12c50b975a1a922aa5a393cde6a581d35bfda2db Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 17:26:42 +0530 Subject: [PATCH 4/8] Add regression test for stale sidecar cohesion recomputation Confirms the wiki export path specifically, since it is the one that prints cohesion into article metadata: a stale, impossibly large cohesion value in the sidecar must not survive into the export once staleness is detected, proving score_all actually ran on the fresh reconstruction rather than the fossil value merely being ignored by coincidence. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_cli_export.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_cli_export.py b/tests/test_cli_export.py index 3754d81d2e..7bbf389792 100644 --- a/tests/test_cli_export.py +++ b/tests/test_cli_export.py @@ -682,6 +682,26 @@ def test_export_html_prefers_fresh_data_when_sidecar_is_stale(tmp_path): 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_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 From 21342bda3766dd28fe0be96441468dde45bc4aa1 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 17:27:20 +0530 Subject: [PATCH 5/8] Add negative control test for a sidecar that still matches Guards against a false positive: an up to date sidecar (the normal case immediately after extract or the clustering command) must not trip the new staleness warning or take the reconstruction path. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_cli_export.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_cli_export.py b/tests/test_cli_export.py index 7bbf389792..6b629c956e 100644 --- a/tests/test_cli_export.py +++ b/tests/test_cli_export.py @@ -702,6 +702,17 @@ def test_export_wiki_recomputes_cohesion_when_sidecar_is_stale(tmp_path): 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_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 From e0640d6212cb82efde865d86f945f3155725b952 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 17:27:34 +0530 Subject: [PATCH 6/8] Add changelog entry for issue 2386 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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. From 30a5955931c36b3b74fbfed03f9579dc01bb8608 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Wed, 16 Sep 2026 14:56:25 +0530 Subject: [PATCH 7/8] Compare partition structure, not just the node id set, for staleness The node id set comparison missed a whole class of staleness: a merge, a split, or a single node moving from one community to another can leave the overall node set exactly unchanged while still describing a different clustering, so a stale sidecar that happened to still cover the same nodes kept winning. Compare each side's set of community blocks instead, which catches a partition change even when the flat node set stays identical, while still ignoring a pure id renumbering of the same partition per the existing #1667 guard. Co-Authored-By: Claude Sonnet 5 --- graphify/cli.py | 28 +++++++++++++++++----------- tests/test_cli_export.py | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/graphify/cli.py b/graphify/cli.py index ab4f2a09fa..89ee212d49 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3006,18 +3006,24 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": # #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 the node-id set each side covers, - # not the community ids themselves (those can renumber run to - # run even for the same partition, #1667). 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 nodes that no longer agree with it. - sidecar_nodes = {str(n) for nodes in communities.values() for n in nodes} - fresh_nodes = {n for nodes in reconstructed.values() for n in nodes} - if sidecar_nodes != fresh_nodes: + # 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 ({len(sidecar_nodes)} node(s) " - f"recorded vs {len(fresh_nodes)} in graph.json) — reconstructing " + 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, diff --git a/tests/test_cli_export.py b/tests/test_cli_export.py index 6b629c956e..61160b700c 100644 --- a/tests/test_cli_export.py +++ b/tests/test_cli_export.py @@ -713,6 +713,26 @@ def test_export_html_uses_sidecar_when_it_still_matches(tmp_path): 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 From b071d6e7887df64b06ea4143e8f600227218cd67 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Wed, 16 Sep 2026 15:24:32 +0530 Subject: [PATCH 8/8] Recompute god node data directly on the stale sidecar path god_nodes ranks purely by graph degree, independent of the community partition, so clearing it to an empty list on the stale path and relying on the wiki subcommand's own recompute-if-empty fallback further down worked today only because wiki happens to be the only current reader of this value. Recompute it directly here instead, so a future reader that does not carry the same fallback cannot silently lose real data. Co-Authored-By: Claude Sonnet 5 --- graphify/cli.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/graphify/cli.py b/graphify/cli.py index 89ee212d49..19bb0eddb6 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3030,8 +3030,16 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": ) 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) - gods_data = [] + # 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():