diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json index ce5ae3b..93e9f24 100644 --- a/.claude/skills/agent-eval/corpus.json +++ b/.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." + } ] -} \ No newline at end of file +} diff --git a/CHANGELOG.md b/CHANGELOG.md index faf97c8..144ec92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- 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 (``/``) and modern bare-script `component { ... }` syntax, including `extends`/`implements`, embedded `` blocks (at any nesting depth, including inside ``/``/``), call edges, and calls embedded in `#hash#` expressions inside `` 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) diff --git a/README.md b/README.md index a9522c8..3ae0732 100644 --- a/README.md +++ b/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 ``/`` and bare-script `component { ... }` styles, `extends`/`implements`, embedded `` 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 diff --git a/__tests__/explore-output-budget.test.ts b/__tests__/explore-output-budget.test.ts index 5e14647..9d9a0b3 100644 --- a/__tests__/explore-output-budget.test.ts +++ b/__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'); + }); +}); diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 4441e2b..08fd356 100644 --- a/__tests__/extraction.test.ts +++ b/__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); + }); + }); +}); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 1351442..a9dd2a3 100644 --- a/__tests__/resolution.test.ts +++ b/__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 = { diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 52360c6..d139fd8 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -44,6 +44,7 @@ const WASM_GRAMMAR_FILES: Record = { 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 = { // 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 | '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 | 'all' { + if (filePath === exportsFile) return exportsMemo; + let root: SyntaxNode = node; + while (root.parent) root = root.parent; + let result: Set | 'all' = new Set(); + 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; + } + }, +}; diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 6e16766..a257713 100644 --- a/src/extraction/languages/index.ts +++ b/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> = { typescript: typescriptExtractor, @@ -59,4 +60,5 @@ export const EXTRACTORS: Partial> = { cfquery: cfqueryExtractor, cobol: cobolExtractor, vbnet: vbnetExtractor, + erlang: erlangExtractor, }; diff --git a/src/extraction/tree-sitter-helpers.ts b/src/extraction/tree-sitter-helpers.ts index 4ffa595..a6438e1 100644 --- a/src/extraction/tree-sitter-helpers.ts +++ b/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(); } diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index b16a63c..c162159 100644 --- a/src/extraction/tree-sitter.ts +++ b/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(); + + 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(); + 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 diff --git a/src/extraction/wasm/tree-sitter-erlang.wasm b/src/extraction/wasm/tree-sitter-erlang.wasm new file mode 100755 index 0000000..e0e8dd7 Binary files /dev/null and b/src/extraction/wasm/tree-sitter-erlang.wasm differ diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 064416f..56f4c38 100644 --- a/src/mcp/tools.ts +++ b/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): Promise { - 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(); { - 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); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index feb3e8e..f47f771 100644 --- a/src/resolution/name-matcher.ts +++ b/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; diff --git a/src/types.ts b/src/types.ts index d58e7b2..8d82959 100644 --- a/src/types.ts +++ b/src/types.ts @@ -100,6 +100,7 @@ export const LANGUAGES = [ 'cfquery', 'cobol', 'vbnet', + 'erlang', 'unknown', ] as const;