mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 15:40:05 +00:00
fix: harden Ruby inherited lookup proof
This commit is contained in:
1 parent
aec5c32000
commit
43f56f8633
3 files changed
+259
-8
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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]] = {}
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user