"""Swift member-level protocol dispatch. `repo.fetch()` on an injected `Repository` resolves to the protocol's own `.fetch()` requirement (#3673), which is what the call site names. The conformer's `.fetch()` is a separate node with nothing joining the two, so a directed walk stops at the protocol and every chain through an injected dependency is cut there — the Swift twin of the C# gap #3003 closed. `resolve_swift_protocol_dispatch` links the requirement to every conforming method, as long as that conformer owns exactly one method of the same name. A conformer's own ambiguity leaves only that conformer's edge out, the same single-owner guard the C#, Pascal and Ruby resolvers use. Confidence falls as the conformer count grows (0.85 / 0.75 / 0.65). """ from __future__ import annotations import os import tempfile from pathlib import Path from graphify.extract import extract def _extract(tmp_path, files: dict[str, str]): for name, body in files.items(): p = tmp_path / name p.parent.mkdir(parents=True, exist_ok=True) p.write_text(body) old = os.getcwd() try: os.chdir(tmp_path) r = extract([Path(n) for n in files], cache_root=Path(tempfile.mkdtemp())) finally: os.chdir(old) dispatch = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "dispatches_to"} return dispatch, r def _find(r, label, id_contains): return next(n["id"] for n in r["nodes"] if n["label"] == label and id_contains in n["id"]) def _reachable(r, start: str) -> set[str]: adjacency: dict[str, list[str]] = {} for e in r["edges"]: adjacency.setdefault(e["source"], []).append(e["target"]) seen = {start} queue = [start] while queue: for nxt in adjacency.get(queue.pop(0), []): if nxt not in seen: seen.add(nxt) queue.append(nxt) return seen # Protocol in one file, conformer in another, consumer injected with the # protocol: the shape every service layer in an iOS app has. _INJECTED = { "Core/Store.swift": ( "protocol Store {\n" " func fetch() async throws -> [Item]\n" "}\n" "struct Item { let id: Int }\n" ), "Services/RemoteStore.swift": ( "struct RemoteStore: Store {\n" " func fetch() async throws -> [Item] { try decode() }\n" " func decode() throws -> [Item] { [] }\n" "}\n" ), "App/ItemModel.swift": ( "final class ItemModel {\n" " private let store: Store\n" " init(store: Store) { self.store = store }\n" " func load() async throws -> [Item] { try await store.fetch() }\n" "}\n" ), } def test_single_conformer_links_the_protocol_requirement(tmp_path): dispatch, r = _extract(tmp_path, _INJECTED) assert (_find(r, ".fetch()", "core_store"), _find(r, ".fetch()", "remotestore")) in dispatch def test_chain_through_an_injected_dependency_becomes_reachable(tmp_path): dispatch, r = _extract(tmp_path, _INJECTED) assert dispatch # load -> Store.fetch -> RemoteStore.fetch -> decode assert _find(r, ".decode()", "remotestore") in _reachable(r, _find(r, ".load()", "itemmodel")) def test_the_call_to_the_protocol_requirement_is_kept(tmp_path): # Additive: the call site really does name the protocol. _, r = _extract(tmp_path, _INJECTED) calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"} assert (_find(r, ".load()", "itemmodel"), _find(r, ".fetch()", "core_store")) in calls def test_dispatch_edge_shape(tmp_path): # INFERRED: the target is forced once there is a single conformer, but the # source text never names it. Anchored at the conformer, where the method is. _, r = _extract(tmp_path, _INJECTED) edge = next(e for e in r["edges"] if e["relation"] == "dispatches_to") assert edge["confidence"] == "INFERRED" assert edge["confidence_score"] == 0.85 assert edge["context"] == "call" assert edge["source_file"].endswith("RemoteStore.swift") def test_conformance_declared_in_an_extension_links(tmp_path): # `extension T: P` is the idiomatic place to declare a conformance. The # extension is folded into T before the resolvers run, so the pair is the # same as a conformance written on the type. dispatch, r = _extract(tmp_path, { "Core/Store.swift": "protocol Store { func fetch() }\n", "Services/RemoteStore.swift": ( "final class RemoteStore { }\n" "extension RemoteStore: Store {\n" " func fetch() { }\n" "}\n" ), }) assert (_find(r, ".fetch()", "core_store"), _find(r, ".fetch()", "remotestore")) in dispatch def test_class_conformer_links_when_the_protocol_is_in_the_same_file(tmp_path): dispatch, r = _extract(tmp_path, {"S.swift": ( "protocol Store { func fetch() }\n" "final class RemoteStore: Store { func fetch() { } }\n" )}) assert (_find(r, ".fetch()", "s_store"), _find(r, ".fetch()", "remotestore")) in dispatch def test_class_conformer_of_a_protocol_in_another_file_is_a_known_limit(tmp_path): # Swift spells a superclass and a protocol the same way in a class's # inheritance list. `_swift_classify_base` only knows the protocols declared # in the file it is extracting, so `class C: P` with P elsewhere is emitted # as `inherits`, and this pass — keyed on `implements`, like the C# one — # never sees it. Teaching the classifier about the corpus is follow-up work; # struct/enum/actor conformers and `extension C: P` are unaffected. dispatch, r = _extract(tmp_path, { "Core/Store.swift": "protocol Store { func fetch() }\n", "Services/RemoteStore.swift": "final class RemoteStore: Store { func fetch() { } }\n", }) heritage = {e["relation"] for e in r["edges"] if e["source"] == _find(r, "RemoteStore", "remotestore") and e["target"] == _find(r, "Store", "core_store")} assert heritage == {"inherits"} assert not dispatch def test_two_conformers_both_get_an_edge_at_lower_confidence(tmp_path): dispatch, r = _extract(tmp_path, {"S.swift": ( "protocol Shape { func area() -> Double }\n" "struct Square: Shape { func area() -> Double { 1 } }\n" "struct Circle: Shape { func area() -> Double { 2 } }\n" )}) requirement = _find(r, ".area()", "s_shape") assert dispatch == { (requirement, _find(r, ".area()", "square")), (requirement, _find(r, ".area()", "circle")), } for edge in r["edges"]: if edge["relation"] == "dispatches_to": assert edge["confidence_score"] == 0.75 def test_no_matching_member_produces_no_edge(tmp_path): dispatch, _ = _extract(tmp_path, {"S.swift": ( "protocol Store { func fetch() }\n" "struct Local: Store { func other() { } }\n" )}) assert not dispatch def test_default_implementation_in_a_protocol_extension_is_not_dispatched(tmp_path): # `extension P { func m() {} }` is folded into P's own node, so a conformer # that does not override `m` owns no candidate — the call really does land # on the default, which the graph already reaches through P.m. dispatch, _ = _extract(tmp_path, {"S.swift": ( "protocol Greeter { func greet() }\n" "extension Greeter { func greet() { } }\n" "struct Silent: Greeter { }\n" )}) assert not dispatch def test_overriding_a_default_implementation_links(tmp_path): dispatch, r = _extract(tmp_path, {"S.swift": ( "protocol Greeter { func greet() }\n" "extension Greeter { func greet() { } }\n" "struct Loud: Greeter { func greet() { } }\n" )}) assert (_find(r, ".greet()", "s_greeter"), _find(r, ".greet()", "s_loud")) in dispatch def test_builtin_protocol_conformance_is_skipped_and_static_requirements_link(tmp_path): # `View` is a sourceless stub minted for the dangling reference; it carries # no members and never passes the declared-in guard. The user protocol # beside it still links, static requirement included. dispatch, r = _extract(tmp_path, {"V.swift": ( "import SwiftUI\n" "protocol Factory { static func make() -> Self }\n" "struct Box: View, Factory {\n" " var body: some View { Text(\"x\") }\n" " static func make() -> Box { Box() }\n" "}\n" )}) assert dispatch == {(_find(r, ".make()", "v_factory"), _find(r, ".make()", "v_box"))} def test_subclassing_a_class_is_out_of_scope(tmp_path): # `class Sub: Base` is an `inherits` edge, and the base method may well be # the real target, so walking inheritance is a different bet from dispatch. dispatch, _ = _extract(tmp_path, {"S.swift": ( "class Base { func m() { } }\n" "final class Sub: Base { override func m() { } }\n" )}) assert not dispatch def test_protocol_inheritance_is_not_walked_transitively(tmp_path): # Known limit, same as the C# pass: Base's only "conformer" is Child, a # protocol that declares nothing, so Base.m never reaches Impl.m. dispatch, _ = _extract(tmp_path, {"S.swift": ( "protocol Base { func m() }\n" "protocol Child: Base { }\n" "struct Impl: Child { func m() { } }\n" )}) assert not dispatch def test_a_kotlin_conformer_of_a_same_named_protocol_is_not_dispatched_to(tmp_path): # `implements` resolves by name, so in a mixed corpus a Kotlin class # declaring `: Store` binds to the Swift protocol when that is the only node # with the name. Linking a Swift requirement to a Kotlin method would be a # wrong edge rather than a missing one. dispatch, _ = _extract(tmp_path, { "Store.swift": "protocol Store { func fetch() }\n", "RemoteStore.kt": "class RemoteStore : Store { fun fetch() {} }\n", }) assert not dispatch def test_a_csharp_corpus_is_untouched_by_the_swift_pass(tmp_path): # Both ends must be Swift declarations; the C# pair is the C# pass's job. from graphify.swift_dispatch import resolve_swift_protocol_dispatch nodes = [ {"id": "iface", "label": "IR", "source_file": "a.cs", "_callable_class": True}, {"id": "impl", "label": "A", "source_file": "a.cs", "_callable_class": True}, {"id": "iface_m", "label": ".M()", "source_file": "a.cs", "_callable": True}, {"id": "impl_m", "label": ".M()", "source_file": "a.cs", "_callable": True}, ] edges = [ {"source": "impl", "target": "iface", "relation": "implements"}, {"source": "iface", "target": "iface_m", "relation": "method"}, {"source": "impl", "target": "impl_m", "relation": "method"}, ] resolve_swift_protocol_dispatch([], nodes, edges) assert not any(e["relation"] == "dispatches_to" for e in edges) def test_swift_pass_is_registered_after_the_csharp_pass(): import graphify.extract # noqa: F401 (registers resolvers on import) from graphify.resolver_registry import registered_resolvers names = [r.name for r in registered_resolvers()] assert "swift_protocol_dispatch" in names assert names.index("csharp_interface_dispatch") < names.index("swift_protocol_dispatch")