Summary
The Elixir AST extractor silently drops every function whose only clause carries a when guard. In a real 2,900-file Elixir/Phoenix codebase this hid 19 of 582 functions (100% of that shape) from the graph — including several that are architecturally significant, so downstream analysis (god nodes, community detection, "what calls this?") is systematically blind to them.
No error is raised; the node is simply never created.
Reproduction
lib/demo.ex:
defmodule Demo do
# A: single clause, no guard
def plain(x) do
x + 1
end
# B: single clause, WITH guard
def guarded(x) when is_integer(x) do
x + 1
end
# C: multiple clauses, one with a guard
def mixed(x) when is_integer(x) do
x + 1
end
def mixed(_), do: :error
# D: private, single clause, with guard
defp guarded_private(x) when is_binary(x) do
String.upcase(x)
end
end
from graphify.extract import extract
from pathlib import Path
res = extract([Path("lib/demo.ex")], cache_root=Path("."))
print(sorted((n.get("label") or "").rstrip("()") for n in res["nodes"]))
Actual: ['Demo', 'demo.ex', 'mixed', 'plain']
Expected: also guarded and guarded_private
| case |
shape |
extracted |
| A |
single clause, no guard |
✅ |
| B |
single clause, when guard |
❌ |
| C |
multi-clause, one guarded |
✅ (picked up via the unguarded clause) |
| D |
defp, single clause, when guard |
❌ |
C passing is what makes this easy to miss: a guarded function is only lost when no clause is unguarded.
Root cause
graphify/extractors/elixir.py, in the keyword in ("def", "defp") branch:
for child in arguments_node.children:
if child.type == "call":
...
elif child.type == "identifier":
...
if not func_name:
return # <-- node silently dropped
tree-sitter-elixir wraps a guarded head in a binary_operator, so arguments_node's direct child is not a call:
def plain(x) do ... -> arguments children = ['call']
def guarded(x) when is_integer(x) do ... -> arguments children = ['binary_operator']
and that binary_operator's children are [call 'guarded(x)', when, call 'is_integer(x)'].
Neither branch matches, func_name stays None, and the early return discards the function.
Suggested fix
Descend into the binary_operator's left operand before the existing type checks:
for child in arguments_node.children:
# `def f(x) when guard do` -> arguments child is
# binary_operator(call, "when", call); use the left operand.
if child.type == "binary_operator":
child = next(
(c for c in child.children if c.type in ("call", "identifier")),
child,
)
if child.type == "call":
...
Verified locally against the reproduction above: all four cases extract (plain, guarded, mixed, guarded_private). I reverted the patched package afterwards, so this is a suggestion rather than a tested-in-CI change — happy to open a PR with a regression test covering all four shapes if that helps.
Note for anyone reproducing
extract() caches per file, so re-running after editing the extractor replays the cached result. Use a fresh filename (or clear the cache) when testing a fix — I lost a cycle to this.
Environment
- graphify (
graphifyy) installed via uv tool, Python 3.12
- tree-sitter-elixir via the bundled
tree_sitter_elixir
- macOS
Impact detail
Across lib/**/*.ex in the affected repo:
| function shape |
count |
present in graph |
single clause + when guard |
19 |
0 (0%) |
| everything else |
563 |
547 (97%) |
Examples of what was missing: an authorization invariant predicate, an audit-log actor-role resolver, and a guide-lookup entry point — all public API, all referenced elsewhere.
Summary
The Elixir AST extractor silently drops every function whose only clause carries a
whenguard. In a real 2,900-file Elixir/Phoenix codebase this hid 19 of 582 functions (100% of that shape) from the graph — including several that are architecturally significant, so downstream analysis (god nodes, community detection, "what calls this?") is systematically blind to them.No error is raised; the node is simply never created.
Reproduction
lib/demo.ex:Actual:
['Demo', 'demo.ex', 'mixed', 'plain']Expected: also
guardedandguarded_privatewhenguarddefp, single clause,whenguardC passing is what makes this easy to miss: a guarded function is only lost when no clause is unguarded.
Root cause
graphify/extractors/elixir.py, in thekeyword in ("def", "defp")branch:tree-sitter-elixir wraps a guarded head in a
binary_operator, soarguments_node's direct child is not acall:and that
binary_operator's children are[call 'guarded(x)', when, call 'is_integer(x)'].Neither branch matches,
func_namestaysNone, and the earlyreturndiscards the function.Suggested fix
Descend into the
binary_operator's left operand before the existing type checks:Verified locally against the reproduction above: all four cases extract (
plain,guarded,mixed,guarded_private). I reverted the patched package afterwards, so this is a suggestion rather than a tested-in-CI change — happy to open a PR with a regression test covering all four shapes if that helps.Note for anyone reproducing
extract()caches per file, so re-running after editing the extractor replays the cached result. Use a fresh filename (or clear the cache) when testing a fix — I lost a cycle to this.Environment
graphifyy) installed viauv tool, Python 3.12tree_sitter_elixirImpact detail
Across
lib/**/*.exin the affected repo:whenguardExamples of what was missing: an authorization invariant predicate, an audit-log actor-role resolver, and a guide-lookup entry point — all public API, all referenced elsewhere.