From 0bddff659ebd753e42da2c60363cb8041b80f1d2 Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 21 Aug 2026 02:01:42 -0500 Subject: [PATCH 01/12] fix(extract): resolve typed Kotlin member calls --- graphify/cli.py | 41 +- graphify/extract.py | 385 ++++++++++++- graphify/extractors/engine.py | 638 ++++++++++++++++++++- graphify/watch.py | 80 ++- tests/test_kotlin_member_calls.py | 897 ++++++++++++++++++++++++++++++ 5 files changed, 2020 insertions(+), 21 deletions(-) create mode 100644 tests/test_kotlin_member_calls.py diff --git a/graphify/cli.py b/graphify/cli.py index 02e55b944..8baf6d13f 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3402,8 +3402,35 @@ def _parse_float(name: str, raw: str) -> float: # AST extraction on code files. Empty code list (docs-only corpus) is # the issue #698 case — skip cleanly instead of crashing inside extract(). ast_result: dict = {"nodes": [], "edges": [], "input_tokens": 0, "output_tokens": 0} + from graphify.extract import ( + _kotlin_incremental_member_callers, + extract as _ast_extract, + ) + if incremental_mode and existing_graph_path.exists(): + _kotlin_requeued = _kotlin_incremental_member_callers( + existing_graph_path, + changed_paths=code_files, + deleted_paths=[ + Path(path) + for path in ( + list(deleted_files) + + list(excluded_files) + + list(graph_stale_sources) + ) + ], + live_code_paths=[ + Path(path) for path in files_by_type.get("code", []) + ], + root=target, + ) + if _kotlin_requeued: + code_files.extend(_kotlin_requeued) + print( + "[graphify extract] re-queuing " + f"{len(_kotlin_requeued)} Kotlin member-call caller(s) " + "after a type/method/factory inventory change" + ) if code_files: - from graphify.extract import extract as _ast_extract # Anchor the cache at the output root, not the scanned project: # with --out, a /graphify-out/cache/ would leak a # graphify-out/ dir into a project that asked for external output. @@ -3454,6 +3481,7 @@ def _ctx_identity(source_file) -> str | None: for _flist in detection.get("unchanged_files", {}).values() for f in _flist } + _ctx_live -= {_ctx_identity(path) for path in code_files} _ctx_live.discard(None) for _node in _ctx_graph.get("nodes", []): if not _node.get("id") or not _ctx_is_ast_tier(_node): @@ -3468,8 +3496,15 @@ def _ctx_identity(source_file) -> str | None: "file_type": _node.get("file_type"), "type": _node.get("type"), } - for _marker in ("_callable", "_callable_class"): - if _node.get(_marker): + for _marker in ( + "_callable", + "_callable_class", + "_kotlin_fqn", + "_kotlin_top_level_function_fqns", + "_kotlin_member_symbol_inventory_version", + "_kotlin_member_symbol_inventory", + ): + if _marker in _node: _ctx_node[_marker] = _node[_marker] _ctx_nodes.append(_ctx_node) for _edge in _ctx_graph.get( diff --git a/graphify/extract.py b/graphify/extract.py index ffc6153f8..49cb0e89f 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2078,7 +2078,12 @@ def extract_csharp(path: Path) -> dict: def extract_kotlin(path: Path) -> dict: """Extract classes, objects, functions, and imports from a .kt/.kts file.""" - return _extract_generic(path, _KOTLIN_CONFIG) + result = _extract_generic(path, _KOTLIN_CONFIG) + result["_kotlin_member_symbol_inventory_complete"] = any( + node.get("_kotlin_member_symbol_inventory_version") == 1 + for node in result.get("nodes", []) + ) + return result def extract_scala(path: Path) -> dict: @@ -3879,6 +3884,344 @@ def _resolve_kotlin_qualified_calls( }) +def _kotlin_incremental_member_callers( + graph_path: Path, + *, + changed_paths: list[Path], + deleted_paths: list[Path], + live_code_paths: list[Path], + root: Path, + stored_source_identity: Callable[[str], str | None] | None = None, +) -> list[Path]: + """Return unchanged Kotlin callers invalidated by symbol inventories. + + Receiver resolution depends on corpus-wide type, directly-owned method, + overload, and top-level-factory inventories. When one of those inventories + changes, a caller-owned edge from an unchanged file cannot be preserved: + the same call may now be ambiguous (or may have become resolvable). Requeue + only files known to contain typed Kotlin member-call dependencies. + """ + root = Path(os.path.abspath(root)) + + def identity(value: str | Path) -> str: + path = Path(value) + if not path.is_absolute(): + path = root / path + return Path(os.path.abspath(path)).as_posix() + + changed_kotlin = [ + path + for path in changed_paths + if path.suffix.lower() in (".kt", ".kts") and path.is_file() + ] + deleted_kotlin = [ + path for path in deleted_paths if path.suffix.lower() in (".kt", ".kts") + ] + if not changed_kotlin and not deleted_kotlin: + return [] + + changed_source_ids = {identity(path) for path in changed_kotlin} + deleted_source_ids = {identity(path) for path in deleted_kotlin} + live_source_ids = {identity(path) for path in live_code_paths} + known_source_ids = changed_source_ids | deleted_source_ids | live_source_ids + path_rehoming_required = False + suffix_index: dict[str, set[str]] = {} + for candidate in known_source_ids: + parts = candidate.replace("\\", "/").strip("/").split("/") + for offset in range(len(parts)): + suffix_index.setdefault("/".join(parts[offset:]), set()).add(candidate) + + def persisted_identity(source_file: str) -> str: + nonlocal path_rehoming_required + stored = None + if stored_source_identity is not None: + try: + stored = stored_source_identity(source_file) + except (OSError, ValueError): + stored = None + fallback = identity(source_file) + for candidate in (stored, fallback): + if candidate in known_source_ids: + return str(candidate) + suffix = Path(source_file).as_posix().lstrip("/") + suffix_matches = suffix_index.get(suffix, set()) + if len(suffix_matches) == 1: + path_rehoming_required = True + return next(iter(suffix_matches)) + return str(stored or fallback) + + try: + from graphify.security import check_graph_file_size_cap + + check_graph_file_size_cap(graph_path) + graph = json.loads(graph_path.read_text(encoding="utf-8")) + except (OSError, ValueError, json.JSONDecodeError): + return [] + + old_inventories: dict[str, tuple[str, ...]] = {} + dependency_sources: set[str] = set() + graph_kotlin_sources: set[str] = set() + inventoried_sources: set[str] = set() + for node in graph.get("nodes", []): + source_file = node.get("source_file") + if not source_file: + continue + source_id = persisted_identity(str(source_file)) + if str(source_file).endswith((".kt", ".kts")): + graph_kotlin_sources.add(source_id) + if "_kotlin_member_symbol_inventory_version" in node: + inventoried_sources.add(source_id) + old_inventories[source_id] = tuple( + str(value) + for value in node.get("_kotlin_member_symbol_inventory") or () + ) + if node.get("_kotlin_member_call_dependencies"): + dependency_sources.add(source_id) + + if graph_kotlin_sources - inventoried_sources: + # One-time migration for a graph persisted before inventory v1. Its + # caller edges may already be stale, and it has no dependency markers + # with which to target a narrower invalidation. Refresh the live Kotlin + # corpus once; the resulting graph carries complete inventories. + return [ + path + for path in live_code_paths + if path.suffix.lower() in (".kt", ".kts") + and identity(path) not in changed_source_ids + and identity(path) not in deleted_source_ids + and path.is_file() + ] + + inventory_changed = False + incomplete_fresh_inventory = False + for path in changed_kotlin: + try: + result = extract_kotlin(path) + except Exception: + # Incremental invalidation is a safety preflight, not the main + # extraction boundary. A provider that cannot be parsed cannot + # prove that an ambiguity disappeared, so requeue the Kotlin + # corpus below and let the normal safe extractor persist an + # incomplete-inventory sentinel for subsequent rebuilds. + incomplete_fresh_inventory = True + continue + fresh_file = next( + ( + node + for node in result.get("nodes", []) + if node.get("_kotlin_member_symbol_inventory_version") == 1 + ), + None, + ) + if fresh_file is None: + # A recovery parse cannot prove that an ambiguity provider was + # removed. Requeue dependants below, then the missing completeness + # marker makes the corpus resolver fail closed. + incomplete_fresh_inventory = True + continue + fresh_inventory = tuple( + str(value) + for value in fresh_file.get("_kotlin_member_symbol_inventory") or () + ) + if old_inventories.get(identity(path)) != fresh_inventory: + inventory_changed = True + + if incomplete_fresh_inventory: + return [ + path + for path in live_code_paths + if path.suffix.lower() in (".kt", ".kts") + and identity(path) not in changed_source_ids + and identity(path) not in deleted_source_ids + and path.is_file() + ] + if any(old_inventories.get(source_id) for source_id in deleted_source_ids): + inventory_changed = True + if path_rehoming_required: + # Re-extracting only the caller while invocation-root semantics changed + # can leave its unchanged type/method targets outside reconciliation's + # live identity set. Refresh the small affected language corpus so the + # rewritten graph remains self-consistent across invocation styles. + return [ + path + for path in live_code_paths + if path.suffix.lower() in (".kt", ".kts") + and identity(path) not in changed_source_ids + and identity(path) not in deleted_source_ids + and path.is_file() + ] + if not inventory_changed: + return [] + + invalidated_sources = dependency_sources - changed_source_ids - deleted_source_ids + return [ + path + for path in live_code_paths + if identity(path) in invalidated_sources and path.is_file() + ] + + +def _resolve_kotlin_member_calls( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Resolve Kotlin member calls through one exact receiver type (#1699). + + Receiver facts are collected per method by the Kotlin engine. This corpus + pass intentionally fails closed: a type must resolve through its written + FQN, an explicit import/alias, or the caller's package, and the type must + directly own exactly one method with the requested name. There is no global + simple-name or inheritance fallback. + """ + raw_calls = [ + call + for result in per_file + for call in result.get("raw_calls", []) + if call.get("lang") == "kotlin" + and call.get("is_member_call") + and call.get("receiver_type") + and not call.get("qualified_prefix") + and call.get("callee") + and call.get("caller_nid") + ] + if not raw_calls: + return + + if any( + result.get("_kotlin_member_symbol_inventory_complete") is False + for result in per_file + ): + # A failed/zero-node Kotlin extraction has no source node on which to + # carry the corpus completeness marker. The explicit per-result flag + # keeps that unknown provider visible and prevents negative-evidence + # resolution from fabricating an edge. + return + + kotlin_sources = { + str(node.get("source_file", "")) + for node in all_nodes + if str(node.get("source_file", "")).endswith((".kt", ".kts")) + } + inventoried_sources = { + str(node.get("source_file", "")) + for node in all_nodes + if node.get("_kotlin_member_symbol_inventory_version") == 1 + } + if not kotlin_sources.issubset(inventoried_sources): + # Incremental graphs created by an older extractor do not carry + # negative evidence about duplicate types, overloads, or factories. + # Until a full refresh inventories every Kotlin file, fail closed. + return + + node_by_id = {node.get("id"): node for node in all_nodes} + types_by_fqn: dict[str, list[str]] = {} + functions_by_fqn: dict[str, list[str]] = {} + for node in all_nodes: + node_id = node.get("id") + if not node_id: + continue + type_fqn = node.get("_kotlin_fqn") + if type_fqn and node.get("_callable_class"): + types_by_fqn.setdefault(str(type_fqn), []).append(node_id) + for function_fqn in node.get("_kotlin_top_level_function_fqns") or (): + functions_by_fqn.setdefault(str(function_fqn), []).append(node_id) + + methods: dict[tuple[str, str], list[str]] = {} + imports_by_file: dict[str, dict[str, set[str]]] = {} + for edge in all_edges: + if edge.get("relation") == "method": + target = edge.get("target") + label = str(node_by_id.get(target, {}).get("label", "")) + method_name = label.strip("()").lstrip(".") + if method_name: + methods.setdefault((edge.get("source"), method_name), []).append( + target + ) + continue + if edge.get("relation") != "imports": + continue + source_file = str(edge.get("source_file", "")) + if not source_file.endswith((".kt", ".kts")): + continue + metadata = edge.get("metadata") or {} + target_fqn = str(metadata.get("target_fqn", "")) + if not target_fqn: + continue + local_name = str(metadata.get("alias") or target_fqn.rpartition(".")[2]) + if local_name: + imports_by_file.setdefault(source_file, {}).setdefault( + local_name, set() + ).add(target_fqn) + + existing_pairs = {(edge.get("source"), edge.get("target")) for edge in all_edges} + + def visible_type_fqns( + type_name: str, source_file: str, package: str + ) -> set[str]: + if "." in type_name: + return {type_name} + imported = imports_by_file.get(source_file, {}).get(type_name) + if imported is not None: + return set(imported) + return {f"{package}.{type_name}" if package else type_name} + + for call in raw_calls: + source_file = str(call.get("source_file", "")) + receiver_type = str(call["receiver_type"]) + package = str(call.get("kotlin_package", "")) + candidate_fqns = visible_type_fqns(receiver_type, source_file, package) + if len(candidate_fqns) != 1: + continue + resolved_fqn = next(iter(candidate_fqns)) + constructor_name = str(call.get("receiver_constructor_name") or "") + visible_function_fqns = {resolved_fqn} + if constructor_name and "." not in constructor_name: + visible_function_fqns.add( + f"{package}.{constructor_name}" if package else constructor_name + ) + visible_function_fqns.update( + imports_by_file.get(source_file, {}).get(constructor_name, set()) + ) + if call.get("receiver_constructor") and any( + functions_by_fqn.get(fqn) for fqn in visible_function_fqns + ): + # A same-named top-level callable can be a factory returning another + # type (`fun Widget(...): Product`). Without overload/signature + # resolution, constructor-inferred receivers must fail closed. + continue + type_candidates = [ + node_id + for fqn in candidate_fqns + for node_id in types_by_fqn.get(fqn, []) + ] + if len(type_candidates) != 1: + continue + + callee = str(call["callee"]) + targets = methods.get((type_candidates[0], callee), []) + if len(targets) != 1: + continue + caller = call["caller_nid"] + target = targets[0] + if caller == target or (caller, target) in existing_pairs: + continue + existing_pairs.add((caller, target)) + all_edges.append( + { + "source": caller, + "target": target, + "relation": "calls", + "context": "call", + "confidence": "INFERRED", + "confidence_score": 0.85, + "source_file": source_file, + "source_location": call.get("source_location"), + "weight": 1.0, + } + ) + + # Kotlin import-target resolution runs EARLY (directly in extract(), before the # shared call pass builds its import-evidence index) — registering it in the # tail registry would rewrite the targets after promotion already read them. @@ -3932,6 +4275,11 @@ def _resolve_kotlin_qualified_calls( register_language_resolver( LanguageResolver("java_member_calls", frozenset({".java"}), _resolve_java_member_calls) ) +register_language_resolver( + LanguageResolver( + "kotlin_member_calls", frozenset({".kt", ".kts"}), _resolve_kotlin_member_calls + ) +) # Pascal/Delphi cross-file inherited-method-call resolution: a call from a # manual descendant class to a method it inherits from an ancestor declared # in a DIFFERENT file (the common generated-base/manual-descendant split, @@ -5605,6 +5953,19 @@ def extract( "error": "internal: no extraction result produced", } + # Stamp Kotlin corpus-completeness centrally, after cached, sequential, + # parallel, and safe-exception paths converge. `extract_kotlin()` stamps + # its normal return for the incremental preflight above, but exceptions + # caught by `_safe_extract` and old cache entries bypass that wrapper. + for i, _p in enumerate(paths): + if _p.suffix.lower() not in (".kt", ".kts"): + continue + _res = per_file[i] or {} + _res["_kotlin_member_symbol_inventory_complete"] = any( + node.get("_kotlin_member_symbol_inventory_version") == 1 + for node in _res.get("nodes", []) + ) + # #1666: surface any source file an extractor accepted but that produced zero # nodes (not even a file node). Such a file is silently absent from the graph, # so affected/explain are blind to and through it with no other signal. @@ -5768,6 +6129,28 @@ def _describe_syntax_error(rel: str, line: "int | None", kept: int) -> str: file=sys.stderr, flush=True, ) + # A zero-node Kotlin failure has no natural carrier for the absent + # inventory marker. Persist a file-shaped sentinel only after empty/error + # diagnostics have been collected, so warnings and manifest retry behavior + # remain unchanged. Its missing inventory-v1 marker keeps both this batch + # and later incremental rebuilds fail-closed until the source extracts + # successfully and replaces it. + for i, _p in enumerate(paths): + _res = per_file[i] or {} + if ( + _p.suffix.lower() in (".kt", ".kts") + and _res.get("_kotlin_member_symbol_inventory_complete") is False + and not _res.get("nodes") + ): + _res["nodes"] = [{ + "id": _make_id(str(_p)), + "label": _p.name, + "file_type": "code", + "source_file": str(_p), + "source_location": "L1", + "_kotlin_member_symbol_inventory_incomplete": True, + }] + all_nodes: list[dict] = [] all_edges: list[dict] = [] all_raw_calls: list[dict] = [] diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index ab6ed0c90..b50a2107f 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -797,6 +797,459 @@ def _kotlin_function_return_type_node(func_node): return c return None + +def _kotlin_type_parameters_in_scope(node, source: bytes) -> set[str]: + """Return Kotlin generic parameter names visible at ``node``.""" + names: set[str] = set() + current = node + while current is not None: + if current.type in ( + "class_declaration", + "object_declaration", + "function_declaration", + ): + for child in current.children: + if child.type != "type_parameters": + continue + for parameter in child.children: + if parameter.type != "type_parameter": + continue + name = next( + ( + _read_text(token, source) + for token in parameter.children + if token.type + in ("identifier", "simple_identifier", "type_identifier") + ), + None, + ) + if name: + names.add(name) + current = current.parent + return names + + +def _kotlin_receiver_type_name(type_node, source: bytes) -> str | None: + """Return a concrete Kotlin type name suitable for receiver resolution.""" + if type_node is None: + return None + if type_node.type in ("nullable_type", "parenthesized_type", "type_reference"): + for child in type_node.children: + if child.is_named: + result = _kotlin_receiver_type_name(child, source) + if result: + return result + return None + if type_node.type not in ("user_type", "simple_user_type"): + return None + + identifiers: list[str] = [] + for child in type_node.children: + if child.type in ("identifier", "simple_identifier", "type_identifier"): + identifiers.append(_read_text(child, source)) + elif child.type == "simple_user_type": + nested = _kotlin_receiver_type_name(child, source) + if nested: + identifiers.append(nested) + if not identifiers: + return None + bare_name = identifiers[-1] + if ( + bare_name in _KOTLIN_BUILTIN_TYPES + or bare_name in _JAVA_BUILTIN_TYPES + or ( + len(identifiers) == 1 + and bare_name in _kotlin_type_parameters_in_scope(type_node, source) + ) + ): + return None + return ".".join(identifiers) + + +def _kotlin_declaration_name(node, source: bytes) -> str | None: + """Return the first name declared by a Kotlin binding node.""" + if node is None: + return None + for child in node.children: + if child.type in ("identifier", "simple_identifier"): + return _read_text(child, source) or None + if child.type == "variable_declaration": + name = _kotlin_declaration_name(child, source) + if name: + return name + return None + + +def _kotlin_variable_names(node, source: bytes) -> list[str]: + """Return names introduced by a Kotlin binding or destructuring pattern.""" + if node is not None and node.type in ("identifier", "simple_identifier"): + name = _read_text(node, source) + return [name] if name else [] + names: list[str] = [] + stack = [node] if node is not None else [] + while stack: + current = stack.pop() + if current.type in ("variable_declaration", "parameter", "class_parameter"): + name = _kotlin_declaration_name(current, source) + if name: + names.append(name) + continue + stack.extend( + child + for child in current.children + if child.type + in ( + "variable_declaration", + "multi_variable_declaration", + "parameter", + "class_parameter", + ) + ) + return names + + +def _kotlin_call_target_name(call_node, source: bytes) -> str | None: + """Return the name invoked by a simple Kotlin call expression.""" + if call_node is None or call_node.type != "call_expression": + return None + first = next((child for child in call_node.children if child.is_named), None) + if first is None or first.type not in ("identifier", "simple_identifier"): + return None + return _read_text(first, source) or None + + +def _kotlin_function_receiver_type_node(function_node): + """Return the extension receiver before a Kotlin function name, if present.""" + name_node = function_node.child_by_field_name("name") + if name_node is None: + return None + for child in function_node.children: + if child == name_node: + break + if child.type in ("user_type", "nullable_type", "type_reference"): + return child + return None + + +def _kotlin_owner_members(owner_node) -> list: + """Return direct declarations owned by a Kotlin file/class/object.""" + if owner_node.type == "source_file": + return list(owner_node.children) + body = next( + (child for child in owner_node.children if child.type == "class_body"), + None, + ) + return list(body.children) if body is not None else [] + + +def _kotlin_owner_return_types(owner_node, source: bytes) -> dict[str, str | None]: + """Collect unique declared return types for direct functions of one owner.""" + returns: dict[str, str | None] = {} + ambiguous: set[str] = set() + for member in _kotlin_owner_members(owner_node): + if member.type != "function_declaration": + continue + if _kotlin_function_receiver_type_node(member) is not None: + continue + name = _kotlin_declaration_name(member, source) + type_name = _kotlin_receiver_type_name( + _kotlin_function_return_type_node(member), source + ) + if not name or name in ambiguous: + continue + if name in returns and returns[name] != type_name: + returns[name] = None + ambiguous.add(name) + else: + returns[name] = type_name + return returns + + +_KOTLIN_CONSTRUCTOR_TYPE_PREFIX = "@constructor:" + + +def _kotlin_receiver_fact_type(fact: str | None) -> str | None: + if fact and fact.startswith(_KOTLIN_CONSTRUCTOR_TYPE_PREFIX): + return fact.removeprefix(_KOTLIN_CONSTRUCTOR_TYPE_PREFIX) + return fact + + +def _kotlin_binding_type( + property_node, + source: bytes, + return_types: dict[str, str | None], + shadowed_call_targets: set[str], + callable_names: set[str], +) -> str | None: + """Infer a binding from an annotation, constructor, or typed getter call.""" + explicit = _kotlin_receiver_type_name( + _kotlin_property_type_node(property_node), source + ) + if explicit: + return explicit + initializer = next( + (child for child in property_node.children if child.type == "call_expression"), + None, + ) + target = _kotlin_call_target_name(initializer, source) + if not target or target in shadowed_call_targets: + return None + if target[:1].isupper(): + # A same-named function can shadow a constructor. Without signatures, + # choosing either would fabricate a receiver type. + return ( + None + if target in callable_names + else f"{_KOTLIN_CONSTRUCTOR_TYPE_PREFIX}{target}" + ) + return return_types.get(target) + + +def _kotlin_receiver_types_by_body( + root_node, source: bytes +) -> dict[tuple[int, int], dict[str, str]]: + """Build fail-closed, per-function Kotlin receiver facts (#1699).""" + callable_names: set[str] = set() + stack = [root_node] + while stack: + current = stack.pop() + if current.type == "function_declaration": + name = _kotlin_declaration_name(current, source) + if name: + callable_names.add(name) + stack.extend(current.children) + + tables: dict[tuple[int, int], dict[str, str]] = {} + + def process_owner(owner_node) -> None: + members = _kotlin_owner_members(owner_node) + return_types = _kotlin_owner_return_types(owner_node, source) + # Only top-level owners have a stable package-qualified identity in the + # narrow #1699 resolver. A nested/companion ``this`` must fail closed: + # reducing it to the simple owner name can bind a same-named top-level + # class and fabricate a call edge. + owner_type = ( + _kotlin_declaration_name(owner_node, source) + if owner_node.type != "source_file" + and owner_node.parent is not None + and owner_node.parent.type == "source_file" + else None + ) + field_types: dict[str, str] = {} + field_poisoned: set[str] = set() + + def bind_field(name: str | None, type_name: str | None) -> None: + if not name or name in field_poisoned: + return + previous = field_types.get(name) + if not type_name or ( + previous is not None + and _kotlin_receiver_fact_type(previous) + != _kotlin_receiver_fact_type(type_name) + ): + field_types.pop(name, None) + field_poisoned.add(name) + else: + field_types[name] = type_name + + owner_shadows = { + name + for member in members + if member.type == "property_declaration" + for name in _kotlin_variable_names(member, source) + } + if owner_node.type != "source_file": + constructor = next( + ( + child + for child in owner_node.children + if child.type == "primary_constructor" + ), + None, + ) + if constructor is not None: + pending = list(constructor.children) + while pending: + parameter = pending.pop() + if parameter.type == "class_parameter": + if any(token.type in ("val", "var") for token in parameter.children): + type_node = next( + ( + token + for token in parameter.children + if token.type + in ("user_type", "nullable_type", "type_reference") + ), + None, + ) + bind_field( + _kotlin_declaration_name(parameter, source), + _kotlin_receiver_type_name(type_node, source), + ) + continue + pending.extend(parameter.children) + for member in members: + if member.type != "property_declaration": + continue + names = _kotlin_variable_names(member, source) + type_name = ( + _kotlin_binding_type( + member, + source, + return_types, + owner_shadows, + callable_names, + ) + if len(names) == 1 + else None + ) + for name in names: + bind_field(name, type_name) + + field_types.update( + {f"this.{name}": type_name for name, type_name in field_types.items()} + ) + + for member in members: + if member.type != "function_declaration": + continue + body = next( + (child for child in member.children if child.type == "function_body"), + None, + ) + if body is None: + continue + table = dict(field_types) + if owner_type: + table["@this"] = owner_type + poisoned: set[str] = set() + + def bind(name: str | None, type_name: str | None) -> None: + if not name or name in poisoned: + return + previous = table.get(name) + if not type_name or ( + previous is not None + and _kotlin_receiver_fact_type(previous) + != _kotlin_receiver_fact_type(type_name) + ): + table.pop(name, None) + poisoned.add(name) + else: + table[name] = type_name + + params = next( + ( + child + for child in member.children + if child.type == "function_value_parameters" + ), + None, + ) + method_shadows: set[str] = set(owner_shadows) + local_names: set[str] = set() + if params is not None: + for parameter in params.children: + if parameter.type != "parameter": + continue + name = _kotlin_declaration_name(parameter, source) + method_shadows.update([name] if name else []) + local_names.update([name] if name else []) + type_node = next( + ( + child + for child in parameter.children + if child.type + in ("user_type", "nullable_type", "type_reference") + ), + None, + ) + bind(name, _kotlin_receiver_type_name(type_node, source)) + + declarations: list = [] + assigned_names: set[str] = set() + pending = list(body.children) + while pending: + current = pending.pop() + if current.type in ( + "class_declaration", + "object_declaration", + "function_declaration", + ): + if current.type == "function_declaration": + name = _kotlin_declaration_name(current, source) + if name: + method_shadows.add(name) + continue + if current.type == "property_declaration": + declarations.append(current) + declared = _kotlin_variable_names(current, source) + method_shadows.update(declared) + local_names.update(declared) + elif current.type == "assignment": + left = current.child_by_field_name("left") + if left is not None and left.type == "navigation_expression": + named = [child for child in left.children if child.is_named] + if ( + len(named) == 2 + and named[0].type == "this_expression" + and named[1].type + in ("identifier", "simple_identifier") + ): + assigned_names.add( + f"this.{_read_text(named[1], source)}" + ) + else: + assigned_names.update(_kotlin_variable_names(left, source)) + elif current.type in ( + "for_statement", + "lambda_parameters", + "catch_block", + "when_subject", + ): + for name in _kotlin_variable_names(current, source): + bind(name, None) + pending.extend(current.children) + + field_assignment_aliases: set[str] = set() + for name in assigned_names: + if name.startswith("this."): + field_assignment_aliases.add(name.removeprefix("this.")) + elif name in field_types and name not in local_names: + field_assignment_aliases.add(f"this.{name}") + assigned_names.update(field_assignment_aliases) + + for declaration in declarations: + names = _kotlin_variable_names(declaration, source) + type_name = ( + _kotlin_binding_type( + declaration, + source, + return_types, + method_shadows, + callable_names, + ) + if len(names) == 1 + else None + ) + for name in names: + bind(name, type_name) + for name in assigned_names: + bind(name, None) + for name in poisoned: + table.pop(name, None) + for name, type_name in return_types.items(): + if type_name and name not in method_shadows: + table[f"@call:{name}"] = type_name + tables[(body.start_byte, body.end_byte)] = table + + for member in members: + if member.type in ("class_declaration", "object_declaration"): + process_owner(member) + + process_owner(root_node) + return tables + 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: @@ -2816,6 +3269,11 @@ def _extract_generic( stem = _file_stem(path) str_path = str(path) + kotlin_package = ( + _kotlin_package_name(root, source) + if config.ts_module == "tree_sitter_kotlin" + else None + ) # Names bound by an import of a module outside the corpus. Module-scoped, so it # is computed once per file and consulted from every scope — see # `_js_external_import_names`. @@ -4739,6 +5197,11 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: ) for body_id, (method_node, class_nid) in csharp_method_scopes.items() } + kotlin_receiver_types = ( + _kotlin_receiver_types_by_body(root, source) + if config.ts_module == "tree_sitter_kotlin" + else {} + ) def _emit_indirect_by_name(ident_name: str, loc_node, scope_nid: str, context: str) -> None: @@ -4963,6 +5426,9 @@ def walk_calls( swift_receiver: str | None = None member_receiver: str | None = None kotlin_qualified_prefix: str | None = None + kotlin_receiver_type: str | None = None + kotlin_constructor_receiver = False + kotlin_constructor_receiver_name: str | None = None # Special handling per language if config.ts_module == "tree_sitter_swift": @@ -5011,6 +5477,67 @@ def walk_calls( segments = _kotlin_nav_identifier_segments(first, source) if segments is not None and len(segments) >= 3: kotlin_qualified_prefix = ".".join(segments[:-1]) + else: + named = [child for child in first.children if child.is_named] + receiver = named[0] if len(named) >= 2 else None + if receiver is not None and receiver.type in ( + "identifier", + "simple_identifier", + ): + member_receiver = _read_text(receiver, source) + elif ( + receiver is not None + and receiver.type == "this_expression" + ): + member_receiver = "this" + elif receiver is not None and receiver.type == "call_expression": + target = _kotlin_call_target_name(receiver, source) + if target: + kotlin_receiver_type = (receiver_types or {}).get( + f"@call:{target}" + ) + elif ( + receiver is not None + and receiver.type == "navigation_expression" + ): + receiver_named = [ + child for child in receiver.children if child.is_named + ] + if ( + len(receiver_named) == 2 + and receiver_named[0].type == "this_expression" + and receiver_named[1].type + in ("identifier", "simple_identifier") + ): + member_receiver = ( + f"this.{_read_text(receiver_named[1], source)}" + ) + if member_receiver: + lookup_name = ( + "@this" if member_receiver == "this" else member_receiver + ) + kotlin_receiver_type = (receiver_types or {}).get(lookup_name) + kotlin_constructor_receiver = bool( + kotlin_receiver_type + and kotlin_receiver_type.startswith( + _KOTLIN_CONSTRUCTOR_TYPE_PREFIX + ) + ) + kotlin_receiver_type = _kotlin_receiver_fact_type( + kotlin_receiver_type + ) + if kotlin_constructor_receiver: + kotlin_constructor_receiver_name = ( + kotlin_receiver_type + ) + if ( + kotlin_receiver_type is None + and member_receiver[:1].isupper() + ): + # Preserve direct object/companion calls without + # the old label-only fallback. The corpus resolver + # still requires exact package/import evidence. + kotlin_receiver_type = member_receiver elif config.ts_module == "tree_sitter_scala": # Scala: first child first = node.children[0] if node.children else None @@ -5256,7 +5783,10 @@ def walk_calls( _java_defer = ( config.ts_module == "tree_sitter_java" and is_member_call ) - if _python_defer or _java_defer or ( + _kotlin_defer = ( + config.ts_module == "tree_sitter_kotlin" and is_member_call + ) + if _python_defer or _java_defer or _kotlin_defer or ( is_member_call and member_receiver and ( @@ -5323,11 +5853,21 @@ def walk_calls( receiver_type = (receiver_types or {}).get(member_receiver or "") if receiver_type: 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: + # Kotlin calls are claimed by package-aware resolvers. Member + # calls carry a method-scoped receiver type; fully-qualified + # calls retain the existing exact written prefix (#2550). + if config.ts_module == "tree_sitter_kotlin" and is_member_call: rc_entry["lang"] = "kotlin" - rc_entry["qualified_prefix"] = kotlin_qualified_prefix + rc_entry["kotlin_package"] = kotlin_package or "" + if kotlin_receiver_type: + rc_entry["receiver_type"] = kotlin_receiver_type + if kotlin_constructor_receiver: + rc_entry["receiver_constructor"] = True + rc_entry["receiver_constructor_name"] = ( + kotlin_constructor_receiver_name + ) + 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 @@ -5583,13 +6123,20 @@ def walk_calls( # Body ids are unique (one language per file), so the Java (flat) and C# # (scoped, #2472) per-method receiver tables merge without collision — the - # stamp site branches on language to read the matching shape. + # stamp site branches on language to read the matching shape. Kotlin uses + # byte ranges because its fail-closed pre-scan and the main walk can receive + # distinct Python wrappers for the same tree-sitter node. receiver_types_by_body = {**java_receiver_types, **csharp_receiver_types} for caller_nid, body_node in function_bodies: + body_receiver_types = receiver_types_by_body.get(id(body_node)) + if config.ts_module == "tree_sitter_kotlin": + body_receiver_types = kotlin_receiver_types.get( + (body_node.start_byte, body_node.end_byte) + ) walk_calls( body_node, caller_nid, - receiver_types_by_body.get(id(body_node)), + body_receiver_types, frozenset(closure_locals_by_body.get(id(body_node), ())), ) @@ -5701,9 +6248,80 @@ def _scan_js_module_dispatch(n) -> None: # file; the import-target and qualified-call resolvers key their per-package # symbol indexes off it. if config.ts_module == "tree_sitter_kotlin": - _pkg = _kotlin_package_name(root, source) - if _pkg: - result["kotlin_package"] = _pkg + if kotlin_package: + result["kotlin_package"] = kotlin_package + top_level_types = { + edge["target"] + for edge in clean_edges + if edge.get("relation") == "contains" + and edge.get("source") == file_nid + and edge.get("target") in callable_class_nids + } + for item in nodes: + if item["id"] not in top_level_types: + continue + name = str(item.get("label", "")).strip("()") + item["_kotlin_fqn"] = ( + f"{kotlin_package}.{name}" if kotlin_package else name + ) + top_level_function_fqns = sorted( + { + f"{kotlin_package}.{name}" if kotlin_package else name + for declaration in root.children + if declaration.type == "function_declaration" + and _kotlin_function_receiver_type_node(declaration) is None + if (name := _kotlin_declaration_name(declaration, source)) + } + ) + node_by_id = {item["id"]: item for item in nodes} + member_symbol_inventory = [ + f"type:{item['_kotlin_fqn']}" + for item in nodes + if item["id"] in top_level_types and item.get("_kotlin_fqn") + ] + for edge in clean_edges: + if edge.get("relation") != "method" or edge.get("source") not in top_level_types: + continue + owner = node_by_id.get(edge["source"], {}).get("_kotlin_fqn") + target = node_by_id.get(edge.get("target"), {}) + method_name = str(target.get("label", "")).strip("()").lstrip(".") + if owner and method_name: + # Keep duplicate entries: two same-named overloads are an + # ambiguity-provider change even though their symbol key is equal. + member_symbol_inventory.append(f"method:{owner}#{method_name}") + member_symbol_inventory.extend( + f"function:{fqn}" for fqn in top_level_function_fqns + ) + has_member_call_dependencies = any( + call.get("lang") == "kotlin" + and call.get("is_member_call") + and call.get("receiver_type") + for call in raw_calls + ) + inventory_carrier_ids = {file_nid} | { + edge["target"] + for edge in clean_edges + if edge.get("relation") == "contains" + and edge.get("source") == file_nid + and edge.get("target") in callable_def_nids + } + inventory_complete = not root.has_error or ( + len(nodes) > 1 and not _has_multiline_error(root) + ) + if inventory_complete: + for item in nodes: + if item["id"] not in inventory_carrier_ids: + continue + # The explicit version is completeness evidence. Empty + # inventories survive persistence, but a recovery parse can + # never provide trustworthy negative symbol evidence. + item["_kotlin_member_symbol_inventory_version"] = 1 + item["_kotlin_member_symbol_inventory"] = sorted( + member_symbol_inventory + ) + item["_kotlin_top_level_function_fqns"] = top_level_function_fqns + if has_member_call_dependencies: + item["_kotlin_member_call_dependencies"] = True if callable_def_nids: # Mark function / method / class defs with a `_callable` attribute so the # cross-file indirect_call pass can resolve a by-name callback only to a real diff --git a/graphify/watch.py b/graphify/watch.py index 8ad02c4df..486975b6b 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1179,7 +1179,11 @@ def _rebuild_code( project_root = Path.cwd().resolve() if not watch_path.is_absolute() else watch_root report_root = _report_root_label(watch_path) try: - from graphify.extract import extract, _get_extractor + from graphify.extract import ( + _get_extractor, + _kotlin_incremental_member_callers, + extract, + ) from graphify.detect import detect from graphify.build import build_from_json, _is_ast_tier, _norm_source_file as _nsf from graphify.cluster import cluster, remap_communities_to_previous, score_all @@ -1229,6 +1233,39 @@ def _rebuild_code( print("[graphify watch] No code files found - nothing to rebuild.") return False + ignored_kotlin_inventory_sources: list[Path] = [] + inventory_source_identity: Callable[[str], str | None] | None = None + if changed_paths is not None and existing_graph.exists(): + try: + check_graph_file_size_cap(existing_graph) + inventory_graph = json.loads( + existing_graph.read_text(encoding="utf-8") + ) + inventory_paths = _StoredSourcePaths( + inventory_graph, + out=out, + project_root=project_root, + watch_root=watch_root, + normalize_source=_nsf, + ) + inventory_source_identity = inventory_paths.identity + seen_ignored_inventory_sources: set[str] = set() + for node in inventory_graph.get("nodes", []): + source_file = str(node.get("source_file", "")) + if not source_file.endswith((".kt", ".kts")): + continue + identity = inventory_paths.identity(source_file) + if ( + identity + and identity not in seen_ignored_inventory_sources + and Path(identity).is_file() + and _ignored_always(Path(identity)) + ): + seen_ignored_inventory_sources.add(identity) + ignored_kotlin_inventory_sources.append(Path(identity)) + except Exception: + ignored_kotlin_inventory_sources = [] + # #1915: a document that already carries SEMANTIC (LLM) nodes in the # existing graph must not ALSO be AST-quick-scanned — otherwise every # rebuild mints heading nodes on top of the preserved semantic nodes @@ -1354,7 +1391,11 @@ def _add_deleted_source(path: Path) -> None: # File was deleted or renamed away inside the watched root. # Evict preserved nodes that still claim this source path. _add_deleted_source(deleted_in_root) - if not wanted and not deleted_paths: + if ( + not wanted + and not deleted_paths + and not ignored_kotlin_inventory_sources + ): print("[graphify watch] No tracked code files in change set - skipping rebuild.") return True extract_targets = wanted @@ -1367,6 +1408,24 @@ def _add_deleted_source(path: Path) -> None: # AST heading layer intact alongside the semantic layer. extract_targets = [p for p in code_files if p not in semantic_doc_files] + if changed_paths is not None and existing_graph.exists(): + kotlin_requeued = _kotlin_incremental_member_callers( + existing_graph, + changed_paths=extract_targets, + deleted_paths=[Path(path) for path in deleted_source_identities] + + ignored_kotlin_inventory_sources, + live_code_paths=code_files, + root=project_root, + stored_source_identity=inventory_source_identity, + ) + if kotlin_requeued: + extract_targets.extend(kotlin_requeued) + print( + "[graphify watch] re-queuing " + f"{len(kotlin_requeued)} Kotlin member-call caller(s) after " + "a type/method/factory inventory change" + ) + # #2406: an incremental rebuild parses only the changed files, so the # cross-file resolvers could not see a callee living in an unchanged # file and every changed->unchanged `calls` edge disappeared (reconcile @@ -1419,11 +1478,18 @@ def _add_deleted_source(path: Path) -> None: "file_type": node.get("file_type"), "type": node.get("type"), } - # #2438: the persisted callability markers are the only - # thing that lets an unchanged target pass the - # indirect_call guard — never re-derived from the label. - for marker in ("_callable", "_callable_class"): - if node.get(marker): + # Persisted resolver facts are never re-derived from labels: + # callability guards indirect calls (#2438), while Kotlin + # FQNs support receiver resolution. + for marker in ( + "_callable", + "_callable_class", + "_kotlin_fqn", + "_kotlin_top_level_function_fqns", + "_kotlin_member_symbol_inventory_version", + "_kotlin_member_symbol_inventory", + ): + if marker in node: ctx_node[marker] = node[marker] resolution_context_nodes.append(ctx_node) # #2437: the member-call resolvers map receiver type -> owning diff --git a/tests/test_kotlin_member_calls.py b/tests/test_kotlin_member_calls.py new file mode 100644 index 000000000..48edea608 --- /dev/null +++ b/tests/test_kotlin_member_calls.py @@ -0,0 +1,897 @@ +"""Kotlin receiver-typed member-call resolution (#1699).""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +from graphify.extract import extract + + +def _extract(tmp_path: Path, files: dict[str, str]) -> dict: + paths: list[Path] = [] + 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") + paths.append(path) + previous = Path.cwd() + try: + os.chdir(tmp_path) + return extract( + [path.relative_to(tmp_path) for path in paths], + cache_root=tmp_path / "graphify-out", + parallel=False, + ) + finally: + os.chdir(previous) + + +def _find(result: dict, label: str, id_contains: str) -> str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +def _call_edges(result: dict) -> list[dict]: + return [edge for edge in result["edges"] if edge.get("relation") == "calls"] + + +def test_issue_1699_resolves_four_typed_receiver_shapes(tmp_path: Path) -> None: + result = _extract( + tmp_path, + { + "InputView.kt": ( + "class InputView {\n" + " fun updateKeyboardShow(show: Boolean) {}\n" + "}\n" + ), + "PanelController.kt": ( + "class PanelController {\n" + " private val input = InputView()\n" + " fun updateKeyboardShow(show: Boolean) {}\n" + " fun getInputView(): InputView = input\n" + " fun onPanelClose() { input.updateKeyboardShow(false) }\n" + " fun onPanelOpen() { getInputView().updateKeyboardShow(true) }\n" + " fun onPanelToggle() {\n" + " val view = getInputView()\n" + " view.updateKeyboardShow(true)\n" + " }\n" + "}\n" + ), + "Window.kt": ( + "fun open() {\n" + " val view = InputView()\n" + " view.updateKeyboardShow(true)\n" + "}\n" + ), + }, + ) + + update = _find(result, ".updateKeyboardShow()", "inputview") + callers = { + _find(result, ".onPanelClose()", "panelcontroller"), + _find(result, ".onPanelOpen()", "panelcontroller"), + _find(result, ".onPanelToggle()", "panelcontroller"), + _find(result, "open()", "window"), + } + resolved = { + edge["source"]: edge + for edge in _call_edges(result) + if edge.get("target") == update and edge.get("source") in callers + } + assert set(resolved) == callers + assert all(edge.get("confidence") == "INFERRED" for edge in resolved.values()) + assert all(edge.get("confidence_score") == 0.85 for edge in resolved.values()) + + get_input = _find(result, ".getInputView()", "panelcontroller") + assert any( + edge["source"] in callers + and edge["target"] == get_input + and edge.get("confidence") == "EXTRACTED" + for edge in _call_edges(result) + ) + + +def test_receiver_bindings_do_not_leak_between_methods(tmp_path: Path) -> None: + result = _extract( + tmp_path, + { + "Alpha.kt": "class Alpha { fun close() {} }\n", + "Beta.kt": "class Beta { fun close() {} }\n", + "Worker.kt": ( + "class Worker {\n" + " fun first(service: Alpha) { service.close() }\n" + " fun second(service: Beta) { service.close() }\n" + "}\n" + ), + }, + ) + + first = _find(result, ".first()", "worker") + second = _find(result, ".second()", "worker") + alpha_close = _find(result, ".close()", "alpha") + beta_close = _find(result, ".close()", "beta") + pairs = {(edge["source"], edge["target"]) for edge in _call_edges(result)} + assert (first, alpha_close) in pairs + assert (second, beta_close) in pairs + assert (first, beta_close) not in pairs + assert (second, alpha_close) not in pairs + + +def test_explicit_this_call_resolves_only_to_its_owner(tmp_path: Path) -> None: + result = _extract( + tmp_path, + { + "First.kt": ( + "class First {\n" + " fun target() {}\n" + " fun caller() { this.target() }\n" + "}\n" + ), + "Second.kt": "class Second { fun target() {} }\n", + }, + ) + + caller = _find(result, ".caller()", "first") + first_target = _find(result, ".target()", "first") + second_target = _find(result, ".target()", "second") + pairs = {(edge["source"], edge["target"]) for edge in _call_edges(result)} + assert (caller, first_target) in pairs + assert (caller, second_target) not in pairs + + +def test_nested_this_with_same_named_top_level_owner_fails_closed( + tmp_path: Path, +) -> None: + result = _extract( + tmp_path, + { + "Owners.kt": ( + "package sample\n" + "class Inner { fun target() {} }\n" + "class Outer {\n" + " class Inner {\n" + " fun target() {}\n" + " fun caller() { this.target() }\n" + " }\n" + "}\n" + ), + }, + ) + + caller = _find(result, ".caller()", "owners_inner") + assert not any(edge["source"] == caller for edge in _call_edges(result)) + + +def test_shadow_and_reassignment_poison_receiver_but_this_field_survives( + tmp_path: Path, +) -> None: + result = _extract( + tmp_path, + { + "Alpha.kt": "class Alpha { fun ping() {} }\n", + "Beta.kt": "class Beta { fun ping() {} }\n", + "Owner.kt": ( + "class Owner(var service: Alpha) {\n" + " fun shadow(service: Beta) { service.ping() }\n" + " fun reassigned() {\n" + " var local = Alpha()\n" + " local = unknown\n" + " local.ping()\n" + " }\n" + " fun explicitThis(service: Beta) { this.service.ping() }\n" + " fun explicitReassigned(other: Beta) {\n" + " this.service = other\n" + " service.ping()\n" + " }\n" + " fun bareReassigned(other: Beta) {\n" + " service = other\n" + " this.service.ping()\n" + " }\n" + " fun localShadow(other: Beta) {\n" + " var service = other\n" + " service = other\n" + " this.service.ping()\n" + " }\n" + "}\n" + ), + }, + ) + + shadow = _find(result, ".shadow()", "owner") + reassigned = _find(result, ".reassigned()", "owner") + explicit_this = _find(result, ".explicitThis()", "owner") + explicit_reassigned = _find(result, ".explicitReassigned()", "owner") + bare_reassigned = _find(result, ".bareReassigned()", "owner") + local_shadow = _find(result, ".localShadow()", "owner") + alpha_ping = _find(result, ".ping()", "alpha") + beta_ping = _find(result, ".ping()", "beta") + pairs = {(edge["source"], edge["target"]) for edge in _call_edges(result)} + assert not any( + source in {shadow, reassigned, explicit_reassigned, bare_reassigned} + and target in {alpha_ping, beta_ping} + for source, target in pairs + ) + assert (explicit_this, alpha_ping) in pairs + assert (explicit_this, beta_ping) not in pairs + assert (local_shadow, alpha_ping) in pairs + assert (local_shadow, beta_ping) not in pairs + + +def test_import_alias_and_fqn_select_the_exact_type(tmp_path: Path) -> None: + result = _extract( + tmp_path, + { + "a/Service.kt": "package a\nclass Service { fun run() {} }\n", + "b/Service.kt": "package b\nclass Service { fun run() {} }\n", + "app/Use.kt": ( + "package app\n" + "import a.Service as Primary\n" + "fun alias(value: Primary) { value.run() }\n" + "fun qualified(value: a.Service) { value.run() }\n" + ), + }, + ) + + alias = _find(result, "alias()", "use") + qualified = _find(result, "qualified()", "use") + a_run = _find(result, ".run()", "a_service") + b_run = _find(result, ".run()", "b_service") + pairs = {(edge["source"], edge["target"]) for edge in _call_edges(result)} + assert (alias, a_run) in pairs + assert (qualified, a_run) in pairs + assert (alias, b_run) not in pairs + assert (qualified, b_run) not in pairs + + +def test_markerless_incremental_kotlin_context_fails_closed(tmp_path: Path) -> None: + caller_path = tmp_path / "Caller.kt" + caller_path.write_text( + "package app\n" + "import lib.Service\n" + "fun call(service: Service) { service.ping() }\n", + encoding="utf-8", + ) + previous = Path.cwd() + try: + os.chdir(tmp_path) + result = extract( + [Path("Caller.kt")], + cache_root=tmp_path / "graphify-out", + parallel=False, + resolution_context_nodes=[ + { + "id": "service_type", + "label": "Service", + "source_file": "Service.kt", + "file_type": "code", + "_callable": True, + "_callable_class": True, + "_kotlin_fqn": "lib.Service", + }, + { + "id": "service_ping", + "label": ".ping()", + "source_file": "Service.kt", + "file_type": "code", + "_callable": True, + }, + ], + resolution_context_edges=[ + { + "source": "service_type", + "target": "service_ping", + "relation": "method", + "source_file": "Service.kt", + } + ], + ) + finally: + os.chdir(previous) + + caller = _find(result, "call()", "caller") + assert not any(edge["source"] == caller for edge in _call_edges(result)) + + +def test_ambiguous_type_and_overloaded_method_emit_no_edge(tmp_path: Path) -> None: + result = _extract( + tmp_path, + { + "one/Service.kt": "package dup\nclass Service { fun ping() {} }\n", + "two/Service.kt": "package dup\nclass Service { fun ping() {} }\n", + "Use.kt": ( + "package dup\n" + "fun ambiguous(value: Service) { value.ping() }\n" + ), + "Overloaded.kt": ( + "package overload\n" + "class Service {\n" + " fun ping() {}\n" + " fun ping(value: Int) {}\n" + "}\n" + "fun overloaded(value: Service) { value.ping() }\n" + ), + }, + ) + + ambiguous = _find(result, "ambiguous()", "use") + overloaded = _find(result, "overloaded()", "overloaded") + assert not any( + edge["source"] in {ambiguous, overloaded} for edge in _call_edges(result) + ) + + +def test_nullable_script_receiver_and_external_negatives(tmp_path: Path) -> None: + result = _extract( + tmp_path, + { + "lib/Service.kt": "package lib\nclass Service { fun ping() {} }\n", + "decoy/Log.kt": "package decoy\nclass Log { fun d() {} }\n", + "decoy/List.kt": "package decoy\nclass List { fun map() {} }\n", + "app/Main.kts": ( + "package app\n" + "import lib.Service\n" + "import android.util.Log\n" + "val service: Service? = null\n" + "fun run(list: List) {\n" + " service?.ping()\n" + " Log.d(\"tag\", \"message\")\n" + " list.map { it }\n" + "}\n" + ), + }, + ) + + caller = _find(result, "run()", "main") + service_ping = _find(result, ".ping()", "lib_service") + decoy_d = _find(result, ".d()", "decoy_log") + decoy_map = _find(result, ".map()", "decoy_list") + targets = { + edge["target"] for edge in _call_edges(result) if edge["source"] == caller + } + assert service_ping in targets + assert decoy_d not in targets + assert decoy_map not in targets + + +def test_same_named_factory_makes_constructor_binding_ambiguous(tmp_path: Path) -> None: + result = _extract( + tmp_path, + { + "Widget.kt": ( + "package model\n" + "class Widget { fun render() {} }\n" + ), + "Product.kt": ( + "package model\n" + "class Product { fun render() {} }\n" + ), + "Factory.kt": ( + "package model\n" + "fun Widget(size: Int): Product = Product()\n" + ), + "Use.kt": ( + "package model\n" + "fun use() {\n" + " val value = Widget(1)\n" + " value.render()\n" + "}\n" + "fun typed(value: Widget) { value.render() }\n" + ), + }, + ) + + caller = _find(result, "use()", "use") + typed = _find(result, "typed()", "use") + widget_render = _find(result, ".render()", "widget") + product_render = _find(result, ".render()", "product") + pairs = {(edge["source"], edge["target"]) for edge in _call_edges(result)} + assert not any( + source == caller and target in {widget_render, product_render} + for source, target in pairs + ) + assert (typed, widget_render) in pairs + assert (typed, product_render) not in pairs + + +def test_same_package_factory_shadows_imported_class_constructor( + tmp_path: Path, +) -> None: + result = _extract( + tmp_path, + { + "lib/Widget.kt": ( + "package lib\n" + "class Widget { fun render() {} }\n" + ), + "app/Product.kt": ( + "package app\n" + "class Product { fun render() {} }\n" + "fun Widget(size: Int): Product = Product()\n" + ), + "app/Use.kt": ( + "package app\n" + "import lib.Widget\n" + "fun use() {\n" + " val value = Widget(1)\n" + " value.render()\n" + "}\n" + "fun typed(value: Widget) { value.render() }\n" + ), + }, + ) + + caller = _find(result, "use()", "use") + typed = _find(result, "typed()", "use") + widget_render = _find(result, ".render()", "lib_widget") + product_render = _find(result, ".render()", "product") + pairs = {(edge["source"], edge["target"]) for edge in _call_edges(result)} + assert not any( + source == caller and target in {widget_render, product_render} + for source, target in pairs + ) + assert (typed, widget_render) in pairs + assert (typed, product_render) not in pairs + + +def test_recovery_parsed_kotlin_provider_fails_inventory_closed( + tmp_path: Path, +) -> None: + result = _extract( + tmp_path, + { + "Widget.kt": ( + "package model\n" + "class Widget(val size: Int) {\n" + " fun render() {}\n" + "}\n" + ), + "Product.kt": "package model\nclass Product { fun render() {} }\n", + "Use.kt": ( + "package model\n" + "fun use() {\n" + " val value = Widget(1)\n" + " value.render()\n" + "}\n" + ), + "Broken.kt": ( + "package model\n" + "class A { val value: Money = Money(5) }\n" + "class B { val value: Ledger = Ledger() }\n" + "fun Widget(size: Int): Product = Product()\n" + ), + }, + ) + + caller = _find(result, "use()", "use") + widget_render = _find(result, ".render()", "widget") + product_render = _find(result, ".render()", "product") + assert not any( + edge["source"] == caller + and edge["target"] in {widget_render, product_render} + for edge in _call_edges(result) + ) + + +def test_zero_node_kotlin_provider_fails_inventory_closed(tmp_path: Path) -> None: + root, missing = _incremental_factory_corpus(tmp_path / "zero-node-provider") + previous = Path.cwd() + try: + os.chdir(root) + result = extract( + [Path("Widget.kt"), Path("Product.kt"), Path("Use.kt"), missing], + cache_root=root / "graphify-out", + parallel=False, + ) + finally: + os.chdir(previous) + + caller = _find(result, "use()", "use") + widget_render = _find(result, ".render()", "widget") + product_render = _find(result, ".render()", "product") + assert not any( + edge["source"] == caller + and edge["target"] in {widget_render, product_render} + for edge in _call_edges(result) + ) + assert any( + str(node.get("source_file", "")).endswith("Factory.kt") + and node.get("_kotlin_member_symbol_inventory_incomplete") is True + for node in result["nodes"] + ) + + +def _incremental_corpus(root: Path) -> tuple[Path, Path]: + root.mkdir(parents=True) + (root / "Service.kt").write_text( + "package lib\nclass Service { fun ping() {} }\n", encoding="utf-8" + ) + caller = root / "Caller.kt" + caller.write_text( + "package app\n" + "import lib.Service\n" + "fun call(service: Service) { service.ping() }\n", + encoding="utf-8", + ) + return root, caller + + +def _incremental_member_edges(root: Path) -> list[tuple[str, str]]: + graph = json.loads((root / "graphify-out" / "graph.json").read_text()) + caller = next( + node["id"] + for node in graph["nodes"] + if node.get("label") == "call()" + and str(node.get("source_file", "")).endswith("Caller.kt") + ) + target = next( + node["id"] + for node in graph["nodes"] + if node.get("label") == ".ping()" + and str(node.get("source_file", "")).endswith("Service.kt") + ) + return [ + (edge["source"], edge["target"]) + for edge in graph.get("links", graph.get("edges", [])) + if edge.get("relation") == "calls" + and edge["source"] == caller + and edge["target"] == target + ] + + +def _incremental_factory_corpus(root: Path) -> tuple[Path, Path]: + root.mkdir(parents=True) + (root / "Widget.kt").write_text( + "package model\nclass Widget(val size: Int) { fun render() {} }\n", + encoding="utf-8", + ) + (root / "Product.kt").write_text( + "package model\nclass Product { fun render() {} }\n", + encoding="utf-8", + ) + (root / "Use.kt").write_text( + "package model\n" + "fun use() {\n" + " val value = Widget(1)\n" + " value.render()\n" + "}\n", + encoding="utf-8", + ) + return root, root / "Factory.kt" + + +def _factory_call_targets(root: Path) -> set[str]: + graph = json.loads((root / "graphify-out" / "graph.json").read_text()) + caller = next( + node["id"] + for node in graph["nodes"] + if node.get("label") == "use()" + and str(node.get("source_file", "")).endswith("Use.kt") + ) + return { + edge["target"] + for edge in graph.get("links", graph.get("edges", [])) + if edge.get("relation") == "calls" and edge["source"] == caller + } + + +def _factory_render_ids(root: Path) -> tuple[str, str]: + graph = json.loads((root / "graphify-out" / "graph.json").read_text()) + widget_render = next( + node["id"] + for node in graph["nodes"] + if node.get("label") == ".render()" + and str(node.get("source_file", "")).endswith("Widget.kt") + ) + product_render = next( + node["id"] + for node in graph["nodes"] + if node.get("label") == ".render()" + and str(node.get("source_file", "")).endswith("Product.kt") + ) + return widget_render, product_render + + +def _write_factory(path: Path) -> None: + path.write_text( + "package model\nfun Widget(size: Int): Product = Product()\n", + encoding="utf-8", + ) + + +def _strip_kotlin_inventory_markers(root: Path) -> None: + graph_path = root / "graphify-out" / "graph.json" + graph = json.loads(graph_path.read_text()) + for node in graph["nodes"]: + for key in list(node): + if key.startswith("_kotlin_member_"): + node.pop(key) + graph_path.write_text(json.dumps(graph), encoding="utf-8") + + +def test_watch_changed_caller_resolves_against_unchanged_kotlin_context( + tmp_path: Path, +) -> None: + from graphify.watch import _rebuild_code + + root, caller = _incremental_corpus(tmp_path / "watch") + assert _rebuild_code(root, no_cluster=True, acquire_lock=False) is True + assert len(_incremental_member_edges(root)) == 1 + + caller.write_text(caller.read_text() + "// changed\n", encoding="utf-8") + for _ in range(2): + assert _rebuild_code( + root, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + assert len(_incremental_member_edges(root)) == 1 + + +def test_watch_requeues_caller_when_factory_is_added_and_removed( + tmp_path: Path, +) -> None: + from graphify.watch import _rebuild_code + + root, factory = _incremental_factory_corpus(tmp_path / "watch-factory") + assert _rebuild_code(root, no_cluster=True, acquire_lock=False) is True + widget_render, product_render = _factory_render_ids(root) + assert widget_render in _factory_call_targets(root) + assert product_render not in _factory_call_targets(root) + + _strip_kotlin_inventory_markers(root) + _write_factory(factory) + assert _rebuild_code( + root, changed_paths=[factory], no_cluster=True, acquire_lock=False + ) is True + assert not _factory_call_targets(root) & {widget_render, product_render} + + factory.unlink() + assert _rebuild_code( + root, changed_paths=[factory], no_cluster=True, acquire_lock=False + ) is True + assert widget_render in _factory_call_targets(root) + assert product_render not in _factory_call_targets(root) + + +def test_watch_requeues_caller_when_factory_becomes_excluded( + tmp_path: Path, +) -> None: + from graphify.watch import _rebuild_code + + root, factory = _incremental_factory_corpus(tmp_path / "watch-excluded-factory") + _write_factory(factory) + assert _rebuild_code(root, no_cluster=True, acquire_lock=False) is True + widget_render, product_render = _factory_render_ids(root) + assert not _factory_call_targets(root) & {widget_render, product_render} + + ignore = root / ".graphifyignore" + ignore.write_text("Factory.kt\n", encoding="utf-8") + assert _rebuild_code( + root, changed_paths=[ignore], no_cluster=True, acquire_lock=False + ) is True + assert widget_render in _factory_call_targets(root) + assert product_render not in _factory_call_targets(root) + + +def test_watch_recovery_parsed_provider_invalidates_existing_caller( + tmp_path: Path, +) -> None: + from graphify.watch import _rebuild_code + + root, _ = _incremental_factory_corpus(tmp_path / "watch-recovery-provider") + assert _rebuild_code(root, no_cluster=True, acquire_lock=False) is True + widget_render, product_render = _factory_render_ids(root) + assert widget_render in _factory_call_targets(root) + + broken = root / "Broken.kt" + broken.write_text( + "package model\n" + "class A { val value: Money = Money(5) }\n" + "class B { val value: Ledger = Ledger() }\n" + "fun Widget(size: Int): Product = Product()\n", + encoding="utf-8", + ) + assert _rebuild_code( + root, changed_paths=[broken], no_cluster=True, acquire_lock=False + ) is True + assert not _factory_call_targets(root) & {widget_render, product_render} + + +def test_watch_zero_node_provider_invalidates_existing_caller( + tmp_path: Path, + monkeypatch, +) -> None: + import graphify.extract as extract_module + from graphify.watch import _rebuild_code + + root, broken = _incremental_factory_corpus(tmp_path / "watch-zero-provider") + assert _rebuild_code(root, no_cluster=True, acquire_lock=False) is True + widget_render, product_render = _factory_render_ids(root) + assert widget_render in _factory_call_targets(root) + + broken.write_text( + "package model\nfun Widget(size: Int): Product = Product()\n", + encoding="utf-8", + ) + original = extract_module.extract_kotlin + + def fail_broken(path: Path) -> dict: + if path.name == "Factory.kt": + raise RuntimeError("forced Kotlin extraction failure") + return original(path) + + monkeypatch.setattr(extract_module, "extract_kotlin", fail_broken) + monkeypatch.setitem(extract_module._DISPATCH, ".kt", fail_broken) + assert _rebuild_code( + root, changed_paths=[broken], no_cluster=True, acquire_lock=False + ) is True + assert not _factory_call_targets(root) & {widget_render, product_render} + + # The failure sentinel must survive graph persistence. A later caller-only + # rebuild cannot treat the still-broken provider as proven absent. + use = root / "Use.kt" + use.write_text(use.read_text() + "// changed again\n", encoding="utf-8") + assert _rebuild_code( + root, changed_paths=[use], no_cluster=True, acquire_lock=False + ) is True + assert not _factory_call_targets(root) & {widget_render, product_render} + + +def test_watch_inventory_requeue_survives_invocation_style_changes( + tmp_path: Path, +) -> None: + from graphify.watch import _rebuild_code + + parent = tmp_path / "invocation-style" + root, factory = _incremental_factory_corpus(parent / "project") + assert _rebuild_code(root, no_cluster=True, acquire_lock=False) is True + widget_render, product_render = _factory_render_ids(root) + assert widget_render in _factory_call_targets(root) + + use = root / "Use.kt" + use.write_text(use.read_text() + "// changed\n", encoding="utf-8") + previous = Path.cwd() + try: + os.chdir(parent) + assert _rebuild_code( + Path("project"), + changed_paths=[Path("project/Use.kt")], + no_cluster=True, + acquire_lock=False, + ) is True + finally: + os.chdir(previous) + assert widget_render in _factory_call_targets(root) + assert product_render not in _factory_call_targets(root) + + _write_factory(factory) + try: + os.chdir(parent) + assert _rebuild_code( + Path("project"), + changed_paths=[Path("project/Factory.kt")], + no_cluster=True, + acquire_lock=False, + ) is True + finally: + os.chdir(previous) + assert not _factory_call_targets(root) & {widget_render, product_render} + + factory.unlink() + assert _rebuild_code( + root, changed_paths=[factory], no_cluster=True, acquire_lock=False + ) is True + assert widget_render in _factory_call_targets(root) + assert product_render not in _factory_call_targets(root) + + +def test_cli_incremental_preserves_kotlin_call_to_unchanged_target( + tmp_path: Path, +) -> None: + root, caller = _incremental_corpus(tmp_path / "cli") + + def run() -> subprocess.CompletedProcess: + return subprocess.run( + [ + sys.executable, + "-m", + "graphify", + "extract", + str(root), + "--code-only", + "--no-cluster", + ], + cwd=tmp_path, + capture_output=True, + text=True, + ) + + first = run() + assert first.returncode == 0, first.stderr + assert len(_incremental_member_edges(root)) == 1 + + caller.write_text(caller.read_text() + "// changed\n", encoding="utf-8") + second = run() + assert second.returncode == 0, second.stderr + assert "incremental scan" in second.stdout.lower() + assert len(_incremental_member_edges(root)) == 1 + + +def test_cli_requeues_caller_when_factory_is_added_and_removed( + tmp_path: Path, +) -> None: + root, factory = _incremental_factory_corpus(tmp_path / "cli-factory") + + def run() -> subprocess.CompletedProcess: + return subprocess.run( + [ + sys.executable, + "-m", + "graphify", + "extract", + str(root), + "--code-only", + "--no-cluster", + ], + cwd=tmp_path, + capture_output=True, + text=True, + ) + + first = run() + assert first.returncode == 0, first.stderr + widget_render, product_render = _factory_render_ids(root) + assert widget_render in _factory_call_targets(root) + assert product_render not in _factory_call_targets(root) + + _strip_kotlin_inventory_markers(root) + _write_factory(factory) + second = run() + assert second.returncode == 0, second.stderr + assert "re-queuing 3 kotlin member-call caller" in second.stdout.lower() + assert not _factory_call_targets(root) & {widget_render, product_render} + + factory.unlink() + third = run() + assert third.returncode == 0, third.stderr + assert "re-queuing 1 kotlin member-call caller" in third.stdout.lower() + assert widget_render in _factory_call_targets(root) + assert product_render not in _factory_call_targets(root) + + +def test_cli_requeues_caller_when_factory_becomes_excluded(tmp_path: Path) -> None: + root, factory = _incremental_factory_corpus(tmp_path / "cli-excluded-factory") + _write_factory(factory) + + def run() -> subprocess.CompletedProcess: + return subprocess.run( + [ + sys.executable, + "-m", + "graphify", + "extract", + str(root), + "--code-only", + "--no-cluster", + ], + cwd=tmp_path, + capture_output=True, + text=True, + ) + + first = run() + assert first.returncode == 0, first.stderr + widget_render, product_render = _factory_render_ids(root) + assert not _factory_call_targets(root) & {widget_render, product_render} + + (root / ".graphifyignore").write_text("Factory.kt\n", encoding="utf-8") + second = run() + assert second.returncode == 0, second.stderr + assert "re-queuing 1 kotlin member-call caller" in second.stdout.lower() + assert widget_render in _factory_call_targets(root) + assert product_render not in _factory_call_targets(root) From 9b8ed29a7ca6b6006fc0d6329b622ace2ba86d7f Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 21 Aug 2026 03:58:55 -0500 Subject: [PATCH 02/12] fix(extract): fail closed on relative dotted Kotlin receivers --- graphify/extract.py | 8 ++++++ tests/test_kotlin_member_calls.py | 46 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/graphify/extract.py b/graphify/extract.py index 49cb0e89f..5310e7117 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4160,6 +4160,14 @@ def visible_type_fqns( type_name: str, source_file: str, package: str ) -> set[str]: if "." in type_name: + head = type_name.partition(".")[0] + imported_heads = imports_by_file.get(source_file, {}) + package_head = f"{package}.{head}" if package else head + if head in imported_heads or types_by_fqn.get(package_head): + # `Outer.Inner` can qualify an imported or same-package class, + # not an absolute package. Nested types are not inventoried yet, + # so accepting the spelling as an FQN could bind a package decoy. + return set() return {type_name} imported = imports_by_file.get(source_file, {}).get(type_name) if imported is not None: diff --git a/tests/test_kotlin_member_calls.py b/tests/test_kotlin_member_calls.py index 48edea608..3e1595739 100644 --- a/tests/test_kotlin_member_calls.py +++ b/tests/test_kotlin_member_calls.py @@ -250,6 +250,52 @@ def test_import_alias_and_fqn_select_the_exact_type(tmp_path: Path) -> None: assert (qualified, b_run) not in pairs +def test_dotted_relative_receivers_do_not_bind_absolute_package_decoy( + tmp_path: Path, +) -> None: + result = _extract( + tmp_path, + { + "domain/Outer.kt": ( + "package domain\n" + "class Outer {\n" + " class Service { fun run() {} }\n" + "}\n" + ), + "absolute_decoy/Service.kt": ( + "package Outer\n" + "class Service { fun run() {} }\n" + ), + "domain/SamePackageUse.kt": ( + "package domain\n" + "fun samePackage(value: Outer.Service) { value.run() }\n" + ), + "app/Use.kt": ( + "package app\n" + "import domain.Outer\n" + "fun imported(value: Outer.Service) { value.run() }\n" + ), + }, + ) + + callers = { + _find(result, "samePackage()", "samepackageuse"), + _find(result, "imported()", "use"), + } + decoy_run = next( + node["id"] + for node in result["nodes"] + if node.get("label") == ".run()" + and str(node.get("source_file", "")).endswith( + "absolute_decoy/Service.kt" + ) + ) + assert not any( + edge["source"] in callers and edge["target"] == decoy_run + for edge in _call_edges(result) + ) + + def test_markerless_incremental_kotlin_context_fails_closed(tmp_path: Path) -> None: caller_path = tmp_path / "Caller.kt" caller_path.write_text( From ab67f823b32173def3cca84940b471d6a106b5a5 Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 21 Aug 2026 04:22:44 -0500 Subject: [PATCH 03/12] fix(extract): guard ambiguous Kotlin type qualifiers --- graphify/extract.py | 33 ++++++++++-- graphify/extractors/engine.py | 83 +++++++++++++++++++++++++++++++ tests/test_kotlin_member_calls.py | 71 ++++++++++++++++++++++++-- 3 files changed, 178 insertions(+), 9 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 5310e7117..e91f1230d 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4117,6 +4117,7 @@ def _resolve_kotlin_member_calls( node_by_id = {node.get("id"): node for node in all_nodes} types_by_fqn: dict[str, list[str]] = {} functions_by_fqn: dict[str, list[str]] = {} + type_alias_fqns: set[str] = set() for node in all_nodes: node_id = node.get("id") if not node_id: @@ -4126,6 +4127,9 @@ def _resolve_kotlin_member_calls( types_by_fqn.setdefault(str(type_fqn), []).append(node_id) for function_fqn in node.get("_kotlin_top_level_function_fqns") or (): functions_by_fqn.setdefault(str(function_fqn), []).append(node_id) + for symbol in node.get("_kotlin_member_symbol_inventory") or (): + if str(symbol).startswith("typealias:"): + type_alias_fqns.add(str(symbol).removeprefix("typealias:")) methods: dict[tuple[str, str], list[str]] = {} imports_by_file: dict[str, dict[str, set[str]]] = {} @@ -4157,16 +4161,27 @@ def _resolve_kotlin_member_calls( existing_pairs = {(edge.get("source"), edge.get("target")) for edge in all_edges} def visible_type_fqns( - type_name: str, source_file: str, package: str + type_name: str, + source_file: str, + package: str, + has_relative_head: bool, ) -> set[str]: if "." in type_name: head = type_name.partition(".")[0] imported_heads = imports_by_file.get(source_file, {}) package_head = f"{package}.{head}" if package else head - if head in imported_heads or types_by_fqn.get(package_head): + if ( + head[:1].isupper() + or has_relative_head + or head in imported_heads + or types_by_fqn.get(package_head) + or package_head in type_alias_fqns + ): # `Outer.Inner` can qualify an imported or same-package class, - # not an absolute package. Nested types are not inventoried yet, - # so accepting the spelling as an FQN could bind a package decoy. + # a class supplied by a wildcard import, or a lexically nested + # classifier (including Kotlin/JVM implicit imports), not an + # absolute package. Nested types are not inventoried yet, so + # accepting the spelling as an FQN could bind a package decoy. return set() return {type_name} imported = imports_by_file.get(source_file, {}).get(type_name) @@ -4178,7 +4193,15 @@ def visible_type_fqns( source_file = str(call.get("source_file", "")) receiver_type = str(call["receiver_type"]) package = str(call.get("kotlin_package", "")) - candidate_fqns = visible_type_fqns(receiver_type, source_file, package) + candidate_fqns = visible_type_fqns( + receiver_type, + source_file, + package, + bool( + call.get("kotlin_has_wildcard_import") + or call.get("kotlin_has_relative_classifier_head") + ), + ) if len(candidate_fqns) != 1: continue resolved_fqn = next(iter(candidate_fqns)) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index b50a2107f..92d16c4f8 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -2988,6 +2988,65 @@ def _kotlin_package_name(root, source: bytes) -> str | None: return None +def _kotlin_has_wildcard_import(root, source: bytes) -> bool: + """Whether a Kotlin source file imports any package with ``*``.""" + for child in root.children: + if child.type not in ("import", "import_header"): + continue + path_node = child.child_by_field_name("path") + if path_node is None: + path_node = next( + ( + part + for part in child.children + if part.type == "qualified_identifier" + ), + None, + ) + if path_node is not None: + raw = _read_text(path_node, source).strip() + else: + raw = next( + ( + _read_text(part, source).strip() + for part in child.children + if part.type in ("identifier", "simple_identifier") + ), + "", + ) + is_wildcard = raw.endswith(".*") or any( + part.type == "*" for part in child.children + ) + package = raw.removesuffix(".*").rstrip(".") + if is_wildcard and package: + return True + return False + + +def _kotlin_relative_classifier_heads(root, source: bytes) -> set[str]: + """File-local classifier names that cannot be treated as package heads.""" + names: set[str] = set() + stack = [root] + while stack: + current = stack.pop() + is_relative_classifier = ( + current.type in ("type_alias", "companion_object") + or ( + current.type in ("class_declaration", "object_declaration") + and current.parent is not None + and current.parent.type != "source_file" + ) + ) + if is_relative_classifier: + name = _kotlin_declaration_name(current, source) + if current.type == "companion_object" and not name: + name = "Companion" + if name: + names.add(name) + stack.extend(current.children) + return names + + def _kotlin_nav_identifier_segments(nav, source: bytes) -> list[str] | None: """Flatten a Kotlin ``navigation_expression`` chain into its dotted identifier segments (``com.example.Foo.bar`` -> [com, example, Foo, bar]). @@ -6250,6 +6309,19 @@ def _scan_js_module_dispatch(n) -> None: if config.ts_module == "tree_sitter_kotlin": if kotlin_package: result["kotlin_package"] = kotlin_package + has_wildcard_import = _kotlin_has_wildcard_import(root, source) + relative_classifier_heads = _kotlin_relative_classifier_heads(root, source) + for call in raw_calls: + if call.get("lang") != "kotlin": + continue + if has_wildcard_import: + call["kotlin_has_wildcard_import"] = True + receiver_type = str(call.get("receiver_type") or "") + if ( + "." in receiver_type + and receiver_type.partition(".")[0] in relative_classifier_heads + ): + call["kotlin_has_relative_classifier_head"] = True top_level_types = { edge["target"] for edge in clean_edges @@ -6273,6 +6345,14 @@ def _scan_js_module_dispatch(n) -> None: if (name := _kotlin_declaration_name(declaration, source)) } ) + top_level_type_alias_fqns = sorted( + { + f"{kotlin_package}.{name}" if kotlin_package else name + for declaration in root.children + if declaration.type == "type_alias" + if (name := _kotlin_declaration_name(declaration, source)) + } + ) node_by_id = {item["id"]: item for item in nodes} member_symbol_inventory = [ f"type:{item['_kotlin_fqn']}" @@ -6292,6 +6372,9 @@ def _scan_js_module_dispatch(n) -> None: member_symbol_inventory.extend( f"function:{fqn}" for fqn in top_level_function_fqns ) + member_symbol_inventory.extend( + f"typealias:{fqn}" for fqn in top_level_type_alias_fqns + ) has_member_call_dependencies = any( call.get("lang") == "kotlin" and call.get("is_member_call") diff --git a/tests/test_kotlin_member_calls.py b/tests/test_kotlin_member_calls.py index 3e1595739..aec7358c4 100644 --- a/tests/test_kotlin_member_calls.py +++ b/tests/test_kotlin_member_calls.py @@ -266,6 +266,14 @@ def test_dotted_relative_receivers_do_not_bind_absolute_package_decoy( "package Outer\n" "class Service { fun run() {} }\n" ), + "companion_decoy/Service.kt": ( + "package Companion\n" + "class Service { fun run() {} }\n" + ), + "implicit_decoy/Option.kt": ( + "package StackWalker\n" + "class Option { override fun toString(): String = \"x\" }\n" + ), "domain/SamePackageUse.kt": ( "package domain\n" "fun samePackage(value: Outer.Service) { value.run() }\n" @@ -275,23 +283,78 @@ def test_dotted_relative_receivers_do_not_bind_absolute_package_decoy( "import domain.Outer\n" "fun imported(value: Outer.Service) { value.run() }\n" ), + "app/WildcardUse.kt": ( + "package app\n" + "import domain.*\n" + "fun wildcard(value: Outer.Service) { value.run() }\n" + ), + "app/Alias.kt": "package app\ntypealias Outer = domain.Outer\n", + "app/TypeAliasUse.kt": ( + "package app\n" + "fun typeAlias(value: Outer.Service) { value.run() }\n" + ), + "app/NestedUse.kt": ( + "package app\n" + "class Container {\n" + " class Outer {\n" + " class Service { fun run() {} }\n" + " }\n" + " fun nested(value: Outer.Service) { value.run() }\n" + "}\n" + ), + "app/NamedCompanionUse.kt": ( + "package app\n" + "class NamedHost {\n" + " companion object Outer {\n" + " class Service { fun run() {} }\n" + " }\n" + " fun namedCompanion(value: Outer.Service) { value.run() }\n" + "}\n" + ), + "app/UnnamedCompanionUse.kt": ( + "package app\n" + "class UnnamedHost {\n" + " companion object {\n" + " class Service { fun run() {} }\n" + " }\n" + " fun unnamedCompanion(value: Companion.Service) { value.run() }\n" + "}\n" + ), + "app/ImplicitUse.kt": ( + "package app\n" + "fun implicit(value: StackWalker.Option) { value.toString() }\n" + ), }, ) callers = { _find(result, "samePackage()", "samepackageuse"), _find(result, "imported()", "use"), + _find(result, "wildcard()", "wildcarduse"), + _find(result, "typeAlias()", "typealiasuse"), + _find(result, ".nested()", "nesteduse"), + _find(result, ".namedCompanion()", "namedcompanionuse"), + _find(result, ".unnamedCompanion()", "unnamedcompanionuse"), + _find(result, "implicit()", "implicituse"), } - decoy_run = next( + decoy_runs = { node["id"] for node in result["nodes"] if node.get("label") == ".run()" and str(node.get("source_file", "")).endswith( - "absolute_decoy/Service.kt" + ("absolute_decoy/Service.kt", "companion_decoy/Service.kt") ) - ) + } + decoy_methods = decoy_runs | { + node["id"] + for node in result["nodes"] + if node.get("label") == ".toString()" + and str(node.get("source_file", "")).endswith( + "implicit_decoy/Option.kt" + ) + } assert not any( - edge["source"] in callers and edge["target"] == decoy_run + edge["source"] in callers and edge["target"] in decoy_methods for edge in _call_edges(result) ) From c24a8eb7ed8a006d9702f95576090779dcb27b4c Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 21 Aug 2026 11:03:55 -0500 Subject: [PATCH 04/12] fix(extract): preserve Kotlin calls alongside method edges --- graphify/extract.py | 6 +++++- tests/test_kotlin_member_calls.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/graphify/extract.py b/graphify/extract.py index e91f1230d..5ab7e84b9 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4158,7 +4158,11 @@ def _resolve_kotlin_member_calls( local_name, set() ).add(target_fqn) - existing_pairs = {(edge.get("source"), edge.get("target")) for edge in all_edges} + existing_pairs = { + (edge.get("source"), edge.get("target")) + for edge in all_edges + if edge.get("relation") == "calls" + } def visible_type_fqns( type_name: str, diff --git a/tests/test_kotlin_member_calls.py b/tests/test_kotlin_member_calls.py index aec7358c4..9ff9aaec8 100644 --- a/tests/test_kotlin_member_calls.py +++ b/tests/test_kotlin_member_calls.py @@ -98,6 +98,31 @@ def test_issue_1699_resolves_four_typed_receiver_shapes(tmp_path: Path) -> None: ) +def test_member_call_coexists_with_method_ownership_edge(tmp_path: Path) -> None: + result = _extract( + tmp_path, + { + "Owner.kt": ( + "class Owner {\n" + " companion object {\n" + " fun ping() {}\n" + " val initialized = Owner.ping()\n" + " }\n" + "}\n" + ), + }, + ) + + owner = _find(result, "Owner", "owner") + ping = _find(result, ".ping()", "owner") + relations = { + edge["relation"] + for edge in result["edges"] + if edge.get("source") == owner and edge.get("target") == ping + } + assert {"method", "calls"} <= relations + + def test_receiver_bindings_do_not_leak_between_methods(tmp_path: Path) -> None: result = _extract( tmp_path, From f8259ef00b4ef7eff4fe26fa3ec6e764dca85b75 Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 21 Aug 2026 11:39:57 -0500 Subject: [PATCH 05/12] fix(extract): close Kotlin ambiguity gaps --- graphify/extract.py | 17 +++++++++-- tests/test_kotlin_member_calls.py | 50 +++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 5ab7e84b9..f64c8c262 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3884,6 +3884,10 @@ def _resolve_kotlin_qualified_calls( }) +def _is_kotlin_source_file(value: object) -> bool: + return str(value).lower().endswith((".kt", ".kts")) + + def _kotlin_incremental_member_callers( graph_path: Path, *, @@ -3967,7 +3971,7 @@ def persisted_identity(source_file: str) -> str: if not source_file: continue source_id = persisted_identity(str(source_file)) - if str(source_file).endswith((".kt", ".kts")): + if _is_kotlin_source_file(source_file): graph_kotlin_sources.add(source_id) if "_kotlin_member_symbol_inventory_version" in node: inventoried_sources.add(source_id) @@ -4101,7 +4105,7 @@ def _resolve_kotlin_member_calls( kotlin_sources = { str(node.get("source_file", "")) for node in all_nodes - if str(node.get("source_file", "")).endswith((".kt", ".kts")) + if _is_kotlin_source_file(node.get("source_file", "")) } inventoried_sources = { str(node.get("source_file", "")) @@ -4146,7 +4150,7 @@ def _resolve_kotlin_member_calls( if edge.get("relation") != "imports": continue source_file = str(edge.get("source_file", "")) - if not source_file.endswith((".kt", ".kts")): + if not _is_kotlin_source_file(source_file): continue metadata = edge.get("metadata") or {} target_fqn = str(metadata.get("target_fqn", "")) @@ -4210,6 +4214,13 @@ def visible_type_fqns( continue resolved_fqn = next(iter(candidate_fqns)) constructor_name = str(call.get("receiver_constructor_name") or "") + if call.get("receiver_constructor") and call.get( + "kotlin_has_wildcard_import" + ): + # A wildcard import can supply a same-named factory whose return type + # differs from the visible class. Without signature resolution, a + # constructor-inferred receiver must not assume the class type. + continue visible_function_fqns = {resolved_fqn} if constructor_name and "." not in constructor_name: visible_function_fqns.add( diff --git a/tests/test_kotlin_member_calls.py b/tests/test_kotlin_member_calls.py index 9ff9aaec8..e10e21b23 100644 --- a/tests/test_kotlin_member_calls.py +++ b/tests/test_kotlin_member_calls.py @@ -574,6 +574,51 @@ def test_same_package_factory_shadows_imported_class_constructor( assert (typed, product_render) not in pairs +def test_wildcard_imported_factory_makes_constructor_binding_ambiguous( + tmp_path: Path, +) -> None: + result = _extract( + tmp_path, + { + "model/Widget.kt": ( + "package model\n" + "class Widget { fun render() {} }\n" + ), + "product/Product.kt": ( + "package product\n" + "class Product { fun render() {} }\n" + ), + "factory/Factory.kt": ( + "package factory\n" + "import product.Product\n" + "fun Widget(size: Int): Product = Product()\n" + ), + "app/Use.kt": ( + "package app\n" + "import model.Widget\n" + "import factory.*\n" + "fun use() {\n" + " val value = Widget(1)\n" + " value.render()\n" + "}\n" + "fun typed(value: Widget) { value.render() }\n" + ), + }, + ) + + caller = _find(result, "use()", "use") + typed = _find(result, "typed()", "use") + widget_render = _find(result, ".render()", "widget") + product_render = _find(result, ".render()", "product") + pairs = {(edge["source"], edge["target"]) for edge in _call_edges(result)} + assert not any( + source == caller and target in {widget_render, product_render} + for source, target in pairs + ) + assert (typed, widget_render) in pairs + assert (typed, product_render) not in pairs + + def test_recovery_parsed_kotlin_provider_fails_inventory_closed( tmp_path: Path, ) -> None: @@ -843,7 +888,8 @@ def test_watch_zero_node_provider_invalidates_existing_caller( import graphify.extract as extract_module from graphify.watch import _rebuild_code - root, broken = _incremental_factory_corpus(tmp_path / "watch-zero-provider") + root, _ = _incremental_factory_corpus(tmp_path / "watch-zero-provider") + broken = root / "Factory.KT" assert _rebuild_code(root, no_cluster=True, acquire_lock=False) is True widget_render, product_render = _factory_render_ids(root) assert widget_render in _factory_call_targets(root) @@ -855,7 +901,7 @@ def test_watch_zero_node_provider_invalidates_existing_caller( original = extract_module.extract_kotlin def fail_broken(path: Path) -> dict: - if path.name == "Factory.kt": + if path.name == "Factory.KT": raise RuntimeError("forced Kotlin extraction failure") return original(path) From 50ee78bef1ac0958f2198c41b873572cce4e656b Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 21 Aug 2026 11:53:30 -0500 Subject: [PATCH 06/12] fix(extract): activate resolvers for mixed-case suffixes --- graphify/resolver_registry.py | 6 +++++- tests/test_kotlin_member_calls.py | 4 ++-- tests/test_language_resolvers.py | 7 +++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/graphify/resolver_registry.py b/graphify/resolver_registry.py index b17478a78..825023a25 100644 --- a/graphify/resolver_registry.py +++ b/graphify/resolver_registry.py @@ -75,7 +75,11 @@ def run_language_resolvers( exercise the driver in isolation. """ active = _REGISTRY if resolvers is None else resolvers - suffixes_present = {p.suffix for p in paths} + suffixes_present = { + suffix + for path in paths + for suffix in (path.suffix, path.suffix.lower()) + } for resolver in active: if not (resolver.suffixes & suffixes_present): continue diff --git a/tests/test_kotlin_member_calls.py b/tests/test_kotlin_member_calls.py index e10e21b23..f4502f0ab 100644 --- a/tests/test_kotlin_member_calls.py +++ b/tests/test_kotlin_member_calls.py @@ -691,7 +691,7 @@ def _incremental_corpus(root: Path) -> tuple[Path, Path]: (root / "Service.kt").write_text( "package lib\nclass Service { fun ping() {} }\n", encoding="utf-8" ) - caller = root / "Caller.kt" + caller = root / "Caller.KT" caller.write_text( "package app\n" "import lib.Service\n" @@ -707,7 +707,7 @@ def _incremental_member_edges(root: Path) -> list[tuple[str, str]]: node["id"] for node in graph["nodes"] if node.get("label") == "call()" - and str(node.get("source_file", "")).endswith("Caller.kt") + and str(node.get("source_file", "")).lower().endswith("caller.kt") ) target = next( node["id"] diff --git a/tests/test_language_resolvers.py b/tests/test_language_resolvers.py index 787c1d505..e05674285 100644 --- a/tests/test_language_resolvers.py +++ b/tests/test_language_resolvers.py @@ -42,6 +42,13 @@ def test_resolver_runs_only_when_suffix_present() -> None: assert log == ["ruby"] # go skipped: no .go file present +def test_resolver_accepts_mixed_case_suffix() -> None: + log: list[str] = [] + resolvers = [_make_resolver("kotlin", ".kt", log)] + run_language_resolvers([Path("Caller.KT")], [], [], [], resolvers=resolvers) + assert log == ["kotlin"] + + def test_resolvers_run_in_given_order() -> None: log: list[str] = [] resolvers = [_make_resolver("first", ".rb", log), _make_resolver("second", ".rb", log)] From 3f572a5edbcec10f9099c3b9d8e4752946bc4474 Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 21 Aug 2026 12:06:59 -0500 Subject: [PATCH 07/12] fix(extract): fail closed on Kotlin shadowing gaps --- graphify/extract.py | 2 +- graphify/extractors/engine.py | 5 +++- graphify/watch.py | 3 ++- tests/test_kotlin_grammar.py | 12 ++++++++++ tests/test_kotlin_member_calls.py | 38 ++++++++++++++++++++++++++++++- 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index f64c8c262..cd27fbb57 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3762,7 +3762,7 @@ def _resolve_kotlin_import_targets( for e in all_edges: if e.get("relation") != "imports": continue - if not str(e.get("source_file", "")).endswith((".kt", ".kts")): + if not _is_kotlin_source_file(e.get("source_file", "")): continue fqn = (e.get("metadata") or {}).get("target_fqn", "") pkg, _, name = str(fqn).rpartition(".") diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 92d16c4f8..54d723bbb 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -1122,7 +1122,7 @@ def bind_field(name: str | None, type_name: str | None) -> None: table = dict(field_types) if owner_type: table["@this"] = owner_type - poisoned: set[str] = set() + poisoned: set[str] = set(field_poisoned) def bind(name: str | None, type_name: str | None) -> None: if not name or name in poisoned: @@ -1238,6 +1238,7 @@ def bind(name: str | None, type_name: str | None) -> None: bind(name, None) for name in poisoned: table.pop(name, None) + table[f"@blocked:{name}"] = "1" for name, type_name in return_types.items(): if type_name and name not in method_shadows: table[f"@call:{name}"] = type_name @@ -5592,6 +5593,8 @@ def walk_calls( if ( kotlin_receiver_type is None and member_receiver[:1].isupper() + and f"@blocked:{member_receiver}" + not in (receiver_types or {}) ): # Preserve direct object/companion calls without # the old label-only fallback. The corpus resolver diff --git a/graphify/watch.py b/graphify/watch.py index 486975b6b..78d9a3bd1 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1181,6 +1181,7 @@ def _rebuild_code( try: from graphify.extract import ( _get_extractor, + _is_kotlin_source_file, _kotlin_incremental_member_callers, extract, ) @@ -1252,7 +1253,7 @@ def _rebuild_code( seen_ignored_inventory_sources: set[str] = set() for node in inventory_graph.get("nodes", []): source_file = str(node.get("source_file", "")) - if not source_file.endswith((".kt", ".kts")): + if not _is_kotlin_source_file(source_file): continue identity = inventory_paths.identity(source_file) if ( diff --git a/tests/test_kotlin_grammar.py b/tests/test_kotlin_grammar.py index dabc4dc03..d86f77ef1 100644 --- a/tests/test_kotlin_grammar.py +++ b/tests/test_kotlin_grammar.py @@ -102,6 +102,18 @@ def test_kotlin_imports_resolve_to_real_nodes(tmp_path): assert e["target"] in node_ids, f"import target {e['target']} dangles" +def test_kotlin_imports_resolve_from_mixed_case_source_suffix(tmp_path): + corpus = dict(_IMPORT_CORPUS) + corpus["app/Main.KT"] = corpus.pop("app/Main.kt") + result = _extract(tmp_path, corpus) + main_file = _find(result, "Main.KT") + money = _find(result, "Money") + ledger = _find(result, "Ledger") + imports = _edges(result, "imports") + assert (main_file, money) in imports + assert (main_file, ledger) in imports + + def test_kotlin_import_evidence_promotes_calls_to_extracted(tmp_path): r = _extract(tmp_path, _IMPORT_CORPUS) main_fn = _find(r, "main()") diff --git a/tests/test_kotlin_member_calls.py b/tests/test_kotlin_member_calls.py index f4502f0ab..aa413fb8d 100644 --- a/tests/test_kotlin_member_calls.py +++ b/tests/test_kotlin_member_calls.py @@ -149,6 +149,41 @@ def test_receiver_bindings_do_not_leak_between_methods(tmp_path: Path) -> None: assert (second, alpha_close) not in pairs +def test_uppercase_receiver_fallback_respects_lexical_value_shadowing( + tmp_path: Path, +) -> None: + result = _extract( + tmp_path, + { + "Service.kt": "object Service { fun ping() {} }\n", + "Other.kt": "class Other { fun ping() {} }\n", + "Use.kt": ( + "fun local(other: Other) {\n" + " val Service = other\n" + " Service.ping()\n" + "}\n" + "class Holder(private val other: Other) {\n" + " val Service = other\n" + " fun field() { Service.ping() }\n" + "}\n" + ), + }, + ) + + callers = { + _find(result, "local()", "use"), + _find(result, ".field()", "holder"), + } + targets = { + _find(result, ".ping()", "service"), + _find(result, ".ping()", "other"), + } + assert not any( + edge["source"] in callers and edge["target"] in targets + for edge in _call_edges(result) + ) + + def test_explicit_this_call_resolves_only_to_its_owner(tmp_path: Path) -> None: result = _extract( tmp_path, @@ -843,13 +878,14 @@ def test_watch_requeues_caller_when_factory_becomes_excluded( from graphify.watch import _rebuild_code root, factory = _incremental_factory_corpus(tmp_path / "watch-excluded-factory") + factory = factory.with_suffix(".KT") _write_factory(factory) assert _rebuild_code(root, no_cluster=True, acquire_lock=False) is True widget_render, product_render = _factory_render_ids(root) assert not _factory_call_targets(root) & {widget_render, product_render} ignore = root / ".graphifyignore" - ignore.write_text("Factory.kt\n", encoding="utf-8") + ignore.write_text("Factory.KT\n", encoding="utf-8") assert _rebuild_code( root, changed_paths=[ignore], no_cluster=True, acquire_lock=False ) is True From 1efa368322b0fec2d61edff59ddded16e70ddf0b Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 21 Aug 2026 12:14:20 -0500 Subject: [PATCH 08/12] fix(extract): preserve Kotlin owner shadow facts --- graphify/extractors/engine.py | 66 ++++++++++++++++++++++++------- tests/test_kotlin_member_calls.py | 7 ++++ 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 54d723bbb..720f1bd8b 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -932,14 +932,27 @@ def _kotlin_function_receiver_type_node(function_node): def _kotlin_owner_members(owner_node) -> list: - """Return direct declarations owned by a Kotlin file/class/object.""" + """Return declarations attributed to a Kotlin file/class/object.""" if owner_node.type == "source_file": return list(owner_node.children) body = next( (child for child in owner_node.children if child.type == "class_body"), None, ) - return list(body.children) if body is not None else [] + if body is None: + return [] + members: list = [] + for child in body.children: + if child.type != "companion_object": + members.append(child) + continue + companion_body = next( + (item for item in child.children if item.type == "class_body"), + None, + ) + if companion_body is not None: + members.extend(companion_body.children) + return members def _kotlin_owner_return_types(owner_node, source: bytes) -> dict[str, str | None]: @@ -1007,7 +1020,10 @@ def _kotlin_binding_type( def _kotlin_receiver_types_by_body( root_node, source: bytes -) -> dict[tuple[int, int], dict[str, str]]: +) -> tuple[ + dict[tuple[int, int], dict[str, str]], + dict[tuple[int, int], dict[str, str]], +]: """Build fail-closed, per-function Kotlin receiver facts (#1699).""" callable_names: set[str] = set() stack = [root_node] @@ -1020,6 +1036,7 @@ def _kotlin_receiver_types_by_body( stack.extend(current.children) tables: dict[tuple[int, int], dict[str, str]] = {} + owner_tables: dict[tuple[int, int], dict[str, str]] = {} def process_owner(owner_node) -> None: members = _kotlin_owner_members(owner_node) @@ -1109,6 +1126,10 @@ def bind_field(name: str | None, type_name: str | None) -> None: field_types.update( {f"this.{name}": type_name for name, type_name in field_types.items()} ) + owner_table = dict(field_types) + for name in field_poisoned: + owner_table[f"@blocked:{name}"] = "1" + owner_tables[(owner_node.start_byte, owner_node.end_byte)] = owner_table for member in members: if member.type != "function_declaration": @@ -1176,10 +1197,9 @@ def bind(name: str | None, type_name: str | None) -> None: "object_declaration", "function_declaration", ): - if current.type == "function_declaration": - name = _kotlin_declaration_name(current, source) - if name: - method_shadows.add(name) + name = _kotlin_declaration_name(current, source) + if name: + method_shadows.add(name) continue if current.type == "property_declaration": declarations.append(current) @@ -1239,6 +1259,9 @@ def bind(name: str | None, type_name: str | None) -> None: for name in poisoned: table.pop(name, None) table[f"@blocked:{name}"] = "1" + for name in method_shadows: + if name not in table: + table[f"@blocked:{name}"] = "1" for name, type_name in return_types.items(): if type_name and name not in method_shadows: table[f"@call:{name}"] = type_name @@ -1249,7 +1272,7 @@ def bind(name: str | None, type_name: str | None) -> None: process_owner(member) process_owner(root_node) - return tables + return tables, owner_tables def _swift_declaration_keyword(node) -> str | None: """Return the leading kind token for a Swift class_declaration: class/struct/enum/extension/actor.""" @@ -3384,6 +3407,7 @@ def _extract_generic( # `let vm = VM()`) live outside function bodies, so the call-walk never # reaches them. Collect (owner_nid, call_node) here and walk them too. initializer_nodes: list[tuple[str, object]] = [] + kotlin_owner_nids: dict[tuple[int, int], str] = {} # Ruby include/extend/prepend mixins collected during the node walk (#1668), # merged into raw_calls after the call-walk populates it (raw_calls does not # exist yet while walk() runs). Resolved cross-file by the Ruby resolver. @@ -3485,6 +3509,8 @@ def ensure_named_node(name: str, line: int) -> str: file_nid = _make_id(str(path)) add_node(file_nid, path.name, 1) + if config.ts_module == "tree_sitter_kotlin": + kotlin_owner_nids[(root.start_byte, root.end_byte)] = file_nid def walk(node, parent_class_nid: str | None = None) -> None: t = node.type @@ -3544,6 +3570,8 @@ def walk(node, parent_class_nid: str | None = None) -> None: ruby_segments = class_name.split("::") class_name = "::".join(ruby_namespace + ruby_segments) class_nid = _make_id(stem, ".".join(namespace_stack), class_name) + if config.ts_module == "tree_sitter_kotlin": + kotlin_owner_nids[(node.start_byte, node.end_byte)] = class_nid line = node.start_point[0] + 1 metadata = None if config.ts_module == "tree_sitter_c_sharp": @@ -5257,11 +5285,17 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: ) for body_id, (method_node, class_nid) in csharp_method_scopes.items() } - kotlin_receiver_types = ( - _kotlin_receiver_types_by_body(root, source) - if config.ts_module == "tree_sitter_kotlin" - else {} - ) + if config.ts_module == "tree_sitter_kotlin": + kotlin_receiver_types, kotlin_owner_receiver_types = ( + _kotlin_receiver_types_by_body(root, source) + ) + kotlin_initializer_receiver_types = { + owner_nid: kotlin_owner_receiver_types.get(owner_span, {}) + for owner_span, owner_nid in kotlin_owner_nids.items() + } + else: + kotlin_receiver_types = {} + kotlin_initializer_receiver_types = {} def _emit_indirect_by_name(ident_name: str, loc_node, scope_nid: str, context: str) -> None: @@ -6206,7 +6240,11 @@ def walk_calls( # self-guards against re-entering function bodies and dedups via # seen_call_pairs, so a closure inside an initializer is not double-walked. for owner_nid, init_node in initializer_nodes: - walk_calls(init_node, owner_nid) + walk_calls( + init_node, + owner_nid, + kotlin_initializer_receiver_types.get(owner_nid), + ) # ── Event listener pass ─────────────────────────────────────────────────── seen_listen_pairs: set[tuple[str, str]] = set() diff --git a/tests/test_kotlin_member_calls.py b/tests/test_kotlin_member_calls.py index aa413fb8d..e311e7598 100644 --- a/tests/test_kotlin_member_calls.py +++ b/tests/test_kotlin_member_calls.py @@ -162,9 +162,14 @@ def test_uppercase_receiver_fallback_respects_lexical_value_shadowing( " val Service = other\n" " Service.ping()\n" "}\n" + "fun callable() {\n" + " fun Service() {}\n" + " Service.ping()\n" + "}\n" "class Holder(private val other: Other) {\n" " val Service = other\n" " fun field() { Service.ping() }\n" + " val initialized = Service.ping()\n" "}\n" ), }, @@ -172,7 +177,9 @@ def test_uppercase_receiver_fallback_respects_lexical_value_shadowing( callers = { _find(result, "local()", "use"), + _find(result, "callable()", "use"), _find(result, ".field()", "holder"), + _find(result, "Holder", "holder"), } targets = { _find(result, ".ping()", "service"), From 1a36b9a60724d6dd8141b8583ffe4dc7fa39caab Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 21 Aug 2026 12:21:37 -0500 Subject: [PATCH 09/12] fix(extract): isolate Kotlin initializer owner facts --- graphify/extractors/engine.py | 43 +++++++++++++-------- tests/test_kotlin_member_calls.py | 63 +++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 16 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 720f1bd8b..0ad92cc3c 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -978,6 +978,19 @@ def _kotlin_owner_return_types(owner_node, source: bytes) -> dict[str, str | Non return returns +def _kotlin_initializer_owner_span(property_node, root_node) -> tuple[int, int]: + """Return the Kotlin file/class/object span that owns a property initializer.""" + current = property_node.parent + while current is not None: + if current.type == "companion_object": + current = current.parent + continue + if current.type in ("class_declaration", "object_declaration"): + return current.start_byte, current.end_byte + current = current.parent + return root_node.start_byte, root_node.end_byte + + _KOTLIN_CONSTRUCTOR_TYPE_PREFIX = "@constructor:" @@ -1075,6 +1088,12 @@ def bind_field(name: str | None, type_name: str | None) -> None: if member.type == "property_declaration" for name in _kotlin_variable_names(member, source) } + owner_shadows.update( + name + for member in members + if member.type in ("class_declaration", "object_declaration") + if (name := _kotlin_declaration_name(member, source)) + ) if owner_node.type != "source_file": constructor = next( ( @@ -3406,8 +3425,7 @@ def _extract_generic( # #1356: call expressions in property/field initializers (e.g. # `let vm = VM()`) live outside function bodies, so the call-walk never # reaches them. Collect (owner_nid, call_node) here and walk them too. - initializer_nodes: list[tuple[str, object]] = [] - kotlin_owner_nids: dict[tuple[int, int], str] = {} + initializer_nodes: list[tuple[str, object, tuple[int, int] | None]] = [] # Ruby include/extend/prepend mixins collected during the node walk (#1668), # merged into raw_calls after the call-walk populates it (raw_calls does not # exist yet while walk() runs). Resolved cross-file by the Ruby resolver. @@ -3509,8 +3527,6 @@ def ensure_named_node(name: str, line: int) -> str: file_nid = _make_id(str(path)) add_node(file_nid, path.name, 1) - if config.ts_module == "tree_sitter_kotlin": - kotlin_owner_nids[(root.start_byte, root.end_byte)] = file_nid def walk(node, parent_class_nid: str | None = None) -> None: t = node.type @@ -3570,8 +3586,6 @@ def walk(node, parent_class_nid: str | None = None) -> None: ruby_segments = class_name.split("::") class_name = "::".join(ruby_namespace + ruby_segments) class_nid = _make_id(stem, ".".join(namespace_stack), class_name) - if config.ts_module == "tree_sitter_kotlin": - kotlin_owner_nids[(node.start_byte, node.end_byte)] = class_nid line = node.start_point[0] + 1 metadata = None if config.ts_module == "tree_sitter_c_sharp": @@ -4388,17 +4402,18 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: # the `=`, so post-`=` named children are only the initializer. # Top-level properties attribute to the file node. owner_nid = parent_class_nid or file_nid + owner_span = _kotlin_initializer_owner_span(node, root) seen_eq = False for child in node.children: if not child.is_named: seen_eq = seen_eq or child.type == "=" continue if seen_eq: # `= expr` initializer - initializer_nodes.append((owner_nid, child)) + initializer_nodes.append((owner_nid, child, owner_span)) elif child.type == "property_delegate": # `by lazy { ... }` / any delegate for sub in child.children: if sub.is_named: - initializer_nodes.append((owner_nid, sub)) + initializer_nodes.append((owner_nid, sub, owner_span)) return if (config.ts_module == "tree_sitter_swift" @@ -4424,7 +4439,7 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: pending_factory: tuple[str, str] | None = None for child in node.children: if child.type in config.call_types: - initializer_nodes.append((parent_class_nid, child)) + initializer_nodes.append((parent_class_nid, child, None)) if prop_type is None: ctor = _swift_constructor_type(child, source) if ctor is not None: @@ -5289,13 +5304,9 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: kotlin_receiver_types, kotlin_owner_receiver_types = ( _kotlin_receiver_types_by_body(root, source) ) - kotlin_initializer_receiver_types = { - owner_nid: kotlin_owner_receiver_types.get(owner_span, {}) - for owner_span, owner_nid in kotlin_owner_nids.items() - } else: kotlin_receiver_types = {} - kotlin_initializer_receiver_types = {} + kotlin_owner_receiver_types = {} def _emit_indirect_by_name(ident_name: str, loc_node, scope_nid: str, context: str) -> None: @@ -6239,11 +6250,11 @@ def walk_calls( # #1356: walk property/field initializers (collected above). walk_calls # self-guards against re-entering function bodies and dedups via # seen_call_pairs, so a closure inside an initializer is not double-walked. - for owner_nid, init_node in initializer_nodes: + for owner_nid, init_node, owner_span in initializer_nodes: walk_calls( init_node, owner_nid, - kotlin_initializer_receiver_types.get(owner_nid), + kotlin_owner_receiver_types.get(owner_span) if owner_span else None, ) # ── Event listener pass ─────────────────────────────────────────────────── diff --git a/tests/test_kotlin_member_calls.py b/tests/test_kotlin_member_calls.py index e311e7598..fc5315d62 100644 --- a/tests/test_kotlin_member_calls.py +++ b/tests/test_kotlin_member_calls.py @@ -191,6 +191,69 @@ def test_uppercase_receiver_fallback_respects_lexical_value_shadowing( ) +def test_nested_classifier_shadows_top_level_receiver_type(tmp_path: Path) -> None: + result = _extract( + tmp_path, + { + "Top.kt": ( + "package demo\n" + "class Service {\n" + " fun ping() {}\n" + "}\n" + ), + "Holder.kt": ( + "package demo\n" + "class Holder {\n" + " class Service {\n" + " fun ping() {}\n" + " }\n" + " val instance = Service()\n" + " fun caller() { instance.ping() }\n" + "}\n" + ), + }, + ) + + caller = _find(result, ".caller()", "holder") + top_level_ping = _find(result, ".ping()", "top") + assert not any( + edge["source"] == caller and edge["target"] == top_level_ping + for edge in _call_edges(result) + ) + + +def test_initializer_owner_facts_do_not_cross_same_named_nested_types( + tmp_path: Path, +) -> None: + result = _extract( + tmp_path, + { + "Owners.kt": ( + "class Other\n" + "class Decoy { fun ping() {} }\n" + "class OuterA {\n" + " class Inner(private val other: Other) {\n" + " val Service = other\n" + " val initialized = Service.ping()\n" + " }\n" + "}\n" + "class OuterB {\n" + " class Inner {\n" + " val Service: Decoy = Decoy()\n" + " }\n" + "}\n" + ), + }, + ) + + inner = _find(result, "Inner", "owners") + decoy_ping = _find(result, ".ping()", "decoy") + assert not any( + edge["source"] == inner and edge["target"] == decoy_ping + for edge in _call_edges(result) + ) + + def test_explicit_this_call_resolves_only_to_its_owner(tmp_path: Path) -> None: result = _extract( tmp_path, From 3bfca2bd170391c27a1368a06305293bc5d5829e Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 21 Aug 2026 12:26:25 -0500 Subject: [PATCH 10/12] fix(extract): cover Kotlin special initializer owners --- graphify/extractors/engine.py | 26 +++++++++++++- tests/test_kotlin_member_calls.py | 59 +++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 0ad92cc3c..69c130395 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -985,7 +985,11 @@ def _kotlin_initializer_owner_span(property_node, root_node) -> tuple[int, int]: if current.type == "companion_object": current = current.parent continue - if current.type in ("class_declaration", "object_declaration"): + if current.type in ( + "class_declaration", + "object_declaration", + "object_literal", + ): return current.start_byte, current.end_byte current = current.parent return root_node.start_byte, root_node.end_byte @@ -1094,6 +1098,17 @@ def bind_field(name: str | None, type_name: str | None) -> None: if member.type in ("class_declaration", "object_declaration") if (name := _kotlin_declaration_name(member, source)) ) + owner_body = next( + (child for child in owner_node.children if child.type == "class_body"), + None, + ) + if owner_body is not None: + for child in owner_body.children: + if child.type != "companion_object": + continue + owner_shadows.add( + _kotlin_declaration_name(child, source) or "Companion" + ) if owner_node.type != "source_file": constructor = next( ( @@ -1148,6 +1163,9 @@ def bind_field(name: str | None, type_name: str | None) -> None: owner_table = dict(field_types) for name in field_poisoned: owner_table[f"@blocked:{name}"] = "1" + for name in owner_shadows: + if name not in owner_table: + owner_table[f"@blocked:{name}"] = "1" owner_tables[(owner_node.start_byte, owner_node.end_byte)] = owner_table for member in members: @@ -1291,6 +1309,12 @@ def bind(name: str | None, type_name: str | None) -> None: process_owner(member) process_owner(root_node) + pending = list(root_node.children) + while pending: + current = pending.pop() + if current.type == "object_literal": + process_owner(current) + pending.extend(current.children) return tables, owner_tables def _swift_declaration_keyword(node) -> str | None: diff --git a/tests/test_kotlin_member_calls.py b/tests/test_kotlin_member_calls.py index fc5315d62..c92b3f511 100644 --- a/tests/test_kotlin_member_calls.py +++ b/tests/test_kotlin_member_calls.py @@ -254,6 +254,65 @@ def test_initializer_owner_facts_do_not_cross_same_named_nested_types( ) +def test_companion_names_shadow_top_level_receiver_types(tmp_path: Path) -> None: + result = _extract( + tmp_path, + { + "Decoys.kt": ( + "package demo\n" + "object Service {\n" + " fun ping() {}\n" + "}\n" + "object Companion {\n" + " fun ping() {}\n" + "}\n" + ), + "Holders.kt": ( + "package demo\n" + "class NamedHolder {\n" + " companion object Service {\n" + " val initialized = Service.ping()\n" + " }\n" + "}\n" + "class UnnamedHolder {\n" + " companion object {\n" + " val initialized = Companion.ping()\n" + " }\n" + "}\n" + ), + }, + ) + + targets = { + _find(result, ".ping()", "service"), + _find(result, ".ping()", "companion"), + } + assert not any(edge["target"] in targets for edge in _call_edges(result)) + + +def test_object_literal_initializer_uses_its_own_shadow_facts(tmp_path: Path) -> None: + result = _extract( + tmp_path, + { + "Service.kt": "object Service { fun ping() {} }\n", + "Use.kt": ( + "class Other\n" + "fun make(other: Other) {\n" + " val value = object {\n" + " val Service = other\n" + " val initialized = Service.ping()\n" + " }\n" + "}\n" + ), + }, + ) + + service_ping = _find(result, ".ping()", "service") + assert not any( + edge["target"] == service_ping for edge in _call_edges(result) + ) + + def test_explicit_this_call_resolves_only_to_its_owner(tmp_path: Path) -> None: result = _extract( tmp_path, From 76632ab663e9a421e067adc230cd63433517ab66 Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 21 Aug 2026 12:29:39 -0500 Subject: [PATCH 11/12] fix(extract): preserve Kotlin classifier scope --- graphify/extractors/engine.py | 29 ++++++++++++----- tests/test_kotlin_member_calls.py | 54 +++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 69c130395..58dea00ce 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -936,7 +936,11 @@ def _kotlin_owner_members(owner_node) -> list: if owner_node.type == "source_file": return list(owner_node.children) body = next( - (child for child in owner_node.children if child.type == "class_body"), + ( + child + for child in owner_node.children + if child.type in ("class_body", "enum_class_body") + ), None, ) if body is None: @@ -1092,14 +1096,23 @@ def bind_field(name: str | None, type_name: str | None) -> None: if member.type == "property_declaration" for name in _kotlin_variable_names(member, source) } - owner_shadows.update( - name - for member in members - if member.type in ("class_declaration", "object_declaration") - if (name := _kotlin_declaration_name(member, source)) - ) + if owner_node.type != "source_file": + owner_shadows.update( + name + for member in members + if member.type in ( + "class_declaration", + "object_declaration", + "enum_entry", + ) + if (name := _kotlin_declaration_name(member, source)) + ) owner_body = next( - (child for child in owner_node.children if child.type == "class_body"), + ( + child + for child in owner_node.children + if child.type in ("class_body", "enum_class_body") + ), None, ) if owner_body is not None: diff --git a/tests/test_kotlin_member_calls.py b/tests/test_kotlin_member_calls.py index c92b3f511..4851d264d 100644 --- a/tests/test_kotlin_member_calls.py +++ b/tests/test_kotlin_member_calls.py @@ -313,6 +313,60 @@ def test_object_literal_initializer_uses_its_own_shadow_facts(tmp_path: Path) -> ) +def test_enum_entry_shadows_top_level_receiver_type(tmp_path: Path) -> None: + result = _extract( + tmp_path, + { + "Top.kt": ( + "package demo\n" + "object Service {\n" + " fun ping() {}\n" + "}\n" + ), + "Mode.kt": ( + "package demo\n" + "enum class Mode {\n" + " Service;\n" + " fun ping() {}\n" + " fun caller() { Service.ping() }\n" + "}\n" + ), + }, + ) + + caller = _find(result, ".caller()", "mode") + top_level_ping = _find(result, ".ping()", "top") + assert not any( + edge["source"] == caller and edge["target"] == top_level_ping + for edge in _call_edges(result) + ) + + +def test_top_level_classifier_remains_a_valid_receiver(tmp_path: Path) -> None: + result = _extract( + tmp_path, + { + "Use.kt": ( + "object Service {\n" + " fun ping() {}\n" + "}\n" + "val initialized = Service.ping()\n" + "fun caller() { Service.ping() }\n" + ), + }, + ) + + service_ping = _find(result, ".ping()", "service") + caller = _find(result, "caller()", "use") + file_owner = _find(result, "Use.kt", "use") + pairs = { + (edge["source"], edge["target"]) + for edge in _call_edges(result) + } + assert (caller, service_ping) in pairs + assert (file_owner, service_ping) in pairs + + def test_explicit_this_call_resolves_only_to_its_owner(tmp_path: Path) -> None: result = _extract( tmp_path, From b3da1a387c1b653fb5bc322a1add21db9fc870f9 Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 21 Aug 2026 12:33:29 -0500 Subject: [PATCH 12/12] fix(extract): scope Kotlin enum entry facts --- graphify/extractors/engine.py | 7 +++++- tests/test_kotlin_member_calls.py | 38 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 58dea00ce..293f20b45 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -991,6 +991,7 @@ def _kotlin_initializer_owner_span(property_node, root_node) -> tuple[int, int]: continue if current.type in ( "class_declaration", + "enum_entry", "object_declaration", "object_literal", ): @@ -1318,7 +1319,11 @@ def bind(name: str | None, type_name: str | None) -> None: tables[(body.start_byte, body.end_byte)] = table for member in members: - if member.type in ("class_declaration", "object_declaration"): + if member.type in ( + "class_declaration", + "enum_entry", + "object_declaration", + ): process_owner(member) process_owner(root_node) diff --git a/tests/test_kotlin_member_calls.py b/tests/test_kotlin_member_calls.py index 4851d264d..f5c5192fd 100644 --- a/tests/test_kotlin_member_calls.py +++ b/tests/test_kotlin_member_calls.py @@ -367,6 +367,44 @@ def test_top_level_classifier_remains_a_valid_receiver(tmp_path: Path) -> None: assert (file_owner, service_ping) in pairs +def test_enum_entry_body_uses_its_own_receiver_facts(tmp_path: Path) -> None: + result = _extract( + tmp_path, + { + "Types.kt": ( + "object Service {\n" + " fun ping() {}\n" + "}\n" + "class Other {\n" + " fun ping() {}\n" + "}\n" + ), + "Mode.kt": ( + "enum class Mode {\n" + " ENTRY {\n" + " val Service = Other()\n" + " val initialized = Service.ping()\n" + " fun caller() { Service.ping() }\n" + " }\n" + "}\n" + ), + }, + ) + + entry = _find(result, "ENTRY", "mode") + caller = _find(result, ".caller()", "mode") + service_ping = _find(result, ".ping()", "service") + other_ping = _find(result, ".ping()", "other") + pairs = { + (edge["source"], edge["target"]) + for edge in _call_edges(result) + } + assert (entry, other_ping) in pairs + assert (caller, other_ping) in pairs + assert (entry, service_ping) not in pairs + assert (caller, service_ping) not in pairs + + def test_explicit_this_call_resolves_only_to_its_owner(tmp_path: Path) -> None: result = _extract( tmp_path,