mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 15:40:05 +00:00
fix: guard Ruby namespace rebinding
(cherry picked from commit 1a7262e2a8)
This commit is contained in:
1 parent
436a735f7f
commit
7e857e020b
3 files changed
+144
-13
No files matched your search
@@ -3207,28 +3207,56 @@ 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 collect_constant_refs(node) -> None:
|
||||
def constant_refs(node) -> set[str]:
|
||||
if node.type in {"constant", "scope_resolution"}:
|
||||
raw = _ruby_const_full_name(node, source)
|
||||
if raw:
|
||||
owners.add(raw)
|
||||
return
|
||||
return {raw} if raw else set()
|
||||
if node.type not in {
|
||||
"left_assignment_list",
|
||||
"parenthesized_statements",
|
||||
"splat_argument",
|
||||
}:
|
||||
# Attribute/index writes such as `Namespace.config = value`
|
||||
# contain a constant receiver but do not rebind that constant.
|
||||
return set()
|
||||
refs: set[str] = set()
|
||||
for child in node.children:
|
||||
if child.is_named:
|
||||
collect_constant_refs(child)
|
||||
refs.update(constant_refs(child))
|
||||
return refs
|
||||
|
||||
def alias_rhs_refs(node) -> set[str]:
|
||||
if node.type in {"constant", "scope_resolution"}:
|
||||
raw = _ruby_const_full_name(node, source)
|
||||
return {raw} if raw else set()
|
||||
if node.type in {"parenthesized_statements", "right_assignment_list"}:
|
||||
refs: set[str] = set()
|
||||
for child in node.children:
|
||||
if child.is_named:
|
||||
refs.update(alias_rhs_refs(child))
|
||||
return refs
|
||||
return set()
|
||||
|
||||
def prefix_marker(raw: str) -> str:
|
||||
return f"{raw}::*"
|
||||
|
||||
def visit(node) -> None:
|
||||
if node.type in {"assignment", "operator_assignment"}:
|
||||
left = node.child_by_field_name("left")
|
||||
if left is not None:
|
||||
lhs_refs = constant_refs(left) if left is not None else set()
|
||||
if lhs_refs:
|
||||
# Any write can change which superclass a constant reference
|
||||
# denotes, including dynamic RHS and multiple assignment.
|
||||
collect_constant_refs(left)
|
||||
owners.update(prefix_marker(raw) for raw in lhs_refs)
|
||||
right = node.child_by_field_name("right")
|
||||
if node.type == "assignment" and right is not None:
|
||||
if node.type == "assignment" and lhs_refs and right is not None:
|
||||
# Treat both ends of a potential constant alias as unsafe. A
|
||||
# later mutation through the alias affects the original owner.
|
||||
collect_constant_refs(right)
|
||||
# later mutation through the alias affects descendant owners
|
||||
# too. Only direct/parenthesized constant RHS forms qualify;
|
||||
# ordinary values containing constant references do not.
|
||||
owners.update(
|
||||
prefix_marker(raw) for raw in alias_rhs_refs(right)
|
||||
)
|
||||
if node.type in {"class", "module"}:
|
||||
name = node.child_by_field_name("name")
|
||||
if name is not None and name.type == "scope_resolution":
|
||||
|
||||
@@ -223,6 +223,18 @@ def resolve_ruby_member_calls(
|
||||
return next(iter(candidates))
|
||||
if len(candidates) > 1:
|
||||
return None
|
||||
if scope and len(ref_parts) > 1:
|
||||
prefix_label = "::".join(
|
||||
[
|
||||
*[part for part in scope.split("::") if part],
|
||||
*ref_parts[:-1],
|
||||
]
|
||||
)
|
||||
if class_labels.get(prefix_label):
|
||||
# Ruby bound the qualified prefix in this nearer lexical
|
||||
# scope. Its inherited constants are not modelled, so do
|
||||
# not retry the same prefix in an outer scope.
|
||||
return None
|
||||
return None
|
||||
|
||||
inheritance: dict[str, set[str]] = {}
|
||||
@@ -266,14 +278,26 @@ def resolve_ruby_member_calls(
|
||||
def _has_external_method_owner(label: str) -> bool:
|
||||
label_parts = tuple(part for part in label.split("::") if part)
|
||||
for raw_owner in external_method_owners:
|
||||
is_prefix = raw_owner.endswith("::*")
|
||||
owner_ref = raw_owner.removesuffix("::*") if is_prefix else raw_owner
|
||||
owner_parts = tuple(
|
||||
part for part in raw_owner.removeprefix("::").split("::") if part
|
||||
part for part in owner_ref.removeprefix("::").split("::") if part
|
||||
)
|
||||
if not owner_parts:
|
||||
continue
|
||||
if raw_owner.startswith("::") and label_parts == owner_parts:
|
||||
if is_prefix:
|
||||
if owner_ref.startswith("::"):
|
||||
if label_parts[: len(owner_parts)] == owner_parts:
|
||||
return True
|
||||
elif any(
|
||||
label_parts[index : index + len(owner_parts)] == owner_parts
|
||||
for index in range(len(label_parts) - len(owner_parts) + 1)
|
||||
):
|
||||
return True
|
||||
continue
|
||||
if owner_ref.startswith("::") and label_parts == owner_parts:
|
||||
return True
|
||||
if not raw_owner.startswith("::") and label_parts[-len(owner_parts):] == owner_parts:
|
||||
if not owner_ref.startswith("::") and label_parts[-len(owner_parts):] == owner_parts:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -937,6 +937,55 @@ def test_external_owner_mutator_through_constant_alias_stays_inferred(
|
||||
assert _only_call(graph)["confidence"] == "INFERRED"
|
||||
|
||||
|
||||
def test_qualified_external_owner_through_namespace_alias_stays_inferred(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_write(tmp_path, "base.rb", "class Base\n def self.helper; :base; end\nend\n")
|
||||
_write(
|
||||
tmp_path,
|
||||
"child.rb",
|
||||
"module Namespace\n class Child < Base\n"
|
||||
" def self.call\n helper()\n end\n end\nend\n",
|
||||
)
|
||||
_write(
|
||||
tmp_path,
|
||||
"override.rb",
|
||||
"AliasNS = Namespace\n"
|
||||
"AliasNS::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_qualified_superclass_under_rebound_namespace_stays_inferred(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_write(
|
||||
tmp_path,
|
||||
"base.rb",
|
||||
"module Namespace\n class Base\n"
|
||||
" def self.helper; :wrong; end\n end\nend\n",
|
||||
)
|
||||
_write(
|
||||
tmp_path,
|
||||
"other.rb",
|
||||
"module Other\n class Base\n class << self\n"
|
||||
" attr_reader :helper\n end\n end\nend\n",
|
||||
)
|
||||
_write(
|
||||
tmp_path,
|
||||
"child.rb",
|
||||
"Namespace = Other\nclass Child < Namespace::Base\n"
|
||||
" def self.call\n 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_stays_inferred_for_compact_reopening(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
@@ -980,6 +1029,36 @@ def test_absolute_class_declaration_inside_namespace_stays_inferred(
|
||||
assert _only_call(graph)["confidence"] == "INFERRED"
|
||||
|
||||
|
||||
def test_qualified_superclass_stops_at_nearer_lexical_prefix(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_write(
|
||||
tmp_path,
|
||||
"base.rb",
|
||||
"module Outer\n module Services\n class Base\n"
|
||||
" def self.helper; :wrong; end\n end\n 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",
|
||||
"module Outer\n module Inner\n"
|
||||
" class Services < ::Provider; end\n"
|
||||
" class Child < Services::Base\n"
|
||||
" def self.call\n helper()\n end\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_stops_at_ambiguous_nearer_method(tmp_path: Path) -> None:
|
||||
_write(tmp_path, "grand.rb", "class Grand\n def helper; :grand; end\nend\n")
|
||||
_write(
|
||||
|
||||
Reference in new issue
Block a user