Skip to content

Elixir: def with a when guard is silently dropped from the graph; multi-clause defs collapse to the last clause #979

Closed

Description

@GlebTyazhyolov

Elixir: def with a when guard is silently dropped from the graph; multi-clause defs collapse to the last clause

Version: 2.3.8 (installed via uv; identical to main — line numbers below are from both).

Repro

# test.ex
defmodule T do
  def plain(x), do: x
  def guarded(x) when is_binary(x), do: x
  def multi(0), do: :zero
  def multi(n), do: n
end
from pathlib import Path
from code_review_graph.parser import CodeParser

nodes, _ = CodeParser().parse_file(Path("test.ex"))
for n in nodes:
    if n.kind in ("Function", "Class"):
        print(f"{n.kind:8} {n.name:10} L{n.line_start}-{n.line_end}")

Parser output:

Class    T          L1-6
Function plain      L2-2
Function multi      L4-4
Function multi      L5-5

guarded is gone. multi is emitted twice — once per clause — which is correct at parse time, but the built graph keeps only one multi node, with the last clause's line range.

Root cause

Guards. _elixir_function_name_and_params (code_review_graph/parser.py:6563) iterates the children of a def's arguments node and only handles two shapes: call (the head) and identifier (zero-arity). With a guard, tree-sitter-elixir wraps the head in a binary_operator — guarded(x) when is_binary(x) parses as binary_operator(left: call, operator: "when", right: call) — so neither branch matches, the function returns (None, None), and _extract_elixir_constructs bails at the if fn_name is None: return False check (parser.py:6671). The function is skipped with no diagnostic.

Fix sketch: when the child is a binary_operator with operator "when", descend into its left (recursively — chained guards like def f(x) when g1 when g2 are legal Elixir and nest further) and treat that node as the head.

Multi-clause. The parser is fine here; the loss happens downstream. Each clause produces a NodeInfo with the same qualified name, and the graph layer dedups by qualified name, so the last clause wins — line_start/line_end/params all come from clause N instead of spanning the definition. Merging per qualified name at insert time (min line_start, max line_end) or keying function nodes by name/arity would fix the line numbers at least.

Why it matters

On a real Elixir/OTP codebase this is not an edge case — guards are idiomatic. Indexing an 847-file Elixir umbrella repo, every public function written as def foo(x) when is_binary(x) do ... is absent from the graph, so callers_of / callees_of for those functions return nothing and the CALLS edges pointing at them are dropped. Spot-check on that repo: 5 of 5 public functions in one module (pattern_from_url, acquire_permit, release_permit, report_throttle, get_throttle_state — all guard-clause defs) produced zero nodes.

Activity

  1. tirth8205 commented on Nov 6, 2026

    @tirth8205
    Owner

    Reopening: #980 closed this automatically but fixes only part of it. Guarded def clauses are now indexed and short-form bodies are walked. A multi-clause function still keeps the line range of its last clause, so that part stays open.

  2. added a commit that references this issue on Nov 9, 2026
    7e05457
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions