diff --git a/graphify/extractors/r.py b/graphify/extractors/r.py index 62c4901a7..2033a2413 100644 --- a/graphify/extractors/r.py +++ b/graphify/extractors/r.py @@ -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") diff --git a/tests/test_r_extractor.py b/tests/test_r_extractor.py index dfb0b9ad0..953587bde 100644 --- a/tests/test_r_extractor.py +++ b/tests/test_r_extractor.py @@ -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")