feat(merge): link a type declaration two repos share (#3007)

When merging graphs from multiple repos, a type declared in more than one repo under the
same fully-qualified namespace and name (a shared contract type) now gets a same_type_as
edge linking the declarations, so a cross-repo contract is navigable. Matching requires a
non-empty namespace plus label, a real sourced type declaration (not a method/field or
sourceless stub), and at least two distinct repos, so two unrelated types that merely share
a short name are not linked; the edge is INFERRED/0.9.
This commit is contained in:
durmazoguzhan
2026-08-24 14:09:46 +01:00
committed by safishamsi
parent 3ef625b3b6
commit c46b756cde
3 changed files with 205 additions and 0 deletions
+7
View File
@@ -2543,6 +2543,13 @@ def dispatch_command(cmd: str) -> None:
if isinstance(hes, list):
collected_hyperedges.extend(h for h in hes if isinstance(h, dict))
merged = _nx.compose(merged, prefixed)
# A contract type both repos declare arrives as two unconnected nodes,
# since every id is repo-prefixed. Link them so a traversal can cross
# the repo boundary (#3007).
from graphify.cross_repo_types import link_shared_type_declarations as _link_shared
shared_links = _link_shared(merged)
if shared_links:
print(f" linked {shared_links} type declaration(s) shared across repos")
# Drop whatever compose left behind (the last input's list, possibly
# with internal duplicates) so attach_hyperedges dedups the full
# collection by id from a clean slate.
+75
View File
@@ -0,0 +1,75 @@
"""Join the same declared type across repositories in a merged graph (#3007).
`merge-graphs` prefixes every node id with its repo tag, so a contract type that
two services both declare arrives as two unconnected nodes. In a message-bus
codebase that is exactly where the interesting hop lives: the producer references
`SyncProductUpsertToSearchEvent` in one repo, the consumer implements
`IConsumer<SyncProductUpsertToSearchEvent>` in the other, and the merged graph has
no way to get from one to the other even though both sides name the same type.
This pass adds a `same_type_as` edge between type declarations that share both a
namespace and a name and come from different repos. Requiring the namespace keeps
it to types that were declared identically rather than two classes that merely
picked the same short name: on a pair of .NET services with 1440 and 262 declared
types, the namespace-plus-name match produced 7 pairs, all of them the shared
`EventManager.Models.*Event` contracts, and nothing else.
Edges only, no node merging. Two repos can hold copies of a contract that have
drifted, and collapsing them would hide that; a link lets a traversal cross while
each side keeps its own members, file and provenance.
"""
from __future__ import annotations
from collections import defaultdict
from typing import TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover - typing only
import networkx as nx
SHARED_TYPE_RELATION = "same_type_as"
def link_shared_type_declarations(merged: "nx.Graph") -> int:
"""Link identically declared types across repos. Returns the edge count added.
A candidate node is a sourced type declaration carrying a namespace and a
repo tag. A group qualifies when its members span at least two repos, and
every pair inside a qualifying group gets one edge. Groups are the handful of
types two repos genuinely share, so the pairwise walk stays small.
"""
by_declaration: dict[tuple[str, str], list[str]] = defaultdict(list)
for node, data in merged.nodes(data=True):
if not data.get("_callable_class") or not data.get("source_file"):
continue
namespace = str((data.get("metadata") or {}).get("namespace") or "")
label = str(data.get("label") or "")
if not namespace or not label or not data.get("repo"):
continue
by_declaration[(namespace, label)].append(node)
added = 0
for (namespace, label), nodes in by_declaration.items():
if len(nodes) < 2:
continue
if len({merged.nodes[n].get("repo") for n in nodes}) < 2:
continue
for index, left in enumerate(nodes):
for right in nodes[index + 1:]:
if merged.nodes[left].get("repo") == merged.nodes[right].get("repo"):
continue
if merged.has_edge(left, right):
continue
merged.add_edge(
left,
right,
relation=SHARED_TYPE_RELATION,
context="cross_repo",
confidence="INFERRED",
confidence_score=0.9,
source_file=str(merged.nodes[left].get("source_file") or ""),
weight=1.0,
_src=left,
_tgt=right,
)
added += 1
return added
+123
View File
@@ -0,0 +1,123 @@
"""`merge-graphs` links a type declaration two repos share (#3007).
Node ids are repo-prefixed, so a contract type both services declare arrives as
two unconnected nodes and a traversal cannot cross the repo boundary even though
both sides name the same type. The merge now adds a `same_type_as` edge between
declarations that agree on namespace and name and come from different repos.
Namespace agreement is what keeps this from linking two classes that merely
picked the same short name, so the cases below pin both halves of that rule.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
PYTHON = sys.executable
def _run(args, cwd):
return subprocess.run([PYTHON, "-m", "graphify"] + args, cwd=cwd,
capture_output=True, text=True)
def _type_node(node_id: str, label: str, namespace: str | None, source_file: str = "a.cs"):
node: dict = {
"id": node_id,
"label": label,
"source_file": source_file,
"_callable_class": True,
"_callable": True,
}
if namespace is not None:
node["metadata"] = {"namespace": namespace}
return node
def _write(p: Path, nodes: list[dict]):
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps({
"directed": True, "multigraph": False, "graph": {},
"nodes": nodes, "links": [],
}))
def _merge(tmp_path, left: list[dict], right: list[dict]):
a = tmp_path / "svc_a" / "graphify-out" / "graph.json"
b = tmp_path / "svc_b" / "graphify-out" / "graph.json"
_write(a, left)
_write(b, right)
out = tmp_path / "merged.json"
r = _run(["merge-graphs", str(a), str(b), "--out", str(out)], tmp_path)
assert r.returncode == 0, f"merge failed: {r.stderr}"
data = json.loads(out.read_text())
links = [e for e in data["links"] if e.get("relation") == "same_type_as"]
return links, data
def test_same_namespace_and_name_across_repos_are_linked(tmp_path):
links, _ = _merge(
tmp_path,
[_type_node("evt", "OrderPlaced", "Contracts.Events")],
[_type_node("evt", "OrderPlaced", "Contracts.Events")],
)
assert len(links) == 1
endpoints = {links[0]["source"], links[0]["target"]}
assert endpoints == {"svc_a::evt", "svc_b::evt"}
assert links[0]["confidence"] == "INFERRED"
def test_same_name_in_different_namespaces_is_not_linked(tmp_path):
# Two services with their own unrelated `Settings` class.
links, _ = _merge(
tmp_path,
[_type_node("s", "Settings", "Catalog.Configuration")],
[_type_node("s", "Settings", "Search.Configuration")],
)
assert links == []
def test_two_declarations_inside_one_repo_are_not_linked(tmp_path):
# A partial class or a same-named type in two files of one repo is not a
# cross-repo join, and merging them is #296's question, not this one.
links, _ = _merge(
tmp_path,
[
_type_node("one", "OrderPlaced", "Contracts.Events", "one.cs"),
_type_node("two", "OrderPlaced", "Contracts.Events", "two.cs"),
],
[_type_node("other", "Unrelated", "Contracts.Events")],
)
assert links == []
def test_a_type_with_no_namespace_is_not_linked(tmp_path):
# Without a namespace the name alone is too weak to claim they are the same.
links, _ = _merge(
tmp_path,
[_type_node("evt", "OrderPlaced", None)],
[_type_node("evt", "OrderPlaced", None)],
)
assert links == []
def test_non_type_nodes_are_not_linked(tmp_path):
method_a = {"id": "m", "label": ".Handle()", "source_file": "a.cs",
"metadata": {"namespace": "Contracts.Events"}}
method_b = dict(method_a)
links, _ = _merge(tmp_path, [method_a], [method_b])
assert links == []
def test_sourceless_stub_is_not_linked(tmp_path):
# A stub minted for a dangling reference has no declaration behind it.
stub = {"id": "evt", "label": "OrderPlaced", "_callable_class": True,
"metadata": {"namespace": "Contracts.Events"}}
links, _ = _merge(
tmp_path,
[stub],
[_type_node("evt", "OrderPlaced", "Contracts.Events")],
)
assert links == []