diff --git a/libs/openant-core/parsers/php/call_graph_builder.py b/libs/openant-core/parsers/php/call_graph_builder.py index 42e37bbf..d0e8ec17 100644 --- a/libs/openant-core/parsers/php/call_graph_builder.py +++ b/libs/openant-core/parsers/php/call_graph_builder.py @@ -161,6 +161,7 @@ def _extract_calls_from_code(self, code: str, caller_id: str) -> Set[str]: caller_file = caller_id.split(':')[0] caller_func = self.functions.get(caller_id, {}) caller_class = caller_func.get('class_name') + caller_namespace = caller_func.get('namespace_name') code_bytes = code.encode('utf-8', errors='replace') try: @@ -173,7 +174,8 @@ def _extract_calls_from_code(self, code: str, caller_id: str) -> Set[str]: node = stack.pop() if node.type in ('function_call_expression', 'member_call_expression', 'scoped_call_expression'): - resolved = self._resolve_call_node(node, code_bytes, caller_file, caller_class) + resolved = self._resolve_call_node(node, code_bytes, caller_file, + caller_class, caller_namespace) if resolved: calls.add(resolved) stack.extend(reversed(node.children)) @@ -181,10 +183,12 @@ def _extract_calls_from_code(self, code: str, caller_id: str) -> Set[str]: return calls def _resolve_call_node(self, node, source: bytes, caller_file: str, - caller_class: Optional[str]) -> Optional[str]: + caller_class: Optional[str], + caller_namespace: Optional[str] = None) -> Optional[str]: """Resolve a tree-sitter call node to a function ID.""" if node.type == 'function_call_expression': - return self._resolve_function_call(node, source, caller_file, caller_class) + return self._resolve_function_call(node, source, caller_file, caller_class, + caller_namespace) elif node.type == 'member_call_expression': return self._resolve_member_call(node, source, caller_file, caller_class) elif node.type == 'scoped_call_expression': @@ -192,7 +196,8 @@ def _resolve_call_node(self, node, source: bytes, caller_file: str, return None def _resolve_function_call(self, node, source: bytes, caller_file: str, - caller_class: Optional[str]) -> Optional[str]: + caller_class: Optional[str], + caller_namespace: Optional[str] = None) -> Optional[str]: """Resolve a simple function call like func().""" func_name = None @@ -213,7 +218,7 @@ def _resolve_function_call(self, node, source: bytes, caller_file: str, if self._is_builtin(func_name): return None - return self._resolve_simple_call(func_name, caller_file, caller_class) + return self._resolve_simple_call(func_name, caller_file, caller_class, caller_namespace) def _resolve_member_call(self, node, source: bytes, caller_file: str, caller_class: Optional[str]) -> Optional[str]: @@ -271,8 +276,9 @@ def _resolve_scoped_call(self, node, source: bytes, caller_file: str, return self._resolve_class_call(scope, method_name, caller_file) def _resolve_simple_call(self, func_name: str, caller_file: str, - caller_class: Optional[str]) -> Optional[str]: - """Resolve a simple function call to a function ID.""" + caller_class: Optional[str], + caller_namespace: Optional[str] = None) -> Optional[str]: + """Resolve a simple (unqualified) function call to a function ID.""" # 1. Check same class first (implicit $this) if caller_class: result = self._resolve_self_call(func_name, caller_file, caller_class) @@ -299,14 +305,30 @@ def _resolve_simple_call(self, func_name: str, caller_file: str, if func_data.get('name') == func_name: return func_id - # 4. Unique name match across files + # 4. Unique name match across files. An unqualified call resolves within + # the caller's own namespace; a function in a different namespace is not + # reachable this way, so a same-named function elsewhere must not leak an + # edge across the namespace boundary. candidates = self.functions_by_name.get(func_name, []) - candidates = [c for c in candidates if not self.functions.get(c, {}).get('class_name')] + candidates = [c for c in candidates + if not self.functions.get(c, {}).get('class_name') + and self._namespace_compatible( + self.functions.get(c, {}).get('namespace_name'), caller_namespace)] if len(candidates) == 1: return candidates[0] return None + @staticmethod + def _namespace_compatible(candidate_ns: Optional[str], + caller_ns: Optional[str]) -> bool: + """Whether an unqualified call from caller_ns may bind a function in + candidate_ns. They must be the same namespace (treating None / '' / '\\' + as the global namespace).""" + def norm(ns): + return (ns or '').strip('\\') + return norm(candidate_ns) == norm(caller_ns) + def _resolve_self_call(self, method_name: str, caller_file: str, caller_class: str) -> Optional[str]: """Resolve a $this->method() or self::method() call within a class.""" diff --git a/libs/openant-core/parsers/ruby/call_graph_builder.py b/libs/openant-core/parsers/ruby/call_graph_builder.py index 7e5d533b..9e1f7212 100644 --- a/libs/openant-core/parsers/ruby/call_graph_builder.py +++ b/libs/openant-core/parsers/ruby/call_graph_builder.py @@ -32,6 +32,7 @@ """ import json +import posixpath import re import sys from pathlib import Path @@ -131,6 +132,11 @@ def __init__(self, extractor_output: Dict, options: Optional[Dict] = None): self.functions_by_name: Dict[str, List[str]] = {} self.functions_by_file: Dict[str, List[str]] = {} self.methods_by_class: Dict[str, List[str]] = {} + # Module-level functions, keyed by module_name. Ruby module functions + # carry module_name (class_name is None), so methods_by_class never + # indexed them and their Module.method / same-module sibling calls were + # unresolvable while a bare call to one could leak into an unrelated file. + self.methods_by_module: Dict[str, List[str]] = {} self._build_indexes() @@ -159,6 +165,12 @@ def _build_indexes(self) -> None: self.methods_by_class[class_key] = [] self.methods_by_class[class_key].append(func_id) + module_name = func_data.get('module_name') + if module_name and not class_name: + if module_name not in self.methods_by_module: + self.methods_by_module[module_name] = [] + self.methods_by_module[module_name].append(func_id) + def _is_builtin(self, name: str) -> bool: """Check if name is a Ruby builtin or common method.""" return name in RUBY_BUILTINS @@ -169,6 +181,8 @@ def _extract_calls_from_code(self, code: str, caller_id: str) -> Set[str]: caller_file = caller_id.split(':')[0] caller_func = self.functions.get(caller_id, {}) caller_class = caller_func.get('class_name') + caller_module = caller_func.get('module_name') + caller_method = caller_func.get('name') code_bytes = code.encode('utf-8', errors='replace') try: @@ -180,19 +194,34 @@ def _extract_calls_from_code(self, code: str, caller_id: str) -> Set[str]: while stack: node = stack.pop() if node.type == 'call': - resolved = self._resolve_call_node(node, code_bytes, caller_file, caller_class) + resolved = self._resolve_call_node(node, code_bytes, caller_file, + caller_class, caller_module, caller_method) if resolved: calls.add(resolved) + elif node.type == 'super': + # A bare `super` is its own node (not a `call`); `super(args)` is a + # `call` whose first child is a `super` node, handled below. + if node.parent is None or node.parent.type != 'call': + resolved = self._resolve_super_call(caller_file, caller_class, caller_method) + if resolved: + calls.add(resolved) stack.extend(reversed(node.children)) return calls def _resolve_call_node(self, node, source: bytes, caller_file: str, - caller_class: Optional[str]) -> Optional[str]: + caller_class: Optional[str], + caller_module: Optional[str] = None, + caller_method: Optional[str] = None) -> Optional[str]: """Resolve a tree-sitter call node to a function ID.""" + # `super(args)` is a call node whose head is a `super` node, not an + # identifier method name -- resolve it against the superclass. + if any(c.type == 'super' for c in node.children): + return self._resolve_super_call(caller_file, caller_class, caller_method) # Extract method name method_name = None receiver = None + args_node = None for child in node.children: if child.type == 'identifier' and method_name is None: @@ -200,6 +229,8 @@ def _resolve_call_node(self, node, source: bytes, caller_file: str, elif child.type == '.': continue elif child.type in ('argument_list', 'block', 'do_block'): + if child.type == 'argument_list' and args_node is None: + args_node = child continue elif method_name is None and child.type not in ('identifier',): # This might be the receiver @@ -210,60 +241,196 @@ def _resolve_call_node(self, node, source: bytes, caller_file: str, if not method_name: return None + # ClassName.new(...) -> the class's initialize. `new` is in RUBY_BUILTINS, + # so this must precede the builtin filter below or the edge is dropped. + if method_name == 'new' and receiver and receiver[0:1].isupper(): + return self._resolve_class_call(receiver, 'initialize', caller_file) + + # send/public_send/__send__ with a literal symbol/string argument: the + # dispatched method is the first literal arg. Runtime-string targets are + # not statically recoverable. These verbs are filtered as builtins below, + # so resolve the literal-symbol case before that filter. + if method_name in ('send', 'public_send', '__send__'): + target = self._literal_symbol_arg(args_node, source) + if target is None: + return None + if receiver == 'self' or receiver is None: + if caller_class: + return self._resolve_self_call(target, caller_file, caller_class) + return self._resolve_simple_call(target, caller_file, caller_class, caller_module) + if receiver[0:1].isupper(): + return self._resolve_class_call(receiver, target, caller_file) + return None + if self._is_builtin(method_name): return None # self.method(...) - same class if receiver == 'self' and caller_class: return self._resolve_self_call(method_name, caller_file, caller_class) + # self.method(...) inside a module function (module_name, no class) + if receiver == 'self' and caller_module: + return self._resolve_module_call(caller_module, method_name, caller_file) # No receiver - simple function call if receiver is None: - return self._resolve_simple_call(method_name, caller_file, caller_class) + return self._resolve_simple_call(method_name, caller_file, caller_class, caller_module) - # Receiver is a constant (ClassName.method) - class method call + # Receiver is a constant (ClassName.method or ModuleName.method) if receiver and receiver[0:1].isupper(): return self._resolve_class_call(receiver, method_name, caller_file) return None + @staticmethod + def _literal_symbol_arg(args_node, source: bytes) -> Optional[str]: + """Return the first literal symbol/string argument value, or None. + + Handles ``:foo`` (simple_symbol) and ``"foo"``/``'foo'`` (string). A + non-literal first argument (variable, interpolation) has no static target. + """ + if args_node is None: + return None + for child in args_node.children: + if child.type == 'simple_symbol': + text = source[child.start_byte:child.end_byte].decode('utf-8', errors='replace') + return text[1:] if text.startswith(':') else text + if child.type == 'string': + for sc in child.children: + if sc.type == 'string_content': + return source[sc.start_byte:sc.end_byte].decode('utf-8', errors='replace') + return None + if child.type in ('(', ')', ','): + continue + # First non-literal positional argument: no static target. + return None + return None + + def _resolve_super_call(self, caller_file: str, caller_class: Optional[str], + caller_method: Optional[str]) -> Optional[str]: + """Resolve a `super` call to the same-named method in the superclass. + + `super` re-dispatches the CURRENT method name (caller_method) up the + ancestor chain, so the target is the superclass's method of the same name. + """ + if not caller_class or not caller_method: + return None + superclass = self._superclass_of(caller_file, caller_class) + if not superclass: + return None + if '::' in superclass: + superclass = superclass.rsplit('::', 1)[-1] + return self._resolve_class_call(superclass, caller_method, caller_file) + + def _superclass_of(self, caller_file: str, caller_class: str) -> Optional[str]: + """Return the superclass of caller_class defined in caller_file, or None.""" + class_data = self.classes.get(f"{caller_file}:{caller_class}") + if class_data: + return class_data.get('superclass') + for data in self.classes.values(): + if data.get('name') == caller_class and data.get('file_path') == caller_file: + return data.get('superclass') + return None + def _resolve_simple_call(self, func_name: str, caller_file: str, - caller_class: Optional[str]) -> Optional[str]: - """Resolve a simple function call to a function ID.""" + caller_class: Optional[str], + caller_module: Optional[str] = None) -> Optional[str]: + """Resolve a simple (no-receiver) function call to a function ID.""" # 1. Check same class first (implicit self) if caller_class: result = self._resolve_self_call(func_name, caller_file, caller_class) if result: return result - # 2. Check same file + # 1b. Inside a module function, a bare call resolves to a sibling of the + # SAME module first (any file), before falling through to file/global + # lookups. This is the same-module-sibling path. + if caller_module: + result = self._resolve_module_call(caller_module, func_name, caller_file) + if result: + return result + + # 2. Check same file. A module function is only a valid same-file target + # when the caller is in the same module (handled in 1b); a bare call + # from outside any module must not bind to a same-file module fn, so + # require both class_name and module_name to be unset here. same_file_funcs = self.functions_by_file.get(caller_file, []) for func_id in same_file_funcs: func_data = self.functions.get(func_id, {}) - if func_data.get('name') == func_name and not func_data.get('class_name'): + if (func_data.get('name') == func_name + and not func_data.get('class_name') + and not func_data.get('module_name')): return func_id - # 3. Check require-resolved files + # 3. Check require-resolved files (anchored). file_imports = self.imports.get(caller_file, {}) for import_name, import_type in file_imports.items(): - if import_type in ('require', 'require_relative'): - # Try matching import path to file - for file_path in self.functions_by_file: - if file_path.endswith(f"{import_name}.rb") or import_name in file_path: - file_funcs = self.functions_by_file[file_path] - for func_id in file_funcs: - func_data = self.functions.get(func_id, {}) - if func_data.get('name') == func_name: - return func_id - - # 4. Unique name match across files + if import_type not in ('require', 'require_relative'): + continue + target_file = self._resolve_import_file(import_name, import_type, caller_file) + if target_file is None: + continue + for func_id in self.functions_by_file.get(target_file, []): + func_data = self.functions.get(func_id, {}) + if (func_data.get('name') == func_name + and not func_data.get('class_name') + and not func_data.get('module_name')): + return func_id + + # 4. Unique name match across files. Restrict to genuine top-level + # functions (neither class- nor module-bound) so a module function + # never leaks an edge to an unrelated bare call. candidates = self.functions_by_name.get(func_name, []) - candidates = [c for c in candidates if not self.functions.get(c, {}).get('class_name')] + candidates = [c for c in candidates + if not self.functions.get(c, {}).get('class_name') + and not self.functions.get(c, {}).get('module_name')] if len(candidates) == 1: return candidates[0] return None + def _resolve_import_file(self, import_name: str, import_type: str, + caller_file: str) -> Optional[str]: + """Resolve a require / require_relative target to a known file path. + + `require_relative` is anchored to the caller file's directory and + normalized (so `./helper` and `../lib/util` resolve correctly even when + the basename collides). `require` matches by anchored file name, never an + unanchored substring (which over-matched any path containing the string). + """ + if import_type == 'require_relative': + base_dir = posixpath.dirname(caller_file) + anchored = posixpath.normpath(posixpath.join(base_dir, import_name)) + target = anchored if anchored.endswith('.rb') else f"{anchored}.rb" + return target if target in self.functions_by_file else None + + # `require 'name'` / `require 'path/name'`: match the file by its name + # component, anchored -- not a bare substring. + candidate = import_name if import_name.endswith('.rb') else f"{import_name}.rb" + if candidate in self.functions_by_file: + return candidate + base = candidate.rsplit('/', 1)[-1] + matches = [fp for fp in self.functions_by_file + if fp == base or fp.endswith(f"/{base}")] + return matches[0] if len(matches) == 1 else None + + def _resolve_module_call(self, module_name: str, method_name: str, + caller_file: str) -> Optional[str]: + """Resolve a ModuleName.method() / same-module sibling call. + + Prefers a same-file definition, then any file defining the module. + """ + fallback = None + for func_id in self.methods_by_module.get(module_name, []): + func_data = self.functions.get(func_id, {}) + if func_data.get('name') != method_name: + continue + if func_data.get('file_path') == caller_file: + return func_id + if fallback is None: + fallback = func_id + return fallback + def _resolve_self_call(self, method_name: str, caller_file: str, caller_class: str) -> Optional[str]: """Resolve a self.method() call within a class.""" @@ -279,7 +446,11 @@ def _resolve_self_call(self, method_name: str, caller_file: str, def _resolve_class_call(self, class_name: str, method_name: str, caller_file: str) -> Optional[str]: - """Resolve a ClassName.method() call.""" + """Resolve a ClassName.method() or ModuleName.method() call. + + A constant receiver may name a class or a module; module functions are not + in methods_by_class, so fall back to the module index. + """ # Check same file first class_key = f"{caller_file}:{class_name}" if class_key in self.methods_by_class: @@ -296,7 +467,8 @@ def _resolve_class_call(self, class_name: str, method_name: str, if func_data.get('name') == method_name: return func_id - return None + # The receiver may be a module (e.g. Utils.helper for a module_function). + return self._resolve_module_call(class_name, method_name, caller_file) def _extract_calls_regex(self, code: str, caller_id: str) -> Set[str]: """Fallback regex-based call extraction for unparseable code.""" diff --git a/libs/openant-core/parsers/ruby/function_extractor.py b/libs/openant-core/parsers/ruby/function_extractor.py index 798945bb..cfbc0544 100644 --- a/libs/openant-core/parsers/ruby/function_extractor.py +++ b/libs/openant-core/parsers/ruby/function_extractor.py @@ -366,7 +366,7 @@ def process_file(self, file_path: Path) -> None: self.stats['files_with_errors'] += 1 return - relative_path = str(file_path.relative_to(self.repo_path)) + relative_path = file_path.relative_to(self.repo_path).as_posix() # posix-normalize keys for cross-platform call-graph resolution try: tree = self.parser.parse(source) diff --git a/libs/openant-core/tests/parsers/php/__init__.py b/libs/openant-core/tests/parsers/php/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/openant-core/tests/parsers/php/test_call_graph_builder.py b/libs/openant-core/tests/parsers/php/test_call_graph_builder.py new file mode 100644 index 00000000..f0839fa6 --- /dev/null +++ b/libs/openant-core/tests/parsers/php/test_call_graph_builder.py @@ -0,0 +1,77 @@ +r"""PHP call-graph resolution: a bare function call must not resolve across namespaces. + +`_resolve_simple_call` consulted class_name but never namespace_name, so a bare +`helper()` from namespace App\Other leaked an edge to App\Utils\helper. + +The module basename ``call_graph_builder.py`` recurs across every parser, so the +PHP builder is loaded under a UNIQUE importlib name. +""" +import importlib.util +import sys +from pathlib import Path + +CORE = Path(__file__).resolve().parents[3] +if str(CORE) not in sys.path: + sys.path.insert(0, str(CORE)) + + +def _load(unique_name, relpath): + spec = importlib.util.spec_from_file_location(unique_name, str(CORE / relpath)) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +_cgb = _load("php_call_graph_builder_isolated", "parsers/php/call_graph_builder.py") +CallGraphBuilder = _cgb.CallGraphBuilder + + +def _build(funcs): + b = CallGraphBuilder({"functions": funcs, "classes": {}, "imports": {}, "repository": "/r"}) + b.build_call_graph() + return b + + +def test_bare_call_does_not_leak_across_namespaces(): + """A bare helper() in App\\Other must NOT edge to App\\Utils\\helper. + + Driven through the full call-graph build so the caller's namespace is threaded + exactly as in production; the base builder ignores namespace_name and leaks the + edge. + """ + funcs = { + "utils.php:helper": { + "name": "helper", "file_path": "utils.php", + "class_name": None, "namespace_name": "App\\Utils", + "code": " initialize untracked + - send/public_send/__send__ literal-symbol dispatch dropped + - require_relative not anchored to caller dir (basename collision) + - require import matched by unanchored substring (wrong file) + - require_relative '../...' not normalized against caller dir +""" +import importlib.util +import os +import sys +import tempfile +from pathlib import Path + +CORE = Path(__file__).resolve().parents[3] +if str(CORE) not in sys.path: + sys.path.insert(0, str(CORE)) + + +def _load(unique_name, relpath): + spec = importlib.util.spec_from_file_location(unique_name, str(CORE / relpath)) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +_cgb = _load("ruby_call_graph_builder_isolated", "parsers/ruby/call_graph_builder.py") +_fe = _load("ruby_function_extractor_isolated", "parsers/ruby/function_extractor.py") +CallGraphBuilder = _cgb.CallGraphBuilder +FunctionExtractor = _fe.FunctionExtractor + + +def _build_from_sources(files): + """Write Ruby sources, run the real extractor + builder, return the builder.""" + d = tempfile.mkdtemp() + for name, code in files.items(): + path = os.path.join(d, name) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as fh: + fh.write(code) + extractor = FunctionExtractor(d) + result = extractor.extract_all(list(files.keys())) + builder = CallGraphBuilder(result) + builder.build_call_graph() + return builder + + +# -------------------------------------------------------------------------- +# Module-function resolution cluster (shared root: no methods_by_module index) +# -------------------------------------------------------------------------- + +def test_module_singleton_self_call_resolves(): + """`def self.api` calling `self.helper` must edge to the module's helper.""" + b = _build_from_sources({ + "u.rb": "module Utils\n def self.helper(x)\n x\n end\n" + " def self.api(i)\n self.helper(i)\n end\nend\n", + }) + assert b.call_graph.get("u.rb:Utils.api") == ["u.rb:Utils.helper"], b.call_graph + + +def test_module_function_call_does_not_leak_to_outside_caller(): + """a bare `helper()` OUTSIDE Utils must NOT resolve to Utils.helper.""" + b = _build_from_sources({ + "utils.rb": "module Utils\n def helper(x)\n x\n end\nend\n", + "main.rb": "class App\n def run\n helper(1)\n end\nend\n", + }) + assert b.call_graph.get("main.rb:App.run") == [], ( + f"module leak: outside-module bare call resolved to a module fn: {b.call_graph}" + ) + + +def test_module_method_resolved_cross_file(): + """`Utils.helper(1)` from another file must edge to the module fn.""" + b = _build_from_sources({ + "utils.rb": "module Utils\n module_function\n def helper(x)\n x\n end\nend\n", + "main.rb": "class App\n def run\n Utils.helper(1)\n end\nend\n", + }) + assert b.call_graph.get("main.rb:App.run") == ["utils.rb:Utils.helper"], b.call_graph + + +def test_module_function_sibling_resolves_by_module_not_accident(): + """A cross-file same-module sibling must resolve by module. + + `Utils.api` (a.rb) calls bare `helper`, defined in `Utils` in b.rb; a decoy + `Other.helper` in c.rb makes the unsound unique-name fallback ambiguous, so at + base the edge is dropped. The module-scoped resolution must pick b.rb's helper. + """ + b = _build_from_sources({ + "a.rb": "module Utils\n module_function\n def api(i)\n helper(i)\n end\nend\n", + "b.rb": "module Utils\n module_function\n def helper(x)\n x\n end\nend\n", + "c.rb": "module Other\n module_function\n def helper(y)\n y\n end\nend\n", + }) + assert b.call_graph.get("a.rb:Utils.api") == ["b.rb:Utils.helper"], b.call_graph + + +# -------------------------------------------------------------------------- +# Inheritance / dispatch +# -------------------------------------------------------------------------- + +def test_super_resolves_to_superclass_method(): + """`super` in Child#greet must edge to Base#greet.""" + b = _build_from_sources({ + "a.rb": "class Base\n def greet\n 1\n end\nend\n" + "class Child < Base\n def greet\n super\n end\nend\n", + }) + assert b.call_graph.get("a.rb:Child.greet") == ["a.rb:Base.greet"], b.call_graph + + +def test_constructor_new_resolves_to_initialize(): + """`Widget.new(1)` must edge to Widget#initialize.""" + b = _build_from_sources({ + "a.rb": "class Widget\n def initialize(x)\n @x = x\n end\nend\n" + "class App\n def run\n Widget.new(1)\n end\nend\n", + }) + assert b.call_graph.get("a.rb:App.run") == ["a.rb:Widget.initialize"], b.call_graph + + +def test_send_literal_symbol_dispatch_resolves(): + """send/public_send/__send__ with a literal symbol resolve the target.""" + for verb in ("send", "public_send", "__send__"): + b = _build_from_sources({ + "a.rb": f"class App\n def target\n 1\n end\n" + f" def run\n {verb}(:target)\n end\nend\n", + }) + assert b.call_graph.get("a.rb:App.run") == ["a.rb:App.target"], ( + f"{verb}(:target) not resolved: {b.call_graph}" + ) + + +# -------------------------------------------------------------------------- +# require_relative anchoring (one line-252 root: substring + no caller-dir anchor) +# -------------------------------------------------------------------------- + +def test_require_relative_anchored_to_caller_dir_on_collision(): + """`require_relative './helper'` must anchor to the caller's dir. + + Two files share the basename helper.rb; only the caller-dir one is the target. + """ + b = _build_from_sources({ + "lib/sub/a.rb": "require_relative './helper'\ndef go\n do_help(1)\nend\n", + "lib/sub/helper.rb": "def do_help(x)\n x\nend\n", + "lib/other/helper.rb": "def do_help(y)\n y\nend\n", + }) + assert b.call_graph.get("lib/sub/a.rb:go") == ["lib/sub/helper.rb:do_help"], b.call_graph + + +def test_require_relative_parent_dir_normalized(): + """`require_relative '../lib/util'` normalized against the caller dir.""" + b = _build_from_sources({ + "app/main.rb": "require_relative '../lib/util'\ndef run\n helper2(1)\nend\n", + "lib/util.rb": "def helper2(x)\n x\nend\n", + "app/decoy.rb": "def helper2(y)\n y\nend\n", + }) + assert b.call_graph.get("app/main.rb:run") == ["lib/util.rb:helper2"], b.call_graph + + +def test_require_import_matches_anchored_file_not_substring(): + """`require 'util'` must bind lib/util.rb, not lib/util_extra.rb. + + Both files define `helper`, so the unique-name fallback cannot decide; only an + anchored require match resolves it. The unanchored `'util' in file_path` + substring would also match the decoy lib/util_extra.rb. + """ + # The decoy is indexed FIRST so the unanchored substring scan reaches it + # before the real target -- pinning the wrong-file binding at base. + funcs = { + "lib/util_extra.rb:helper": { + "name": "helper", "file_path": "lib/util_extra.rb", + "class_name": None, "module_name": None, "code": "", + }, + "lib/util.rb:helper": { + "name": "helper", "file_path": "lib/util.rb", + "class_name": None, "module_name": None, "code": "", + }, + } + b = CallGraphBuilder({"functions": funcs, "classes": {}, + "imports": {"caller.rb": {"util": "require"}}, "repository": "/r"}) + assert b._resolve_simple_call("helper", "caller.rb", None) == "lib/util.rb:helper", ( + "require 'util' must anchor to lib/util.rb, not the substring-matched decoy" + )