mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 23:49:14 +00:00
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:
1 parent
01a687cb78
commit
b676462c1e
3 files changed
+211
-1
No files matched your search
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user