Просмотр исходного кода

feat(extraction): add Erlang language support (.erl/.hrl) (#635, #648)

Vendored WhatsApp/tree-sitter-erlang 0.19 (the ELP grammar, ABI 14) with an
Erlang-shaped extractor: multi-clause/multi-arity functions merged into one
symbol, -spec signatures, records with fields, -type/-opaque aliases, -define
macros, -include/-include_lib file edges, and -export-driven visibility.

Modules wrap in a namespace so remote mod:fn(...) calls resolve through the
existing qualified-name matcher as mod::fn with zero resolver changes.
-behaviour declarations link to the behaviour module — gated to namespace
targets only (bare-name fallthrough linked -behaviour(supervisor) to an
unrelated macro constant on emqx). OTP indirection with static targets is
followed: spawn/apply/proc_lib/timer/rpc MFA-argument callees, and
gen_server:call/cast(?MODULE | ?SERVER) to the module's own
handle_call/handle_cast. Var-module dispatch and message sends stay
deliberately unlinked. codegraph_explore also normalizes Erlang-native query
spelling (mod:fn/3, init/2) so named symbols resolve as typed.

Benchmarked on cowboy (189 files), ejabberd (414), emqx (2,447): extraction
PASS on all three; with-codegraph arms reached 2/2/0 file Reads vs 10/5+/19
without, fastest on the largest repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Colby McHenry 2 месяцев назад
Родитель
Сommit
d2bae8e094

+ 24 - 1
.claude/skills/agent-eval/corpus.json

@@ -469,5 +469,28 @@
       "files": "~145",
       "question": "When a job finishes video encoding, how does staxrip decide which muxer runs and how does the muxer command line get built and executed? Trace from job processing to the mkvmerge invocation."
     }
+  ],
+  "Erlang": [
+    {
+      "name": "cowboy",
+      "repo": "https://github.com/ninenines/cowboy",
+      "size": "Small",
+      "files": "~190",
+      "question": "How does an incoming HTTP request travel from cowboy's connection process to a user-defined handler's init/2 callback? Trace the path through the stream handler and middleware chain."
+    },
+    {
+      "name": "ejabberd",
+      "repo": "https://github.com/processone/ejabberd",
+      "size": "Medium",
+      "files": "~410",
+      "question": "When a client sends a chat message, how does the stanza get from the receiving C2S process to the recipient's session on the same node? Trace the path through the router and session manager."
+    },
+    {
+      "name": "emqx",
+      "repo": "https://github.com/emqx/emqx",
+      "size": "Large",
+      "files": "~2450",
+      "question": "How does a PUBLISH packet from an MQTT client reach the sessions of matching subscribers? Trace the flow from the connection/channel layer through the broker's routing to session delivery."
+    }
   ]
-}
+}

+ 1 - 0
CHANGELOG.md

@@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- 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)
 - CodeGraph now indexes **CFML** (`.cfc`, `.cfm`, `.cfs`) — both the classic tag-based style (`<cfcomponent>`/`<cffunction>`) and modern bare-script `component { ... }` syntax, including `extends`/`implements`, embedded `<cfscript>` blocks (at any nesting depth, including inside `<cfif>`/`<cfloop>`/`<cftry>`), call edges, and calls embedded in `#hash#` expressions inside `<cfquery>` SQL bodies. Files saved with a UTF-8 byte-order mark and tags with unquoted attribute values — both common in long-lived CFML codebases — are handled too. Thanks @ghedwards. (#1118)

+ 2 - 1
README.md

@@ -244,7 +244,7 @@ The reliable, universal payoff is **surgical context and speed**: CodeGraph coll
 | **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 |
 | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes |
 | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
-| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, CFML, COBOL, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
+| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Erlang, CFML, COBOL, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
 | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks |
 | **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules |
 | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
@@ -718,6 +718,7 @@ is written):
 | CFML | `.cfc`, `.cfm`, `.cfs` | Full support (tag-based `<cfcomponent>`/`<cffunction>` and bare-script `component { ... }` styles, `extends`/`implements`, embedded `<cfscript>` delegation, call edges) |
 | COBOL | `.cbl`, `.cob`, `.cpy` | Full support (programs, sections/paragraphs with PERFORM/GO TO call edges, CALL 'literal' cross-program calls, COPY copybook imports — including standalone `.cpy` files — DATA DIVISION records/fields/88-levels, EXEC CICS LINK/XCTL and EXEC SQL INCLUDE targets; fixed and free format) |
 | Visual Basic .NET | `.vb` | Full support (classes, Modules, interfaces, structures, enums, properties, events, `Declare` P/Invoke, `Handles`/`WithEvents`, `Inherits`/`Implements` edges, call edges through VB's call/index paren ambiguity, `As New` instantiation, interpolated strings, LINQ, Unicode identifiers) |
+| Erlang | `.erl`, `.hrl` | Full support (functions with multi-clause/multi-arity grouping, `-spec` signatures, records with fields, `-type`/`-opaque` aliases, `-define` macros, `-include`/`-include_lib`/`-import` edges, local and `mod:fn` remote call edges, `fun name/arity` references, `spawn`/`apply`/`proc_lib`/`timer`/`rpc` MFA-argument call edges, `gen_server:call/cast(?MODULE)` → own `handle_call`/`handle_cast` links, `-behaviour` links, `-export`-based visibility) |
 
 ## Measured cross-file coverage
 

+ 25 - 1
__tests__/explore-output-budget.test.ts

@@ -10,7 +10,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
 import * as fs from 'fs';
 import * as path from 'path';
 import * as os from 'os';
-import { getExploreOutputBudget, getExploreBudget, ToolHandler } from '../src/mcp/tools';
+import { getExploreOutputBudget, getExploreBudget, normalizeQuerySpelling, ToolHandler } from '../src/mcp/tools';
 import CodeGraph from '../src/index';
 
 describe('getExploreOutputBudget', () => {
@@ -254,3 +254,27 @@ describe('codegraph_explore output respects the adaptive budget', () => {
     expect(hasMethodBody).toBe(true);
   });
 });
+
+describe('normalizeQuerySpelling (Erlang mod:fn/arity)', () => {
+  it('rewrites Erlang-native symbol spellings to pipeline shapes', () => {
+    expect(normalizeQuerySpelling('cowboy_stream_h:request_process/3'))
+      .toBe('cowboy_stream_h.request_process');
+    expect(normalizeQuerySpelling('ejabberd_router:route/1 do_route/1 session'))
+      .toBe('ejabberd_router.route do_route session');
+    expect(normalizeQuerySpelling('init/2 handle_call/3')).toBe('init handle_call');
+  });
+
+  it('leaves query-language field prefixes and other spellings alone', () => {
+    expect(normalizeQuerySpelling('kind:function lang:erlang route'))
+      .toBe('kind:function lang:erlang route');
+    expect(normalizeQuerySpelling('path:src/api name:auth')).toBe('path:src/api name:auth');
+    expect(normalizeQuerySpelling('Foo::bar baz')).toBe('Foo::bar baz');
+    expect(normalizeQuerySpelling('https://example.com/docs')).toBe('https://example.com/docs');
+    expect(normalizeQuerySpelling('meeting at 12:30')).toBe('meeting at 12:30');
+    expect(normalizeQuerySpelling('src/2fa/handler.ts')).toBe('src/2fa/handler.ts');
+  });
+
+  it('maps Lua colon-method spelling onto the qualified form', () => {
+    expect(normalizeQuerySpelling('logger:log message')).toBe('logger.log message');
+  });
+});

+ 384 - 0
__tests__/extraction.test.ts

@@ -107,6 +107,11 @@ describe('Language Detection', () => {
     expect(isSourceFile('Renderer/Shaders.metal')).toBe(true);
   });
 
+  it('should detect Erlang files', () => {
+    expect(detectLanguage('src/my_server.erl')).toBe('erlang');
+    expect(detectLanguage('include/records.hrl')).toBe('erlang');
+  });
+
   it('should return unknown for unsupported extensions', () => {
     expect(detectLanguage('styles.css')).toBe('unknown');
     expect(detectLanguage('data.json')).toBe('unknown');
@@ -8753,3 +8758,382 @@ End Class
     expect(calls).toContain('Parser.GetSteamNameAndID');
   });
 });
