diff --git a/graphify/extractors/erlang.py b/graphify/extractors/erlang.py index 4df8a686c..01067d055 100644 --- a/graphify/extractors/erlang.py +++ b/graphify/extractors/erlang.py @@ -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 = [ diff --git a/tests/test_erlang_extractor.py b/tests/test_erlang_extractor.py index dbc0dd59d..b6d8b199a 100644 --- a/tests/test_erlang_extractor.py +++ b/tests/test_erlang_extractor.py @@ -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")