diff --git a/graphify/cross_repo_calls.py b/graphify/cross_repo_calls.py index 32117e182..4859c17da 100644 --- a/graphify/cross_repo_calls.py +++ b/graphify/cross_repo_calls.py @@ -42,6 +42,7 @@ _LANG_SUFFIXES: dict[str, frozenset[str]] = { "cpp": frozenset({".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".h", ".cu", ".cuh"}), "csharp": frozenset({".cs"}), "java": frozenset({".java"}), + "kotlin": frozenset({".kt", ".kts"}), "swift": frozenset({".swift"}), } diff --git a/graphify/extract.py b/graphify/extract.py index bd8d541c5..61a5040b0 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -6994,6 +6994,14 @@ def _resolve_kotlin_member_calls( callee = rc["callee"] caller = rc["caller_nid"] type_nids = types_by_name.get(receiver, []) + if not type_nids: + # Object/class declared nowhere here — usually in a repo this build does + # not contain, so park it for the merge (#3152/#3388). A builtin receiver + # (String.format, ...) never crosses a repo boundary. + if receiver not in _LANGUAGE_BUILTIN_GLOBALS: + _park_unresolved_member_call( + node_by_id.get(caller), callee, receiver, "kotlin", rc) + continue if len(type_nids) != 1: continue wanted = f".{callee}" diff --git a/tests/test_cross_repo_member_calls.py b/tests/test_cross_repo_member_calls.py index fc6bc030e..760ef37cc 100644 --- a/tests/test_cross_repo_member_calls.py +++ b/tests/test_cross_repo_member_calls.py @@ -6,8 +6,8 @@ type already in hand and nothing about it reached `graph.json` — the only arti `merge-graphs` and `global add` read. The two-repo graph was missing precisely the edges that make it a call graph. -The Java, C++, C# and Swift resolvers now park those calls on the caller node and -this pass finishes them after the merge. The cases below pin what it must NOT do +The Java, C++, C#, Swift and Kotlin resolvers now park those calls on the caller +node and this pass finishes them after the merge. The cases below pin what it must NOT do as much as what it must: the single-definition guard, the cross-repo-only scope, and the language guard are what keep it from fabricating an edge from a name collision. @@ -45,6 +45,7 @@ needs_java = _needs("tree_sitter_java") needs_cpp = _needs("tree_sitter_cpp") needs_csharp = _needs("tree_sitter_c_sharp") needs_swift = _needs("tree_sitter_swift") +needs_kotlin = _needs("tree_sitter_kotlin") def _caller(repo: str, parked: list[dict], node_id: str = "app_run", @@ -374,6 +375,14 @@ def test_a_java_build_parks_the_call_and_the_merge_finishes_it(tmp_path: Path): ("src/Greeter.swift", "class Greeter { func greet() {} }\n"), marks=needs_swift, id="swift-property-receiver", ), + pytest.param( + # The object-qualified shape the Kotlin resolver answers: `Greeter.greet()` + # names the object singleton explicitly, and only the other repo declares it. + "kotlin", "greet", + ("src/App.kt", "fun run() { Greeter.greet() }\n"), + ("src/Greeter.kt", "object Greeter { fun greet() {} }\n"), + marks=needs_kotlin, id="kotlin-object-receiver", + ), ]) def test_each_language_parks_the_call_and_the_merge_finishes_it( tmp_path: Path, lang: str, callee: str, app_file: tuple[str, str],