+
+// =============================================================================
+// Erlang (vendored WhatsApp/tree-sitter-erlang grammar — the ELP grammar)
+// =============================================================================
+
+describe('Erlang Extraction', () => {
+  describe('Language detection', () => {
+    it('should report Erlang as supported', () => {
+      expect(isLanguageSupported('erlang')).toBe(true);
+      expect(getSupportedLanguages()).toContain('erlang');
+      expect(isSourceFile('apps/app/src/foo.erl')).toBe(true);
+      expect(isSourceFile('include/foo.hrl')).toBe(true);
+    });
+  });
+
+  describe('Function extraction', () => {
+    it('should merge multi-clause functions into one node spanning all clauses', () => {
+      const code = `-module(m).
+-export([classify/1]).
+
+classify(X) when is_atom(X) ->
+    atom;
+classify(X) when is_binary(X) ->
+    binary;
+classify(_X) ->
+    other.
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const fns = result.nodes.filter((n) => n.kind === 'function' && n.name === 'classify');
+      expect(fns).toHaveLength(1);
+      expect(fns[0]!.startLine).toBe(4);
+      expect(fns[0]!.endLine).toBe(9);
+      expect(fns[0]!.language).toBe('erlang');
+    });
+
+    it('should qualify functions with the module namespace', () => {
+      const code = `-module(my_server).
+-export([start/0]).
+
+start() -> ok.
+helper() -> ok.
+`;
+      const result = extractFromSource('src/my_server.erl', code);
+      const ns = result.nodes.find((n) => n.kind === 'namespace');
+      expect(ns?.name).toBe('my_server');
+      const start = result.nodes.find((n) => n.kind === 'function' && n.name === 'start');
+      expect(start?.qualifiedName).toBe('my_server::start');
+    });
+
+    it('should flag exported functions and honor -compile(export_all)', () => {
+      const code = `-module(m).
+-export([api/0]).
+
+api() -> internal().
+internal() -> ok.
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const api = result.nodes.find((n) => n.name === 'api');
+      const internal = result.nodes.find((n) => n.name === 'internal');
+      expect(api?.isExported).toBe(true);
+      expect(internal?.isExported).toBe(false);
+
+      const all = extractFromSource('src/all.erl', `-module(all).
+-compile(export_all).
+
+anything() -> ok.
+`);
+      expect(all.nodes.find((n) => n.name === 'anything')?.isExported).toBe(true);
+    });
+
+    it('should use the preceding -spec as the signature and capture doc comments', () => {
+      const code = `-module(m).
+
+%% Fetches a value by key.
+-spec fetch(binary()) -> {ok, term()} | not_found.
+fetch(Key) ->
+    lookup(Key).
+
+lookup(_K) -> not_found.
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const fetch = result.nodes.find((n) => n.name === 'fetch');
+      expect(fetch?.signature).toBe('-spec fetch(binary()) -> {ok, term()} | not_found.');
+      expect(fetch?.docstring).toBe('Fetches a value by key.');
+    });
+
+    it('should fall back to the clause header as the signature', () => {
+      const code = `-module(m).
+
+resize(W, H) when W > 0, H > 0 ->
+    {W, H}.
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const resize = result.nodes.find((n) => n.name === 'resize');
+      expect(resize?.signature).toBe('resize(W, H) when W > 0, H > 0');
+    });
+  });
+
+  describe('Record and type extraction', () => {
+    it('should extract records as structs with fields', () => {
+      const code = `-module(m).
+
+-record(state, {
+    store = #{} :: map(),
+    counter = 0 :: non_neg_integer()
+}).
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const rec = result.nodes.find((n) => n.kind === 'struct');
+      expect(rec?.name).toBe('state');
+      const fields = result.nodes.filter((n) => n.kind === 'field').map((n) => n.name);
+      expect(fields).toContain('store');
+      expect(fields).toContain('counter');
+    });
+
+    it('should extract -type and -opaque as type aliases, without bogus type-call refs', () => {
+      const code = `-module(m).
+
+-type key() :: atom() | binary().
+-opaque handle() :: reference().
+-spec noop(key()) -> ok.
+noop(_K) -> ok.
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const aliases = result.nodes.filter((n) => n.kind === 'type_alias').map((n) => n.name);
+      expect(aliases).toContain('key');
+      expect(aliases).toContain('handle');
+      // Type-position expressions parse as `call` nodes — the spec/type subtrees
+      // must not leak `calls` refs to type names like atom()/binary().
+      const bogus = result.unresolvedReferences.filter(
+        (r) => r.referenceKind === 'calls' && ['atom', 'binary', 'reference', 'key'].includes(r.referenceName)
+      );
+      expect(bogus).toHaveLength(0);
+    });
+
+    it('should extract -define macros as constants', () => {
+      const code = `-module(m).
+
+-define(TIMEOUT, 5000).
+-define(WRAP(X), {ok, X}).
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const consts = result.nodes.filter((n) => n.kind === 'constant').map((n) => n.name);
+      expect(consts).toContain('TIMEOUT');
+      expect(consts).toContain('WRAP');
+    });
+  });
+
+  describe('Import extraction', () => {
+    it('should extract -include/-include_lib and -import', () => {
+      const code = `-module(m).
+
+-include("records.hrl").
+-include_lib("kernel/include/logger.hrl").
+-import(lists, [map/2]).
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
+      expect(imports).toContain('records.hrl');
+      expect(imports).toContain('kernel/include/logger.hrl');
+      expect(imports).toContain('lists');
+      const ref = result.unresolvedReferences.find(
+        (r) => r.referenceKind === 'imports' && r.referenceName === 'records.hrl'
+      );
+      expect(ref).toBeDefined();
+    });
+  });
+
+  describe('Call extraction', () => {
+    it('should record local calls bare and remote calls module-qualified', () => {
+      const code = `-module(m).
+-export([run/1]).
+
+run(X) ->
+    Y = prepare(X),
+    other_mod:process(Y).
+
+prepare(X) -> X.
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
+      expect(calls).toContain('prepare');
+      // `mod:fn(...)` is emitted as `mod::fn` — the same shape the module
+      // namespace gives every function's qualifiedName, so it resolves via
+      // the qualified-name matcher.
+      expect(calls).toContain('other_mod::process');
+    });
+
+    it('should not emit calls for dynamic dispatch (var module / var fun)', () => {
+      const code = `-module(m).
+-export([run/2]).
+
+run(Mod, F) ->
+    Mod:handle(x),
+    F(y).
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
+      expect(calls).not.toContain('handle');
+      expect(calls).not.toContain('Mod::handle');
+      expect(calls).not.toContain('F');
+    });
+
+    it('should connect gen_server self-calls to the module handlers', () => {
+      const code = `-module(kv_store).
+-behaviour(gen_server).
+-export([get/1, put/2, drop/1]).
+-export([init/1, handle_call/3, handle_cast/2]).
+
+-define(SERVER, ?MODULE).
+
+get(Key) ->
+    gen_server:call(?SERVER, {get, Key}).
+
+put(Key, Value) ->
+    gen_server:cast(?MODULE, {put, Key, Value}).
+
+drop(Key) ->
+    gen_server:call(kv_store, {drop, Key}).
+
+init(_) -> {ok, #{}}.
+handle_call({get, K}, _From, S) -> {reply, maps:find(K, S), S};
+handle_call({drop, K}, _From, S) -> {reply, ok, maps:remove(K, S)}.
+handle_cast({put, K, V}, S) -> {noreply, maps:put(K, V, S)}.
+`;
+      const result = extractFromSource('src/kv_store.erl', code);
+      const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
+      // ?SERVER (defined as ?MODULE), ?MODULE, and the module's own atom all
+      // count as self — public API wrappers connect to their handlers.
+      expect(calls.filter((c) => c === 'kv_store::handle_call')).toHaveLength(2);
+      expect(calls).toContain('kv_store::handle_cast');
+    });
+
+    it('should not connect gen_server calls to other processes', () => {
+      const code = `-module(m).
+-export([go/2]).
+
+go(Pid, Msg) ->
+    gen_server:call(Pid, Msg),
+    gen_server:call(other_registered_name, Msg),
+    gen_server:cast({global, some_name}, Msg).
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
+      expect(calls.filter((c) => c.includes('handle_call') || c.includes('handle_cast'))).toHaveLength(0);
+    });
+
+    it('should lift static MFA arguments of the spawn/apply family into call refs', () => {
+      const code = `-module(m).
+-export([boot/2]).
+
+boot(Req, Env) ->
+    Pid = proc_lib:spawn_link(?MODULE, request_process, [Req, Env]),
+    spawn(?MODULE, monitor_loop, [Pid]),
+    apply(other_mod, handle, [Req]),
+    timer:apply_after(500, other_mod, tick, []),
+    Pid.
+
+request_process(_R, _E) -> ok.
+monitor_loop(_P) -> ok.
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
+      expect(calls).toContain('request_process'); // ?MODULE → bare, same-file resolution
+      expect(calls).toContain('monitor_loop');
+      expect(calls).toContain('other_mod::handle');
+      expect(calls).toContain('other_mod::tick');
+    });
+
+    it('should stay silent on dynamic spawn/apply (var module, fun value, or plain fun)', () => {
+      const code = `-module(m).
+-export([go/3]).
+
+go(M, F, A) ->
+    spawn(M, F, A),
+    spawn(fun() -> helper() end),
+    apply(M, F, A).
+
+helper() -> ok.
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
+      // The fun body's call is still walked; no phantom MFA targets appear.
+      expect(calls).toContain('helper');
+      expect(calls.filter((c) => c !== 'spawn' && c !== 'apply' && c !== 'helper')).toHaveLength(0);
+    });
+
+    it('should treat ?MODULE:fn calls as local calls', () => {
+      const code = `-module(m).
+-export([kick/0]).
+
+kick() ->
+    ?MODULE:work().
+
+work() -> ok.
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
+      expect(calls).toContain('work');
+    });
+
+    it('should capture fun name/arity values as function references', () => {
+      const code = `-module(m).
+-export([wire/1]).
+
+wire(Pids) ->
+    lists:foreach(fun notify/1, Pids),
+    lists:map(fun m:notify/1, Pids).
+
+notify(_P) -> ok.
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references').map((r) => r.referenceName);
+      expect(refs).toContain('notify');
+      expect(refs).toContain('m::notify');
+    });
+
+    it('should reference records used in bodies and argument patterns', () => {
+      const code = `-module(m).
+-export([mk/1, get_id/1]).
+
+-record(req, {id, payload}).
+
+mk(Id) -> #req{id = Id}.
+get_id(#req{id = Id}) -> Id.
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const refs = result.unresolvedReferences.filter(
+        (r) => r.referenceKind === 'references' && r.referenceName === 'req'
+      );
+      expect(refs.length).toBeGreaterThanOrEqual(2);
+    });
+
+    it('should attribute calls from every clause of a multi-clause function', () => {
+      const code = `-module(m).
+-export([handle/1]).
+
+handle({a, X}) ->
+    first(X);
+handle({b, X}) ->
+    second(X).
+
+first(X) -> X.
+second(X) -> X.
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const handle = result.nodes.find((n) => n.kind === 'function' && n.name === 'handle');
+      const calls = result.unresolvedReferences.filter(
+        (r) => r.referenceKind === 'calls' && r.fromNodeId === handle?.id
+      ).map((r) => r.referenceName);
+      expect(calls).toContain('first');
+      expect(calls).toContain('second');
+    });
+  });
+
+  describe('Behaviour extraction', () => {
+    it('should emit an implements reference for -behaviour', () => {
+      const code = `-module(m).
+-behaviour(gen_server).
+
+init(_) -> {ok, #{}}.
+`;
+      const result = extractFromSource('src/m.erl', code);
+      const impl = result.unresolvedReferences.find((r) => r.referenceKind === 'implements');
+      expect(impl?.referenceName).toBe('gen_server');
+    });
+
+    it('should not create symbols from -callback declarations', () => {
+      const code = `-module(b).
+
+-callback handle_thing(term()) -> ok.
+-callback init(list()) -> {ok, term()}.
+`;
+      const result = extractFromSource('src/b.erl', code);
+      const fns = result.nodes.filter((n) => n.kind === 'function');
+      expect(fns).toHaveLength(0);
+    });
+  });
+});

+ 61 - 0
__tests__/resolution.test.ts

@@ -83,6 +83,67 @@ describe('Resolution Module', () => {
       expect(result?.resolvedBy).toBe('exact-match');
     });
 
+    it('should resolve Erlang -behaviour refs only to module namespaces', () => {
+      // On emqx, `-behaviour(supervisor)` (OTP behaviour, not in the repo)
+      // fell through to bare-name matching and resolved to a
+      // `-define(supervisor, ...)` macro constant in an unrelated app.
+      const macroConstant: Node = {
+        id: 'constant:apps/bridge/src/impl.erl:supervisor:61',
+        kind: 'constant',
+        name: 'supervisor',
+        qualifiedName: 'impl::supervisor',
+        filePath: 'apps/bridge/src/impl.erl',
+        language: 'erlang',
+        startLine: 61,
+        endLine: 61,
+        startColumn: 0,
+        endColumn: 0,
+        updatedAt: Date.now(),
+      };
+      const behaviourModule: Node = {
+        id: 'namespace:src/my_behaviour.erl:my_behaviour:1',
+        kind: 'namespace',
+        name: 'my_behaviour',
+        qualifiedName: 'my_behaviour',
+        filePath: 'src/my_behaviour.erl',
+        language: 'erlang',
+        startLine: 1,
+        endLine: 1,
+        startColumn: 0,
+        endColumn: 0,
+        updatedAt: Date.now(),
+      };
+      const nodes = [macroConstant, behaviourModule];
+      const context: ResolutionContext = {
+        getNodesInFile: () => [],
+        getNodesByName: (name) => nodes.filter((n) => n.name === name),
+        getNodesByQualifiedName: () => [],
+        getNodesByKind: () => [],
+        fileExists: () => false,
+        readFile: () => null,
+        getProjectRoot: () => '/test',
+        getAllFiles: () => [],
+        getNodesByLowerName: () => [],
+        getImportMappings: () => [],
+      };
+      const mkRef = (name: string) => ({
+        fromNodeId: 'namespace:src/worker.erl:worker:1',
+        referenceName: name,
+        referenceKind: 'implements' as const,
+        line: 2,
+        column: 0,
+        filePath: 'src/worker.erl',
+        language: 'erlang' as const,
+      });
+
+      // Out-of-repo behaviour whose name collides with a macro constant:
+      // stays unresolved instead of linking the constant.
+      expect(matchReference(mkRef('supervisor'), context)).toBeNull();
+      // In-repo behaviour module resolves to its namespace.
+      const resolved = matchReference(mkRef('my_behaviour'), context);
+      expect(resolved?.targetNodeId).toBe(behaviourModule.id);
+    });
+
     it('should prefer same-module candidates over cross-module matches', () => {
       // Simulates a Python monorepo where multiple apps define navigate()
       const candidateA: Node = {

+ 7 - 1
src/extraction/grammars.ts

@@ -44,6 +44,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
   cfquery: 'tree-sitter-cfquery.wasm',
   cobol: 'tree-sitter-cobol.wasm',
   vbnet: 'tree-sitter-vbnet.wasm',
+  erlang: 'tree-sitter-erlang.wasm',
 };
 
 /**
@@ -135,6 +136,10 @@ export const EXTENSION_MAP: Record<string, Language> = {
   // VB.NET: vendored grammar (patched govindbanura/tree-sitter-vbnet) — classes,
   // modules, interfaces, structures, properties, events, Handles clauses, LINQ.
   '.vb': 'vbnet',
+  // Erlang: modules (.erl) and header files (.hrl). Vendored WhatsApp/
+  // tree-sitter-erlang grammar (the ELP grammar).
+  '.erl': 'erlang',
+  '.hrl': 'erlang',
   // Spring config: `application.properties` / `application-*.properties`. Same
   // shape as the `.yml` variants — the YAML/properties extractor emits one node
   // per leaf key, and the Spring resolver links `@Value("${k}")` references.
@@ -253,7 +258,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
       // `class Foo(...)` as an ERROR that swallows the whole class (#237); we
       // vendor the upstream ABI-15 tree-sitter-c-sharp 0.23.5 wasm, which parses
       // primary constructors natively.
-      const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet')
+      const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet' || lang === 'erlang')
         ? path.join(__dirname, 'wasm', wasmFile)
         : require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
       const language = await WasmLanguage.load(wasmPath);
@@ -473,6 +478,7 @@ export function getLanguageDisplayName(language: Language): string {
     cfquery: 'CFQuery (SQL)',
     cobol: 'COBOL',
     vbnet: 'Visual Basic .NET',
+    erlang: 'Erlang',
     unknown: 'Unknown',
   };
   return names[language] || language;

+ 276 - 0
src/extraction/languages/erlang.ts

@@ -0,0 +1,276 @@
+import type { Node as SyntaxNode } from 'web-tree-sitter';
+import { getNodeText, getChildByField, getPrecedingDocstring } from '../tree-sitter-helpers';
+import type { LanguageExtractor, ExtractorContext } from '../tree-sitter-types';
+
+// Node names follow the vendored WhatsApp/tree-sitter-erlang grammar (0.19,
+// ABI 14) — the grammar behind the Erlang Language Platform (ELP).
+//
+// Erlang is form-based, and three of its shapes don't fit the generic
+// extractor, so every symbol-bearing top-level form is dispatched through the
+// visitNode hook below instead:
+//   - a function's name lives on its CLAUSE, not the fun_decl, and the grammar
+//     emits one fun_decl PER CLAUSE — consecutive same-name fun_decl forms are
+//     merged into a single function node here;
+//   - type-position expressions (-spec/-type/-callback bodies, record field
+//     types) parse as `call` nodes, so descending into them would mint bogus
+//     call refs to type names (`pid()`, `term()`); the hook consumes those
+//     subtrees;
+//   - record_decl carries its fields as direct children (no body field), which
+//     the generic extractStruct would skip as a forward declaration.
+// Calls (local `f(X)`, remote `mod:f(X)`, `fun f/1` references, and record
+// usages) are handled by the erlang branch in extractCall — remote calls are
+// emitted as `mod::f`, which matches the qualifiedName the module namespace
+// produces (see packageTypes below), so cross-module resolution rides the
+// standard qualified-name matcher.
+
+/** Text of an atom with quoted-atom quotes stripped (`'EXIT'` → `EXIT`). */
+function atomText(node: SyntaxNode, source: string): string {
+  return getNodeText(node, source).replace(/^'([\s\S]*)'$/, '$1');
+}
+
+function collapseWs(text: string): string {
+  return text.replace(/\s+/g, ' ').trim();
+}
+
+// --- Per-file memos. Extraction is file-sequential within a worker, so a
+// single-entry memo keyed by filePath is safe (and resets naturally). ---
+
+/** Exported function names for the current file ('all' for -compile(export_all)). */
+let exportsFile = '';
+let exportsMemo: Set<string> | 'all' = new Set();
+
+/**
+ * Clause-merge state: the previous fun_decl's name and node id. A fun_decl
+ * whose clause repeats that name is a continuation clause (or a same-name
+ * different-arity definition — deliberately grouped under one node, the way
+ * overloads are elsewhere) and attaches to the existing node instead of
+ * creating a duplicate.
+ */
+let lastFnFile = '';
+let lastFnName = '';
+let lastFnId = '';
+
+function moduleExports(node: SyntaxNode, source: string, filePath: string): Set<string> | 'all' {
+  if (filePath === exportsFile) return exportsMemo;
+  let root: SyntaxNode = node;
+  while (root.parent) root = root.parent;
+  let result: Set<string> | 'all' = new Set<string>();
+  for (let i = 0; i < root.namedChildCount; i++) {
+    const form = root.namedChild(i);
+    if (!form) continue;
+    if (
+      form.type === 'compile_options_attribute' &&
+      getNodeText(form, source).includes('export_all')
+    ) {
+      result = 'all';
+      break;
+    }
+    if (form.type === 'export_attribute') {
+      for (const fa of form.namedChildren) {
+        if (fa.type !== 'fa') continue;
+        const fun = getChildByField(fa, 'fun');
+        if (fun) result.add(atomText(fun, source));
+      }
+    }
+  }
+  exportsFile = filePath;
+  exportsMemo = result;
+  return result;
+}
+
+/** The -spec directly above a function (comments may sit between), if it names it. */
+function precedingSpec(node: SyntaxNode, name: string, source: string): SyntaxNode | null {
+  let prev = node.previousNamedSibling;
+  while (prev && prev.type === 'comment') prev = prev.previousNamedSibling;
+  if (prev?.type === 'spec') {
+    const specFun = getChildByField(prev, 'fun');
+    if (specFun && atomText(specFun, source) === name) return prev;
+  }
+  return null;
+}
+
+/** `name(Args) when Guard` — the clause text up to the `->`. */
+function clauseHeader(clause: SyntaxNode, source: string): string | undefined {
+  const body = getChildByField(clause, 'body');
+  const end = body ? body.startIndex : clause.endIndex;
+  return collapseWs(source.substring(clause.startIndex, end)) || undefined;
+}
+
+function handleFunDecl(node: SyntaxNode, ctx: ExtractorContext): boolean {
+  const clauses = node.namedChildren.filter((c) => c.type === 'function_clause');
+  const first = clauses[0];
+  if (!first) return true; // macro-templated clause (`?M(...) -> ...`) — no static name
+  const nameNode = getChildByField(first, 'name');
+  if (!nameNode) return true;
+  const name = atomText(nameNode, ctx.source);
+  if (!name) return true;
+
+  // Continuation clause: extend the existing node's span and attribute this
+  // clause's calls to it.
+  if (ctx.filePath === lastFnFile && name === lastFnName && lastFnId) {
+    for (let i = ctx.nodes.length - 1; i >= 0; i--) {
+      const n = ctx.nodes[i];
+      if (n && n.id === lastFnId) {
+        if (node.endPosition.row + 1 > n.endLine) n.endLine = node.endPosition.row + 1;
+        break;
+      }
+    }
+    ctx.pushScope(lastFnId);
+    for (const clause of clauses) ctx.visitFunctionBody(clause, lastFnId);
+    ctx.popScope();
+    return true;
+  }
+
+  const spec = precedingSpec(node, name, ctx.source);
+  const exports = moduleExports(node, ctx.source, ctx.filePath);
+  const fn = ctx.createNode('function', name, node, {
+    docstring: getPrecedingDocstring(spec ?? node, ctx.source),
+    signature: spec
+      ? collapseWs(getNodeText(spec, ctx.source)).slice(0, 300)
+      : clauseHeader(first, ctx.source),
+    isExported: exports === 'all' || exports.has(name),
+  });
+  if (!fn) return true;
+  ctx.pushScope(fn.id);
+  // The whole clause is walked (not just the body) so record patterns in the
+  // arguments and guard calls contribute references too.
+  for (const clause of clauses) ctx.visitFunctionBody(clause, fn.id);
+  ctx.popScope();
+  lastFnFile = ctx.filePath;
+  lastFnName = name;
+  lastFnId = fn.id;
+  return true;
+}
+
+function handleRecordDecl(node: SyntaxNode, ctx: ExtractorContext): boolean {
+  const nameNode = getChildByField(node, 'name');
+  if (!nameNode) return true;
+  const rec = ctx.createNode('struct', atomText(nameNode, ctx.source), node, {
+    docstring: getPrecedingDocstring(node, ctx.source),
+    signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 300),
+  });
+  if (rec) {
+    ctx.pushScope(rec.id);
+    for (const field of node.namedChildren) {
+      if (field.type !== 'record_field') continue;
+      const fieldName = getChildByField(field, 'name');
+      if (fieldName) ctx.createNode('field', atomText(fieldName, ctx.source), field);
+    }
+    ctx.popScope();
+  }
+  return true; // field types/defaults are type-position exprs — don't descend
+}
+
+function handleTypeAlias(node: SyntaxNode, ctx: ExtractorContext): boolean {
+  const typeName = getChildByField(node, 'name'); // type_name wrapper
+  const nameNode = typeName ? getChildByField(typeName, 'name') : null;
+  if (nameNode) {
+    ctx.createNode('type_alias', atomText(nameNode, ctx.source), node, {
+      signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 200),
+    });
+  }
+  return true;
+}
+
+function handlePpDefine(node: SyntaxNode, ctx: ExtractorContext): boolean {
+  const lhs = getChildByField(node, 'lhs');
+  const nameNode = lhs ? getChildByField(lhs, 'name') : null;
+  if (nameNode) {
+    ctx.createNode('constant', getNodeText(nameNode, ctx.source), node, {
+      signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 200),
+    });
+  }
+  return true; // the replacement's calls only exist at expansion sites
+}
+
+function handleBehaviour(node: SyntaxNode, ctx: ExtractorContext): boolean {
+  const nameNode = getChildByField(node, 'name');
+  const parentId = ctx.nodeStack[ctx.nodeStack.length - 1];
+  if (nameNode && parentId) {
+    // `-behaviour(x)` implements x's callback contract. Resolves when the
+    // behaviour module is in the repo; OTP behaviours (gen_server, …) simply
+    // stay unresolved.
+    ctx.addUnresolvedReference({
+      fromNodeId: parentId,
+      referenceName: atomText(nameNode, ctx.source),
+      referenceKind: 'implements',
+      line: node.startPosition.row + 1,
+      column: node.startPosition.column,
+    });
+  }
+  return true;
+}
+
+export const erlangExtractor: LanguageExtractor = {
+  functionTypes: ['fun_decl'], // dispatched via visitNode (name lives on the clause)
+  classTypes: [],
+  methodTypes: [],
+  interfaceTypes: [],
+  structTypes: ['record_decl'], // dispatched via visitNode (fields are direct children)
+  enumTypes: [],
+  typeAliasTypes: ['type_alias', 'opaque'], // dispatched via visitNode
+  importTypes: ['import_attribute', 'pp_include', 'pp_include_lib'],
+  callTypes: [
+    'call',
+    'internal_fun', // fun f/1
+    'external_fun', // fun mod:f/1
+    'record_expr', // #rec{...} construction
+    'record_update_expr', // X#rec{...}
+    'record_index_expr', // #rec.field
+    'record_field_expr', // X#rec.field
+  ],
+  variableTypes: [],
+  nameField: 'name',
+  bodyField: 'body',
+  paramsField: 'args',
+
+  // `-module(m)` wraps the file's declarations in a namespace so every
+  // function's qualifiedName is `m::f` — which is exactly the reference shape
+  // the extractCall erlang branch emits for remote calls, so `mod:f(...)`
+  // resolves through matchByQualifiedName with no resolver changes.
+  packageTypes: ['module_attribute'],
+  extractPackage: (node, source) => {
+    const name = getChildByField(node, 'name');
+    return name ? atomText(name, source) : null;
+  },
+
+  extractImport: (node, source) => {
+    if (node.type === 'import_attribute') {
+      const mod = getChildByField(node, 'module');
+      if (!mod) return null;
+      return {
+        moduleName: atomText(mod, source),
+        signature: collapseWs(getNodeText(node, source)).slice(0, 200),
+      };
+    }
+    // pp_include / pp_include_lib — a C-include-style file dependency on a .hrl.
+    const file = getChildByField(node, 'file');
+    if (!file) return null;
+    const headerPath = getNodeText(file, source).replace(/^"/, '').replace(/"$/, '');
+    if (!headerPath) return null;
+    return { moduleName: headerPath, signature: getNodeText(node, source).trim() };
+  },
+
+  visitNode: (node, ctx) => {
+    switch (node.type) {
+      case 'fun_decl':
+        return handleFunDecl(node, ctx);
+      case 'record_decl':
+        return handleRecordDecl(node, ctx);
+      case 'type_alias':
+      case 'opaque':
+        return handleTypeAlias(node, ctx);
+      case 'pp_define':
+        return handlePpDefine(node, ctx);
+      case 'behaviour_attribute':
+        return handleBehaviour(node, ctx);
+      // -spec / -callback: their type expressions parse as `call` nodes;
+      // consume the subtree so the walker doesn't mint bogus call refs.
+      case 'spec':
+      case 'callback':
+        return true;
+      default:
+        return false;
+    }
+  },
+};

+ 2 - 0
src/extraction/languages/index.ts

@@ -31,6 +31,7 @@ import { cfscriptExtractor } from './cfscript';
 import { cfqueryExtractor } from './cfquery';
 import { cobolExtractor } from './cobol';
 import { vbnetExtractor } from './vbnet';
+import { erlangExtractor } from './erlang';
 
 export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
   typescript: typescriptExtractor,
@@ -59,4 +60,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
   cfquery: cfqueryExtractor,
   cobol: cobolExtractor,
   vbnet: vbnetExtractor,
+  erlang: erlangExtractor,
 };

+ 1 - 0
src/extraction/tree-sitter-helpers.ts

@@ -84,6 +84,7 @@ function cleanCommentMarkers(comment: string): string {
     .replace(/^\/\/[/!]?\s?/gm, '') // // , and Rust/Swift doc lines /// //!
     .replace(/^--\s?/gm, '') //        Lua/Luau line comments
     .replace(/^#\s?/gm, '') //         Python/Ruby/shell line comments
+    .replace(/^%+\s?/gm, '') //        Erlang line comments (% / %% / %%%)
     .replace(/^\s*\*\s?/gm, '') //     block-comment continuation (* foo)
     .trim();
 }

+ 209 - 0
src/extraction/tree-sitter.ts

@@ -60,6 +60,22 @@ const VUE_STORE_FACTORY_CALLEES = new Set(['defineStore', 'createStore']);
  *  `const actions = {…}` as a store collection — see looksLikeVueStoreFile). */
 const VUE_STORE_FILE_SIGNAL = /\bdefineStore\b|\bcreateStore\b|\bVuex\b|\bmutations\b|\bactions\b|\bgetters\b|\bnamespaced\b/g;
 
+/**
+ * Erlang calls that take their real callee as (Module, Function, Args)
+ * ARGUMENTS — the spawn/apply family. Keys are the callee as the call site
+ * spells it: bare for auto-imported BIFs, `module:function` for remote calls.
+ * Used by the erlang branch of extractCall to lift a static MFA pair into a
+ * call edge (the spawned/applied function is otherwise invisible to the graph).
+ */
+const ERLANG_MFA_CALLS = new Set([
+  'spawn', 'spawn_link', 'spawn_monitor', 'spawn_opt', 'apply',
+  'erlang:spawn', 'erlang:spawn_link', 'erlang:spawn_monitor', 'erlang:spawn_opt', 'erlang:apply',
+  'proc_lib:spawn', 'proc_lib:spawn_link', 'proc_lib:spawn_opt', 'proc_lib:start', 'proc_lib:start_link',
+  'timer:apply_after', 'timer:apply_interval',
+  'rpc:call', 'rpc:cast', 'rpc:async_call',
+  'erpc:call', 'erpc:cast',
+]);
+
 /**
  * Extract the name from a node based on language
  */
@@ -3492,6 +3508,50 @@ export class TreeSitterExtractor {
   /**
    * Extract a function call
    */
+  /**
+   * Whether an Erlang gen_server target expression statically refers to the
+   * module it appears in: `?MODULE`, a macro the file defines as `?MODULE`
+   * (`-define(SERVER, ?MODULE)` — the standard idiom), or the module's own
+   * name as an atom. The self-macro set is memoized per file (single entry —
+   * extraction is file-sequential).
+   */
+  private erlangSelfMacroFile = '';
+  private erlangSelfMacros = new Set<string>();
+
+  private isErlangSelfReference(target: SyntaxNode): boolean {
+    const ownModule = (this.filePath.split('/').pop() ?? '').replace(/\.erl$/, '');
+    if (target.type === 'atom') {
+      return getNodeText(target, this.source) === ownModule;
+    }
+    if (target.type !== 'macro_call_expr') return false;
+    const nameNode = getChildByField(target, 'name');
+    if (!nameNode) return false;
+    const macroName = getNodeText(nameNode, this.source);
+    if (macroName === 'MODULE') return true;
+    if (this.erlangSelfMacroFile !== this.filePath) {
+      this.erlangSelfMacroFile = this.filePath;
+      this.erlangSelfMacros = new Set<string>();
+      let root: SyntaxNode = target;
+      while (root.parent) root = root.parent;
+      for (let i = 0; i < root.namedChildCount; i++) {
+        const form = root.namedChild(i);
+        if (form?.type !== 'pp_define') continue;
+        const lhs = getChildByField(form, 'lhs');
+        const defName = lhs ? getChildByField(lhs, 'name') : null;
+        const replacement = getChildByField(form, 'replacement');
+        if (
+          defName &&
+          replacement?.type === 'macro_call_expr' &&
+          getChildByField(replacement, 'name') &&
+          getNodeText(getChildByField(replacement, 'name')!, this.source) === 'MODULE'
+        ) {
+          this.erlangSelfMacros.add(getNodeText(defName, this.source));
+        }
+      }
+    }
+    return this.erlangSelfMacros.has(macroName);
+  }
+
   private extractCall(node: SyntaxNode): void {
     if (this.nodeStack.length === 0) return;
 
@@ -3543,6 +3603,155 @@ export class TreeSitterExtractor {
       return;
     }
 
+    // Erlang: a local call is `call(expr: atom, args)`; a remote call nests it
+    // under `remote(module: remote_module, fun: call)` — the module qualifier
+    // lives on the PARENT. Remote calls are emitted as `mod::fn`, which is
+    // byte-identical to the qualifiedName the module namespace gives every
+    // function (see packageTypes in languages/erlang.ts), so they resolve via
+    // matchByQualifiedName. A var/macro callee or module (`F(X)`, `?M(X)`,
+    // `Mod:handle(X)`) has no static target — except `?MODULE:fn(X)`, which the
+    // bare name + same-file preference resolves correctly. `fun name/1` /
+    // `fun mod:name/1` values are function REFERENCES (callback registration),
+    // and record construction/update/index/field-access are `references` to the
+    // record's struct node.
+    if (this.language === 'erlang') {
+      const line = node.startPosition.row + 1;
+      const column = node.startPosition.column;
+      const erlAtom = (n: SyntaxNode): string => getNodeText(n, this.source).replace(/^'([\s\S]*)'$/, '$1');
+      if (node.type === 'call') {
+        let callee = getChildByField(node, 'expr');
+        let moduleNode: SyntaxNode | null = null;
+        // remote(module, fun: call) — the shape the grammar produces today; the
+        // node-types also permit call(expr: remote), so handle both nestings.
+        if (node.parent?.type === 'remote') {
+          moduleNode = getChildByField(node.parent, 'module');
+        } else if (callee?.type === 'remote') {
+          moduleNode = getChildByField(callee, 'module');
+          callee = getChildByField(callee, 'fun');
+        }
+        if (callee?.type === 'atom') {
+          const fnBare = erlAtom(callee);
+          let calleeName = fnBare;
+          const moduleExpr = moduleNode ? getChildByField(moduleNode, 'module') : null;
+          if (moduleExpr?.type === 'atom') {
+            calleeName = `${erlAtom(moduleExpr)}::${calleeName}`;
+          } else if (moduleExpr) {
+            // Non-atom module qualifier. `?MODULE:f(X)` targets THIS module —
+            // keep the bare name so same-file preference resolves it. Anything
+            // else (`Mod:f(X)`) is behaviour-style dynamic dispatch with no
+            // static target: emitting the bare name would link an arbitrary
+            // same-named function, so stay silent instead.
+            const macroName =
+              moduleExpr.type === 'macro_call_expr' ? getChildByField(moduleExpr, 'name') : null;
+            if (!macroName || getNodeText(macroName, this.source) !== 'MODULE') return;
+          }
+          this.unresolvedReferences.push({
+            fromNodeId: callerId,
+            referenceName: calleeName,
+            referenceKind: 'calls',
+            line,
+            column,
+          });
+          // gen_server self-dispatch: `gen_server:call(?SERVER, Msg)` /
+          // `gen_server:cast(?MODULE, Msg)` — the OTP API-wrapper idiom (a
+          // module's public functions wrap gen_server requests to itself, and
+          // the real work happens in its own handle_call/handle_cast). The
+          // target is static when the first argument is ?MODULE, a macro the
+          // file defines as ?MODULE (the standard `-define(SERVER, ?MODULE)`),
+          // or the module's own name as an atom — emit the qualified callback
+          // ref so the module's public API connects to its handlers. Any other
+          // target (pid/var/registered name of another process) stays silent.
+          if (
+            moduleExpr?.type === 'atom' &&
+            erlAtom(moduleExpr) === 'gen_server' &&
+            (fnBare === 'call' || fnBare === 'cast' || fnBare === 'send_request')
+          ) {
+            const argsNode = getChildByField(node, 'args');
+            const target = argsNode?.namedChild(0) ?? null;
+            if (target && this.isErlangSelfReference(target)) {
+              const ownModule = (this.filePath.split('/').pop() ?? '').replace(/\.erl$/, '');
+              if (ownModule) {
+                this.unresolvedReferences.push({
+                  fromNodeId: callerId,
+                  referenceName: `${ownModule}::${fnBare === 'cast' ? 'handle_cast' : 'handle_call'}`,
+                  referenceKind: 'calls',
+                  line,
+                  column,
+                });
+              }
+            }
+          }
+          // MFA-in-argument dispatch: the spawn/apply family names its real
+          // callee in ARGUMENT position — `proc_lib:spawn_link(?MODULE,
+          // request_process, [Req, Env, Middlewares])` — so the walker above
+          // sees only the spawn itself and the spawned function ends up with
+          // zero callers (measured on cowboy: request_process had no incoming
+          // edges and the agent Read the file to find it). When the (Module,
+          // Function) pair is static, lift it as a call edge. The pair is
+          // found positionally-agnostically (first adjacent module-atom/
+          // ?MODULE + atom pair) so every arity variant works: spawn/3,
+          // spawn(Node,M,F,A)/4, timer:apply_after(Time,M,F,A),
+          // rpc:call(Node,M,F,A). A var module or fun stays silent.
+          const familyKey = moduleExpr?.type === 'atom' ? `${erlAtom(moduleExpr)}:${fnBare}` : fnBare;
+          if (ERLANG_MFA_CALLS.has(familyKey)) {
+            const argsNode = getChildByField(node, 'args');
+            const argExprs = argsNode ? argsNode.namedChildren : [];
+            for (let i = 0; i + 1 < argExprs.length; i++) {
+              const m = argExprs[i]!;
+              const f = argExprs[i + 1]!;
+              if (f.type !== 'atom') continue;
+              const isLocalModule =
+                m.type === 'macro_call_expr' &&
+                getChildByField(m, 'name') !== null &&
+                getNodeText(getChildByField(m, 'name')!, this.source) === 'MODULE';
+              if (m.type !== 'atom' && !isLocalModule) continue;
+              this.unresolvedReferences.push({
+                fromNodeId: callerId,
+                referenceName: isLocalModule ? erlAtom(f) : `${erlAtom(m)}::${erlAtom(f)}`,
+                referenceKind: 'calls',
+                line: f.startPosition.row + 1,
+                column: f.startPosition.column,
+              });
+              break;
+            }
+          }
+        }
+        return;
+      }
+      if (node.type === 'internal_fun' || node.type === 'external_fun') {
+        const funNode = getChildByField(node, 'fun');
+        if (funNode?.type !== 'atom') return; // fun Mod:F/A with var parts — dynamic
+        let refName = erlAtom(funNode);
+        if (node.type === 'external_fun') {
+          const moduleWrapper = getChildByField(node, 'module');
+          const moduleAtom = moduleWrapper ? getChildByField(moduleWrapper, 'name') : null;
+          if (moduleAtom?.type !== 'atom') return;
+          refName = `${erlAtom(moduleAtom)}::${refName}`;
+        }
+        this.unresolvedReferences.push({
+          fromNodeId: callerId,
+          referenceName: refName,
+          referenceKind: 'references',
+          line,
+          column,
+        });
+        return;
+      }
+      // record_expr / record_update_expr / record_index_expr / record_field_expr
+      const recordName = getChildByField(node, 'name');
+      const recordAtom = recordName?.type === 'record_name' ? getChildByField(recordName, 'name') : null;
+      if (recordAtom?.type === 'atom') {
+        this.unresolvedReferences.push({
+          fromNodeId: callerId,
+          referenceName: erlAtom(recordAtom),
+          referenceKind: 'references',
+          line,
+          column,
+        });
+      }
+      return;
+    }
+
     // Ruby `call` nodes use `receiver` + `method` fields (tree-sitter-ruby), not
     // the `object`/`name`/`function` fields the branches below expect — so
     // without this they fell through to the generic path, which took the

BIN
src/extraction/wasm/tree-sitter-erlang.wasm


+ 37 - 4
src/mcp/tools.ts

@@ -95,6 +95,36 @@ function lastQualifierPart(symbol: string): string {
   return parts[parts.length - 1] ?? symbol;
 }
 
+/**
+ * Normalize Erlang-native symbol spellings in an explore query into the shapes
+ * the rest of the pipeline already understands. Agents working Erlang code
+ * name symbols the way the language spells them — `mod:fn/3`, `init/2` — and
+ * those tokens previously died in both consumers: the flow-builder's token
+ * filter rejects `:` and `/arity` outright, and the search-side field parser
+ * eats `mod:fn` as an unknown `field:value`. Measured on cowboy: the agent
+ * named `cowboy_stream_h:request_process/3` in two queries, got no body back
+ * either time, and fell back to Read.
+ *
+ *   - `fn/3` → `fn` (arity tail after an identifier; a path segment like
+ *     `src/2fa` doesn't match because the tail must be all digits)
+ *   - `mod:fn` → `mod.fn` (exactly one colon between identifiers, so it rides
+ *     the existing Class.method qualified handling; `::`, URLs, drive letters,
+ *     and times don't match, and the query language's own field prefixes —
+ *     kind:/lang:/language:/path:/name: — are left alone)
+ *
+ * Safe cross-language: Lua's `t:m` spelling maps to the same `t.m` its
+ * qualified names use, and no other supported spelling contains a bare
+ * single-colon identifier pair.
+ */
+export function normalizeQuerySpelling(query: string): string {
+  return query
+    .replace(/\b([A-Za-z_][\w@]*)\/(\d{1,3})(?=$|[\s,()[\]/])/g, '$1')
+    .replace(
+      /(^|[\s,()[\]])(?!(?:kind|lang|language|path|name):)([a-z_][\w@]*):([A-Za-z_][\w@]*)(?=$|[\s,()[\]])/g,
+      '$1$2.$3'
+    );
+}
+
 /**
  * Calculate the recommended number of codegraph_explore calls based on project size.
  * Larger codebases need more exploration calls to cover their surface area,
@@ -1854,7 +1884,7 @@ export class ToolHandler {
       // names (Class.method / Class::method) — the agent's most precise input,
       // resolved exactly by findAllSymbols. (The old strip mangled Class.method
       // into Class, throwing the method away.)
-      const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro)$/i;
+      const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl)$/i;
       const tokens = [...new Set(
         query.split(/[\s,()[\]]+/)
           .map((t) => t.replace(FILE_EXT, '').trim())
@@ -2457,8 +2487,11 @@ export class ToolHandler {
    * tax on small projects while earning its keep on large ones.
    */
   private async handleExplore(args: Record<string, unknown>): Promise<ToolResult> {
-    const query = this.validateString(args.query, 'query');
-    if (typeof query !== 'string') return query;
+    const rawQuery = this.validateString(args.query, 'query');
+    if (typeof rawQuery !== 'string') return rawQuery;
+    // One normalization point so the flow-builder, relevance search, and
+    // ranking all see the same canonical spelling (Erlang `mod:fn/arity`).
+    const query = normalizeQuerySpelling(rawQuery);
 
     const cg = this.getCodeGraph(args.projectPath as string | undefined);
     const projectRoot = cg.getProjectRoot();
@@ -2539,7 +2572,7 @@ export class ToolHandler {
     // overloads (the query also named the type) all earn it. (#1064)
     const tierSeedIds = new Set<string>();
     {
-      const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro)$/i;
+      const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl)$/i;
       const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
       const isTestPath = (p: string) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
       const bodyLines = (n: Node) => Math.max(0, (n.endLine ?? n.startLine) - n.startLine);

+ 20 - 0
src/resolution/name-matcher.ts

@@ -1753,6 +1753,26 @@ export function matchReference(
     return matchFunctionRef(ref, context);
   }
 
+  // Erlang `-behaviour(m)` refs target a MODULE. Letting them fall through to
+  // bare-name matching grabs any same-named symbol — on emqx,
+  // `-behaviour(supervisor)` resolved to a `-define(supervisor, …)` macro
+  // constant in an unrelated app. Resolve only to the behaviour module's
+  // namespace; an out-of-repo behaviour (OTP's gen_server/supervisor) stays
+  // unresolved rather than guessed.
+  if (ref.language === 'erlang' && ref.referenceKind === 'implements') {
+    const modules = context
+      .getNodesByName(ref.referenceName)
+      .filter((n) => n.language === 'erlang' && n.kind === 'namespace');
+    const chosen = preferCallSiteFile(modules, ref.filePath)[0];
+    if (!chosen) return null;
+    return {
+      original: ref,
+      targetNodeId: chosen.id,
+      confidence: 0.9,
+      resolvedBy: 'exact-match',
+    };
+  }
+
   // Try strategies in order of confidence
   let result: ResolvedRef | null;
 

+ 1 - 0
src/types.ts

@@ -100,6 +100,7 @@ export const LANGUAGES = [
   'cfquery',
   'cobol',
   'vbnet',
+  'erlang',
   'unknown',
 ] as const;