diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c86564000..23c899a5a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -112,3 +112,9 @@ pytest tests/ -q ``` The test suite is designed to avoid network access and uncontrolled filesystem effects; most tests are isolated with temporary directories and environment fixtures. + +R extraction preserves symbol-only bindings such as `%||%` as separate nodes. +When normal ID normalization removes an entire binding name, its UTF-8 bytes +provide a deterministic operator suffix within the same lexical owner. Its double +underscore separators cannot collide with an ordinary normalized binding ID. Local +call resolution stops if malformed scope metadata cycles. diff --git a/graphify/extractors/r.py b/graphify/extractors/r.py index 1c8f85819..3edc9ada2 100644 --- a/graphify/extractors/r.py +++ b/graphify/extractors/r.py @@ -182,10 +182,20 @@ def extract_r(path: Path) -> dict: return "" return _unquote(_read_text(node, source)) + def binding_id(owner: str, name: str) -> str: + suffix = _make_id(name) + if not suffix: + # Symbol-only R operators are real bindings. Repeated underscores + # cannot be produced by _make_id for ordinary names. + return f"{owner}__operator__{name.encode('utf-8').hex()}" + # Preserve the owner verbatim so nested bindings in an operator's scope + # keep its reserved namespace instead of normalizing it away. + return f"{owner}_{suffix}" + def declare_function( name: str, function: Node, body: Node | None, owner: str, relation: str = "contains" ) -> str: - nid = _make_id(owner, name) + nid = binding_id(owner, name) line = function.start_point[0] + 1 add_node(nid, f"{name}()", line, kind="function", callable_node=True) add_edge(owner, nid, relation, line) @@ -355,7 +365,7 @@ def extract_r(path: Path) -> dict: add_class_members(class_id, arguments) continue if top_level and name_node is not None: - nid = _make_id(owner, name) + nid = binding_id(owner, name) line = name_node.start_point[0] + 1 add_node(nid, name, line, kind="variable") add_edge(owner, nid, "contains", line) @@ -382,7 +392,9 @@ def extract_r(path: Path) -> dict: def resolve_local(caller_id: str, name: str) -> str | None: scope: str | None = caller_id - while scope is not None: + visited: set[str] = set() + while scope is not None and scope not in visited: + visited.add(scope) target = definitions.get(scope, {}).get(name) if target is not None: return target diff --git a/tests/test_r_extractor.py b/tests/test_r_extractor.py index 65d510a25..c28ccc17c 100644 --- a/tests/test_r_extractor.py +++ b/tests/test_r_extractor.py @@ -181,3 +181,94 @@ def test_r_missing_parser_reports_install_hint(tmp_path, monkeypatch, capsys): assert result["nodes"] == [] assert 'pip install "graphifyy[r]"' in capsys.readouterr().err + + +def test_r_symbol_only_bindings_have_distinct_ids(tmp_path): + source = tmp_path / "ops.R" + source.write_text( + "`%||%` <- function(a, b) a\n" + "`%>%` <- function(a, b) b\n" + "`%+%` <- 42\n", + encoding="utf-8", + ) + result = extract_r(source) + by_label = {node["label"]: node for node in result["nodes"]} + assert {"`%||%`()", "`%>%`()", "`%+%`"} <= by_label.keys() + assert len({by_label[label]["id"] for label in by_label}) == len(by_label) + file_id = by_label["ops.R"]["id"] + for label in ("`%||%`()", "`%>%`()", "`%+%`"): + assert (file_id, by_label[label]["id"]) in { + (edge["source"], edge["target"]) + for edge in result["edges"] if edge["relation"] == "contains" + } + + +def test_r_symbol_only_function_with_external_call_terminates(tmp_path): + import json + import subprocess + + source = tmp_path / "ops.R" + source.write_text( + "`%||%` <- function(a, b) if (is.null(a)) b else a\n", + encoding="utf-8", + ) + completed = subprocess.run( + [sys.executable, "-c", + "import json, sys; from pathlib import Path; " + "from graphify.extractors.r import extract_r; " + "print(json.dumps(extract_r(Path(sys.argv[1]))))", str(source)], + check=True, capture_output=True, text=True, timeout=10, + ) + result = json.loads(completed.stdout) + assert "`%||%`()" in {node["label"] for node in result["nodes"]} + assert any(call["callee"] == "is.null" for call in result["raw_calls"]) + + +def test_r_operator_fallback_does_not_collide_with_ordinary_binding(tmp_path): + from graphify.build import build + + source = tmp_path / "ops.R" + operator = "`%||%` <- function(a, b) a\n" + ordinary = "operator_60257c7c2560 <- function(a, b) b\n" + ids_by_order = [] + for declarations in (operator + ordinary, ordinary + operator): + source.write_text( + declarations + + "ordinary_call <- function() operator_60257c7c2560(1, 2)\n" + + "operator_call <- function() `%||%`(1, 2)\n", + encoding="utf-8", + ) + result = extract_r(source) + by_label = {node["label"]: node for node in result["nodes"]} + assert {"`%||%`()", "operator_60257c7c2560()"} <= by_label.keys() + assert ("ordinary_call()", "operator_60257c7c2560()") in _edge_labels(result, "calls") + assert ("operator_call()", "`%||%`()") in _edge_labels(result, "calls") + ids_by_order.append({label: node["id"] for label, node in by_label.items()}) + graph = build([result]) + assert by_label["`%||%`()"]["id"] in graph + assert by_label["operator_60257c7c2560()"]["id"] in graph + assert ids_by_order[0] == ids_by_order[1] + + +def test_r_operator_scope_keeps_nested_binding_identity(tmp_path): + source = tmp_path / "ops.R" + source.write_text( + "`%||%` <- function() { inner <- function() 1; inner() }\n" + "operator_60257c7c2560 <- function() { inner <- function() 2; inner() }\n", + encoding="utf-8", + ) + result = extract_r(source) + inners = [node for node in result["nodes"] if node["label"] == "inner()"] + assert len(inners) == 2 + for label in ("`%||%`()", "operator_60257c7c2560()"): + owner = next(node["id"] for node in result["nodes"] if node["label"] == label) + contained = { + edge["target"] for edge in result["edges"] + if edge["source"] == owner and edge["relation"] == "contains" + } + called = { + edge["target"] for edge in result["edges"] + if edge["source"] == owner and edge["relation"] == "calls" + } + assert len(contained) == 1 + assert called == contained