Sfoglia il codice sorgente

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

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>
Colby McHenry 2 mesi fa
parent
commit
7b74d2eb5e

+ 1 - 0
CHANGELOG.md

@@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- Erlang behaviour dispatch is now followed through the graph: a framework call through a variable module — cowboy's `Handler:init`/`Middleware:execute` folds, a plugin manager's `Mod:callback(...)` — links to the repo's implementations of the behaviour that declares that callback, so flow traces and impact cross the OTP callback boundary instead of stopping at it. The links are precision-gated: the callback arity must match, exactly one behaviour may own that callback shape (a collision stays unlinked rather than guessed), the implementer must actually export the callback, and the fan-out is bounded — a behaviour with hundreds of implementers stays a visibly dynamic boundary. Every bridged hop is labeled as dynamic dispatch with its wiring site, never shown as a plain static call.
 - CodeGraph now indexes **Erlang** (`.erl`, `.hrl`) — functions, with clauses and arities of the same name grouped as one symbol spanning all of them, plus records with their fields, `-type`/`-opaque` aliases, `-define` macros, and `-spec` signatures attached to every function. Cross-module `mod:fn(...)` calls resolve to the target module's function, `fun name/arity` values are captured as references (so callback registrations like `lists:foreach(fun submit/1, ...)` link up), `-include`/`-include_lib` connect to the header files they pull in, `-behaviour` declarations link a callback module to its behaviour (and only ever to a module — a same-named macro or function elsewhere in the repo is never mistaken for one), and `-export` lists (plus `-compile(export_all)`) drive each function's public/private flag. OTP's indirection idioms are followed where the target is static: `spawn`/`apply`/`proc_lib`/`timer`/`rpc` calls that name their target as `(Module, Function, Args)` arguments produce call edges, and a module's public API wrappers connect to its own `handle_call`/`handle_cast` when `gen_server:call`/`cast` targets `?MODULE` (including the `-define(SERVER, ?MODULE)` idiom). Truly dynamic dispatch (`Mod:handle(...)`, message sends, var-module spawns) is deliberately left unlinked rather than guessed. `codegraph_explore` also understands Erlang-native symbol spelling in queries — `mod:fn/3` and `init/2` find the symbols they name. (#635, #648)
 - CodeGraph now indexes **Visual Basic .NET** (`.vb`) — classes, Modules, interfaces, structures, enums, properties, events, `MustOverride` abstract members, and `Declare` P/Invoke signatures, with `Inherits`/`Implements` hierarchy edges, call edges (resolved through VB's ambiguous call-vs-index parentheses), and `New`/`As New` instantiation links. Real-world VB styles parse cleanly: WinForms designer files, interpolated and multi-line strings, XML literals (embedded `<%= %>` expressions included), single-line and multi-line LINQ queries, multi-line lambdas, `Handles`/`WithEvents` event wiring, Custom Events, date literals, classic type-character identifiers (`i%`, `name$`), and non-English (Unicode) identifiers. (#648, #639, #170)
 - CodeGraph now indexes **COBOL** (`.cbl`, `.cob`, `.cpy`) — programs, sections and paragraphs with `PERFORM`/`GO TO` call edges, `CALL` cross-program calls, `COPY` copybook imports (standalone copybooks included), and DATA DIVISION records with 88-level condition names, in both fixed and free source format. Impact queries work on data items: every `MOVE`/`ADD`/`COMPUTE`/`SUBTRACT` write-site links back to the field it changes, so "what touches this copybook field" answers across programs. CICS flows connect too: `EXEC CICS LINK`/`XCTL` program targets, `EXEC SQL INCLUDE` copybooks, and pseudo-conversational `RETURN TRANSID(...)` hops resolve to the program owning the transaction id. (#590, #648)

+ 190 - 0
__tests__/erlang-behaviour-synthesizer.test.ts

@@ -0,0 +1,190 @@
+/**
+ * Erlang behaviour-callback dispatch bridge.
+ *
+ * A behaviour module declares `-callback fn/N`, implementers declare
+ * `-behaviour(B)` and export the callbacks, and the framework dispatches
+ * through a variable module (`Handler:init(...)`, `Mod:handle_thing(...)`) — a
+ * dynamic hop extraction deliberately leaves silent. This bridges each
+ * `Var:fn(args)` site to every in-repo implementer of the ONE behaviour that
+ * declares (fn, site-arity), and proves the precision gates: a same-named
+ * function in a non-implementer module contributes no edge, an arity mismatch
+ * contributes no edge, and a (fn, arity) declared by TWO behaviours bails
+ * entirely.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { CodeGraph } from '../src';
+
+describe('erlang-behaviour synthesizer', () => {
+  let dir: string;
+  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'erlang-behaviour-')); });
+  afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
+
+  async function synthEdges(d: string): Promise<any[]> {
+    const cg = await CodeGraph.init(d, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+    const rows = db
+      .prepare(
+        `SELECT s.name source, s.file_path sf, t.name target, t.file_path tf,
+                json_extract(e.metadata,'$.via') via
+         FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
+         WHERE json_extract(e.metadata,'$.synthesizedBy') = 'erlang-behaviour'`
+      )
+      .all();
+    cg.destroy();
+    return rows;
+  }
+
+  it('bridges Var:fn(...) dispatch to every implementer, gated on behaviour + export + arity', async () => {
+    fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
+    fs.writeFileSync(
+      path.join(dir, 'src', 'worker_behaviour.erl'),
+      `-module(worker_behaviour).
+
+-callback handle_thing(Arg :: term()) -> ok | {error, term()}.
+-callback init(list()) -> {ok, term()}.
+
+-export([dispatch/2]).
+
+dispatch(Mod, Arg) ->
+    Mod:handle_thing(Arg).
+`
+    );
+    // Two real implementers, exporting the callback.
+    fs.writeFileSync(
+      path.join(dir, 'src', 'worker_a.erl'),
+      `-module(worker_a).
+-behaviour(worker_behaviour).
+-export([handle_thing/1, init/1]).
+
+handle_thing(X) -> {ok, X}.
+init(_) -> {ok, state}.
+`
+    );
+    fs.writeFileSync(
+      path.join(dir, 'src', 'worker_b.erl'),
+      `-module(worker_b).
+-behaviour(worker_behaviour).
+-export([handle_thing/1, init/1]).
+
+handle_thing(X) -> {done, X}.
+init(_) -> {ok, state}.
+`
+    );
+    // Defines + exports the same function name but does NOT implement the behaviour.
+    fs.writeFileSync(
+      path.join(dir, 'src', 'freeloader.erl'),
+      `-module(freeloader).
+-export([handle_thing/1]).
+
+handle_thing(X) -> X.
+`
+    );
+    // A second dispatcher in another module, plus an arity-mismatched site and a
+    // macro-module site — neither of the latter two may produce edges.
+    fs.writeFileSync(
+      path.join(dir, 'src', 'runner.erl'),
+      `-module(runner).
+-export([run/2, wrong/2, self_call/1]).
+
+run(Mod, Arg) ->
+    Mod:handle_thing(Arg).
+
+wrong(Mod, Arg) ->
+    Mod:handle_thing(Arg, extra).
+
+self_call(X) ->
+    ?MODULE:handle_thing(X).
+`
+    );
+
+    const rows = await synthEdges(dir);
+    const targets = (src: string) =>
+      rows.filter((r) => r.source === src).map((r) => `${path.basename(r.tf)}:${r.target}`).sort();
+
+    // Both dispatch sites link both implementers — and only them (no freeloader).
+    expect(targets('dispatch')).toEqual(['worker_a.erl:handle_thing', 'worker_b.erl:handle_thing']);
+    expect(targets('run')).toEqual(['worker_a.erl:handle_thing', 'worker_b.erl:handle_thing']);
+    // Arity mismatch (handle_thing/2 undeclared) and ?MODULE sites: nothing.
+    expect(targets('wrong')).toEqual([]);
+    expect(targets('self_call')).toEqual([]);
+    // Provenance metadata names the contract.
+    expect(rows.every((r) => r.via === 'worker_behaviour:handle_thing/1')).toBe(true);
+  });
+
+  it('bails when two behaviours declare the same callback name and arity', async () => {
+    fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
+    for (const b of ['left_behaviour', 'right_behaviour']) {
+      fs.writeFileSync(
+        path.join(dir, 'src', `${b}.erl`),
+        `-module(${b}).
+
+-callback common_cb(term()) -> ok.
+`
+      );
+    }
+    fs.writeFileSync(
+      path.join(dir, 'src', 'impl_left.erl'),
+      `-module(impl_left).
+-behaviour(left_behaviour).
+-export([common_cb/1]).
+
+common_cb(X) -> X.
+`
+    );
+    fs.writeFileSync(
+      path.join(dir, 'src', 'caller.erl'),
+      `-module(caller).
+-export([go/2]).
+
+go(Mod, X) ->
+    Mod:common_cb(X).
+`
+    );
+
+    const rows = await synthEdges(dir);
+    expect(rows).toEqual([]);
+  });
+
+  it('does not link an implementer whose callback is not exported', async () => {
+    fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
+    fs.writeFileSync(
+      path.join(dir, 'src', 'hook_behaviour.erl'),
+      `-module(hook_behaviour).
+
+-callback on_event(term()) -> ok.
+
+-export([fire/2]).
+
+fire(Mod, Ev) ->
+    Mod:on_event(Ev).
+`
+    );
+    fs.writeFileSync(
+      path.join(dir, 'src', 'private_impl.erl'),
+      `-module(private_impl).
+-behaviour(hook_behaviour).
+-export([start/0]).
+
+start() -> ok.
+
+on_event(_Ev) -> ok.
+`
+    );
+    fs.writeFileSync(
+      path.join(dir, 'src', 'public_impl.erl'),
+      `-module(public_impl).
+-behaviour(hook_behaviour).
+-export([on_event/1]).
+
+on_event(Ev) -> {seen, Ev}.
+`
+    );
+
+    const rows = await synthEdges(dir);
+    expect(rows.map((r) => path.basename(r.tf))).toEqual(['public_impl.erl']);
+  });
+});

File diff suppressed because it is too large
+ 0 - 0
docs/design/dynamic-dispatch-coverage-playbook.md


+ 181 - 0
src/resolution/callback-synthesizer.ts

@@ -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
src/resolution/strip-comments.ts

@@ -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('');
+}

Some files were not shown because too many files changed in this diff