Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 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.

Expand Down
3 changes: 3 additions & 0 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
239 changes: 156 additions & 83 deletions graphify/global_graph.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from __future__ import annotations
import contextlib
import json
import hashlib
import sys
Expand All @@ -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:
Expand Down Expand Up @@ -88,101 +128,134 @@ 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 <tag> 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}

# 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)

# 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
G = _load_global_graph()
removed = prune_repo_from_graph(G, repo_tag)

# 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)
_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 <tag> 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}
"skipped": False, "cross_repo_calls": cross_repo_calls,
"shared_type_links": shared_type_links}


def global_remove(repo_tag: str) -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionglobal_remove()

high coupling complexity (Ca·Ce = 25).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionglobal_remove()

high coupling complexity (Ca·Ce = 25).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionglobal_remove()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""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:
Expand Down
Loading
Loading