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.
Problem
In Erlang, arity is part of a function's identity:
f/1andf/2are two independent top-level definitions that can be exported separately and are unrelated at the language level. The canonical spelling ismodule:function/arity—-export,fun M:F/A, stack traces and the docs all carry it.The comment on
handleFunDeclexplains 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/1andf/2are 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:
Neither shape distinguishes arity.
Impact
cowboy_req.erl:Inside a single node:
signaturecomes from the first definition whilespancovers both functions; the-specfor/3on 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 thingsignaturedescribes.Call edges:
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/Ndelegating tof/N+1; the latter makes "publicf/1calling privatef/3with 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:
resolution/callback-synthesizer.tsneeds to look up byname/arity; since the node doesn't carry it,erlangArityAtrecounts arguments from source text (Erlang: erlangArityAt doesn't skip <<binary>> literals (contrary to its docstring), so binary-arg dispatch sites get an inflated arity and the behaviour-callback edge is dropped or mis-linked — verified #1358 is a bug in that text scan)mcp/tools.tsstrips/3offmod:fn/3before searching; the comment there records a measurement on cowboy where an agent asked forcowboy_stream_h:request_process/3twice, got nothing back, and fell back to ReadEnvironment
Reproduced both on the v1.5.0 release (npm
@colbymchenry/codegraph@1.5.0) and on a local build of currentmain(44e1812). macOS arm64, Node v24.7.0.