diff --git a/graphify/cross_repo_calls.py b/graphify/cross_repo_calls.py index 63a319e471..f7d1f22424 100644 --- a/graphify/cross_repo_calls.py +++ b/graphify/cross_repo_calls.py @@ -42,6 +42,9 @@ "cpp": frozenset({".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".h", ".cu", ".cuh"}), "csharp": frozenset({".cs"}), "java": frozenset({".java"}), + # A Kotlin call may be answered by any JVM-family declaration: the classpath is one + # namespace, and a Kotlin module over a Java library is the common Android shape. + "kotlin": frozenset({".kt", ".kts", ".java", ".scala", ".groovy", ".gradle"}), "swift": frozenset({".swift"}), } diff --git a/graphify/extract.py b/graphify/extract.py index 7cc9e62c90..78e6f28c2b 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -146,7 +146,7 @@ from graphify.symbol_resolution import resolve_bash_source_edges # noqa: E402 -from graphify.extractors.engine import REFERENCE_CONTEXTS, _CSHARP_TYPE_PARAMETER_SCOPE_DECLARATIONS, _C_PRIMITIVE_TYPE_NODES, _JAVA_BUILTIN_TYPES, _JAVA_TYPE_PARAMETER_SCOPE_DECLARATIONS, _JS_FUNCTION_VALUE_TYPES, _JS_SCOPE_BOUNDARY, _PYTHON_ANNOTATION_NOISE, _PYTHON_TYPE_CONTAINERS, _RUBY_CLASS_FACTORIES, _c_collect_type_refs, _cpp_collect_type_refs, _cpp_declarator_name, _cpp_local_var_types, _csharp_attribute_names, _csharp_classify_base, _csharp_collect_type_refs, _csharp_extra_walk, _csharp_namespace_id, _csharp_namespace_name, _csharp_pre_scan_interfaces, _csharp_type_parameters_in_scope, _dynamic_import_js, _extract_generic, _find_body, _find_require_call, _get_cpp_func_name, _java_annotation_names, _java_collect_type_refs, _java_extra_walk, _java_type_parameters_in_scope, _js_collect_pattern_idents, _js_dispatch_value_idents, _js_extra_walk, _js_local_bound_names, _js_member_assignment_target, _js_module_bound_names, _kotlin_collect_type_refs, _kotlin_function_return_type_node, _kotlin_property_type_node, _kotlin_user_type_name, _php_collect_type_refs, _php_method_return_type_node, _php_name_text, _python_collect_assignment_targets, _python_collect_param_refs, _python_collect_type_refs, _python_local_bound_names, _python_module_bound_names, _python_param_names, _read_csharp_type_name, _require_imports_js, _ruby_const_last_name, _ruby_extra_walk, _ruby_local_class_bindings, _ruby_new_class_name, _scala_collect_type_refs, _semantic_reference_edge, _source_location, _swift_classify_base, _swift_collect_type_refs, _swift_constructor_type, _swift_declaration_keyword, _swift_extra_walk, _swift_local_var_types, _swift_pre_scan, _swift_property_name, _swift_property_type_node, _swift_receiver_name, _swift_user_type_name, _ts_decorator_name, _ts_descendant_decorators, _ts_emit_decorator_edges, _ts_extra_walk, _ts_method_name, _ts_receiver_type_table # noqa: E402,F401 +from graphify.extractors.engine import REFERENCE_CONTEXTS, _CSHARP_TYPE_PARAMETER_SCOPE_DECLARATIONS, _C_PRIMITIVE_TYPE_NODES, _JAVA_BUILTIN_TYPES, _JAVA_TYPE_PARAMETER_SCOPE_DECLARATIONS, _JS_FUNCTION_VALUE_TYPES, _JS_SCOPE_BOUNDARY, _KOTLIN_BUILTIN_TYPES, _PYTHON_ANNOTATION_NOISE, _PYTHON_TYPE_CONTAINERS, _RUBY_CLASS_FACTORIES, _c_collect_type_refs, _cpp_collect_type_refs, _cpp_declarator_name, _cpp_local_var_types, _csharp_attribute_names, _csharp_classify_base, _csharp_collect_type_refs, _csharp_extra_walk, _csharp_namespace_id, _csharp_namespace_name, _csharp_pre_scan_interfaces, _csharp_type_parameters_in_scope, _dynamic_import_js, _extract_generic, _find_body, _find_require_call, _get_cpp_func_name, _java_annotation_names, _java_collect_type_refs, _java_extra_walk, _java_type_parameters_in_scope, _js_collect_pattern_idents, _js_dispatch_value_idents, _js_extra_walk, _js_local_bound_names, _js_member_assignment_target, _js_module_bound_names, _kotlin_collect_type_refs, _kotlin_function_return_type_node, _kotlin_property_type_node, _kotlin_user_type_name, _php_collect_type_refs, _php_method_return_type_node, _php_name_text, _python_collect_assignment_targets, _python_collect_param_refs, _python_collect_type_refs, _python_local_bound_names, _python_module_bound_names, _python_param_names, _read_csharp_type_name, _require_imports_js, _ruby_const_last_name, _ruby_extra_walk, _ruby_local_class_bindings, _ruby_new_class_name, _scala_collect_type_refs, _semantic_reference_edge, _source_location, _swift_classify_base, _swift_collect_type_refs, _swift_constructor_type, _swift_declaration_keyword, _swift_extra_walk, _swift_local_var_types, _swift_pre_scan, _swift_property_name, _swift_property_type_node, _swift_receiver_name, _swift_user_type_name, _ts_decorator_name, _ts_descendant_decorators, _ts_emit_decorator_edges, _ts_extra_walk, _ts_method_name, _ts_receiver_type_table # noqa: E402,F401 from graphify.extractors.pascal import _PAS_BEGIN_END_TOKEN_RE, _PAS_CALL_RE, _PAS_END_SEMI_RE, _PAS_IMPL_HEADER_RE, _PAS_KEYWORDS, _PAS_METHOD_DECL_RE, _PAS_MODULE_RE, _PAS_TOKEN_RE, _PAS_TYPE_HEADER_RE, _PAS_USES_RE, _extract_pascal_regex, _pascal_find_body, _pascal_split_bases, _pascal_split_sections, _pascal_split_uses, _pascal_strip_comments, extract_pascal # noqa: E402,F401 @@ -4414,6 +4414,110 @@ def _resolve_csharp_qualified_calls( }) +def _resolve_kotlin_member_calls( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Resolve Kotlin member calls (``greeter.greet()``) through the receiver's type. + + The shared cross-file pass skips member calls, so a call on a typed receiver whose + method is declared in another file resolved to nothing at all. The per-file + ``kotlin_type_table`` names the declared type of every property, primary-constructor + parameter, function parameter and local binding; this pass looks the receiver up + there, takes the single class/object declaring that type, and emits the ``calls`` + edge to its member — EXTRACTED when the receiver names the type in source + (``Registry.register()``), INFERRED when the type came from the table. + + A receiver typed to a class this corpus declares nowhere is parked on the caller for + a merged graph to finish (#3152). + """ + raw = [ + rc + for result in per_file + for rc in result.get("raw_calls", []) + if rc.get("lang") == "kotlin" and rc.get("is_member_call") + and rc.get("receiver") and rc.get("callee") and rc.get("caller_nid") + ] + if not raw: + return + type_table_by_file: dict[str, dict[str, str]] = {} + for result in per_file: + tt = result.get("kotlin_type_table") + if tt and tt.get("path"): + type_table_by_file[tt["path"]] = tt.get("table", {}) + + def _key(label: str) -> str: + return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower() + + # A genuine declaration is the target of a `contains` edge from its file node; a bare + # type reference mints a same-label stub that would otherwise make a real name ambiguous. + # The whole JVM family counts, `.java` included: one classpath makes interop a real + # answer, while a same-named class outside the family is a collision. + contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"} + type_def_nids: dict[str, list[str]] = {} + node_by_id: dict[str, dict] = {} + for n in all_nodes: + node_by_id[n.get("id")] = n + if (_lang_family(n.get("source_file")) == "jvm" + and n.get("id") in contained and _is_type_like_definition(n)): + type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) + + method_index: dict[tuple[str, str], str] = {} + for e in all_edges: + if e.get("relation") != "method": + continue + tnode = node_by_id.get(e.get("target")) + if tnode is not None: + method_index[(e.get("source"), _key(tnode.get("label", "")))] = e["target"] + + existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges} + for rc in raw: + receiver, callee, caller = rc["receiver"], rc["callee"], rc["caller_nid"] + # A capitalized receiver is the type itself — a companion/static call or an + # `object` singleton. Kotlin imports the class name into scope rather than a + # module alias, so unlike TS/JS this spelling is not also the namespace idiom. + if receiver[:1].isupper(): + type_name = receiver + type_qualified = True + else: + type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver) + type_qualified = False + if not type_name: + continue + if (type_name in _LANGUAGE_BUILTIN_GLOBALS or type_name in _KOTLIN_BUILTIN_TYPES + or type_name in _JAVA_BUILTIN_TYPES): + continue + type_defs = type_def_nids.get(_key(type_name), []) + if not type_defs: + # Declared nowhere here — usually "in a repo this build does not contain", + # so park it for the merge (#3152). The extractor's `lang` tag already says + # who is asking, so no suffix sniff is needed. + _park_unresolved_member_call( + node_by_id.get(caller), callee, type_name, "kotlin", rc, + ) + continue + if len(type_defs) != 1: # ambiguous -> bail (god-node guard) + continue + target = method_index.get((type_defs[0], _key(callee))) + if not target or target == caller or (caller, target) in existing_pairs: + continue + existing_pairs.add((caller, target)) + all_edges.append({ + "source": caller, + "target": target, + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED" if type_qualified else "INFERRED", + # The rubric's discrete INFERRED scale (references/extraction-spec.md): + # a single-definition type-table hit is the high-confidence rung. + "confidence_score": 1.0 if type_qualified else 0.85, + "source_file": rc.get("source_file", ""), + "source_location": rc.get("source_location"), + "weight": 1.0, + }) + + def _resolve_kotlin_qualified_calls( per_file: list[dict], all_nodes: list[dict], @@ -4591,6 +4695,12 @@ def _resolve_kotlin_qualified_calls( resolve_pascal_inherited_calls, ) ) +# Kotlin receiver-typed member-call resolution: `greeter.greet()` where the method is +# declared in another file. The shared pass skips member calls, so these had no edge. +register_language_resolver( + LanguageResolver("kotlin_member_calls", frozenset({".kt", ".kts"}), + _resolve_kotlin_member_calls) +) # Kotlin fully-qualified call resolution (#2550): `com.pkg.Fn()` / # `com.pkg.Object.method()` raw_calls the shared pass skips (member calls with # no receiver). Runs in the tail registry like the other member-call resolvers; diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index be6c38552c..5ccf90b0ac 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -807,6 +807,90 @@ def _kotlin_function_return_type_node(func_node): return c return None +def _kotlin_property_name(property_node, source: bytes) -> str | None: + """Return the name a Kotlin ``val``/``var`` declaration binds.""" + for c in property_node.children: + if c.type == "variable_declaration": + for sub in c.children: + if sub.type in ("simple_identifier", "identifier"): + return _read_text(sub, source) + elif c.type in ("simple_identifier", "identifier"): + return _read_text(c, source) + return None + +def _kotlin_head_type_name(type_node, source: bytes) -> str | None: + """The declared type's own name from a Kotlin type node, generic arguments dropped.""" + refs: list[tuple[str, str]] = [] + _kotlin_collect_type_refs(type_node, source, False, refs) + return next((name for name, role in refs if role == "type"), None) + +def _kotlin_constructor_type(property_node, source: bytes) -> str | None: + """Infer an unannotated Kotlin binding's type from its constructor call. + + A constructor invocation is spelled exactly like a function call in this grammar, so + the capitalized head is the only evidence that `val c = Client()` binds a Client. + """ + call = next((c for c in property_node.children if c.type == "call_expression"), None) + head = call.children[0] if call is not None and call.children else None + if head is None or head.type not in ("simple_identifier", "identifier"): + return None + text = _read_text(head, source) + if (text and text[:1].isupper() and text not in _KOTLIN_BUILTIN_TYPES + and text not in _JAVA_BUILTIN_TYPES): + return text + return None + +def _kotlin_binding_type(node, source: bytes) -> str | None: + """A Kotlin property/local's type: the annotation, else its constructor call.""" + return (_kotlin_head_type_name(_kotlin_property_type_node(node), source) + or _kotlin_constructor_type(node, source)) + +def _kotlin_constructor_param_types(class_node, source: bytes, table: dict[str, str]) -> None: + """Collect ``name -> Type`` from a Kotlin primary constructor's parameters. + + `class App(private val greeter: Greeter)` is the idiomatic injection point and declares + no property_declaration, so nothing else in the walk ever names the receiver's type. + """ + for ctor in class_node.children: + if ctor.type != "primary_constructor": + continue + for params in ctor.children: + if params.type != "class_parameters": + continue + for cp in params.children: + if cp.type != "class_parameter": + continue + name: str | None = None + cp_type: str | None = None + for sub in cp.children: + if name is None and sub.type in ("simple_identifier", "identifier"): + name = _read_text(sub, source) + elif sub.type in ("user_type", "nullable_type", "type_reference"): + cp_type = _kotlin_head_type_name(sub, source) + if name and cp_type and name not in table: + table[name] = cp_type + +def _kotlin_local_var_types(body_node, source: bytes, table: dict[str, str]) -> None: + """Collect ``name -> Type`` from local ``val``/``var`` bindings in a Kotlin body. + + A nested function's locals are scoped away, and the first binding for a name wins so a + shadowing local cannot retype a name the enclosing class already bound. + """ + stack = [body_node] + while stack: + n = stack.pop() + if n.type == "function_declaration" and n is not body_node: + continue + if n.type == "property_declaration": + name = _kotlin_property_name(n, source) + if name and name not in table: + var_type = _kotlin_binding_type(n, source) + if var_type: + table[name] = var_type + # Reversed so the LIFO stack pops in document order: the first binding written has to + # win, or a call loses its own branch's type to a sibling branch rebinding the name. + stack.extend(reversed(n.children)) + def _swift_declaration_keyword(node) -> str | None: """Return the leading kind token for a Swift class_declaration: class/struct/enum/extension/actor.""" for c in node.children: @@ -3371,6 +3455,7 @@ def _php_emit_base(base_name: str, rel: str, at_line: int) -> None: # Kotlin-specific: delegation_specifiers → inherits (constructor_invocation) / implements (user_type) if config.ts_module == "tree_sitter_kotlin": + _kotlin_constructor_param_types(node, source, type_table) for child in node.children: if child.type != "delegation_specifiers": continue @@ -4077,6 +4162,13 @@ def scala_base_name(type_node) -> str | None: target_nid = ensure_named_node(ref_name, line) if target_nid != parent_class_nid: add_edge(parent_class_nid, target_nid, "references", line, context=ctx) + # Per-file receiver table, first binding wins: a method parameter or a + # shadowing local must not retype a name a property already bound. + prop_name = _kotlin_property_name(node, source) + if prop_name and prop_name not in type_table: + prop_type = _kotlin_binding_type(node, source) + if prop_type: + type_table[prop_name] = prop_type # #2565: seed the initializer into initializer_nodes so walk_calls # collects its calls (`val repo = createRepo()`), which previously # died at the `return` below. Seeding the WHOLE expression (not just @@ -4511,6 +4603,12 @@ def scala_base_name(type_node) -> str | None: target_nid = ensure_named_node(ref_name, line) if target_nid != func_nid: add_edge(func_nid, target_nid, "references", line, context=ctx) + param_type = next((n for n, r in refs if r == "type"), None) + param_name = next( + (_read_text(sub, source) for sub in p.children + if sub.type in ("simple_identifier", "identifier")), None) + if param_name and param_type and param_name not in type_table: + type_table[param_name] = param_type return_type_node = _kotlin_function_return_type_node(node) if return_type_node is not None: refs = [] @@ -5289,12 +5387,11 @@ def walk_calls( # EVERY chain segment is a plain identifier and there are # >= 3 (a real dotted FQN, not `recv.method()`), stamp the # dotted prefix for _resolve_kotlin_qualified_calls. - # member_receiver is deliberately NOT set: an uppercase - # receiver would trip the capitalized-receiver deferral - # below and regress in-file `Foo.bar()` resolution. segments = _kotlin_nav_identifier_segments(first, source) if segments is not None and len(segments) >= 3: kotlin_qualified_prefix = ".".join(segments[:-1]) + elif segments is not None and len(segments) == 2: + member_receiver = segments[0] elif config.ts_module == "tree_sitter_scala": # Scala: first child first = node.children[0] if node.children else None @@ -5636,9 +5733,14 @@ def walk_calls( _java_defer = ( config.ts_module == "tree_sitter_java" and is_member_call ) + # Kotlin never defers: `Foo.bar()` resolves in-file today, and the + # receiver type is only ever usable once the bare name misses locally, + # which already leaves tgt_nid None and routes the call to raw_calls. + _kotlin_keeps_in_file = config.ts_module == "tree_sitter_kotlin" if _python_defer or _java_defer or ( is_member_call and member_receiver + and not _kotlin_keeps_in_file and ( member_receiver[:1].isupper() or is_this_field_call @@ -5717,9 +5819,10 @@ def walk_calls( rc_entry["receiver_type"] = receiver_type # Kotlin fully-qualified call (#2550): the dotted prefix + # lang tag let _resolve_kotlin_qualified_calls claim it. - if kotlin_qualified_prefix: + if config.ts_module == "tree_sitter_kotlin": rc_entry["lang"] = "kotlin" - rc_entry["qualified_prefix"] = kotlin_qualified_prefix + if kotlin_qualified_prefix: + rc_entry["qualified_prefix"] = kotlin_qualified_prefix raw_calls.append(rc_entry) # Indirect dispatch: a function passed BY NAME as a call argument @@ -5965,6 +6068,11 @@ def walk_calls( _swift_local_var_types(body_node, source, type_table, factory=swift_factory_bindings) + # Kotlin: the same for `val c = Client()` / `val c: Client = …` inside a body. + if config.ts_module == "tree_sitter_kotlin": + for _caller_nid, body_node in function_bodies: + _kotlin_local_var_types(body_node, source, type_table) + # JS/TS: bodies already walked with their own caller_nid (const-assigned # arrows, methods). An INLINE/returned arrow or function-expression that is # NOT separately tracked (e.g. `return () => svc.doThing()`) is otherwise @@ -6163,6 +6271,8 @@ def _scan_js_module_dispatch(n) -> None: result["ts_type_table"] = {"path": str_path, "table": type_table} elif config.ts_module == "tree_sitter_cpp": result["cpp_type_table"] = {"path": str_path, "table": type_table} + elif config.ts_module == "tree_sitter_kotlin": + result["kotlin_type_table"] = {"path": str_path, "table": type_table} return result def _python_decorator_name(deco_node, source: bytes) -> str | None: diff --git a/tests/test_cross_repo_member_calls.py b/tests/test_cross_repo_member_calls.py index 3c16724646..d8bc823174 100644 --- a/tests/test_cross_repo_member_calls.py +++ b/tests/test_cross_repo_member_calls.py @@ -6,10 +6,10 @@ `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 -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 +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. """ from __future__ import annotations @@ -45,6 +45,7 @@ def _needs(module: str): 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", @@ -167,6 +168,30 @@ def test_a_cpp_header_declaration_answers_through_defines(): assert _added_calls(G) == {("a::app_run", "b::greeter_greet")} +PARKED_KOTLIN = [{"callee": "greet", "receiver_type": "Greeter", "lang": "kotlin", + "line": "L2"}] + + +def test_a_kotlin_call_binds_to_a_java_declaration(): + # The JVM classpath is one namespace, so a Kotlin module calling a Java library in + # another repo is a member call across repos, not a language mismatch. + G = _graph( + caller=_caller("a", PARKED_KOTLIN, source_file="src/App.kt"), + declarations=[(_declaration("b", "Greeter"), _method("b"))], + ) + assert link_cross_repo_member_calls(G) == 1 + assert _added_calls(G) == {("a::app_run", "b::greeter_greet")} + + +def test_a_kotlin_call_does_not_bind_to_a_swift_declaration(): + G = _graph( + caller=_caller("a", PARKED_KOTLIN, source_file="src/App.kt"), + declarations=[(_declaration("b", "Greeter", "Greeter.swift"), + _method("b", source_file="Greeter.swift"))], + ) + assert link_cross_repo_member_calls(G) == 0 + + def test_a_defines_member_does_not_answer_a_java_call(): # Outside C++ a `defines` target is a field, and a field cannot be called. G = _graph( @@ -373,6 +398,16 @@ 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 idiomatic Kotlin injection point declares no property, so the primary + # constructor's parameter is the only place the receiver's type is written. + "kotlin", "greet", + ("src/App.kt", "class App(private val greeter: Greeter) {\n" + " fun run() { greeter.greet() }\n" + "}\n"), + ("src/Greeter.kt", "class Greeter {\n fun greet() {}\n}\n"), + marks=needs_kotlin, id="kotlin-primary-constructor", + ), ]) def test_each_language_parks_the_call_and_the_merge_finishes_it( tmp_path: Path, lang: str, callee: str, app_file: tuple[str, str], diff --git a/tests/test_kotlin_receiver_member_calls.py b/tests/test_kotlin_receiver_member_calls.py new file mode 100644 index 0000000000..3078c2871a --- /dev/null +++ b/tests/test_kotlin_receiver_member_calls.py @@ -0,0 +1,221 @@ +"""Kotlin member calls resolve through the receiver's declared type. + +The shared cross-file pass skips member calls, so `greeter.greet()` on a receiver +whose class lives in another file produced no edge at all — the Kotlin twin of the +Swift gap in #1356. Each case below pins one source of the receiver's type, and the +negative cases pin what must stay unresolved: an untyped receiver, an ambiguous class +name, and a builtin type that happens to share a name with a local class. +""" +from __future__ import annotations + +import importlib + +import pytest + +from graphify.extract import extract + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("tree_sitter_kotlin") is None, + reason="tree_sitter_kotlin not installed", +) + +GREETER = "class Greeter {\n fun greet() {}\n}\n" + + +def _calls(tmp_path, files: dict[str, str]): + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + result = extract([tmp_path / n for n in files], + cache_root=tmp_path / "graphify-out", parallel=False) + label = {n["id"]: n["label"] for n in result["nodes"]} + calls = {(label.get(e["source"]), label.get(e["target"])): e + for e in result["edges"] if e["relation"] == "calls"} + return calls, result + + +def _greet_edge(calls: dict) -> dict | None: + return next((e for (src, tgt), e in calls.items() + if src and "run" in src and tgt == ".greet()"), None) + + +def test_a_primary_constructor_parameter_types_the_receiver(tmp_path): + # Kotlin's idiomatic injection point declares no property, so nothing else in + # the walk ever names `greeter`'s type. + calls, _ = _calls(tmp_path, { + "Greeter.kt": GREETER, + "App.kt": "class App(private val greeter: Greeter) {\n" + " fun run() { greeter.greet() }\n" + "}\n", + }) + edge = _greet_edge(calls) + assert edge is not None, calls + assert edge["confidence"] == "INFERRED" + + +def test_an_annotated_property_types_the_receiver(tmp_path): + calls, _ = _calls(tmp_path, { + "Greeter.kt": GREETER, + "App.kt": "class App {\n" + " private val greeter: Greeter = Greeter()\n" + " fun run() { greeter.greet() }\n" + "}\n", + }) + assert _greet_edge(calls) is not None, calls + + +def test_a_constructor_call_types_an_unannotated_local(tmp_path): + calls, _ = _calls(tmp_path, { + "Greeter.kt": GREETER, + "App.kt": "class App {\n" + " fun run() {\n" + " val greeter = Greeter()\n" + " greeter.greet()\n" + " }\n" + "}\n", + }) + assert _greet_edge(calls) is not None, calls + + +def test_a_function_parameter_types_the_receiver(tmp_path): + calls, _ = _calls(tmp_path, { + "Greeter.kt": GREETER, + "App.kt": "class App {\n" + " fun run(greeter: Greeter) { greeter.greet() }\n" + "}\n", + }) + assert _greet_edge(calls) is not None, calls + + +def test_an_object_receiver_is_the_type_itself(tmp_path): + # `Registry.register()` names the type in source, so the edge is exact. + calls, _ = _calls(tmp_path, { + "Registry.kt": "object Registry {\n fun register() {}\n}\n", + "App.kt": "class App {\n fun run() { Registry.register() }\n}\n", + }) + edge = next((e for (src, tgt), e in calls.items() + if src and "run" in src and tgt == ".register()"), None) + assert edge is not None, calls + assert edge["confidence"] == "EXTRACTED" + + +def test_an_untyped_receiver_resolves_to_nothing(tmp_path): + calls, _ = _calls(tmp_path, { + "Greeter.kt": GREETER, + "App.kt": "class App {\n" + " fun run(greeter: Any) { greeter.greet() }\n" + "}\n", + }) + assert _greet_edge(calls) is None, calls + + +def test_two_classes_of_the_same_name_resolve_to_neither(tmp_path): + # The single-definition guard: guessing one of two `Greeter`s is worse than + # leaving the call unresolved. + calls, _ = _calls(tmp_path, { + "a/Greeter.kt": GREETER, + "b/Greeter.kt": GREETER, + "App.kt": "class App(private val greeter: Greeter) {\n" + " fun run() { greeter.greet() }\n" + "}\n", + }) + assert _greet_edge(calls) is None, calls + + +def test_a_builtin_receiver_type_does_not_reach_a_same_named_class(tmp_path): + # A local `class Regex` must not answer for `kotlin.text.Regex`. + calls, _ = _calls(tmp_path, { + "Regex.kt": "class Regex {\n fun greet() {}\n}\n", + "App.kt": "class App {\n" + " fun run(r: Regex) { r.greet() }\n" + "}\n", + }) + assert _greet_edge(calls) is None, calls + + +def test_the_first_binding_of_a_name_wins(tmp_path): + # The table is flat per file, so a parameter named like a property has to lose: + # otherwise `other`'s signature would redirect the property's own calls. + calls, result = _calls(tmp_path, { + "Greeter.kt": GREETER, + "Other.kt": "class Other {\n fun greet() {}\n}\n", + "App.kt": "class App {\n" + " private val greeter: Greeter = Greeter()\n" + " fun run() { greeter.greet() }\n" + " fun other(greeter: Other) { greeter.greet() }\n" + "}\n", + }) + source_of = {n["id"]: str(n.get("source_file") or "") for n in result["nodes"]} + label_of = {n["id"]: n["label"] for n in result["nodes"]} + run_targets = {source_of[e["target"]] for e in result["edges"] + if e["relation"] == "calls" + and "run" in str(label_of.get(e["source"])) + and label_of.get(e["target"]) == ".greet()"} + assert len(run_targets) == 1, run_targets + assert run_targets.pop().endswith("Greeter.kt") + + +def test_a_qualified_call_still_reaches_the_package_resolver(tmp_path): + # A >= 3-segment chain is an FQN, not a receiver, and stays with #2550's pass. + calls, _ = _calls(tmp_path, { + "Greeter.kt": "package com.example\n\nobject Greeter {\n fun greet() {}\n}\n", + "App.kt": "class App {\n fun run() { com.example.Greeter.greet() }\n}\n", + }) + edge = _greet_edge(calls) + assert edge is not None, calls + assert edge["confidence"] == "EXTRACTED" + + +def test_a_java_class_answers_a_kotlin_receiver(tmp_path): + # Kotlin and Java compile to one classpath, so a receiver typed to a Java class is + # ordinary interop — the dominant shape in an Android codebase mid-migration. + calls, _ = _calls(tmp_path, { + "Greeter.java": "public class Greeter {\n public void greet() {}\n}\n", + "App.kt": "class App(private val greeter: Greeter) {\n" + " fun run() { greeter.greet() }\n" + "}\n", + }) + edge = _greet_edge(calls) + assert edge is not None, calls + assert edge["confidence"] == "INFERRED" + + +def test_a_class_from_an_unrelated_language_never_answers_a_kotlin_receiver(tmp_path): + # The declaration index is corpus-wide, so a same-named PHP class would both answer + # the receiver and hide that nothing on the classpath declares it. + calls, result = _calls(tmp_path, { + "Greeter.php": "