From b5525a64bafde5652a565660b2da8e09534a7575 Mon Sep 17 00:00:00 2001 From: Roman Wolan Date: Sat, 11 Jul 2026 23:00:02 +0200 Subject: [PATCH 1/6] feat(elixir): add Elixir language support (.ex/.exs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elixir has no keywords — defmodule/def/use/@spec are all macros that parse as plain `call` nodes in elixir-lang/tree-sitter-elixir (prebuilt wasm from tree-sitter-wasms, ABI 14) — so nothing fits the generic functionTypes/classTypes dispatch. The whole language is driven from the visitNode hook, keyed off each call's target identifier, following the erlang extractor's architecture. Extraction: - `defmodule` (and nested defmodules) → namespace nodes carrying the full dotted name; every contained symbol's qualifiedName is `Full.Module::fun`. - `def`/`defp`/`defmacro(p)`/`defguard(p)`/`defdelegate` → function nodes with visibility (defp/defmacrop/defguardp private); multiple clauses of the same function (pattern matching, guards, default args) merge into one node, matching the erlang multi-clause treatment. Both body forms are handled: `do…end` blocks and the one-liner `, do:` keyword. - `@spec` becomes the signature, `@doc`/`@moduledoc` the docstring; attribute subtrees are consumed so type-position expressions (`String.t()` parses as a call) never mint bogus call refs. - `defstruct`/`defexception` fields (keyword and atom-list forms) → field nodes on the module; `%Struct{}` literals → references. - `defprotocol` → namespace; `defimpl Proto, for: Type` → namespace `Proto.Type` plus an implements ref to the protocol. Calls and edges (extractCall elixir branch): - Remote `Mod.Sub.fun(x)` is emitted as `Mod.Sub::fun` — byte-identical to the qualifiedName the module namespace produces — expanding the head segment through the enclosing module's `alias` table (`alias Foo.Bar`, `alias Foo.Bar, as: Baz`, `alias Foo.{A, B}`). - `__MODULE__.fun(...)` → bare name (same-file resolution), like erlang's `?MODULE:fn`. - `&Mod.fun/2` and `&local/1` captures → references (no duplicate call edge); pipes need nothing — the right side is an ordinary call node. - `@behaviour Mod` and `use Mod` → implements refs, resolved module-only in the name matcher (same rule as erlang -behaviour, so an OTP behaviour like GenServer stays unresolved rather than guessed); `import`/`require`/`alias` → imports edges. - "A wrong edge is worse than no edge": calls through a variable receiver (`mod.fun(x)`), `apply/3`, and `GenServer.call(pid, …)` have no static target and stay silent. Bare local calls resolve same-file ONLY — a bare call can only legitimately target its own module (or an import, deliberately unexpanded): letting them fall through to repo-wide bare-name matching linked every Mix `config :app, …` DSL call to some module's config/0 and every ConnTest `get(conn, path)` to an unrelated HTTP client's get. Validated on a real 994-file Elixir umbrella app (6.7k nodes, 19.8k edges, 5.2s): grep-truth vs graph callers matched 23/23 call sites for 5 uniquely-named functions, and a 10-edge random sample verified with zero false edges. Known limitations (v1): user macro expansion, Phoenix/Ecto DSLs, GenServer/process dispatch, import-based resolution of bare calls, and .heex templates are out of scope. Co-Authored-By: Claude --- CHANGELOG.md | 1 + README.md | 4 +- __tests__/extraction.test.ts | 281 ++++++++++++++++ __tests__/resolution.test.ts | 69 ++++ assets/languages/elixir.svg | 6 + src/extraction/grammars.ts | 6 + src/extraction/languages/elixir.ts | 510 +++++++++++++++++++++++++++++ src/extraction/languages/index.ts | 2 + src/extraction/tree-sitter.ts | 238 ++++++++++++++ src/resolution/name-matcher.ts | 46 ++- src/types.ts | 1 + 11 files changed, 1160 insertions(+), 4 deletions(-) create mode 100644 assets/languages/elixir.svg create mode 100644 src/extraction/languages/elixir.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ba15487..293453520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph install` and `codegraph upgrade` now offer CodeGraph Pro beta access after finishing — answer yes, type your email, and you join the same waitlist as the getcodegraph.com homepage form. Strictly opt-in and asked at most once per machine total: nothing is sent unless you say yes and enter an email, either answer is remembered so no later install or upgrade ever re-asks, and non-interactive runs (`--yes`, scripts, CI) never see the question. - Every release is now cryptographically verifiable: npm packages publish with npm provenance (the "Provenance" badge on npmjs.com, proving each version was built by this repository's release workflow from a specific commit), and the GitHub Release bundles carry signed build attestations you can check with `gh attestation verify -R colbymchenry/codegraph`. +- Elixir is now a supported language (`.ex`/`.exs`). CodeGraph indexes modules as namespaces — including nested `defmodule`s, which get their full dotted name — and their `def`/`defp`/`defmacro`/`defguard` definitions, grouping a function's multiple clauses (pattern matching, guards, default args) into one node with the right visibility. It reads `@spec` as the signature and `@doc`/`@moduledoc` as docstrings, `defstruct` fields, and `defprotocol`/`defimpl` blocks. Cross-module call edges resolve `Module.fun(...)` through the file's `alias` declarations (including `alias Foo.{A, B}` and `alias …, as: X`), link `__MODULE__.fun` within the same module, and record `&Mod.fun/arity` captures and `%Struct{}` literals as references; `@behaviour`/`use` become `implements` links and `import`/`require`/`alias` become import edges. Following the project's "a wrong edge is worse than no edge" rule, dynamic dispatch that has no static target — a call through a variable receiver, `apply/3`, or `GenServer.call(pid, …)` — is deliberately left unlinked rather than guessed. Known limitations: user-macro expansion, Phoenix/Ecto DSLs, GenServer/process dispatch, `.heex` templates, and import-based resolution of bare calls are out of scope for this first version. ### Fixes diff --git a/README.md b/README.md index dbaa2ad69..01dd195e4 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,7 @@ Every language below gets the same treatment — full structural extraction and COBOL Visual Basic .NET Erlang + Elixir Solidity Terraform / OpenTofu Nix @@ -317,7 +318,7 @@ The reliable, universal payoff is **surgical context and speed**: CodeGraph coll | **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 | | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes | | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config | -| **20+ Languages** | TypeScript, JavaScript, 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 | @@ -836,6 +837,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 as namespaces incl. nested modules, `def`/`defp`/`defmacro`/`defguard` with multi-clause grouping and visibility, `@spec` signatures, `@doc`/`@moduledoc` docstrings, `defstruct` fields, `%Struct{}` references, local and `Module.fun` remote call edges with `alias`/`alias …, as:`/`alias Foo.{A,B}` expansion, `__MODULE__.fun` same-module links, `&Mod.fun/arity` captures, `defprotocol`/`defimpl` namespaces, `@behaviour`/`use` implements links, `import`/`require`/`alias` import edges; dynamic dispatch — variable receivers, `apply/3`, `GenServer.call(pid, …)` — left unlinked) | | 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 d7947073a..891a1e76c 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -10329,6 +10329,287 @@ init(_) -> {ok, #{}}. }); }); +// ============================================================================= +// Elixir (prebuilt elixir-lang/tree-sitter-elixir grammar from tree-sitter-wasms) +// Every construct is a `call`/`@`-attribute macro, driven from the visitNode +// hook; remote calls emit `Module::fun` to ride the qualified-name matcher. +// ============================================================================= + +describe('Elixir Extraction', () => { + const calls = (r: ReturnType): string[] => + r.unresolvedReferences.filter((u) => u.referenceKind === 'calls').map((u) => u.referenceName); + const refsOf = (r: ReturnType, kind: string): string[] => + r.unresolvedReferences.filter((u) => u.referenceKind === kind).map((u) => u.referenceName); + + describe('Language detection', () => { + it('should detect Elixir files', () => { + expect(detectLanguage('lib/foo.ex')).toBe('elixir'); + expect(detectLanguage('test/foo_test.exs')).toBe('elixir'); + expect(detectLanguage('mix.exs')).toBe('elixir'); + expect(isLanguageSupported('elixir')).toBe(true); + expect(getSupportedLanguages()).toContain('elixir'); + expect(isSourceFile('lib/app/worker.ex')).toBe(true); + }); + }); + + describe('Module and function extraction', () => { + it('should extract a module as a namespace and qualify its functions', () => { + const code = `defmodule Foo.Bar do + def greet(name), do: "hi " <> name + defp helper(x), do: x + 1 +end +`; + const result = extractFromSource('lib/foo_bar.ex', code); + const ns = result.nodes.find((n) => n.kind === 'namespace'); + expect(ns?.name).toBe('Foo.Bar'); + const greet = result.nodes.find((n) => n.kind === 'function' && n.name === 'greet'); + expect(greet?.qualifiedName).toBe('Foo.Bar::greet'); + expect(greet?.language).toBe('elixir'); + expect(greet?.visibility).toBe('public'); + expect(greet?.isExported).toBe(true); + const helper = result.nodes.find((n) => n.kind === 'function' && n.name === 'helper'); + expect(helper?.visibility).toBe('private'); + expect(helper?.isExported).toBe(false); + }); + + it('should merge multi-clause functions (incl. default args) into one node', () => { + const code = `defmodule M do + def greet(nil), do: "hello" + def greet(name) when is_binary(name) do + "hello " <> name + end + def greet(name, greeting \\\\ "hi") do + greeting <> " " <> name + end +end +`; + const result = extractFromSource('lib/m.ex', code); + const greets = result.nodes.filter((n) => n.kind === 'function' && n.name === 'greet'); + expect(greets).toHaveLength(1); + expect(greets[0]!.startLine).toBe(2); + expect(greets[0]!.endLine).toBe(8); + }); + + it('should not merge same-named functions across different modules', () => { + const code = `defmodule A do + def run(x), do: x +end +defmodule B do + def run(x), do: x +end +`; + const result = extractFromSource('lib/ab.ex', code); + const runs = result.nodes.filter((n) => n.kind === 'function' && n.name === 'run'); + expect(runs).toHaveLength(2); + expect(runs.map((n) => n.qualifiedName).sort()).toEqual(['A::run', 'B::run']); + }); + + it('should use @spec as signature and @doc as docstring', () => { + const code = `defmodule M do + @doc "Greets a person." + @spec greet(String.t()) :: String.t() + def greet(name), do: name +end +`; + const result = extractFromSource('lib/m.ex', code); + const greet = result.nodes.find((n) => n.name === 'greet'); + expect(greet?.signature).toBe('@spec greet(String.t()) :: String.t()'); + expect(greet?.docstring).toBe('Greets a person.'); + // @spec type expressions parse as calls — they must not leak call refs. + expect(calls(result)).not.toContain('String.t'); + expect(calls(result)).not.toContain('greet'); + }); + + it('should treat defmacro/defguard as functions and capture @moduledoc', () => { + const code = `defmodule M do + @moduledoc "A module." + defmacro mac(x), do: x + defguard is_even(n) when rem(n, 2) == 0 +end +`; + const result = extractFromSource('lib/m.ex', code); + const ns = result.nodes.find((n) => n.kind === 'namespace'); + expect(ns?.docstring).toBe('A module.'); + expect(result.nodes.find((n) => n.name === 'mac')?.kind).toBe('function'); + expect(result.nodes.find((n) => n.name === 'is_even')?.kind).toBe('function'); + }); + + it('should qualify nested modules with the full dotted name', () => { + const code = `defmodule Foo.Bar do + defmodule Nested do + def inner(y), do: y * 2 + end +end +`; + const result = extractFromSource('lib/foo_bar.ex', code); + const nested = result.nodes.find((n) => n.kind === 'namespace' && n.name === 'Foo.Bar.Nested'); + expect(nested).toBeDefined(); + const inner = result.nodes.find((n) => n.name === 'inner'); + expect(inner?.qualifiedName).toBe('Foo.Bar.Nested::inner'); + }); + }); + + describe('Call extraction', () => { + it('should emit remote calls as Module::fun, resolving aliases', () => { + const code = `defmodule M do + alias Foo.Baz + alias Foo.Qux, as: Q + alias Foo.{Alpha, Beta} + + def run(x) do + Foo.Sub.Mod.compute(x) + Baz.process(x) + Q.handle(x) + Alpha.a(x) + end +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + expect(c).toContain('Foo.Sub.Mod::compute'); + expect(c).toContain('Foo.Baz::process'); + expect(c).toContain('Foo.Qux::handle'); + expect(c).toContain('Foo.Alpha::a'); + }); + + it('should emit bare local calls and treat __MODULE__.fun as same-module', () => { + const code = `defmodule M do + def run(x) do + helper(x) + __MODULE__.greet("self") + end + def helper(x), do: x + def greet(s), do: s +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + expect(c).toContain('helper'); + expect(c).toContain('greet'); // __MODULE__.greet → bare name + expect(c).not.toContain('__MODULE__::greet'); + }); + + it('should link a piped remote call and record captures as references', () => { + const code = `defmodule M do + def run do + [1, 2, 3] + |> Enum.map(&private_helper/1) + |> MyMod.transform() + end + defp private_helper(x), do: x +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + expect(c).toContain('Enum::map'); + expect(c).toContain('MyMod::transform'); + expect(refsOf(result, 'references')).toContain('private_helper'); + }); + + it('should record a remote capture once, as a reference (no duplicate call edge)', () => { + const code = `defmodule M do + alias Foo.Baz + def run do + &Baz.process/2 + end +end +`; + const result = extractFromSource('lib/m.ex', code); + expect(refsOf(result, 'references')).toContain('Foo.Baz::process'); + expect(calls(result)).not.toContain('Foo.Baz::process'); + }); + + it('should stay silent on dynamic dispatch (variable receiver, apply, gen_server pid)', () => { + const code = `defmodule M do + alias Foo.Baz + def run(x) do + GenServer.call(x, :msg) + apply(Baz, :process, [x]) + mod = Baz + mod.process(x) + end +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + // A variable receiver has no static target — no edge to process at all. + expect(c).not.toContain('Foo.Baz::process'); + expect(c).not.toContain('process'); + // GenServer.call routes to a pid, not a local module handler. + expect(c.filter((n) => n.endsWith('::process'))).toHaveLength(0); + }); + }); + + describe('Structural edges', () => { + it('should model @behaviour and use as implements, and alias/import/require as imports', () => { + const code = `defmodule M do + @behaviour GenServer + use GenServer + import Enum, only: [map: 2] + alias Foo.Baz + require Logger +end +`; + const result = extractFromSource('lib/m.ex', code); + const impls = refsOf(result, 'implements'); + expect(impls.filter((n) => n === 'GenServer')).toHaveLength(2); // @behaviour + use + const imports = refsOf(result, 'imports'); + expect(imports).toContain('Enum'); + expect(imports).toContain('Foo.Baz'); + expect(imports).toContain('Logger'); + }); + + it('should extract defstruct fields on the module and %Struct{} as a reference', () => { + const code = `defmodule Foo.Bar do + defstruct name: nil, count: 0 + def make, do: %Foo.Bar{name: "n", count: 1} +end +`; + const result = extractFromSource('lib/foo_bar.ex', code); + const fields = result.nodes.filter((n) => n.kind === 'field').map((n) => n.name); + expect(fields).toContain('name'); + expect(fields).toContain('count'); + expect(refsOf(result, 'references')).toContain('Foo.Bar'); + }); + }); + + describe('Protocols', () => { + it('should extract defprotocol and defimpl as namespaces with implements', () => { + const code = `defprotocol Sizeable do + @doc "Returns size." + def size(data) +end + +defimpl 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 === 'namespace' && n.name === 'Sizeable'); + expect(proto).toBeDefined(); + const impl = result.nodes.find((n) => n.kind === 'namespace' && n.name === 'Sizeable.List'); + expect(impl).toBeDefined(); + expect(refsOf(result, 'implements')).toContain('Sizeable'); + const implSize = result.nodes.find((n) => n.qualifiedName === 'Sizeable.List::size'); + expect(implSize).toBeDefined(); + // one-liner do: body still contributes calls + expect(calls(result)).toContain('length'); + }); + }); + + describe('Scripts', () => { + it('should index .exs script modules', () => { + const code = `defmodule Mix.Tasks.Hello do + def run(_), do: IO.puts("hi") +end +`; + const result = extractFromSource('mix.exs', code); + expect(result.nodes.find((n) => n.kind === 'namespace')?.name).toBe('Mix.Tasks.Hello'); + expect(calls(result)).toContain('IO::puts'); + }); + }); +}); + describe('Terraform Extraction', () => { describe('Language detection', () => { it('should detect Terraform files', () => { diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index aa1bd72a7..c65f86e7d 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -173,6 +173,75 @@ describe('Resolution Module', () => { expect(matchReference(appRef('my_behaviour'), context)?.targetNodeId).toBe(behaviourModule.id); }); + it('should resolve Elixir bare calls same-file only, and implements refs to namespaces only', () => { + const mkNode = (partial: Partial & Pick): Node => ({ + language: 'elixir', + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + ...partial, + }); + // A cross-file `config/1` function that Mix's `config :app, …` DSL calls + // must NOT link to, and a same-file `helper/1` that a bare call must. + const crossFileConfig = mkNode({ + id: 'function:lib/consumer.ex:config:5', + kind: 'function', + name: 'config', + qualifiedName: 'MyApp.Consumer::config', + filePath: 'lib/consumer.ex', + }); + const sameFileHelper = mkNode({ + id: 'function:lib/worker.ex:helper:20', + kind: 'function', + name: 'helper', + qualifiedName: 'MyApp.Worker::helper', + filePath: 'lib/worker.ex', + }); + const behaviourModule = mkNode({ + id: 'namespace:lib/my_behaviour.ex:MyBehaviour:1', + kind: 'namespace', + name: 'MyBehaviour', + qualifiedName: 'MyBehaviour', + filePath: 'lib/my_behaviour.ex', + }); + const nodes = [crossFileConfig, sameFileHelper, behaviourModule]; + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: (name) => nodes.filter((n) => n.name === name), + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }; + const mkRef = (name: string, kind: 'calls' | 'references' | 'implements') => ({ + fromNodeId: 'function:lib/worker.ex:run:3', + referenceName: name, + referenceKind: kind, + line: 4, + column: 0, + filePath: 'lib/worker.ex', + language: 'elixir' as const, + }); + + // Bare call to a function that only exists in ANOTHER file: silence. + expect(matchReference(mkRef('config', 'calls'), context)).toBeNull(); + // Bare call to a same-file function resolves. + expect(matchReference(mkRef('helper', 'calls'), context)?.targetNodeId).toBe(sameFileHelper.id); + // Bare capture reference follows the same rule. + expect(matchReference(mkRef('helper', 'references'), context)?.targetNodeId).toBe(sameFileHelper.id); + expect(matchReference(mkRef('config', 'references'), context)).toBeNull(); + // `@behaviour`/`use` implements refs resolve only to module namespaces; + // an out-of-repo behaviour stays unresolved. + expect(matchReference(mkRef('MyBehaviour', 'implements'), context)?.targetNodeId).toBe(behaviourModule.id); + expect(matchReference(mkRef('GenServer', 'implements'), context)).toBeNull(); + }); + it('should prefer same-module candidates over cross-module matches', () => { // Simulates a Python monorepo where multiple apps define navigate() const candidateA: Node = { diff --git a/assets/languages/elixir.svg b/assets/languages/elixir.svg new file mode 100644 index 000000000..20d4f51ae --- /dev/null +++ b/assets/languages/elixir.svg @@ -0,0 +1,6 @@ + + Elixir + + + + diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index a26d232ae..0fc4ee9bd 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,10 @@ 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, tests, seeds). Prebuilt + // elixir-lang/tree-sitter-elixir grammar from tree-sitter-wasms (ABI 14). + '.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. @@ -590,6 +595,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..f70897ead --- /dev/null +++ b/src/extraction/languages/elixir.ts @@ -0,0 +1,510 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getNodeText, getChildByField } from '../tree-sitter-helpers'; +import type { LanguageExtractor, ExtractorContext } from '../tree-sitter-types'; + +// Node names follow the elixir-lang/tree-sitter-elixir grammar (ABI 14, the +// grammar shipped in tree-sitter-wasms). Elixir has no keywords — `defmodule`, +// `def`, `defp`, `use`, `import`, `@spec`, … are all MACROS, so every one of +// them parses as a `call` node (or a `@`-`unary_operator` for module +// attributes). Nothing fits the generic functionTypes/classTypes dispatch, so +// the whole language is driven from the visitNode hook below, keyed off the +// target identifier of each `call`. +// +// Verified empirically with scripts/add-lang/dump-ast.mjs (2026-07-11): +// - `defmodule Foo.Bar do … end` → call(target=identifier "defmodule", +// arguments→alias "Foo.Bar", do_block CHILD of the call) +// - `def name(args) do … end` → call(target=identifier "def", +// arguments→call(head: target=name, arguments=params), do_block child) +// - guard clause `def f(x) when g` → arguments→binary_operator("when") +// {left: head call, right: guard} +// - one-liner `def f(x), do: e` → no do_block; body is arguments→keywords +// →pair(key keyword "do:") +// - `@spec`/`@doc`/`@moduledoc`/`@behaviour` → unary_operator("@") +// {operand: call(target=identifier "spec"/"doc"/… )} +// - remote call `Mod.Sub.fun(x)` → call(target=dot{left: alias "Mod.Sub", +// right: identifier "fun"}) +// +// Remote calls are emitted (in the elixir branch of extractCall) as +// `Full.Module::fun`, byte-identical to the qualifiedName a module's functions +// carry, so cross-module resolution rides the standard qualified-name matcher — +// the same design the erlang extractor uses. + +const PUBLIC_DEFS = new Set(['def', 'defmacro', 'defguard', 'defdelegate']); +const PRIVATE_DEFS = new Set(['defp', 'defmacrop', 'defguardp']); +/** Every macro that introduces a named function. */ +const DEF_MACROS = new Set([...PUBLIC_DEFS, ...PRIVATE_DEFS]); + +/** + * Per-file clause-merge state. Elixir emits one `def` call PER CLAUSE (pattern + * matching / default args / guards), so consecutive same-name definitions in + * the same module are merged into a single function node — the endLine is + * extended and the extra clause's body attributed to the existing node. Keyed + * by (file, module node id, name): a same-named function in a different module + * must NOT merge. Extraction is file-sequential, so a single-entry memo is safe. + */ +let lastFnFile = ''; +let lastFnModuleId = ''; +let lastFnName = ''; +let lastFnId = ''; + +/** Nesting stack of enclosing module full dotted names (for nested defmodule). */ +let moduleNameStack: string[] = []; +let moduleStackFile = ''; + +function resetModuleStack(filePath: string): void { + if (moduleStackFile !== filePath) { + moduleStackFile = filePath; + moduleNameStack = []; + } +} + +function currentModulePrefix(): string { + return moduleNameStack.length ? moduleNameStack[moduleNameStack.length - 1]! : ''; +} + +function collapseWs(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +/** Text of a `keyword` key with surrounding space and trailing colon stripped + * (`do:` → `do`). The grammar can include trailing whitespace on the token. */ +function keywordKey(node: SyntaxNode, source: string): string { + return getNodeText(node, source).trim().replace(/:$/, ''); +} + +/** + * The `arguments` node of a call. In tree-sitter-elixir `arguments` is a child + * NODE TYPE, not a field, so it must be located by type (not childForFieldName). + */ +function argsOf(node: SyntaxNode): SyntaxNode | null { + for (const child of node.namedChildren) { + if (child.type === 'arguments') return child; + } + return null; +} + +/** First `alias` under a node's direct named children. */ +function firstAlias(node: SyntaxNode | null): SyntaxNode | null { + if (!node) return null; + for (const child of node.namedChildren) { + if (child.type === 'alias') return child; + } + return null; +} + +/** Look up a keyword value (`for:` → its value node) inside an arguments node. */ +function keywordValue(argsNode: SyntaxNode | null, key: string, source: string): SyntaxNode | null { + if (!argsNode) return null; + for (const child of argsNode.namedChildren) { + if (child.type !== 'keywords') continue; + for (const pair of child.namedChildren) { + if (pair.type !== 'pair') continue; + const k = getChildByField(pair, 'key'); + if (k && keywordKey(k, source) === key) return getChildByField(pair, 'value'); + } + } + return null; +} + +/** The `do_block` child of a def/defmodule call, if the block form is used. */ +function doBlockOf(node: SyntaxNode): SyntaxNode | null { + for (const child of node.namedChildren) { + if (child.type === 'do_block') return child; + } + return null; +} + +/** + * Unwrap the head `call` of a `def`: arguments[0] is either the head call + * directly, or a `binary_operator` ("when") whose `left` is the head call. + * Returns the head call (target=identifier function name, arguments=params). + */ +function defHead(node: SyntaxNode): SyntaxNode | null { + const args = argsOf(node); + const first = args?.namedChildren[0] ?? null; + if (!first) return null; + if (first.type === 'binary_operator') { + const left = getChildByField(first, 'left'); + return left && left.type === 'call' ? left : null; + } + if (first.type === 'call') return first; + // A zero-arg macro def head can be a bare identifier (`def hello, do: …`). + return null; +} + +/** Function name + head node for a def call (handles the bare-identifier head). */ +function defName(node: SyntaxNode, source: string): { name: string; head: SyntaxNode | null } | null { + const head = defHead(node); + if (head) { + const target = getChildByField(head, 'target'); + if (target?.type === 'identifier') return { name: getNodeText(target, source), head }; + return null; + } + // Bare head: `def hello do … end` / `def hello, do: …` — arguments[0] is an + // identifier (the name), or when there is a `when` guard on a zero-arg def. + const args = argsOf(node); + const first = args?.namedChildren[0] ?? null; + if (first?.type === 'identifier') return { name: getNodeText(first, source), head: null }; + if (first?.type === 'binary_operator') { + const left = getChildByField(first, 'left'); + if (left?.type === 'identifier') return { name: getNodeText(left, source), head: null }; + } + return null; +} + +/** + * Collect the `@spec` (signature) and `@doc` (docstring) directly preceding a + * def, skipping comments and other clauses. Mirrors erlang's precedingSpec. + */ +function precedingAttrs( + node: SyntaxNode, + source: string +): { signature?: string; docstring?: string } { + const out: { signature?: string; docstring?: string } = {}; + let prev = node.previousNamedSibling; + while (prev) { + if (prev.type === 'comment') { + prev = prev.previousNamedSibling; + continue; + } + if (prev.type === 'unary_operator') { + const attr = attributeCall(prev); + if (attr) { + const attrName = attributeName(attr, source); + if (attrName === 'spec' && out.signature === undefined) { + out.signature = collapseWs(getNodeText(prev, source)).slice(0, 300); + prev = prev.previousNamedSibling; + continue; + } + if (attrName === 'doc' && out.docstring === undefined) { + out.docstring = docContent(attr, source); + prev = prev.previousNamedSibling; + continue; + } + } + } + break; + } + return out; +} + +/** For a `@`-unary_operator, the inner `call` (`@spec name(...) :: ...`). */ +function attributeCall(unary: SyntaxNode): SyntaxNode | null { + const operand = getChildByField(unary, 'operand'); + return operand?.type === 'call' ? operand : null; +} + +/** The attribute name of an inner attribute call (`spec`/`doc`/`behaviour`). */ +function attributeName(call: SyntaxNode, source: string): string { + const target = getChildByField(call, 'target'); + return target?.type === 'identifier' ? getNodeText(target, source) : ''; +} + +/** The string literal content of a `@doc "..."` / `@moduledoc "..."`. */ +function docContent(call: SyntaxNode, source: string): string | undefined { + const args = argsOf(call); + const strNode = args?.namedChildren.find((c) => c.type === 'string'); + if (!strNode) return undefined; + const content = strNode.namedChildren.find((c) => c.type === 'quoted_content'); + const text = content ? getNodeText(content, source) : getNodeText(strNode, source); + return collapseWs(text) || undefined; +} + +/** `@moduledoc` docstring inside a module's do_block, if present. */ +function moduledocOf(doBlock: SyntaxNode | null, source: string): string | undefined { + if (!doBlock) return undefined; + for (const child of doBlock.namedChildren) { + if (child.type !== 'unary_operator') continue; + const call = attributeCall(child); + if (call && attributeName(call, source) === 'moduledoc') return docContent(call, source); + } + return undefined; +} + +function handleModule(node: SyntaxNode, ctx: ExtractorContext, localName: string): boolean { + const prefix = currentModulePrefix(); + const fullName = prefix ? `${prefix}.${localName}` : localName; + const doBlock = doBlockOf(node); + const ns = ctx.createNode('namespace', fullName, node, { + qualifiedName: fullName, + docstring: moduledocOf(doBlock, ctx.source), + }); + if (!ns) return true; + ctx.pushScope(ns.id); + moduleNameStack.push(fullName); + if (doBlock) { + for (const child of doBlock.namedChildren) ctx.visitNode(child); + } + moduleNameStack.pop(); + ctx.popScope(); + return true; +} + +function handleDefImpl(node: SyntaxNode, ctx: ExtractorContext): boolean { + const args = argsOf(node); + const protoAlias = firstAlias(args); + const forValue = keywordValue(args, 'for', ctx.source); + const proto = protoAlias ? getNodeText(protoAlias, ctx.source) : ''; + const forType = + forValue?.type === 'alias' + ? getNodeText(forValue, ctx.source) + : forValue + ? collapseWs(getNodeText(forValue, ctx.source)) + : ''; + const localName = forType ? `${proto}.${forType}` : proto; + // `defimpl Proto, for: Type` implements Proto's contract — emit the + // implements ref, then treat the block like a normal module named Proto.Type. + const prefix = currentModulePrefix(); + const fullName = prefix ? `${prefix}.${localName}` : localName; + const doBlock = doBlockOf(node); + const ns = ctx.createNode('namespace', fullName, node, { qualifiedName: fullName }); + if (!ns) return true; + if (proto) { + ctx.addUnresolvedReference({ + fromNodeId: ns.id, + referenceName: proto, + referenceKind: 'implements', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + ctx.pushScope(ns.id); + moduleNameStack.push(fullName); + if (doBlock) { + for (const child of doBlock.namedChildren) ctx.visitNode(child); + } + moduleNameStack.pop(); + ctx.popScope(); + return true; +} + +function handleDef(node: SyntaxNode, ctx: ExtractorContext, macro: string): boolean { + const named = defName(node, ctx.source); + if (!named) return true; + const { name, head } = named; + const moduleId = ctx.nodeStack[ctx.nodeStack.length - 1] ?? ''; + const fullMod = currentModulePrefix(); + + // Continuation clause of the same function in the same module — merge. + if ( + ctx.filePath === lastFnFile && + moduleId === lastFnModuleId && + name === lastFnName && + lastFnId + ) { + for (let i = ctx.nodes.length - 1; i >= 0; i--) { + const n = ctx.nodes[i]; + if (n && n.id === lastFnId) { + if (node.endPosition.row + 1 > n.endLine) n.endLine = node.endPosition.row + 1; + break; + } + } + ctx.pushScope(lastFnId); + visitDefBodies(node, head, lastFnId, ctx); + ctx.popScope(); + return true; + } + + const attrs = precedingAttrs(node, ctx.source); + const signature = + attrs.signature ?? (head ? collapseWs(getNodeText(head, ctx.source)).slice(0, 300) : name); + const fn = ctx.createNode('function', name, node, { + qualifiedName: fullMod ? `${fullMod}::${name}` : name, + signature, + docstring: attrs.docstring, + isExported: PUBLIC_DEFS.has(macro), + visibility: PRIVATE_DEFS.has(macro) ? 'private' : 'public', + }); + if (!fn) return true; + ctx.pushScope(fn.id); + visitDefBodies(node, head, fn.id, ctx); + ctx.popScope(); + lastFnFile = ctx.filePath; + lastFnModuleId = moduleId; + lastFnName = name; + lastFnId = fn.id; + return true; +} + +/** + * Walk a def's body/bodies for calls WITHOUT walking the head (which would emit + * a spurious self-call to the function's own name): the do_block child and, for + * the one-liner form, the `do:` keyword value. + */ +function visitDefBodies( + node: SyntaxNode, + _head: SyntaxNode | null, + fnId: string, + ctx: ExtractorContext +): void { + const doBlock = doBlockOf(node); + if (doBlock) ctx.visitFunctionBody(doBlock, fnId); + const doValue = keywordValue(argsOf(node), 'do', ctx.source); + if (doValue) ctx.visitFunctionBody(doValue, fnId); +} + +function handleDefstruct(node: SyntaxNode, ctx: ExtractorContext): boolean { + // Fields belong to the enclosing module (the struct IS the module). Two + // shapes: keyword form `defstruct name: nil, count: 0` and atom-list form + // `defstruct [:name, :count]`. + const args = argsOf(node); + if (!args) return true; + const addField = (fieldName: string, pos: SyntaxNode): void => { + if (!fieldName) return; + const fullMod = currentModulePrefix(); + ctx.createNode('field', fieldName, pos, { + qualifiedName: fullMod ? `${fullMod}::${fieldName}` : fieldName, + }); + }; + for (const child of args.namedChildren) { + if (child.type === 'keywords') { + for (const pair of child.namedChildren) { + if (pair.type !== 'pair') continue; + const key = getChildByField(pair, 'key'); + if (key) addField(keywordKey(key, ctx.source), pair); + } + } else if (child.type === 'list') { + for (const item of child.namedChildren) { + if (item.type === 'atom') { + addField(getNodeText(item, ctx.source).replace(/^:/, ''), item); + } else if (item.type === 'keywords') { + for (const pair of item.namedChildren) { + if (pair.type !== 'pair') continue; + const key = getChildByField(pair, 'key'); + if (key) addField(keywordKey(key, ctx.source), pair); + } + } + } + } + } + return true; +} + +/** `use`/`import`/`require`/`alias` — structural edges (D7). */ +function handleModuleDirective(node: SyntaxNode, ctx: ExtractorContext, macro: string): boolean { + const parentId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (!parentId) return true; + const args = argsOf(node); + const line = node.startPosition.row + 1; + const column = node.startPosition.column; + + // `use Mod` injects a contract — model as `implements` (closest existing + // semantics; resolves to the module namespace via the module-only rule). + if (macro === 'use') { + const aliasNode = firstAlias(args); + if (aliasNode) { + ctx.addUnresolvedReference({ + fromNodeId: parentId, + referenceName: getNodeText(aliasNode, ctx.source), + referenceKind: 'implements', + line, + column, + }); + } + return true; + } + + // import / require / alias → file-level import edge to the module. Handles + // grouped `alias Foo.{Alpha, Beta}` (dot{left: alias prefix, right: tuple}). + const emit = (moduleName: string): void => { + if (!moduleName) return; + ctx.addUnresolvedReference({ + fromNodeId: parentId, + referenceName: moduleName, + referenceKind: 'imports', + line, + column, + }); + }; + if (args) { + for (const child of args.namedChildren) { + if (child.type === 'alias') { + emit(getNodeText(child, ctx.source)); + } else if (child.type === 'dot') { + // grouped alias: Foo.{Alpha, Beta} + const left = getChildByField(child, 'left'); + const right = getChildByField(child, 'right'); + const prefix = left?.type === 'alias' ? getNodeText(left, ctx.source) : ''; + if (right?.type === 'tuple' && prefix) { + for (const member of right.namedChildren) { + if (member.type === 'alias') emit(`${prefix}.${getNodeText(member, ctx.source)}`); + } + } + } + } + } + return true; +} + +/** `@behaviour Mod` → implements ref; every other `@attr` is consumed silently. */ +function handleAttribute(node: SyntaxNode, ctx: ExtractorContext): boolean { + const call = attributeCall(node); + if (!call) return true; // `@x 5` custom attribute — consume, don't descend + const name = attributeName(call, ctx.source); + if (name === 'behaviour' || name === 'behavior') { + const parentId = ctx.nodeStack[ctx.nodeStack.length - 1]; + const aliasNode = firstAlias(argsOf(call)); + if (parentId && aliasNode) { + ctx.addUnresolvedReference({ + fromNodeId: parentId, + referenceName: getNodeText(aliasNode, ctx.source), + referenceKind: 'implements', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + // @doc/@spec are consumed here (picked up by precedingAttrs on the next def); + // @moduledoc by moduledocOf. Consuming prevents type-position exprs in @spec + // (`String.t()` parses as a call) from minting bogus call refs (D3). + return true; +} + +export const elixirExtractor: LanguageExtractor = { + functionTypes: [], + classTypes: [], + methodTypes: [], + interfaceTypes: [], + structTypes: [], + enumTypes: [], + typeAliasTypes: [], + importTypes: [], + // Real call sites (remote/local calls, `&` captures, `%Struct{}` literals) + // are dispatched to the elixir branch of extractCall; declaration macros are + // intercepted by visitNode below (returning true) before they reach it. + callTypes: ['call', 'unary_operator', 'map'], + variableTypes: [], + nameField: 'target', + bodyField: 'do_block', + paramsField: 'arguments', + + visitNode: (node, ctx) => { + resetModuleStack(ctx.filePath); + + if (node.type === 'unary_operator') { + // `@attr …` module attribute (operator text starts with '@'). + if (getNodeText(node, ctx.source).startsWith('@')) { + return handleAttribute(node, ctx); + } + return false; // `&capture/1`, negation, etc. → extractCall / generic walk + } + + if (node.type !== 'call') return false; + const target = getChildByField(node, 'target'); + if (target?.type !== 'identifier') return false; // remote call (dot) → extractCall + + const macro = getNodeText(target, ctx.source); + if (macro === 'defmodule' || macro === 'defprotocol') { + const aliasNode = firstAlias(argsOf(node)); + if (!aliasNode) return true; + return handleModule(node, ctx, getNodeText(aliasNode, ctx.source)); + } + if (macro === 'defimpl') return handleDefImpl(node, ctx); + if (DEF_MACROS.has(macro)) return handleDef(node, ctx, macro); + if (macro === 'defstruct' || macro === 'defexception') return handleDefstruct(node, ctx); + if (macro === 'use' || macro === 'import' || macro === 'require' || macro === 'alias') { + return handleModuleDirective(node, ctx, macro); + } + return false; // ordinary local call → extractCall + }, +}; 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/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index fe3664ae8..9724567c8 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -83,6 +83,27 @@ const ERLANG_MFA_CALLS = new Set([ 'erpc:call', 'erpc:cast', ]); +/** + * Elixir declaration macros — intercepted by the elixir visitNode hook, so they + * should never reach extractCall as a bare local call. Used only as a defensive + * guard against a shape the hook might not have consumed. + */ +const ELIXIR_DECL_MACROS = new Set([ + 'defmodule', 'defprotocol', 'defimpl', 'def', 'defp', 'defmacro', 'defmacrop', + 'defguard', 'defguardp', 'defdelegate', 'defstruct', 'defexception', + 'use', 'import', 'require', 'alias', +]); + +/** + * Elixir special forms that parse as a bare `call` but are never a + * user-defined function — emitting a `calls` ref for them is pure noise (they + * resolve to nothing, and the identifiers are reserved so they can't collide + * with a real function either). + */ +const ELIXIR_SPECIAL_FORMS = new Set([ + 'quote', 'unquote', 'unquote_splicing', 'super', 'receive', +]); + /** * Extract the name from a node based on language */ @@ -3577,6 +3598,99 @@ export class TreeSitterExtractor { private erlangSelfMacros = new Set(); private erlangAtomMacros = new Map(); + // Elixir per-module alias table (`alias Foo.Bar` → Bar ⇒ Foo.Bar), memoized + // by the enclosing module node so `Bar.fun(x)` expands to `Foo.Bar::fun`. + // Single-entry: extraction is sequential and a module's calls are contiguous. + private elixirAliasModuleKey = ''; + private elixirAliasMap = new Map(); + + /** + * Expand an Elixir module reference (`Bar`, `Bar.Deep`, `Foo.Sub.Mod`) to its + * full dotted name using the enclosing module's `alias` declarations. Only the + * FIRST segment is an alias key; the rest is appended verbatim. A reference + * whose head isn't aliased is already fully qualified and returned as-is. + */ + private resolveElixirModule(callNode: SyntaxNode, modText: string): string { + // Find the enclosing module (defmodule/defprotocol/defimpl) call. + let module: SyntaxNode | null = callNode.parent; + while (module) { + if (module.type === 'call') { + const t = getChildByField(module, 'target'); + if (t?.type === 'identifier') { + const name = getNodeText(t, this.source); + if (name === 'defmodule' || name === 'defprotocol' || name === 'defimpl') break; + } + } + module = module.parent; + } + const key = module ? `${this.filePath}:${module.startIndex}` : this.filePath; + if (this.elixirAliasModuleKey !== key) { + this.elixirAliasModuleKey = key; + this.elixirAliasMap = new Map(); + const doBlock = module + ? module.namedChildren.find((c) => c.type === 'do_block') + : null; + if (doBlock) { + for (const stmt of doBlock.namedChildren) { + if (stmt.type !== 'call') continue; + const t = getChildByField(stmt, 'target'); + if (t?.type !== 'identifier' || getNodeText(t, this.source) !== 'alias') continue; + // `arguments` is a child node TYPE in tree-sitter-elixir, not a field. + const args = stmt.namedChildren.find((c) => c.type === 'arguments'); + if (!args) continue; + const aliasNode = args.namedChildren.find((c) => c.type === 'alias'); + const asValue = this.elixirKeywordValue(args, 'as'); + if (aliasNode) { + const full = getNodeText(aliasNode, this.source); + if (asValue?.type === 'alias') { + this.elixirAliasMap.set(getNodeText(asValue, this.source), full); + } else { + const last = full.split('.').pop()!; + this.elixirAliasMap.set(last, full); + } + } + // grouped: alias Foo.{Alpha, Beta} + const dot = args.namedChildren.find((c) => c.type === 'dot'); + if (dot) { + const left = getChildByField(dot, 'left'); + const right = getChildByField(dot, 'right'); + const prefix = left?.type === 'alias' ? getNodeText(left, this.source) : ''; + if (right?.type === 'tuple' && prefix) { + for (const member of right.namedChildren) { + if (member.type === 'alias') { + const seg = getNodeText(member, this.source); + this.elixirAliasMap.set(seg, `${prefix}.${seg}`); + } + } + } + } + } + } + } + const segments = modText.split('.'); + const head = segments[0]!; + const mapped = this.elixirAliasMap.get(head); + if (mapped) { + return segments.length > 1 ? `${mapped}.${segments.slice(1).join('.')}` : mapped; + } + return modText; + } + + /** Look up a keyword value inside an Elixir `arguments` node (`as:` → value). */ + private elixirKeywordValue(argsNode: SyntaxNode, key: string): SyntaxNode | null { + for (const child of argsNode.namedChildren) { + if (child.type !== 'keywords') continue; + for (const pair of child.namedChildren) { + if (pair.type !== 'pair') continue; + const k = getChildByField(pair, 'key'); + if (k && getNodeText(k, this.source).trim().replace(/:$/, '') === key) { + return getChildByField(pair, 'value'); + } + } + } + return null; + } + private resolveErlangGenServerTarget(target: SyntaxNode): string | null { const ownModule = (this.filePath.split('/').pop() ?? '').replace(/\.erl$/, ''); if (target.type === 'atom') { @@ -3840,6 +3954,130 @@ export class TreeSitterExtractor { return; } + // Elixir: every construct is a `call`, but the DECLARATION macros + // (defmodule/def/use/…) are intercepted by the elixir visitNode hook and + // never reach here — so a `call` that arrives is a real invocation. Shapes: + // - remote `Mod.Sub.fun(x)` → call(target: dot{left: alias, right: id}). + // Emitted as `Full.Module::fun` (alias-expanded), byte-identical to the + // qualifiedName the module's functions carry, so it resolves via + // matchByQualifiedName — the erlang design. A dot whose `left` is an + // identifier is `__MODULE__.fun` (same-file bare name) or a variable + // receiver (`mod.fun` / `apply/3`) which has no static target → silence. + // - local `fun(x)` → call(target: identifier) → bare name (same-file pref). + // - `&Mod.fun/2` / `&local/1` capture → unary_operator → `references`. + // - `%Foo.Bar{…}` struct literal → map > struct > alias → `references`. + if (this.language === 'elixir') { + const line = node.startPosition.row + 1; + const column = node.startPosition.column; + if (node.type === 'call') { + // Skip a call that is the function part of an `&Mod.fun/arity` capture + // (`unary_operator & → binary_operator / → left: call`): the capture is a + // `references` edge emitted by the unary_operator branch, not a `calls`. + const cap = node.parent; + if ( + cap?.type === 'binary_operator' && + cap.parent?.type === 'unary_operator' && + getNodeText(cap.parent, this.source).startsWith('&') + ) { + return; + } + const target = getChildByField(node, 'target'); + if (!target) return; + if (target.type === 'dot') { + const left = getChildByField(target, 'left'); + const right = getChildByField(target, 'right'); + if (right?.type !== 'identifier') return; + const fn = getNodeText(right, this.source); + if (left?.type === 'alias') { + const fullMod = this.resolveElixirModule(node, getNodeText(left, this.source)); + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: `${fullMod}::${fn}`, + referenceKind: 'calls', + line, + column, + }); + } else if (left?.type === 'identifier' && getNodeText(left, this.source) === '__MODULE__') { + // `__MODULE__.fun(...)` targets THIS module — bare name, same-file + // preference resolves it (like `?MODULE:fn` in erlang). + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: fn, + referenceKind: 'calls', + line, + column, + }); + } + // else: variable receiver (`mod.fun`, `apply/3`) → no static target. + return; + } + if (target.type === 'identifier') { + // Bare local call `fun(x)`. Declaration macros are consumed by the + // hook, but guard defensively in case one slips through. + const name = getNodeText(target, this.source); + if (ELIXIR_DECL_MACROS.has(name) || ELIXIR_SPECIAL_FORMS.has(name)) return; + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: name, + referenceKind: 'calls', + line, + column, + }); + } + return; + } + if (node.type === 'unary_operator') { + // `&Mod.fun/arity` / `&local/arity` capture. operand is `/` + // binary_operator; `&1`-style arg captures (operand: integer) are NOT. + const operand = getChildByField(node, 'operand'); + if (operand?.type !== 'binary_operator') return; + const capLeft = getChildByField(operand, 'left'); + if (capLeft?.type === 'call') { + const t = getChildByField(capLeft, 'target'); + if (t?.type === 'dot') { + const l = getChildByField(t, 'left'); + const r = getChildByField(t, 'right'); + if (l?.type === 'alias' && r?.type === 'identifier') { + const fullMod = this.resolveElixirModule(node, getNodeText(l, this.source)); + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: `${fullMod}::${getNodeText(r, this.source)}`, + referenceKind: 'references', + line, + column, + }); + } + } + } else if (capLeft?.type === 'identifier') { + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: getNodeText(capLeft, this.source), + referenceKind: 'references', + line, + column, + }); + } + return; + } + if (node.type === 'map') { + // `%Foo.Bar{…}` struct literal: map > struct > alias. + const structChild = node.namedChildren.find((c) => c.type === 'struct'); + const aliasNode = structChild?.namedChildren.find((c) => c.type === 'alias'); + if (aliasNode) { + const fullMod = this.resolveElixirModule(node, getNodeText(aliasNode, this.source)); + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: fullMod, + referenceKind: 'references', + line, + column, + }); + } + return; + } + return; + } + // Ruby `call` nodes use `receiver` + `method` fields (tree-sitter-ruby), not // the `object`/`name`/`function` fields the branches below expect — so // without this they fell through to the generic path, which took the diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 9e77d630f..7a5fa8da3 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1959,13 +1959,53 @@ export function matchReference( // ref an `.app`/`.app.src` resource file emits — its `{mod, …}` callback and // `{applications, …}` dependency names can only mean modules, and on emqx // the `ssl` OTP app otherwise resolved to a test helper FUNCTION named ssl. + // Elixir bare (unqualified, lowercase-initial) call/reference names — local + // calls, `__MODULE__.fun`, `&local/arity` captures. In Elixir a bare call can + // only legitimately target the same module — which lives in the same file — + // or an `import`ed function (deliberately unexpanded in v1: a wrong edge is + // worse than none). Letting these fall through to bare-name matching grabbed + // arbitrary same-named functions across the repo: on a Phoenix app, every + // Mix `config :app, …` DSL call resolved to some module's `config/0`, and + // every ConnTest `get(conn, path)` to an unrelated HTTP client's `get`. + // Resolve same-file only; no same-file match → stay unresolved. Function + // names in Elixir always start with a lowercase letter or underscore, so + // module references (`Foo`, `Foo.Bar` — capitalized) are unaffected. if ( - ref.language === 'erlang' && - (ref.referenceKind === 'implements' || /\.app(?:\.src)?$/i.test(ref.filePath)) + ref.language === 'elixir' && + (ref.referenceKind === 'calls' || ref.referenceKind === 'references') && + !ref.referenceName.includes('::') && + /^[a-z_]/.test(ref.referenceName) + ) { + const sameFile = context + .getNodesByName(ref.referenceName) + .filter( + (n) => + n.language === 'elixir' && + n.kind === 'function' && + n.filePath === ref.filePath + ); + const chosen = sameFile[0]; + if (!chosen) return null; + return { + original: ref, + targetNodeId: chosen.id, + confidence: 0.9, + resolvedBy: 'exact-match', + }; + } + + // Elixir `@behaviour Mod` / `use Mod` implements refs also target a MODULE + // (namespace). Same rationale as erlang: bare-name matching would grab any + // same-named symbol, and an out-of-repo behaviour (GenServer, …) must stay + // unresolved rather than guessed. + if ( + (ref.language === 'erlang' && + (ref.referenceKind === 'implements' || /\.app(?:\.src)?$/i.test(ref.filePath))) || + (ref.language === 'elixir' && ref.referenceKind === 'implements') ) { const modules = context .getNodesByName(ref.referenceName) - .filter((n) => n.language === 'erlang' && n.kind === 'namespace'); + .filter((n) => n.language === ref.language && n.kind === 'namespace'); const chosen = preferCallSiteFile(modules, ref.filePath)[0]; if (!chosen) return null; return { diff --git a/src/types.ts b/src/types.ts index 6f3de63db..83476eec1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -104,6 +104,7 @@ export const LANGUAGES = [ 'cobol', 'vbnet', 'erlang', + 'elixir', 'terraform', 'unknown', ] as const; From f020d5a3a8ca0cdbc7ddd5e4b78211f16d577692 Mon Sep 17 00:00:00 2001 From: Roman Wolan Date: Sun, 12 Jul 2026 09:55:56 +0200 Subject: [PATCH 2/6] fix(elixir): emit calls in custom module-attribute values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleAttribute consumed every @-attribute subtree so a compile-time call in a custom attribute value (`@id Product.get_product_name(:x)`) never produced a `calls` edge — the sole recall miss across the 6-repo validation battery. Split attributes into two classes: reserved/META attributes (@spec, @doc, @type, @behaviour, @derive, @enforce_keys, … — the full reserved set from the official Module docs) keep being consumed, so type-position expressions like `String.t()` in @spec still cannot mint bogus call edges (D3). Every other attribute is a custom attribute whose value is real compile-time code: its value subtree is now walked, so a remote or local call in the value emits a normal `calls` edge attributed to the enclosing module namespace (attributes live at module level). The inner call's target — the attribute name itself — is never treated as a call. Revalidated on crm-proxy: the @calltracker_product_id miss now resolves to Product::get_product_name; calls edges 2613 → 2640 (+27, no explosion); all 20 attribute-driven edges verified correct. Co-Authored-By: Claude --- __tests__/extraction.test.ts | 47 ++++++++++++++++++++ src/extraction/languages/elixir.ts | 70 +++++++++++++++++++++++++++--- 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 891a1e76c..561fa114b 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -10519,6 +10519,53 @@ end expect(calls(result)).not.toContain('Foo.Baz::process'); }); + it('should emit calls in custom module-attribute values, attributed to the module', () => { + const code = `defmodule M do + @product_id Product.get_product_name("calltracker", :calltracker_api) + @nested Foo.bar(Baz.qux(1)) + def run, do: @product_id +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + // Compile-time remote call in a custom attribute value → normal calls edge. + expect(c).toContain('Product::get_product_name'); + // Nested calls inside the value are walked too. + expect(c).toContain('Foo::bar'); + expect(c).toContain('Baz::qux'); + // The attribute NAME itself is never a call target. + expect(c).not.toContain('product_id'); + expect(c).not.toContain('nested'); + // The call originates from the module namespace (attributes live at + // module level), not from any function. + const ns = result.nodes.find((n) => n.kind === 'namespace' && n.name === 'M'); + const call = result.unresolvedReferences.find( + (u) => u.referenceKind === 'calls' && u.referenceName === 'Product::get_product_name' + ); + expect(call?.fromNodeId).toBe(ns?.id); + }); + + it('should NOT emit calls from @spec/@type type positions or @enforce_keys', () => { + const code = `defmodule M do + @enforce_keys [:a, :b] + defstruct [:a, :b] + @type t :: String.t() + @spec build(String.t()) :: t() + def build(s), do: s +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + // Type-position expressions in @spec/@type must not leak call refs (D3). + expect(c).not.toContain('String.t'); + expect(c).not.toContain('String::t'); + expect(c).not.toContain('t'); + expect(c).not.toContain('build'); + // @enforce_keys value (an atom list) produces no call and no noise. + expect(c).not.toContain('enforce_keys'); + expect(c).toHaveLength(0); + }); + it('should stay silent on dynamic dispatch (variable receiver, apply, gen_server pid)', () => { const code = `defmodule M do alias Foo.Baz diff --git a/src/extraction/languages/elixir.ts b/src/extraction/languages/elixir.ts index f70897ead..8efc7248e 100644 --- a/src/extraction/languages/elixir.ts +++ b/src/extraction/languages/elixir.ts @@ -31,6 +31,47 @@ import type { LanguageExtractor, ExtractorContext } from '../tree-sitter-types'; const PUBLIC_DEFS = new Set(['def', 'defmacro', 'defguard', 'defdelegate']); const PRIVATE_DEFS = new Set(['defp', 'defmacrop', 'defguardp']); + +/** + * Reserved/built-in module attributes whose VALUES are type positions, docs, or + * compiler metadata — NOT runtime code. Their subtree is consumed (not walked) + * because a type expression like `String.t()` in `@spec` parses as a `call` and + * would otherwise mint a bogus `calls` edge (D3). List sourced from the official + * Module docs (https://hexdocs.pm/elixir/Module.html, verified 2026-07-12) plus + * the American spelling `behavior` and the Mix `@shortdoc` convention. + * Every attribute OUTSIDE this set is a custom attribute: its value is real + * compile-time code, so we descend into it to emit normal `calls` edges. + */ +const META_ATTRIBUTES = new Set([ + 'spec', + 'doc', + 'moduledoc', + 'typedoc', + 'shortdoc', + 'behaviour', + 'behavior', + 'type', + 'typep', + 'opaque', + 'callback', + 'macrocallback', + 'optional_callbacks', + 'impl', + 'derive', + 'enforce_keys', + 'deprecated', + 'dialyzer', + 'external_resource', + 'compile', + 'before_compile', + 'after_compile', + 'after_verify', + 'on_definition', + 'on_load', + 'nifs', + 'vsn', + 'file', +]); /** Every macro that introduces a named function. */ const DEF_MACROS = new Set([...PUBLIC_DEFS, ...PRIVATE_DEFS]); @@ -436,10 +477,15 @@ function handleModuleDirective(node: SyntaxNode, ctx: ExtractorContext, macro: s return true; } -/** `@behaviour Mod` → implements ref; every other `@attr` is consumed silently. */ +/** + * `@behaviour Mod` → implements ref; other META attributes (@spec/@doc/…) are + * consumed silently; CUSTOM attributes have their VALUE walked so a compile-time + * call in the value (`@id Product.get_product_name(:x)`) emits a normal `calls` + * edge attributed to the enclosing module (the current scope). + */ function handleAttribute(node: SyntaxNode, ctx: ExtractorContext): boolean { const call = attributeCall(node); - if (!call) return true; // `@x 5` custom attribute — consume, don't descend + if (!call) return true; // no inner call (e.g. bare `@x`) — nothing to walk const name = attributeName(call, ctx.source); if (name === 'behaviour' || name === 'behavior') { const parentId = ctx.nodeStack[ctx.nodeStack.length - 1]; @@ -453,10 +499,24 @@ function handleAttribute(node: SyntaxNode, ctx: ExtractorContext): boolean { column: node.startPosition.column, }); } + return true; + } + if (META_ATTRIBUTES.has(name)) { + // @doc/@spec are consumed here (picked up by precedingAttrs on the next def); + // @moduledoc by moduledocOf. Consuming prevents type-position exprs in @spec + // (`String.t()` parses as a call) from minting bogus call refs (D3). + return true; + } + // Custom attribute: its value is real compile-time code. Walk the value + // subtree (the inner call's arguments) — NOT the inner call's target, which is + // just the attribute NAME (`x` in `@x value`) and must never become a `calls` + // edge. Descending lets `Mod.fun(...)` in the value resolve normally; the + // enclosing module namespace is the current scope, so the edge originates + // there (attributes live at module level, not inside any function). + const args = argsOf(call); + if (args) { + for (const child of args.namedChildren) ctx.visitNode(child); } - // @doc/@spec are consumed here (picked up by precedingAttrs on the next def); - // @moduledoc by moduledocOf. Consuming prevents type-position exprs in @spec - // (`String.t()` parses as a call) from minting bogus call refs (D3). return true; } From f3fe4c8e7e3729c357ec34696ddf5eeef2321de5 Mon Sep 17 00:00:00 2001 From: Roman Wolan Date: Sun, 12 Jul 2026 15:22:49 +0200 Subject: [PATCH 3/6] docs(elixir): add measured fair-coverage row to README elixir-ecto/ecto 52/56 = 92.9%, methodology calibrated by reproducing the published requests (100.0%) and ripgrep (86.8%) rows with the same build. Residual (4 files) is genuine frontier: OTP application entry, conditional protocol impls, and the Ecto.Query.API/WindowAPI query-DSL pseudo-modules. Co-Authored-By: Claude --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 01dd195e4..4127bb2dc 100644 --- a/README.md +++ b/README.md @@ -856,6 +856,7 @@ Impact and blast-radius queries are only as good as the dependency graph behind | C# | jbogard/MediatR | 85.2% | | PHP | guzzle/guzzle | 100% | | Ruby | sidekiq/sidekiq | 100% | +| Elixir | elixir-ecto/ecto | 92.9% | | C | redis/redis | 92.2% | | C++ | google/leveldb | 94.8% | | Objective-C | SDWebImage | 91.6% | From 8e01301cf7902e720e2c5e0a0316cc3b54de4b8a Mon Sep 17 00:00:00 2001 From: Roman Wolan Date: Sun, 12 Jul 2026 21:30:34 +0200 Subject: [PATCH 4/6] fix(elixir): close extraction gaps found in review (guards, __MODULE__ struct/captures, one-line modules, local aliases, macro noise) Found in two code reviews (GLM + Cursor). Verified extraction gaps, each reproduced by a failing test before the fix: - guards: emit calls in defguard bodies and def ... when guards (the when binary_operator right side was never walked) - %__MODULE__{} struct literals now emit a reference to the enclosing module - &__MODULE__.fun/arity captures now emit a same-module reference - defoverridable no longer leaks as a call ref - Kernel control-flow/process macros (if/unless/case/cond/with/for/raise/try/ send/spawn/self/throw/exit) no longer leak as unresolvable call noise - defstruct default-value calls (ts: DateTime.utc_now()) are now emitted; field names never become call edges - one-line modules (defmodule M, do: ...) now index their body - alias declared inside a function body now resolves module-wide; alias-table memo changed to a per-(file,module) map to avoid nested-module re-walk - dynamically-named defmodule (e.g. defmodule unquote(name) do ...) no longer silently swallows its body: inner defs are now indexed like top-level defs, with no fabricated namespace. A literal Module.concat(A, B) / [A, B] name is statically recovered as A.B and treated as a normal module (F6) Co-Authored-By: Claude --- __tests__/extraction.test.ts | 186 +++++++++++++++++++++++++++++ src/extraction/languages/elixir.ts | 107 +++++++++++++++-- src/extraction/tree-sitter.ts | 180 +++++++++++++++++++++------- 3 files changed, 417 insertions(+), 56 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 561fa114b..b023f8e8b 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -10447,6 +10447,49 @@ end const inner = result.nodes.find((n) => n.name === 'inner'); expect(inner?.qualifiedName).toBe('Foo.Bar.Nested::inner'); }); + + it('should still index defs inside a dynamically-named defmodule (no fake namespace)', () => { + const code = `defmodule unquote(name) do + def handle(e), do: Helper.work(e) +end +`; + const result = extractFromSource('lib/dyn.ex', code); + // No static module name is inventable, so the def is indexed like a + // top-level def (bare qualifiedName) — NOT dropped, NOT under a fabricated + // namespace (F6). + const handle = result.nodes.find((n) => n.kind === 'function' && n.name === 'handle'); + expect(handle).toBeDefined(); + expect(handle?.qualifiedName).toBe('handle'); + // The body call inside the def is still emitted, attributed to the def. + expect(calls(result)).toContain('Helper::work'); + // No bogus namespace node minted for the un-recoverable dynamic name. + expect(result.nodes.find((n) => n.kind === 'namespace')).toBeUndefined(); + }); + + it('should statically recover Module.concat(A, B) as a normal A.B module', () => { + const code = `defmodule Module.concat(Foo, Bar) do + def handle(e), do: Helper.work(e) +end +`; + const result = extractFromSource('lib/concat.ex', code); + const ns = result.nodes.find((n) => n.kind === 'namespace'); + expect(ns?.name).toBe('Foo.Bar'); + const handle = result.nodes.find((n) => n.kind === 'function' && n.name === 'handle'); + expect(handle?.qualifiedName).toBe('Foo.Bar::handle'); + expect(calls(result)).toContain('Helper::work'); + }); + + it('should statically recover Module.concat([A, B]) (list form) as A.B', () => { + const code = `defmodule Module.concat([Foo, Bar]) do + def handle(e), do: Helper.work(e) +end +`; + const result = extractFromSource('lib/concat_list.ex', code); + const ns = result.nodes.find((n) => n.kind === 'namespace'); + expect(ns?.name).toBe('Foo.Bar'); + const handle = result.nodes.find((n) => n.kind === 'function' && n.name === 'handle'); + expect(handle?.qualifiedName).toBe('Foo.Bar::handle'); + }); }); describe('Call extraction', () => { @@ -10655,6 +10698,149 @@ end expect(calls(result)).toContain('IO::puts'); }); }); + + // Gaps found in code review (GLM + Cursor), fixed together. + describe('Review-found extraction gaps', () => { + it('should emit calls inside guard expressions (defguard body and def ... when)', () => { + const code = `defmodule M do + defguard ok(x) when local_guard(x) + def run(x) when allowed?(x), do: work(x) + def check(x) when MyValidator.valid?(x), do: x +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + // defguard body is a \`when\` binary_operator in arguments, not a do_block. + expect(c).toContain('local_guard'); + // \`def ... when guard, do: body\` — both the guard and the body have calls. + expect(c).toContain('allowed?'); + expect(c).toContain('work'); + // Remote call in a guard resolves as Module::fun. + expect(c).toContain('MyValidator::valid?'); + // The def head names must never leak as self-calls. + expect(c).not.toContain('ok'); + expect(c).not.toContain('run'); + expect(c).not.toContain('check'); + }); + + it('should emit %__MODULE__{} as a reference to the enclosing module', () => { + const code = `defmodule Foo.Bar do + def new(attrs), do: %__MODULE__{a: attrs} +end +`; + const result = extractFromSource('lib/foo_bar.ex', code); + // Struct analog of __MODULE__.fun(): reference the current module by name. + expect(refsOf(result, 'references')).toContain('Foo.Bar'); + }); + + it('should resolve %__MODULE__{} inside a nested module to the nested name', () => { + const code = `defmodule Foo.Bar do + defmodule Nested do + def new, do: %__MODULE__{a: 1} + end +end +`; + const result = extractFromSource('lib/foo_bar.ex', code); + expect(refsOf(result, 'references')).toContain('Foo.Bar.Nested'); + }); + + it('should emit &__MODULE__.fun/arity captures as a same-module reference', () => { + const code = `defmodule M do + def cap, do: &__MODULE__.normalize/1 + def normalize(x), do: x +end +`; + const result = extractFromSource('lib/m.ex', code); + // __MODULE__.normalize → bare name, same-file preference (like the call form). + expect(refsOf(result, 'references')).toContain('normalize'); + expect(refsOf(result, 'references')).not.toContain('__MODULE__::normalize'); + }); + + it('should not leak defoverridable as a call ref', () => { + const code = `defmodule M do + def run(x), do: x + defoverridable run: 1 +end +`; + const result = extractFromSource('lib/m.ex', code); + expect(calls(result)).not.toContain('defoverridable'); + }); + + it('should not leak Kernel control-flow / process macros as call refs', () => { + const code = `defmodule M do + def run(x) do + if x, do: raise("no") + unless x, do: throw(:stop) + case x do + _ -> exit(:normal) + end + cond do + true -> send(self(), :ok) + end + with :ok <- x, do: spawn(fn -> real_helper(x) end) + for _ <- [], do: :ok + try do + real_helper(x) + rescue + _ -> :err + end + end + def real_helper(x), do: x +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + for (const m of ['if', 'unless', 'case', 'cond', 'with', 'for', 'raise', 'try', + 'send', 'spawn', 'self', 'throw', 'exit']) { + expect(c).not.toContain(m); + } + // A genuine local call in the same body is still emitted. + expect(c).toContain('real_helper'); + }); + + it('should emit calls in defstruct default values without minting field-name edges', () => { + const code = `defmodule M do + defstruct ts: DateTime.utc_now(), id: generate_id(), items: [] +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + // Default-value calls are real compile-time code. + expect(c).toContain('DateTime::utc_now'); + expect(c).toContain('generate_id'); + // Field names must never become call edges. + expect(c).not.toContain('ts'); + expect(c).not.toContain('id'); + // Fields are still recorded on the module. + const fields = result.nodes.filter((n) => n.kind === 'field').map((n) => n.name); + expect(fields).toContain('ts'); + expect(fields).toContain('id'); + expect(fields).toContain('items'); + }); + + it('should index a one-liner module body (defmodule M, do: ...)', () => { + const code = `defmodule M, do: (def run, do: Helper.work()) +`; + const result = extractFromSource('lib/m.ex', code); + const run = result.nodes.find((n) => n.kind === 'function' && n.name === 'run'); + expect(run?.qualifiedName).toBe('M::run'); + expect(calls(result)).toContain('Helper::work'); + }); + + it('should resolve an alias declared inside a function body (module-wide)', () => { + const code = `defmodule M do + def run do + alias Foo.Bar + Bar.work() + end +end +`; + const result = extractFromSource('lib/m.ex', code); + const c = calls(result); + expect(c).toContain('Foo.Bar::work'); + expect(c).not.toContain('Bar::work'); + }); + }); }); describe('Terraform Extraction', () => { diff --git a/src/extraction/languages/elixir.ts b/src/extraction/languages/elixir.ts index 8efc7248e..7383f1a0b 100644 --- a/src/extraction/languages/elixir.ts +++ b/src/extraction/languages/elixir.ts @@ -133,6 +133,47 @@ function firstAlias(node: SyntaxNode | null): SyntaxNode | null { return null; } +/** + * Statically recover a module name from `Module.concat(A, B)` / + * `Module.concat([A, B])` when every argument is a literal `alias`. Returns the + * dotted join (`A.B`) or null for any other shape (variable args, non-literal + * segments, nesting) — those stay genuinely dynamic. The `defmodule` argument is + * the head `call` whose target is a `dot` (left alias "Module", right identifier + * "concat"); its own `arguments` hold the aliases, either directly or wrapped in + * a single `list`. + */ +function recoverModuleConcat(head: SyntaxNode, source: string): string | null { + if (head.type !== 'call') return null; + const target = getChildByField(head, 'target'); + if (!target || target.type !== 'dot') return null; + const left = getChildByField(target, 'left'); + const right = getChildByField(target, 'right'); + if ( + !left || + left.type !== 'alias' || + getNodeText(left, source) !== 'Module' || + !right || + right.type !== 'identifier' || + getNodeText(right, source) !== 'concat' + ) { + return null; + } + const args = argsOf(head); + if (!args) return null; + let segNodes = args.namedChildren; + // List form: a single `list` child holding the aliases. + if (segNodes.length === 1 && segNodes[0]!.type === 'list') { + segNodes = segNodes[0]!.namedChildren; + } + if (segNodes.length === 0) return null; + const parts: string[] = []; + for (const seg of segNodes) { + if (seg.type !== 'alias') return null; // any non-literal segment → dynamic + parts.push(getNodeText(seg, source)); + } + return parts.join('.'); +} + /** Look up a keyword value (`for:` → its value node) inside an arguments node. */ function keywordValue(argsNode: SyntaxNode | null, key: string, source: string): SyntaxNode | null { if (!argsNode) return null; @@ -262,6 +303,27 @@ function moduledocOf(doBlock: SyntaxNode | null, source: string): string | undef return undefined; } +/** + * Visit a module's body regardless of form: the `do_block` child (block form) or + * the `do:` keyword value (one-liner `defmodule M, do: def ...`). The one-liner + * value is a `block` wrapping the statements; visit its children individually so + * each inner `def` reaches the visitNode hook. + */ +function visitModuleBody(node: SyntaxNode, ctx: ExtractorContext): void { + const doBlock = doBlockOf(node); + if (doBlock) { + for (const child of doBlock.namedChildren) ctx.visitNode(child); + return; + } + const doValue = keywordValue(argsOf(node), 'do', ctx.source); + if (!doValue) return; + if (doValue.type === 'block') { + for (const child of doValue.namedChildren) ctx.visitNode(child); + } else { + ctx.visitNode(doValue); + } +} + function handleModule(node: SyntaxNode, ctx: ExtractorContext, localName: string): boolean { const prefix = currentModulePrefix(); const fullName = prefix ? `${prefix}.${localName}` : localName; @@ -273,9 +335,7 @@ function handleModule(node: SyntaxNode, ctx: ExtractorContext, localName: string if (!ns) return true; ctx.pushScope(ns.id); moduleNameStack.push(fullName); - if (doBlock) { - for (const child of doBlock.namedChildren) ctx.visitNode(child); - } + visitModuleBody(node, ctx); moduleNameStack.pop(); ctx.popScope(); return true; @@ -297,7 +357,6 @@ function handleDefImpl(node: SyntaxNode, ctx: ExtractorContext): boolean { // implements ref, then treat the block like a normal module named Proto.Type. const prefix = currentModulePrefix(); const fullName = prefix ? `${prefix}.${localName}` : localName; - const doBlock = doBlockOf(node); const ns = ctx.createNode('namespace', fullName, node, { qualifiedName: fullName }); if (!ns) return true; if (proto) { @@ -311,9 +370,7 @@ function handleDefImpl(node: SyntaxNode, ctx: ExtractorContext): boolean { } ctx.pushScope(ns.id); moduleNameStack.push(fullName); - if (doBlock) { - for (const child of doBlock.namedChildren) ctx.visitNode(child); - } + visitModuleBody(node, ctx); moduleNameStack.pop(); ctx.popScope(); return true; @@ -369,8 +426,13 @@ function handleDef(node: SyntaxNode, ctx: ExtractorContext, macro: string): bool /** * Walk a def's body/bodies for calls WITHOUT walking the head (which would emit - * a spurious self-call to the function's own name): the do_block child and, for - * the one-liner form, the `do:` keyword value. + * a spurious self-call to the function's own name): the guard expression (the + * RIGHT side of a `when` binary_operator in arguments[0] — the LEFT is the head), + * the do_block child and, for the one-liner form, the `do:` keyword value. + * + * The guard walk covers two shapes both invisible before: `defguard`/`defguardp` + * (whose ONLY body is the guard — no do_block, no `do:`) and a `def f(x) when + * guard, do: body` whose guard calls were previously dropped. */ function visitDefBodies( node: SyntaxNode, @@ -378,6 +440,12 @@ function visitDefBodies( fnId: string, ctx: ExtractorContext ): void { + const first = argsOf(node)?.namedChildren[0] ?? null; + if (first?.type === 'binary_operator') { + // Guard clause: arguments[0] is `head when guard`; walk the guard (right). + const right = getChildByField(first, 'right'); + if (right) ctx.visitFunctionBody(right, fnId); + } const doBlock = doBlockOf(node); if (doBlock) ctx.visitFunctionBody(doBlock, fnId); const doValue = keywordValue(argsOf(node), 'do', ctx.source); @@ -397,12 +465,20 @@ function handleDefstruct(node: SyntaxNode, ctx: ExtractorContext): boolean { qualifiedName: fullMod ? `${fullMod}::${fieldName}` : fieldName, }); }; + // Field default VALUES are real compile-time code (`ts: DateTime.utc_now()`), + // so their calls must be emitted — attributed to the enclosing module (the + // current scope). The field NAME (the keyword key) must never become an edge. + const walkValue = (pair: SyntaxNode): void => { + const value = getChildByField(pair, 'value'); + if (value) ctx.visitNode(value); + }; for (const child of args.namedChildren) { if (child.type === 'keywords') { for (const pair of child.namedChildren) { if (pair.type !== 'pair') continue; const key = getChildByField(pair, 'key'); if (key) addField(keywordKey(key, ctx.source), pair); + walkValue(pair); } } else if (child.type === 'list') { for (const item of child.namedChildren) { @@ -413,6 +489,7 @@ function handleDefstruct(node: SyntaxNode, ctx: ExtractorContext): boolean { if (pair.type !== 'pair') continue; const key = getChildByField(pair, 'key'); if (key) addField(keywordKey(key, ctx.source), pair); + walkValue(pair); } } } @@ -556,8 +633,16 @@ export const elixirExtractor: LanguageExtractor = { const macro = getNodeText(target, ctx.source); if (macro === 'defmodule' || macro === 'defprotocol') { const aliasNode = firstAlias(argsOf(node)); - if (!aliasNode) return true; - return handleModule(node, ctx, getNodeText(aliasNode, ctx.source)); + if (aliasNode) return handleModule(node, ctx, getNodeText(aliasNode, ctx.source)); + // Dynamic module name (no literal alias). Try to recover a static name from + // `Module.concat(A, B)` / `Module.concat([A, B])`; otherwise (genuinely + // dynamic) DON'T silently swallow the body — visit it so inner defs are + // indexed like top-level defs, with no fabricated namespace (F6). + const head = argsOf(node)?.namedChildren[0] ?? null; + const recovered = head ? recoverModuleConcat(head, ctx.source) : null; + if (recovered) return handleModule(node, ctx, recovered); + visitModuleBody(node, ctx); + return true; } if (macro === 'defimpl') return handleDefImpl(node, ctx); if (DEF_MACROS.has(macro)) return handleDef(node, ctx, macro); diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 9724567c8..a4433bebd 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -91,17 +91,28 @@ const ERLANG_MFA_CALLS = new Set([ const ELIXIR_DECL_MACROS = new Set([ 'defmodule', 'defprotocol', 'defimpl', 'def', 'defp', 'defmacro', 'defmacrop', 'defguard', 'defguardp', 'defdelegate', 'defstruct', 'defexception', + 'defoverridable', 'use', 'import', 'require', 'alias', ]); /** - * Elixir special forms that parse as a bare `call` but are never a - * user-defined function — emitting a `calls` ref for them is pure noise (they - * resolve to nothing, and the identifiers are reserved so they can't collide - * with a real function either). + * Elixir Kernel special forms / macros / process primitives that parse as a bare + * `call` but are never a user-defined function — emitting a `calls` ref for them + * is pure noise (they resolve to nothing; the same-file-only rule keeps them from + * ever colliding with a real function, so this only suppresses dead edges). + * + * Includes the everyday control-flow macros (`if`/`case`/`with`/…) — in Elixir + * these are macros, not keywords, so unlike erlang they parse as calls and leak. + * `send`/`spawn`/`self`/`throw`/`exit` are Kernel process/flow primitives that in + * practice never target repo code; suppressing them is a conscious trade-off + * (a hypothetical local `def send` called bare would no longer link). `apply` is + * deliberately NOT here: dynamic dispatch is handled elsewhere and its silence is + * already covered by the variable-receiver path. */ const ELIXIR_SPECIAL_FORMS = new Set([ 'quote', 'unquote', 'unquote_splicing', 'super', 'receive', + 'if', 'unless', 'case', 'cond', 'with', 'for', 'raise', 'try', + 'send', 'spawn', 'self', 'throw', 'exit', ]); /** @@ -3599,10 +3610,11 @@ export class TreeSitterExtractor { private erlangAtomMacros = new Map(); // Elixir per-module alias table (`alias Foo.Bar` → Bar ⇒ Foo.Bar), memoized - // by the enclosing module node so `Bar.fun(x)` expands to `Foo.Bar::fun`. - // Single-entry: extraction is sequential and a module's calls are contiguous. - private elixirAliasModuleKey = ''; - private elixirAliasMap = new Map(); + // per enclosing module node so `Bar.fun(x)` expands to `Foo.Bar::fun`. Keyed by + // `(file, module.startIndex)` — a Map, not a single entry, so interleaved + // nested-module resolution (outer→inner→outer) does not thrash the cache by + // rebuilding the outer module's table on every return from a nested one. + private elixirAliasMemo = new Map>(); /** * Expand an Elixir module reference (`Bar`, `Bar.Deep`, `Foo.Sub.Mod`) to its @@ -3624,58 +3636,103 @@ export class TreeSitterExtractor { module = module.parent; } const key = module ? `${this.filePath}:${module.startIndex}` : this.filePath; - if (this.elixirAliasModuleKey !== key) { - this.elixirAliasModuleKey = key; - this.elixirAliasMap = new Map(); + let aliasMap = this.elixirAliasMemo.get(key); + if (!aliasMap) { + aliasMap = new Map(); + this.elixirAliasMemo.set(key, aliasMap); + const addAlias = (stmt: SyntaxNode): void => { + // `arguments` is a child node TYPE in tree-sitter-elixir, not a field. + const args = stmt.namedChildren.find((c) => c.type === 'arguments'); + if (!args) return; + const aliasNode = args.namedChildren.find((c) => c.type === 'alias'); + const asValue = this.elixirKeywordValue(args, 'as'); + if (aliasNode) { + const full = getNodeText(aliasNode, this.source); + if (asValue?.type === 'alias') { + aliasMap!.set(getNodeText(asValue, this.source), full); + } else { + const last = full.split('.').pop()!; + aliasMap!.set(last, full); + } + } + // grouped: alias Foo.{Alpha, Beta} + const dot = args.namedChildren.find((c) => c.type === 'dot'); + if (dot) { + const left = getChildByField(dot, 'left'); + const right = getChildByField(dot, 'right'); + const prefix = left?.type === 'alias' ? getNodeText(left, this.source) : ''; + if (right?.type === 'tuple' && prefix) { + for (const member of right.namedChildren) { + if (member.type === 'alias') { + const seg = getNodeText(member, this.source); + aliasMap!.set(seg, `${prefix}.${seg}`); + } + } + } + } + }; + // Collect `alias` directives from the ENTIRE module subtree, not just the + // module body's direct children — an alias declared inside a function body + // (`def run do alias Foo.Bar; Bar.work() end`) must resolve too. Recursion + // stops at nested module boundaries so an inner module's aliases don't bleed + // outward. Scoping an alias to the one function it sits in is a deliberate + // non-goal (module-wide is the pragmatic, near-always-correct choice). const doBlock = module ? module.namedChildren.find((c) => c.type === 'do_block') : null; - if (doBlock) { - for (const stmt of doBlock.namedChildren) { - if (stmt.type !== 'call') continue; - const t = getChildByField(stmt, 'target'); - if (t?.type !== 'identifier' || getNodeText(t, this.source) !== 'alias') continue; - // `arguments` is a child node TYPE in tree-sitter-elixir, not a field. - const args = stmt.namedChildren.find((c) => c.type === 'arguments'); - if (!args) continue; - const aliasNode = args.namedChildren.find((c) => c.type === 'alias'); - const asValue = this.elixirKeywordValue(args, 'as'); - if (aliasNode) { - const full = getNodeText(aliasNode, this.source); - if (asValue?.type === 'alias') { - this.elixirAliasMap.set(getNodeText(asValue, this.source), full); - } else { - const last = full.split('.').pop()!; - this.elixirAliasMap.set(last, full); + const collect = (n: SyntaxNode): void => { + for (const child of n.namedChildren) { + if (child.type === 'call') { + const t = getChildByField(child, 'target'); + const tn = t?.type === 'identifier' ? getNodeText(t, this.source) : ''; + if (tn === 'alias') { + addAlias(child); + continue; } - } - // grouped: alias Foo.{Alpha, Beta} - const dot = args.namedChildren.find((c) => c.type === 'dot'); - if (dot) { - const left = getChildByField(dot, 'left'); - const right = getChildByField(dot, 'right'); - const prefix = left?.type === 'alias' ? getNodeText(left, this.source) : ''; - if (right?.type === 'tuple' && prefix) { - for (const member of right.namedChildren) { - if (member.type === 'alias') { - const seg = getNodeText(member, this.source); - this.elixirAliasMap.set(seg, `${prefix}.${seg}`); - } - } + if (tn === 'defmodule' || tn === 'defprotocol' || tn === 'defimpl') { + continue; // nested module — its aliases belong to a different scope } } + collect(child); } - } + }; + if (doBlock) collect(doBlock); } const segments = modText.split('.'); const head = segments[0]!; - const mapped = this.elixirAliasMap.get(head); + const mapped = aliasMap.get(head); if (mapped) { return segments.length > 1 ? `${mapped}.${segments.slice(1).join('.')}` : mapped; } return modText; } + /** + * Full dotted name of the module enclosing `node`, matching the qualifiedName + * its namespace node carries (outer→inner alias names joined with `.`). Used to + * resolve `__MODULE__` self-references (`%__MODULE__{}`) to the current module. + * Empty string when there is no enclosing `defmodule`/`defprotocol`. + */ + private currentElixirModuleName(node: SyntaxNode): string { + const parts: string[] = []; + let n: SyntaxNode | null = node.parent; + while (n) { + if (n.type === 'call') { + const t = getChildByField(n, 'target'); + if (t?.type === 'identifier') { + const name = getNodeText(t, this.source); + if (name === 'defmodule' || name === 'defprotocol') { + const args = n.namedChildren.find((c) => c.type === 'arguments'); + const alias = args?.namedChildren.find((c) => c.type === 'alias'); + if (alias) parts.unshift(getNodeText(alias, this.source)); + } + } + } + n = n.parent; + } + return parts.join('.'); + } + /** Look up a keyword value inside an Elixir `arguments` node (`as:` → value). */ private elixirKeywordValue(argsNode: SyntaxNode, key: string): SyntaxNode | null { for (const child of argsNode.namedChildren) { @@ -4046,6 +4103,20 @@ export class TreeSitterExtractor { line, column, }); + } else if ( + l?.type === 'identifier' && + getNodeText(l, this.source) === '__MODULE__' && + r?.type === 'identifier' + ) { + // `&__MODULE__.fun/arity` → bare name, same-file preference resolves + // it to the local function (mirrors the `__MODULE__.fun()` call form). + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: getNodeText(r, this.source), + referenceKind: 'references', + line, + column, + }); } } } else if (capLeft?.type === 'identifier') { @@ -4060,7 +4131,10 @@ export class TreeSitterExtractor { return; } if (node.type === 'map') { - // `%Foo.Bar{…}` struct literal: map > struct > alias. + // `%Foo.Bar{…}` struct literal: map > struct > alias. A `%__MODULE__{…}` + // literal parses as map > struct > identifier "__MODULE__" instead — the + // struct analog of the `__MODULE__.fun()` call: resolve it to the + // enclosing module by name so the self-build reference is not lost. const structChild = node.namedChildren.find((c) => c.type === 'struct'); const aliasNode = structChild?.namedChildren.find((c) => c.type === 'alias'); if (aliasNode) { @@ -4072,6 +4146,22 @@ export class TreeSitterExtractor { line, column, }); + } else { + const idNode = structChild?.namedChildren.find( + (c) => c.type === 'identifier' && getNodeText(c, this.source) === '__MODULE__' + ); + if (idNode) { + const selfMod = this.currentElixirModuleName(node); + if (selfMod) { + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: selfMod, + referenceKind: 'references', + line, + column, + }); + } + } } return; } From 663e385f495c576645993dd871b94125f20582f9 Mon Sep 17 00:00:00 2001 From: Roman Wolan Date: Tue, 14 Jul 2026 05:22:55 +0200 Subject: [PATCH 5/6] fix(elixir): qualify bare-call resolution by the enclosing module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare lowercase Elixir call (`helper()`) resolves same-file only. But one file can hold two+ modules, and same-file was the ONLY candidate filter: in a file where modules A and B both define `helper/0`, a bare `helper()` inside B resolved to whichever `getNodesByName` returned first — often A::helper. That's a wrong cross-module edge, and the project's rule is that a wrong edge is worse than no edge. Qualify the candidate by the CALLER's enclosing module. Derive the module from the from-node (`Mod.Sub::fun` -> `Mod.Sub`; a namespace from-node, as carried by calls in module-attribute values (c4c0eec), IS the module) and keep only candidates in that module. If the caller's module can't be determined, or no same-module candidate exists, stay unresolved. The common one-module-per-file case is unchanged. Adds TDD coverage: two modules both defining `helper` (resolve to caller's, not the first), only-other-module defines it (no edge), single-module control, and a module-attribute (namespace) caller. Validated on crm-proxy (2067 Elixir nodes): all 1115 legitimate same-module bare-call edges intact, zero cross-module false positives (none existed to remove). --- __tests__/resolution.test.ts | 136 ++++++++++++++++++++++++++++++++- src/resolution/name-matcher.ts | 34 ++++++++- 2 files changed, 166 insertions(+), 4 deletions(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index c65f86e7d..f651c1d1a 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -206,7 +206,18 @@ describe('Resolution Module', () => { qualifiedName: 'MyBehaviour', filePath: 'lib/my_behaviour.ex', }); - const nodes = [crossFileConfig, sameFileHelper, behaviourModule]; + // The caller — `MyApp.Worker.run/1` — so bare calls resolve within its + // own module (`MyApp.Worker`, same module as sameFileHelper). + const runCaller = mkNode({ + id: 'function:lib/worker.ex:run:3', + kind: 'function', + name: 'run', + qualifiedName: 'MyApp.Worker::run', + filePath: 'lib/worker.ex', + startLine: 3, + }); + const nodes = [crossFileConfig, sameFileHelper, behaviourModule, runCaller]; + const byId = new Map(nodes.map((n) => [n.id, n])); const context: ResolutionContext = { getNodesInFile: () => [], getNodesByName: (name) => nodes.filter((n) => n.name === name), @@ -218,6 +229,7 @@ describe('Resolution Module', () => { getAllFiles: () => [], getNodesByLowerName: () => [], getImportMappings: () => [], + getNodeById: (id) => byId.get(id) ?? null, }; const mkRef = (name: string, kind: 'calls' | 'references' | 'implements') => ({ fromNodeId: 'function:lib/worker.ex:run:3', @@ -242,6 +254,128 @@ describe('Resolution Module', () => { expect(matchReference(mkRef('GenServer', 'implements'), context)).toBeNull(); }); + describe('Elixir bare-call resolution qualifies by enclosing module', () => { + // One file, two modules that BOTH define `helper/0`. A bare `helper()` + // call must resolve to the CALLER's module's function, never the other + // module's — a cross-module edge is a wrong edge, worse than none. + const mkNode = ( + partial: Partial & Pick + ): Node => ({ + language: 'elixir', + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + ...partial, + }); + const FILE = 'lib/two_mods.ex'; + // Module A defined FIRST so getNodesByName returns A's helper first — + // pre-fix, a bare call from B would (wrongly) pick A[0]. + const aHelper = mkNode({ + id: 'function:lib/two_mods.ex:helper:2', + kind: 'function', + name: 'helper', + qualifiedName: 'Sample.A::helper', + filePath: FILE, + startLine: 2, + }); + const aModule = mkNode({ + id: 'namespace:lib/two_mods.ex:Sample.A:1', + kind: 'namespace', + name: 'Sample.A', + qualifiedName: 'Sample.A', + filePath: FILE, + startLine: 1, + }); + const bHelper = mkNode({ + id: 'function:lib/two_mods.ex:helper:12', + kind: 'function', + name: 'helper', + qualifiedName: 'Sample.B::helper', + filePath: FILE, + startLine: 12, + }); + const bRun = mkNode({ + id: 'function:lib/two_mods.ex:run:14', + kind: 'function', + name: 'run', + qualifiedName: 'Sample.B::run', + filePath: FILE, + startLine: 14, + }); + const bModule = mkNode({ + id: 'namespace:lib/two_mods.ex:Sample.B:11', + kind: 'namespace', + name: 'Sample.B', + qualifiedName: 'Sample.B', + filePath: FILE, + startLine: 11, + }); + + const mkContext = (nodes: Node[]): ResolutionContext => { + const byId = new Map(nodes.map((n) => [n.id, n])); + return { + getNodesInFile: () => [], + // A's helper first, so the OLD `sameFile[0]` picks the wrong module. + getNodesByName: (name) => nodes.filter((n) => n.name === name), + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + getNodeById: (id) => byId.get(id) ?? null, + }; + }; + + const mkRef = (fromNodeId: string) => ({ + fromNodeId, + referenceName: 'helper', + referenceKind: 'calls' as const, + line: 15, + column: 4, + filePath: FILE, + language: 'elixir' as const, + }); + + it('(a) resolves a bare call to the CALLER module, not the first same-named function', () => { + const context = mkContext([aModule, aHelper, bModule, bHelper, bRun]); + const result = matchReference(mkRef(bRun.id), context); + expect(result).not.toBeNull(); + // The wrong-target guard: must be B's helper, never A's. + const target = [aModule, aHelper, bModule, bHelper, bRun].find( + (n) => n.id === result!.targetNodeId + ); + expect(target?.qualifiedName).toBe('Sample.B::helper'); + expect(result!.targetNodeId).toBe(bHelper.id); + }); + + it('(b) stays unresolved when only ANOTHER module defines the name', () => { + // Only module A defines helper; the call lives in module B → no edge + // (pre-fix this wrongly created B.run → A::helper). + const context = mkContext([aModule, aHelper, bModule, bRun]); + expect(matchReference(mkRef(bRun.id), context)).toBeNull(); + }); + + it('(c) control: single module, bare call to own function resolves', () => { + const context = mkContext([bModule, bHelper, bRun]); + const result = matchReference(mkRef(bRun.id), context); + expect(result?.targetNodeId).toBe(bHelper.id); + }); + + it('(d) module-attribute caller (namespace node) resolves to its own module', () => { + // c4c0eec: a call inside a custom module-attribute value carries the + // MODULE namespace node as fromNode. It must still resolve to that + // module's helper. + const context = mkContext([aModule, aHelper, bModule, bHelper, bRun]); + const result = matchReference(mkRef(bModule.id), context); + expect(result?.targetNodeId).toBe(bHelper.id); + }); + }); + it('should prefer same-module candidates over cross-module matches', () => { // Simulates a Python monorepo where multiple apps define navigate() const candidateA: Node = { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 7a5fa8da3..51b7e8647 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1908,6 +1908,22 @@ export function matchFuzzy( /** ArkUI attribute-helper decorators a `.attr(...)` chain may resolve to. */ const ARKUI_ATTRIBUTE_DECORATORS = new Set(['Extend', 'Styles', 'AnimatableExtend', 'Builder']); +/** + * The Elixir module a node belongs to, or `null` when it can't be determined. + * Function/method nodes carry a `Full.Module::fun` qualifiedName, so the module + * is everything before the last `::`. A `namespace` node IS a module, so its + * whole qualifiedName is the module name (calls in module-attribute values + * report the namespace node as their from-node — c4c0eec). A top-level function + * with no enclosing module (no `::`) yields `null`, so its bare calls stay + * unresolved rather than guess a module. + */ +function elixirModuleOf(node: Node | null | undefined): string | null { + if (!node) return null; + if (node.kind === 'namespace') return node.qualifiedName || null; + const sep = node.qualifiedName.lastIndexOf('::'); + return sep > 0 ? node.qualifiedName.slice(0, sep) : null; +} + export function matchReference( ref: UnresolvedRef, context: ResolutionContext @@ -1976,15 +1992,27 @@ export function matchReference( !ref.referenceName.includes('::') && /^[a-z_]/.test(ref.referenceName) ) { - const sameFile = context + // Same-file is necessary but NOT sufficient: one file can hold two+ modules, + // and a bare call may only legitimately target a function of the CALLER's own + // module. Without qualifying by module, a bare `helper()` in module B would + // grab module A's same-named `helper` (whichever getNodesByName returns + // first) — a wrong cross-module edge, worse than none. Derive the caller's + // module from its node (`Mod.Sub::fun` → `Mod.Sub`; a namespace caller, for + // calls in module-attribute values (c4c0eec), IS the module) and keep only + // candidates in that module. Caller module indeterminable, or no same-module + // candidate → stay unresolved. + const callerModule = elixirModuleOf(context.getNodeById?.(ref.fromNodeId)); + if (!callerModule) return null; + const sameModule = context .getNodesByName(ref.referenceName) .filter( (n) => n.language === 'elixir' && n.kind === 'function' && - n.filePath === ref.filePath + n.filePath === ref.filePath && + elixirModuleOf(n) === callerModule ); - const chosen = sameFile[0]; + const chosen = sameModule[0]; if (!chosen) return null; return { original: ref, From 5d499a33a1c843a9b683b229c96af67b8bc64587 Mon Sep 17 00:00:00 2001 From: Roman Wolan Date: Thu, 16 Jul 2026 04:01:40 +0200 Subject: [PATCH 6/6] docs(changelog): rewrite Elixir entry in user-facing terms Per review: the entry described the extractor internals (macro-by-macro list) instead of user-visible behavior. Now states what gets indexed and how calls resolve, without implementation detail. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 293453520..30a15782a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph install` and `codegraph upgrade` now offer CodeGraph Pro beta access after finishing — answer yes, type your email, and you join the same waitlist as the getcodegraph.com homepage form. Strictly opt-in and asked at most once per machine total: nothing is sent unless you say yes and enter an email, either answer is remembered so no later install or upgrade ever re-asks, and non-interactive runs (`--yes`, scripts, CI) never see the question. - Every release is now cryptographically verifiable: npm packages publish with npm provenance (the "Provenance" badge on npmjs.com, proving each version was built by this repository's release workflow from a specific commit), and the GitHub Release bundles carry signed build attestations you can check with `gh attestation verify -R colbymchenry/codegraph`. -- Elixir is now a supported language (`.ex`/`.exs`). CodeGraph indexes modules as namespaces — including nested `defmodule`s, which get their full dotted name — and their `def`/`defp`/`defmacro`/`defguard` definitions, grouping a function's multiple clauses (pattern matching, guards, default args) into one node with the right visibility. It reads `@spec` as the signature and `@doc`/`@moduledoc` as docstrings, `defstruct` fields, and `defprotocol`/`defimpl` blocks. Cross-module call edges resolve `Module.fun(...)` through the file's `alias` declarations (including `alias Foo.{A, B}` and `alias …, as: X`), link `__MODULE__.fun` within the same module, and record `&Mod.fun/arity` captures and `%Struct{}` literals as references; `@behaviour`/`use` become `implements` links and `import`/`require`/`alias` become import edges. Following the project's "a wrong edge is worse than no edge" rule, dynamic dispatch that has no static target — a call through a variable receiver, `apply/3`, or `GenServer.call(pid, …)` — is deliberately left unlinked rather than guessed. Known limitations: user-macro expansion, Phoenix/Ecto DSLs, GenServer/process dispatch, `.heex` templates, and import-based resolution of bare calls are out of scope for this first version. +- Elixir is now a supported language (`.ex`/`.exs`). CodeGraph indexes modules (including nested ones) as namespaces, public and private functions and macros (a function's multiple clauses become one symbol), structs, protocols and their implementations, and picks up specs and doc attributes as signatures and docstrings. Cross-module calls resolve through the file's alias declarations (including grouped and renamed aliases), behaviours count as implementations, and function captures and struct literals become references. Dynamic dispatch with no static target (a module held in a variable, `apply/3`, process messaging) is deliberately left unlinked rather than guessed. Phoenix/Ecto DSLs, user-macro expansion, and `.heex` templates are out of scope for this first version. ### Fixes