From cfc6a75c8692c9ce739d392910a2dfd1aa831b81 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Sat, 8 Aug 2026 23:37:32 +0100 Subject: [PATCH] fix(extract): TS member-call gating + Kotlin grammar match (#2553, #2552, #2526, #2550, #2551) TypeScript (#2553, #2552, thanks @Earthfreedom): - _resolve_typescript_member_calls matched a receiver type by name alone and emitted EXTRACTED, so a third-party import could bind to an unrelated local class of the same name. It now requires the matched type to be same-file or imported by the caller's file, and tiers table-inferred receivers to INFERRED. - calls inside a callback passed to another call (const h = wrapper(arrow)) were never walked; the callback body is now walked and attributed to the declaration, through the same import-gated resolution so it cannot fabricate edges. The #2553 gate lands with #2552 by design. Kotlin (#2526, #2550, #2551; adapts PR #2531, thanks @Mustaqeem66; reports from @spaceBrownie and @thomasrengot-hub): - match the bundled tree-sitter-kotlin 1.1.0 import node and resolve each import to the real target node (imports were silently dropped), so genuine calls promote to EXTRACTED via import evidence; - a fully-qualified call com.example.Foo.bar() now produces a calls edge; - a file the grammar cannot fully parse (e.g. one-line class C { val x }) now warns instead of silently extracting nothing, and declarations recovered inside an error span keep their enclosing class. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/extract.py | 419 ++++++++++++++++++++++--- graphify/extractors/engine.py | 147 ++++++++- tests/test_js_callback_calls.py | 85 +++++ tests/test_kotlin_grammar.py | 340 ++++++++++++++++++++ tests/test_ts_receiver_member_calls.py | 78 ++++- 5 files changed, 1027 insertions(+), 42 deletions(-) create mode 100644 tests/test_js_callback_calls.py create mode 100644 tests/test_kotlin_grammar.py diff --git a/graphify/extract.py b/graphify/extract.py index b29caec6..2ea5b500 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -606,39 +606,61 @@ def _import_csharp(node, source: bytes, file_nid: str, stem: str, edges: list, s def _import_kotlin(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None: + # Grammar 1.1.0 (PyPI tree_sitter_kotlin) emits an `import` node whose + # children are the `import` keyword and a `qualified_identifier` (the dotted + # path), optionally followed by `.` `*` (wildcard) or `as` + `identifier` + # (alias). There is no `path` field. Older forks emit `import_header` with a + # `path` field or a bare `identifier` child; keep those branches so the + # extractor works across grammar generations (#2526, adapted from PR #2531 + # by @Mustaqeem66). path_node = node.child_by_field_name("path") - if path_node: - raw = _read_text(path_node, source) - module_name = raw.split(".")[-1].strip() - if module_name: - tgt_nid = _make_id(module_name) - edges.append({ - "source": file_nid, - "target": tgt_nid, - "relation": "imports", - "context": "import", - "confidence": "EXTRACTED", - "source_file": str_path, - "source_location": f"L{node.start_point[0] + 1}", - "weight": 1.0, - }) + if path_node is None: + path_node = next( + (c for c in node.children if c.type == "qualified_identifier"), None + ) + if path_node is not None: + raw = _read_text(path_node, source).strip() + else: + raw = next( + (_read_text(c, source).strip() for c in node.children + if c.type == "identifier"), + "", + ) + if not raw: return - # Fallback: find identifier child + # Wildcard (`import a.b.*`): imports a whole package, not a symbol. The last + # path segment is a PACKAGE name, so a symbol-level edge would dangle on (or + # collide with) an unrelated node that happens to share the package's name. + if raw.endswith(".*") or raw == "*" or any(c.type == "*" for c in node.children): + return + # Alias (`import a.b.C as D`): the alias is the identifier child after `as`. + alias = None + saw_as = False for child in node.children: - if child.type == "identifier": - raw = _read_text(child, source) - tgt_nid = _make_id(raw) - edges.append({ - "source": file_nid, - "target": tgt_nid, - "relation": "imports", - "context": "import", - "confidence": "EXTRACTED", - "source_file": str_path, - "source_location": f"L{node.start_point[0] + 1}", - "weight": 1.0, - }) + if not saw_as: + saw_as = child.type == "as" + elif child.type in ("identifier", "simple_identifier"): + alias = _read_text(child, source).strip() or None break + module_name = raw.split(".")[-1].strip() + if not module_name: + return + # Target is the bare last segment for now; _resolve_kotlin_import_targets + # rewrites it to the real node id via the target_fqn stamped here, once the + # per-file package index exists. Unresolved targets stay dangling like other + # languages' external imports. + edges.append({ + "source": file_nid, + "target": _make_id(module_name), + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + "metadata": sanitize_metadata({k: v for k, v in + {"target_fqn": raw, "alias": alias}.items() if v is not None}), + }) def _import_scala(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None: @@ -888,7 +910,9 @@ _KOTLIN_CONFIG = LanguageConfig( ts_module="tree_sitter_kotlin", class_types=frozenset({"class_declaration", "object_declaration"}), function_types=frozenset({"function_declaration"}), - import_types=frozenset({"import_header"}), + # Grammar 1.1.0 (PyPI tree_sitter_kotlin) names the import node `import`; + # older forks use `import_header`. Accept both (#2526). + import_types=frozenset({"import_header", "import"}), call_types=frozenset({"call_expression"}), call_function_field="", call_accessor_node_types=frozenset({"navigation_expression"}), @@ -2551,7 +2575,17 @@ def _resolve_typescript_member_calls( parameter-property modifiers (``private repo: IUserRepository``) produce a per-file type table mapping field names to their declared types. This pass looks up the receiver field's type, finds a single-definition class/interface - owning a method with the callee name, and emits an EXTRACTED ``calls`` edge. + owning a method with the callee name, and emits a ``calls`` edge — EXTRACTED + when the receiver names the type in source (``Type.method()``), INFERRED when + the type came from the table (the Swift/C#/Java tiering). + + Origin gate (#2553): a name-only match is not evidence the caller can even + see the matched type. ``import type { Repo } from 'external-pkg'`` plus + ``this.repo.save()`` must not fabricate an edge to an unrelated local + ``class Repo`` in another file. The matched type must be origin-verified: + defined in the caller's own file, a named import of the caller's file, or + contained in a module the caller's file imports. Otherwise EMIT NOTHING — + a false call edge is worse than a missing one (the C++ resolver's bar). """ type_table_by_file: dict[str, dict[str, str]] = {} for result in per_file: @@ -2582,6 +2616,29 @@ def _resolve_typescript_member_calls( if tnode is not None: method_index[(src, _key(tnode.get("label", "")))] = tgt + # Origin maps (#2553), built like the Python resolver's module arm: key on + # stable NODE ids, not source_file strings (raw_calls keep their original + # pre-relativization paths, so a string join would miss under an explicit + # cache_root). ``contains`` maps a node to its file node; a member call's + # caller is usually a METHOD node, which hangs off its class via a ``method`` + # edge instead, so fold those through to the owning class's file. + file_of_node: dict[str, str] = {} + for e in all_edges: + if e.get("relation") == "contains": + file_of_node[e.get("target")] = e.get("source") + for e in all_edges: + if e.get("relation") == "method": + owner_file = file_of_node.get(e.get("source")) + if owner_file is not None: + file_of_node.setdefault(e.get("target"), owner_file) + # ``imports`` targets are the imported symbol nodes; ``imports_from`` targets + # are module file nodes. A symbol id never collides with a file id, so one + # set serves both origin checks below. + imported_by_filenode: dict[str, set[str]] = {} + for e in all_edges: + if e.get("relation") in ("imports", "imports_from"): + imported_by_filenode.setdefault(e.get("source"), set()).add(e.get("target")) + all_raw_calls: list[dict] = [] for result in per_file: all_raw_calls.extend(result.get("raw_calls", [])) @@ -2597,7 +2654,9 @@ def _resolve_typescript_member_calls( continue if receiver[:1].isupper(): type_name = receiver + type_qualified = True # the receiver names the type in source else: + type_qualified = False type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver) if not type_name: continue @@ -2612,19 +2671,38 @@ def _resolve_typescript_member_calls( if len(type_defs) != 1: continue type_nid = type_defs[0] - method_nid = method_index.get((type_nid, _key(callee))) - target = method_nid or type_nid - relation = "calls" if method_nid else "references" - if target == caller or (caller, target) in existing_pairs: + # Origin gate (#2553): the caller's file must actually see the matched + # type — same file, a named import of the type, or a module import of + # the type's file. Otherwise a third-party type name that happens to + # collide with a local class fabricates an edge; emit nothing. + caller_file = file_of_node.get(caller) + type_file = file_of_node.get(type_nid) + imported = imported_by_filenode.get(caller_file, set()) + if not ( + (caller_file is not None and caller_file == type_file) + or type_nid in imported + or (type_file is not None and type_file in imported) + ): continue - existing_pairs.add((caller, target)) + method_nid = method_index.get((type_nid, _key(callee))) + if not method_nid: + # Receiver typed, but the type has no such method. The old fallback + # (a `references` edge to the type node) was another fabrication + # vector; skip instead, matching the C# resolver. + continue + if method_nid == caller or (caller, method_nid) in existing_pairs: + continue + existing_pairs.add((caller, method_nid)) + # `Type.method()` names the receiver type explicitly in source — + # EXTRACTED; a receiver typed via the constructor-injection/local table + # is inference — INFERRED (the Swift/C#/Java ternary). all_edges.append({ "source": caller, - "target": target, - "relation": relation, + "target": method_nid, + "relation": "calls", "context": "call", - "confidence": "EXTRACTED", - "confidence_score": 1.0, + "confidence": "EXTRACTED" if type_qualified else "INFERRED", + "confidence_score": 1.0 if type_qualified else 0.8, "source_file": rc.get("source_file", ""), "source_location": rc.get("source_location"), "weight": 1.0, @@ -3166,6 +3244,195 @@ def _resolve_objc_member_calls( }) +def _kotlin_package_index(per_file: list[dict]) -> dict[str, list[dict]]: + """Group per-file results by the Kotlin package they declare. + + ``kotlin_package`` is stamped by the generic engine from the file's + ``package_header`` (see extractors/engine.py); every node in the file + inherits it. Files with no package header contribute nothing. + """ + pkg_results: dict[str, list[dict]] = {} + for result in per_file: + pkg = result.get("kotlin_package") + if pkg: + pkg_results.setdefault(pkg, []).append(result) + return pkg_results + + +def _resolve_kotlin_import_targets( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Rewrite Kotlin ``imports`` edge targets from the bare last segment to the + node the written FQN actually names (#2526). + + ``_import_kotlin`` emits ``file --imports--> _make_id(last_segment)`` with + the full dotted path stamped as ``metadata.target_fqn``. That target dangles + (node ids carry a file-stem prefix), so build pruned every Kotlin import and + the import-evidence promotion in the shared call pass never fired. Here the + per-file ``kotlin_package`` declarations index each package's importable + (non-member) symbols by exact label; an edge whose ``target_fqn`` splits + into a known package P plus a Name defined exactly ONCE in P is rewritten to + that node id. The FQN is written verbatim in source, so the match is exact — + confidence stays EXTRACTED. Anything else (external dependency, ambiguous + name) is left untouched and dangles like other languages' external imports. + + Must run BEFORE the shared call pass builds its import-evidence index (it is + invoked directly in extract(), not via the tail registry run). + """ + pkg_results = _kotlin_package_index(per_file) + if not pkg_results: + return + # package fqn -> {importable label -> [node ids]}. Member labels (leading + # dot) are not importable as `P.Name`, and sourceless reference stubs are + # not definitions; both are excluded so they can't shadow the real symbol. + pkg_symbols: dict[str, dict[str, list[str]]] = {} + for pkg, results in pkg_results.items(): + by_label = pkg_symbols.setdefault(pkg, {}) + for result in results: + for n in result.get("nodes", []): + if not n.get("source_file") or n.get("type") == "namespace": + continue + label = str(n.get("label", "")) + if not label or label.startswith("."): + continue + by_label.setdefault(label.strip("()"), []).append(n["id"]) + for e in all_edges: + if e.get("relation") != "imports": + continue + if not str(e.get("source_file", "")).endswith((".kt", ".kts")): + continue + fqn = (e.get("metadata") or {}).get("target_fqn", "") + pkg, _, name = str(fqn).rpartition(".") + if not pkg or not name: + continue + candidates = pkg_symbols.get(pkg, {}).get(name, []) + if len(candidates) == 1: # single-candidate guard: never fabricate + e["target"] = candidates[0] + + +def _resolve_kotlin_qualified_calls( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Resolve Kotlin fully-qualified call expressions (#2550). + + ``com.example.nav.NavGraph()`` parses to a nested navigation_expression + chain; the engine flattens it and stamps the raw_call with + ``qualified_prefix="com.example.nav"`` + ``lang="kotlin"`` when EVERY chain + segment is a plain identifier. The shared pass skips member calls, so these + raw_calls produced no edge at all — this pass is strictly additive. + + Resolution, guarded by exactly-one-candidate at every step: + * prefix == a declared package FQN P -> candidates are P's top-level + callables (functions/classes the file node `contains`) named callee; + * prefix == P + "." + TypeName where TypeName is a class/object declared + in P -> candidates are that type's methods (`method` edges, `.callee()` + label). + Zero or 2+ candidates -> no edge. The FQN is written verbatim in source, so + a unique match is EXTRACTED. + """ + pkg_results = _kotlin_package_index(per_file) + if not pkg_results: + return + raw = [ + rc + for result in per_file + for rc in result.get("raw_calls", []) + if rc.get("lang") == "kotlin" and rc.get("qualified_prefix") + and rc.get("callee") and rc.get("caller_nid") + ] + if not raw: + return + + node_by_id: dict[str, dict] = {n.get("id"): n for n in all_nodes} + contains_by_source: dict[str, list[str]] = {} + methods_by_type: dict[str, list[str]] = {} + for e in all_edges: + rel = e.get("relation") + if rel == "contains": + contains_by_source.setdefault(e.get("source"), []).append(e.get("target")) + elif rel == "method": + methods_by_type.setdefault(e.get("source"), []).append(e.get("target")) + + # package fqn -> {name -> [top-level callable nids]} and + # package fqn -> {name -> [top-level type nids]} (classes/objects). + pkg_callables: dict[str, dict[str, list[str]]] = {} + pkg_types: dict[str, dict[str, list[str]]] = {} + for pkg, results in pkg_results.items(): + callables = pkg_callables.setdefault(pkg, {}) + types = pkg_types.setdefault(pkg, {}) + for result in results: + file_nid = next( + (n["id"] for n in result.get("nodes", []) + if n.get("source_file") + and n.get("label") == Path(str(n["source_file"])).name), + None, + ) + if file_nid is None: + continue + for tgt in contains_by_source.get(file_nid, []): + n = node_by_id.get(tgt) + if n is None or not n.get("source_file"): + continue + name = str(n.get("label", "")).strip("()") + if not name or name.startswith("."): + continue + if n.get("_callable"): + callables.setdefault(name, []).append(tgt) + if n.get("_callable_class"): + types.setdefault(name, []).append(tgt) + + existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges} + for rc in raw: + prefix = rc["qualified_prefix"] + callee = rc["callee"] + caller = rc["caller_nid"] + candidates: list[str] = [] + if prefix in pkg_callables: + # `P.callee()` — a top-level function or class constructor in P. + candidates = pkg_callables[prefix].get(callee, []) + else: + # `P.Type.callee()` — a method of a class/object declared in P. + pkg, _, type_name = prefix.rpartition(".") + type_nids = pkg_types.get(pkg, {}).get(type_name, []) if pkg else [] + if len(type_nids) == 1: + wanted = f".{callee}" + candidates = [ + m for m in methods_by_type.get(type_nids[0], []) + if str(node_by_id.get(m, {}).get("label", "")).strip("()") == wanted + ] + if len(candidates) != 1: # zero or ambiguous -> no edge (god-node guard) + continue + tgt = candidates[0] + if tgt == caller or (caller, tgt) in existing_pairs: + continue + existing_pairs.add((caller, tgt)) + all_edges.append({ + "source": caller, + "target": tgt, + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED", # the FQN is written verbatim in source + "confidence_score": 1.0, + "source_file": rc.get("source_file", ""), + "source_location": rc.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. +# It still uses the registry's LanguageResolver/driver for the suffix gate and +# failure isolation. +_KOTLIN_IMPORT_TARGET_RESOLVER = LanguageResolver( + "kotlin_import_targets", frozenset({".kt", ".kts"}), _resolve_kotlin_import_targets +) + + # Register the cross-file, language-specific member-call resolvers into the shared # registry (framework lives in graphify.resolver_registry). A new language plugs in # by adding one register() call below — no edits to extract()'s body. Order @@ -3222,6 +3489,15 @@ register_language_resolver( resolve_pascal_inherited_calls, ) ) +# Kotlin fully-qualified call resolution (#2550): `com.pkg.Fn()` / +# `com.pkg.Object.method()` raw_calls the shared pass skips (member calls with +# no receiver). Runs in the tail registry like the other member-call resolvers; +# its sibling import-target pass runs earlier (see _KOTLIN_IMPORT_TARGET_RESOLVER). +register_language_resolver( + LanguageResolver( + "kotlin_qualified_calls", frozenset({".kt", ".kts"}), _resolve_kotlin_qualified_calls + ) +) # Inline markdown link: [text](target "optional title"). The negative lookbehind @@ -4874,6 +5150,28 @@ def extract( file=sys.stderr, flush=True, ) + # #2543: collect sources that must NOT be stamped as up-to-date in the + # incremental manifest. Two cases: + # - extractor returned an error (missing optional extra, parse failure, …) + # - extractor exists but produced zero nodes (#1666 empty-source set) + # The CLI drops these from the stamped file set and clears any prior + # hashes so the next run retries them after the user installs the extra + # (or the transient failure self-heals) without deleting graphify-out/. + _failed_sources: list[str] = [] + _failed_seen: set[str] = set() + for i, _p in enumerate(paths): + _res = per_file[i] or {} + _key = str(_p) + if _res.get("error"): + if _key not in _failed_seen: + _failed_sources.append(_key) + _failed_seen.add(_key) + continue + if (not _res.get("nodes")) and _get_extractor(_p) is not None: + if _key not in _failed_seen: + _failed_sources.append(_key) + _failed_seen.add(_key) + # #1689: a file counted as code (extension in CODE_EXTENSIONS) but with no AST # extractor wired up (e.g. .r/.R — there is no tree-sitter-r dispatch) silently # contributes zero nodes. The #1666 warning above deliberately skips these (it @@ -4926,6 +5224,32 @@ def extract( file=sys.stderr, flush=True, ) + # #2551: a file the parser ACCEPTED but only with ERROR recovery (e.g. the + # Kotlin grammar rejecting one-line `class C { val x }` bodies, or Luau + # syntax the Lua grammar can't parse, #2520) extracts partially — sometimes + # to nothing but the file node — with no other signal. Neither warning + # above fires (nodes exist, no error marker), so surface it explicitly, + # naming the first error line so the user can find the construct. + _syntax_error_files: list[tuple[str, int | None]] = [] + for i, _p in enumerate(paths): + _pe = (per_file[i] or {}).get("parse_errors") + if _pe: + _syntax_error_files.append((str(_p), _pe.get("first_error_line"))) + if _syntax_error_files: + _shown = ", ".join( + f"{Path(x).name} (first error at line {ln})" if ln else Path(x).name + for x, ln in _syntax_error_files[:5] + ) + _more = ( + f" (+{len(_syntax_error_files) - 5} more)" + if len(_syntax_error_files) > 5 else "" + ) + print( + f" warning: {len(_syntax_error_files)} file(s) had syntax errors and " + f"may be partially extracted: {_shown}{_more} (#2551)", + file=sys.stderr, flush=True, + ) + all_nodes: list[dict] = [] all_edges: list[dict] = [] all_raw_calls: list[dict] = [] @@ -5533,6 +5857,17 @@ def extract( # them from the indirect_call guard below to avoid false edges (#2137). class_nids = {n["id"] for n in resolution_nodes if n.get("_callable_class")} + # Kotlin import targets (#2526): rewrite each `imports` edge from the bare + # last-segment id to the node its written FQN names, via the per-file + # package declarations. Runs HERE — after the id-remap/disambiguation passes + # (ids are final) but before the import-evidence index just below reads the + # edges — so genuine imported calls get promoted INFERRED -> EXTRACTED. The + # tail registry run (run_language_resolvers below) would be too late. + run_language_resolvers( + paths, per_file, all_nodes, all_edges, + resolvers=[_KOTLIN_IMPORT_TARGET_RESOLVER], + ) + # Build evidence index from import edges so cross-file calls backed by an # explicit import statement can be promoted from INFERRED to EXTRACTED. # Direct symbol imports (`import { foo }` / `const { foo } = require()`) are @@ -5963,6 +6298,10 @@ def extract( "edges": all_edges, "input_tokens": 0, "output_tokens": 0, + # Surfaces failed/empty AST sources to the CLI so the incremental + # manifest does not freeze them as processed (#2543). Callers that + # only read nodes/edges ignore this key. + "failed_sources": _failed_sources, } diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index e19f8fe6..1baaca83 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -1781,6 +1781,16 @@ def _require_imports_js(node, source: bytes, file_nid: str, stem: str, edges: li _JS_FUNCTION_VALUE_TYPES = frozenset({"arrow_function", "function_expression", "function", "generator_function"}) +def _js_topmost_closures(node, out: list) -> None: + """Collect the TOPMOST closure nodes (arrow / function expressions) under + ``node``, without descending into a found closure — its nested closures + belong to it and are reached by the walk_calls closure descend (#1630).""" + for c in node.children: + if c.type in _JS_FUNCTION_VALUE_TYPES: + out.append(c) + else: + _js_topmost_closures(c, out) + def _js_member_assignment_target(left, source: bytes): """Classify the symbol an `assignment_expression` LHS defines when its RHS is a function. Returns (kind, owner_name, member_name) or None. @@ -1952,7 +1962,8 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, elif value and ( is_exported_scalar_binding or value.type in ( - "object", "array", "as_expression", "call_expression", + "object", "array", "as_expression", + "satisfies_expression", "call_expression", "new_expression", ) ): @@ -1965,6 +1976,33 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, add_node_fn(const_nid, const_name, line) add_edge_fn(file_nid, const_nid, "contains", line) const_found = True + # #2552: `const handler = wrapper(async (req) => …)` + # created the const node above but, unlike the arrow + # branch, never tracked the callback's body — so + # walk_calls never descended into it and its calls + # were dropped. Track each TOPMOST closure in the + # initializer under the const's nid; nested closures + # are reached by the #1630 closure descend with the + # same caller, so appending them too would + # double-walk. `_tracked_body_ids` picks these up, + # so the descend skips them (no double-count). + inner = value + while inner is not None and inner.type in ( + "as_expression", "satisfies_expression"): + inner = (inner.named_children[0] + if inner.named_children else None) + if inner is not None and inner.type in ( + "call_expression", "new_expression"): + closures: list = [] + _js_topmost_closures(inner, closures) + for closure in closures: + if local_bound_names is not None: + local_bound_names[const_nid] = ( + local_bound_names.get(const_nid, set()) + | _js_local_bound_names(closure, source)) + body = closure.child_by_field_name("body") + if body: + function_bodies.append((const_nid, body)) if arrow_found: return True if const_found: @@ -2158,6 +2196,70 @@ def _kotlin_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: return False +def _kotlin_package_name(root, source: bytes) -> str | None: + """Dotted package FQN from the file's ``package_header``, or None. + + Grammar 1.1.0 puts the path in a ``qualified_identifier`` child; older + forks use an ``identifier`` that spans the whole dotted text. Either way + the node's text IS the FQN. + """ + for child in root.children: + if child.type != "package_header": + continue + for c in child.children: + if c.type in ("qualified_identifier", "identifier"): + pkg = _read_text(c, source).strip() + return pkg or None + return None + return None + + +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]). + + Returns None when any segment is not a plain identifier — a receiver that + is an expression, a call, ``this``, a string literal, etc. must never read + as a qualified name (#2550). Older grammars with a different navigation + shape also bail here, preserving their current behavior. + """ + segments: list[str] = [] + node = nav + while node is not None and node.type == "navigation_expression": + named = [c for c in node.children if c.is_named] + # Grammar 1.1.0 shape: "." (the dot is unnamed). + if len(named) != 2: + return None + head, tail = named + if tail.type not in ("simple_identifier", "identifier"): + return None + segments.append(_read_text(tail, source)) + node = head + if node is None or node.type not in ("simple_identifier", "identifier"): + return None + segments.append(_read_text(node, source)) + segments.reverse() + return segments + + +def _first_parse_error_line(root) -> int: + """1-based line of the first ERROR/MISSING node under ``root`` (#2551). + + Descends the first erroring child at each level (document order), so it + lands on the earliest error region. Some recoveries set ``has_error`` + without materializing an ERROR/MISSING child (zero-width recovery); the + deepest still-erroring node's line is reported for those. + """ + node = root + while True: + if node.type == "ERROR" or node.is_missing: + return node.start_point[0] + 1 + child = next((c for c in node.children if c.has_error), None) + if child is None: + return node.start_point[0] + 1 + node = child + + def _read_csharp_type_name(node, source: bytes) -> tuple[str, bool, str] | None: """Resolve a C# type name, whether it was qualified, and its qualifier prefix.""" if node is None: @@ -4015,6 +4117,17 @@ def _extract_generic( walk(child, parent_class_nid=parent_class_nid) return + # #2551: tree-sitter ERROR recovery can wrap declarations that plainly + # sit inside a class body (e.g. the Kotlin grammar choking on a one-line + # sibling member). The default recurse below deliberately drops + # parent_class_nid (an unknown wrapper usually IS a scope boundary), but + # an ERROR node is a parse artifact, not a scope — keep the enclosing + # class linkage for whatever declarations were recovered inside it. + if t == "ERROR": + for child in node.children: + walk(child, parent_class_nid=parent_class_nid) + return + # Default: recurse for child in node.children: walk(child, parent_class_nid=None) @@ -4259,6 +4372,7 @@ def _extract_generic( is_this_field_call: bool = False swift_receiver: str | None = None member_receiver: str | None = None + kotlin_qualified_prefix: str | None = None # Special handling per language if config.ts_module == "tree_sitter_swift": @@ -4294,6 +4408,19 @@ def _extract_generic( if child.type in ("simple_identifier", "identifier"): callee_name = _read_text(child, source) break + # #2550: `com.example.Foo.bar()` is a NESTED + # navigation_expression chain; the last identifier alone + # (`bar`) rarely matches in-file, so the call was dropped + # (the shared cross-file pass skips member calls). When + # EVERY chain segment is a plain identifier and there are + # >= 3 (a real dotted FQN, not `recv.method()`), stamp the + # dotted prefix for _resolve_kotlin_qualified_calls. + # member_receiver is deliberately NOT set: an uppercase + # receiver would trip the capitalized-receiver deferral + # below and regress in-file `Foo.bar()` resolution. + segments = _kotlin_nav_identifier_segments(first, source) + if segments is not None and len(segments) >= 3: + kotlin_qualified_prefix = ".".join(segments[:-1]) elif config.ts_module == "tree_sitter_scala": # Scala: first child first = node.children[0] if node.children else None @@ -4581,6 +4708,11 @@ def _extract_generic( 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: + rc_entry["lang"] = "kotlin" + rc_entry["qualified_prefix"] = kotlin_qualified_prefix raw_calls.append(rc_entry) # Indirect dispatch: a function passed BY NAME as a call argument @@ -4922,6 +5054,19 @@ def _extract_generic( if _ruby_mixin_calls: raw_calls.extend(_ruby_mixin_calls) result = {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls} + # #2551: the parser recovered from syntax errors, so extraction may be + # partial (in the worst case, nothing but the file node). Record the first + # error's line so extract() can warn instead of reporting silent success. + # Rides on the result dict, so it survives the per-file AST cache. + if root.has_error: + result["parse_errors"] = {"first_error_line": _first_parse_error_line(root)} + # Kotlin (#2526/#2550): the declared package qualifies every node in the + # 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 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/tests/test_js_callback_calls.py b/tests/test_js_callback_calls.py new file mode 100644 index 00000000..d91e9db1 --- /dev/null +++ b/tests/test_js_callback_calls.py @@ -0,0 +1,85 @@ +"""Calls inside a callback passed to a module-level call must not be dropped (#2552). + +`export const handler = wrapper(async (req) => { helperA(); })` has a +`call_expression` initializer, so `_js_extra_walk` took the const-literal branch +and never tracked the callback's body — `walk_calls` never descended into it and +the `helperA()` call was lost. The fix tracks each TOPMOST closure in such an +initializer under the const's nid, so its calls flow through the normal +machinery (import-evidence gate included). + +The composition test guards the #2552/#2553 coupling: the newly-walked callback +body feeds member calls into `_resolve_typescript_member_calls`, whose origin +gate (#2553) must keep a third-party-typed receiver from fabricating an edge to +an unrelated local class. +""" +from __future__ import annotations + +from graphify.extract import extract + +_HELPERS = "export function helperA(): number { return 1; }\n" + + +def _extract(tmp_path, files: dict[str, str]): + for name, body in files.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + r = extract([tmp_path / n for n in files], + cache_root=tmp_path / "graphify-out", parallel=False) + lbl = {n["id"]: n["label"] for n in r["nodes"]} + calls = {(lbl.get(e["source"]), lbl.get(e["target"])) for e in r["edges"] + if e["relation"] == "calls"} + return calls, lbl, r + + +_HANDLER = ("import { helperA } from './helpers';\n" + "function wrapper(fn: (req: unknown) => Promise) { return fn; }\n" + "export const handler = wrapper(async (req) => { return helperA(); });\n" + "export function control(): number { return helperA(); }\n") + + +def test_callback_body_calls_are_captured(tmp_path): + calls, _, _ = _extract(tmp_path, { + "helpers.ts": _HELPERS, + "handler.ts": _HANDLER, + }) + assert ("handler", "helperA()") in calls, \ + f"callback body call dropped; calls={sorted(calls)}" + # control: a plain named-function caller in the same file is unaffected + assert ("control()", "helperA()") in calls + + +def test_callback_body_call_is_not_double_counted(tmp_path): + _, lbl, r = _extract(tmp_path, { + "helpers.ts": _HELPERS, + "handler.ts": _HANDLER, + }) + n = sum(1 for e in r["edges"] + if e["relation"] == "calls" + and lbl.get(e["source"]) == "handler" + and lbl.get(e["target"]) == "helperA()") + assert n == 1, f"expected exactly one handler -> helperA calls edge, got {n}" + + +def test_callback_member_call_is_origin_gated(tmp_path): + # Coupling guard: #2552 makes the callback body visible to the member-call + # resolver; #2553's origin gate must then block the name-only `Repo` match + # (third-party type) while the import-evidenced helperA() call resolves. + calls, lbl, r = _extract(tmp_path, { + "helpers.ts": _HELPERS, + "fileb.ts": "export class Repo {\n save(): void {}\n}\n", + "h.ts": ("import { helperA } from './helpers';\n" + "import type { Repo } from 'external-pkg';\n" + "function wrapper(fn: (repo: Repo) => void) { return fn; }\n" + "export const h = wrapper((repo: Repo) => " + "{ repo.save(); helperA(); });\n"), + }) + assert ("h", "helperA()") in calls, \ + f"import-evidenced callback call must resolve; calls={sorted(calls)}" + sf = {n["id"]: str(n.get("source_file", "")) for n in r["nodes"]} + fabricated = [e for e in r["edges"] + if e["relation"] in ("calls", "references", "indirect_call") + and sf.get(e["source"], "").endswith("h.ts") + and sf.get(e["target"], "").endswith("fileb.ts")] + assert not fabricated, \ + f"third-party-typed receiver fabricated edge(s) to local Repo: {fabricated}" diff --git a/tests/test_kotlin_grammar.py b/tests/test_kotlin_grammar.py new file mode 100644 index 00000000..fc54c796 --- /dev/null +++ b/tests/test_kotlin_grammar.py @@ -0,0 +1,340 @@ +"""Kotlin grammar-node-type mismatches (#2526, #2550, #2551). + +PyPI tree-sitter-kotlin 1.x renamed/reshaped several nodes relative to the +older forks the extractor was written against: + +* #2526 — imports are `import` nodes (an `import` keyword + a + `qualified_identifier`; no `path` field), so every Kotlin import edge was + silently dropped. Fixed by accepting the new node shape (adapted from PR + #2531 by @Mustaqeem66) and resolving the written FQN to the real node via + the per-file `package_header` declarations, which also unlocks the + INFERRED -> EXTRACTED import-evidence promotion. +* #2550 — `com.example.Foo.bar()` parses to a NESTED navigation_expression + chain; only the last identifier was kept, the receiver was never captured, + and the raw_call died in the member-call skip. Fixed by flattening + all-identifier chains into a `qualified_prefix` resolved against the + declared packages (exactly-one-candidate guarded). +* #2551 — the grammar rejects one-line `class C { val x }` bodies; consecutive + one-liners can dissolve the whole file's parse. Graphify now warns on any + file extracted through ERROR recovery (language-agnostic, also #2520) and + keeps class linkage for declarations recovered inside an ERROR span. +""" +from __future__ import annotations + +import os +import re +from pathlib import Path + +from graphify.extract import extract + + +def _extract(tmp_path, files: dict[str, str]): + for name, body in files.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + old = os.getcwd() + try: + os.chdir(tmp_path) + r = extract([Path(n) for n in files], + cache_root=tmp_path / ".cache", parallel=False) + finally: + os.chdir(old) + return r + + +def _edges(r, relation): + return {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == relation} + + +def _find(r, label, id_contains=""): + return next(n["id"] for n in r["nodes"] + if n["label"] == label and id_contains in n["id"]) + + +# ── #2526: import edges ─────────────────────────────────────────────────────── + +_IMPORT_CORPUS = { + "model/Money.kt": ( + "package com.demo.model\n" + "\n" + "class Money(val amount: Int)\n" + ), + "model/Ledger.kt": ( + "package com.demo.model\n" + "\n" + "class Ledger {\n" + " fun record(m: Money) { }\n" + "}\n" + ), + "app/Main.kt": ( + "package com.demo.app\n" + "\n" + "import com.demo.model.Money\n" + "import com.demo.model.Ledger\n" + "\n" + "fun main() {\n" + " val m = Money(5)\n" + " val l = Ledger()\n" + "}\n" + ), +} + + +def test_kotlin_imports_resolve_to_real_nodes(tmp_path): + r = _extract(tmp_path, _IMPORT_CORPUS) + node_ids = {n["id"] for n in r["nodes"]} + main_file = _find(r, "Main.kt") + money = _find(r, "Money") + ledger = _find(r, "Ledger") + imports = _edges(r, "imports") + assert (main_file, money) in imports + assert (main_file, ledger) in imports + # Every Kotlin import edge points at an EXISTING node — the dangling + # bare-last-segment target ("money") would be pruned by build. + kotlin_imports = [e for e in r["edges"] if e["relation"] == "imports" + and str(e.get("source_file", "")).endswith(".kt")] + assert len(kotlin_imports) >= 2 + for e in kotlin_imports: + assert e["target"] in node_ids, f"import target {e['target']} dangles" + + +def test_kotlin_import_evidence_promotes_calls_to_extracted(tmp_path): + r = _extract(tmp_path, _IMPORT_CORPUS) + main_fn = _find(r, "main()") + money = _find(r, "Money") + call = next(e for e in r["edges"] if e["relation"] == "calls" + and e["source"] == main_fn and e["target"] == money) + assert call["confidence"] == "EXTRACTED", \ + "an explicitly-imported cross-file call must be promoted to EXTRACTED" + + +def test_kotlin_wildcard_import_emits_no_symbol_edge(tmp_path): + r = _extract(tmp_path, { + **{k: v for k, v in _IMPORT_CORPUS.items() if k != "app/Main.kt"}, + "app/Main.kt": ( + "package com.demo.app\n" + "\n" + "import com.demo.model.*\n" + "\n" + "fun main() { }\n" + ), + }) + main_file = _find(r, "Main.kt") + bad_targets = {"model", "*", ""} + for e in r["edges"]: + if e["relation"] == "imports" and e["source"] == main_file: + assert e["target"] not in bad_targets, \ + "a wildcard import names a PACKAGE; a symbol-level edge to the " \ + "last segment is a phantom" + + +def test_kotlin_aliased_import_resolves_to_original_symbol(tmp_path): + r = _extract(tmp_path, { + **{k: v for k, v in _IMPORT_CORPUS.items() if k != "app/Main.kt"}, + "app/Main.kt": ( + "package com.demo.app\n" + "\n" + "import com.demo.model.Money as Cash\n" + "\n" + "fun main() {\n" + " val m = Cash(5)\n" + "}\n" + ), + }) + main_file = _find(r, "Main.kt") + money = _find(r, "Money") + assert (main_file, money) in _edges(r, "imports"), \ + "`import a.b.C as D` still imports C — the alias is caller-local" + alias_edge = next(e for e in r["edges"] if e["relation"] == "imports" + and e["source"] == main_file and e["target"] == money) + meta = alias_edge.get("metadata") or {} + assert meta.get("target_fqn") == "com.demo.model.Money" + assert meta.get("alias") == "Cash" + + +# ── #2550: fully-qualified call expressions ────────────────────────────────── + +_FQ_CORPUS = { + "lib/Lib.kt": ( + "package com.demo.lib\n" + "\n" + "fun BetaScreen() { }\n" + "\n" + "object Help {\n" + " fun help() { }\n" + "}\n" + ), + "feature/Feature.kt": ( + "package com.demo.feature\n" + "\n" + "fun DeltaScreen() { }\n" + "\n" + "fun SamePackageCaller() {\n" + " com.demo.feature.DeltaScreen()\n" + "}\n" + ), + "nav/Nav.kt": ( + "package com.demo.nav\n" + "\n" + "fun NavGraph() {\n" + " com.demo.lib.BetaScreen()\n" + " com.demo.feature.DeltaScreen()\n" + " com.demo.lib.Help.help()\n" + " com.nonexistent.pkg.Thing()\n" + "}\n" + ), +} + + +def test_kotlin_fully_qualified_calls_resolve(tmp_path): + r = _extract(tmp_path, _FQ_CORPUS) + calls = _edges(r, "calls") + navgraph = _find(r, "NavGraph()") + beta = _find(r, "BetaScreen()") + delta = _find(r, "DeltaScreen()") + same_pkg = _find(r, "SamePackageCaller()") + help_fn = _find(r, ".help()") + assert (navgraph, beta) in calls + assert (navgraph, delta) in calls + assert (same_pkg, delta) in calls + assert (navgraph, help_fn) in calls, \ + "`com.demo.lib.Help.help()` must resolve through the object declaration" + fq_calls = [e for e in r["edges"] if e["relation"] == "calls" + and e["source"] == navgraph] + assert all(e["confidence"] == "EXTRACTED" for e in fq_calls), \ + "the FQN is written verbatim in source: exact match, EXTRACTED" + + +def test_kotlin_fq_call_to_unknown_package_yields_no_edge(tmp_path): + r = _extract(tmp_path, _FQ_CORPUS) + navgraph = _find(r, "NavGraph()") + targets = {t for s, t in _edges(r, "calls") if s == navgraph} + assert not any("thing" in t.lower() for t in targets), \ + "`com.nonexistent.pkg.Thing()` is external — no edge, no fabricated node" + + +def test_kotlin_fq_call_to_ambiguous_name_yields_no_edge(tmp_path): + r = _extract(tmp_path, { + "dup1/D1.kt": ( + "package com.demo.dup\n" + "\n" + "fun Same() { }\n" + ), + "dup2/D2.kt": ( + "package com.demo.dup\n" + "\n" + "fun Same() { }\n" + ), + "callr/Caller.kt": ( + "package com.demo.callr\n" + "\n" + "fun Caller() {\n" + " com.demo.dup.Same()\n" + "}\n" + ), + }) + caller = _find(r, "Caller()") + assert not {t for s, t in _edges(r, "calls") if s == caller}, \ + "`Same` is defined twice in com.demo.dup — the exactly-one-candidate " \ + "guard must refuse to pick" + + +# ── #2551: one-line type bodies + ERROR recovery ───────────────────────────── + +def test_kotlin_partial_parse_warns_with_file_and_line(tmp_path, capsys): + # Consecutive one-line class bodies dissolve the whole file's parse in + # tree-sitter-kotlin 1.x; graphify must say so instead of silently + # returning a near-empty result. + files = { + "Broken.kt": ( + "class A { val v: Money = Money(5) }\n" + "class B { val w: Ledger = Ledger() }\n" + "fun Top() { }\n" + ), + } + _extract(tmp_path, files) + err = capsys.readouterr().err + assert "syntax errors" in err + assert "Broken.kt" in err + assert re.search(r"first error at line \d+", err) + # The marker must survive the per-file AST cache: a warm re-run (same + # cache_root) warns again. + old = os.getcwd() + try: + os.chdir(tmp_path) + extract([Path("Broken.kt")], cache_root=tmp_path / ".cache", parallel=False) + finally: + os.chdir(old) + err = capsys.readouterr().err + assert "syntax errors" in err and "Broken.kt" in err + + +def test_kotlin_one_line_class_with_fun_still_extracts(tmp_path, capsys): + # `class VM { fun f() = 1 }` trips has_error but recovers structurally: + # everything must extract, and the warning names the file. + r = _extract(tmp_path, { + "VM.kt": ( + "class VM { fun f() = 1 }\n" + "fun After() { }\n" + ), + }) + vm = _find(r, "VM") + f = _find(r, ".f()") + _find(r, "After()") # present + assert (vm, f) in _edges(r, "method") + assert "VM.kt" in capsys.readouterr().err + + +def test_kotlin_one_line_class_keeps_field_reference(tmp_path): + # ERROR parent-link guard: the one-line body's property must keep its + # enclosing class, so the field-type reference lands on C. + r = _extract(tmp_path, { + "C.kt": "class C { val v: Money = Money(5) }\n", + }) + c = _find(r, "C") + money = _find(r, "Money") + field_refs = {(e["source"], e["target"]) for e in r["edges"] + if e["relation"] == "references" and e.get("context") == "field"} + assert (c, money) in field_refs + + +# ── keep-the-bar: multi-line Kotlin is byte-identical ──────────────────────── + +def test_multiline_kotlin_unchanged(tmp_path, capsys): + """Golden guard: ordinary multi-line Kotlin produces the same nodes/edges + as before — the #2526/#2550/#2551 handling is purely additive — and no + partial-parse warning fires.""" + r = _extract(tmp_path, { + "Shop.kt": ( + "package com.shop\n" + "\n" + "class Cart {\n" + " val items: Inventory = Inventory()\n" + " fun checkout() {\n" + " total()\n" + " }\n" + " fun total() { }\n" + "}\n" + "\n" + "class Inventory\n" + "\n" + "fun main() {\n" + " Cart().checkout()\n" + "}\n" + ), + }) + labels = {n["label"] for n in r["nodes"]} + assert {"Shop.kt", "Cart", "Inventory", ".checkout()", ".total()", + "main()"} <= labels + cart = _find(r, "Cart") + checkout = _find(r, ".checkout()") + total = _find(r, ".total()") + methods = _edges(r, "method") + assert (cart, checkout) in methods and (cart, total) in methods + assert (checkout, total) in _edges(r, "calls") + inv = _find(r, "Inventory") + field_refs = {(e["source"], e["target"]) for e in r["edges"] + if e["relation"] == "references" and e.get("context") == "field"} + assert (cart, inv) in field_refs + assert "syntax errors" not in capsys.readouterr().err diff --git a/tests/test_ts_receiver_member_calls.py b/tests/test_ts_receiver_member_calls.py index c7c2c943..682f95b0 100644 --- a/tests/test_ts_receiver_member_calls.py +++ b/tests/test_ts_receiver_member_calls.py @@ -25,7 +25,8 @@ def _calls(tmp_path, files: dict[str, str]): p.parent.mkdir(parents=True, exist_ok=True) p.write_text(body) # Real-CLI shape: absolute input paths + a graphify-out cache subdir. - r = extract([tmp_path / n for n in files], cache_root=tmp_path / "graphify-out") + r = extract([tmp_path / n for n in files], + cache_root=tmp_path / "graphify-out", parallel=False) lbl = {n["id"]: n["label"] for n in r["nodes"]} return {(lbl.get(e["source"]), lbl.get(e["target"])) for e in r["edges"] if e["relation"] == "calls"}, r @@ -79,3 +80,78 @@ def test_array_typed_receiver_emits_no_edge(tmp_path): "export function h(xs: Svc[]): number { return xs[0].doThing(); }\n"), }) assert not any("h(" in s and "doThing" in t for s, t in calls) + + +# ── Origin gate (#2553) ────────────────────────────────────────────────────── +# A receiver typed as a THIRD-PARTY `Repo` must never bind, by name alone, to an +# unrelated local `class Repo` the caller's file neither defines nor imports. + +_LOCAL_REPO = "export class Repo {\n save(): void {}\n static staticSave(): void {}\n}\n" + + +def _cross_file_edges(r, src_file: str, tgt_file: str): + """Edges (any relation) whose source node lives in src_file and target in tgt_file.""" + sf = {n["id"]: str(n.get("source_file", "")) for n in r["nodes"]} + # method nodes carry their own source_file; fall back to it for both ends + return [e for e in r["edges"] + if sf.get(e["source"], "").endswith(src_file) + and sf.get(e["target"], "").endswith(tgt_file)] + + +def test_third_party_type_does_not_fabricate_edge_to_local_class(tmp_path): + _, r = _calls(tmp_path, { + "fileb.ts": _LOCAL_REPO, + "filea.ts": ("import type { Repo } from 'external-pkg';\n" + "export class ReportService {\n" + " constructor(private repo: Repo) {}\n" + " run(): void { this.repo.save(); }\n" + "}\n"), + }) + bad = [e for e in _cross_file_edges(r, "filea.ts", "fileb.ts") + if e["relation"] in ("calls", "references", "indirect_call")] + assert not bad, f"fabricated cross-file edge(s) to un-imported local Repo: {bad}" + + +def test_genuinely_imported_type_still_resolves_inferred(tmp_path): + _, r = _calls(tmp_path, { + "fileb.ts": _LOCAL_REPO, + "filea.ts": ('import { Repo } from "./fileb";\n' + "export class ReportService {\n" + " constructor(private repo: Repo) {}\n" + " run(): void { this.repo.save(); }\n" + "}\n"), + }) + lbl = {n["id"]: n["label"] for n in r["nodes"]} + hits = [e for e in r["edges"] + if e["relation"] == "calls" + and "run" in lbl.get(e["source"], "") and "save" in lbl.get(e["target"], "")] + assert hits, "imported receiver type must still resolve" + # table-inferred receiver -> INFERRED (Swift/C#/Java tiering parity) + assert all(e["confidence"] == "INFERRED" for e in hits) + + +def test_source_qualified_static_call_is_extracted(tmp_path): + _, r = _calls(tmp_path, { + "fileb.ts": _LOCAL_REPO, + "filea.ts": ('import { Repo } from "./fileb";\n' + "export class ReportService {\n" + " constructor(private repo: Repo) {}\n" + " run(): void { Repo.staticSave(); }\n" + "}\n"), + }) + lbl = {n["id"]: n["label"] for n in r["nodes"]} + hits = [e for e in r["edges"] + if e["relation"] == "calls" + and "run" in lbl.get(e["source"], "") + and "staticSave" in lbl.get(e["target"], "")] + assert hits, "source-qualified Repo.staticSave() must resolve" + assert all(e["confidence"] == "EXTRACTED" for e in hits) + + +def test_same_file_type_still_resolves(tmp_path): + calls, _ = _calls(tmp_path, { + "one.ts": (_LOCAL_REPO + + "const r = new Repo();\n" + "export function runLocal(): void { r.save(); }\n"), + }) + assert any("runLocal" in s and "save" in t for s, t in calls)