From 43f56f86337fa0fa348c9d4a67498a2b112a5d90 Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Tue, 15 Sep 2026 04:07:13 -0500 Subject: [PATCH] fix: harden Ruby inherited lookup proof --- graphify/extractors/engine.py | 50 +++++++++- graphify/ruby_resolution.py | 38 ++++++-- tests/test_ruby_resolution.py | 179 ++++++++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 8 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index ecf323889..b2856a918 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3077,6 +3077,10 @@ _RUBY_LOOKUP_MUTATORS = frozenset( } ) +_RUBY_EXTERNAL_OWNER_MUTATORS = _RUBY_LOOKUP_MUTATORS | frozenset( + {"class_eval", "class_exec", "module_eval", "module_exec"} +) + def _ruby_body_has_lookup_barrier(body_node, source: bytes) -> bool: """Whether a lexical body can alter Ruby method lookup dynamically.""" @@ -3114,11 +3118,39 @@ def _ruby_body_has_lookup_barrier(body_node, source: bytes) -> bool: return visit(body_node, direct=True) +def _ruby_file_has_refinement_barrier(root_node, source: bytes) -> bool: + """Whether any lexical scope in a file activates or defines a refinement. + + ``using`` affects method lookup for definitions in its lexical scope, while + ``refine`` can hide method bodies behind a dynamic lookup layer. The + extractor does not model that lexical activation precisely, so inherited + implicit-self promotion fails closed for the whole file. + """ + + def visit(node) -> bool: + if node.type == "call": + method = node.child_by_field_name("method") + if method is not None and _read_text(method, source) in {"refine", "using"}: + return True + return any(visit(child) for child in node.children) + + return visit(root_node) + + def _ruby_external_method_owners(root_node, source: bytes) -> list[str]: """Return explicit constant owners modified outside their class body.""" owners: set[str] = set() def visit(node) -> None: + if node.type in {"class", "module"}: + name = node.child_by_field_name("name") + if name is not None and name.type == "scope_resolution": + raw = _ruby_const_full_name(name, source) + if raw: + # Compact declarations resolve a constant path rather than + # introducing every segment lexically. Until that lookup + # is modelled, guard every matching real owner. + owners.add(raw) if node.type == "singleton_method": owner = node.child_by_field_name("object") if owner is not None and _read_text(owner, source) != "self": @@ -3132,7 +3164,19 @@ def _ruby_external_method_owners(root_node, source: bytes) -> list[str]: raw = _ruby_const_full_name(owner, source) if raw: owners.add(raw) - return + return + if node.type == "call": + receiver = node.child_by_field_name("receiver") + method = node.child_by_field_name("method") + if ( + receiver is not None + and receiver.type in {"constant", "scope_resolution"} + and method is not None + and _read_text(method, source) in _RUBY_EXTERNAL_OWNER_MUTATORS + ): + raw = _ruby_const_full_name(receiver, source) + if raw: + owners.add(raw) if node.type == "method": return for child in node.children: @@ -3466,7 +3510,9 @@ def _extract_generic( if config.ts_module == "tree_sitter_ruby": file_node = next(n for n in nodes if n["id"] == file_nid) file_metadata = {"ruby_resolution_schema": 1} - if _ruby_body_has_lookup_barrier(root, source): + if _ruby_body_has_lookup_barrier( + root, source + ) or _ruby_file_has_refinement_barrier(root, source): file_metadata["ruby_lookup_unsafe"] = True external_owners = _ruby_external_method_owners(root, source) if external_owners: diff --git a/graphify/ruby_resolution.py b/graphify/ruby_resolution.py index 9cb8325ed..d0d5dbe45 100644 --- a/graphify/ruby_resolution.py +++ b/graphify/ruby_resolution.py @@ -118,7 +118,7 @@ def resolve_ruby_member_calls( class_def_nids: dict[str, list[str]] = {} method_index: dict[tuple[str, str], str] = {} method_owners: dict[str, set[str]] = {} - inheritance_edges: list[tuple[str, str | None, list[str] | None]] = [] + inheritance_edges: list[tuple[str, str, str | None, list[str] | None]] = [] for e in all_edges: if e.get("relation") == "inherits": src, tgt = e.get("source"), e.get("target") @@ -141,7 +141,9 @@ def resolve_ruby_member_calls( and all(isinstance(scope, str) for scope in candidate_scopes) else None ) - inheritance_edges.append((str(src), raw_base, lexical_scopes)) + inheritance_edges.append( + (str(src), str(tgt), raw_base, lexical_scopes) + ) continue if e.get("relation") != "method": continue @@ -203,8 +205,16 @@ def resolve_ruby_member_calls( ref_parts = [part for part in reference.split("::") if part] if not ref_parts: return None - scopes = [] if raw_base.startswith("::") else lexical_scopes - for scope in [*scopes, ""]: + if raw_base.startswith("::"): + lookup_scopes = [""] + elif lexical_scopes: + # A miss here is unsafe: Ruby searches inherited constants of the + # innermost lexical owner before any root fallback, and that lookup + # is not represented in the graph. + lookup_scopes = lexical_scopes + else: + lookup_scopes = [""] + for scope in lookup_scopes: candidate_label = "::".join( [*[part for part in scope.split("::") if part], *ref_parts] ) @@ -216,11 +226,27 @@ def resolve_ruby_member_calls( return None inheritance: dict[str, set[str]] = {} - for source, raw_base, lexical_scopes in inheritance_edges: + for source, edge_target, raw_base, lexical_scopes in inheritance_edges: if source not in ruby_class_nids: continue resolved = _resolve_base(raw_base, lexical_scopes) - if resolved is not None: + target_node = node_by_id.get(edge_target) + target_label = str(target_node.get("label", "")) if target_node else "" + target_source = str(target_node.get("source_file", "")) if target_node else "" + raw_tail = str(raw_base or "").removeprefix("::").split("::")[-1] + edge_matches_reference = bool( + target_node + and raw_tail + and _key(target_label.split("::")[-1]) == _key(raw_tail) + ) + # The structural target is independent evidence. A normal unresolved + # Ruby superclass remains a source-less last-segment stub after corpus + # disambiguation, so accept that representation only when its label + # agrees with the raw reference. Conflicting edge/metadata pairs bail. + if resolved is not None and ( + resolved == edge_target + or (not target_source and edge_matches_reference) + ): inheritance.setdefault(source, set()).add(resolved) methods_by_kind: dict[tuple[str, str, str], set[str]] = {} diff --git a/tests/test_ruby_resolution.py b/tests/test_ruby_resolution.py index b639f3c73..31223cd9f 100644 --- a/tests/test_ruby_resolution.py +++ b/tests/test_ruby_resolution.py @@ -737,6 +737,112 @@ def test_compact_class_does_not_invent_lexical_superclass(tmp_path: Path) -> Non assert _only_call(graph)["confidence"] == "INFERRED" +def test_inherited_constant_lookup_does_not_fall_back_past_lexical_owner( + tmp_path: Path, +) -> None: + _write(tmp_path, "base.rb", "class Base\n def self.helper; :wrong; end\nend\n") + _write( + tmp_path, + "provider.rb", + "class Provider\n class Base\n class << self\n" + " attr_reader :helper\n end\n end\nend\n", + ) + _write( + tmp_path, + "child.rb", + "class Scope < Provider\n class Child < Base\n" + " def self.call\n helper()\n end\n end\nend\n", + ) + + graph = extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False) + + assert _only_call(graph)["confidence"] == "INFERRED" + + +def test_inherited_call_stays_inferred_under_enclosing_refinement( + tmp_path: Path, +) -> None: + _write(tmp_path, "base.rb", "class Base\n def helper; :base; end\nend\n") + _write( + tmp_path, + "refinement.rb", + "module R\n refine Base do\n" + " define_method(:helper) { :refined }\n end\nend\n", + ) + _write( + tmp_path, + "child.rb", + "module App\n using R\n class Child < Base\n" + " def call\n helper()\n end\n end\nend\n", + ) + + graph = extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False) + + assert _only_call(graph)["confidence"] == "INFERRED" + + +def test_inherited_call_stays_inferred_for_external_owner_mutator( + tmp_path: Path, +) -> None: + _write(tmp_path, "base.rb", "class Base\n def self.helper; :base; end\nend\n") + _write( + tmp_path, + "child.rb", + "class Child < Base\n def self.call\n helper()\n end\nend\n", + ) + _write( + tmp_path, + "override.rb", + "Child.define_singleton_method(:helper) { :child }\n", + ) + + graph = extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False) + + assert _only_call(graph)["confidence"] == "INFERRED" + + +def test_inherited_call_stays_inferred_for_external_owner_eval( + tmp_path: Path, +) -> None: + _write(tmp_path, "base.rb", "class Base\n def helper; :base; end\nend\n") + _write( + tmp_path, + "child.rb", + "class Child < Base\n def call\n helper()\n end\nend\n", + ) + _write( + tmp_path, + "patch.rb", + "Base.class_eval { prepend ExternalMethods }\n", + ) + + graph = extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False) + + assert _only_call(graph)["confidence"] == "INFERRED" + + +def test_inherited_call_stays_inferred_for_compact_reopening( + tmp_path: Path, +) -> None: + _write(tmp_path, "base.rb", "class Base\n def helper; :base; end\nend\n") + _write( + tmp_path, + "child.rb", + "module Namespace\n class Child < Base\n" + " def call\n helper()\n end\n end\nend\n", + ) + _write( + tmp_path, + "override.rb", + "module Wrapper\n class Namespace::Child\n" + " attr_reader :helper\n end\nend\n", + ) + + graph = extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False) + + assert _only_call(graph)["confidence"] == "INFERRED" + + def test_inherited_call_stops_at_ambiguous_nearer_method(tmp_path: Path) -> None: _write(tmp_path, "grand.rb", "class Grand\n def helper; :grand; end\nend\n") _write( @@ -920,3 +1026,76 @@ def test_inherited_resolver_never_creates_or_repoints_call_edge() -> None: assert with_wrong_call[-1] == wrong_edge assert with_wrong_call[-1]["target"] == "wrong" assert with_wrong_call[-1]["confidence"] == "INFERRED" + + +def test_inherited_resolver_requires_edge_target_to_match_metadata() -> None: + nodes = [ + { + "id": "child_file", + "label": "child.rb", + "source_file": "child.rb", + "metadata": {"ruby_resolution_schema": 1}, + }, + { + "id": "base_file", + "label": "base.rb", + "source_file": "base.rb", + "metadata": {"ruby_resolution_schema": 1}, + }, + { + "id": "other_file", + "label": "other.rb", + "source_file": "other.rb", + "metadata": {"ruby_resolution_schema": 1}, + }, + {"id": "child", "label": "Child", "source_file": "child.rb"}, + {"id": "base", "label": "Base", "source_file": "base.rb"}, + {"id": "other", "label": "Other", "source_file": "other.rb"}, + { + "id": "caller", + "label": ".call()", + "source_file": "child.rb", + "metadata": {"ruby_method_kind": "instance"}, + }, + { + "id": "helper", + "label": ".helper()", + "source_file": "base.rb", + "metadata": {"ruby_method_kind": "instance"}, + }, + ] + edge = { + "source": "caller", + "target": "helper", + "relation": "calls", + "context": "call", + "confidence": "INFERRED", + "confidence_score": 0.85, + "source_file": "child.rb", + "source_location": "L3", + } + edges = [ + {"source": "child", "target": "caller", "relation": "method"}, + { + "source": "child", + "target": "other", + "relation": "inherits", + "metadata": { + "ruby_superclass_ref": "Base", + "ruby_lexical_scopes": [], + }, + }, + {"source": "base", "target": "helper", "relation": "method"}, + edge, + ] + raw_call = { + "caller_nid": "caller", + "callee": "helper", + "is_member_call": False, + "source_file": "child.rb", + "source_location": "L3", + } + + resolve_ruby_member_calls([{"raw_calls": [raw_call]}], nodes, edges) + + assert edge["confidence"] == "INFERRED"