feat(extraction): Erlang macro-body call linkage (#635, #648) (#1168)

Calls hidden inside -define bodies were invisible: the extractor consumed
pp_define without walking the replacement, and macro use sites produced no
edges, so a call path routed through a macro (ejabberd's SQL upsert macros,
logging wrappers) was completely dark.

The macro's constant node now participates in the graph. The -define body's
calls are attributed to the MACRO — true exactly once, instead of a per-use
duplicate that would explode on logging macros — and each use site links
in: ?MACRO(...) with arguments emits a `calls` ref (inlined code joins the
call chain), a bare ?CONSTANT read emits `references` (answering "where is
this macro used" without polluting call paths). Compiler-predefined macros
(?MODULE, ?LINE, ?FUNCTION_NAME, ...) are excluded, macro-use arguments
keep walking so a call nested in ?assertEqual(ok, do_thing()) still
attributes to the enclosing function, and macro-to-macro chains connect.

Validated: node counts unchanged on cowboy/ejabberd/emqx; edges +26/+7.3K/
+42K with honest hub shapes (?T i18n, ?SLOG logging, ?QOS_1 protocol
constants); 40/40 sampled edges precise; +1.3s index cost on emqx's 2,273
files. The payoff chain on ejabberd: set_password_scram_t → ?SQL_UPSERT_T →
ejabberd_sql:sql_query_t — database writes through SQL macros now trace
end-to-end.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-03 15:19:46 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 7e3d44fa96
commit a5b8cd8e25
4 changed files with 124 additions and 6 deletions
+18 -5
View File
@@ -175,12 +175,24 @@ function handleTypeAlias(node: SyntaxNode, ctx: ExtractorContext): boolean {
function handlePpDefine(node: SyntaxNode, ctx: ExtractorContext): boolean {
const lhs = getChildByField(node, 'lhs');
const nameNode = lhs ? getChildByField(lhs, 'name') : null;
if (nameNode) {
ctx.createNode('constant', getNodeText(nameNode, ctx.source), node, {
signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 200),
});
if (!nameNode) return true;
const macro = ctx.createNode('constant', getNodeText(nameNode, ctx.source), node, {
signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 200),
});
// The replacement's calls execute at expansion sites, but attributing them
// to the MACRO node keeps them true exactly once: `-define(LOG_AUDIT(E),
// audit_logger:log(E))` gives the LOG_AUDIT constant a `calls` edge to the
// logger, and each `?LOG_AUDIT(...)` use site links to the constant (see the
// macro_call_expr case in extractCall) — so the chain
// `caller → LOG_AUDIT → audit_logger:log` traverses without minting a
// per-use duplicate of the body's calls.
const replacement = getChildByField(node, 'replacement');
if (macro && replacement) {
ctx.pushScope(macro.id);
ctx.visitFunctionBody(replacement, macro.id);
ctx.popScope();
}
return true; // the replacement's calls only exist at expansion sites
return true;
}
function handleBehaviour(node: SyntaxNode, ctx: ExtractorContext): boolean {
@@ -218,6 +230,7 @@ export const erlangExtractor: LanguageExtractor = {
'record_update_expr', // X#rec{...}
'record_index_expr', // #rec.field
'record_field_expr', // X#rec.field
'macro_call_expr', // ?MACRO / ?MACRO(...) — links use sites to the -define constant
],
variableTypes: [],
nameField: 'name',
+30
View File
@@ -67,6 +67,13 @@ const VUE_STORE_FILE_SIGNAL = /\bdefineStore\b|\bcreateStore\b|\bVuex\b|\bmutati
* Used by the erlang branch of extractCall to lift a static MFA pair into a
* call edge (the spawned/applied function is otherwise invisible to the graph).
*/
/** Compiler-predefined Erlang macros — no `-define` exists to link a use to. */
const ERLANG_PREDEFINED_MACROS = new Set([
'MODULE', 'MODULE_STRING', 'FILE', 'LINE', 'MACHINE',
'FUNCTION_NAME', 'FUNCTION_ARITY', 'OTP_RELEASE',
'FEATURE_AVAILABLE', 'FEATURE_ENABLED',
]);
const ERLANG_MFA_CALLS = new Set([
'spawn', 'spawn_link', 'spawn_monitor', 'spawn_opt', 'apply',
'erlang:spawn', 'erlang:spawn_link', 'erlang:spawn_monitor', 'erlang:spawn_opt', 'erlang:apply',
@@ -3752,6 +3759,29 @@ export class TreeSitterExtractor {
});
return;
}
if (node.type === 'macro_call_expr') {
// Macro use site → the `-define` constant node. Function-like uses
// (`?LOG_AUDIT(X)` — args present) are inlined code, so they join the
// call chain and connect through the macro node to the body's calls
// (attributed there by handlePpDefine); bare reads (`?TIMEOUT`) are
// `references`, answering "where is this macro used" without
// polluting call paths. Compiler-predefined macros carry no
// definition to link. The use site's ARGUMENTS are children and keep
// walking, so a call nested in `?assertEqual(ok, do_thing())` still
// attributes to the enclosing function.
const macroName = getChildByField(node, 'name');
if (!macroName) return;
const name = getNodeText(macroName, this.source);
if (ERLANG_PREDEFINED_MACROS.has(name)) return;
this.unresolvedReferences.push({
fromNodeId: callerId,
referenceName: name,
referenceKind: getChildByField(node, 'args') ? 'calls' : 'references',
line,
column,
});
return;
}
// record_expr / record_update_expr / record_index_expr / record_field_expr
const recordName = getChildByField(node, 'name');
const recordAtom = recordName?.type === 'record_name' ? getChildByField(recordName, 'name') : null;