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:
co-authored by
Claude Fable 5
parent
6511722250
commit
2217a35943
@@ -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,
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user