fix(r): preserve symbol-only binding identity

Keep operator scopes separate from their containing file and ordinary normalized identifiers, including nested bindings.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
This commit is contained in:
Huanyi XieandOpenAI Codex committed 2026-10-06 19:47:19 +03:00
1 parent 5c7b84792f
commit dc43c7c646
3 files changed
+112 -3

No files matched your search

+6
View File
@@ -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.
+15 -3
View File
@@ -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
+91
View File
@@ -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