diff --git a/graphify/cross_repo_calls.py b/graphify/cross_repo_calls.py index 2e571042d..841ca39f4 100644 --- a/graphify/cross_repo_calls.py +++ b/graphify/cross_repo_calls.py @@ -43,6 +43,7 @@ _LANG_SUFFIXES: dict[str, frozenset[str]] = { "csharp": frozenset({".cs"}), "go": frozenset({".go"}), "java": frozenset({".java"}), + "kotlin": frozenset({".kt", ".kts"}), "swift": frozenset({".swift"}), "php": frozenset({".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps"}), # TS and JS share one key: a TS class legitimately answers a JS call site, so diff --git a/graphify/extract.py b/graphify/extract.py index 07efd41f2..68bdfe388 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -7145,6 +7145,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 4a435509b..0129a3504 100644 --- a/tests/test_cross_repo_member_calls.py +++ b/tests/test_cross_repo_member_calls.py @@ -6,9 +6,9 @@ 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#, Swift, Go, PHP and TS/JS 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 +The Java, C++, C#, Swift, Go, Kotlin, PHP and TS/JS 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. """ @@ -47,6 +47,7 @@ needs_csharp = _needs("tree_sitter_c_sharp") needs_swift = _needs("tree_sitter_swift") needs_go = _needs("tree_sitter_go") needs_typescript = _needs("tree_sitter_typescript") +needs_kotlin = _needs("tree_sitter_kotlin") def _caller(repo: str, parked: list[dict], node_id: str = "app_run", @@ -469,6 +470,14 @@ def test_a_java_build_parks_the_call_and_the_merge_finishes_it(tmp_path: Path): "func (g *Greeter) Greet() {}\n"), marks=needs_go, id="go-struct-field", ), + 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],