Skip to content

Erlang: same-name different-arity functions collapse into one node #1610

Description

@Dshuishui

Problem

In Erlang, arity is part of a function's identity: f/1 and f/2 are two independent top-level definitions that can be exported separately and are unrelated at the language level. The canonical spelling is module:function/arity-export, fun M:F/A, stack traces and the docs all carry it.

The comment on handleFunDecl explains the merge is deliberate ("deliberately grouped under one node, the way overloads are elsewhere"), and the reasoning makes sense to us — on C++/Java the analogy holds, overloads really are versions of one function. Erlang differs, though: f/1 and f/2 are separate definitions, either one can be exported alone, and they may do entirely unrelated things.

Merging clauses that share a name and arity into one node is correct — semantically they are one function. The issue is that the merge condition compares only the name, so the outcome also depends on whether the two definitions happen to be adjacent:

f(X)    -> X + 1.
f(X, Y) -> X + Y.
function  gap::f    span 4-5          <- one node holding two functions
f(X)    -> X + 1.
g()     -> ok.
f(X, Y) -> X + Y.
function  gap::f    span 4-4
function  gap::g    span 6-6
function  gap::f    span 8-8          <- two nodes with identical qualified_name

Neither shape distinguishes arity.

Impact

cowboy_req.erl:

420  header(Name, Req) ->
421      header(Name, Req, undefined).
422
423  -spec header(binary(), req(), Default) -> binary() | Default when Default::any().
424  header(Name, #{headers := Headers}, Default) ->
425      maps:get(Name, Headers, Default).

Inside a single node:

signature:  -spec header(binary(), req()) -> binary() | undefined.     <- the spec for header/2
span:       420 - 425                                                  <- covers both /2 and /3

signature comes from the first definition while span covers both functions; the -spec for /3 on line 423 falls inside the span but is not used. Fetching the source for this node returns two functions plus a type declaration — not the thing signature describes.

Call edges:

header -> header          line 421      <- the real header/2 -> header/3 call becomes a self-loop
parse_cookies -> header                 <- no way to tell whether /2 or /3 was called
filter_cookies -> header

Line 421 is a genuine function call.

Scope

Counting -export([name/arity, ...]) across cowboy, ranch, rebar3, jsx and fast_xml, same-name different-arity accounts for roughly 8–17% of the exported API.

Erlang has neither default arguments nor loop constructs: the former means a "with a default" API has to be written as f/N delegating to f/N+1; the latter makes "public f/1 calling private f/3 with an accumulator" the standard way to iterate. In the cowboy sample about 86% of the same-name groups have a real call between their members — so what this mostly affects is not unrelated functions that happen to share a name, but pairs with a genuine call edge that turns into a self-loop.

Elsewhere

With no arity on the node, other layers re-derive it or drop it:

Environment

Reproduced both on the v1.5.0 release (npm @colbymchenry/codegraph@1.5.0) and on a local build of current main (44e1812). macOS arm64, Node v24.7.0.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions