Skip to content
Closed
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
56 changes: 56 additions & 0 deletions graphify/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -1297,3 +1297,59 @@ def prune_repo_from_graph(G: nx.Graph, repo_tag: str) -> int:
to_remove = [n for n, d in G.nodes(data=True) if d.get("repo") == repo_tag]
G.remove_nodes_from(to_remove)
return len(to_remove)


def load_graph_json(path: Path) -> nx.Graph:
"""Load a persisted graph.json into a plain undirected ``nx.Graph``.

Shared by merge-graphs, the global graph, and cluster graphs. Applies the
graph-file size cap, normalizes the legacy ``edges`` key to ``links``
(#738), and coerces DiGraph/MultiGraph/MultiDiGraph inputs to a simple
Graph so ``nx.compose`` never sees mixed types (#1606).
"""
from networkx.readwrite import json_graph as _jg
from .security import check_graph_file_size_cap

check_graph_file_size_cap(path)
data = json.loads(path.read_text(encoding="utf-8"))
if "links" not in data and "edges" in data:
data = dict(data, links=data["edges"])
try:
G = _jg.node_link_graph(data, edges="links")
except TypeError:
G = _jg.node_link_graph(data)
if type(G) is not nx.Graph:
G = nx.Graph(G)
return G


def merge_prefixed_into(G: nx.Graph, prefixed: nx.Graph) -> int:
"""Merge a repo_tag::-prefixed graph into G in-place. Returns nodes added.

External-library nodes (no ``source_file``) are deduplicated by label
against G's existing externals, with incident edges rewired onto the
shared node instead of dropped — the one place cross-repo identity is
established. Self-loops introduced by the rewiring are skipped.
"""
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 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"]]

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)

return prefixed.number_of_nodes() - len(remap)
41 changes: 13 additions & 28 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1993,38 +1993,24 @@ def _load_graph(p: str):
sys.exit(1)
import networkx as _nx
from networkx.readwrite import json_graph as _jg
from graphify.build import prefix_graph_for_global as _prefix, distinct_repo_tags as _repo_tags
from graphify.build import (
prefix_graph_for_global as _prefix,
distinct_repo_tags as _repo_tags,
load_graph_json as _load_graph,
)
graphs = []
for gp in graph_paths:
if not gp.exists():
print(f"error: not found: {gp}", file=sys.stderr)
sys.exit(1)
_enforce_graph_size_cap_or_exit(gp)
data = json.loads(gp.read_text(encoding="utf-8"))
# Normalize edges/links key before loading — graphify writes "links"
# via node_link_data but older runs may have used "edges" (#738).
if "links" not in data and "edges" in data:
data = dict(data, links=data["edges"])
# load_graph_json enforces the size cap, normalizes the legacy
# "edges" key (#738), and coerces directed/multi inputs to a plain
# undirected Graph so nx.compose never sees mixed types (#1606).
try:
G = _jg.node_link_graph(data, edges="links")
except TypeError:
G = _jg.node_link_graph(data)
graphs.append(G)
# nx.compose requires all graphs to be the same type. When input graphs
# come from different sources (e.g. an AST-only run vs a full LLM run) one
# may be a MultiGraph and another a Graph. Normalise everything to Graph
# (the graphify default) by converting MultiGraphs with nx.Graph().
def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
# nx.compose requires every graph to be the same type. Inputs may
# disagree on BOTH axes — directed vs undirected, and multi vs simple
# — because per-repo graph.json files are written by different extract
# paths at different times. Normalise everything to a plain undirected
# Graph (the merged cross-repo view is undirected anyway), which covers
# DiGraph / MultiGraph / MultiDiGraph. Without this a directed input
# crashed compose with "All graphs must be directed or undirected" (#1606).
if type(g) is not _nx.Graph:
return _nx.Graph(g)
return g
graphs.append(_load_graph(gp))
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
sys.exit(1)
# Unique repo tag per graph. The bare `graphify-out/..` dir name is not
# unique across inputs (src/graphify-out and frontend/src/graphify-out both
# → "src"), which collides same-stem node ids and silently merges unrelated
Expand All @@ -2035,8 +2021,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
print(f" note: repo dir names collide; using distinct tags: {', '.join(repo_tags)}")
merged = _nx.Graph()
for G, repo_tag in zip(graphs, repo_tags):
prefixed = _to_simple(_prefix(G, repo_tag))
merged = _nx.compose(merged, prefixed)
merged = _nx.compose(merged, _prefix(G, repo_tag))
try:
out_data = _jg.node_link_data(merged, edges="links")
except TypeError:
Expand Down
61 changes: 13 additions & 48 deletions graphify/global_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,8 @@ def _save_manifest(manifest: dict) -> None:

def _load_global_graph() -> nx.Graph:
if _GLOBAL_GRAPH.exists():
from graphify.security import check_graph_file_size_cap
check_graph_file_size_cap(_GLOBAL_GRAPH)
data = json.loads(_GLOBAL_GRAPH.read_text(encoding="utf-8"))
if "links" not in data and "edges" in data:
data = dict(data, links=data["edges"])
try:
return _jg.node_link_graph(data, edges="links")
except TypeError:
return _jg.node_link_graph(data)
from graphify.build import load_graph_json
return load_graph_json(_GLOBAL_GRAPH)
return nx.Graph()


Expand All @@ -82,7 +75,12 @@ def global_add(source_path: Path, repo_tag: str) -> dict:
Returns a summary dict with keys: repo_tag, nodes_added, nodes_removed, skipped.
Skipped=True means the source graph hasn't changed since last add.
"""
from graphify.build import prefix_graph_for_global, prune_repo_from_graph
from graphify.build import (
load_graph_json,
merge_prefixed_into,
prefix_graph_for_global,
prune_repo_from_graph,
)

if not source_path.exists():
raise FileNotFoundError(f"graph not found: {source_path}")
Expand All @@ -102,48 +100,15 @@ 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}

# 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
# Load source graph, prefix IDs for cross-project isolation
src_G = load_graph_json(source_path)
prefixed = prefix_graph_for_global(src_G, repo_tag)

# Load global graph and prune stale nodes for this repo
# Load global graph, prune stale nodes for this repo, merge with
# external-library dedup-by-label (shared helper in build.py).
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)
added = merge_prefixed_into(G, prefixed)
_save_global_graph(G)

manifest["repos"][repo_tag] = {
Expand Down