fix(global): preserve stored edge direction in global add (#4066)

global add loaded graph.json with a plain undirected node_link_graph, so
every link whose target precedes its source in the node list was written
to ~/.graphify/global-graph.json reversed. Stamp _src/_tgt with
restore_arc_direction before the load, as the export loader and
load_node_link_graph do since #4088.

The external-node dedup rewired edge endpoints but not the markers, so a
deduplicated repo's markers named a node that no longer exists. Remap
_src/_tgt along with the endpoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 477848390e)
This commit is contained in:
raditha dissanayake authored and safishamsi committed 2026-10-11 21:06:09 +01:00
1 parent 25452a63b8
commit 1adadc7f79
2 files changed
+55 -2

No files matched your search

+7
View File
@@ -110,6 +110,8 @@ def global_add(source_path: Path, repo_tag: str) -> dict:
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"])
from graphify.paths import restore_arc_direction
data = restore_arc_direction(data)
try:
src_G = _jg.node_link_graph(data, edges="links")
except TypeError:
@@ -142,6 +144,11 @@ def global_add(source_path: Path, repo_tag: str) -> dict:
for u, v, data in prefixed.edges(data=True):
u = remap.get(u, u)
v = remap.get(v, v)
# The direction markers name endpoints too; rewire them with u/v so
# they never point at a deduplicated (absent) external node.
for key in ("_src", "_tgt"):
if data.get(key) in remap:
data = {**data, key: remap[data[key]]}
if u != v: # don't introduce self-loops via remapping
G.add_edge(u, v, **data)
+48 -2
View File
@@ -8,8 +8,9 @@ node precedes its source node comes back reversed.
query/path/explain (#2309, #2487), affected (#1174), merge-graphs (#2261) and
the MCP server already restore direction. These tests cover the remaining
reloaders: the export subcommands, the watch/update HTML re-render, and the git
merge driver, which writes the committed graph.json.
reloaders: the export subcommands, the watch/update HTML re-render, the git
merge driver, which writes the committed graph.json, and `global add`, which
writes ~/.graphify/global-graph.json.
"""
from __future__ import annotations
@@ -19,6 +20,7 @@ import re
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
PYTHON = sys.executable
# A fixed positive limit, so HTML rendering never depends on the caller's
@@ -189,3 +191,47 @@ def test_merge_driver_keeps_stored_direction_in_arc_order(tmp_path):
# Arc order is the direction (what to_json writes); no markers left behind.
assert {(l["source"], l["target"]) for l in merged["links"]} == TRUE_ARCS
assert not any("_src" in l or "_tgt" in l for l in merged["links"])
def _global_add(tmp_path: Path, *repos: tuple[Path, str]) -> dict:
"""Run `global add` for each (graph.json, tag) and return the saved global graph."""
from graphify.global_graph import global_add
gdir = tmp_path / ".graphify"
with patch("graphify.global_graph._GLOBAL_DIR", gdir), \
patch("graphify.global_graph._GLOBAL_GRAPH", gdir / "global-graph.json"), \
patch("graphify.global_graph._GLOBAL_MANIFEST", gdir / "global-manifest.json"):
for graph_path, tag in repos:
global_add(graph_path, tag)
return json.loads((gdir / "global-graph.json").read_text())
def _marker_arcs(data: dict) -> set[tuple[str, str]]:
return {(l.get("_src", l["source"]), l.get("_tgt", l["target"])) for l in data["links"]}
def test_global_add_persists_stored_direction(tmp_path):
saved = _global_add(tmp_path, (_graph_json(tmp_path), "proj"))
assert _marker_arcs(saved) == {(f"proj::{s}", f"proj::{t}") for s, t in TRUE_ARCS}
def test_global_add_dedup_remaps_direction_markers(tmp_path):
"""Two repos share an external `requests` node. B's copy is deduplicated
onto A::requests, so the markers on B's edge must follow it there."""
def repo(name: str, fn: str) -> Path:
# The external node is listed first, the order an undirected load flips.
data = {"directed": False, "multigraph": False, "graph": {},
"nodes": [{"id": "requests", "label": "requests"},
{"id": fn, "label": f"{fn}()", "source_file": f"{name}/x.py"}],
"links": [{"source": fn, "target": "requests", "relation": "calls"}]}
p = tmp_path / name / "graph.json"
p.parent.mkdir()
p.write_text(json.dumps(data))
return p
saved = _global_add(tmp_path, (repo("A", "fa"), "A"), (repo("B", "fb"), "B"))
arcs = _marker_arcs(saved)
assert arcs == {("A::fa", "A::requests"), ("B::fb", "A::requests")}
ids = {n["id"] for n in saved["nodes"]}
assert all(end in ids for arc in arcs for end in arc)