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
+51 -36
View File
@@ -3509,28 +3509,39 @@ export class TreeSitterExtractor {
* Extract a function call
*/
/**
* Whether an Erlang gen_server target expression statically refers to the
* module it appears in: `?MODULE`, a macro the file defines as `?MODULE`
* (`-define(SERVER, ?MODULE)` the standard idiom), or the module's own
* name as an atom. The self-macro set is memoized per file (single entry
* extraction is file-sequential).
* The module an Erlang gen_server target expression statically names, or
* null when it's dynamic (pid/var/tuple form). Static shapes:
* - a bare atom either this module or another one; OTP's dominant
* registration convention (`{local, ?MODULE}`) names a server process
* after its module, so `gen_server:call(other_mod, …)` reaches
* `other_mod`'s handlers. A registered name that matches no module
* resolves to nothing downstream (the qualified ref just drops).
* - `?MODULE`, or a macro the file defines as `?MODULE`
* (`-define(SERVER, ?MODULE)` the standard self idiom)
* - a macro the file defines as a bare atom
* (`-define(STORE, kv_store)` the cross-module variant)
* The macro tables are memoized per file (single entry extraction is
* file-sequential).
*/
private erlangSelfMacroFile = '';
private erlangServerMacroFile = '';
private erlangSelfMacros = new Set<string>();
private erlangAtomMacros = new Map<string, string>();
private isErlangSelfReference(target: SyntaxNode): boolean {
private resolveErlangGenServerTarget(target: SyntaxNode): string | null {
const ownModule = (this.filePath.split('/').pop() ?? '').replace(/\.erl$/, '');
if (target.type === 'atom') {
return getNodeText(target, this.source) === ownModule;
const name = getNodeText(target, this.source).replace(/^'([\s\S]*)'$/, '$1');
return name || null;
}
if (target.type !== 'macro_call_expr') return false;
if (target.type !== 'macro_call_expr') return null;
const nameNode = getChildByField(target, 'name');
if (!nameNode) return false;
if (!nameNode) return null;
const macroName = getNodeText(nameNode, this.source);
if (macroName === 'MODULE') return true;
if (this.erlangSelfMacroFile !== this.filePath) {
this.erlangSelfMacroFile = this.filePath;
if (macroName === 'MODULE') return ownModule || null;
if (this.erlangServerMacroFile !== this.filePath) {
this.erlangServerMacroFile = this.filePath;
this.erlangSelfMacros = new Set<string>();
this.erlangAtomMacros = new Map<string, string>();
let root: SyntaxNode = target;
while (root.parent) root = root.parent;
for (let i = 0; i < root.namedChildCount; i++) {
@@ -3539,17 +3550,23 @@ export class TreeSitterExtractor {
const lhs = getChildByField(form, 'lhs');
const defName = lhs ? getChildByField(lhs, 'name') : null;
const replacement = getChildByField(form, 'replacement');
if (!defName || !replacement) continue;
if (
defName &&
replacement?.type === 'macro_call_expr' &&
replacement.type === 'macro_call_expr' &&
getChildByField(replacement, 'name') &&
getNodeText(getChildByField(replacement, 'name')!, this.source) === 'MODULE'
) {
this.erlangSelfMacros.add(getNodeText(defName, this.source));
} else if (replacement.type === 'atom') {
this.erlangAtomMacros.set(
getNodeText(defName, this.source),
getNodeText(replacement, this.source).replace(/^'([\s\S]*)'$/, '$1'),
);
}
}
}
return this.erlangSelfMacros.has(macroName);
if (this.erlangSelfMacros.has(macroName)) return ownModule || null;
return this.erlangAtomMacros.get(macroName) ?? null;
}
private extractCall(node: SyntaxNode): void {
@@ -3652,15 +3669,15 @@ export class TreeSitterExtractor {
line,
column,
});
// gen_server self-dispatch: `gen_server:call(?SERVER, Msg)` /
// `gen_server:cast(?MODULE, Msg)` — the OTP API-wrapper idiom (a
// module's public functions wrap gen_server requests to itself, and
// the real work happens in its own handle_call/handle_cast). The
// target is static when the first argument is ?MODULE, a macro the
// file defines as ?MODULE (the standard `-define(SERVER, ?MODULE)`),
// or the module's own name as an atom — emit the qualified callback
// ref so the module's public API connects to its handlers. Any other
// target (pid/var/registered name of another process) stays silent.
// gen_server dispatch: `gen_server:call(?SERVER, Msg)` /
// `gen_server:cast(other_mod, Msg)` — a request routes to the TARGET
// module's handle_call/handle_cast. The target is static when the
// first argument names a module: ?MODULE or a ?MODULE-defined macro
// (the self API-wrapper idiom), a bare atom (OTP's `{local, ?MODULE}`
// convention names a server after its module, so a cross-module
// registered name reaches that module's handlers — and a registered
// name matching no module resolves to nothing), or a macro defined
// as a bare atom. Pid/var/tuple targets stay silent.
if (
moduleExpr?.type === 'atom' &&
erlAtom(moduleExpr) === 'gen_server' &&
@@ -3668,17 +3685,15 @@ export class TreeSitterExtractor {
) {
const argsNode = getChildByField(node, 'args');
const target = argsNode?.namedChild(0) ?? null;
if (target && this.isErlangSelfReference(target)) {
const ownModule = (this.filePath.split('/').pop() ?? '').replace(/\.erl$/, '');
if (ownModule) {
this.unresolvedReferences.push({
fromNodeId: callerId,
referenceName: `${ownModule}::${fnBare === 'cast' ? 'handle_cast' : 'handle_call'}`,
referenceKind: 'calls',
line,
column,
});
}
const targetModule = target ? this.resolveErlangGenServerTarget(target) : null;
if (targetModule) {
this.unresolvedReferences.push({
fromNodeId: callerId,
referenceName: `${targetModule}::${fnBare === 'cast' ? 'handle_cast' : 'handle_call'}`,
referenceKind: 'calls',
line,
column,
});
}
}
// MFA-in-argument dispatch: the spawn/apply family names its real