mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 07:29:33 +00:00
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:
1 parent
5c7b84792f
commit
dc43c7c646
3 files changed
+112
-3
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user