mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-14 07:59:40 +00:00
fix(erlang): record local fun references (fun Name/Arity)
The body walker only visited `call` nodes, so a `fun Name/Arity`
reference — the idiomatic way to pass a local function to lists:map,
spawn, etc. — was dropped from the call graph. It parses as an
internal_fun node (name in the `fun` field, arity in the `arity` field).
Resolve it by exact name and arity and emit an indirect_call edge: the
function is handed off to be invoked, not called on the spot. Remote
references (fun mod:f/2, external_fun) name an explicit module and belong
to the cross-file remote-call boundary; they stay unresolved here.
Fixes #3993
(cherry picked from commit 1748e9bf0a)
This commit is contained in:
1 parent
1e11bd6942
commit
de3289fb74
2 files changed
+91
No files matched your search
@@ -323,6 +323,26 @@ def extract_erlang(path: Path) -> dict:
|
||||
"source_file": source_file,
|
||||
"source_location": f"L{node.start_point[0] + 1}",
|
||||
})
|
||||
elif node.type == "internal_fun":
|
||||
# `fun helper/1` — a reference to a local function by name and
|
||||
# arity, the idiomatic way to pass a callback to `lists:map`,
|
||||
# `spawn`, etc. It names exactly one function, so resolve it the
|
||||
# same way a direct call is, but as an `indirect_call`: the
|
||||
# function is handed off to be invoked, not called on the spot
|
||||
# (#3993). Remote references (`fun mod:f/2`, node type
|
||||
# `external_fun`) are left unresolved — the module is named
|
||||
# explicitly and belongs to the cross-file remote-call boundary.
|
||||
name_node = node.child_by_field_name("fun")
|
||||
arity_node = node.child_by_field_name("arity")
|
||||
if name_node is not None and arity_node is not None:
|
||||
digits = "".join(
|
||||
ch for ch in _read_text(arity_node, source) if ch.isdigit()
|
||||
)
|
||||
if digits:
|
||||
name = _atom(_read_text(name_node, source))
|
||||
target = functions.get((name, int(digits)))
|
||||
if target is not None:
|
||||
add_edge(caller_id, target, "indirect_call", node)
|
||||
stack.extend(reversed(node.named_children))
|
||||
|
||||
clean_edges = [
|
||||
|
||||
@@ -102,6 +102,77 @@ def test_erlang_fixture_uses_normal_extract_path(tmp_path):
|
||||
assert ('run/0', 'helper/0') in _edge_labels(result, "calls")
|
||||
|
||||
|
||||
def test_erlang_local_fun_reference_links_to_the_function(tmp_path):
|
||||
"""`fun helper/1` is a reference to a local function — the idiomatic way to
|
||||
pass a callback to `lists:map`, `spawn`, etc. It names exactly one function
|
||||
(name + arity), so it must link the caller to it. Only direct calls were
|
||||
walked before, so every `fun Name/Arity` reference was dropped from the call
|
||||
graph (#3993)."""
|
||||
source = tmp_path / "worker.erl"
|
||||
source.write_text(
|
||||
"-module(worker).\n"
|
||||
"-export([run/0]).\n"
|
||||
"run() -> lists:map(fun helper/1, [1, 2, 3]).\n"
|
||||
"helper(X) -> X + 1.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = extract([source], cache_root=tmp_path)
|
||||
|
||||
assert ("run/0", "helper/1") in _edge_labels(result, "indirect_call")
|
||||
|
||||
|
||||
def test_erlang_local_fun_reference_respects_arity(tmp_path):
|
||||
"""Fail-closed: `fun helper/1` must bind only to the arity-1 clause, never to
|
||||
a same-named function of a different arity (#3993)."""
|
||||
source = tmp_path / "worker.erl"
|
||||
source.write_text(
|
||||
"-module(worker).\n"
|
||||
"run() -> spawn(fun helper/0).\n"
|
||||
"helper(X) -> X.\n", # only helper/1 exists, not helper/0
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = extract([source], cache_root=tmp_path)
|
||||
|
||||
assert not any(
|
||||
src == "run/0" for src, _tgt in _edge_labels(result, "indirect_call")
|
||||
)
|
||||
|
||||
|
||||
def test_erlang_direct_call_stays_a_call_not_indirect(tmp_path):
|
||||
"""Positive control: a direct call keeps emitting a `calls` edge (not
|
||||
`indirect_call`), unchanged by the fun-reference handling (#3993)."""
|
||||
source = tmp_path / "worker.erl"
|
||||
source.write_text(
|
||||
"-module(worker).\n"
|
||||
"run() -> helper(5).\n"
|
||||
"helper(X) -> X.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = extract([source], cache_root=tmp_path)
|
||||
|
||||
assert ("run/0", "helper/1") in _edge_labels(result, "calls")
|
||||
assert ("run/0", "helper/1") not in _edge_labels(result, "indirect_call")
|
||||
|
||||
|
||||
def test_erlang_call_inside_anonymous_fun_still_captured(tmp_path):
|
||||
"""Positive control: a direct call made inside an anonymous `fun ... end`
|
||||
body must still be walked and resolved (#3993)."""
|
||||
source = tmp_path / "worker.erl"
|
||||
source.write_text(
|
||||
"-module(worker).\n"
|
||||
"run() -> lists:foreach(fun(X) -> helper(X) end, [1, 2, 3]).\n"
|
||||
"helper(X) -> X.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = extract([source], cache_root=tmp_path)
|
||||
|
||||
assert ("run/0", "helper/1") in _edge_labels(result, "calls")
|
||||
|
||||
|
||||
def test_erlang_malformed_tail_comments_and_strings_do_not_create_phantoms(tmp_path):
|
||||
source = tmp_path / 'broken.erl'
|
||||
source.write_text('-module(broken).\nvalid() -> ok.\n% ghost() -> ok.\ninvalid(\n', encoding="utf-8")
|
||||
|
||||
Reference in new issue
Block a user