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/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..c8dc1b54d7 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,34 @@ 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, + # 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, }) +# 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 +412,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 +461,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 +472,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 +536,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 +646,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 +695,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 +756,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 } diff --git a/tests/test_vbnet_extractor.py b/tests/test_vbnet_extractor.py index 58bbb1ef6c..30233ff986 100644 --- a/tests/test_vbnet_extractor.py +++ b/tests/test_vbnet_extractor.py @@ -242,3 +242,447 @@ 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 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")