mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-26 16:26:42 +00:00
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:
committed by
safishamsi
parent
3ef625b3b6
commit
c46b756cde
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
Reference in New Issue
Block a user