diff --git a/CHANGELOG.md b/CHANGELOG.md index 144ec92..032d09f 100644 --- a/CHANGELOG.md +++ b/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) diff --git a/__tests__/erlang-behaviour-synthesizer.test.ts b/__tests__/erlang-behaviour-synthesizer.test.ts new file mode 100644 index 0000000..d5d33f4 --- /dev/null +++ b/__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 { + 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']); + }); +}); diff --git a/docs/design/dynamic-dispatch-coverage-playbook.md b/docs/design/dynamic-dispatch-coverage-playbook.md index f5f1022..63603a9 100644 --- a/docs/design/dynamic-dispatch-coverage-playbook.md +++ b/docs/design/dynamic-dispatch-coverage-playbook.md @@ -266,6 +266,7 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started. | C/C++ | C++ vtables / inheritance | virtual call → override; general direct dispatch | S + X | ✅ **general dispatch strong** (redis C **29k** cross-file calls / leveldb C++ **1.4k**) + **C++ inheritance extraction fix** (`base_class_clause` was unhandled, so C++ extends edges were missing — leveldb **219→298**) + **cpp-override synthesizer** (base virtual method → subclass override, gated to C++, capped — leveldb 12 precise: `Iterator::Next→MergingIterator`). 🔬 C callback structs (`s->fn()` → 422-way fan-out, too noisy to synthesize) + C++ pure-virtual base methods (`virtual void f()=0;` declarations aren't extracted as nodes, so those overrides can't bridge) | | Dart | Flutter | setState → build; build → child widgets | S + X | ✅ **setState→build synthesizer** (Dart analog of react-render: a State method whose body calls `setState(` → `build`) gated to `.dart` + **foundational Dart method-range fix** — Dart models a method body as a *sibling* of the signature, so method nodes were signature-only (`end==start`); now `endLine` spans the body (required for ALL body analysis: callees, context slices, the synthesizer's body scan). counter `initState→build`, books `build→BookDetail/BookForm`; widget composition already static (compass_app `build→ErrorIndicator/HomeButton`). Controls unchanged (excalidraw 9,290 / django 302 — the range fix only extends sibling-body grammars). 🔬 MVVM Command/ChangeNotifier dispatch (compass_app — no setState) + `Navigator.push(MaterialPageRoute(builder:))` nav routes | | Lua / Luau | Neovim / Roblox | module dispatch (require→mod, mod.fn); event/callback | — | ✅ **already covered for the dominant flow (measure-first, no code change)** — Neovim is module-heavy (`require('x')` + `x.fn()`), and the general import + name resolution already handles it: telescope.nvim **220 imports + 335 cross-file `mod.fn` calls**, traces end-to-end (`map_entries ← init.lua → get_current_picker (state.lua)`). Luau instance-path `require(game:GetService(...))` handled by the extractor. 🔬 event-callback registration (`vim.keymap.set(…, fn)`, autocmd `callback=`, Roblox `signal:Connect(fn)`) is predominantly INLINE anonymous closures (corpus ~12 inline vs ~2 named) — the anonymous-handler frontier; named handlers too rare to justify a synthesizer | +| Erlang | OTP behaviours | request → behaviour dispatch (`Var:callback(...)` folds) → implementer callback | S | ✅ **behaviour-callback dispatch synthesizer** (`erlangBehaviourDispatchEdges`) — a behaviour declares `-callback fn/N`, implementers declare `-behaviour(B)`, and the framework dispatches through a VARIABLE module (`Handler:init`, `Middleware:execute` folds), a hop extraction deliberately leaves silent. Bridge: each `Var:fn(args)` site → every implementer of the ONE in-repo behaviour declaring (fn, site-arity) that defines+exports fn; a name+arity collision across behaviours bails (cowboy's `init/2` is declared by FIVE handler-flavored behaviours → correctly silent), and above the fan-out cap (24) the site is skipped entirely (ejabberd's `gen_mod`, ~230 mod_* implementers, stays a visibly dynamic boundary rather than 24 arbitrary edges). Behaviour discovery scans `-callback` decls in every module (not just `implements` targets) so implementer-less behaviours still gate ambiguity. Validated: cowboy S — 38 edges, all real contracts (middleware chain `cowboy_stream_h::execute → cowboy_router/cowboy_handler::execute`, stream-handler `init/data/early_error` folds → all 5 core + 2 test handlers, sub-protocol `upgrade`, `websocket_init`); ejabberd M — 598 edges (listener/auth/pubsub/MIX backends, max per-site fan-out 9); emqx L — 843 edges (gateway codec/channel families, max fan-out 20); **precision spot-check 36/36** (every sampled target declares the via-behaviour + exports the callback); node counts unchanged; erl-sample 0-control clean (dispatch with no valid implementer → no edge); index cost +~1.4s on emqx's 2,273 files. The cowboy request flow now connects END-TO-END in one explore: `cowboy_stream:init → [erlang behaviour] cowboy_stream_h:init → request_process → execute → [erlang behaviour] cowboy_handler:execute`. 🔬 gen_server registered-name cross-module targets (atom == module-name convention); the terminal `Handler:init` hop where multiple sub-protocol behaviours share the contract (genuinely ambiguous — the dispatch site's body is the answer) | | Scala | Play / Akka | request → conf/routes → controller action | R + X | ✅ **Play `conf/routes` → controller** — the extensionless `conf/routes` wasn't indexed; added narrow file-walk opt-in (`isPlayRoutesFile`) + a Play resolver parsing `METHOD /path Controller.action(args)` → the action method (computer-database **0→8, 7/8**; starter 0→4, 3/4 — the unresolved are Play's framework `Assets` controller, external). Scala general controller→DAO dispatch already resolves. No-regression: the file-walk change only ADDS Play routes files (excalidraw 9,290 / suite 800 unchanged). 🔬 SIRD programmatic router (`-> /v1 Router` include + `case GET(p"/x")` in code) + Akka actor `receive`/`Behaviors.receiveMessage` message→handler | | Swift × Objective-C | mixed iOS apps | Swift `obj.foo(bar:)` → ObjC `-fooWithBar:`; ObjC `[obj fooWithBar:]` → Swift `@objc func foo(bar:)` | R | ✅ **Swift↔ObjC cross-language bridge** — `frameworks/swift-objc.ts` implements Apple's `@objc` auto-bridging name math (incl. init forms `initWith:`, property getter+setter pairs, `@objc(custom:)` override) and the reverse direction strips Cocoa preposition prefixes (`With`/`For`/`By`/`In`/`On`/`At`/`From`/`To`/`Of`/`As`) to derive Swift base-name candidates. Validated on Charts S **28/1 obj→swift / swift→objc**, realm-swift M **36/1185**, wikipedia-ios L **52/983**. Genericname blocklist (`init`, `description`, `count`, …) keeps precision. Confidence 0.6 (name-match's 1.0 wins ties) — bridge only fires when name-match has no result. 🔬 Swift generics over ObjC protocols, Swift extensions on ObjC classes (silently miss; matches Java/Kotlin generics frontier) | | JS × native | React Native legacy bridge | JS `NativeModules.X.fn(...)` → ObjC `RCT_EXPORT_METHOD` / Java/Kotlin `@ReactMethod` | R | ✅ **RN legacy bridge** — `frameworks/react-native.ts` parses `RCT_EXPORT_MODULE` (default-name from `RCT`-prefix-stripped class name) + `RCT_EXPORT_METHOD(selector:(...))` + `RCT_REMAP_METHOD(jsName, selector)` on the ObjC side and `@ReactMethod` + `getName()` literal on Java/Kotlin. AsyncStorage S **8/8 precise** (`setItem`→`legacy_multiSet`, etc.), react-native-firebase L **18 precise after `RCTEventEmitter` built-in blocklist** (initial 78 included 60 `addListener:`/`remove:` false positives — every emitter subclass declares those via `RCT_EXPORT_METHOD`, JS callers route through the `NativeEventEmitter` abstraction not the native method directly). 🔬 dynamic bridge keys (`NativeModules[someVar]`) — literal-key only | diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 187c81f..87edbb8 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/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(); + for (const ns of erlangModules) { + if (!moduleByFile.has(ns.filePath)) moduleByFile.set(ns.filePath, ns); + } + const declaringBehaviours = new Map(); // `fn/arity` → behaviour namespaces + const callbackNames = new Set(); + 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(); + 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(); + 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, diff --git a/src/resolution/strip-comments.ts b/src/resolution/strip-comments.ts index 2dcfd31..45dc10a 100644 --- a/src/resolution/strip-comments.ts +++ b/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(''); +}