Ver código fonte

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 meses atrás
pai
commit
d2bae8e094

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

@@ -469,5 +469,28 @@
       "files": "~145",
       "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."
       "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
 ### 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 **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 **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)
 - 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 |
 | **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 |
 | **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 |
 | **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 |
 | **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 |
 | **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 |
 | **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) |
 | 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) |
 | 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) |
 | 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
 ## 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 fs from 'fs';
 import * as path from 'path';
 import * as path from 'path';
 import * as os from 'os';
 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';
 import CodeGraph from '../src/index';
 
 
 describe('getExploreOutputBudget', () => {
 describe('getExploreOutputBudget', () => {
@@ -254,3 +254,27 @@ describe('codegraph_explore output respects the adaptive budget', () => {
     expect(hasMethodBody).toBe(true);
     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);
     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', () => {
   it('should return unknown for unsupported extensions', () => {
     expect(detectLanguage('styles.css')).toBe('unknown');
     expect(detectLanguage('styles.css')).toBe('unknown');
     expect(detectLanguage('data.json')).toBe('unknown');
     expect(detectLanguage('data.json')).toBe('unknown');
@@ -8753,3 +8758,382 @@ End Class
     expect(calls).toContain('Parser.GetSteamNameAndID');
     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');
       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', () => {
     it('should prefer same-module candidates over cross-module matches', () => {
       // Simulates a Python monorepo where multiple apps define navigate()
       // Simulates a Python monorepo where multiple apps define navigate()
       const candidateA: Node = {
       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',
   cfquery: 'tree-sitter-cfquery.wasm',
   cobol: 'tree-sitter-cobol.wasm',
   cobol: 'tree-sitter-cobol.wasm',
   vbnet: 'tree-sitter-vbnet.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,
   // VB.NET: vendored grammar (patched govindbanura/tree-sitter-vbnet) — classes,
   // modules, interfaces, structures, properties, events, Handles clauses, LINQ.
   // modules, interfaces, structures, properties, events, Handles clauses, LINQ.
   '.vb': 'vbnet',
   '.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
   // Spring config: `application.properties` / `application-*.properties`. Same
   // shape as the `.yml` variants — the YAML/properties extractor emits one node
   // shape as the `.yml` variants — the YAML/properties extractor emits one node
   // per leaf key, and the Spring resolver links `@Value("${k}")` references.
   // 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
       // `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
       // vendor the upstream ABI-15 tree-sitter-c-sharp 0.23.5 wasm, which parses
       // primary constructors natively.
       // 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)
         ? path.join(__dirname, 'wasm', wasmFile)
         : require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
         : require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
       const language = await WasmLanguage.load(wasmPath);
       const language = await WasmLanguage.load(wasmPath);
@@ -473,6 +478,7 @@ export function getLanguageDisplayName(language: Language): string {
     cfquery: 'CFQuery (SQL)',
     cfquery: 'CFQuery (SQL)',
     cobol: 'COBOL',
     cobol: 'COBOL',
     vbnet: 'Visual Basic .NET',
     vbnet: 'Visual Basic .NET',
+    erlang: 'Erlang',
     unknown: 'Unknown',
     unknown: 'Unknown',
   };
   };
   return names[language] || language;
   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 { cfqueryExtractor } from './cfquery';
 import { cobolExtractor } from './cobol';
 import { cobolExtractor } from './cobol';
 import { vbnetExtractor } from './vbnet';
 import { vbnetExtractor } from './vbnet';
+import { erlangExtractor } from './erlang';
 
 
 export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
 export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
   typescript: typescriptExtractor,
   typescript: typescriptExtractor,
@@ -59,4 +60,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
   cfquery: cfqueryExtractor,
   cfquery: cfqueryExtractor,
   cobol: cobolExtractor,
   cobol: cobolExtractor,
   vbnet: vbnetExtractor,
   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, '') // // , and Rust/Swift doc lines /// //!
     .replace(/^--\s?/gm, '') //        Lua/Luau line comments
     .replace(/^--\s?/gm, '') //        Lua/Luau line comments
     .replace(/^#\s?/gm, '') //         Python/Ruby/shell line comments
     .replace(/^#\s?/gm, '') //         Python/Ruby/shell line comments
+    .replace(/^%+\s?/gm, '') //        Erlang line comments (% / %% / %%%)
     .replace(/^\s*\*\s?/gm, '') //     block-comment continuation (* foo)
     .replace(/^\s*\*\s?/gm, '') //     block-comment continuation (* foo)
     .trim();
     .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 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;
 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
  * Extract the name from a node based on language
  */
  */
@@ -3492,6 +3508,50 @@ export class TreeSitterExtractor {
   /**
   /**
    * Extract a function call
    * 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 {
   private extractCall(node: SyntaxNode): void {
     if (this.nodeStack.length === 0) return;
     if (this.nodeStack.length === 0) return;
 
 
@@ -3543,6 +3603,155 @@ export class TreeSitterExtractor {
       return;
       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
     // Ruby `call` nodes use `receiver` + `method` fields (tree-sitter-ruby), not
     // the `object`/`name`/`function` fields the branches below expect — so
     // the `object`/`name`/`function` fields the branches below expect — so
     // without this they fell through to the generic path, which took the
     // 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;
   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.
  * Calculate the recommended number of codegraph_explore calls based on project size.
  * Larger codebases need more exploration calls to cover their surface area,
  * 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,
       // names (Class.method / Class::method) — the agent's most precise input,
       // resolved exactly by findAllSymbols. (The old strip mangled Class.method
       // resolved exactly by findAllSymbols. (The old strip mangled Class.method
       // into Class, throwing the method away.)
       // 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(
       const tokens = [...new Set(
         query.split(/[\s,()[\]]+/)
         query.split(/[\s,()[\]]+/)
           .map((t) => t.replace(FILE_EXT, '').trim())
           .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.
    * tax on small projects while earning its keep on large ones.
    */
    */
   private async handleExplore(args: Record<string, unknown>): Promise<ToolResult> {
   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 cg = this.getCodeGraph(args.projectPath as string | undefined);
     const projectRoot = cg.getProjectRoot();
     const projectRoot = cg.getProjectRoot();
@@ -2539,7 +2572,7 @@ export class ToolHandler {
     // overloads (the query also named the type) all earn it. (#1064)
     // overloads (the query also named the type) all earn it. (#1064)
     const tierSeedIds = new Set<string>();
     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 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 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);
       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);
     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
   // Try strategies in order of confidence
   let result: ResolvedRef | null;
   let result: ResolvedRef | null;
 
 

+ 1 - 0
src/types.ts

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