From e843f73f50dbd00ee0c8eae5ec38da7d9761aee9 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Tue, 15 Sep 2026 17:26:26 +0530 Subject: [PATCH 01/16] Load and prune the global graph before prefixing the incoming repo Toward issue 3100: reorders the existing steps in global_add with no behavior change yet, so the community offset computed in the next commit can read the store's community ids as they stand once this repo's own stale entries are already pruned, rather than before. Pruning does not depend on the prefixed graph and prefixing does not depend on the loaded store, so the two steps were independent and safe to swap. Co-Authored-By: Claude Sonnet 5 --- graphify/global_graph.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/graphify/global_graph.py b/graphify/global_graph.py index cd1ea19244..d8e597ce83 100644 --- a/graphify/global_graph.py +++ b/graphify/global_graph.py @@ -115,13 +115,16 @@ def global_add(source_path: Path, repo_tag: str) -> dict: except TypeError: src_G = _jg.node_link_graph(data) - # Prefix IDs for cross-project isolation - prefixed = prefix_graph_for_global(src_G, repo_tag) - - # Load global graph and prune stale nodes for this repo + # Load global graph and prune stale nodes for this repo, before prefixing + # the incoming one: the offset computed in the next commit reads the + # store's community ids as they stand once this repo's own stale entries + # are already gone, so re-adding the same repo cannot inflate it forever. G = _load_global_graph() removed = prune_repo_from_graph(G, repo_tag) + # Prefix IDs for cross-project isolation + prefixed = prefix_graph_for_global(src_G, repo_tag) + # Merge external-library nodes (no source_file) by label to avoid duplication external_labels = { d.get("label", ""): n From d754196db722cb223c7a06e86344b4e7bc1159da Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Tue, 15 Sep 2026 23:40:52 +0530 Subject: [PATCH 02/16] Offset the incoming repo community ids in global_add Fixes half of issue 3100 (the number 3014 fix). The CLI batch merge command already offsets each input community ids into a shared id space as it composes them, since every graph.json numbers its own communities from 0 and ids carried across unchanged collide with whatever id another repo already occupies. global_add builds the same kind of multi repo store with the same prefixer, but kept calling it with the default offset, so a global store built through the incremental add path still had exactly that defect: two repos claiming the same community id fuse into one unrelated meta community in any aggregated view, worst of all at id 0, which every repo starts numbering from. Computed from the pruned store own current community ids, so each successive add lands past everything already there, the same invariant a single batch merge keeps across its inputs. Co-Authored-By: Claude Sonnet 5 --- graphify/global_graph.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/graphify/global_graph.py b/graphify/global_graph.py index d8e597ce83..767aa806c3 100644 --- a/graphify/global_graph.py +++ b/graphify/global_graph.py @@ -116,14 +116,27 @@ def global_add(source_path: Path, repo_tag: str) -> dict: src_G = _jg.node_link_graph(data) # Load global graph and prune stale nodes for this repo, before prefixing - # the incoming one: the offset computed in the next commit reads the - # store's community ids as they stand once this repo's own stale entries - # are already gone, so re-adding the same repo cannot inflate it forever. + # the incoming one: the offset below reads the store's community ids as + # they stand once this repo's own stale entries are already gone, so + # re-adding the same repo cannot inflate it forever. G = _load_global_graph() removed = prune_repo_from_graph(G, repo_tag) + # Offset the incoming repo's community ids past every other repo's + # already in the store (#3014, #3100): every graph.json numbers its own + # communities from 0, and the CLI merge-graphs command already offsets + # for exactly this reason, but the incremental add path here kept + # prefixing each new repo's communities from 0 too, colliding with + # whatever id another repo already occupied. + community_offset = 0 + existing_cids = [ + d["community"] for _, d in G.nodes(data=True) if isinstance(d.get("community"), int) + ] + if existing_cids: + community_offset = max(existing_cids) + 1 + # Prefix IDs for cross-project isolation - prefixed = prefix_graph_for_global(src_G, repo_tag) + prefixed = prefix_graph_for_global(src_G, repo_tag, community_offset=community_offset) # Merge external-library nodes (no source_file) by label to avoid duplication external_labels = { From 6b12f77d028dc174cf41402a2a21471dcc0f2ee9 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Tue, 15 Sep 2026 23:41:32 +0530 Subject: [PATCH 03/16] Link shared type declarations across the whole store on every add Fixes the other half of issue 3100 (the number 3007 fix). The CLI batch merge command already links identically declared types across repos so a traversal can cross the repo boundary, since every id is repo prefixed and two repos declaring the same type otherwise arrive as two unconnected nodes. global_add never called this pass, so an incrementally built store held zero same_type_as edges no matter how many repos actually shared a type. Runs again over the whole store on every add, the same pattern the existing member call linking pass already uses just above it: the pass only adds an edge where none exists yet, so repeated calls across successive adds stay cheap and cannot double an edge, and a type declared in a repo added long ago still gets linked against one added today. Also threads the new edge count through the return dict, alongside the existing cross_repo_calls count, for callers that want to report it. Co-Authored-By: Claude Sonnet 5 --- graphify/global_graph.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/graphify/global_graph.py b/graphify/global_graph.py index 767aa806c3..c52036c054 100644 --- a/graphify/global_graph.py +++ b/graphify/global_graph.py @@ -102,7 +102,7 @@ def global_add(source_path: Path, repo_tag: str) -> dict: ) if existing.get("source_hash") == src_hash: return {"repo_tag": repo_tag, "nodes_added": 0, "nodes_removed": 0, "skipped": True, - "cross_repo_calls": 0} + "cross_repo_calls": 0, "shared_type_links": 0} # Load source graph from graphify.security import check_graph_file_size_cap @@ -169,6 +169,19 @@ def global_add(source_path: Path, repo_tag: str) -> dict: from graphify.cross_repo_calls import link_cross_repo_member_calls cross_repo_calls = link_cross_repo_member_calls(G) + + # A type both this repo and an earlier one declare arrives as two + # unconnected nodes, since every id is repo prefixed. The CLI batch + # merge command already links them so a traversal can cross the repo + # boundary (#3007); global_add never called this pass, so an + # incrementally built store held zero same_type_as edges no matter how + # many repos actually shared a type. Re-run over the whole store on + # every add, same as the member call pass above: it only adds an edge + # where none exists yet, so repeated calls across successive adds stay + # cheap and cannot double an edge. + from graphify.cross_repo_types import link_shared_type_declarations + + shared_type_links = link_shared_type_declarations(G) _save_global_graph(G) manifest["repos"][repo_tag] = { @@ -181,7 +194,8 @@ def global_add(source_path: Path, repo_tag: str) -> dict: _save_manifest(manifest) return {"repo_tag": repo_tag, "nodes_added": added, "nodes_removed": removed, - "skipped": False, "cross_repo_calls": cross_repo_calls} + "skipped": False, "cross_repo_calls": cross_repo_calls, + "shared_type_links": shared_type_links} def global_remove(repo_tag: str) -> int: From 0696cf7bd53a83af5a44bd99ad437823805ac900 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Tue, 15 Sep 2026 23:41:53 +0530 Subject: [PATCH 04/16] Print the shared type link count from the global add CLI Mirrors the existing cross_repo_calls print right above it, now that global_add reports the count. Co-Authored-By: Claude Sonnet 5 --- graphify/cli.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/graphify/cli.py b/graphify/cli.py index feecee3841..80cc0d7fe0 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3145,6 +3145,9 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": if result.get("cross_repo_calls"): print(f" resolved {result['cross_repo_calls']} " f"member call(s) across repos") + if result.get("shared_type_links"): + print(f" linked {result['shared_type_links']} " + f"type declaration(s) shared across repos") except Exception as exc: print(f"error: {exc}", file=sys.stderr); sys.exit(1) elif subcmd == "remove": From 707bc376a4284f6cdefa9749818e9783ba3e5fe3 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Tue, 15 Sep 2026 23:42:17 +0530 Subject: [PATCH 05/16] Add regression test for the community id offset in global_add Two repos each numbering two communities from 0 must land on disjoint ids once both are in the store, matching the invariant a single batch merge already keeps. Co-Authored-By: Claude Sonnet 5 --- tests/test_global_graph.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_global_graph.py b/tests/test_global_graph.py index ad5da774d2..d306ebb63f 100644 --- a/tests/test_global_graph.py +++ b/tests/test_global_graph.py @@ -198,6 +198,44 @@ def test_global_add_two_repos_no_collision(tmp_path): assert G.number_of_nodes() == 2 # no silent merge +# ── #3100: community offset and shared type linking parity with merge-graphs ── + +def test_global_add_offsets_community_ids_across_repos(tmp_path): + """merge-graphs already offsets each input's community ids into a shared + id space (#3014); global_add builds the same kind of store with the same + prefixer but kept the default (no) offset, so two repos both numbering + their own communities from 0 collided in the merged store -- worst of + all at id 0, which every repo starts numbering from.""" + g1 = tmp_path / "graph1.json" + g2 = tmp_path / "graph2.json" + G1 = _make_graph([ + {"id": "a", "label": "A", "source_file": "a.py", "community": 0}, + {"id": "b", "label": "B", "source_file": "b.py", "community": 1}, + ]) + G2 = _make_graph([ + {"id": "c", "label": "C", "source_file": "c.py", "community": 0}, + {"id": "d", "label": "D", "source_file": "d.py", "community": 1}, + ]) + _graph_to_json(G1, g1) + _graph_to_json(G2, g2) + + global_dir = tmp_path / ".graphify" + with patch("graphify.global_graph._GLOBAL_DIR", global_dir), \ + patch("graphify.global_graph._GLOBAL_GRAPH", global_dir / "global-graph.json"), \ + patch("graphify.global_graph._GLOBAL_MANIFEST", global_dir / "global-manifest.json"): + from graphify.global_graph import global_add, _load_global_graph + global_add(g1, "repoA") + global_add(g2, "repoB") + G = _load_global_graph() + + by_repo: dict[str, set[int]] = {} + for _, data in G.nodes(data=True): + by_repo.setdefault(data["repo"], set()).add(data["community"]) + assert by_repo["repoA"].isdisjoint(by_repo["repoB"]), ( + f"community ids collide across repos: {by_repo}" + ) + + def test_global_remove(tmp_path): src_graph = tmp_path / "graph.json" G = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}]) From 78b8425bd46551b0d89d336842f8d13cf8581a28 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Tue, 15 Sep 2026 23:42:38 +0530 Subject: [PATCH 06/16] Add regression test for shared type linking across the store Two repos declaring the same namespaced type must gain a same_type_as edge once both are in the store, and the reported link count on each add matches: zero for the first repo since there is nothing yet to link against, one once the second repo lands. Co-Authored-By: Claude Sonnet 5 --- tests/test_global_graph.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_global_graph.py b/tests/test_global_graph.py index d306ebb63f..6aac49f6a5 100644 --- a/tests/test_global_graph.py +++ b/tests/test_global_graph.py @@ -236,6 +236,37 @@ def test_global_add_offsets_community_ids_across_repos(tmp_path): ) +def test_global_add_links_shared_type_declarations(tmp_path): + """merge-graphs already links identically declared types across repos so + a traversal can cross the repo boundary (#3007); global_add never called + that pass, so an incrementally built store held zero same_type_as edges + no matter how many repos actually shared a type.""" + g1 = tmp_path / "graph1.json" + g2 = tmp_path / "graph2.json" + shared = { + "id": "contracttype", "label": "ContractType", "source_file": "models.cs", + "_callable_class": True, "metadata": {"namespace": "Acme.Contracts"}, + } + G1 = _make_graph([shared]) + G2 = _make_graph([shared]) + _graph_to_json(G1, g1) + _graph_to_json(G2, g2) + + global_dir = tmp_path / ".graphify" + with patch("graphify.global_graph._GLOBAL_DIR", global_dir), \ + patch("graphify.global_graph._GLOBAL_GRAPH", global_dir / "global-graph.json"), \ + patch("graphify.global_graph._GLOBAL_MANIFEST", global_dir / "global-manifest.json"): + from graphify.global_graph import global_add, _load_global_graph + first = global_add(g1, "repoA") + second = global_add(g2, "repoB") + G = _load_global_graph() + + assert first["shared_type_links"] == 0 # nothing to link against yet + assert second["shared_type_links"] == 1 + assert G.has_edge("repoA::contracttype", "repoB::contracttype") + assert G["repoA::contracttype"]["repoB::contracttype"]["relation"] == "same_type_as" + + def test_global_remove(tmp_path): src_graph = tmp_path / "graph.json" G = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}]) From d17f7e07e9066316d3ae9de7f0e4c264de0dadda Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Tue, 15 Sep 2026 23:42:53 +0530 Subject: [PATCH 07/16] Add changelog entry for issue 3100 Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae58e4c560..cf10cb492d 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 global add` now offsets each repo's community ids into a shared id space and links identically declared types across repos, the same #3014/#3007 fixes `merge-graphs` already had — a store built through the incremental add path previously fused unrelated repos' communities into one at id 0 and never linked a single shared type (#3100, 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 115977a9abbc29d074151dc5eda55099cc6c2e80 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Fri, 18 Sep 2026 14:20:34 +0530 Subject: [PATCH 08/16] Serialize the read modify write cycle on the global store A review finding pointed out that global_add and global_remove each load the graph and manifest, mutate them in memory, then save both back, with no locking anywhere. Two concurrent calls would both read the same pre write snapshot, compute conflicting results from it, and the second save would silently discard the first's work entirely, not just a duplicated community id which the narrower earlier fix on this issue already closes. An entire repo's added nodes could vanish this way. Adds an exclusive advisory lock around the whole cycle in both functions, blocking until acquired so a caller waits its turn rather than failing, released automatically if the process is killed, and a no op on platforms without fcntl, matching the same fallback already used by the per repo rebuild lock elsewhere in this codebase. Co-Authored-By: Claude Sonnet 5 --- graphify/global_graph.py | 265 +++++++++++++++++++++++---------------- 1 file changed, 154 insertions(+), 111 deletions(-) diff --git a/graphify/global_graph.py b/graphify/global_graph.py index c52036c054..acdb9b811d 100644 --- a/graphify/global_graph.py +++ b/graphify/global_graph.py @@ -1,4 +1,5 @@ from __future__ import annotations +import contextlib import json import hashlib import sys @@ -12,6 +13,45 @@ _GLOBAL_MANIFEST = _GLOBAL_DIR / "global-manifest.json" +@contextlib.contextmanager +def _global_store_lock(): + """Exclusive advisory lock around a read-modify-write cycle on the + global graph store. + + global_add/global_remove each load the graph and manifest, mutate them + in memory, then save both back. Two concurrent calls would both read + the same pre-write snapshot, compute conflicting results from it, and + the second save would silently discard the first's work entirely (not + just a duplicated community id, which the narrower #3100 fix already + closes). Blocks until acquired rather than failing, since a caller + should wait its turn; released automatically if the process is killed + (fcntl.flock), so no stale-lock cleanup is needed. + + No-op on platforms without fcntl (Windows) -- matching the same + fallback already used by watch.py's per-repo rebuild lock, since a lost + update there is a pre-existing risk this does not newly introduce. + """ + try: + import fcntl + except ImportError: + yield + return + # Read _GLOBAL_DIR fresh rather than a module-level path computed from it + # once at import time, so a caller (tests included) that points the + # store elsewhere by rebinding _GLOBAL_DIR is honored here too. + _GLOBAL_DIR.mkdir(parents=True, exist_ok=True) + fh = open(_GLOBAL_DIR / ".global-graph.lock", "a+", encoding="utf-8") + try: + fcntl.flock(fh.fileno(), fcntl.LOCK_EX) + yield + finally: + try: + fcntl.flock(fh.fileno(), fcntl.LOCK_UN) + except OSError: + pass + fh.close() + + def _load_manifest() -> dict: if _GLOBAL_MANIFEST.exists(): try: @@ -88,110 +128,112 @@ def global_add(source_path: Path, repo_tag: str) -> dict: if not source_path.exists(): raise FileNotFoundError(f"graph not found: {source_path}") - manifest = _load_manifest() src_hash = _file_hash(source_path) - existing = manifest["repos"].get(repo_tag, {}) - existing_path = existing.get("source_path", "") - if existing_path and existing_path != str(source_path.resolve()): - print( - f"[graphify global] warning: repo tag '{repo_tag}' previously pointed to " - f"{existing_path!r}, now updating to {str(source_path.resolve())!r}. " - f"Use --as to give it a different name.", - file=sys.stderr, - ) - if existing.get("source_hash") == src_hash: - return {"repo_tag": repo_tag, "nodes_added": 0, "nodes_removed": 0, "skipped": True, - "cross_repo_calls": 0, "shared_type_links": 0} - - # Load source graph - from graphify.security import check_graph_file_size_cap - check_graph_file_size_cap(source_path) - data = json.loads(source_path.read_text(encoding="utf-8")) - if "links" not in data and "edges" in data: - data = dict(data, links=data["edges"]) - try: - src_G = _jg.node_link_graph(data, edges="links") - except TypeError: - src_G = _jg.node_link_graph(data) - - # Load global graph and prune stale nodes for this repo, before prefixing - # the incoming one: the offset below reads the store's community ids as - # they stand once this repo's own stale entries are already gone, so - # re-adding the same repo cannot inflate it forever. - G = _load_global_graph() - removed = prune_repo_from_graph(G, repo_tag) - - # Offset the incoming repo's community ids past every other repo's - # already in the store (#3014, #3100): every graph.json numbers its own - # communities from 0, and the CLI merge-graphs command already offsets - # for exactly this reason, but the incremental add path here kept - # prefixing each new repo's communities from 0 too, colliding with - # whatever id another repo already occupied. - community_offset = 0 - existing_cids = [ - d["community"] for _, d in G.nodes(data=True) if isinstance(d.get("community"), int) - ] - if existing_cids: - community_offset = max(existing_cids) + 1 - - # Prefix IDs for cross-project isolation - prefixed = prefix_graph_for_global(src_G, repo_tag, community_offset=community_offset) - - # Merge external-library nodes (no source_file) by label to avoid duplication - external_labels = { - d.get("label", ""): n - for n, d in G.nodes(data=True) - if not d.get("source_file") and d.get("label") - } - # Map each deduplicated external onto the existing global node so that - # edges incident to it can be rewired instead of dropped. - remap = {} - for node, data in prefixed.nodes(data=True): - if not data.get("source_file") and data.get("label") in external_labels: - remap[node] = external_labels[data["label"]] - - # Compose: add prefixed nodes (except deduplicated externals) into global graph - for node, data in prefixed.nodes(data=True): - if node not in remap: - G.add_node(node, **data) - for u, v, data in prefixed.edges(data=True): - u = remap.get(u, u) - v = remap.get(v, v) - if u != v: # don't introduce self-loops via remapping - G.add_edge(u, v, **data) - - added = prefixed.number_of_nodes() - len(remap) - # A member call parked on a caller node (#3152) may be answered by a repo - # already in the global graph, or by this one for a repo added earlier. The - # pass recomputes its own output, so adding repos one at a time lands where a - # single merge-graphs of the same inputs would. - from graphify.cross_repo_calls import link_cross_repo_member_calls - - cross_repo_calls = link_cross_repo_member_calls(G) - - # A type both this repo and an earlier one declare arrives as two - # unconnected nodes, since every id is repo prefixed. The CLI batch - # merge command already links them so a traversal can cross the repo - # boundary (#3007); global_add never called this pass, so an - # incrementally built store held zero same_type_as edges no matter how - # many repos actually shared a type. Re-run over the whole store on - # every add, same as the member call pass above: it only adds an edge - # where none exists yet, so repeated calls across successive adds stay - # cheap and cannot double an edge. - from graphify.cross_repo_types import link_shared_type_declarations - - shared_type_links = link_shared_type_declarations(G) - _save_global_graph(G) - - manifest["repos"][repo_tag] = { - "added_at": datetime.now(timezone.utc).isoformat(), - "source_path": str(source_path.resolve()), - "node_count": added, - "edge_count": prefixed.number_of_edges(), - "source_hash": src_hash, - } - _save_manifest(manifest) + with _global_store_lock(): + manifest = _load_manifest() + + existing = manifest["repos"].get(repo_tag, {}) + existing_path = existing.get("source_path", "") + if existing_path and existing_path != str(source_path.resolve()): + print( + f"[graphify global] warning: repo tag '{repo_tag}' previously pointed to " + f"{existing_path!r}, now updating to {str(source_path.resolve())!r}. " + f"Use --as to give it a different name.", + file=sys.stderr, + ) + if existing.get("source_hash") == src_hash: + return {"repo_tag": repo_tag, "nodes_added": 0, "nodes_removed": 0, "skipped": True, + "cross_repo_calls": 0, "shared_type_links": 0} + + # Load source graph + from graphify.security import check_graph_file_size_cap + check_graph_file_size_cap(source_path) + data = json.loads(source_path.read_text(encoding="utf-8")) + if "links" not in data and "edges" in data: + data = dict(data, links=data["edges"]) + try: + src_G = _jg.node_link_graph(data, edges="links") + except TypeError: + src_G = _jg.node_link_graph(data) + + # Load global graph and prune stale nodes for this repo, before prefixing + # the incoming one: the offset below reads the store's community ids as + # they stand once this repo's own stale entries are already gone, so + # re-adding the same repo cannot inflate it forever. + G = _load_global_graph() + removed = prune_repo_from_graph(G, repo_tag) + + # Offset the incoming repo's community ids past every other repo's + # already in the store (#3014, #3100): every graph.json numbers its own + # communities from 0, and the CLI merge-graphs command already offsets + # for exactly this reason, but the incremental add path here kept + # prefixing each new repo's communities from 0 too, colliding with + # whatever id another repo already occupied. + community_offset = 0 + existing_cids = [ + d["community"] for _, d in G.nodes(data=True) if isinstance(d.get("community"), int) + ] + if existing_cids: + community_offset = max(existing_cids) + 1 + + # Prefix IDs for cross-project isolation + prefixed = prefix_graph_for_global(src_G, repo_tag, community_offset=community_offset) + + # Merge external-library nodes (no source_file) by label to avoid duplication + external_labels = { + d.get("label", ""): n + for n, d in G.nodes(data=True) + if not d.get("source_file") and d.get("label") + } + # Map each deduplicated external onto the existing global node so that + # edges incident to it can be rewired instead of dropped. + remap = {} + for node, data in prefixed.nodes(data=True): + if not data.get("source_file") and data.get("label") in external_labels: + remap[node] = external_labels[data["label"]] + + # Compose: add prefixed nodes (except deduplicated externals) into global graph + for node, data in prefixed.nodes(data=True): + if node not in remap: + G.add_node(node, **data) + for u, v, data in prefixed.edges(data=True): + u = remap.get(u, u) + v = remap.get(v, v) + if u != v: # don't introduce self-loops via remapping + G.add_edge(u, v, **data) + + added = prefixed.number_of_nodes() - len(remap) + # A member call parked on a caller node (#3152) may be answered by a repo + # already in the global graph, or by this one for a repo added earlier. The + # pass recomputes its own output, so adding repos one at a time lands where a + # single merge-graphs of the same inputs would. + from graphify.cross_repo_calls import link_cross_repo_member_calls + + cross_repo_calls = link_cross_repo_member_calls(G) + + # A type both this repo and an earlier one declare arrives as two + # unconnected nodes, since every id is repo prefixed. The CLI batch + # merge command already links them so a traversal can cross the repo + # boundary (#3007); global_add never called this pass, so an + # incrementally built store held zero same_type_as edges no matter how + # many repos actually shared a type. Re-run over the whole store on + # every add, same as the member call pass above: it only adds an edge + # where none exists yet, so repeated calls across successive adds stay + # cheap and cannot double an edge. + from graphify.cross_repo_types import link_shared_type_declarations + + shared_type_links = link_shared_type_declarations(G) + _save_global_graph(G) + + manifest["repos"][repo_tag] = { + "added_at": datetime.now(timezone.utc).isoformat(), + "source_path": str(source_path.resolve()), + "node_count": added, + "edge_count": prefixed.number_of_edges(), + "source_hash": src_hash, + } + _save_manifest(manifest) return {"repo_tag": repo_tag, "nodes_added": added, "nodes_removed": removed, "skipped": False, "cross_repo_calls": cross_repo_calls, @@ -202,17 +244,18 @@ def global_remove(repo_tag: str) -> int: """Remove all nodes for repo_tag from the global graph. Returns count removed.""" from graphify.build import prune_repo_from_graph - manifest = _load_manifest() - if repo_tag not in manifest["repos"]: - raise KeyError(f"repo '{repo_tag}' not in global graph") + with _global_store_lock(): + manifest = _load_manifest() + if repo_tag not in manifest["repos"]: + raise KeyError(f"repo '{repo_tag}' not in global graph") - G = _load_global_graph() - removed = prune_repo_from_graph(G, repo_tag) - _save_global_graph(G) + G = _load_global_graph() + removed = prune_repo_from_graph(G, repo_tag) + _save_global_graph(G) - del manifest["repos"][repo_tag] - _save_manifest(manifest) - return removed + del manifest["repos"][repo_tag] + _save_manifest(manifest) + return removed def global_list() -> dict: From cd5a7195180a7a195bd701acfe346d7a0d3d92b7 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Fri, 18 Sep 2026 14:20:39 +0530 Subject: [PATCH 09/16] Add regression tests for the global store lock Covers the lock's own mutual exclusion guarantee directly (several threads through the critical section, at most one active at once) and an end to end check that two concurrently added repos both survive. Co-Authored-By: Claude Sonnet 5 --- tests/test_global_graph.py | 84 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/test_global_graph.py b/tests/test_global_graph.py index 6aac49f6a5..690a67e5c6 100644 --- a/tests/test_global_graph.py +++ b/tests/test_global_graph.py @@ -454,3 +454,87 @@ def test_global_add_rejects_oversized_source_graph(monkeypatch, tmp_path): from graphify.global_graph import global_add with pytest.raises(ValueError, match="exceeds"): global_add(src_graph, "repoA") + + +def test_global_store_lock_serializes_concurrent_critical_sections(tmp_path): + """Review finding: global_add/global_remove each load-mutate-save the + shared store with no locking, so two concurrent calls read the same + pre-write snapshot, compute conflicting results, and the second save + silently discards the first's work entirely. The lock added to close + this must actually provide mutual exclusion -- verified directly by + running several threads through the critical section and confirming + at most one is ever inside it at once, rather than through global_add + itself (racing its real logic reliably, without deadlocking a test + that also needs to pass once the lock works, is far harder to get + right than testing the lock's own guarantee in isolation).""" + import threading + import time + from graphify import global_graph as gg_mod + + global_dir = tmp_path / ".graphify" + active = {"count": 0} + max_active = {"value": 0} + counter_lock = threading.Lock() + + def worker(): + with gg_mod._global_store_lock(): + with counter_lock: + active["count"] += 1 + max_active["value"] = max(max_active["value"], active["count"]) + time.sleep(0.05) + with counter_lock: + active["count"] -= 1 + + with patch("graphify.global_graph._GLOBAL_DIR", global_dir): + threads = [threading.Thread(target=worker) for _ in range(5)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10) + + assert max_active["value"] == 1, ( + f"the lock let {max_active['value']} threads into the critical section at once" + ) + + +def test_global_add_concurrent_calls_both_survive(tmp_path): + """End-to-end: two different repos added via genuinely concurrent + global_add calls must both still be present afterward -- the lock + from the finding above must be held across the real load/mutate/save + cycle, not just demonstrated in isolation.""" + import threading + from graphify import global_graph as gg_mod + + src_a = tmp_path / "a.json" + src_b = tmp_path / "b.json" + _graph_to_json( + _make_graph([{"id": "a1", "label": "A1", "source_file": "a.py"}]), src_a + ) + _graph_to_json( + _make_graph([{"id": "b1", "label": "B1", "source_file": "b.py"}]), src_b + ) + + global_dir = tmp_path / ".graphify" + errors: list[Exception] = [] + + def add(src, tag): + try: + gg_mod.global_add(src, tag) + except Exception as exc: # pragma: no cover - surfaced via assertion below + errors.append(exc) + + with patch("graphify.global_graph._GLOBAL_DIR", global_dir), \ + patch("graphify.global_graph._GLOBAL_GRAPH", global_dir / "global-graph.json"), \ + patch("graphify.global_graph._GLOBAL_MANIFEST", global_dir / "global-manifest.json"): + t1 = threading.Thread(target=add, args=(src_a, "repoA")) + t2 = threading.Thread(target=add, args=(src_b, "repoB")) + t1.start() + t2.start() + t1.join(timeout=10) + t2.join(timeout=10) + + assert not errors, errors + manifest = json.loads((global_dir / "global-manifest.json").read_text()) + assert set(manifest["repos"]) == {"repoA", "repoB"}, ( + f"a concurrent add lost the other's update, got {set(manifest['repos'])}" + ) From 21c6cd56bb61a67fe8feb5aa4ad833f9dc51cd22 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Fri, 18 Sep 2026 14:20:56 +0530 Subject: [PATCH 10/16] Update changelog entry for issue 3100 review finding Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf10cb492d..a211cac13c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.61 (2026-09-12) -- Fix: `graphify global add` now offsets each repo's community ids into a shared id space and links identically declared types across repos, the same #3014/#3007 fixes `merge-graphs` already had — a store built through the incremental add path previously fused unrelated repos' communities into one at id 0 and never linked a single shared type (#3100, thanks @ayushcodes10). +- Fix: `graphify global add` now offsets each repo's community ids into a shared id space and links identically declared types across repos, the same #3014/#3007 fixes `merge-graphs` already had — a store built through the incremental add path previously fused unrelated repos' communities into one at id 0 and never linked a single shared type. `graphify global add`/`global remove` also now hold an exclusive lock across their whole load, mutate, save cycle — without it, two concurrent calls could each read the same snapshot and the second save would silently discard the first's work entirely, losing an entire repo's added nodes (#3100, 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 731a7cbea94beea47b75d5b5658ed6e4a2c261c3 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sat, 19 Sep 2026 23:02:36 +0530 Subject: [PATCH 11/16] Hash and parse the source graph from a single read A review finding pointed out that hashing the source path happened before the lock while parsing it happened after, two reads of the same file at two different times. A concurrent writer to that file in between could make the recorded hash describe different bytes than what actually gets imported, corrupting the unchanged hash skip check on every later call. Both now come from one read inside the lock, and the standalone hashing helper is gone since it had no other caller. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/global_graph.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/graphify/global_graph.py b/graphify/global_graph.py index acdb9b811d..1699fd09c9 100644 --- a/graphify/global_graph.py +++ b/graphify/global_graph.py @@ -110,12 +110,6 @@ def _save_global_graph(G: nx.Graph) -> None: write_json_atomic(_GLOBAL_GRAPH, data, indent=2) -def _file_hash(path: Path) -> str: - h = hashlib.sha256() - h.update(path.read_bytes()) - return h.hexdigest()[:16] - - def global_add(source_path: Path, repo_tag: str) -> dict: """Add or update a project graph in the global graph. @@ -128,11 +122,20 @@ def global_add(source_path: Path, repo_tag: str) -> dict: if not source_path.exists(): raise FileNotFoundError(f"graph not found: {source_path}") - src_hash = _file_hash(source_path) - with _global_store_lock(): manifest = _load_manifest() + # Hash and parse the SAME read of source_path, inside the lock. Hashing + # it separately before the lock (as this used to) reads the file twice + # at two different times -- a concurrent writer to source_path between + # those two reads could make the recorded hash describe different + # bytes than what actually gets imported below, corrupting the + # unchanged-hash skip check on every later call. + from graphify.security import check_graph_file_size_cap + check_graph_file_size_cap(source_path) + raw_bytes = source_path.read_bytes() + src_hash = hashlib.sha256(raw_bytes).hexdigest()[:16] + existing = manifest["repos"].get(repo_tag, {}) existing_path = existing.get("source_path", "") if existing_path and existing_path != str(source_path.resolve()): @@ -146,10 +149,7 @@ def global_add(source_path: Path, repo_tag: str) -> dict: return {"repo_tag": repo_tag, "nodes_added": 0, "nodes_removed": 0, "skipped": True, "cross_repo_calls": 0, "shared_type_links": 0} - # Load source graph - from graphify.security import check_graph_file_size_cap - check_graph_file_size_cap(source_path) - data = json.loads(source_path.read_text(encoding="utf-8")) + data = json.loads(raw_bytes.decode("utf-8")) if "links" not in data and "edges" in data: data = dict(data, links=data["edges"]) try: From 7af5b2ebf0d6371de04ecf63d7d884e61a57f33c Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sat, 19 Sep 2026 23:02:41 +0530 Subject: [PATCH 12/16] Add a regression test for the single read hash and parse fix Counts calls to read_bytes/read_text on the source path and asserts exactly one read happens, covering both hashing and parsing, so the recorded hash can never describe different bytes than what got imported. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_global_graph.py | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_global_graph.py b/tests/test_global_graph.py index 690a67e5c6..d328939183 100644 --- a/tests/test_global_graph.py +++ b/tests/test_global_graph.py @@ -6,6 +6,7 @@ import json import pytest import networkx as nx +from pathlib import Path from unittest.mock import patch @@ -538,3 +539,45 @@ def add(src, tag): assert set(manifest["repos"]) == {"repoA", "repoB"}, ( f"a concurrent add lost the other's update, got {set(manifest['repos'])}" ) + + +def test_global_add_hashes_and_parses_a_single_read(tmp_path, monkeypatch): + """Review finding: source_path used to be read twice at two different + times -- once by a standalone hashing helper before the lock, once for + the actual JSON parse after it -- so a concurrent writer to source_path + between those two reads could make the recorded manifest hash describe + different bytes than what was actually imported into the global graph. + Hashing and parsing must now share a single read of the file.""" + src_graph = tmp_path / "graph.json" + G = _make_graph([{"id": "x", "label": "X", "source_file": "x.py"}]) + _graph_to_json(G, src_graph) + + reads = [] + orig_read_bytes = Path.read_bytes + orig_read_text = Path.read_text + + def counting_read_bytes(self, *a, **kw): + if self == src_graph: + reads.append("bytes") + return orig_read_bytes(self, *a, **kw) + + def counting_read_text(self, *a, **kw): + if self == src_graph: + reads.append("text") + return orig_read_text(self, *a, **kw) + + monkeypatch.setattr(Path, "read_bytes", counting_read_bytes) + monkeypatch.setattr(Path, "read_text", counting_read_text) + + global_dir = tmp_path / ".graphify" + with patch("graphify.global_graph._GLOBAL_DIR", global_dir), \ + patch("graphify.global_graph._GLOBAL_GRAPH", global_dir / "global-graph.json"), \ + patch("graphify.global_graph._GLOBAL_MANIFEST", global_dir / "global-manifest.json"): + from graphify.global_graph import global_add + global_add(src_graph, "repoA") + + assert reads == ["bytes"], ( + f"source_path must be read exactly once, for both hashing and " + f"parsing, so the recorded hash always matches what was actually " + f"imported -- got {reads}" + ) From 02c68ffcbb7d61d52b00a95f1f1c4100aaf7e74b Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sat, 19 Sep 2026 23:03:01 +0530 Subject: [PATCH 13/16] Update changelog entry for issue 3100 again Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a211cac13c..ef6317d1da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.61 (2026-09-12) -- Fix: `graphify global add` now offsets each repo's community ids into a shared id space and links identically declared types across repos, the same #3014/#3007 fixes `merge-graphs` already had — a store built through the incremental add path previously fused unrelated repos' communities into one at id 0 and never linked a single shared type. `graphify global add`/`global remove` also now hold an exclusive lock across their whole load, mutate, save cycle — without it, two concurrent calls could each read the same snapshot and the second save would silently discard the first's work entirely, losing an entire repo's added nodes (#3100, thanks @ayushcodes10). +- Fix: `graphify global add` now offsets each repo's community ids into a shared id space and links identically declared types across repos, the same #3014/#3007 fixes `merge-graphs` already had — a store built through the incremental add path previously fused unrelated repos' communities into one at id 0 and never linked a single shared type. `graphify global add`/`global remove` also now hold an exclusive lock across their whole load, mutate, save cycle — without it, two concurrent calls could each read the same snapshot and the second save would silently discard the first's work entirely, losing an entire repo's added nodes. Hashing the source graph and parsing it now share a single read too, instead of reading it twice at two different times, so a concurrent writer to the source file cannot make the recorded hash describe different bytes than what was actually imported (#3100, 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 1e0b19e125a301798a1b6689bdc72c42636264df Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sat, 19 Sep 2026 23:20:49 +0530 Subject: [PATCH 14/16] Do not run the size cap ahead of the unchanged hash skip A review finding pointed out that the read consolidation fix moved the size cap check ahead of the unchanged hash skip check, so an already tracked, unchanged graph started erroring on every later call once the file, or a lowered GRAPHIFY_MAX_GRAPH_BYTES, crossed the cap threshold. That check was never reached at all on the skip path before this fix, so the skip must still win when nothing changed, even if the file would now fail the cap on its own. The cap check moves back to only guard the actual parse, right after the skip check, matching where it ran before that fix, while keeping the single read this fix already collapsed hashing and parsing onto. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/global_graph.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/graphify/global_graph.py b/graphify/global_graph.py index 1699fd09c9..5f152a14aa 100644 --- a/graphify/global_graph.py +++ b/graphify/global_graph.py @@ -131,8 +131,6 @@ def global_add(source_path: Path, repo_tag: str) -> dict: # those two reads could make the recorded hash describe different # bytes than what actually gets imported below, corrupting the # unchanged-hash skip check on every later call. - from graphify.security import check_graph_file_size_cap - check_graph_file_size_cap(source_path) raw_bytes = source_path.read_bytes() src_hash = hashlib.sha256(raw_bytes).hexdigest()[:16] @@ -149,6 +147,14 @@ def global_add(source_path: Path, repo_tag: str) -> dict: return {"repo_tag": repo_tag, "nodes_added": 0, "nodes_removed": 0, "skipped": True, "cross_repo_calls": 0, "shared_type_links": 0} + # The size cap only guards a file that is actually about to be parsed + # and merged -- checking it before the skip check above (a review + # finding on the read-consolidation fix) made an unchanged, already + # tracked graph error out on every call once it (or the configured + # cap) crossed the threshold, instead of continuing to skip exactly + # as it did before that fix, since it was never reached at all then. + from graphify.security import check_graph_file_size_cap + check_graph_file_size_cap(source_path) data = json.loads(raw_bytes.decode("utf-8")) if "links" not in data and "edges" in data: data = dict(data, links=data["edges"]) From 48a88ec49fdeed47e077fa16a2300a3d69f72a8e Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sat, 19 Sep 2026 23:20:55 +0530 Subject: [PATCH 15/16] Add a regression test for the skip vs size cap ordering fix Covers a graph that succeeds while under the cap, then survives a later cap drop for the same unchanged file (must skip, not raise), while a genuinely different oversized file for the same repo is still rejected. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_global_graph.py | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_global_graph.py b/tests/test_global_graph.py index d328939183..53984b503b 100644 --- a/tests/test_global_graph.py +++ b/tests/test_global_graph.py @@ -457,6 +457,45 @@ def test_global_add_rejects_oversized_source_graph(monkeypatch, tmp_path): global_add(src_graph, "repoA") +def test_global_add_skip_on_unchanged_hash_survives_a_later_size_cap_drop(tmp_path, monkeypatch): + """Review finding: reordering the size cap check ahead of the unchanged + hash skip check (to consolidate hashing and parsing into a single read) + made an already tracked, unchanged graph start erroring on every later + call once the file (or a lowered GRAPHIFY_MAX_GRAPH_BYTES) crossed the + cap, instead of continuing to skip -- the cap was never reached at all + on that path before. Skip must still win when nothing changed, even if + the file would now fail the cap on its own.""" + import pytest + + src_graph = tmp_path / "graph.json" + G = _make_graph([{"id": "x", "label": "X", "source_file": "src/x.py"}]) + _graph_to_json(G, src_graph) + + global_dir = tmp_path / ".graphify" + with patch("graphify.global_graph._GLOBAL_DIR", global_dir), \ + patch("graphify.global_graph._GLOBAL_GRAPH", global_dir / "global-graph.json"), \ + patch("graphify.global_graph._GLOBAL_MANIFEST", global_dir / "global-manifest.json"): + from graphify.global_graph import global_add + + # First add succeeds while the file is well under the cap. + result1 = global_add(src_graph, "repoA") + assert result1["skipped"] is False + + # The cap drops below the (unchanged) file's size -- a later call + # for the SAME, untouched file must still skip, not raise. + monkeypatch.setattr("graphify.security._MAX_GRAPH_FILE_BYTES", 8) + result2 = global_add(src_graph, "repoA") + assert result2["skipped"] is True + + # A genuinely different (still oversized) file for the same repo + # must still be rejected -- the cap is not bypassed entirely. + _graph_to_json( + _make_graph([{"id": "y", "label": "Y", "source_file": "src/y.py"}]), src_graph + ) + with pytest.raises(ValueError, match="exceeds"): + global_add(src_graph, "repoA") + + def test_global_store_lock_serializes_concurrent_critical_sections(tmp_path): """Review finding: global_add/global_remove each load-mutate-save the shared store with no locking, so two concurrent calls read the same From 1308499308dcc9724cc669e186418bb8ce208680 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sat, 19 Sep 2026 23:21:11 +0530 Subject: [PATCH 16/16] Update changelog entry for issue 3100 a third time Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef6317d1da..7952ac0999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.61 (2026-09-12) -- Fix: `graphify global add` now offsets each repo's community ids into a shared id space and links identically declared types across repos, the same #3014/#3007 fixes `merge-graphs` already had — a store built through the incremental add path previously fused unrelated repos' communities into one at id 0 and never linked a single shared type. `graphify global add`/`global remove` also now hold an exclusive lock across their whole load, mutate, save cycle — without it, two concurrent calls could each read the same snapshot and the second save would silently discard the first's work entirely, losing an entire repo's added nodes. Hashing the source graph and parsing it now share a single read too, instead of reading it twice at two different times, so a concurrent writer to the source file cannot make the recorded hash describe different bytes than what was actually imported (#3100, thanks @ayushcodes10). +- Fix: `graphify global add` now offsets each repo's community ids into a shared id space and links identically declared types across repos, the same #3014/#3007 fixes `merge-graphs` already had — a store built through the incremental add path previously fused unrelated repos' communities into one at id 0 and never linked a single shared type. `graphify global add`/`global remove` also now hold an exclusive lock across their whole load, mutate, save cycle — without it, two concurrent calls could each read the same snapshot and the second save would silently discard the first's work entirely, losing an entire repo's added nodes. Hashing the source graph and parsing it now share a single read too, instead of reading it twice at two different times, so a concurrent writer to the source file cannot make the recorded hash describe different bytes than what was actually imported. That same change had also moved the size cap check ahead of the unchanged hash skip, so an already tracked, unchanged graph started erroring on every later call once the file or a lowered `GRAPHIFY_MAX_GRAPH_BYTES` crossed the cap; the cap now only guards the actual parse again, so an unchanged graph keeps skipping regardless (#3100, 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.