Skip to content

feat(elixir): index Elixir with Phoenix, Plug and Ecto flow coverage - #1648

Open
ferrine wants to merge 3 commits into
colbymchenry:mainfrom
ferrine:feat/elixir-support
Open

feat(elixir): index Elixir with Phoenix, Plug and Ecto flow coverage#1648
ferrine wants to merge 3 commits into
colbymchenry:mainfrom
ferrine:feat/elixir-support

Conversation

@ferrine

@ferrine ferrine commented Aug 30, 2026

Copy link
Copy Markdown

Branch: ferrine:feat/elixir-supportmain
Size: 10 files, +1806/−1 · Tests: 26 in __tests__/extraction.test.ts
Grammar: tree-sitter-elixir from tree-sitter-wasms (already vendored, ABI
health-checked — no new dependency, no new .wasm).

Why

Elixir was unsupported. Added following .claude/skills/add-lang.

What changed

src/extraction/languages/elixir.ts, plus the four standard wiring edits.

Elixir's grammar is homoiconic — every construct is a call node, including
defmodule and def — so there are no declaration node types to map and
everything dispatches through the visitNode hook. Two grammar details worth
flagging for review, because both produced silent, total extraction failures
before they were found:

  • arguments is a plain named child of a call, not a field (only target
    is), so childForFieldName('arguments') never finds it.
  • a keyword node's text spans trailing whitespace ("for: "), so it must be
    trimmed before comparison.

Indexed: modules including nested ones with @moduledoc; public/private
functions with @doc and @spec; macros, guards, operator definitions,
defdelegate targets; defstruct/defexception fields; @type/@opaque;
module attributes as constants; protocols and their defimpl; @behaviour
links; alias/import/require/use with alias/as:/{A, B} expansion so
remote calls resolve to the real module; &fun/1 captures; %Struct{}
instantiation.

Multi-clause functions are merged into one symbol per arity rather than one
node per clause — the GenServer idiom otherwise indexes a 6-clause
handle_call as six identical nodes with caller edges landing on an arbitrary
one. Adjacency is a safe merge key because Elixir warns on any non-adjacent
redefinition. Same-name different-arity stays separate.

Kernel.SpecialForms and the Kernel macros that are language syntax are
suppressed as call targets — without that list every if/case/quote mints a
calls ref, and a project that legitimately defines def send/2 collects
hundreds of wrong caller edges.

Framework coverage: Phoenix routes as symbols linked to the controller
action they dispatch to (nested scope paths and aliases, resources
expansion, forward, live); Plug pipeline entries linked to the function
or plug that runs; Ecto schema fields and association edges.

Generated-protobuf modules

A protobuf generator writes a module's shape as bare macro calls in the module
body — field :observed_at, 3, type: :string, oneof, rpc — with no
enclosing block. Ecto's schema do … end gives its fields a block to be
recognised by; these have nothing but the use Protobuf / use GRPC.Service
marker at the top of the module, so they were doing damage twice over: the
declarations went missing entirely, and the macro calls resolved by name, so a
project that defines its own field/2 collected every generated field in the
repo as a caller of that one unrelated helper.

Reading the use marker fixes both. Fields carry their tag, an enum's values
become enum_member, and an rpc links to the request and response messages it
names — including through a stream(...) wrapper. Gated on the marker, so a
hand-written field(...) call in an ordinary module still resolves as a call,
and the marker stops applying at the end of its own module.

Notes for review

assets/languages/elixir.svg uses the published simple-icons path plus a
wordmark recomposed from published glyph metrics — not hand-drawn.

ferrine and others added 3 commits August 30, 2026 08:53
Elixir's grammar has no declaration node types — `defmodule`, `def`,
`alias`, an Ecto `schema` and a Phoenix route are all the same `call`
node, told apart only by the text of the target identifier. So the
generic node-type ladder has nothing to match and every construct is
dispatched through the visitNode hook, which also owns call extraction
(visitFunctionBody does not invoke the hook, and Elixir call sites live
inside bodies).

Three things beyond plain symbol extraction:

- Alias expansion — `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
  instead of by bare name.
- Clause merging by (module, name, arity). The GenServer idiom spells a
  multi-clause function as repeated `def`s, which otherwise index as one
  identical node per clause and scatter every caller edge.
- Macro-argument dispatch, closed end to end rather than half-bridged:
  Phoenix routes become `route` nodes linked to the controller action
  they dispatch to (nested `scope` path + alias composition, `resources`
  expansion, `forward`, `live`); `plug :atom` / `plug Module` link to the
  function or `call/2` that runs; Ecto schema fields and association
  target modules are extracted.

Kernel special forms (`case`, `if`, `quote`, `raise`, …) are suppressed
as call refs — they are syntax, and on plug they were ~1,900 refs that
could only ever resolve wrongly.

Validated on plug (S), phoenix (M) and firezone (L, 2,111 files):
extraction PASS on all three, 137/144 route→action edges resolved at
100% precision on firezone, node/edge counts stable across re-index and
incremental sync. Agent A/B (sonnet/high, 2 runs/arm) takes Read to ~0
and cuts tool calls 4-8x on every repo; wall-clock only improves on the
large repo, which is recorded honestly in the coverage playbook.

Grammar is the tree-sitter-wasms tree-sitter-elixir build (ABI 14),
health-checked — no vendored wasm needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Bsi9EH64kMisnik4E1gf7
…macros

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 nothing but the
`use Protobuf` / `use GRPC.Service` marker at the top of the module, so they
were falling through to ordinary call handling and doing two kinds of damage
at once.

The declarations went missing, leaving every generated message as a module
with zero members, so anything matching a wire declaration against its
generated peer could only ever reach the enclosing module. And the macro
calls were then resolved by name: on a codebase that defines its own
`field/2`, every generated field in the repo landed on that one unrelated
private helper, making it the third most-called symbol there.

Reading the `use` marker fixes both. Fields carry their tag the way the
declaration side records it, an enum's values become enum members rather
than fields, and an rpc links to the request and response messages it names
— including through a `stream(...)` wrapper, which is the only place that
binding is written. Gated on the marker, so a hand-written `field(...)` call
in an ordinary module still resolves as the call it is, and the marker stops
applying at the end of the module that carried it (generators put every
message from one file in one file).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Bsi9EH64kMisnik4E1gf7
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Bsi9EH64kMisnik4E1gf7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant