fix(vbnet): resolve type/module-qualified calls to their target method

The invocation resolver only fired for self/unqualified receivers (Me, the
enclosing type, or a bare name). A call qualified by a type or module name —
Helpers.Log(), the idiomatic way to reach a shared Module Sub — matched
nothing and produced no edge at all, so the caller was disconnected from the
method it invoked.

Map each declared type/module's short name to its owner key, and resolve a
qualified call against the receiver's owner (falling back to raw_calls for
cross-file partial-type merging). A call through a value receiver (a local
variable) still has no known owner and is left unresolved rather than
guessed (fail-closed).

(cherry picked from commit 1297c18f50)
This commit is contained in:
rajatnagda45 authored and safishamsi committed 2026-09-30 20:41:10 +01:00
1 parent 85390c83a4
commit 4dc58d17a7
2 files changed
+67 -4

No files matched your search

+19 -4
View File
@@ -100,6 +100,10 @@ def extract_vbnet(path: Path) -> dict:
seen_ids: set[str] = set()
seen_edges: set[tuple[str, str, str]] = set()
types_by_name: dict[str, str] = {}
# Short type/module name -> its owner_key, so a qualified call such as
# Helpers.Log() can be resolved back to the owner that keys `methods`
# (which is the namespace-qualified name, not the bare receiver token).
owner_by_short: dict[str, str] = {}
methods: dict[tuple[str, str, int], list[str]] = {}
events: dict[tuple[str, str], str] = {}
bodies: list[tuple[Node, str, str, str]] = []
@@ -209,6 +213,7 @@ def extract_vbnet(path: Path) -> dict:
)
add_edge(parent_id, type_id, "contains", block)
types_by_name[name.casefold()] = type_id
owner_by_short.setdefault(name.casefold(), owner_key)
for clause in block.named_children:
if clause.type not in {"inherits_clause", "implements_clause"}:
@@ -378,14 +383,24 @@ def extract_vbnet(path: Path) -> dict:
parts = written.split(".")
receiver = ".".join(parts[:-1]).casefold()
callee = parts[-1]
known_receiver = (
self_receiver = (
not receiver
or receiver in {"me", "myclass", type_name.casefold()}
)
if known_receiver:
# A self/unqualified call targets the caller's own type; a
# qualified call (Helpers.Log(), OtherType.Foo()) targets the
# named receiver type/module. Only these two forms can be
# resolved by name — a call through a value receiver (a local
# variable) has no known owner, so it is left unresolved.
target_owner = None
if self_receiver:
target_owner = owner_key
else:
target_owner = owner_by_short.get(receiver.split(".")[-1])
if target_owner is not None:
arity = len(arguments.named_children) if arguments is not None else 0
candidates = methods.get(
(owner_key, callee.casefold(), arity), []
(target_owner, callee.casefold(), arity), []
)
if len(candidates) == 1:
add_edge(caller_id, candidates[0], "calls", node)
@@ -394,7 +409,7 @@ def extract_vbnet(path: Path) -> dict:
"caller_nid": caller_id,
"callee": callee,
"arity": arity,
"owner": owner_key,
"owner": target_owner,
"is_member_call": True,
"language": "vbnet",
"source_file": source_file,
+48
View File
@@ -59,6 +59,54 @@ def test_vbnet_class_methods_and_case_insensitive_calls(tmp_path):
assert ("Run()", "Helper()") in _edge_labels(result, "calls")
def test_vbnet_module_qualified_call_resolves_to_the_module_method(tmp_path):
# A call qualified by a type/module name (Helpers.Log()) — the idiomatic way
# to reach a shared Module Sub — was dropped entirely: the invocation
# resolver only fired for self/unqualified receivers, so the caller had no
# edge to the module method it invoked.
source = tmp_path / "App.vb"
source.write_text(
"Namespace Demo\n"
" Public Module Helpers\n"
" Public Sub Log()\n"
" End Sub\n"
" End Module\n"
" Public Class Service\n"
" Public Sub Run()\n"
" Helpers.Log()\n"
" End Sub\n"
" End Class\n"
"End Namespace\n",
encoding="utf-8",
)
result = extract([source], cache_root=tmp_path)
assert ("Run()", "Log()") in _edge_labels(result, "calls")
def test_vbnet_call_through_a_value_receiver_stays_unresolved(tmp_path):
# A call through a value receiver (a local variable) has no statically known
# owner, so it must NOT be guessed into a false edge (fail-closed).
source = tmp_path / "App.vb"
source.write_text(
"Public Class Other\n"
" Public Sub Save()\n"
" End Sub\n"
"End Class\n"
"Public Class Service\n"
" Public Sub Run()\n"
" obj.Save()\n"
" End Sub\n"
"End Class\n",
encoding="utf-8",
)
result = extract([source], cache_root=tmp_path)
assert ("Run()", "Save()") not in _edge_labels(result, "calls")
def test_vbnet_types_members_relationships_and_partial_calls(tmp_path):
first = tmp_path / "Counter.vb"
first.write_text(