diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json
index 150b4a601..699ed08f7 100644
--- a/.claude/skills/agent-eval/corpus.json
+++ b/.claude/skills/agent-eval/corpus.json
@@ -470,6 +470,29 @@
"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."
}
],
+ "Elixir": [
+ {
+ "name": "plug",
+ "repo": "https://github.com/elixir-plug/plug",
+ "size": "Small",
+ "files": "~80",
+ "question": "How does an incoming request travel through a Plug.Builder pipeline? Trace the path from Plug.Conn through the builder's compiled plug chain to an individual plug's call/2, and explain how halting works."
+ },
+ {
+ "name": "phoenix",
+ "repo": "https://github.com/phoenixframework/phoenix",
+ "size": "Medium",
+ "files": "~210",
+ "question": "How does an incoming HTTP request reach a controller action in Phoenix? Trace the path from the Endpoint through the Router's dispatch and pipelines into the controller, and show where the response is rendered."
+ },
+ {
+ "name": "firezone",
+ "repo": "https://github.com/firezone/firezone",
+ "size": "Large",
+ "files": "~2110",
+ "question": "How does an API request to list clients flow through the system? Trace the path from the router's route definition through the controller action into the Portal context and down to the Ecto query that loads them."
+ }
+ ],
"Erlang": [
{
"name": "cowboy",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3ca5b9ff1..a18aa9d93 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,11 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+### New Features
+
+- CodeGraph now indexes **Elixir** (`.ex`, `.exs`). Modules, functions, macros, guards, protocols and their implementations, structs, module attributes and typespecs all become part of the graph, with `@doc` and `@spec` carried through so a function's documentation and types come back with it. Multi-clause functions are grouped into one symbol per arity instead of one per clause, and `alias` — including `alias Foo.{Bar, Baz}` and `alias Foo, as: Bar` — is expanded, so a call written `Repo.insert(...)` links to the real `MyApp.Repo` across files.
+- Elixir projects get their framework conventions traced too: **Phoenix** routes become searchable symbols linked to the controller action they dispatch to (nested `scope` paths and aliases, `resources`, `forward` and `live` included), **Plug** pipeline entries link to the function or plug that actually runs, and **Ecto** schema fields and associations are extracted — so "how does this request reach the database" traces end to end without opening the router.
+- Elixir modules generated from Protocol Buffers are read as the declarations they are. A generated message's fields, an enum's values and a service's RPCs become symbols — fields keeping their wire tag, and each RPC linked to the request and response messages it names — instead of being mistaken for calls to a function named `field` or `rpc`. On a project that happened to define its own `field`, every generated field in the repo had been showing up as a caller of it.
## [1.6.0] - 2026-08-26
diff --git a/README.md b/README.md
index 48323f6fd..dc286c624 100644
--- a/README.md
+++ b/README.md
@@ -176,6 +176,7 @@ Every language below gets the same treatment — full structural extraction and
+
@@ -278,7 +279,7 @@ CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScr
| **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, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
+| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, Elixir, CFML, COBOL, Solidity, Terraform/OpenTofu, 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 |
@@ -822,6 +823,7 @@ is written):
| 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`, `.escript`, `.app.src`, `.app` | 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) |
+| Elixir | `.ex`, `.exs` | Full support (modules — including nested ones — with `@moduledoc`, public/private functions with `@doc` and `@spec` signatures, multi-clause grouping by arity, macros, guards, operator definitions, `defdelegate` targets, `defstruct`/`defexception` fields, `@type`/`@opaque` aliases, module attributes as constants, protocols and their `defimpl` implementations, `@behaviour` links, `alias`/`import`/`require`/`use` edges with `alias`/`as:`/`{A, B}` expansion so remote calls resolve to the real module, `&fun/1` capture references, `%Struct{}` instantiation; Ecto schema fields and association edges; Phoenix routes — nested `scope` paths and aliases, `resources` expansion, `forward`, `live` — linked to their controller actions, and `plug` pipeline entries linked to the function or plug that runs) |
| Solidity | `.sol` | Full support (contracts, libraries, interfaces, structs, enums, modifiers, events, errors, state variables, `import`/`using` directives, `emit`/`revert` calls) |
| Terraform / OpenTofu | `.tf`, `.tfvars`, `.tofu` | Full support (resources, data sources, modules, variables, outputs, providers incl. aliases, `locals`; `var.`/`local.`/`module.`/resource references with Terraform's per-directory scoping enforced; module calls bridged across the boundary — inputs to the child module's variables, `module.M.out` to the child's output, `source` to the module's files; cloudposse/atmos `remote-state` cross-component wiring when the component is statically named; `provider = aws.east` selections resolved up the module tree; `moved`/`import`/`removed`/`check` block references; `.tfvars` assignments linked to the variables they set) |
| Nix | `.nix` | Full support (functions with simple/destructured/curried params, `let`/attrset bindings, `inherit`, `import ./path` file edges — `./dir` resolving through `default.nix` — plus NixOS module `imports = [ ./x.nix ]` lists and `callPackage ./pkg.nix` file edges; call edges; module-system option wiring — a config write like `launchd.user.agents.x = { ... }` links to the module declaring `options.launchd.user.agents`, so option flows trace across modules) |
diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts
index ad0ba2374..3f4d8c220 100644
--- a/__tests__/extraction.test.ts
+++ b/__tests__/extraction.test.ts
@@ -127,6 +127,15 @@ describe('Language Detection', () => {
expect(isSourceFile('legacy/module.src')).toBe(false);
});
+ it('should detect Elixir files', () => {
+ expect(detectLanguage('lib/my_app/accounts.ex')).toBe('elixir');
+ // `.exs` scripts are where mix config and every ExUnit suite live.
+ expect(detectLanguage('mix.exs')).toBe('elixir');
+ expect(detectLanguage('test/my_app/accounts_test.exs')).toBe('elixir');
+ expect(isSourceFile('lib/my_app/accounts.ex')).toBe(true);
+ expect(isSourceFile('config/runtime.exs')).toBe(true);
+ });
+
it('should detect Solidity files', () => {
expect(detectLanguage('contracts/Vault.sol')).toBe('solidity');
});
@@ -10939,6 +10948,516 @@ init(_) -> {ok, #{}}.
});
});
+describe('Elixir Extraction', () => {
+ const refNames = (result: ReturnType, kind: string) =>
+ result.unresolvedReferences.filter((r) => r.referenceKind === kind).map((r) => r.referenceName);
+
+ describe('Language detection', () => {
+ it('should report Elixir as supported', () => {
+ expect(isLanguageSupported('elixir')).toBe(true);
+ expect(getSupportedLanguages()).toContain('elixir');
+ });
+ });
+
+ describe('Modules and functions', () => {
+ it('should extract modules, public/private functions and their qualified names', () => {
+ const code = `defmodule MyApp.Accounts do
+ @moduledoc "The Accounts context."
+
+ @doc "List every user."
+ @spec list_users() :: [User.t()]
+ def list_users do
+ Repo.all(User)
+ end
+
+ defp normalize(email) when is_binary(email) do
+ String.downcase(email)
+ end
+end
+`;
+ const result = extractFromSource('lib/my_app/accounts.ex', code);
+ const mod = result.nodes.find((n) => n.kind === 'module');
+ expect(mod?.name).toBe('MyApp.Accounts');
+ expect(mod?.qualifiedName).toBe('MyApp.Accounts');
+ expect(mod?.docstring).toBe('The Accounts context.');
+ expect(mod?.language).toBe('elixir');
+
+ const list = result.nodes.find((n) => n.kind === 'function' && n.name === 'list_users');
+ expect(list?.qualifiedName).toBe('MyApp.Accounts::list_users');
+ expect(list?.isExported).toBe(true);
+ expect(list?.docstring).toBe('List every user.');
+ // The @spec is the only place the types are written — it leads the signature.
+ expect(list?.signature).toContain('@spec list_users() :: [User.t()]');
+
+ const norm = result.nodes.find((n) => n.kind === 'function' && n.name === 'normalize');
+ expect(norm?.visibility).toBe('private');
+ expect(norm?.isExported).toBe(false);
+ expect(norm?.signature).toBe('defp normalize(email) when is_binary(email)');
+ });
+
+ it('should merge adjacent same-arity clauses into one node but split by arity', () => {
+ const code = `defmodule Server do
+ def handle_call({:get, k}, _from, state), do: {:reply, k, state}
+ def handle_call({:put, k}, _from, state), do: {:reply, k, state}
+ def handle_call({:del, k}, _from, state), do: {:reply, k, state}
+
+ def sum(a), do: sum(a, 0)
+ def sum(a, b), do: a + b
+end
+`;
+ const result = extractFromSource('lib/server.ex', code);
+ const handlers = result.nodes.filter((n) => n.name === 'handle_call');
+ expect(handlers).toHaveLength(1);
+ expect([handlers[0]!.startLine, handlers[0]!.endLine]).toEqual([2, 4]);
+ // Different arity is a different function, so it gets its own node.
+ expect(result.nodes.filter((n) => n.name === 'sum')).toHaveLength(2);
+ });
+
+ it('should not leak clause-merge state when the same file is re-extracted', () => {
+ // An incremental sync re-parses the same path; without a hard reset on
+ // the root node the second run would merge onto the FIRST run's node id,
+ // which no longer exists — a dangling scope and an edge to nothing.
+ const code = `defmodule M do
+ def handle(:a), do: :ok
+ def handle(:b), do: :ok
+end
+`;
+ const first = extractFromSource('lib/m.ex', code);
+ const second = extractFromSource('lib/m.ex', code);
+ expect(second.nodes.map((n) => n.qualifiedName)).toEqual(
+ first.nodes.map((n) => n.qualifiedName)
+ );
+ const ids = new Set(second.nodes.map((n) => n.id));
+ for (const edge of second.edges) {
+ expect(ids.has(edge.source)).toBe(true);
+ expect(ids.has(edge.target)).toBe(true);
+ }
+ });
+
+ it('should name a nested defmodule with its parent prefix', () => {
+ const code = `defmodule Outer do
+ defmodule Inner do
+ def deep, do: :ok
+ end
+end
+`;
+ const result = extractFromSource('lib/outer.ex', code);
+ expect(result.nodes.map((n) => n.qualifiedName)).toContain('Outer.Inner');
+ expect(result.nodes.map((n) => n.qualifiedName)).toContain('Outer.Inner::deep');
+ });
+
+ it('should extract defmacro, defguard and operator definitions', () => {
+ const code = `defmodule M do
+ defmacro __using__(_opts), do: :ok
+ defguard is_adult(age) when age >= 18
+ def left ++ right, do: nil
+end
+`;
+ const result = extractFromSource('lib/m.ex', code);
+ const byName = (n: string) => result.nodes.find((x) => x.name === n);
+ expect(byName('__using__')?.decorators).toContain('macro');
+ expect(byName('is_adult')?.decorators).toContain('guard');
+ // An operator definition's name IS the operator — that is how it is called.
+ expect(byName('++')?.qualifiedName).toBe('M::++');
+ });
+ });
+
+ describe('Calls and aliases', () => {
+ it('should expand aliases so remote calls carry the full module name', () => {
+ const code = `defmodule MyApp.Accounts do
+ alias MyApp.Repo
+ alias MyApp.Accounts.{User, Credential}
+ alias MyApp.Mailer, as: Post
+
+ def create(attrs) do
+ %User{}
+ |> User.changeset(attrs)
+ |> Repo.insert()
+ Credential.new()
+ Post.deliver()
+ end
+end
+`;
+ const result = extractFromSource('lib/my_app/accounts.ex', code);
+ const calls = refNames(result, 'calls');
+ expect(calls).toContain('MyApp.Accounts.User::changeset');
+ expect(calls).toContain('MyApp.Repo::insert');
+ expect(calls).toContain('MyApp.Accounts.Credential::new');
+ // `as:` rebinds the short name — `Post` here is the Mailer, not a Post schema.
+ expect(calls).toContain('MyApp.Mailer::deliver');
+ expect(refNames(result, 'instantiates')).toContain('MyApp.Accounts.User');
+ expect(refNames(result, 'imports')).toEqual(
+ expect.arrayContaining(['MyApp.Repo', 'MyApp.Accounts.User', 'MyApp.Accounts.Credential'])
+ );
+ });
+
+ it('should link function captures — the way Elixir registers callbacks', () => {
+ const code = `defmodule M do
+ def run(items) do
+ Enum.map(items, &double/1)
+ Enum.each(items, &String.upcase/1)
+ end
+
+ def double(x), do: x * 2
+end
+`;
+ const result = extractFromSource('lib/m.ex', code);
+ const refs = refNames(result, 'references');
+ expect(refs).toContain('double');
+ expect(refs).toContain('String::upcase');
+ });
+
+ it('should not emit call refs for special forms', () => {
+ const code = `defmodule M do
+ def run(x) do
+ if x do
+ case x do
+ 1 -> raise "boom"
+ _ -> send(self(), :ok)
+ end
+ end
+ real_work(x)
+ end
+end
+`;
+ const result = extractFromSource('lib/m.ex', code);
+ const calls = refNames(result, 'calls');
+ expect(calls).toContain('real_work');
+ for (const form of ['if', 'case', 'raise', 'send', 'self']) {
+ expect(calls).not.toContain(form);
+ }
+ });
+
+ it('should not mint call refs from typespec bodies', () => {
+ const code = `defmodule M do
+ @type t :: %{name: String.t(), size: non_neg_integer()}
+ @spec fetch(String.t()) :: {:ok, t()}
+ def fetch(id), do: {:ok, id}
+end
+`;
+ const result = extractFromSource('lib/m.ex', code);
+ const calls = refNames(result, 'calls');
+ expect(calls).not.toContain('String::t');
+ expect(calls).not.toContain('non_neg_integer');
+ const alias = result.nodes.find((n) => n.kind === 'type_alias');
+ expect(alias?.name).toBe('t');
+ expect(alias?.qualifiedName).toBe('M::t');
+ });
+
+ it('should link defdelegate to its target', () => {
+ const code = `defmodule M do
+ defdelegate encode(data), to: Jason
+ defdelegate run(x), to: Worker, as: :perform
+end
+`;
+ const result = extractFromSource('lib/m.ex', code);
+ const calls = refNames(result, 'calls');
+ expect(calls).toContain('Jason::encode');
+ expect(calls).toContain('Worker::perform');
+ });
+ });
+
+ describe('Attributes, structs and protocols', () => {
+ it('should extract module attributes as constants and link their reads', () => {
+ const code = `defmodule M do
+ @timeout 5_000
+ @doc "not a constant"
+ def wait, do: @timeout
+end
+`;
+ const result = extractFromSource('lib/m.ex', code);
+ const consts = result.nodes.filter((n) => n.kind === 'constant');
+ expect(consts.map((n) => n.name)).toEqual(['timeout']);
+ expect(consts[0]!.qualifiedName).toBe('M::timeout');
+ expect(refNames(result, 'references')).toContain('timeout');
+ });
+
+ it('should extract defstruct and Ecto schema fields', () => {
+ const code = `defmodule MyApp.User do
+ use Ecto.Schema
+ defstruct [:id, :name]
+
+ schema "users" do
+ field :email, :string
+ has_many :posts, MyApp.Post
+ timestamps()
+ end
+end
+`;
+ const result = extractFromSource('lib/my_app/user.ex', code);
+ const fields = result.nodes.filter((n) => n.kind === 'field').map((n) => n.name);
+ expect(fields).toEqual(expect.arrayContaining(['id', 'name', 'email', 'posts']));
+ // The association names the related schema — a real cross-file dependency.
+ expect(refNames(result, 'references')).toContain('MyApp.Post');
+ // Schema macros are not function calls.
+ expect(refNames(result, 'calls')).not.toContain('field');
+ });
+
+ it('should extract protocols and implementations with an implements edge', () => {
+ const code = `defprotocol MyApp.Sizeable do
+ def size(data)
+end
+
+defimpl MyApp.Sizeable, for: List do
+ def size(data), do: length(data)
+end
+`;
+ const result = extractFromSource('lib/sizeable.ex', code);
+ const proto = result.nodes.find((n) => n.kind === 'interface');
+ expect(proto?.qualifiedName).toBe('MyApp.Sizeable');
+ // defimpl compiles to the module `Protocol.Type` — name it what it is.
+ const impl = result.nodes.find((n) => n.qualifiedName === 'MyApp.Sizeable.List');
+ expect(impl?.kind).toBe('module');
+ expect(refNames(result, 'implements')).toContain('MyApp.Sizeable');
+ });
+
+ it('should link @behaviour to the behaviour module', () => {
+ const code = `defmodule M do
+ @behaviour MyApp.Storage
+ def fetch(_k), do: :ok
+end
+`;
+ const result = extractFromSource('lib/m.ex', code);
+ expect(refNames(result, 'implements')).toContain('MyApp.Storage');
+ });
+ });
+
+ describe('Phoenix router and Plug pipelines', () => {
+ it('should emit route nodes linked to their controller actions', () => {
+ const code = `defmodule MyAppWeb.Router do
+ scope "/", MyAppWeb do
+ get "/", PageController, :home
+ resources "/posts", PostController
+ end
+
+ scope "/api", MyAppWeb do
+ scope "/admin", Admin do
+ post "/users", UserController, :create
+ end
+ forward "/health", HealthPlug
+ end
+end
+`;
+ const result = extractFromSource('lib/my_app_web/router.ex', code);
+ const routes = result.nodes.filter((n) => n.kind === 'route').map((n) => n.name);
+ expect(routes).toContain('GET /');
+ expect(routes).toContain('RESOURCES /posts');
+ // Nested scopes compose both the path and the controller alias.
+ expect(routes).toContain('POST /api/admin/users');
+ expect(routes).toContain('FORWARD /api/health');
+
+ const refs = refNames(result, 'references');
+ expect(refs).toContain('MyAppWeb.PageController::home');
+ expect(refs).toContain('MyAppWeb.Admin.UserController::create');
+ expect(refs).toContain('MyAppWeb.HealthPlug::call');
+ // `resources` expands to the REST seven.
+ expect(refs).toContain('MyAppWeb.PostController::index');
+ expect(refs).toContain('MyAppWeb.PostController::delete');
+ });
+
+ it('should link plug pipeline entries to the function or plug that runs', () => {
+ const code = `defmodule MyAppWeb.Auth do
+ use Plug.Builder
+
+ plug :fetch_session
+ plug MyAppWeb.RequireUser
+
+ def fetch_session(conn, _opts), do: conn
+end
+`;
+ const result = extractFromSource('lib/my_app_web/auth.ex', code);
+ const calls = refNames(result, 'calls');
+ expect(calls).toContain('fetch_session');
+ expect(calls).toContain('MyAppWeb.RequireUser::call');
+ });
+
+ it('should leave a same-named ordinary function alone', () => {
+ // `get`/`plug` are only route macros in the `verb "path", Module` shape.
+ const code = `defmodule M do
+ def run(cache, key) do
+ get(cache, key)
+ end
+end
+`;
+ const result = extractFromSource('lib/m.ex', code);
+ expect(refNames(result, 'calls')).toContain('get');
+ expect(result.nodes.filter((n) => n.kind === 'route')).toHaveLength(0);
+ });
+ });
+
+ describe('Cross-file resolution', () => {
+ let tempDir: string;
+ beforeEach(() => { tempDir = createTempDir(); });
+ afterEach(() => { cleanupTempDir(tempDir); });
+
+ it('resolves an aliased remote call to the definition in another file', async () => {
+ fs.mkdirSync(path.join(tempDir, 'lib', 'my_app'), { recursive: true });
+ fs.writeFileSync(
+ path.join(tempDir, 'lib', 'my_app', 'repo.ex'),
+ `defmodule MyApp.Repo do
+ def insert(struct), do: {:ok, struct}
+end
+`
+ );
+ fs.writeFileSync(
+ path.join(tempDir, 'lib', 'my_app', 'accounts.ex'),
+ `defmodule MyApp.Accounts do
+ alias MyApp.Repo
+
+ def create(attrs) do
+ Repo.insert(attrs)
+ end
+end
+`
+ );
+
+ const graph = await CodeGraph.init(tempDir, { silent: true });
+ await graph.indexAll();
+
+ const db = (graph as any).db.db;
+ const edge = db
+ .prepare(
+ `SELECT s.qualified_name src, t.qualified_name tgt
+ FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
+ WHERE e.kind = 'calls' AND s.qualified_name = 'MyApp.Accounts::create'`
+ )
+ .all();
+ expect(edge.map((r: any) => r.tgt)).toContain('MyApp.Repo::insert');
+ graph.destroy();
+ });
+ });
+
+ describe('Generated protobuf modules', () => {
+ // A protobuf generator writes a module's shape as bare macro calls in the
+ // module body, with no block around them. Ecto's `schema do … end` gives
+ // its fields a block to be found in; these have nothing but the `use`
+ // marker at the top of the module, so without reading it the generated
+ // declarations are invisible AND every one of them resolves as a call to
+ // whatever function the repo happens to have named `field`.
+ const MESSAGE = `defmodule Acme.V1.Run do
+ @moduledoc false
+
+ use Protobuf, full_name: "acme.v1.Run", protoc_gen_elixir_version: "0.17.0", syntax: :proto3
+
+ oneof :kind, 0
+
+ field :id, 1, type: :string
+ field :observed_at, 3, type: Google.Protobuf.Timestamp, json_name: "observedAt"
+end
+`;
+
+ it('extracts a generated message’s fields, with their wire tags', () => {
+ const result = extractFromSource('lib/acme_proto/acme/v1/run.pb.ex', MESSAGE);
+ const fields = result.nodes.filter((n) => n.kind === 'field');
+ expect(fields.map((f) => f.name).sort()).toEqual(['id', 'kind', 'observed_at']);
+
+ // The tag is the field's identity on the wire — renaming a field at the
+ // same tag is harmless, changing its type is a silent break — so it is
+ // recorded as a marker, matching how the `.proto` side records it.
+ expect(fields.find((f) => f.name === 'observed_at')?.decorators).toContain('tag=3');
+ expect(fields.find((f) => f.name === 'id')?.decorators).toContain('tag=1');
+ // A `oneof`'s second argument is a group index, not a tag.
+ expect(fields.find((f) => f.name === 'kind')?.decorators ?? []).not.toContain('tag=0');
+ });
+
+ it('does not mint call refs for the generator’s own macros', () => {
+ // The defect this guards: `field`/`oneof` resolving by name, so a
+ // project that defines its own `field/2` collects every generated field
+ // in the repo as a caller of it.
+ const result = extractFromSource('lib/acme_proto/acme/v1/run.pb.ex', MESSAGE);
+ const called = refNames(result, 'calls');
+ expect(called).not.toContain('field');
+ expect(called).not.toContain('oneof');
+ });
+
+ it('extracts a generated enum’s values as enum members, not fields', () => {
+ const code = `defmodule Acme.V1.Status do
+ @moduledoc false
+
+ use Protobuf, enum: true, full_name: "acme.v1.Status", syntax: :proto3
+
+ field :STATUS_UNSPECIFIED, 0
+ field :STATUS_QUEUED, 1
+end
+`;
+ const result = extractFromSource('lib/acme_proto/acme/v1/status.pb.ex', code);
+ const members = result.nodes.filter((n) => n.kind === 'enum_member');
+ expect(members.map((m) => m.name)).toEqual(['STATUS_UNSPECIFIED', 'STATUS_QUEUED']);
+ expect(members[1]?.decorators).toContain('number=1');
+ expect(result.nodes.filter((n) => n.kind === 'field')).toHaveLength(0);
+ });
+
+ it('extracts a generated service’s rpcs and links their message types', () => {
+ const code = `defmodule Acme.V1.RunService.Service do
+ @moduledoc false
+
+ use GRPC.Service, name: "acme.v1.RunService", protoc_gen_elixir_version: "0.17.0"
+
+ rpc :GetRun, Acme.V1.GetRunRequest, Acme.V1.GetRunResponse
+ rpc :StreamRuns, Acme.V1.StreamRunsRequest, stream(Acme.V1.Run)
+end
+`;
+ const result = extractFromSource('lib/acme_proto/acme/v1/run.pb.ex', code);
+ const methods = result.nodes.filter((n) => n.kind === 'method');
+ expect(methods.map((m) => m.name)).toEqual(['GetRun', 'StreamRuns']);
+
+ // The rpc line is the only place the service-to-message binding is
+ // written, including through a `stream(...)` wrapper.
+ const referenced = refNames(result, 'references');
+ expect(referenced).toEqual(expect.arrayContaining([
+ 'Acme.V1.GetRunRequest', 'Acme.V1.GetRunResponse',
+ 'Acme.V1.StreamRunsRequest', 'Acme.V1.Run',
+ ]));
+ expect(refNames(result, 'calls')).not.toContain('rpc');
+ });
+
+ it('leaves an ordinary module’s field/rpc calls alone', () => {
+ // The `use` marker is what makes the macros declarations. Without it
+ // these are function calls, and turning them into declarations would
+ // invent members on any module that happens to call `field/2`.
+ const code = `defmodule Acme.Report do
+ def build(row) do
+ field(row, :name)
+ rpc(:fetch)
+ end
+end
+`;
+ const result = extractFromSource('lib/acme/report.ex', code);
+ expect(result.nodes.filter((n) => n.kind === 'field')).toHaveLength(0);
+ expect(result.nodes.filter((n) => n.kind === 'method')).toHaveLength(0);
+ expect(refNames(result, 'calls')).toEqual(expect.arrayContaining(['field', 'rpc']));
+ });
+
+ it('keeps each generated module’s members under that module', () => {
+ // Generators put every message from one `.proto` in ONE file, so the
+ // marker has to stop applying at the end of the module that carried it.
+ const code = `defmodule Acme.V1.Status do
+ use Protobuf, enum: true, syntax: :proto3
+
+ field :STATUS_QUEUED, 1
+end
+
+defmodule Acme.V1.Run do
+ use Protobuf, syntax: :proto3
+
+ field :id, 1, type: :string
+end
+
+defmodule Acme.V1.Helper do
+ def field(a, b), do: {a, b}
+end
+`;
+ const result = extractFromSource('lib/acme_proto/acme/v1/run.pb.ex', code);
+ const byName = (n: string) => result.nodes.find((x) => x.name === n);
+ expect(byName('STATUS_QUEUED')?.qualifiedName).toBe('Acme.V1.Status::STATUS_QUEUED');
+ expect(byName('id')?.qualifiedName).toBe('Acme.V1.Run::id');
+ // The third module never said `use Protobuf`, so its `field` is a
+ // function definition and stays one.
+ expect(byName('field')?.kind).toBe('function');
+ });
+ });
+});
+
describe('Terraform Extraction', () => {
describe('Language detection', () => {
it('should detect Terraform files', () => {
diff --git a/assets/languages/elixir.svg b/assets/languages/elixir.svg
new file mode 100644
index 000000000..21c627763
--- /dev/null
+++ b/assets/languages/elixir.svg
@@ -0,0 +1,6 @@
+
diff --git a/docs/design/dynamic-dispatch-coverage-playbook.md b/docs/design/dynamic-dispatch-coverage-playbook.md
index c61fb9f31..7cf32adbd 100644
--- a/docs/design/dynamic-dispatch-coverage-playbook.md
+++ b/docs/design/dynamic-dispatch-coverage-playbook.md
@@ -267,6 +267,7 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started.
| Dart | Flutter | setState → build; build → child widgets | S + X | ✅ **setState→build synthesizer** (Dart analog of react-render: a State method whose body calls `setState(` → `build`) gated to `.dart` + **foundational Dart method-range fix** — Dart models a method body as a *sibling* of the signature, so method nodes were signature-only (`end==start`); now `endLine` spans the body (required for ALL body analysis: callees, context slices, the synthesizer's body scan). counter `initState→build`, books `build→BookDetail/BookForm`; widget composition already static (compass_app `build→ErrorIndicator/HomeButton`). Controls unchanged (excalidraw 9,290 / django 302 — the range fix only extends sibling-body grammars). 🔬 MVVM Command/ChangeNotifier dispatch (compass_app — no setState) + `Navigator.push(MaterialPageRoute(builder:))` nav routes |
| Lua / Luau | Neovim / Roblox | module dispatch (require→mod, mod.fn); event/callback | — | ✅ **already covered for the dominant flow (measure-first, no code change)** — Neovim is module-heavy (`require('x')` + `x.fn()`), and the general import + name resolution already handles it: telescope.nvim **220 imports + 335 cross-file `mod.fn` calls**, traces end-to-end (`map_entries ← init.lua → get_current_picker (state.lua)`). Luau instance-path `require(game:GetService(...))` handled by the extractor. 🔬 event-callback registration (`vim.keymap.set(…, fn)`, autocmd `callback=`, Roblox `signal:Connect(fn)`) is predominantly INLINE anonymous closures (corpus ~12 inline vs ~2 named) — the anonymous-handler frontier; named handlers too rare to justify a synthesizer |
| Erlang | OTP behaviours | request → behaviour dispatch (`Var:callback(...)` folds) → implementer callback | S | ✅ **behaviour-callback dispatch synthesizer** (`erlangBehaviourDispatchEdges`) — a behaviour declares `-callback fn/N`, implementers declare `-behaviour(B)`, and the framework dispatches through a VARIABLE module (`Handler:init`, `Middleware:execute` folds), a hop extraction deliberately leaves silent. Bridge: each `Var:fn(args)` site → every implementer of the ONE in-repo behaviour declaring (fn, site-arity) that defines+exports fn; a name+arity collision across behaviours bails (cowboy's `init/2` is declared by FIVE handler-flavored behaviours → correctly silent), and above the fan-out cap (24) the site is skipped entirely (ejabberd's `gen_mod`, ~230 mod_* implementers, stays a visibly dynamic boundary rather than 24 arbitrary edges). Behaviour discovery scans `-callback` decls in every module (not just `implements` targets) so implementer-less behaviours still gate ambiguity. Validated: cowboy S — 38 edges, all real contracts (middleware chain `cowboy_stream_h::execute → cowboy_router/cowboy_handler::execute`, stream-handler `init/data/early_error` folds → all 5 core + 2 test handlers, sub-protocol `upgrade`, `websocket_init`); ejabberd M — 598 edges (listener/auth/pubsub/MIX backends, max per-site fan-out 9); emqx L — 843 edges (gateway codec/channel families, max fan-out 20); **precision spot-check 36/36** (every sampled target declares the via-behaviour + exports the callback); node counts unchanged; erl-sample 0-control clean (dispatch with no valid implementer → no edge); index cost +~1.4s on emqx's 2,273 files. The cowboy request flow now connects END-TO-END in one explore: `cowboy_stream:init → [erlang behaviour] cowboy_stream_h:init → request_process → execute → [erlang behaviour] cowboy_handler:execute`. 🔬 gen_server registered-name cross-module targets (atom == module-name convention); the terminal `Handler:init` hop where multiple sub-protocol behaviours share the contract (genuinely ambiguous — the dispatch site's body is the answer) |
+| Elixir | Phoenix / Plug / Ecto | request → route → controller action → context → Ecto query; `plug` pipeline entry → the function/plug that runs | X (extract) | ✅ **macro-argument dispatch, extracted rather than synthesized** — Elixir builds its dispatch at COMPILE time from macro arguments, so the binding is written down literally in the source and is fully static once the homoiconic AST is understood (there are no declaration node types at all — every construct is a `call` node; see `languages/elixir.ts`). Three hops closed together, per the never-half-bridge rule: (1) **Phoenix routes** — `get "/users", UserController, :index` becomes a `route` node named `GET /api/users` with a `references` edge to the exact action, composing nested `scope` path + alias prefixes, expanding `resources` to the REST seven and `forward`/`live` to `call`/the module; (2) **Plug pipelines** — `plug :atom` → the same module's function and `plug Module` → its `call/2`, so a `Plug.Builder` / Phoenix pipeline chains instead of dead-ending at the declaration; (3) **alias expansion** (incl. `alias Foo.{A, B}`, `as:`, `__MODULE__`, and the implicit nested-module alias) so a call written `Repo.insert(...)` carries `MyApp.Repo::insert` and resolves by exact qualified name rather than by bare name. Ecto `schema` fields + association target modules are extracted alongside, so the context→schema hop exists too. Multi-clause `def`s merge per (module, name, arity) — the GenServer idiom otherwise indexes one identical node per clause and scatters caller edges. Validated: **firezone L (2,111 files) 137/144 route→action edges resolved at 100% precision** (the 7 unresolved are `resources` actions the controller genuinely does not define — silent beats wrong); node/edge counts byte-stable across re-index and incremental sync (1,225/5,271 on plug). **Agent A/B (sonnet/high, 2 runs/arm, headless, CLI blocked, 0 contamination; both runs of an arm shown as `r1, r2`): plug S with = 2, 1 tool calls / 0, 0 Read vs without 3, 3 calls / 1, 1 Read / 2, 2 Bash; phoenix M with = 4, 3 calls / 0, 0 Read vs without 25, 23 calls / 9, 11 Read / 14, 10 Bash; firezone L with = 3, 4 calls / 0, 1 Read vs without 28, 25 calls / 12, 8 Read / 11, 16 Bash. Grep is 0 in every arm of every run. Reads go to ~0 and tool calls drop 4-8x on all three repos, inside a 5-call ceiling. Wall-clock is the honest caveat and it tracks repo size: plug S is SLOWER with codegraph (62, 37s vs 38, 32s -- on an 80-file tree grep is instant and MCP startup is not), phoenix M is a wash (64, 63s vs 63, 54s), and only firezone L pays off (41, 53s vs 41, 132s -- 2.5x on the run where the without-arm floundered). Control: the full 3,068-test suite is green, and the only shared-code edits are three additive registrations (`LANGUAGES`, the grammar maps, the `EXTRACTORS` entry), so no other language can be affected.** 🔬 `Phoenix.Router` itself (the framework's own `__using__`/`quote` machinery is metaprogramming, so flows INSIDE phoenix-the-repo stay partly implicit — an app built ON Phoenix is fully covered); GenServer `call`/`cast` → `handle_call`/`handle_cast` (the Erlang synthesizer's equivalent, not yet ported to Elixir); `use MyAppWeb, :controller` macro-injected imports; LiveView `handle_event("name", ...)` ← template `phx-click` bindings (HEEx templates are not parsed yet) |
| Scala | Play / Akka | request → conf/routes → controller action | R + X | ✅ **Play `conf/routes` → controller** — the extensionless `conf/routes` wasn't indexed; added narrow file-walk opt-in (`isPlayRoutesFile`) + a Play resolver parsing `METHOD /path Controller.action(args)` → the action method (computer-database **0→8, 7/8**; starter 0→4, 3/4 — the unresolved are Play's framework `Assets` controller, external). Scala general controller→DAO dispatch already resolves. No-regression: the file-walk change only ADDS Play routes files (excalidraw 9,290 / suite 800 unchanged). 🔬 SIRD programmatic router (`-> /v1 Router` include + `case GET(p"/x")` in code) + Akka actor `receive`/`Behaviors.receiveMessage` message→handler |
| Swift × Objective-C | mixed iOS apps | Swift `obj.foo(bar:)` → ObjC `-fooWithBar:`; ObjC `[obj fooWithBar:]` → Swift `@objc func foo(bar:)` | R | ✅ **Swift↔ObjC cross-language bridge** — `frameworks/swift-objc.ts` implements Apple's `@objc` auto-bridging name math (incl. init forms `initWith:`, property getter+setter pairs, `@objc(custom:)` override) and the reverse direction strips Cocoa preposition prefixes (`With`/`For`/`By`/`In`/`On`/`At`/`From`/`To`/`Of`/`As`) to derive Swift base-name candidates. Validated on Charts S **28/1 obj→swift / swift→objc**, realm-swift M **36/1185**, wikipedia-ios L **52/983**. Genericname blocklist (`init`, `description`, `count`, …) keeps precision. Confidence 0.6 (name-match's 1.0 wins ties) — bridge only fires when name-match has no result. 🔬 Swift generics over ObjC protocols, Swift extensions on ObjC classes (silently miss; matches Java/Kotlin generics frontier) |
| JS × native | React Native legacy bridge | JS `NativeModules.X.fn(...)` → ObjC `RCT_EXPORT_METHOD` / Java/Kotlin `@ReactMethod` | R | ✅ **RN legacy bridge** — `frameworks/react-native.ts` parses `RCT_EXPORT_MODULE` (default-name from `RCT`-prefix-stripped class name) + `RCT_EXPORT_METHOD(selector:(...))` + `RCT_REMAP_METHOD(jsName, selector)` on the ObjC side and `@ReactMethod` + `getName()` literal on Java/Kotlin. AsyncStorage S **8/8 precise** (`setItem`→`legacy_multiSet`, etc.), react-native-firebase L **18 precise after `RCTEventEmitter` built-in blocklist** (initial 78 included 60 `addListener:`/`remove:` false positives — every emitter subclass declares those via `RCT_EXPORT_METHOD`, JS callers route through the `NativeEventEmitter` abstraction not the native method directly). 🔬 dynamic bridge keys (`NativeModules[someVar]`) — literal-key only |
diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts
index 84647c3e4..f64ce82e5 100644
--- a/src/extraction/grammars.ts
+++ b/src/extraction/grammars.ts
@@ -46,6 +46,7 @@ const WASM_GRAMMAR_FILES: Record = {
cobol: 'tree-sitter-cobol.wasm',
vbnet: 'tree-sitter-vbnet.wasm',
erlang: 'tree-sitter-erlang.wasm',
+ elixir: 'tree-sitter-elixir.wasm',
solidity: 'tree-sitter-solidity.wasm',
terraform: 'tree-sitter-terraform.wasm',
arkts: 'tree-sitter-arkts.wasm',
@@ -162,6 +163,12 @@ export const EXTENSION_MAP: Record = {
// (`.app`/`.app.src` resource files route via isErlangAppFile below: their
// last-dot extension is too generic for this map.)
'.escript': 'erlang',
+ // Elixir: modules (.ex) and scripts (.exs — mix.exs, config/*.exs, and every
+ // *_test.exs, which is where ExUnit test suites live). Same grammar, same
+ // extractor; `.exs` is compiled-on-load rather than precompiled, which the
+ // graph doesn't distinguish.
+ '.ex': 'elixir',
+ '.exs': 'elixir',
// 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.
@@ -692,6 +699,7 @@ export function getLanguageDisplayName(language: Language): string {
cobol: 'COBOL',
vbnet: 'Visual Basic .NET',
erlang: 'Erlang',
+ elixir: 'Elixir',
terraform: 'Terraform',
arkts: 'ArkTS',
unknown: 'Unknown',
diff --git a/src/extraction/languages/elixir.ts b/src/extraction/languages/elixir.ts
new file mode 100644
index 000000000..ecd8989e7
--- /dev/null
+++ b/src/extraction/languages/elixir.ts
@@ -0,0 +1,1238 @@
+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 tree-sitter-elixir (ABI 14, the tree-sitter-wasms build).
+//
+// Elixir is HOMOICONIC: there are no declaration node types at all. Every
+// construct — `defmodule`, `def`, `alias`, `import`, an Ecto `schema`, a
+// Phoenix route — parses as the SAME `call` node, distinguished only by the
+// text of its `target` identifier:
+//
+// defmodule MyApp.Repo do … end
+// → call(target: identifier "defmodule", arguments(alias "MyApp.Repo"), do_block)
+// def create(attrs \\ %{}) when is_map(attrs), do: …
+// → call(target: identifier "def",
+// arguments(binary_operator(left: call(target: identifier "create", …),
+// right: ),
+// keywords(pair(key: keyword "do:", …))))
+//
+// So the generic node-type ladder in tree-sitter.ts has nothing to match, and
+// EVERYTHING is dispatched through the visitNode hook below. The hook also
+// owns call extraction (rather than an `elixir` branch in the core
+// extractCall) because it must run inside function bodies too — and
+// visitFunctionBody does NOT invoke this hook. The hook therefore descends
+// with ctx.visitNode(), which re-enters it for every child, so a `call`
+// nested anywhere still gets Elixir semantics.
+//
+// Naming / resolution model, mirroring Erlang's `mod::fn` (#1610) minus arity:
+// - a `defmodule` becomes a `module` node whose name AND qualifiedName are
+// the full dotted name (`MyApp.Accounts`); a nested defmodule concatenates
+// (`MyApp.Accounts.Inner`), which is exactly the module Elixir defines;
+// - every `def` inside it gets qualifiedName `MyApp.Accounts::list_users`;
+// - a remote call `Repo.all(User)` is emitted with its receiver ALIAS
+// EXPANDED (`alias MyApp.Repo` → ref `MyApp.Repo::all`), so it resolves by
+// exact qualified-name match. An unaliased short receiver still lands via
+// matchByQualifiedName's partial `endsWith` fallback.
+// Arity is deliberately NOT part of the qualified name (unlike Erlang):
+// Elixir's default arguments (`def f(a, b \\ 0)`) make ONE definition answer
+// to several arities, so an arity-pinned ref would miss the real target.
+
+const DEF_KINDS = new Set([
+ 'def', 'defp', 'defmacro', 'defmacrop', 'defguard', 'defguardp',
+]);
+
+/** `defp`/`defmacrop`/`defguardp` — the private half of each pair. */
+const PRIVATE_DEFS = new Set(['defp', 'defmacrop', 'defguardp']);
+
+/** Lexical directives; all four make the named module a dependency. */
+const DIRECTIVES = new Set(['alias', 'import', 'require', 'use']);
+
+/**
+ * Module attributes with compiler meaning — never module-level constants.
+ * Everything else (`@default_role :member`) IS a constant and is extracted as
+ * one. `@spec`/`@type`/`@callback` bodies are TYPE expressions that parse as
+ * `call` nodes, so their subtrees must be consumed, not descended into, or
+ * every `String.t()` in a typespec would mint a bogus call ref.
+ */
+const RESERVED_ATTRS = new Set([
+ 'moduledoc', 'doc', 'typedoc', 'shortdoc', 'spec', 'callback', 'macrocallback',
+ 'impl', 'behaviour', 'behavior', 'derive', 'enforce_keys', 'deprecated',
+ 'optional_callbacks', 'before_compile', 'after_compile', 'on_definition',
+ 'on_load', 'external_resource', 'compile', 'dialyzer', 'file', 'fallback_to_any',
+]);
+
+/** `@type` / `@typep` / `@opaque` — named type declarations. */
+const TYPE_ATTRS = new Set(['type', 'typep', 'opaque']);
+
+/**
+ * Ecto schema field macros. A `schema "users" do … end` block declares the
+ * struct's shape through these, and nothing else in the graph would record it
+ * — so a schema module would otherwise index as a module with zero members and
+ * an agent asking "what columns does User have" has to Read the file. The
+ * association macros additionally name the related schema MODULE, which is a
+ * real cross-file dependency.
+ */
+const SCHEMA_FIELDS = new Set([
+ 'field', 'belongs_to', 'has_many', 'has_one', 'many_to_many',
+ 'embeds_one', 'embeds_many',
+]);
+
+/**
+ * `Kernel.SpecialForms` plus the Kernel macros that ARE language syntax. These
+ * parse as ordinary `call` nodes — `case x do … end` is
+ * `call(target: identifier "case", …)` — so without this list every `if`,
+ * `case` and `quote` in the codebase mints a `calls` ref. On plug that was
+ * ~1,900 refs (a third of the file's total) that can never resolve to
+ * anything, and worse: a project that legitimately defines `def send(…)` or
+ * `def raise(…)` would collect hundreds of wrong caller edges. The subtree is
+ * still walked, so real calls nested inside a `case` are unaffected.
+ * Closed list — it is fixed by the language, not by any library.
+ */
+const SPECIAL_FORMS = new Set([
+ '__CALLER__', '__DIR__', '__ENV__', '__MODULE__', '__STACKTRACE__',
+ '__aliases__', '__block__',
+ 'case', 'cond', 'fn', 'for', 'if', 'unless', 'quote', 'receive', 'super',
+ 'try', 'unquote', 'unquote_splicing', 'with',
+ 'and', 'or', 'not', 'in',
+ 'raise', 'reraise', 'throw', 'send', 'self', 'exit', 'binding', 'var!',
+]);
+
+/**
+ * Phoenix router verbs → the HTTP method a `route` node is named for. Written
+ * as `get "/users", UserController, :index` inside a `scope`, which is the
+ * ONLY place the URL↔controller binding exists — there is no static call from
+ * the router to the action, so without this a request flow dead-ends at the
+ * router and the agent has to read it. (`forward` and `live` are handled
+ * alongside; `resources` expands to the REST seven.)
+ */
+const ROUTE_VERBS = new Map([
+ ['get', 'GET'], ['post', 'POST'], ['put', 'PUT'], ['patch', 'PATCH'],
+ ['delete', 'DELETE'], ['options', 'OPTIONS'], ['head', 'HEAD'],
+]);
+
+/** The actions `resources "/posts", PostController` generates. */
+const RESOURCE_ACTIONS = ['index', 'edit', 'new', 'show', 'create', 'update', 'delete'];
+
+/** Association macros whose second argument is the related schema module. */
+const SCHEMA_ASSOCS = new Set([
+ 'belongs_to', 'has_many', 'has_one', 'many_to_many', 'embeds_one', 'embeds_many',
+]);
+
+/**
+ * What `use Protobuf` / `use GRPC.Service` says the generated module is. The
+ * three shapes take different macros and mint different kinds of member.
+ */
+type GeneratedProtoShape = 'message' | 'enum' | 'service';
+
+/** The macros a protobuf-generated module body is made of. */
+const GENERATED_PROTO_MACROS = new Set(['field', 'oneof', 'rpc']);
+
+function collapseWs(text: string): string {
+ return text.replace(/\s+/g, ' ').trim();
+}
+
+/** `:member` → `member`; `"name"` stays as-is for non-atoms. */
+function atomName(node: SyntaxNode, source: string): string {
+ return getNodeText(node, source).trim().replace(/^:/, '').replace(/^"([\s\S]*)"$/, '$1');
+}
+
+/**
+ * `for: ` → `for`. A `keyword` node spans its trailing colon AND the
+ * whitespace after it, so both must come off before comparing.
+ */
+function keywordName(node: SyntaxNode, source: string): string {
+ return getNodeText(node, source).trim().replace(/:$/, '');
+}
+
+/**
+ * The operator of a `binary_operator`, read from the gap between its `left`
+ * and `right` fields — the grammar exposes no `operator` field. Distinguishes
+ * a guard (`def f(x) when is_x(x)`) from an operator DEFINITION
+ * (`def a ++ b`) and from a default argument (`b \\ 0`), all of which are the
+ * same node type in a def head.
+ */
+function operatorText(node: SyntaxNode, source: string): string {
+ const left = getChildByField(node, 'left');
+ const right = getChildByField(node, 'right');
+ if (!left || !right) return '';
+ return source.substring(left.endIndex, right.startIndex).trim();
+}
+
+function namedChildOfType(node: SyntaxNode, type: string): SyntaxNode | null {
+ for (const child of node.namedChildren) {
+ if (child.type === type) return child;
+ }
+ return null;
+}
+
+/**
+ * A call's argument list. `arguments` is a plain named CHILD of the `call`
+ * node, not a field (only `target` is), so childForFieldName never finds it.
+ */
+function argsOf(node: SyntaxNode): SyntaxNode | null {
+ return namedChildOfType(node, 'arguments');
+}
+
+// --- Per-file state. Extraction is file-sequential within a worker, so
+// single-entry memos keyed by filePath are safe (and reset naturally). ---
+
+/**
+ * `alias`-established short names → the full dotted module they stand for
+ * (`alias MyApp.Accounts.User` ⇒ `User` → `MyApp.Accounts.User`). Populated in
+ * source order as the walk meets each directive, which matches Elixir's own
+ * rule that an alias must precede its uses. File-wide rather than
+ * module-scoped: an alias re-bound to a different module in a second module of
+ * the SAME file is vanishingly rare, and the approximation costs nothing.
+ */
+let aliasFile = '';
+let aliasMap = new Map();
+
+/** Full dotted names of the `defmodule`s currently open (innermost last). */
+let moduleStack: string[] = [];
+
+/**
+ * Enclosing Phoenix `scope "/api", MyAppWeb do` frames (innermost last). A
+ * route's real path and its controller's real module name are both assembled
+ * from these — `get "/users", UserController` inside that scope means
+ * `GET /api/users` → `MyAppWeb.UserController`.
+ */
+let routeScopes: { path: string; alias: string }[] = [];
+
+/**
+ * Modules whose body was written by a protobuf generator, by full module name,
+ * and which shape the generator gave them. `use Protobuf` / `use GRPC.Service`
+ * is the ONLY thing marking the macro calls that follow as declarations rather
+ * than function calls, so it has to be remembered for the rest of the module.
+ * Keyed by name rather than stacked because a module name is unique per file
+ * and the marker always precedes the macros it governs.
+ */
+let generatedProtoModules = new Map();
+
+/**
+ * Clause-merge state. Elixir spells multi-clause functions as repeated `def`s
+ * of the same name and arity (`def handle_call({:get, k}, _from, s)` ×N — the
+ * whole GenServer idiom), so without merging, a 6-clause handler indexes as 6
+ * identical nodes and every caller edge lands on an arbitrary one. Consecutive
+ * same-(module, name, arity) defs extend the FIRST node instead. Adjacency is
+ * a safe key: Elixir warns ("clauses with the same name and arity should be
+ * grouped") on any non-adjacent redefinition. A same-name DIFFERENT-arity def
+ * is a separate function and gets its own node.
+ */
+let lastDefFile = '';
+let lastDefModule = '';
+let lastDefName = '';
+let lastDefArity = -1;
+let lastDefId = '';
+
+/**
+ * Clear the per-file memos. Called unconditionally on the `source` root — a
+ * guaranteed once-per-extract event — because keying on filePath alone leaks
+ * state when the SAME file is extracted twice in a row (an incremental sync
+ * re-parse): `lastDefId` would still name a node from the previous run, which
+ * no longer exists in this run's node list, and merging onto it would push a
+ * dangling scope id and emit edges to a nonexistent node.
+ */
+function resetFileState(filePath: string, force = false): void {
+ if (!force && filePath === aliasFile) return;
+ aliasFile = filePath;
+ aliasMap = new Map();
+ moduleStack = [];
+ routeScopes = [];
+ generatedProtoModules = new Map();
+ lastDefFile = '';
+ lastDefModule = '';
+ lastDefName = '';
+ lastDefArity = -1;
+ lastDefId = '';
+}
+
+function currentModule(): string {
+ return moduleStack.length > 0 ? moduleStack[moduleStack.length - 1]! : '';
+}
+
+/**
+ * Expand a written module reference to its full dotted name: an alias short
+ * name to what it aliases, `__MODULE__` to the enclosing module, anything else
+ * unchanged. A DOTTED reference expands on its first segment
+ * (`alias MyApp.Accounts` then `Accounts.User` → `MyApp.Accounts.User`).
+ */
+function expandAlias(written: string): string {
+ if (!written) return written;
+ if (written === '__MODULE__') return currentModule();
+ const direct = aliasMap.get(written);
+ if (direct) return direct;
+ const dot = written.indexOf('.');
+ if (dot > 0) {
+ const head = written.slice(0, dot);
+ if (head === '__MODULE__') {
+ const mod = currentModule();
+ return mod ? mod + written.slice(dot) : written;
+ }
+ const mapped = aliasMap.get(head);
+ if (mapped) return mapped + written.slice(dot);
+ }
+ return written;
+}
+
+/** Qualified name for a member of the module currently being walked. */
+function qualify(name: string): string {
+ const mod = currentModule();
+ return mod ? `${mod}::${name}` : name;
+}
+
+/**
+ * The `@doc "…"` / `@moduledoc "…"` heredoc text of an attribute node, or
+ * undefined when the attribute holds something else (`@doc false`).
+ */
+function attrString(attrCall: SyntaxNode, source: string): string | undefined {
+ const args = argsOf(attrCall);
+ const str = args ? namedChildOfType(args, 'string') : null;
+ if (!str) return undefined;
+ const content = namedChildOfType(str, 'quoted_content');
+ const text = content ? getNodeText(content, source) : '';
+ return text.trim() || undefined;
+}
+
+/** The `call` under an `@name …` attribute (`unary_operator` with operand). */
+function attrCallOf(node: SyntaxNode): SyntaxNode | null {
+ const operand = getChildByField(node, 'operand');
+ return operand && operand.type === 'call' ? operand : null;
+}
+
+/** The attribute's name, for both `@name value` and a bare `@name` read. */
+function attrNameOf(node: SyntaxNode, source: string): string | null {
+ const operand = getChildByField(node, 'operand');
+ if (!operand) return null;
+ if (operand.type === 'identifier') return getNodeText(operand, source);
+ if (operand.type === 'call') {
+ const target = getChildByField(operand, 'target');
+ if (target?.type === 'identifier') return getNodeText(target, source);
+ }
+ return null;
+}
+
+/** True for a `unary_operator` whose operator is the given single character. */
+function unaryOpIs(node: SyntaxNode, source: string, op: string): boolean {
+ return source[node.startIndex] === op;
+}
+
+/**
+ * Walk back over the attribute/comment preamble of a definition and return the
+ * `@doc` prose plus the `@spec` text. Both sit as preceding siblings, so the
+ * generic comment-based getPrecedingDocstring never sees them — yet `@doc` is
+ * where essentially all Elixir documentation lives, and the `@spec` is the
+ * only place a function's types are written.
+ */
+function defPreamble(
+ node: SyntaxNode,
+ source: string
+): { doc?: string; spec?: string } {
+ let doc: string | undefined;
+ let spec: string | undefined;
+ let sibling = node.previousNamedSibling;
+ while (sibling) {
+ if (sibling.type === 'comment') {
+ sibling = sibling.previousNamedSibling;
+ continue;
+ }
+ if (sibling.type !== 'unary_operator' || !unaryOpIs(sibling, source, '@')) break;
+ const attrCall = attrCallOf(sibling);
+ const target = attrCall ? getChildByField(attrCall, 'target') : null;
+ const name = target ? getNodeText(target, source) : '';
+ if (name === 'doc') doc ??= attrString(attrCall!, source);
+ else if (name === 'spec') spec ??= collapseWs(getNodeText(sibling, source)).slice(0, 300);
+ else if (name !== 'impl' && name !== 'deprecated' && name !== 'since') break;
+ sibling = sibling.previousNamedSibling;
+ }
+ return { doc, spec };
+}
+
+/** The `@moduledoc` prose of a module's `do_block`, if it opens with one. */
+function moduleDoc(doBlock: SyntaxNode, source: string): string | undefined {
+ for (const child of doBlock.namedChildren) {
+ if (child.type !== 'unary_operator' || !unaryOpIs(child, source, '@')) continue;
+ const attrCall = attrCallOf(child);
+ const target = attrCall ? getChildByField(attrCall, 'target') : null;
+ if (target && getNodeText(target, source) === 'moduledoc') {
+ return attrString(attrCall!, source);
+ }
+ }
+ return undefined;
+}
+
+/**
+ * The body of a definition: either the `do_block` sibling of the `arguments`
+ * (`def f do … end`) or the trailing `keywords` inside them
+ * (`def f, do: …` — the `do:`/`rescue:`/`after:` keyword list).
+ */
+function defBody(node: SyntaxNode): SyntaxNode | null {
+ const doBlock = namedChildOfType(node, 'do_block');
+ if (doBlock) return doBlock;
+ const args = argsOf(node);
+ return args ? namedChildOfType(args, 'keywords') : null;
+}
+
+interface DefHead {
+ name: string;
+ arity: number;
+ /** The `when …` guard expression, which holds real calls. */
+ guard: SyntaxNode | null;
+ /** The parameter list, whose default values (`\\ %{}`) hold real calls. */
+ params: SyntaxNode | null;
+}
+
+/**
+ * Parse the head of a `def`. The three shapes the grammar produces:
+ * `def f do` → identifier
+ * `def f(a, b \\ 0) do` → call(target: identifier, arguments)
+ * `def f(a) when g do` → binary_operator(op `when`)
+ * `def a ++ b do` → binary_operator(op `++`) — an operator definition
+ * Returns null for a head with no static name (`def unquote(name)(x)`), whose
+ * subtree is then consumed rather than mined for a bogus call.
+ */
+function parseDefHead(head: SyntaxNode, source: string): DefHead | null {
+ if (head.type === 'identifier') {
+ return { name: getNodeText(head, source), arity: 0, guard: null, params: null };
+ }
+ if (head.type === 'call') {
+ const target = getChildByField(head, 'target');
+ if (target?.type !== 'identifier') return null; // `def unquote(name)(…)`
+ const params = argsOf(head);
+ return {
+ name: getNodeText(target, source),
+ arity: params ? params.namedChildCount : 0,
+ guard: null,
+ params,
+ };
+ }
+ if (head.type === 'binary_operator') {
+ const op = operatorText(head, source);
+ if (op === 'when') {
+ const left = getChildByField(head, 'left');
+ const inner = left ? parseDefHead(left, source) : null;
+ return inner ? { ...inner, guard: getChildByField(head, 'right') } : null;
+ }
+ // Operator definition — `def a ++ b`, `def left <> right`. The operator IS
+ // the function's name (that is how call sites and `&(++)/2` spell it).
+ if (op && !/[\w\s]/.test(op)) {
+ return { name: op, arity: 2, guard: null, params: null };
+ }
+ }
+ return null;
+}
+
+/** Push a scope, walk the given subtrees through the hook, pop. */
+function walkUnder(scopeId: string, ctx: ExtractorContext, subtrees: (SyntaxNode | null)[]): void {
+ ctx.pushScope(scopeId);
+ for (const subtree of subtrees) {
+ if (!subtree) continue;
+ for (const child of subtree.namedChildren) ctx.visitNode(child);
+ }
+ ctx.popScope();
+}
+
+function addRef(
+ ctx: ExtractorContext,
+ fromNodeId: string,
+ referenceName: string,
+ referenceKind: 'calls' | 'references' | 'imports' | 'implements' | 'instantiates',
+ at: SyntaxNode
+): void {
+ if (!referenceName) return;
+ ctx.addUnresolvedReference({
+ fromNodeId,
+ referenceName,
+ referenceKind,
+ line: at.startPosition.row + 1,
+ column: at.startPosition.column,
+ });
+}
+
+function scopeHead(ctx: ExtractorContext): string | undefined {
+ return ctx.nodeStack.length > 0 ? ctx.nodeStack[ctx.nodeStack.length - 1] : undefined;
+}
+
+// --- Definition handlers -----------------------------------------------------
+
+function handleDefmodule(node: SyntaxNode, ctx: ExtractorContext): boolean {
+ const args = argsOf(node);
+ const aliasNode = args ? namedChildOfType(args, 'alias') : null;
+ const doBlock = namedChildOfType(node, 'do_block');
+ if (!aliasNode) return true; // `defmodule unquote(name) do` — no static name
+
+ const written = getNodeText(aliasNode, ctx.source);
+ const outer = currentModule();
+ // A nested defmodule defines `Outer.Inner`, which is the module name every
+ // call site and `alias` in the project spells.
+ const fullName = outer ? `${outer}.${written}` : written;
+
+ const mod = ctx.createNode('module', fullName, node, {
+ docstring: doBlock ? moduleDoc(doBlock, ctx.source) : getPrecedingDocstring(node, ctx.source),
+ signature: `defmodule ${fullName}`,
+ });
+ if (!mod) return true;
+ mod.qualifiedName = fullName;
+
+ // Elixir auto-aliases a NESTED module's last segment inside its parent, so
+ // `defmodule Outer do defmodule Inner do` makes bare `Inner.f()` mean
+ // `Outer.Inner.f()`. A top-level defmodule establishes no such binding —
+ // registering one would silently rewrite an unrelated same-suffix receiver.
+ if (outer) aliasMap.set(written.split('.').pop()!, fullName);
+
+ moduleStack.push(fullName);
+ const savedDefName = lastDefName;
+ lastDefName = ''; // a new module never continues the previous module's clause
+ walkUnder(mod.id, ctx, [doBlock]);
+ lastDefName = savedDefName;
+ moduleStack.pop();
+ return true;
+}
+
+/** `defprotocol Sizeable do def size(data) end` — a behaviour contract. */
+function handleDefprotocol(node: SyntaxNode, ctx: ExtractorContext): boolean {
+ const args = argsOf(node);
+ const aliasNode = args ? namedChildOfType(args, 'alias') : null;
+ const doBlock = namedChildOfType(node, 'do_block');
+ if (!aliasNode) return true;
+ const fullName = expandAlias(getNodeText(aliasNode, ctx.source));
+
+ const proto = ctx.createNode('interface', fullName, node, {
+ docstring: doBlock ? moduleDoc(doBlock, ctx.source) : undefined,
+ signature: `defprotocol ${fullName}`,
+ });
+ if (!proto) return true;
+ proto.qualifiedName = fullName;
+
+ moduleStack.push(fullName);
+ const savedDefName = lastDefName;
+ lastDefName = ''; // as in handleDefmodule — a new scope starts a new clause run
+ walkUnder(proto.id, ctx, [doBlock]);
+ lastDefName = savedDefName;
+ moduleStack.pop();
+ return true;
+}
+
+/**
+ * `defimpl Sizeable, for: List do … end` — Elixir compiles this to the module
+ * `Sizeable.List`. Named that way so the dispatch target is findable, with an
+ * `implements` edge to the protocol so "who implements this" answers.
+ */
+function handleDefimpl(node: SyntaxNode, ctx: ExtractorContext): boolean {
+ const args = argsOf(node);
+ const aliasNode = args ? namedChildOfType(args, 'alias') : null;
+ const doBlock = namedChildOfType(node, 'do_block');
+ if (!aliasNode) return true;
+ const protocol = expandAlias(getNodeText(aliasNode, ctx.source));
+
+ let forType = '';
+ const keywords = args ? namedChildOfType(args, 'keywords') : null;
+ if (keywords) {
+ for (const pair of keywords.namedChildren) {
+ const key = getChildByField(pair, 'key');
+ const value = getChildByField(pair, 'value');
+ if (key && value && keywordName(key, ctx.source) === 'for') {
+ forType = expandAlias(getNodeText(value, ctx.source));
+ break;
+ }
+ }
+ }
+ // A bare `defimpl P do` inside `defmodule T` implements P for T.
+ if (!forType) forType = currentModule();
+ const fullName = forType ? `${protocol}.${forType}` : protocol;
+
+ const impl = ctx.createNode('module', fullName, node, {
+ signature: collapseWs(
+ ctx.source.substring(node.startIndex, doBlock ? doBlock.startIndex : node.endIndex)
+ ).slice(0, 200),
+ });
+ if (!impl) return true;
+ impl.qualifiedName = fullName;
+ addRef(ctx, impl.id, protocol, 'implements', node);
+
+ moduleStack.push(fullName);
+ const savedDefName = lastDefName;
+ lastDefName = '';
+ walkUnder(impl.id, ctx, [doBlock]);
+ lastDefName = savedDefName;
+ moduleStack.pop();
+ return true;
+}
+
+function handleDef(node: SyntaxNode, defKind: string, ctx: ExtractorContext): boolean {
+ const args = argsOf(node);
+ const head = args ? args.namedChild(0) : null;
+ const parsed = head ? parseDefHead(head, ctx.source) : null;
+ // Consume either way: descending into an unparseable head would emit a call
+ // ref to the function's OWN name (the head is literally `f(a, b)`).
+ if (!parsed) return true;
+
+ const body = defBody(node);
+ const isPrivate = PRIVATE_DEFS.has(defKind);
+ const mod = currentModule();
+
+ // Continuation clause of the same function — extend the first node's span
+ // and attribute this clause's calls to it.
+ if (
+ ctx.filePath === lastDefFile &&
+ mod === lastDefModule &&
+ parsed.name === lastDefName &&
+ parsed.arity === lastDefArity &&
+ lastDefId
+ ) {
+ for (let i = ctx.nodes.length - 1; i >= 0; i--) {
+ const n = ctx.nodes[i];
+ if (n && n.id === lastDefId) {
+ if (node.endPosition.row + 1 > n.endLine) n.endLine = node.endPosition.row + 1;
+ break;
+ }
+ }
+ walkUnder(lastDefId, ctx, [parsed.params, parsed.guard, body]);
+ return true;
+ }
+
+ const { doc, spec } = defPreamble(node, ctx.source);
+ // Everything up to the body — `def create(attrs \\ %{}) when is_map(attrs)`.
+ // A bodiless head (a protocol's `def size(data)`, a `defguard`) has no body
+ // to stop at, so the whole node IS the header.
+ const header = collapseWs(
+ ctx.source.substring(node.startIndex, body ? body.startIndex : node.endIndex)
+ ).replace(/,$/, '');
+ const fn = ctx.createNode('function', parsed.name, node, {
+ docstring: doc ?? getPrecedingDocstring(node, ctx.source),
+ // The @spec is the only place a function's types are written, so lead with
+ // it when present — that is what makes the signature useful to an agent.
+ signature: spec ? `${spec} ${header}` : header,
+ visibility: isPrivate ? 'private' : 'public',
+ isExported: !isPrivate,
+ decorators: defKind.startsWith('defmacro')
+ ? ['macro']
+ : defKind.startsWith('defguard')
+ ? ['guard']
+ : undefined,
+ });
+ if (!fn) return true;
+ fn.qualifiedName = qualify(parsed.name);
+
+ // The parameter list and guard hold real calls (default values, `is_map(x)`),
+ // but the head's own name must never become a call ref — hence walking
+ // `params`/`guard` rather than the whole head.
+ walkUnder(fn.id, ctx, [parsed.params, parsed.guard, body]);
+
+ lastDefFile = ctx.filePath;
+ lastDefModule = mod;
+ lastDefName = parsed.name;
+ lastDefArity = parsed.arity;
+ lastDefId = fn.id;
+ return true;
+}
+
+/**
+ * `defdelegate encode(data), to: Jason, as: :dump` — a real function whose
+ * whole body is a call to another module. Without the synthesized call edge
+ * the delegation chain simply stops here.
+ */
+function handleDefdelegate(node: SyntaxNode, ctx: ExtractorContext): boolean {
+ const args = argsOf(node);
+ const head = args ? args.namedChild(0) : null;
+ const parsed = head ? parseDefHead(head, ctx.source) : null;
+ if (!parsed) return true;
+
+ let to = '';
+ let as = parsed.name;
+ const keywords = args ? namedChildOfType(args, 'keywords') : null;
+ if (keywords) {
+ for (const pair of keywords.namedChildren) {
+ const key = getChildByField(pair, 'key');
+ const value = getChildByField(pair, 'value');
+ if (!key || !value) continue;
+ const k = keywordName(key, ctx.source);
+ if (k === 'to') to = expandAlias(getNodeText(value, ctx.source));
+ else if (k === 'as') as = atomName(value, ctx.source);
+ }
+ }
+
+ const { doc } = defPreamble(node, ctx.source);
+ const fn = ctx.createNode('function', parsed.name, node, {
+ docstring: doc,
+ signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 300),
+ isExported: true,
+ visibility: 'public',
+ });
+ if (!fn) return true;
+ fn.qualifiedName = qualify(parsed.name);
+ if (to) addRef(ctx, fn.id, `${to}::${as}`, 'calls', node);
+ return true;
+}
+
+/**
+ * `defstruct [:id, :name]` / `defstruct name: nil, age: 0` — the struct's
+ * fields, emitted under the enclosing module (which IS the struct in Elixir,
+ * so no separate struct node is minted).
+ */
+function handleDefstruct(node: SyntaxNode, ctx: ExtractorContext): boolean {
+ const args = argsOf(node);
+ if (!args) return true;
+ for (const arg of args.namedChildren) {
+ if (arg.type === 'list' || arg.type === 'keywords') {
+ for (const item of arg.namedChildren) {
+ if (item.type === 'atom') {
+ ctx.createNode('field', atomName(item, ctx.source), item);
+ } else if (item.type === 'pair') {
+ const key = getChildByField(item, 'key');
+ if (key) ctx.createNode('field', keywordName(key, ctx.source), item);
+ }
+ }
+ } else if (arg.type === 'atom') {
+ ctx.createNode('field', atomName(arg, ctx.source), arg);
+ }
+ }
+ return true;
+}
+
+/**
+ * Ecto `schema "users" do field :name, :string; has_many :posts, MyApp.Post end`
+ * — the schema block's macros are the module's data shape and its associations
+ * to other schemas. Consumed rather than descended into so the macro names
+ * (`field`, `has_many`) don't become call refs.
+ */
+function handleSchema(node: SyntaxNode, ctx: ExtractorContext): boolean {
+ const doBlock = namedChildOfType(node, 'do_block');
+ if (!doBlock) return false; // not the schema-block form — treat as a plain call
+ const ownerId = scopeHead(ctx);
+
+ for (const stmt of doBlock.namedChildren) {
+ if (stmt.type !== 'call') continue;
+ const target = getChildByField(stmt, 'target');
+ if (target?.type !== 'identifier') continue;
+ const macro = getNodeText(target, ctx.source);
+ if (!SCHEMA_FIELDS.has(macro)) continue;
+ const stmtArgs = argsOf(stmt);
+ const nameArg = stmtArgs ? stmtArgs.namedChild(0) : null;
+ if (!nameArg || nameArg.type !== 'atom') continue;
+
+ const field = ctx.createNode('field', atomName(nameArg, ctx.source), stmt, {
+ signature: collapseWs(getNodeText(stmt, ctx.source)).slice(0, 200),
+ });
+ // `has_many :posts, MyApp.Post` — the related schema is a genuine
+ // cross-module dependency, and the only place the association is written.
+ if (SCHEMA_ASSOCS.has(macro)) {
+ const related = stmtArgs ? stmtArgs.namedChild(1) : null;
+ if (related?.type === 'alias') {
+ const from = field?.id ?? ownerId;
+ if (from) addRef(ctx, from, expandAlias(getNodeText(related, ctx.source)), 'references', related);
+ }
+ }
+ }
+ return true;
+}
+
+/**
+ * Whether a `use` names a protobuf generator's runtime, and which shape it
+ * gives the module: `use Protobuf` (a message), `use Protobuf, enum: true` (an
+ * enum), `use GRPC.Service` (a service). protoc-gen-elixir emits exactly one
+ * of these at the top of every module it writes, and nothing else in the
+ * generated body identifies it — so this marker is what the field/rpc macros
+ * below are recognised by.
+ */
+function generatedProtoShape(args: SyntaxNode, ctx: ExtractorContext): GeneratedProtoShape | null {
+ const first = args.namedChild(0);
+ if (first?.type !== 'alias') return null;
+ const used = getNodeText(first, ctx.source).trim();
+ if (used === 'GRPC.Service' || used.endsWith('.GRPC.Service')) return 'service';
+ if (used !== 'Protobuf' && !used.endsWith('.Protobuf')) return null;
+
+ for (const arg of args.namedChildren) {
+ if (arg.type !== 'keywords') continue;
+ for (const pair of arg.namedChildren) {
+ const key = getChildByField(pair, 'key');
+ const value = getChildByField(pair, 'value');
+ if (key && keywordName(key, ctx.source) === 'enum'
+ && value && getNodeText(value, ctx.source).trim() === 'true') {
+ return 'enum';
+ }
+ }
+ }
+ return 'message';
+}
+
+/**
+ * A protobuf generator writes a module's shape as bare macro calls in the
+ * module BODY — `field :observed_at, 3, type: :string`, `oneof :kind, 0`,
+ * `rpc :GetRun, Req, Resp` — with no enclosing block. Ecto's `schema do … end`
+ * gives its fields a block to be recognised by; these have none, so without
+ * this they fall through to ordinary call handling and do two kinds of damage
+ * at once. The declarations go missing, which is what leaves a generated
+ * message with zero members and forces anything matching against it to settle
+ * for the enclosing module. And the calls are then resolved by name: a project
+ * that happens to define its own `field/2` collects every generated field in
+ * the repo as a caller (observed on a real codebase: ~1,000 edges landing on
+ * one unrelated private helper, making it the third most-called symbol there).
+ *
+ * Gated on the `use` marker, so a hand-written `field(x)` call in an ordinary
+ * module still resolves as the call it is.
+ */
+function handleGeneratedProtoMacro(
+ node: SyntaxNode,
+ macro: string,
+ shape: GeneratedProtoShape,
+ ctx: ExtractorContext
+): boolean {
+ // A service takes `rpc` and nothing else; a message/enum takes the rest.
+ if ((macro === 'rpc') !== (shape === 'service')) return false;
+ const args = argsOf(node);
+ const nameArg = args ? args.namedChild(0) : null;
+ if (!args || nameArg?.type !== 'atom') return false;
+
+ const name = atomName(nameArg, ctx.source);
+ const signature = collapseWs(getNodeText(node, ctx.source)).slice(0, 200);
+
+ if (macro === 'rpc') {
+ const method = ctx.createNode('method', name, node, { signature, isExported: true });
+ // The request and response messages are a real dependency of the service,
+ // and this line is the only place the binding is written.
+ const from = method?.id ?? scopeHead(ctx);
+ if (from) {
+ for (let i = 1; i < args.namedChildCount; i++) {
+ const arg = args.namedChild(i)!;
+ // `stream(Acme.V1.Run)` wraps the message in a call of its own.
+ const alias = arg.type === 'alias' ? arg : namedChildOfType(argsOf(arg) ?? arg, 'alias');
+ if (alias) addRef(ctx, from, expandAlias(getNodeText(alias, ctx.source)), 'references', alias);
+ }
+ }
+ return true;
+ }
+
+ // The number in second position is the field's identity on the wire (or the
+ // enum value), recorded as a marker the way the `.proto` side records it so
+ // a "same number, changed type" check can read it without re-parsing. A
+ // `oneof`'s second argument is a group index, not a tag, so it carries none.
+ const numArg = args.namedChild(1);
+ const number = macro !== 'oneof' && numArg?.type === 'integer'
+ ? getNodeText(numArg, ctx.source).trim()
+ : null;
+ const isEnumValue = shape === 'enum' && macro === 'field';
+ ctx.createNode(isEnumValue ? 'enum_member' : 'field', name, node, {
+ signature,
+ isExported: true,
+ ...(number !== null
+ ? { decorators: [isEnumValue ? `number=${number}` : `tag=${number}`] }
+ : {}),
+ });
+ return true;
+}
+
+/** `alias` / `import` / `require` / `use` — module dependencies. */
+function handleDirective(node: SyntaxNode, directive: string, ctx: ExtractorContext): boolean {
+ const args = argsOf(node);
+ if (!args) return true;
+ const parentId = scopeHead(ctx);
+ if (directive === 'use') {
+ const shape = generatedProtoShape(args, ctx);
+ const mod = currentModule();
+ if (shape && mod) generatedProtoModules.set(mod, shape);
+ }
+ const first = args.namedChild(0);
+ if (!first) return true;
+
+ const signature = collapseWs(getNodeText(node, ctx.source)).slice(0, 200);
+ const register = (full: string, at: SyntaxNode, short?: string): void => {
+ if (!full) return;
+ if (directive === 'alias') aliasMap.set(short ?? full.split('.').pop()!, full);
+ const imported = ctx.createNode('import', full, at, { signature });
+ // Anchor to the enclosing module only. The generic `::`-joined name would
+ // repeat the whole nodeStack (`Outer::Outer.Inner::Plug.Builder`).
+ if (imported) imported.qualifiedName = qualify(full);
+ if (parentId) addRef(ctx, parentId, full, 'imports', at);
+ };
+
+ // `alias MyApp.Accounts.{User, Credential}` — one dot node, many aliases.
+ if (first.type === 'dot') {
+ const left = getChildByField(first, 'left');
+ const right = getChildByField(first, 'right');
+ if (left && right?.type === 'tuple') {
+ const base = expandAlias(getNodeText(left, ctx.source));
+ for (const member of right.namedChildren) {
+ if (member.type !== 'alias') continue;
+ const written = getNodeText(member, ctx.source);
+ register(`${base}.${written}`, member, written.split('.').pop()!);
+ }
+ return true;
+ }
+ }
+
+ if (first.type !== 'alias') return true; // `alias __MODULE__.Sub`, dynamic forms
+ const full = expandAlias(getNodeText(first, ctx.source));
+
+ // `alias MyApp.Repo, as: R` renames the binding.
+ let short: string | undefined;
+ const keywords = namedChildOfType(args, 'keywords');
+ if (keywords) {
+ for (const pair of keywords.namedChildren) {
+ const key = getChildByField(pair, 'key');
+ const value = getChildByField(pair, 'value');
+ if (key && value && keywordName(key, ctx.source) === 'as') {
+ short = getNodeText(value, ctx.source).split('.').pop();
+ }
+ }
+ }
+ register(full, first, short);
+ return true;
+}
+
+/**
+ * `@name …`. Four distinct things wear this syntax: documentation, typespecs,
+ * behaviour declarations, and plain module constants — plus a bare `@name`
+ * READ inside a function body.
+ */
+function handleAttribute(node: SyntaxNode, ctx: ExtractorContext): boolean {
+ const name = attrNameOf(node, ctx.source);
+ if (!name) return false;
+ const attrCall = attrCallOf(node);
+
+ // Bare read (`@timeout` in an expression) → a reference to the constant.
+ if (!attrCall) {
+ const parentId = scopeHead(ctx);
+ if (parentId) addRef(ctx, parentId, name, 'references', node);
+ return true;
+ }
+
+ if (TYPE_ATTRS.has(name)) {
+ // `@type t :: %__MODULE__{}` — the declared name is the left of the `::`.
+ const args = argsOf(attrCall);
+ const decl = args ? args.namedChild(0) : null;
+ let nameNode: SyntaxNode | null = null;
+ if (decl?.type === 'binary_operator') nameNode = getChildByField(decl, 'left');
+ else if (decl) nameNode = decl;
+ if (nameNode?.type === 'call') nameNode = getChildByField(nameNode, 'target');
+ if (nameNode?.type === 'identifier') {
+ const alias = ctx.createNode('type_alias', getNodeText(nameNode, ctx.source), node, {
+ signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 300),
+ });
+ if (alias) alias.qualifiedName = qualify(alias.name);
+ }
+ return true; // type position — never descend (every `String.t()` is a `call`)
+ }
+
+ if (name === 'behaviour' || name === 'behavior') {
+ const args = argsOf(attrCall);
+ const target = args ? namedChildOfType(args, 'alias') : null;
+ const parentId = scopeHead(ctx);
+ if (target && parentId) {
+ addRef(ctx, parentId, expandAlias(getNodeText(target, ctx.source)), 'implements', node);
+ }
+ return true;
+ }
+
+ // Documentation, typespecs, and compiler directives carry no symbol; their
+ // bodies are type expressions or literals, so consume the subtree.
+ if (RESERVED_ATTRS.has(name)) return true;
+
+ // Everything else is a module-level constant: `@default_role :member`.
+ const constant = ctx.createNode('constant', name, node, {
+ signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 200),
+ });
+ if (constant) {
+ constant.qualifiedName = qualify(name);
+ // The value can hold real calls (`@config Application.compile_env(:app, :k)`).
+ walkUnder(constant.id, ctx, [argsOf(attrCall)]);
+ }
+ return true;
+}
+
+// --- Reference handlers ------------------------------------------------------
+
+/**
+ * A call site. `Repo.all(User)` is `call(target: dot(left, right), arguments)`;
+ * `list_users()` is `call(target: identifier, arguments)`. Remote receivers are
+ * alias-expanded so the emitted `MyApp.Repo::all` matches the callee's
+ * qualifiedName exactly; local calls stay bare and resolve by name with the
+ * call site's own file preferred (an Elixir local call targets its own module).
+ */
+function handleCallSite(node: SyntaxNode, ctx: ExtractorContext): void {
+ const callerId = scopeHead(ctx);
+ if (!callerId) return;
+ const target = getChildByField(node, 'target');
+ if (!target) return;
+
+ if (target.type === 'identifier') {
+ const name = getNodeText(target, ctx.source);
+ // `case`/`if`/`quote`/… are syntax, not callees (see SPECIAL_FORMS).
+ if (!SPECIAL_FORMS.has(name)) addRef(ctx, callerId, name, 'calls', node);
+ return;
+ }
+ if (target.type !== 'dot') return; // `fun.()`, `apply/3` — no static callee
+
+ const left = getChildByField(target, 'left');
+ const right = getChildByField(target, 'right');
+ if (!left || !right) return;
+ // A non-alias receiver (`conn.assigns`, `socket.foo()`) is a runtime value,
+ // not a module: emitting a bare `::`-qualified ref there would resolve
+ // against an unrelated same-named function.
+ if (left.type !== 'alias') return;
+ const receiver = expandAlias(getNodeText(left, ctx.source));
+ const fn = getNodeText(right, ctx.source);
+ if (!receiver || !fn) return;
+ addRef(ctx, callerId, `${receiver}::${fn}`, 'calls', node);
+}
+
+/**
+ * `&double/1` and `&String.upcase/1` — Elixir's function-capture syntax, and
+ * the whole of how callbacks are registered (`Enum.map(list, &process/1)`,
+ * `Task.async(&worker/0)`). The captured function has no call site of its own,
+ * so without this it shows zero callers and the flow breaks exactly where an
+ * agent has to start reading. Parses as `unary_operator(&)` over
+ * `binary_operator(/)` — the arity literal is dropped, matching the
+ * arity-free naming model above.
+ */
+function handleCapture(node: SyntaxNode, ctx: ExtractorContext): boolean {
+ const callerId = scopeHead(ctx);
+ const operand = getChildByField(node, 'operand');
+ if (!callerId || operand?.type !== 'binary_operator') return false;
+ if (operatorText(operand, ctx.source) !== '/') return false;
+ let left = getChildByField(operand, 'left');
+ if (!left) return false;
+ // A qualified capture's name half parses as an ARGUMENT-LESS call
+ // (`&String.upcase/1` ⇒ binary_operator(left: call(target: dot(String,
+ // upcase)))), so unwrap to the target. Without this the descent below sees
+ // that inner node and records an invocation that never happens.
+ if (left.type === 'call' && !argsOf(left)) {
+ const inner = getChildByField(left, 'target');
+ if (!inner) return false;
+ left = inner;
+ }
+
+ if (left.type === 'identifier') {
+ addRef(ctx, callerId, getNodeText(left, ctx.source), 'references', node);
+ return true;
+ }
+ if (left.type === 'dot') {
+ const mod = getChildByField(left, 'left');
+ const fn = getChildByField(left, 'right');
+ if (mod?.type === 'alias' && fn) {
+ const receiver = expandAlias(getNodeText(mod, ctx.source));
+ addRef(ctx, callerId, `${receiver}::${getNodeText(fn, ctx.source)}`, 'references', node);
+ return true;
+ }
+ }
+ return false;
+}
+
+/** The text of a `string` literal argument, or null for a non-literal. */
+function stringArg(node: SyntaxNode | null, source: string): string | null {
+ if (node?.type !== 'string') return null;
+ const content = namedChildOfType(node, 'quoted_content');
+ return content ? getNodeText(content, source) : '';
+}
+
+/** Join a scope prefix and a route path into one clean URL path. */
+function joinPath(prefix: string, segment: string): string {
+ const joined = `${prefix}/${segment}`.replace(/\/+/g, '/');
+ return joined.length > 1 ? joined.replace(/\/$/, '') : '/';
+}
+
+/**
+ * `scope "/api", MyAppWeb do … end` — a router path + controller-alias frame.
+ * Returns false when the call isn't the scope-block form, so it falls through
+ * to ordinary call handling.
+ */
+function handleRouteScope(node: SyntaxNode, ctx: ExtractorContext): boolean {
+ const doBlock = namedChildOfType(node, 'do_block');
+ const args = argsOf(node);
+ if (!doBlock || !args) return false;
+ const path = stringArg(args.namedChild(0), ctx.source);
+ if (path === null) return false; // `scope path: "/x", alias: Y do` — keyword form
+
+ const aliasArg = args.namedChild(1);
+ const prefix = routeScopes.length > 0 ? routeScopes[routeScopes.length - 1]! : { path: '', alias: '' };
+ const scopeAlias =
+ aliasArg?.type === 'alias' ? expandAlias(getNodeText(aliasArg, ctx.source)) : '';
+
+ routeScopes.push({
+ path: joinPath(prefix.path, path),
+ // A nested scope's alias extends the outer one (`scope "/admin", Admin`
+ // inside `scope "/", MyAppWeb` ⇒ `MyAppWeb.Admin`), matching Phoenix.
+ alias: scopeAlias
+ ? prefix.alias && !scopeAlias.startsWith(prefix.alias)
+ ? `${prefix.alias}.${scopeAlias}`
+ : scopeAlias
+ : prefix.alias,
+ });
+ const ownerId = scopeHead(ctx);
+ if (ownerId) walkUnder(ownerId, ctx, [doBlock]);
+ routeScopes.pop();
+ return true;
+}
+
+/**
+ * A Phoenix route macro. Emits a `route` node named `GET /api/users` and a
+ * `references` edge to the controller action it dispatches to — the hop that
+ * otherwise does not exist anywhere in the graph, because Phoenix builds the
+ * dispatch at compile time from these macro arguments.
+ */
+function handleRouteMacro(node: SyntaxNode, verb: string, ctx: ExtractorContext): boolean {
+ const args = argsOf(node);
+ if (!args) return false;
+ const path = stringArg(args.namedChild(0), ctx.source);
+ const controllerArg = args.namedChild(1);
+ // Every form starts `verb "", `; anything else is a same-named
+ // ordinary function (`Map.get` is qualified, but a bare `get(x)` exists).
+ if (path === null || controllerArg?.type !== 'alias') return false;
+
+ const scope = routeScopes.length > 0 ? routeScopes[routeScopes.length - 1]! : { path: '', alias: '' };
+ const written = expandAlias(getNodeText(controllerArg, ctx.source));
+ const controller = scope.alias && !written.startsWith(scope.alias)
+ ? `${scope.alias}.${written}`
+ : written;
+ const fullPath = joinPath(scope.path, path);
+
+ const actionArg = args.namedChild(2);
+ let actions: string[];
+ let method: string;
+ if (verb === 'resources') {
+ actions = RESOURCE_ACTIONS;
+ method = 'RESOURCES';
+ } else if (verb === 'forward') {
+ // `forward "/admin", MyPlug` dispatches into a Plug's `call/2`.
+ actions = ['call'];
+ method = 'FORWARD';
+ } else if (verb === 'live') {
+ // `live "/dash", DashLive, :index` — the third argument is a `live_action`
+ // assign, NOT a function on the module, so the module itself is the target.
+ actions = [];
+ method = 'LIVE';
+ } else {
+ actions = actionArg?.type === 'atom' ? [atomName(actionArg, ctx.source)] : [];
+ method = ROUTE_VERBS.get(verb) ?? verb.toUpperCase();
+ }
+
+ const route = ctx.createNode('route', `${method} ${fullPath}`, node, {
+ signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 200),
+ });
+ const from = route?.id ?? scopeHead(ctx);
+ if (!from) return true;
+ if (route) route.qualifiedName = `${method} ${fullPath}`;
+ for (const action of actions) {
+ addRef(ctx, from, `${controller}::${action}`, 'references', node);
+ }
+ // A LiveView route names a module, not an action — link the module itself.
+ if (actions.length === 0) addRef(ctx, from, controller, 'references', node);
+ return true;
+}
+
+/**
+ * `plug :authenticate` / `plug MyApp.Auth` inside a `Plug.Builder` or Phoenix
+ * `pipeline` — the request pipeline. An ATOM names a function in the SAME
+ * module (`defp authenticate(conn, _opts)`), and a MODULE names a plug whose
+ * `call/2` runs. Neither is a static call, so a request flow otherwise stops
+ * dead at the pipeline declaration.
+ */
+function handlePlugMacro(node: SyntaxNode, ctx: ExtractorContext): boolean {
+ const args = argsOf(node);
+ const first = args ? args.namedChild(0) : null;
+ const ownerId = scopeHead(ctx);
+ if (!first || !ownerId) return false;
+ if (first.type === 'atom') {
+ addRef(ctx, ownerId, atomName(first, ctx.source), 'calls', node);
+ return true;
+ }
+ if (first.type === 'alias') {
+ addRef(ctx, ownerId, `${expandAlias(getNodeText(first, ctx.source))}::call`, 'calls', node);
+ return true;
+ }
+ return false;
+}
+
+/**
+ * `%User{name: "x"}` / `%__MODULE__{}` — struct construction, parsed as
+ * `map(struct(alias), map_content)`. A plain map (`%{a: 1}`) has no `struct`
+ * child and is skipped.
+ */
+function handleStructLiteral(node: SyntaxNode, ctx: ExtractorContext): void {
+ const callerId = scopeHead(ctx);
+ const structNode = namedChildOfType(node, 'struct');
+ if (!callerId || !structNode) return;
+ const aliasNode = namedChildOfType(structNode, 'alias');
+ if (!aliasNode) return; // `%module{}` — a runtime struct name
+ addRef(ctx, callerId, expandAlias(getNodeText(aliasNode, ctx.source)), 'instantiates', node);
+}
+
+export const elixirExtractor: LanguageExtractor = {
+ // Every mapping is empty on purpose: the grammar has no declaration node
+ // types (see the header note) and the visitNode hook owns all dispatch,
+ // including calls — the generic ladder has nothing correct to do here.
+ functionTypes: [],
+ classTypes: [],
+ methodTypes: [],
+ interfaceTypes: [],
+ structTypes: [],
+ enumTypes: [],
+ typeAliasTypes: [],
+ importTypes: [],
+ callTypes: [],
+ variableTypes: [],
+ nameField: 'target',
+ bodyField: 'do_block',
+ paramsField: 'arguments',
+
+ visitNode: (node, ctx) => {
+ resetFileState(ctx.filePath, node.type === 'source');
+
+ if (node.type === 'unary_operator') {
+ if (unaryOpIs(node, ctx.source, '@')) return handleAttribute(node, ctx);
+ if (unaryOpIs(node, ctx.source, '&')) return handleCapture(node, ctx);
+ return false;
+ }
+
+ if (node.type === 'map') {
+ handleStructLiteral(node, ctx);
+ return false; // children still walked — `%User{id: get_id()}` holds calls
+ }
+
+ if (node.type !== 'call') return false;
+
+ const target = getChildByField(node, 'target');
+ const form = target?.type === 'identifier' ? getNodeText(target, ctx.source) : '';
+
+ if (form === 'defmodule') return handleDefmodule(node, ctx);
+ if (form === 'defprotocol') return handleDefprotocol(node, ctx);
+ if (form === 'defimpl') return handleDefimpl(node, ctx);
+ if (DEF_KINDS.has(form)) return handleDef(node, form, ctx);
+ if (form === 'defdelegate') return handleDefdelegate(node, ctx);
+ if (form === 'defstruct' || form === 'defexception') return handleDefstruct(node, ctx);
+ if (DIRECTIVES.has(form)) return handleDirective(node, form, ctx);
+ if (form === 'schema' || form === 'embedded_schema') {
+ if (handleSchema(node, ctx)) return true;
+ }
+ // `defoverridable [foo: 1]` / `quote`d fragments name no new symbol.
+ if (form === 'defoverridable') return true;
+
+ // A generated protobuf module's members. Checked before ordinary call
+ // handling and only inside a module the `use` marker claimed, so an
+ // unrelated `field(…)` call elsewhere is untouched.
+ if (GENERATED_PROTO_MACROS.has(form)) {
+ const shape = generatedProtoModules.get(currentModule());
+ if (shape && handleGeneratedProtoMacro(node, form, shape, ctx)) return true;
+ }
+
+ // Macro-driven dispatch (Phoenix router, Plug pipelines). Each handler
+ // returns false when the call doesn't actually match the framework shape,
+ // so a same-named ordinary function still falls through to a plain call.
+ if (form === 'scope' && handleRouteScope(node, ctx)) return true;
+ if ((ROUTE_VERBS.has(form) || form === 'resources' || form === 'forward' || form === 'live') &&
+ handleRouteMacro(node, form, ctx)) {
+ return true;
+ }
+ if (form === 'plug' && handlePlugMacro(node, ctx)) return true;
+
+ // An ordinary call. Emit its edge, then descend so nested calls in the
+ // arguments and any `do_block` (a DSL block such as a Phoenix `scope`)
+ // still attribute to the enclosing scope.
+ handleCallSite(node, ctx);
+ for (const child of node.namedChildren) {
+ if (child.type === 'identifier' && child.id === target?.id) continue;
+ ctx.visitNode(child);
+ }
+ return true;
+ },
+};
diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts
index 6b760b01d..19df78dc2 100644
--- a/src/extraction/languages/index.ts
+++ b/src/extraction/languages/index.ts
@@ -32,6 +32,7 @@ import { cfqueryExtractor } from './cfquery';
import { cobolExtractor } from './cobol';
import { vbnetExtractor } from './vbnet';
import { erlangExtractor } from './erlang';
+import { elixirExtractor } from './elixir';
import { solidityExtractor } from './solidity';
import { terraformExtractor } from './terraform';
import { arktsExtractor } from './arkts';
@@ -65,6 +66,7 @@ export const EXTRACTORS: Partial> = {
cobol: cobolExtractor,
vbnet: vbnetExtractor,
erlang: erlangExtractor,
+ elixir: elixirExtractor,
solidity: solidityExtractor,
terraform: terraformExtractor,
arkts: arktsExtractor,
diff --git a/src/types.ts b/src/types.ts
index 186f57adc..450362a47 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -116,6 +116,7 @@ export const LANGUAGES = [
'cobol',
'vbnet',
'erlang',
+ 'elixir',
'terraform',
'unknown',
] as const;