feat(extraction): Erlang gen_server registered-name dispatch targets (#635, #648) (#1167)

gen_server:call/cast/send_request now connects to the TARGET module's
handle_call/handle_cast for every statically-named target, not just self:
a bare atom reaches the module of that name (OTP's {local, ?MODULE}
convention names a server after its module), and a macro defined as a bare
atom (-define(STORE, kv_store)) resolves the same way, alongside the
existing ?MODULE / -define(SERVER, ?MODULE) self paths. A registered name
that matches no module emits a qualified ref that never resolves — silent,
never guessed. Pid, var, and tuple targets ({global, Name}, {Name, Node})
stay unlinked.

Validated on emqx: 53 new edges, 53/53 precise (each source line is a real
registered-name gen_server request; each target module self-registers under
that name, macro-indirected registrations included). Nearly all are
test-suite → handler links — production code goes through API wrappers the
self path already covers — which is exactly the tests-exercising-this-
handler linkage blast-radius and test-gap reporting consume. ejabberd
yields zero (it always wraps): no false positives invented.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-03 15:08:28 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 2217a35943
commit 7e3d44fa96
3 changed files with 76 additions and 40 deletions
+24 -3
View File
@@ -8991,14 +8991,35 @@ handle_cast({put, K, V}, S) -> {noreply, maps:put(K, V, S)}.
expect(calls).toContain('kv_store::handle_cast');
});
it('should not connect gen_server calls to other processes', () => {
it('should connect gen_server calls to a registered-name module, directly or via an atom macro', () => {
const code = `-module(kv_client).
-export([fetch/1, evict/1]).
-define(STORE, kv_store).
fetch(Key) ->
gen_server:call(kv_store, {get, Key}).
evict(Key) ->
gen_server:cast(?STORE, {evict, Key}).
`;
const result = extractFromSource('src/kv_client.erl', code);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
// OTP's {local, ?MODULE} convention names a server after its module —
// a cross-module registered name targets that module's handlers. A name
// matching no module simply never resolves downstream.
expect(calls).toContain('kv_store::handle_call');
expect(calls).toContain('kv_store::handle_cast');
});
it('should not connect gen_server calls with dynamic targets', () => {
const code = `-module(m).
-export([go/2]).
go(Pid, Msg) ->
gen_server:call(Pid, Msg),
gen_server:call(other_registered_name, Msg),
gen_server:cast({global, some_name}, Msg).
gen_server:cast({global, some_name}, Msg),
gen_server:call({some_name, node()}, Msg).
`;
const result = extractFromSource('src/m.erl', code);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);