fix(r): resolve R6 self$ and private$ intra-class method calls

An R6 method reaches a sibling only through self$method() or private$method(),
never as a bare name, but walk_calls only handled a bare identifier callee. So
every intra-class call inside an R6 class was dropped, leaving methods that
clearly call each other disconnected in the graph.

Handle an extract_operator callee whose receiver is self or private: resolve
the method against the enclosing class scope, which is already reachable from
the caller via scope_parent. super$ is deliberately excluded — it dispatches to
a parent class this single-file pass cannot see, so a local binding would be
wrong.

Added a regression test covering both self$ and private$ calls.
This commit is contained in:
rajatnagda45 committed 2026-09-27 00:37:28 +05:30
1 parent 54181b5b88
commit 4e00eaf105
2 files changed
+41 -1

No files matched your search

+16
View File
@@ -399,6 +399,22 @@ def extract_r(path: Path) -> dict:
"source_file": source_file,
"source_location": f"L{line}",
})
elif callee_node is not None and callee_node.type == "extract_operator":
# `self$method()` / `private$method()`: an R6 method reaches its
# siblings only through self/private, never as a bare name, so
# these intra-class calls were dropped entirely. Resolve the
# method against the enclosing class scope (walked from the caller
# via scope_parent). super$ is left out: it dispatches to a parent
# class this pass cannot see, so binding it locally would be wrong.
parts = [c for c in callee_node.children if c.type == "identifier"]
if (
len(parts) == 2
and _read_text(parts[0], source) in ("self", "private")
):
method = _read_text(parts[1], source)
target = resolve_local(caller_id, method)
if target is not None:
add_edge(caller_id, target, "calls", node.start_point[0] + 1)
elif callee_node is not None and callee_node.type == "namespace_operator":
package_node = callee_node.child_by_field_name("lhs")
function_node = callee_node.child_by_field_name("rhs")
+25 -1
View File
@@ -16,7 +16,7 @@ from pathlib import Path
from graphify.extract import extract
from graphify.extract import extract, extract_r
@@ -111,6 +111,30 @@ def test_r_malformed_tail_comments_and_strings_do_not_create_phantoms(tmp_path):
assert labels.isdisjoint({'ghost()', 'hidden()'})
def test_r6_self_and_private_method_calls_resolve(tmp_path):
"""R6 methods reach their siblings through `self$` / `private$`, never as a
bare name. Those intra-class calls were dropped because walk_calls only
handled a bare identifier callee."""
source = tmp_path / "counter.R"
source.write_text(
'Counter <- R6Class("Counter",\n'
" public = list(\n"
" increment = function() self$report(),\n"
" report = function() print(1)\n"
" ),\n"
" private = list(\n"
" log = function() private$fmt(),\n"
" fmt = function() 2\n"
" )\n"
")\n",
encoding="utf-8",
)
result = extract_r(source)
calls = _edge_labels(result, "calls")
assert ("increment()", "report()") in calls, "self$ call dropped"
assert ("log()", "fmt()") in calls, "private$ call dropped"
def test_r_missing_parser_reports_install_hint(tmp_path, monkeypatch, capsys):
source = tmp_path / "missing.R"
source.write_text('run <- function() 1\n', encoding="utf-8")