diff --git a/graphify/build.py b/graphify/build.py index 44e8c441ce..625e9db5ee 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -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) diff --git a/graphify/cli.py b/graphify/cli.py index 91df096728..0ea5383823 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -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 @@ -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: diff --git a/graphify/global_graph.py b/graphify/global_graph.py index eddd0c92a4..647afb4474 100644 --- a/graphify/global_graph.py +++ b/graphify/global_graph.py @@ -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() @@ -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}") @@ -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] = {