fix(extract): keep constructor calls that hit a same-file annotation stub

When a class is only referenced in a type annotation (Python) or a generic
argument (C#) earlier in the file, that reference mints a source-less stub
with the class name. A later `Parser()` / `new Widget()` in the same file
found the stub in the local lookup, was recognised as an external stub, and
was dropped: it was neither emitted as a local edge nor queued for
cross-file resolution. Since 0.9.62 these constructor edges were missing.

Queue those calls for cross-file resolution as well. That pass already
refuses source-less targets (#3156), so the stub itself still never becomes
a calls hub.

These calls did not bind to anything before, so a rescued call is marked
and only binds with evidence: an import of the target, or for C# the
namespace / `using` scope via CsharpNameResolver. Without that, a file that
imports `com.lib.Client` or uses `System.Collections.Generic` would bind
`new Client()` / `new List<int>()` to an unrelated same-named class.

For C#, `new X()` also drops `case_of` / `defines` targets from its
candidates, the same exclusion the C# type-definition index uses (#3815),
so it cannot bind to an enum member or property named X.

Fixes #3888

(cherry picked from commit e4a9270f45)
This commit is contained in:
berce authored and safishamsi committed 2026-10-02 17:10:07 +01:00
1 parent 01a687cb78
commit b676462c1e
3 files changed
+211 -1

No files matched your search

+31
View File
@@ -8350,6 +8350,13 @@ def extract(
# of these files with no import evidence is gated below (#1659).
_JS_TS_CALL_SUFFIXES = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
_go_module_cache: dict[Path, str | None] = {}
# Enum members (`case_of`) and fields/properties (`defines`) are never what a
# C# `new X()` constructs. Same exclusion the C# type-definition index uses
# (#3815); without it a `new List<T>()` binds to an enum member named `List`.
_member_nids = {
e.get("target") for e in all_edges if e.get("relation") in ("case_of", "defines")
}
_csharp_stub_resolver: CsharpNameResolver | None = None # built on first use
for rc in all_raw_calls:
if rc.get("_ambiguous_python_import"):
continue
@@ -8398,6 +8405,28 @@ def extract(
candidates = global_label_to_nids_ci.get(callee.lower(), [])
if not candidates:
continue
if rc.get("csharp_new"):
candidates = [c for c in candidates if c not in _member_nids]
if not candidates:
continue
# A call rescued from an annotation stub (#3888): the caller's own file
# names this type without defining it, and before the rescue the call
# got no edge at all. A same-named class elsewhere is only the target
# with evidence: an import (checked below) or, for C#, the namespace /
# `using` scope. `new List<int>()` under `using System.Collections.Generic`
# must not bind to an unrelated `Other.List`.
stub_scope_hit = False
if rc.get("via_stub") and rc.get("lang") == "csharp":
if _csharp_stub_resolver is None:
_csharp_stub_resolver = CsharpNameResolver(all_nodes, all_edges)
caller_node = _csharp_stub_resolver.node_by_id.get(rc.get("caller_nid", ""))
scoped = (
_csharp_stub_resolver.resolve_label(callee, caller_node, str(rc.get("source_file", "")))
if caller_node is not None else None
)
if scoped in candidates:
candidates = [scoped]
stub_scope_hit = True
# Cross-language guard: never bind a call to a definition in a different
# language family. Name-only matching was resolving a TSX callback passed
# by name to a same-named Kotlin method in the Android half of the repo
@@ -8538,6 +8567,8 @@ def extract(
# (INFERRED, callable-target-gated) and independent of import evidence.
if not has_import_evidence and str(rc.get("source_file", "")).endswith(_JS_TS_CALL_SUFFIXES):
continue
if rc.get("via_stub") and not (has_import_evidence or stub_scope_hit):
continue
if tgt != caller and (caller, tgt) not in existing_pairs:
existing_pairs.add((caller, tgt))
# Promote to EXTRACTED when there's a direct import edge from the
+14 -1
View File
@@ -6981,6 +6981,10 @@ def _extract_generic(
# type/reference edge, but it is not a project-defined callable
# and must not become a calls hub. Real definitions have a
# source_file and continue through the normal call path.
# The stub may also stand in for a real definition in another
# file (an annotation elsewhere in this file created it), so the
# call still goes to raw_calls for cross-file resolution, which
# skips source-less candidates on its own (#3888).
external_stub_target = bool(tgt_nid and not nid_to_sf.get(tgt_nid))
if tgt_nid and not external_stub_target:
pair = (caller_nid, tgt_nid)
@@ -6997,7 +7001,7 @@ def _extract_generic(
"source_location": f"L{line}",
"weight": 1.0,
})
elif callee_name and not tgt_nid:
elif callee_name and (not tgt_nid or external_stub_target):
# In Python, if an unqualified call names a local non-callable variable or parameter,
# do NOT append it to raw_calls (#3405 Part 3/4).
is_py_local_data = (
@@ -7015,6 +7019,11 @@ def _extract_generic(
"source_location": f"L{node.start_point[0] + 1}",
"receiver": swift_receiver or member_receiver,
}
# This file already named the callee as a type it does
# not define, so extract() binds it cross-file only with
# import or namespace evidence (#3888).
if external_stub_target:
rc_entry["via_stub"] = True
# Ruby: attach the receiver's inferred type from the method's
# local `var = Const.new` bindings, when unambiguously known.
if member_receiver and config.ts_module == "tree_sitter_ruby":
@@ -7035,6 +7044,10 @@ def _extract_generic(
# class fields/properties are the base scope.
if config.ts_module == "tree_sitter_c_sharp":
rc_entry["lang"] = "csharp"
# `new X()` can only construct a type, never an enum
# member or property that happens to share X's name.
if node.type == "object_creation_expression":
rc_entry["csharp_new"] = True
if csharp_qualified_prefix:
rc_entry["qualified_prefix"] = csharp_qualified_prefix
receiver_type = _csharp_scoped_receiver_type(
+166
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
from pathlib import Path
import pytest
from graphify.extract import extract
@@ -19,6 +21,32 @@ def _labelled_edges(result: dict, nodes: dict[str, dict], label: str) -> list[di
]
def _calls(result: dict) -> set[tuple[str, str, str]]:
"""(caller label, target label, target source_file) for every `calls` edge."""
nodes = _nodes(result)
out = set()
for edge in result["edges"]:
if edge.get("relation") != "calls":
continue
target = nodes.get(edge["target"], {})
out.add((
nodes.get(edge["source"], {}).get("label", ""),
target.get("label", ""),
str(target.get("source_file") or "").replace("\\", "/"),
))
return out
def _extract_tree(tmp_path: Path, files: dict[str, str]) -> dict:
paths = []
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)
return extract(paths, cache_root=tmp_path, root=tmp_path)
def test_sourceless_external_call_target_is_not_emitted(tmp_path: Path):
"""A type-reference stub must not become a project-wide calls hub."""
typed = tmp_path / "typed.py"
@@ -73,3 +101,141 @@ def test_source_backed_exception_call_is_preserved(tmp_path: Path):
and nodes[edge["target"]].get("source_file") == "exceptions.py"
for edge in calls
), calls
def test_constructor_call_survives_same_file_annotation_stub(tmp_path: Path):
"""#3888: an annotation mints a sourceless stub for the imported class, and
the constructor call in the same file must still reach the real definition
through cross-file resolution instead of being dropped with the stub."""
pkg = tmp_path / "app" / "engines"
(pkg / "transcript").mkdir(parents=True)
parser = pkg / "transcript" / "__init__.py"
parser.write_text("class Parser:\n pass\n", encoding="utf-8")
engine = pkg / "__init__.py"
engine.write_text(
"from typing import Optional\n"
"from app.engines.transcript import Parser\n"
"\n"
"\n"
"class Engine:\n"
" def detect(self):\n"
" return Parser()\n"
"\n"
" def get_parser(self) -> Optional[Parser]:\n"
" return None\n",
encoding="utf-8",
)
result = extract([parser, engine], cache_root=tmp_path, root=tmp_path)
assert (".detect()", "Parser", "app/engines/transcript/__init__.py") in _calls(result)
def test_csharp_new_survives_generic_arg_stub_without_binding_members(tmp_path: Path):
"""#3888, C# shape: `List<Widget>` stubs `Widget` in the file, so `new Widget()`
was lost. Recovering it must not let `new List<int>()` bind to an enum
member that happens to be called `List`."""
files = {
"Lib/Widget.cs": "namespace Demo\n{\n public class Widget\n {\n }\n}\n",
"Lib/Kinds.cs": "namespace Demo\n{\n public enum ViewKind { List, Grid }\n}\n",
"App/Factory.cs": (
"using System.Collections.Generic;\n"
"\n"
"namespace Demo\n"
"{\n"
" public class Registry\n"
" {\n"
" public List<Widget> Items;\n"
" }\n"
"\n"
" public class Factory\n"
" {\n"
" public object MakeA() { return new Widget(); }\n"
"\n"
" public object MakeList() { return new List<int>(); }\n"
" }\n"
"}\n"
),
"App/Plain.cs": (
"namespace Demo\n"
"{\n"
" public class Plain\n"
" {\n"
" public object Make() { return new Widget(); }\n"
" }\n"
"}\n"
),
}
calls = _calls(_extract_tree(tmp_path, files))
assert (".Make()", "Widget", "Lib/Widget.cs") in calls
assert (".MakeA()", "Widget", "Lib/Widget.cs") in calls
assert not any(caller == ".MakeList()" for caller, _, _ in calls), calls
@pytest.mark.parametrize(
"files",
[
pytest.param(
{
"other/Client.java": "package other;\npublic class Client {}\n",
"app/A.java": (
"package app;\n"
"import com.lib.Client;\n"
"\n"
"public class A {\n"
" public Client make() { return new Client(); }\n"
"}\n"
),
},
id="java-import-from-another-package",
),
pytest.param(
{
"Other/List.cs": "namespace Other\n{\n public class List { }\n}\n",
"App/A.cs": (
"using System.Collections.Generic;\n"
"\n"
"namespace App\n"
"{\n"
" public class A\n"
" {\n"
" public List<int> Items;\n"
" public object Make() { return new List<int>(); }\n"
" }\n"
"}\n"
),
},
id="csharp-namespace-not-in-scope",
),
],
)
def test_stub_rescued_call_needs_import_or_scope_evidence(tmp_path: Path, files: dict[str, str]):
"""#3888 review: the file annotates the type and imports it from elsewhere,
so a same-named class in an unrelated package/namespace is not the target.
Before the stub rescue these calls had no edge; they must not gain a wrong one."""
calls = _calls(_extract_tree(tmp_path, files))
assert not any(caller in (".make()", ".Make()") for caller, _, _ in calls), calls
def test_csharp_stub_rescued_call_follows_using(tmp_path: Path):
"""The same C# shape with `using Other;` in scope does bind to `Other.List`."""
files = {
"Other/List.cs": "namespace Other\n{\n public class List { }\n}\n",
"App/A.cs": (
"using Other;\n"
"\n"
"namespace App\n"
"{\n"
" public class A\n"
" {\n"
" public List Items;\n"
" public object Make() { return new List(); }\n"
" }\n"
"}\n"
),
}
calls = _calls(_extract_tree(tmp_path, files))
assert (".Make()", "List", "Other/List.cs") in calls