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:
co-authored by
Claude Fable 5
parent
2217a35943
commit
7e3d44fa96
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user