feat(resolution): Erlang behaviour-callback dispatch synthesizer (#635, #648) (#1166)

Bridges the OTP callback boundary: a framework call through a variable
module — cowboy's Handler:init / Middleware:execute folds, a plugin
manager's Mod:callback(...) — now links to the repo's implementers of the
behaviour declaring that callback, so codegraph_explore connects flows
end-to-end across behaviour dispatch instead of stopping at it.

Precision gates: the callback arity must match the site, exactly one
in-repo behaviour may declare that (name, arity) — a collision bails
(cowboy's init/2 is declared by five handler-flavored behaviours and
correctly stays silent) — the implementer must export the callback, and
above the fan-out cap the site is skipped entirely (ejabberd's gen_mod
with ~230 implementers stays a visibly dynamic boundary). Behaviour
discovery scans -callback declarations in every module so implementer-less
behaviours still gate ambiguity. Edges carry provenance:'heuristic' with
synthesizedBy:'erlang-behaviour' and the wiring site, rendered as dynamic
dispatch in explore.

Validated per the dispatch-family playbook: cowboy 38 edges (middleware
chain, stream-handler folds, sub-protocol upgrade), ejabberd 598, emqx 843;
36/36 sampled edges precise (target declares the via-behaviour and exports
the callback); node counts unchanged; ~1.4s added on emqx's 2,273 files;
zero-control clean. The cowboy request flow connects in one explore call.

Includes an Erlang comment stripper (%-comments, string/atom/$-char aware)
for the dispatch-site scans.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-03 14:53:04 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 6511722250
commit 2217a35943
5 changed files with 429 additions and 1 deletions
+181
View File
@@ -2520,6 +2520,185 @@ function sidekiqDispatchEdges(ctx: ResolutionContext): Edge[] {
return edges;
}
// ── Erlang behaviour-callback dispatch ────────────────────────────────────────
// An Erlang behaviour is a compile-checked callback contract: the behaviour
// module declares `-callback init(...) -> ...`, implementers declare
// `-behaviour(B)` and export the callbacks, and the framework side dispatches
// through a VARIABLE module — cowboy's `Handler:init(Req, Opts)` and
// `Middleware:execute(Req, Env)` folds, ejabberd's `Mod:start/2`. Extraction
// deliberately leaves var-module calls silent (no static target), so the flow
// breaks at exactly the hop agents ask about (request → handler init). Bridge:
//
// dispatch site `Var:fn(args…)` → every in-repo implementer of the behaviour
// declaring `fn` with the SITE's arity — provided exactly ONE in-repo
// behaviour declares (fn, arity); a name+arity collision across behaviours
// bails (silent beats wrong) — and the implementer defines and exports `fn`.
//
// Behaviours are discovered by scanning every Erlang file for `-callback`
// declarations (not just `implements` targets), so a behaviour with zero
// implementers still participates in the ambiguity gate. Fan-out control: a
// mega-behaviour (ejabberd's gen_mod, ~200 mod_* implementers) would mint
// hundreds of edges per site that READ as complete coverage while being
// arbitrary — above the cap the site is skipped entirely and the boundary
// stays visibly dynamic (explore's boundary announcer covers it) instead of
// silently truncated.
const ERLANG_EXT = /\.(?:erl|hrl)$/;
// `Var:fn(` — variable (capitalized) module, lowercase function, immediate
// open-paren. The leading char class rejects `?MODULE:fn(` (macro), `a:b(`
// (static remote call, already linked), and mid-word matches.
const ERLANG_DISPATCH_RE = /(^|[^?\w@'])([A-Z][A-Za-z0-9_@]*):([a-z][A-Za-z0-9_@]*)\(/g;
const ERLANG_CALLBACK_DECL_RE = /(^|\n)\s*-callback\s+('[^'\n]+'|[a-z][A-Za-z0-9_@]*)\s*\(/g;
const ERLANG_BEHAVIOUR_FANOUT_CAP = 24;
/**
* Argument count of the call/declaration whose `(` sits at `openIdx` —
* top-level commas + 1, `()` → 0, unbalanced/oversized → -1. Skips nested
* (), [], {}, <<>> content, `"strings"`, `'atoms'`, and `$c` char literals,
* so `-callback init(fun((a, b) -> ok), #{k => v}) -> ok.` counts 2.
*/
function erlangArityAt(src: string, openIdx: number): number {
let depth = 1;
let commas = 0;
let sawArg = false;
const limit = Math.min(src.length, openIdx + 4000);
for (let i = openIdx + 1; i < limit; i++) {
const ch = src[i]!;
if (ch === '"' || ch === "'") {
i++;
while (i < limit && src[i] !== ch) {
if (src[i] === '\\') i++;
i++;
}
sawArg = true;
continue;
}
if (ch === '$') {
i++;
if (src[i] === '\\') i++;
sawArg = true;
continue;
}
if (ch === '(' || ch === '[' || ch === '{') { depth++; sawArg = true; continue; }
if (ch === ')' || ch === ']' || ch === '}') {
depth--;
if (depth === 0) return sawArg ? commas + 1 : 0;
continue;
}
if (ch === ',' && depth === 1) { commas++; continue; }
if (!/\s/.test(ch)) sawArg = true;
}
return -1;
}
function erlangBehaviourDispatchEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
// Cheap language gate: no Erlang modules → no cost beyond one kind query.
const erlangModules = queries.getNodesByKind('namespace').filter((n) => n.language === 'erlang');
if (erlangModules.length === 0) return [];
// Pass 1 — scan every Erlang file with `-callback` decls: behaviour module →
// its (name, arity) callback set, and the global `name/arity` → declaring
// behaviours map that drives the ambiguity gate.
const moduleByFile = new Map<string, Node>();
for (const ns of erlangModules) {
if (!moduleByFile.has(ns.filePath)) moduleByFile.set(ns.filePath, ns);
}
const declaringBehaviours = new Map<string, Node[]>(); // `fn/arity` → behaviour namespaces
const callbackNames = new Set<string>();
for (const file of ctx.getAllFiles()) {
if (!ERLANG_EXT.test(file)) continue;
const behaviour = moduleByFile.get(file);
if (!behaviour) continue; // a .hrl or module-less file can't be a behaviour
const content = ctx.readFile(file);
if (!content || !content.includes('-callback')) continue;
const safe = stripCommentsForRegex(content, 'erlang');
ERLANG_CALLBACK_DECL_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ERLANG_CALLBACK_DECL_RE.exec(safe))) {
const name = m[2]!.replace(/^'|'$/g, '');
const arity = erlangArityAt(safe, m.index + m[0].length - 1);
if (arity < 0) continue;
const key = `${name}/${arity}`;
const arr = declaringBehaviours.get(key);
if (arr) {
if (!arr.some((b) => b.id === behaviour.id)) arr.push(behaviour);
} else {
declaringBehaviours.set(key, [behaviour]);
}
callbackNames.add(name);
}
}
if (declaringBehaviours.size === 0) return [];
// Implementer target lookup, lazy per (behaviour, fn): implementers come
// from the `implements` edges extraction resolved, and the target is the
// implementer module's own exported `fn` function node.
const targetCache = new Map<string, Node[]>();
const targetsOf = (behaviour: Node, fn: string): Node[] => {
const cacheKey = `${behaviour.id}#${fn}`;
let targets = targetCache.get(cacheKey);
if (targets) return targets;
targets = [];
for (const e of queries.getIncomingEdges(behaviour.id, ['implements'])) {
const impl = queries.getNodeById(e.source);
if (!impl || impl.language !== 'erlang' || impl.kind !== 'namespace') continue;
const fnNode = ctx
.getNodesInFile(impl.filePath)
.find((n) => n.kind === 'function' && n.name === fn && n.isExported !== false);
if (fnNode) targets.push(fnNode);
}
targetCache.set(cacheKey, targets);
return targets;
};
// Pass 2 — dispatch sites. Only files containing a var-module call shape are
// scanned in full.
const edges: Edge[] = [];
const seen = new Set<string>();
for (const file of ctx.getAllFiles()) {
if (!ERLANG_EXT.test(file)) continue;
const content = ctx.readFile(file);
if (!content || !/[A-Z][A-Za-z0-9_@]*:[a-z]/.test(content)) continue;
const safe = stripCommentsForRegex(content, 'erlang');
const nodesInFile = ctx.getNodesInFile(file);
ERLANG_DISPATCH_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ERLANG_DISPATCH_RE.exec(safe))) {
const fn = m[3]!;
if (!callbackNames.has(fn)) continue;
const openIdx = m.index + m[0].length - 1;
const arity = erlangArityAt(safe, openIdx);
if (arity < 0) continue;
const behaviours = declaringBehaviours.get(`${fn}/${arity}`);
if (!behaviours || behaviours.length !== 1) continue; // unknown or ambiguous
const behaviour = behaviours[0]!;
const targets = targetsOf(behaviour, fn);
if (targets.length === 0 || targets.length > ERLANG_BEHAVIOUR_FANOUT_CAP) continue;
const line = safe.slice(0, m.index).split('\n').length;
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue;
for (const target of targets) {
if (target.id === disp.id) continue;
const key = `${disp.id}>${target.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: disp.id,
target: target.id,
kind: 'calls',
line,
provenance: 'heuristic',
metadata: {
synthesizedBy: 'erlang-behaviour',
via: `${behaviour.name}:${fn}/${arity}`,
registeredAt: `${file}:${line}`,
},
});
}
}
}
return edges;
}
// ── Laravel events (PHP) ──────────────────────────────────────────────────────
// Laravel decouples an event dispatch from its listener(s), linked by the EVENT CLASS:
// // app/Events/PlaybackStarted.php + app/Listeners/UpdateLastfmNowPlaying.php
@@ -2727,6 +2906,7 @@ export async function synthesizeCallbackEdges(queries: QueryBuilder, ctx: Resolu
const springEdges = springEventEdges(ctx); await yieldToLoop();
const mediatrEdges = mediatrDispatchEdges(ctx); await yieldToLoop();
const sidekiqEdges = sidekiqDispatchEdges(ctx); await yieldToLoop();
const erlangBehaviourEdges = erlangBehaviourDispatchEdges(queries, ctx); await yieldToLoop();
const laravelEdges = laravelEventEdges(ctx); await yieldToLoop();
const cFnPtrEdges = cFnPointerDispatchEdges(queries, ctx); await yieldToLoop();
const goframeEdges = goframeRouteEdges(ctx); await yieldToLoop();
@@ -2762,6 +2942,7 @@ export async function synthesizeCallbackEdges(queries: QueryBuilder, ctx: Resolu
...springEdges,
...mediatrEdges,
...sidekiqEdges,
...erlangBehaviourEdges,
...laravelEdges,
...cFnPtrEdges,
...goframeEdges,
+56 -1
View File
@@ -35,7 +35,8 @@ export type CommentLang =
| 'go'
| 'rust'
| 'c'
| 'cpp';
| 'cpp'
| 'erlang';
export function stripCommentsForRegex(content: string, lang: CommentLang): string {
switch (lang) {
@@ -45,6 +46,8 @@ export function stripCommentsForRegex(content: string, lang: CommentLang): strin
return stripRuby(content);
case 'rust':
return stripRust(content);
case 'erlang':
return stripErlang(content);
case 'php':
return stripPhp(content);
case 'go':
@@ -471,3 +474,55 @@ function stripRust(src: string): string {
return out.join('');
}
// ---------- Erlang ----------
/**
* Erlang: `%` starts a line comment unless it sits inside a `"string"`, a
* `'quoted atom'`, or is the character literal `$%`. Strings and quoted atoms
* are left intact (a behaviour callback name can be a quoted atom); only the
* comment text is blanked.
*/
function stripErlang(src: string): string {
const out = src.split('');
let i = 0;
const n = src.length;
while (i < n) {
const c = src[i];
if (c === '"' || c === "'") {
const quote = c;
i++;
while (i < n && src[i] !== quote) {
if (src[i] === '\\' && i + 1 < n) {
i += 2;
continue;
}
i++;
}
if (i < n) i++;
continue;
}
// Character literal: `$x`, `$\n`, `$%` — the next char (or escape) is data.
if (c === '$') {
i++;
if (i < n && src[i] === '\\') i++;
i++;
continue;
}
if (c === '%') {
let end = i;
while (end < n && src[end] !== '\n') end++;
blankRange(out, i, end, src);
i = end;
continue;
}
i++;
}
return out.join('');
}