From c46b756cde20d70fdd09f2bd174805be18fc88a8 Mon Sep 17 00:00:00 2001 From: durmazoguzhan <81313884+durmazoguzhan@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:09:46 +0100 Subject: [PATCH] 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. --- graphify/cli.py | 7 ++ graphify/cross_repo_types.py | 75 ++++++++++++++++ tests/test_cross_repo_shared_types.py | 123 ++++++++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 graphify/cross_repo_types.py create mode 100644 tests/test_cross_repo_shared_types.py diff --git a/graphify/cli.py b/graphify/cli.py index 24442973..5b733972 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -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. diff --git a/graphify/cross_repo_types.py b/graphify/cross_repo_types.py new file mode 100644 index 00000000..5bc269fe --- /dev/null +++ b/graphify/cross_repo_types.py @@ -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` 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 diff --git a/tests/test_cross_repo_shared_types.py b/tests/test_cross_repo_shared_types.py new file mode 100644 index 00000000..eb123882 --- /dev/null +++ b/tests/test_cross_repo_shared_types.py @@ -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 == []