mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 07:29:33 +00:00
fix(elixir): emit calls edges for keyword-form def bodies
One-line definitions (`def a(x), do: b(x)`) have no do_block node: the
body is the value of the `do:` pair in the arguments keywords node. 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. When
there is no do_block, take the `do:` pair value as the body and walk it
the same way (Fixes #4207).
AI assistance: implementation drafted with Muse (Anthropic), reviewed and
verified by the author.
Signed-off-by: Raphael Malikian <rtmalikian@gmail.com>
(cherry picked from commit b00d3704db)
This commit is contained in:
1 parent
e63386fc0c
commit
c36833d13f
2 files changed
+101
No files matched your search
@@ -169,6 +169,29 @@ def extract_elixir(path: Path) -> dict:
|
||||
aliases.setdefault(parent, {})[first] = f"{parent_name}.{first}"
|
||||
name = f"{parent_name}.{name}"
|
||||
module_names[nid] = name
|
||||
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:
|
||||
if node.type != "call":
|
||||
@@ -305,6 +328,14 @@ def extract_elixir(path: Path) -> dict:
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user