From 2b185d46e215d7953d440ddac1556d68a1a3bf2b Mon Sep 17 00:00:00 2001 From: Deepjyoti Sarmah Date: Tue, 20 Oct 2026 04:07:41 +0530 Subject: [PATCH 1/3] fix(vbnet): resolve cross-file calls to Module members, typed locals and C# statics (#4570) VB.NET calls produced a `calls` edge only when caller and callee were in the same file. A call to a Module Sub declared elsewhere, a qualified call such as `Helpers.LogIt(...)`, a call through a local declared with `As New T()`, and a call to a C# static method were all dropped by the extractor, and the shared name pass skips member calls, so nothing picked them up. The extractor now keeps every call it cannot resolve in its own file as a raw call, with the receiver, the receiver's kind (none, Me, MyBase, a local, or a name), the declared type of a local, parameter or loop variable, the caller's namespace and Imports, and the nested types of the caller's type. A new resolver, resolve_vbnet_calls, binds them with VB's own scoping rules, taken from compiling each case with the .NET 8 SDK: - the caller's type and its base classes hide a Module's member of the same name, whatever its arity; - a field, a property or an event of that name also hides the Module's Sub (a delegate field is invoked instead); - a Module is in scope only through its own, an enclosing, or an imported namespace, and two in-scope Modules that declare the name give no edge; - a field, a property or a nested type named like the receiver hides the type; - a type name must be unique in scope; - a C# target needs to be the only method of that name on its type, because C# method nodes carry no arity. Only a unique match makes an edge. Qualified and unqualified Module calls and base-class calls are EXTRACTED; calls through a typed local and casefolded C# names are INFERRED (0.85). When the caller's base chain leaves the corpus (a Form, a Page), the edge is INFERRED with context `external_base_may_hide`, since that base may declare the name. The resolver replaces resolve_vbnet_partial_calls, which stays as an alias and keeps its registry name, and its `partial_type_call` edges are unchanged. v8 bound two kinds of call to a same-named method of the caller's own type, which the compiler does not: `.Save()` inside a With block (it calls the With object's method) and a call of a local delegate (the local hides the method). Both wrong edges are gone. watch.py now forwards the metadata keys `owner`, `full_name`, `accepted_arities` and `namespace` to the resolvers as context for an incremental rebuild. Two side effects follow. The partial-class resolver, which needs `owner` and `accepted_arities`, now works across changed and unchanged files. And the C# resolvers see the namespace of unchanged types: a `using Beta` caller of a `Svc` declared in both Alpha and Beta bound to Alpha.Svc on an incremental rebuild and to Beta.Svc on a full one, and now binds to Beta.Svc on both. The AST cache schema goes from 17 to 18 because VB raw calls change. Limits: project-level Imports and RootNamespace from the .vbproj are not read; in a multi-project corpus whose projects do not reference each other, a Module from an unreferenced project can be bound; Shared versus instance is not modeled; a call through a field and a call inside a With block are not resolved to the receiver's type. --- graphify/cache.py | 2 +- graphify/extract.py | 4 +- graphify/extractors/vbnet.py | 521 +++++++++++++++++++++++++++++++---- graphify/watch.py | 7 + 4 files changed, 471 insertions(+), 63 deletions(-) diff --git a/graphify/cache.py b/graphify/cache.py index 9f8ecba88c..69144cc842 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -37,7 +37,7 @@ _EXTRACTOR_VERSION = "unknown" # Bump when AST cache-key semantics change independently of the package version. -_AST_CACHE_SCHEMA = 20 # Obsidian `[[wikilink]]` targets that start with the scan root's own folder names now resolve to the real page (#4559), so cached markdown extractions re-run; prior (19): Dart `typedef` declarations are extracted as aliases (not variables of type `typedef`); C++ Metal/CUDA GPU qualifiers no longer mint sourceless god nodes (#4550); a `.h` header containing `extern "C"` routes to the C++ extractor; prior (18): Kotlin `typealias` (#4553), C# `delegate` (#4552), and Swift `macro` declarations (#4551) are now extracted so references stop minting sourceless stubs; Rust `use` rooted at a `mod` declared in the same scope carries `_rust_use_name` (#4564); prior (17): Python pytest-fixture markers (`_py_fixture`/`_python_fixture_param`, #4332) and module-level class-id collision salting (#2810); TS `this.field` typed from any method incl. static (#4473); Solidity callables carry `metadata.arity` + inherited-call/`super`/modifier raw calls (#4379); Go type-decl and Rust file-item case-distinct node ids (#4444); prior (16): Rust `use` edges carry the imported name as written (`_rust_use_name`, #4439); (15): Go generic receiver types normalized (`Box[T]` -> `Box`) so methods attach to the type node; prior (14): C# receiver resolution for nameless/`new X()`/`foreach` receivers (defer + receiver-chain markers) and `_`/case-distinct node ids (underscore-salted); prior (13): Kotlin/Scala/C++/Swift generic & template type params no longer mint sourceless stubs; Objective-C NS_ENUM/NS_OPTIONS extraction and `_callable`/`_callable_class` markers; Go and PHP receiver-typed member calls park cross-repo (`go_type_table`/`php_type_table`, receiver fields); prior (12): Java inherited-supertype (`receiver_unbound`) and chained/new/cast receiver typing (`java_receiver_chain`, `_java_ret`); C/C++ quoted cross-directory include provenance (`include_kind`); JS external imports anchored to the package, not the path leaf; prior (11): Python property-accessor decorators / `Self`, Swift `Self`, Go generic type params, Python forward type-hint file node, PowerShell dot-source, C# verbatim-interpolated string recovery; (10): C# partial types carry `type_path`; (9): Python property-read raw calls and `_py_property` nodes; (8): Rust `use`-group per-binding edges; (7): Python opaque-base/super(args) markers + nested-class enclosing ids. +_AST_CACHE_SCHEMA = 21 # VB.NET raw calls carry the receiver, its kind and declared type, the namespace and the imports for the cross-file resolver (#4570); prior (20): Obsidian `[[wikilink]]` targets that start with the scan root's own folder names now resolve to the real page (#4559), so cached markdown extractions re-run; prior (19): Dart `typedef` declarations are extracted as aliases (not variables of type `typedef`); C++ Metal/CUDA GPU qualifiers no longer mint sourceless god nodes (#4550); a `.h` header containing `extern "C"` routes to the C++ extractor; prior (18): Kotlin `typealias` (#4553), C# `delegate` (#4552), and Swift `macro` declarations (#4551) are now extracted so references stop minting sourceless stubs; Rust `use` rooted at a `mod` declared in the same scope carries `_rust_use_name` (#4564); prior (17): Python pytest-fixture markers (`_py_fixture`/`_python_fixture_param`, #4332) and module-level class-id collision salting (#2810); TS `this.field` typed from any method incl. static (#4473); Solidity callables carry `metadata.arity` + inherited-call/`super`/modifier raw calls (#4379); Go type-decl and Rust file-item case-distinct node ids (#4444); prior (16): Rust `use` edges carry the imported name as written (`_rust_use_name`, #4439); (15): Go generic receiver types normalized (`Box[T]` -> `Box`) so methods attach to the type node; prior (14): C# receiver resolution for nameless/`new X()`/`foreach` receivers (defer + receiver-chain markers) and `_`/case-distinct node ids (underscore-salted); prior (13): Kotlin/Scala/C++/Swift generic & template type params no longer mint sourceless stubs; Objective-C NS_ENUM/NS_OPTIONS extraction and `_callable`/`_callable_class` markers; Go and PHP receiver-typed member calls park cross-repo (`go_type_table`/`php_type_table`, receiver fields); prior (12): Java inherited-supertype (`receiver_unbound`) and chained/new/cast receiver typing (`java_receiver_chain`, `_java_ret`); C/C++ quoted cross-directory include provenance (`include_kind`); JS external imports anchored to the package, not the path leaf; prior (11): Python property-accessor decorators / `Self`, Swift `Self`, Go generic type params, Python forward type-hint file node, PowerShell dot-source, C# verbatim-interpolated string recovery; (10): C# partial types carry `type_path`; (9): Python property-read raw calls and `_py_property` nodes; (8): Rust `use`-group per-binding edges; (7): Python opaque-base/super(args) markers + nested-class enclosing ids. # Per-process memo: distributions() scans site-packages metadata, which is diff --git a/graphify/extract.py b/graphify/extract.py index 0a6037d7d0..2cb3e0e217 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -84,7 +84,7 @@ from graphify.extractors.sql import extract_sql # noqa: F401 from graphify.extractors.terraform import extract_terraform, prepare_terraform, resolve_terraform_modules # noqa: F401 from graphify.extractors.verilog import extract_verilog # noqa: F401 -from graphify.extractors.vbnet import extract_vbnet, resolve_vbnet_partial_calls # noqa: F401 +from graphify.extractors.vbnet import extract_vbnet, resolve_vbnet_calls, resolve_vbnet_partial_calls # noqa: F401 from graphify.extractors.zig import extract_zig # noqa: F401 from graphify.security import sanitize_metadata from graphify.paths import disambiguate_ambiguous_candidates @@ -7452,7 +7452,7 @@ def _resolve_kotlin_member_calls( LanguageResolver("rust_self_member_calls", frozenset({".rs"}), _resolve_rust_self_member_calls) ) register_language_resolver( - LanguageResolver("vbnet_partial_calls", frozenset({".vb"}), resolve_vbnet_partial_calls) + LanguageResolver("vbnet_partial_calls", frozenset({".vb"}), resolve_vbnet_calls) ) register_language_resolver( LanguageResolver("r_sourced_calls", frozenset({".r", ".R"}), resolve_r_sourced_calls) diff --git a/graphify/extractors/vbnet.py b/graphify/extractors/vbnet.py index 8b0b3b7471..d935b1464c 100644 --- a/graphify/extractors/vbnet.py +++ b/graphify/extractors/vbnet.py @@ -1,6 +1,7 @@ """Structural extraction for Visual Basic .NET source files.""" from __future__ import annotations +import re from pathlib import Path from typing import Any @@ -18,31 +19,336 @@ } -def resolve_vbnet_partial_calls( +_NAME = r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*" +_NAME_RE = re.compile(rf"^{_NAME}$") +_IDENT_RE = re.compile(r"^[A-Za-z_]\w*$") +_AS_NEW_RE = re.compile(rf"As\s+New\s+(?:Global\.)?({_NAME})\s*(.*)$", re.I | re.S) +_AS_TYPE_RE = re.compile(rf"As\s+(?:Global\.)?({_NAME})\s*(.*)$", re.I | re.S) +_NEW_RE = re.compile(rf"New\s+(?:Global\.)?({_NAME})\s*(.*)$", re.I | re.S) +_GENERIC_RE = re.compile(r"\(\s*Of\b", re.I) +_VB_TYPE_KINDS = {"class", "module", "structure"} +_INFERRED_SCORE = 0.85 + + +def _declared_type(as_clause: str | None, initializer: str | None) -> str | None: + """The type a local, parameter or loop variable is declared with, as written. + + ``As New T()``, ``As T`` and (with no ``As`` clause) ``= New T()`` name a type; + generics, arrays and anything else give None, so no call through it is guessed. + """ + if as_clause: + match = _AS_NEW_RE.match(as_clause.strip()) + if match: + return None if _GENERIC_RE.match(match.group(2)) else match.group(1) + match = _AS_TYPE_RE.match(as_clause.strip()) + return match.group(1) if match and not match.group(2).strip() else None + if initializer: + match = _NEW_RE.match(initializer.strip()) + if match and not _GENERIC_RE.match(match.group(2)): + return match.group(1) + return None + + +def _enclosing_namespaces(namespace: str) -> list[str]: + parts = namespace.split(".") if namespace else [] + return [".".join(parts[:i]) for i in range(len(parts), 0, -1)] + + +def _is_cs_file(source_file: object) -> bool: + return isinstance(source_file, str) and source_file.lower().endswith(".cs") + + +class _VbCallIndex: + """Corpus-wide lookup tables for VB call resolution, built once per run. + + Types are keyed by ``owner`` (the namespace-qualified name, casefolded), so the + parts of a partial type share one entry. Members come from node metadata + (``owner``, ``name``, ``accepted_arities``) and C# targets from their ``method`` + edges, which is all an unchanged file contributes on an incremental build. + """ + + def __init__(self, all_nodes: list[dict], all_edges: list[dict]) -> None: + self.node_by_id: dict[str, dict] = {} + self.owner_of: dict[str, str] = {} + self.kind: dict[str, str] = {} + self.namespace: dict[str, str] = {} + self.members: dict[tuple[str, str], list[tuple[str, list[int]]]] = {} + self.module_owners: dict[str, set[str]] = {} + self.data_node_names: dict[str, str] = {} + self.data_members: dict[str, set[str]] = {} + self.bases: dict[str, set[tuple]] = {} + self.cs_type_ns: dict[str, str] = {} + self.cs_full: dict[str, list[str]] = {} + self.cs_methods: dict[str, list[tuple[str, str, str]]] = {} + self._chain_external: dict[str, bool] = {} + + cs_candidates: dict[str, dict] = {} + for node in all_nodes: + nid = node.get("id") + if not isinstance(nid, str): + continue + self.node_by_id[nid] = node + metadata = node.get("metadata") + metadata = metadata if isinstance(metadata, dict) else {} + if metadata.get("language") == "vbnet": + kind = metadata.get("kind") + if kind in _VB_TYPE_KINDS or kind == "interface": + owner = str(metadata.get("full_name") or node.get("label") or "").casefold() + if owner: + self.owner_of[nid] = owner + if kind in _VB_TYPE_KINDS: + self.kind[owner] = kind + self.namespace[owner] = owner.rpartition(".")[0] + elif kind in {"method", "constructor"}: + owner, name = metadata.get("owner"), metadata.get("name") + accepted = metadata.get("accepted_arities") + if not isinstance(accepted, list): + accepted = [metadata["arity"]] if isinstance(metadata.get("arity"), int) else [] + if isinstance(owner, str) and isinstance(name, str): + self.members.setdefault((owner.casefold(), name.casefold()), []).append( + (nid, [a for a in accepted if isinstance(a, int)]) + ) + if kind == "method": + self.module_owners.setdefault(name.casefold(), set()).add(owner.casefold()) + elif kind in {"field", "property", "event"}: + self.data_node_names[nid] = str(node.get("label") or "").casefold() + elif ( + _is_cs_file(node.get("source_file")) + and node.get("file_type") == "code" + and node.get("type") != "namespace" + ): + label = str(node.get("label") or "") + if _IDENT_RE.match(label) and label != Path(str(node["source_file"])).name: + cs_candidates[nid] = node + + # a Module's members are only reachable unqualified when the owner really is a Module + self.module_owners = { + name: {o for o in owners if self.kind.get(o) == "module"} + for name, owners in self.module_owners.items() + } + + cs_top_level: set[str] = set() + for edge in all_edges: + relation, source, target = edge.get("relation"), edge.get("source"), edge.get("target") + if relation == "contains" and target in cs_candidates: + holder = self.node_by_id.get(source) or {} + holder_file = holder.get("source_file") + # a C# type is declared in a file or a namespace; a property or a + # nested member is contained by a type + if holder.get("type") == "namespace" or ( + holder_file and holder.get("label") == Path(str(holder_file)).name + ): + cs_top_level.add(target) + if relation == "method" and source in cs_candidates: + method_node = self.node_by_id.get(target) + label = str((method_node or {}).get("label") or "").lstrip(".") + name = re.match(r"\w+", label) + if name: + self.cs_methods.setdefault(source, []).append( + (target, name.group().casefold(), name.group()) + ) + elif relation == "contains" and source in self.owner_of and target in self.data_node_names: + self.data_members.setdefault(self.owner_of[source], set()).add(self.data_node_names[target]) + elif relation == "inherits" and source in self.owner_of: + base = self.node_by_id.get(target) + owner = self.owner_of[source] + if base is None or not base.get("source_file"): + self.bases.setdefault(owner, set()).add(("ext",)) + elif target in self.owner_of: + self.bases.setdefault(owner, set()).add(("vb", self.owner_of[target])) + else: + self.bases.setdefault(owner, set()).add(("cs", target)) + + for nid, node in cs_candidates.items(): + if nid not in cs_top_level: + continue + metadata = node.get("metadata") if isinstance(node.get("metadata"), dict) else {} + namespace = str(metadata.get("namespace") or "").casefold() + self.cs_type_ns[nid] = namespace + full = f"{namespace}.{str(node['label']).casefold()}" if namespace else str(node["label"]).casefold() + self.cs_full.setdefault(full, []).append(nid) + + # -- scoping ------------------------------------------------------------- + + @staticmethod + def _in_scope(namespace: str, caller_namespace: str, imports: list[str]) -> bool: + return ( + not namespace + or namespace == caller_namespace + or caller_namespace.startswith(namespace + ".") + or namespace in imports + ) + + def find_types(self, written: str, caller_namespace: str, imports: list[str]) -> list[tuple]: + """The types a written name can mean from this namespace, deduplicated.""" + name = written.casefold() + if name.startswith("global."): + prefixes, name = [""], name[len("global."):] + else: + prefixes = [""] + _enclosing_namespaces(caller_namespace) + imports + found: list[tuple] = [] + for prefix in prefixes: + full = f"{prefix}.{name}" if prefix else name + if self.kind.get(full) in _VB_TYPE_KINDS and ("vb", full) not in found: + found.append(("vb", full)) + for nid in self.cs_full.get(full, ()): + if ("cs", nid) not in found: + found.append(("cs", nid)) + return found + + def chain_external(self, owner: str) -> bool: + """True when the type's base chain ends in a base that is not in the corpus.""" + if owner not in self._chain_external: + seen, stack, external = set(), [owner], False + while stack: + current = stack.pop() + if current in seen: + continue + seen.add(current) + for base in self.bases.get(current, ()): + if base[0] == "ext": + external = True + elif base[0] == "vb": + stack.append(base[1]) + self._chain_external[owner] = external + return self._chain_external[owner] + + def hidden_by_member(self, owner: str, name: str) -> bool: + """True when the type or a base declares a field, property or event called ``name``.""" + seen, stack = set(), [owner] + while stack: + current = stack.pop() + if current in seen: + continue + seen.add(current) + if name in self.data_members.get(current, ()): + return True + stack.extend(base[1] for base in self.bases.get(current, ()) if base[0] == "vb") + return False + + # -- member lookup --------------------------------------------------------- + + def _named(self, ref: tuple, name: str) -> list[tuple[str, list[int] | None]]: + if ref[0] == "vb": + return list(self.members.get((ref[1], name), ())) + return [(nid, None) for nid, folded, _exact in self.cs_methods.get(ref[1], ()) if folded == name] + + def lookup_member(self, start: tuple, name: str, arity: int) -> tuple[str | None, bool, int]: + """(target, found, depth): the method ``name`` on the type or its nearest base. + + The nearest type that declares the name decides, at any arity (VB hides a + base's or a Module's member by name). A VB target must accept the arity; a + C# target has no arity here, so it needs to be the only method of that name. + """ + level, seen, depth = [start], set(), 0 + while level: + named, following = [], [] + for ref in level: + if ref in seen: + continue + seen.add(ref) + named += self._named(ref, name) + if ref[0] == "vb": + following += [b for b in self.bases.get(ref[1], ()) if b[0] != "ext"] + if named: + if any(accepted is None for _nid, accepted in named): + matches = [named[0][0]] if len(named) == 1 else [] + else: + matches = [nid for nid, accepted in named if arity in accepted] + return (matches[0] if len(matches) == 1 else None), True, depth + level, depth = following, depth + 1 + return None, False, depth + + # -- one call ---------------------------------------------------------------- + + def resolve(self, call: dict) -> tuple[str, str, float, str] | None: + """(target, confidence, score, context) for a raw call, or None.""" + name = str(call.get("callee", "")).casefold() + arity = int(call.get("arity", -1)) + owner = str(call.get("caller_owner") or "").casefold() + namespace = str(call.get("namespace") or "").casefold() + imports = [str(i).casefold() for i in call.get("imports") or []] + kind = call.get("receiver_kind") + external = self.chain_external(owner) + if kind in ("none", "me"): + target, found, depth = self.lookup_member(("vb", owner), name, arity) + if found: + if target is None: + return None + return target, "EXTRACTED", 1.0, "partial_type_call" if depth == 0 else "call" + if kind == "me": + return None + # a field, property or event of that name is what `Name(...)` means (a + # delegate field is invoked); it hides the Module's Sub like a method does + if self.hidden_by_member(owner, name): + return None + modules = [ + m for m in self.module_owners.get(name, ()) + if self._in_scope(self.namespace.get(m, ""), namespace, imports) + ] + if len(modules) != 1: + return None + target, found, _depth = self.lookup_member(("vb", modules[0]), name, arity) + return self._edge(target, external, "call") + if kind == "mybase": + bases = [b for b in self.bases.get(owner, ()) if b[0] != "ext"] + if len(bases) != 1: + return None + target, found, _depth = self.lookup_member(bases[0], name, arity) + return None if target is None else (target, "EXTRACTED", 1.0, "call") + if kind == "local": + declared = call.get("receiver_type") + types = self.find_types(declared, namespace, imports) if declared else [] + if len(types) != 1: + return None + target, found, _depth = self.lookup_member(types[0], name, arity) + return None if target is None else (target, "INFERRED", _INFERRED_SCORE, "call") + if kind == "name": + receiver = str(call.get("receiver") or "") + first = receiver.split(".")[0].casefold() + if first in {str(n).casefold() for n in call.get("nested_types") or []}: + return None + if self.hidden_by_member(owner, first): + return None + types = self.find_types(receiver, namespace, imports) + if len(types) != 1: + return None + target, found, _depth = self.lookup_member(types[0], name, arity) + if target is None: + return None + exact = types[0][0] == "vb" or any( + nid == target and exact_name == call.get("callee") + for nid, _f, exact_name in self.cs_methods.get(types[0][1], ()) + ) + if not exact: + return target, "INFERRED", _INFERRED_SCORE, "call" + return self._edge(target, external, "call") + return None + + @staticmethod + def _edge(target: str | None, external: bool, context: str) -> tuple[str, str, float, str] | None: + if target is None: + return None + if external: + # the caller's base chain leaves the corpus (a Form, a Page...): that base + # may declare the name, which would hide this binding + return target, "INFERRED", _INFERRED_SCORE, "external_base_may_hide" + return target, "EXTRACTED", 1.0, context + + +def resolve_vbnet_calls( per_file: list[dict], all_nodes: list[dict], all_edges: list[dict] ) -> None: - """Resolve calls across files that declare the same partial VB type.""" - methods: dict[tuple[str, str, int], list[str]] = {} - for node in all_nodes: - metadata = node.get("metadata") - if not isinstance(metadata, dict) or metadata.get("language") != "vbnet": - continue - if metadata.get("kind") not in {"method", "constructor"}: - continue - owner = metadata.get("owner") - name = metadata.get("name") - accepted_arities = metadata.get("accepted_arities") - if ( - isinstance(owner, str) - and isinstance(name, str) - and isinstance(accepted_arities, list) - ): - for arity in accepted_arities: - if isinstance(arity, int): - methods.setdefault( - (owner.casefold(), name.casefold(), arity), [] - ).append(node["id"]) + """Resolve VB calls whose callee is declared in another file. + Covers a partial type's other parts, a base class, a Module member called + unqualified or by name, a typed local's methods, and a C# type's methods. The + shared name pass never sees these calls: it matches bare names, and VB scoping + (the caller's own type and bases hide a Module's member, two in-scope Modules + are ambiguous, a Module is only in scope through its namespace) is not a name + match. Only a unique target makes an edge. + """ + index = _VbCallIndex(all_nodes, all_edges) existing = { (edge.get("source"), edge.get("target")) for edge in all_edges @@ -50,34 +356,31 @@ def resolve_vbnet_partial_calls( } for result in per_file: for call in result.get("raw_calls", []): - if call.get("language") != "vbnet" or not call.get("owner"): + if call.get("language") != "vbnet" or not call.get("receiver_kind"): continue - key = ( - str(call["owner"]).casefold(), - str(call.get("callee", "")).casefold(), - int(call.get("arity", -1)), - ) - candidates = methods.get(key, []) + resolved = index.resolve(call) caller = call.get("caller_nid") - if len(candidates) != 1 or candidates[0] == caller: + if resolved is None or resolved[0] == caller or (caller, resolved[0]) in existing: continue - pair = (caller, candidates[0]) - if pair in existing: - continue - existing.add(pair) + target, confidence, score, context = resolved + existing.add((caller, target)) all_edges.append({ "source": caller, - "target": candidates[0], + "target": target, "relation": "calls", - "context": "partial_type_call", - "confidence": "EXTRACTED", - "confidence_score": 1.0, + "context": context, + "confidence": confidence, + "confidence_score": score, "source_file": call.get("source_file", ""), "source_location": call.get("source_location"), "weight": 1.0, }) +# The name the resolver registry and older imports know it by. +resolve_vbnet_partial_calls = resolve_vbnet_calls + + def extract_vbnet(path: Path) -> dict: try: import tree_sitter_vb_dotnet @@ -106,7 +409,7 @@ def extract_vbnet(path: Path) -> dict: 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]] = [] + bodies: list[tuple[Node, str, str, str, str, list[str]]] = [] pending_handles: list[tuple[str, str, str, Node]] = [] def add_node( @@ -155,6 +458,9 @@ def add_edge(source_id: str, target_id: str, relation: str, node: Node) -> None: add_node(file_id, path.name, root, kind="file") + # Namespaces this file imports, for the cross-file resolver. An alias import + # (`Imports G = System.Collections.Generic`) brings no namespace into scope. + file_imports: list[str] = [] for statement in (node for node in root.named_children if node.type == "imports_statement"): namespace_node = next( (child for child in statement.named_children if child.type == "namespace_name"), @@ -163,6 +469,8 @@ def add_edge(source_id: str, target_id: str, relation: str, node: Node) -> None: if namespace_node is None: continue namespace = _read_text(namespace_node, source) + if statement.child_by_field_name("alias") is None and namespace not in file_imports: + file_imports.append(namespace) target = add_node( _make_id("vbnet", "namespace", namespace.casefold()), namespace, @@ -225,6 +533,14 @@ def process_type(block: Node, parent_id: str, namespace: str) -> None: referenced = _read_text(type_node, source) add_edge(type_id, type_reference(referenced, type_node), relation, clause) + nested_types: list[str] = [] + for child in block.named_children: + inner = child.named_children[0] if child.type == "type_declaration" and child.named_children else child + if inner.type in _TYPE_BLOCKS: + inner_name = inner.child_by_field_name("name") + if inner_name is not None: + nested_types.append(_read_text(inner_name, source).casefold()) + for member in block.named_children: if member.type == "enum_member": member_name = member.child_by_field_name("name") @@ -327,7 +643,7 @@ def process_type(block: Node, parent_id: str, namespace: str) -> None: _read_text(handled, source), handled, )) - bodies.append((member, method_id, owner_key, name)) + bodies.append((member, method_id, owner_key, name, namespace, nested_types)) def scan(node: Node, parent_id: str, namespace: str) -> None: if node.type == "imports_statement": @@ -376,7 +692,54 @@ def scan(node: Node, parent_id: str, namespace: str) -> None: ) add_edge(method_id, target, "handles", handled_node) - for body, caller_id, owner_key, type_name in bodies: + def method_locals(body: Node) -> dict[str, str | None]: + """Names declared in a method (parameters, locals, loop and catch variables). + + Each maps to its declared type as written, or None when it has none or is + declared twice (a rebinding poisons the name, as in the C# table). + """ + table: dict[str, str | None] = {} + + def declare(identifier: Node, as_clause: Node | None, initializer: Node | None) -> None: + key = _read_text(identifier, source).casefold() + declared = _declared_type( + _read_text(as_clause, source) if as_clause is not None else None, + _read_text(initializer, source) if initializer is not None else None, + ) + table[key] = None if key in table else declared + + def first(node: Node, kind: str) -> Node | None: + return next((child for child in node.named_children if child.type == kind), None) + + parameters = body.child_by_field_name("parameters") + for parameter in (parameters.named_children if parameters is not None else []): + name_node = first(parameter, "identifier") + if parameter.type == "parameter" and name_node is not None: + declare(name_node, first(parameter, "as_clause"), None) + pending = [body] + while pending: + node = pending.pop() + if node != body and node.type in { + "method_declaration", "constructor_declaration", "property_declaration" + }: + continue + if node.type == "dim_statement": + initializer = first(node, "expression") + for identifier in (c for c in node.named_children if c.type == "identifier"): + declare(identifier, first(node, "as_clause"), initializer) + elif node.type in {"for_each_statement", "for_statement", "using_statement"}: + identifier = first(node, "identifier") + if identifier is not None: + declare(identifier, first(node, "as_clause"), None) + elif node.type == "catch_block": + identifier = first(node, "identifier") + if identifier is not None: + declare(identifier, None, None) + pending.extend(node.named_children) + return table + + for body, caller_id, owner_key, type_name, namespace, nested_types in bodies: + locals_table = method_locals(body) stack = [body] while stack: node = stack.pop() @@ -390,40 +753,78 @@ def scan(node: Node, parent_id: str, namespace: str) -> None: if target_node is not None: written = _read_text(target_node, source) parts = written.split(".") - receiver = ".".join(parts[:-1]).casefold() + receiver_text = ".".join(parts[:-1]) + receiver = receiver_text.casefold() callee = parts[-1] - self_receiver = ( + first_segment = receiver.split(".")[0] + # `.Save()` inside a With block, and `handler(x)` where `handler` is a + # local, call a value: neither names a method to resolve. + value_call = written.startswith(".") or ( + not receiver and callee.casefold() in locals_table + ) + is_local = first_segment in locals_table + self_receiver = not value_call and ( not receiver or receiver in {"me", "myclass", type_name.casefold()} ) # 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. + # named receiver type/module declared in this file. Anything + # else is left to the cross-file resolver (resolve_vbnet_calls). target_owner = None - if self_receiver: + if value_call: + pass + elif self_receiver: target_owner = owner_key - else: + elif not is_local: target_owner = owner_by_short.get(receiver.split(".")[-1]) + arity = len(arguments.named_children) if arguments is not None else 0 if target_owner is not None: - arity = len(arguments.named_children) if arguments is not None else 0 candidates = methods.get( (target_owner, callee.casefold(), arity), [] ) if len(candidates) == 1: add_edge(caller_id, candidates[0], "calls", node) - else: - raw_calls.append({ - "caller_nid": caller_id, - "callee": callee, - "arity": arity, - "owner": target_owner, - "is_member_call": True, - "language": "vbnet", - "source_file": source_file, - "source_location": f"L{node.start_point[0] + 1}", - }) + stack.extend(reversed(node.named_children)) + continue + if value_call or not _IDENT_RE.match(callee) or ( + receiver_text and not _NAME_RE.match(receiver_text) + ): + stack.extend(reversed(node.named_children)) + continue + if not receiver: + receiver_kind = "none" + elif self_receiver: + receiver_kind = "me" + elif receiver == "mybase": + receiver_kind = "mybase" + elif is_local: + receiver_kind = "local" if receiver == first_segment else "value" + elif first_segment in {"me", "mybase"}: + receiver_kind = "value" + else: + receiver_kind = "name" + if receiver_kind != "value": + raw_call = { + "caller_nid": caller_id, + "callee": callee, + "arity": arity, + "owner": target_owner, + "is_member_call": True, + "language": "vbnet", + "source_file": source_file, + "source_location": f"L{node.start_point[0] + 1}", + "caller_owner": owner_key, + "receiver": receiver_text, + "receiver_kind": receiver_kind, + "namespace": namespace, + "imports": file_imports, + } + if receiver_kind == "local" and locals_table.get(receiver): + raw_call["receiver_type"] = locals_table[receiver] + if nested_types: + raw_call["nested_types"] = nested_types + raw_calls.append(raw_call) stack.extend(reversed(node.named_children)) clean_edges = [ diff --git a/graphify/watch.py b/graphify/watch.py index 5483e9d898..48a5f9997f 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -2061,6 +2061,13 @@ def _add_deleted_source(path: Path) -> None: "module", "name", "arity", + # VB cross-file call resolution (#4570): a unchanged + # type keeps its owner, namespace and overload + # arities, and a C# type its namespace. + "owner", + "full_name", + "accepted_arities", + "namespace", ) if key in metadata } From 1b7879e494a484add197152836b1c741d82d3cdb Mon Sep 17 00:00:00 2001 From: Deepjyoti Sarmah Date: Tue, 20 Oct 2026 04:07:41 +0530 Subject: [PATCH 2/3] test(vbnet): cross-file calls to Module members, typed locals and C# statics (#4570) --- tests/test_vbnet_extractor.py | 405 ++++++++++++++++++++++++++++++++++ 1 file changed, 405 insertions(+) diff --git a/tests/test_vbnet_extractor.py b/tests/test_vbnet_extractor.py index 58bbb1ef6c..62ed7b5597 100644 --- a/tests/test_vbnet_extractor.py +++ b/tests/test_vbnet_extractor.py @@ -242,3 +242,408 @@ def test_vbnet_enum_members_emit_case_of_not_contains(tmp_path): # A real class field is a declaration, not a case: it keeps `contains`. assert ("Widget", "count") in contains assert ("Widget", "count") not in case_of + + +# ── #4570: cross-file calls in VB.NET (Module members, typed locals, VB -> C#) ── +# Expected targets come from the compiler runs in /var/tmp/vbcases (dotnet sdk 8.0), not from guesses. +# HARD = any fix must pass. PROBE = records a limit or tells fixes apart. + +def _project(tmp_path, files): + 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 sorted(paths) + + +def _extract_files(tmp_path, files): + return extract(_project(tmp_path, files), cache_root=tmp_path) + + +def _node_id(result, label, filename): + """The one node with this label (a leading `.` of C# methods ignored) in that file.""" + hits = [n["id"] for n in result["nodes"] + if n["label"].lstrip(".") == label and str(n.get("source_file", "")).endswith(filename)] + assert len(hits) == 1, f"{label!r} in {filename}: {hits}" + return hits[0] + + +def _calls(result, caller, caller_file): + """(label, file) of everything the caller has a `calls` edge to.""" + source = _node_id(result, caller, caller_file) + by_id = {n["id"]: n for n in result["nodes"]} + return {(by_id[e["target"]]["label"].lstrip("."), Path(str(by_id[e["target"]].get("source_file", ""))).name) + for e in result["edges"] if e["relation"] == "calls" and e["source"] == source} + + +def _call_edges(result, caller, caller_file): + source = _node_id(result, caller, caller_file) + return [e for e in result["edges"] if e["relation"] == "calls" and e["source"] == source] + + +_MODULE = "Public Module Helpers\n Public Sub LogIt(msg As String)\n End Sub\nEnd Module\n" +_WORKER = "Public Class Worker\n Public Sub Save()\n End Sub\nEnd Class\n" + + +def _caller(body, members="", inherits=""): + return f"Public Class Caller\n{inherits}{members} Public Sub Run()\n {body}\n End Sub\nEnd Class\n" + + +# H1 (compiler: c01) -- an unqualified call reaches a Module Sub declared in another file +@_needs_vbnet +def test_an_unqualified_call_reaches_a_module_sub_in_another_file(tmp_path): + result = _extract_files(tmp_path, {"Helpers.vb": _MODULE, "Caller.vb": _caller('LogIt("x")')}) + assert ("LogIt()", "Helpers.vb") in _calls(result, "Run()", "Caller.vb") + + +# H2 (compiler: c02c) -- a module-qualified call reaches the Module in another file +@_needs_vbnet +def test_a_module_qualified_call_reaches_a_module_sub_in_another_file(tmp_path): + result = _extract_files(tmp_path, {"Helpers.vb": _MODULE, "Caller.vb": _caller('Helpers.LogIt("x")')}) + assert ("LogIt()", "Helpers.vb") in _calls(result, "Run()", "Caller.vb") + + +# H3 (compiler: c07a, c07b) -- a local typed in its declaration statement reaches the type's method +@_needs_vbnet +@pytest.mark.parametrize("declaration", ["Dim w As New Worker()", "Dim w As Worker = New Worker()"], + ids=["as_new", "as_t_equals_new"]) +def test_a_call_through_a_local_typed_in_its_declaration_reaches_the_types_method(tmp_path, declaration): + result = _extract_files(tmp_path, {"Worker.vb": _WORKER, "Caller.vb": _caller(f"{declaration}\n w.Save()")}) + assert ("Save()", "Worker.vb") in _calls(result, "Run()", "Caller.vb") + + +# H4 (compiler: c09) -- a VB call to a C# static method reaches it +@_needs_vbnet +def test_a_vb_call_to_a_csharp_static_method_reaches_it(tmp_path): + cs = "public static class CsRules {\n public static bool IsLocked(int id) { return false; }\n}\n" + result = _extract_files(tmp_path, {"CsRules.cs": cs, "Caller.vb": _caller("Dim b = CsRules.IsLocked(1)")}) + assert ("IsLocked()", "CsRules.cs") in _calls(result, "Run()", "Caller.vb") + + +# H5a (compiler: c03) -- the caller's own method wins over a Module's of the same signature +@_needs_vbnet +def test_the_callers_own_method_wins_over_a_module_method_of_the_same_name(tmp_path): + own = " Public Sub LogIt(msg As String)\n End Sub\n" + result = _extract_files(tmp_path, {"Helpers.vb": _MODULE, "Caller.vb": _caller('LogIt("x")', members=own)}) + assert _calls(result, "Run()", "Caller.vb") == {("LogIt()", "Caller.vb")} + + +# H5b (compiler: c04) -- a method inherited from a base class also wins over the Module +@_needs_vbnet +def test_an_inherited_method_is_never_replaced_by_a_module_method(tmp_path): + base = "Public Class BaseC\n Public Sub LogIt(msg As String)\n End Sub\nEnd Class\n" + result = _extract_files(tmp_path, {"Helpers.vb": _MODULE, "BaseC.vb": base, + "Caller.vb": _caller('LogIt("x")', inherits=" Inherits BaseC\n")}) + assert ("LogIt()", "Helpers.vb") not in _calls(result, "Run()", "Caller.vb") + + +# H5c (compiler: c06a) -- a same-named method on the caller's class hides the Module's, whatever its arity +@_needs_vbnet +def test_a_same_named_method_on_the_callers_class_hides_the_module_method_whatever_its_arity(tmp_path): + own = " Public Sub LogIt(msg As String, n As Integer)\n End Sub\n" + result = _extract_files(tmp_path, {"Helpers.vb": _MODULE, "Caller.vb": _caller('LogIt("x")', members=own)}) + assert ("LogIt()", "Helpers.vb") not in _calls(result, "Run()", "Caller.vb") + + +# H6 (compiler: c05, c05b) -- two Modules with the same member name: ambiguous, so no edge to either +@_needs_vbnet +@pytest.mark.parametrize("second_signature", ["msg As String", "msg As String, n As Integer"], ids=["same_arity", "other_arity"]) +def test_two_modules_declaring_the_same_name_give_no_edge_to_either(tmp_path, second_signature): + second = f"Public Module Helpers2\n Public Sub LogIt({second_signature})\n End Sub\nEnd Module\n" + result = _extract_files(tmp_path, {"Helpers.vb": _MODULE, "Helpers2.vb": second, "Caller.vb": _caller('LogIt("x")')}) + assert _calls(result, "Run()", "Caller.vb") == set() + + +# H7 -- a receiver without a declared type (late bound) gets no edge to a random same-named method +@_needs_vbnet +def test_a_call_through_an_object_typed_receiver_binds_to_no_method(tmp_path): + decoy = "Public Class Decoy\n Public Sub Save()\n End Sub\nEnd Class\n" + body = "Dim o As Object = New Worker()\n o.Save()" + result = _extract_files(tmp_path, {"Worker.vb": _WORKER, "Decoy.vb": decoy, "Caller.vb": _caller(body)}) + assert _calls(result, "Run()", "Caller.vb") == set() + + +# H8 -- a VB call never binds to a same-named function in a file of another language +@_needs_vbnet +def test_a_vb_call_never_binds_to_a_same_named_python_function(tmp_path): + py = "def LogIt(msg):\n pass\n\n\ndef logit(msg):\n pass\n" + result = _extract_files(tmp_path, {"helpers.py": py, "Caller.vb": _caller('LogIt("x")\n logit("x")')}) + assert not {c for c in _calls(result, "Run()", "Caller.vb") if c[1].endswith(".py")} + + +# H9 (compiler: c01b) -- VB is case-insensitive: every spelling of the call binds to the Module's LogIt +@_needs_vbnet +def test_the_case_of_a_call_does_not_change_what_it_binds_to(tmp_path): + for spelling in ("LogIt", "logit", "LOGIT"): + sub = tmp_path / spelling + sub.mkdir() + result = _extract_files(sub, {"Helpers.vb": _MODULE, "Caller.vb": _caller(f'{spelling}("x")')}) + assert ("LogIt()", "Helpers.vb") in _calls(result, "Run()", "Caller.vb"), spelling + + +# H10 -- same-file calls and partial-class calls are unchanged +@_needs_vbnet +def test_same_file_and_partial_class_calls_keep_their_edges_and_evidence(tmp_path): + same_file = (" Public Sub A_SameFileSelf()\n B_Target()\n End Sub\n" + " Public Sub B_Target()\n End Sub\n") + first = "Public Partial Class Counter\n Public Sub Run()\n Helper()\n End Sub\nEnd Class\n" + second = "Partial Public Class Counter\n Private Sub Helper()\n End Sub\nEnd Class\n" + result = _extract_files(tmp_path, {"Caller.vb": f"Public Class Caller\n{same_file}End Class\n", + "Counter.vb": first, "Counter.Designer.vb": second}) + (same,) = _call_edges(result, "A_SameFileSelf()", "Caller.vb") + assert (same["confidence"], same.get("context")) == ("EXTRACTED", None) + (partial,) = _call_edges(result, "Run()", "Counter.vb") + assert (partial["confidence"], partial["context"]) == ("EXTRACTED", "partial_type_call") + assert _calls(result, "Run()", "Counter.vb") == {("Helper()", "Counter.Designer.vb")} + + +# H11 -- C# -> VB (case G of the issue) keeps resolving +@_needs_vbnet +def test_a_csharp_call_to_a_vb_module_function_keeps_its_edge(tmp_path): + cs = 'public class CsCaller {\n public void G_CsToVb() { Helpers.LogIt("x"); }\n}\n' + result = _extract_files(tmp_path, {"Helpers.vb": _MODULE, "CsCaller.cs": cs}) + (edge,) = _call_edges(result, "G_CsToVb()", "CsCaller.cs") + assert _node_id(result, "LogIt()", "Helpers.vb") == edge["target"] + assert edge["confidence"] == "EXTRACTED" + + +# H12 (added; compiler: c02a) -- a Module in a namespace that is not imported is not in scope +@_needs_vbnet +def test_an_unqualified_call_does_not_reach_a_module_in_a_namespace_that_is_not_imported(tmp_path): + module = "Namespace Acme\n" + _MODULE + "End Namespace\n" + result = _extract_files(tmp_path, {"Helpers.vb": module, "Caller.vb": _caller('LogIt("x")')}) + assert _calls(result, "Run()", "Caller.vb") == set() + + +# H13 (added) -- an unqualified call never binds to a same-named method of an unrelated class, VB or C# +@_needs_vbnet +@pytest.mark.parametrize("name,body", [ + ("Worker.vb", _WORKER), + ("CsWorker.cs", "public class CsWorker {\n public void Save() { }\n}\n"), +], ids=["vb_class", "csharp_class"]) +def test_an_unqualified_call_never_binds_to_a_method_of_an_unrelated_class(tmp_path, name, body): + result = _extract_files(tmp_path, {name: body, "Caller.vb": _caller("Save()")}) + assert _calls(result, "Run()", "Caller.vb") == set() + + +# H14 (added; compiler: c06b, c06c) -- a field or a nested type named like the Module shadows it +@_needs_vbnet +def test_a_field_named_like_the_module_hides_it(tmp_path): + result = _extract_files(tmp_path, {"Helpers.vb": _MODULE, + "Caller.vb": _caller('Helpers.LogIt("x")', members=" Private Helpers As Integer\n")}) + assert ("LogIt()", "Helpers.vb") not in _calls(result, "Run()", "Caller.vb") + + +@_needs_vbnet +def test_a_nested_class_named_like_the_module_hides_it(tmp_path): + nested = " Public Class Helpers\n Public Shared Sub LogIt(msg As String)\n End Sub\n End Class\n" + result = _extract_files(tmp_path, {"Helpers.vb": _MODULE, "Caller.vb": _caller('Helpers.LogIt("x")', members=nested)}) + assert ("LogIt()", "Helpers.vb") not in _calls(result, "Run()", "Caller.vb") + + +# P1 (compiler: c07c) -- an inferred local: `Dim w = New Worker()` +@_needs_vbnet +def test_probe_a_call_through_an_inferred_local_reaches_the_types_method(tmp_path): + result = _extract_files(tmp_path, {"Worker.vb": _WORKER, "Caller.vb": _caller("Dim w = New Worker()\n w.Save()")}) + assert ("Save()", "Worker.vb") in _calls(result, "Run()", "Caller.vb") + + +# P2 (compiler: c08a, c08b) -- a field or a parameter typed As Worker +@_needs_vbnet +@pytest.mark.parametrize("caller_source", [ + pytest.param( + _caller("_w = New Worker()\n _w.Save()", members=" Private _w As Worker\n"), + id="field", + marks=pytest.mark.xfail(strict=True, reason="limitation: field-typed receivers are not tracked"), + ), + pytest.param( + "Public Class Caller\n Public Sub Run(x As Worker)\n x.Save()\n End Sub\nEnd Class\n", + id="parameter", + ), +]) +def test_probe_a_call_through_a_field_or_parameter_typed_as_the_class_reaches_its_method(tmp_path, caller_source): + result = _extract_files(tmp_path, {"Worker.vb": _WORKER, "Caller.vb": caller_source}) + assert ("Save()", "Worker.vb") in _calls(result, "Run()", "Caller.vb") + + +# P3 (compiler: c02b) -- a Module in another namespace reached through Imports +@_needs_vbnet +def test_probe_an_unqualified_call_reaches_a_module_in_an_imported_namespace(tmp_path): + module = "Namespace Acme\n" + _MODULE + "End Namespace\n" + result = _extract_files(tmp_path, {"Helpers.vb": module, "Caller.vb": "Imports Acme\n" + _caller('LogIt("x")')}) + assert ("LogIt()", "Helpers.vb") in _calls(result, "Run()", "Caller.vb") + + +# P3b (added; compiler: c02d) -- a Module in an enclosing namespace needs no Imports +@_needs_vbnet +def test_probe_an_unqualified_call_reaches_a_module_in_an_enclosing_namespace(tmp_path): + module = "Namespace Acme\n" + _MODULE + "End Namespace\n" + caller_source = "Namespace Acme.Sub1\n" + _caller('LogIt("x")') + "End Namespace\n" + result = _extract_files(tmp_path, {"Helpers.vb": module, "Caller.vb": caller_source}) + assert ("LogIt()", "Helpers.vb") in _calls(result, "Run()", "Caller.vb") + + +# P4 -- the evidence level each form is given. Records what is produced; only the vocabulary is asserted. +@_needs_vbnet +@pytest.mark.parametrize("form", ["unqualified_module", "module_qualified", "typed_local", "csharp_static"]) +def test_probe_edge_confidence_of_each_cross_file_form(tmp_path, form): + cases = { + "unqualified_module": ({"Helpers.vb": _MODULE, "Caller.vb": _caller('LogIt("x")')}, "LogIt()"), + "module_qualified": ({"Helpers.vb": _MODULE, "Caller.vb": _caller('Helpers.LogIt("x")')}, "LogIt()"), + "typed_local": ({"Worker.vb": _WORKER, "Caller.vb": _caller("Dim w As New Worker()\n w.Save()")}, "Save()"), + "csharp_static": ({"CsRules.cs": "public static class CsRules { public static bool IsLocked(int id) { return false; } }\n", + "Caller.vb": _caller("Dim b = CsRules.IsLocked(1)")}, "IsLocked()"), + } + files, callee = cases[form] + result = _extract_files(tmp_path, files) + edges = [e for e in _call_edges(result, "Run()", "Caller.vb") + if next(n for n in result["nodes"] if n["id"] == e["target"])["label"].lstrip(".") == callee] + assert len(edges) == 1, "no edge to measure" + print(f"P4 {form}: confidence={edges[0]['confidence']} score={edges[0].get('confidence_score')} context={edges[0].get('context')}") + assert edges[0]["confidence"] in {"EXTRACTED", "INFERRED"} + + +# P5 (added) -- a C# call to an overloaded VB Module Sub reaches the overload with the call's arity +@_needs_vbnet +@pytest.mark.xfail(strict=True, reason="separate bug: C# member resolver ignores arity") +def test_probe_a_csharp_call_to_an_overloaded_vb_module_sub_reaches_the_overload_with_its_arity(tmp_path): + module = ("Public Module Helpers\n Public Sub LogIt(a As String)\n End Sub\n" + " Public Sub LogIt(a As String, n As Integer)\n End Sub\nEnd Module\n") + cs = 'public class CsCaller {\n public void M() { Helpers.LogIt("x"); }\n}\n' + result = _extract_files(tmp_path, {"Helpers.vb": module, "CsCaller.cs": cs}) + (edge,) = _call_edges(result, "M()", "CsCaller.cs") + target = next(n for n in result["nodes"] if n["id"] == edge["target"]) + assert target["metadata"]["arity"] == 1 + + +# P6 (added; compiler: c06c) -- the nested class is the target. v8 does not extract nested VB types at all. +@_needs_vbnet +@pytest.mark.xfail(strict=True, reason="separate bug: nested VB types are not extracted") +def test_probe_a_qualified_call_reaches_a_nested_class_named_like_the_module(tmp_path): + nested = " Public Class Helpers\n Public Shared Sub LogIt(msg As String)\n End Sub\n End Class\n" + result = _extract_files(tmp_path, {"Helpers.vb": _MODULE, "Caller.vb": _caller('Helpers.LogIt("x")', members=nested)}) + assert _calls(result, "Run()", "Caller.vb") == {("LogIt()", "Caller.vb")} + + +# P7 (compiler: c04) -- an unqualified call reaches the method the caller inherits from a base class in another file +@_needs_vbnet +def test_probe_an_unqualified_call_reaches_the_method_inherited_from_a_base_class(tmp_path): + base = "Public Class BaseC\n Public Sub LogIt(msg As String)\n End Sub\nEnd Class\n" + result = _extract_files(tmp_path, {"Helpers.vb": _MODULE, "BaseC.vb": base, + "Caller.vb": _caller('LogIt("x")', inherits=" Inherits BaseC\n")}) + assert _calls(result, "Run()", "Caller.vb") == {("LogIt()", "BaseC.vb")} + + +# P8 (compiler: c11) -- VB is case-insensitive on the C# side too: `csrules.islocked(1)` compiles and binds +@_needs_vbnet +def test_probe_a_lowercase_vb_call_reaches_a_csharp_static_method(tmp_path): + cs = "public static class CsRules {\n public static bool IsLocked(int id) { return false; }\n}\n" + result = _extract_files(tmp_path, {"CsRules.cs": cs, "Caller.vb": _caller("Dim b = csrules.islocked(1)")}) + assert ("IsLocked()", "CsRules.cs") in _calls(result, "Run()", "Caller.vb") + + +# H15 (compiler: c10) -- `Helpers` is declared by two Modules in two namespaces and the caller imports neither: +# the name is not declared, so the call binds to no Module +@_needs_vbnet +def test_a_module_name_declared_in_two_unimported_namespaces_binds_to_neither(tmp_path): + first = "Namespace NsA\n" + _MODULE + "End Namespace\n" + second = "Namespace NsB\n" + _MODULE + "End Namespace\n" + result = _extract_files(tmp_path, {"HelpersA.vb": first, "HelpersB.vb": second, "Caller.vb": _caller('Helpers.LogIt("x")')}) + assert _calls(result, "Run()", "Caller.vb") == set() + + +# H16 (#2406) -- rebuilding only the changed caller still binds its call into the unchanged Module file +@_needs_vbnet +def test_an_incremental_rebuild_of_only_the_caller_keeps_its_call_into_an_unchanged_module(tmp_path): + import json + + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "Helpers.vb").write_text(_MODULE, encoding="utf-8") + caller = corpus / "Caller.vb" + caller.write_text(_caller('LogIt("x")'), encoding="utf-8") + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + caller.write_text(_caller('LogIt("x")', members=" Public Sub Other()\n End Sub\n"), encoding="utf-8") + assert _rebuild_code(corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False) is True + + graph = json.loads((corpus / "graphify-out" / "graph.json").read_text(encoding="utf-8")) + by_id = {n["id"]: n for n in graph["nodes"]} + called = {(by_id[e["target"]]["label"], by_id[e["target"]].get("source_file")) + for e in graph["links"] + if e["relation"] == "calls" and by_id[e["source"]]["label"] == "Run()"} + assert ("LogIt()", "Helpers.vb") in called + + +# Cache (#4570): VB raw calls changed shape, so entries written before are not served. +@_needs_vbnet +def test_a_cache_entry_from_before_cross_file_vb_calls_is_not_served(tmp_path, monkeypatch): + import graphify.cache as cache_mod + from graphify.cache import save_cached + + path = tmp_path / "Caller.vb" + path.write_text(_caller('LogIt("x")'), encoding="utf-8") + current = cache_mod._AST_CACHE_SCHEMA + monkeypatch.setattr(cache_mod, "_AST_CACHE_SCHEMA", 20) # the schema before this fix (v8, 0.9.93) + stale = {"nodes": [{"id": "stale_sentinel", "label": "STALE", "file_type": "code", + "source_file": str(path), "source_location": "L1"}], "edges": [], "raw_calls": []} + save_cached(path, stale, tmp_path, cache_root=tmp_path) + monkeypatch.setattr(cache_mod, "_AST_CACHE_SCHEMA", current) + result = extract([path], cache_root=tmp_path) + assert "stale_sentinel" not in {n["id"] for n in result["nodes"]} + + +# H17 (#4570) -- a class whose base leaves the corpus (a Form, a Page) may inherit a member of that name: +# the Module call is still made, but as INFERRED with that reason +@_needs_vbnet +@pytest.mark.parametrize("call", ['LogIt("x")', 'Helpers.LogIt("x")'], ids=["unqualified", "qualified"]) +def test_a_call_from_a_class_with_an_external_base_reaches_the_module_as_inferred(tmp_path, call): + caller = _caller(call, inherits=" Inherits System.Windows.Forms.Form\n") + result = _extract_files(tmp_path, {"Helpers.vb": _MODULE, "Caller.vb": caller}) + edges = _call_edges(result, "Run()", "Caller.vb") + assert len(edges) == 1 + assert edges[0]["target"] == _node_id(result, "LogIt()", "Helpers.vb") + assert (edges[0]["confidence"], edges[0]["confidence_score"], edges[0]["context"]) == ( + "INFERRED", 0.85, "external_base_may_hide") + + +# H18 (compiler: c12) -- `.Save()` inside `With w` calls w's Save, not the caller's own Save +@_needs_vbnet +def test_a_member_call_inside_a_with_block_is_not_a_call_on_the_callers_own_type(tmp_path): + own = " Public Sub Save()\n End Sub\n" + body = "Dim w As New Worker()\n With w\n .Save()\n End With" + result = _extract_files(tmp_path, {"Worker.vb": _WORKER, "Caller.vb": _caller(body, members=own)}) + assert ("Save()", "Caller.vb") not in _calls(result, "Run()", "Caller.vb") + + +# H19 (compiler: c13) -- a local delegate hides a method of the same name: `handler(1)` invokes the local +@_needs_vbnet +def test_a_call_of_a_local_delegate_is_not_a_call_of_a_same_named_method(tmp_path): + own = " Public Sub handler(x As Integer)\n End Sub\n" + body = "Dim handler As Action(Of Integer) = Sub(i As Integer) Console.WriteLine(i)\n handler(1)" + result = _extract_files(tmp_path, {"Caller.vb": _caller(body, members=own)}) + assert _calls(result, "Run()", "Caller.vb") == set() + + +# H20 (compiler: c14a, c14b, c14c) -- a property, field or delegate field named like the Module's Sub hides it +@_needs_vbnet +@pytest.mark.parametrize("member", [ + " Public Property LogIt As String\n", + " Private LogIt As String\n", + " Private LogIt As Action(Of String)\n", +], ids=["property", "field", "delegate_field"]) +def test_a_property_or_field_named_like_a_module_sub_hides_it(tmp_path, member): + result = _extract_files(tmp_path, {"Helpers.vb": _MODULE, "Caller.vb": _caller('LogIt("x")', members=member)}) + assert ("LogIt()", "Helpers.vb") not in _calls(result, "Run()", "Caller.vb") + + +# P9 (compiler: c12) -- `.Save()` inside `With w` reaches w's class +@_needs_vbnet +@pytest.mark.xfail(strict=True, reason="limitation: With-block targets are not tracked") +def test_probe_a_member_call_inside_a_with_block_reaches_the_with_objects_method(tmp_path): + body = "Dim w As New Worker()\n With w\n .Save()\n End With" + result = _extract_files(tmp_path, {"Worker.vb": _WORKER, "Caller.vb": _caller(body)}) + assert ("Save()", "Worker.vb") in _calls(result, "Run()", "Caller.vb") From 813bf2795ddfee4f97855f016225518a0a3fc766 Mon Sep 17 00:00:00 2001 From: Deepjyoti-Sarmah Date: Tue, 20 Oct 2026 19:14:31 +0530 Subject: [PATCH 3/3] fix(vbnet): keep a resolved VB -> C# call in the built graph (#4570) build.py's cross-language phantom filter drops an INFERRED call whose endpoints are in different families, and .vb has none there, so every INFERRED VB -> C# edge the VB resolver made was dropped: a call from a class whose base leaves the corpus (a Form) and a call through a typed local. The resolver's edges now carry _vbnet_resolved, and the filter keeps them. A C# bare name matched to a VB Module member by the shared name pass is still dropped: C# cannot reach it without the Module name. --- graphify/build.py | 3 +++ graphify/extractors/vbnet.py | 3 +++ tests/test_vbnet_extractor.py | 39 +++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/graphify/build.py b/graphify/build.py index 78e298fd77..25c5b807f4 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -1692,9 +1692,12 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat if _edge_rel == "calls": # Unchanged #1547/#1556 behavior: only INFERRED calls, and drop as # soon as either family differs (an unknown ext counts as different). + # A VB call the VB resolver bound to one C# target is not a name + # match; a C# bare name matched to a VB Module member still is (#4570). if ( attrs.get("confidence") == "INFERRED" and src_ext and tgt_ext and src_fam != tgt_fam + and not attrs.get("_vbnet_resolved") ): continue else: diff --git a/graphify/extractors/vbnet.py b/graphify/extractors/vbnet.py index d935b1464c..c8dc1b54d7 100644 --- a/graphify/extractors/vbnet.py +++ b/graphify/extractors/vbnet.py @@ -374,6 +374,9 @@ def resolve_vbnet_calls( "source_file": call.get("source_file", ""), "source_location": call.get("source_location"), "weight": 1.0, + # a unique binding, not a name match: build.py's cross-language + # phantom filter keeps it although .vb and .cs are separate families + "_vbnet_resolved": True, }) diff --git a/tests/test_vbnet_extractor.py b/tests/test_vbnet_extractor.py index 62ed7b5597..30233ff986 100644 --- a/tests/test_vbnet_extractor.py +++ b/tests/test_vbnet_extractor.py @@ -647,3 +647,42 @@ def test_probe_a_member_call_inside_a_with_block_reaches_the_with_objects_method body = "Dim w As New Worker()\n With w\n .Save()\n End With" result = _extract_files(tmp_path, {"Worker.vb": _WORKER, "Caller.vb": _caller(body)}) assert ("Save()", "Worker.vb") in _calls(result, "Run()", "Caller.vb") + + +from graphify.build import build_from_json + + +def _graph_calls(result, caller, caller_file): + """(label, file) of everything the caller calls in the BUILT graph: build.py's + cross-language phantom filter runs there, not in extract.""" + graph = build_from_json(result) + source = _node_id(result, caller, caller_file) + return {(graph.nodes[t]["label"].lstrip("."), Path(str(graph.nodes[t].get("source_file", ""))).name) + for s, t, d in graph.edges(source, data=True) + if d.get("relation") == "calls" and source in (s, t) and t != source} + + +_CS_RULES = ("namespace Acme.Rules {\n" + " public static class NsRules { public static bool Check(int x) { return x > 0; } }\n" + " public class Inst { public void Run() { } }\n" + "}\n") + + +# H19 (#4570 review) -- an INFERRED VB -> C# call survives the cross-language phantom filter +@_needs_vbnet +@pytest.mark.parametrize("body, inherits, callee", [ + ("Acme.Rules.NsRules.Check(1)", " Inherits System.Windows.Forms.Form\n", "Check()"), + ("Dim r As New Acme.Rules.Inst()\n r.Run()", "", "Run()"), +], ids=["external_base", "typed_local"]) +def test_an_inferred_vb_call_into_csharp_is_kept_in_the_built_graph(tmp_path, body, inherits, callee): + result = _extract_files(tmp_path, {"NsRules.cs": _CS_RULES, "Caller.vb": _caller(body, inherits=inherits)}) + assert (callee, "NsRules.cs") in _graph_calls(result, "Run()", "Caller.vb") + + +# H20 (#4570 review) -- C# cannot call a VB Module member by its bare name: the shared +# name pass still matches it, and the filter must still drop that edge +@_needs_vbnet +def test_a_csharp_bare_name_never_reaches_a_vb_module_member_in_the_built_graph(tmp_path): + cs = 'public class CsCaller {\n public void P_CsBare() { LogIt("x"); }\n}\n' + result = _extract_files(tmp_path, {"Helpers.vb": _MODULE, "CsCaller.cs": cs}) + assert ("LogIt()", "Helpers.vb") not in _graph_calls(result, "P_CsBare()", "CsCaller.cs")