Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions graphify/extractors/elixir.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,30 @@ def _get_defimpl_target(node) -> str | None:
return val
return None

def _get_do_keyword_body(node):
"""The body of a keyword-form definition (`def f(x), do: expr`).

tree-sitter-elixir puts the keyword body in the `arguments` node's
trailing `keywords` child, as the value of the `pair` whose keyword
is `do:` (#4207). Returns the value node, or None when the definition
uses a `do_block` (or has no keyword body at all).
"""
if node is None:
return None
for child in node.children:
if child.type != "keywords":
continue
for pair in child.children:
if pair.type != "pair":
continue
keyword_text = None
for sub in pair.children:
if sub.type == "keyword":
keyword_text = source[sub.start_byte:sub.end_byte].decode("utf-8", errors="replace")
elif keyword_text is not None and keyword_text.rstrip(": ").strip() == "do":
return sub
return None

def walk(node, parent_module_nid: str | None = None) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — walk()

fans out to 7 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

if node.type != "call":
for child in node.children:
Expand Down Expand Up @@ -251,6 +275,14 @@ def walk(node, parent_module_nid: str | None = None) -> None:
add_edge(file_nid, func_nid, "contains", line)
if do_block_node:
function_bodies.append((func_nid, do_block_node))
else:
# Keyword form (`def f(x), do: expr`) has no `do_block`; its
# body is the value of the `do:` pair in `arguments` (#4207).
# Without this the body is never walked and its calls are
# silently dropped from the graph.
keyword_body = _get_do_keyword_body(arguments_node)
if keyword_body is not None:
function_bodies.append((func_nid, keyword_body))
return

if keyword in _IMPORT_KEYWORDS and arguments_node:
Expand Down
70 changes: 70 additions & 0 deletions tests/test_elixir_keyword_def_calls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Keyword-form Elixir definitions keep their calls edges (#4207).

A one-line definition (`def a(x), do: b(x)`) has no `do_block` node: the
body is the value of the `do:` pair in the `arguments` keywords. The
extractor only registered `do_block` bodies for the call-walk pass, so every
call inside a keyword-form body was silently dropped from the graph.
"""

from __future__ import annotations

from pathlib import Path

from graphify.extract import extract

_CORPUS = {
"lib/short.ex": (
"defmodule App.Short do\n"
" def a(x), do: b(x)\n"
"\n"
" def b(x) do\n"
" x\n"
" end\n"
"\n"
" defp c(x), do: d(x) |> e(x)\n"
"\n"
" def g(x) when is_list(x), do: b(x)\n"
"end\n"
),
}


def _calls(tmp_path: Path) -> set[tuple[str, str]]:
paths = []
for name, body in _CORPUS.items():
path = tmp_path / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
paths.append(path)
result = extract(paths, cache_root=tmp_path / "graphify-out")
label = {n["id"]: n["label"] for n in result["nodes"]}
return {
(label[e["source"]], label[e["target"]])
for e in result["edges"]
if e["relation"] == "calls" and e["source"] in label and e["target"] in label
}


def test_keyword_def_body_calls_are_extracted(tmp_path: Path):
"""`def a(x), do: b(x)` must emit a calls edge a() -> b()."""
calls = _calls(tmp_path)
assert ("a()", "b()") in calls


def test_guarded_keyword_def_body_calls_are_extracted(tmp_path: Path):
"""A `when`-guarded keyword head keeps its keyword body walkable."""
calls = _calls(tmp_path)
assert ("g()", "b()") in calls


def test_keyword_def_without_local_targets_emits_no_dangling_edges(tmp_path: Path):
"""`defp c(x), do: d(x) |> e(x)` references undefined functions: the walk
must not mint calls edges to non-existent nodes."""
calls = _calls(tmp_path)
assert not any(src == "c()" for src, _ in calls)


def test_block_def_behavior_is_unchanged(tmp_path: Path):
"""The block form still registers its function node exactly once."""
calls = _calls(tmp_path)
assert ("b()", "b()") not in calls # no self-edge invented
Loading