fix: harden Ruby inherited lookup proof

This commit is contained in:
oleksii-tumanov committed 2026-09-15 04:07:13 -05:00
1 parent aec5c32000
commit 43f56f8633
3 files changed
+259 -8

No files matched your search

+48 -2
View File
@@ -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:
+32 -6
View File
@@ -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]] = {}
+179
View File
@@ -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"