Pre-flight checks
What happened?
While working on a project, I used graphify to build a code map of a mid-sized Phoenix application. The module graph came out almost empty: modules that clearly depend on each other had no calls edges between them. I narrowed it down to how the Elixir extractor handles remote calls.
In Elixir, most cross-module calls are qualified: MyApp.Accounts.get_user(id), or Accounts.get_user(id) after alias MyApp.Accounts. import is used much less (mostly for framework helpers). graphify only links unqualified calls: those inside the same module, and those into modules brought in with import/use. Every qualified call is either dropped or, worse, attached to the wrong function.
Failing scenarios (all from the reproduction below):
| # |
Code (inside App.Web / App.Admin) |
Expected edge |
Actual |
| 1 |
App.Accounts.get_user(id) (fully qualified) |
show -> App.Accounts.get_user (EXTRACTED) |
no edge |
| 2 |
alias App.Accounts … Accounts.get_user(id) |
show_aliased -> App.Accounts.get_user (EXTRACTED) |
no edge |
| 6 |
App.Repo.insert(x) in a module that also defines its own insert/1 |
save -> App.Repo.insert |
wrong edge save -> App.Admin.insert, marked EXTRACTED |
Working controls, for comparison:
| # |
Code |
Actual |
| 3 |
import App.Repo … insert(attrs) |
create -> App.Repo.insert (INFERRED) ✓ |
| 4 |
local call build(attrs) |
create_local -> build (EXTRACTED) ✓ |
Scenario 6 is the most harmful one. A remote call becomes an EXTRACTED edge to an unrelated local function, so the graph states a false fact with full confidence. This is a common shape in Phoenix code: a context module with create/1 or insert/1 that calls Repo.insert/1, or a LiveView update/2 that calls Map.update/3.
On the real project, lib/ contains about 14,100 qualified call sites into the project's own modules. graphify produced about 1,100 cross-module call edges in total, almost all of them through import, so the module-level dependency graph was unusable.
Where it happens (v8, graphify/extractors/elixir.py)
walk_calls (L298): for a call whose first child is a dot node, it splits the dot text and keeps only parts[-1] (L319). App.Accounts.get_user becomes get_user, and the module qualifier is discarded.
- The bare name is then looked up in the file's own
label_to_nid (L325). If the file defines a function with that name, the call binds to it as EXTRACTED (scenario 6).
- Otherwise it goes to
raw_calls with is_member_call: True and elixir_call_scope (L340). The cross-file pass in extract.py (L8871) only keeps candidates from modules that are imported or used in scope. alias is deliberately excluded, and a fully qualified module never enters the scope, so scenarios 1 and 2 get no edge.
The import-scope filter (from #4001) is correct for unqualified calls. The problem is that qualified calls never get a resolution path of their own: the qualifier that identifies the target exactly is thrown away first.
Suggested fix
- In
walk_calls, keep the receiver of a dot call: the module part (App.Accounts), separate from the function name.
- Expand the receiver with the module's lexical alias table (
alias A.B, alias A.B, as: C, alias A.{B, C}, __MODULE__). The extractor already parses aliases for the imports edges.
- If the expanded module is defined in the corpus and has a function with that name, emit an EXTRACTED edge to exactly that definition, like the type-qualified passes for Swift and Go.
- If the module is not in the corpus (
Repo from Ecto, Enum, Map, …), emit nothing. Never fall back to the bare-name label_to_nid lookup for a qualified call. This matches "prefer fail-closed behavior over guessed relationships".
- Calls on a variable receiver (
mod.fun(), conn.assigns) cannot be resolved statically and should stay unlinked.
Related: #1883 (Python qualified calls), #2550 (Kotlin), #1313 (Go), #4001 and #3565 (Elixir).
Steps to reproduce
mkdir -p repro/lib/app && cd repro
cat > lib/app/accounts.ex <<'EOF'
defmodule App.Accounts do
def get_user(id) do
{:user, id}
end
end
EOF
cat > lib/app/repo.ex <<'EOF'
defmodule App.Repo do
def insert(changeset) do
{:ok, changeset}
end
end
EOF
cat > lib/app/web.ex <<'EOF'
defmodule App.Web do
alias App.Accounts
import App.Repo
# 1. fully qualified remote call
def show(id) do
App.Accounts.get_user(id)
end
# 2. remote call through an alias
def show_aliased(id) do
Accounts.get_user(id)
end
# 3. control: unqualified call to an imported function
def create(attrs) do
insert(attrs)
end
# 4. control: local call
def create_local(attrs) do
build(attrs)
end
defp build(attrs) do
attrs
end
end
EOF
cat > lib/app/admin.ex <<'EOF'
defmodule App.Admin do
# 6. qualified call to App.Repo.insert/1 from a module with its own insert/1
def save(x) do
App.Repo.insert(x)
end
def insert(x) do
x
end
end
EOF
uvx --python 3.13 --from graphifyy==0.9.80 graphify extract lib --code-only --no-cluster --out out
python3 -c "
import json
g = json.load(open('out/graphify-out/graph.json'))
for e in g['edges']:
if e['relation'] not in ('contains', 'method'):
print(e['source'], e['relation'], e['target'], e.get('source_location'), e.get('confidence'))
"
Error output or graph output
app_admin_app_admin_save calls app_admin_app_admin_insert L4 EXTRACTED <- wrong: the call is App.Repo.insert
app_web imports app_accounts L2 EXTRACTED
app_web imports app_repo L3 EXTRACTED
app_web_app_web_create_local calls app_web_app_web_build L22 EXTRACTED <- ok (local)
app_web_app_web_create calls app_repo_app_repo_insert L17 INFERRED <- ok (import)
missing: app_web_app_web_show calls app_accounts_app_accounts_get_user (L7)
missing: app_web_app_web_show_aliased calls app_accounts_app_accounts_get_user (L12)
Graphify version
0.9.80
Operating System
macOS
Python Version
3.13
Installation Method
uv tool install (recommended)
Additional Environment Details
No provider environment variables set (--code-only, no LLM). Reproduced in a fresh directory containing only the four files above.
Additional context
Function bodies written as one-liners (def f(x), do: ...) are not walked at all; see the separate issue below. To isolate this bug, the reproduction uses do ... end bodies only.
Pre-flight checks
What happened?
While working on a project, I used graphify to build a code map of a mid-sized Phoenix application. The module graph came out almost empty: modules that clearly depend on each other had no
callsedges between them. I narrowed it down to how the Elixir extractor handles remote calls.In Elixir, most cross-module calls are qualified:
MyApp.Accounts.get_user(id), orAccounts.get_user(id)afteralias MyApp.Accounts.importis used much less (mostly for framework helpers). graphify only links unqualified calls: those inside the same module, and those into modules brought in withimport/use. Every qualified call is either dropped or, worse, attached to the wrong function.Failing scenarios (all from the reproduction below):
App.Web/App.Admin)App.Accounts.get_user(id)(fully qualified)show -> App.Accounts.get_user(EXTRACTED)alias App.Accounts…Accounts.get_user(id)show_aliased -> App.Accounts.get_user(EXTRACTED)App.Repo.insert(x)in a module that also defines its owninsert/1save -> App.Repo.insertsave -> App.Admin.insert, marked EXTRACTEDWorking controls, for comparison:
import App.Repo…insert(attrs)create -> App.Repo.insert(INFERRED) ✓build(attrs)create_local -> build(EXTRACTED) ✓Scenario 6 is the most harmful one. A remote call becomes an EXTRACTED edge to an unrelated local function, so the graph states a false fact with full confidence. This is a common shape in Phoenix code: a context module with
create/1orinsert/1that callsRepo.insert/1, or a LiveViewupdate/2that callsMap.update/3.On the real project,
lib/contains about 14,100 qualified call sites into the project's own modules. graphify produced about 1,100 cross-module call edges in total, almost all of them throughimport, so the module-level dependency graph was unusable.Where it happens (
v8,graphify/extractors/elixir.py)walk_calls(L298): for a call whose first child is adotnode, it splits the dot text and keeps onlyparts[-1](L319).App.Accounts.get_userbecomesget_user, and the module qualifier is discarded.label_to_nid(L325). If the file defines a function with that name, the call binds to it as EXTRACTED (scenario 6).raw_callswithis_member_call: Trueandelixir_call_scope(L340). The cross-file pass inextract.py(L8871) only keeps candidates from modules that areimported orused in scope.aliasis deliberately excluded, and a fully qualified module never enters the scope, so scenarios 1 and 2 get no edge.The import-scope filter (from #4001) is correct for unqualified calls. The problem is that qualified calls never get a resolution path of their own: the qualifier that identifies the target exactly is thrown away first.
Suggested fix
walk_calls, keep the receiver of adotcall: the module part (App.Accounts), separate from the function name.alias A.B,alias A.B, as: C,alias A.{B, C},__MODULE__). The extractor already parses aliases for theimportsedges.Repofrom Ecto,Enum,Map, …), emit nothing. Never fall back to the bare-namelabel_to_nidlookup for a qualified call. This matches "prefer fail-closed behavior over guessed relationships".mod.fun(),conn.assigns) cannot be resolved statically and should stay unlinked.Related: #1883 (Python qualified calls), #2550 (Kotlin), #1313 (Go), #4001 and #3565 (Elixir).
Steps to reproduce
Error output or graph output
Graphify version
0.9.80
Operating System
macOS
Python Version
3.13
Installation Method
uv tool install (recommended)
Additional Environment Details
No provider environment variables set (
--code-only, no LLM). Reproduced in a fresh directory containing only the four files above.Additional context
Function bodies written as one-liners (
def f(x), do: ...) are not walked at all; see the separate issue below. To isolate this bug, the reproduction usesdo ... endbodies only.