From b676462c1e739a10669a59bb2a67ce408037a875 Mon Sep 17 00:00:00 2001 From: berce <75228263+bercedev@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:02:21 +0300 Subject: [PATCH] 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()` 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 e4a9270f45a983559c49ada5f544dbaa45785972) --- graphify/extract.py | 31 +++++ graphify/extractors/engine.py | 15 ++- tests/test_sourceless_call_targets.py | 166 ++++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 1 deletion(-) diff --git a/graphify/extract.py b/graphify/extract.py index f51022453..1861a8354 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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()` 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()` 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 diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index f32adc850..0914fe124 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -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( diff --git a/tests/test_sourceless_call_targets.py b/tests/test_sourceless_call_targets.py index e18cac388..a5926203d 100644 --- a/tests/test_sourceless_call_targets.py +++ b/tests/test_sourceless_call_targets.py @@ -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` stubs `Widget` in the file, so `new Widget()` + was lost. Recovering it must not let `new List()` 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 Items;\n" + " }\n" + "\n" + " public class Factory\n" + " {\n" + " public object MakeA() { return new Widget(); }\n" + "\n" + " public object MakeList() { return new List(); }\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 Items;\n" + " public object Make() { return new List(); }\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