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
+75
View File
@@ -9134,6 +9134,81 @@ second(X) -> X.
});
});
describe('Macro linkage', () => {
it('should attribute macro-body calls to the macro and link function-like uses into the chain', () => {
const code = `-module(m).
-export([do_thing/1]).
-define(LOG_AUDIT(Event), audit_logger:log(Event, ?MODULE)).
do_thing(X) ->
?LOG_AUDIT({thing, X}),
ok.
`;
const result = extractFromSource('src/m.erl', code);
const macro = result.nodes.find((n) => n.kind === 'constant' && n.name === 'LOG_AUDIT');
const doThing = result.nodes.find((n) => n.kind === 'function' && n.name === 'do_thing');
const refsFrom = (id?: string) =>
result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => `${r.referenceKind}:${r.referenceName}`);
// The body's remote call belongs to the macro node — true exactly once.
expect(refsFrom(macro?.id)).toContain('calls:audit_logger::log');
// The use site joins the call chain: do_thing -calls→ LOG_AUDIT.
expect(refsFrom(doThing?.id)).toContain('calls:LOG_AUDIT');
});
it('should reference bare macro reads without polluting call chains', () => {
const code = `-module(m).
-export([wait/0]).
-define(TIMEOUT, 5000).
wait() ->
receive after ?TIMEOUT -> ok end.
`;
const result = extractFromSource('src/m.erl', code);
const refs = result.unresolvedReferences.map((r) => `${r.referenceKind}:${r.referenceName}`);
expect(refs).toContain('references:TIMEOUT');
expect(refs).not.toContain('calls:TIMEOUT');
});
it('should skip compiler-predefined macros and keep walking macro-use arguments', () => {
const code = `-module(m).
-export([check/0]).
check() ->
?assertEqual(ok, prepare()),
{?MODULE, ?LINE, ?FUNCTION_NAME}.
prepare() -> ok.
`;
const result = extractFromSource('src/m.erl', code);
const refs = result.unresolvedReferences.map((r) => r.referenceName);
// The nested call inside the macro's arguments still attributes to check/0.
expect(refs).toContain('prepare');
// ?assertEqual (an OTP header macro) is emitted and simply never resolves…
expect(refs).toContain('assertEqual');
// …but predefined macros have no definition to link.
expect(refs).not.toContain('MODULE');
expect(refs).not.toContain('LINE');
expect(refs).not.toContain('FUNCTION_NAME');
});
it('should chain macro-to-macro uses', () => {
const code = `-module(m).
-define(TARGET, target_fn()).
-define(ALIAS, ?TARGET).
`;
const result = extractFromSource('src/m.erl', code);
const target = result.nodes.find((n) => n.kind === 'constant' && n.name === 'TARGET');
const alias = result.nodes.find((n) => n.kind === 'constant' && n.name === 'ALIAS');
const refsFrom = (id?: string) =>
result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => `${r.referenceKind}:${r.referenceName}`);
expect(refsFrom(target?.id)).toContain('calls:target_fn');
expect(refsFrom(alias?.id)).toContain('references:TARGET');
});
});
describe('Behaviour extraction', () => {
it('should emit an implements reference for -behaviour', () => {
const code = `-module(m).