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:
rajatnagda45 authored and safishamsi committed 2026-10-02 16:50:45 +01:00
1 parent 1e11bd6942
commit de3289fb74
2 files changed
+91

No files matched your search

+20
View File
@@ -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 = [
+71
View File
@@ -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")