Skip to content

Elixir extractor drops functions whose only clause has a when guard #3111

Description

@denielchiang

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions