Current section

Files

Jump to
credence lib pattern hallucinated_guard.ex
Raw

lib/pattern/hallucinated_guard.ex

defmodule Credence.Pattern.HallucinatedGuard do
@moduledoc """
Fixes calls to guard functions that don't exist in Elixir.
LLMs sometimes hallucinate guard names from Erlang typespecs or other
languages. This rule detects known hallucinated guards and replaces
them with their correct Elixir equivalents.
## Replacements
is_pos_integer(x) → is_integer(x) and x > 0
is_non_neg_integer(x) → is_integer(x) and x >= 0
is_neg_integer(x) → is_integer(x) and x < 0
is_non_pos_integer(x) → is_integer(x) and x <= 0
"""
use Credence.Pattern.Rule
alias Credence.Issue
@hallucinated_guards %{
is_pos_integer: {:>, 0},
is_non_neg_integer: {:>=, 0},
is_neg_integer: {:<, 0},
is_non_pos_integer: {:<=, 0}
}
@guard_names Map.keys(@hallucinated_guards)
@impl true
def check(ast, _opts) do
active =
if imports_or_uses?(ast),
do: [],
else: @guard_names -- defined_guards(ast)
{_ast, issues} =
Macro.prewalk(ast, [], fn
{name, meta, [_arg]} = node, issues when is_atom(name) ->
if name in active do
{node, [build_issue(name, meta) | issues]}
else
{node, issues}
end
node, issues ->
{node, issues}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
if imports_or_uses?(ast) do
[]
else
do_fix(ast)
end
end
defp do_fix(ast) do
defined = defined_guards(ast)
Credence.RuleHelpers.patches_from_postwalk(ast, fn
{name, _, [arg]} = node when is_atom(name) ->
case Map.get(@hallucinated_guards, name) do
{op, bound} ->
if name in defined do
node
else
{:and, [], [{:is_integer, [], [arg]}, {op, [], [arg, bound]}]}
end
nil ->
node
end
node ->
node
end)
end
# A module that `import`s or `use`s another module may bring these guard names
# into scope as REAL custom guards (e.g. `import MyApp.Guards` defining
# `is_pos_integer/1`). We can't resolve the other module's definitions, and an
# unqualified guard call that compiles must be locally defined OR imported — so
# if any `import`/`use` is present, do not treat these names as hallucinated
# (unrolling an imported guard, possibly to a divergent definition, is unsafe).
defp imports_or_uses?(ast) do
{_ast, found} =
Macro.prewalk(ast, false, fn
_node, true -> {nil, true}
{directive, _, [_ | _]} = node, _ when directive in [:import, :use] -> {node, true}
node, acc -> {node, acc}
end)
found
end
# Names from `@guard_names` that the module DEFINES as a guard via
# `defguard`/`defguardp` at arity 1. A defined guard is not hallucinated — it
# exists — so it must be left untouched everywhere: rewriting it would corrupt
# the definition head (`defguardp is_pos_integer(x) when ...` → invalid) and
# needlessly unroll the user's own abstraction at its call sites.
defp defined_guards(ast) do
{_ast, defined} =
Macro.prewalk(ast, [], fn
{dg, _, [head | _]} = node, acc when dg in [:defguard, :defguardp] ->
case guard_head(head) do
{name, 1} -> {node, [name | acc]}
_ -> {node, acc}
end
node, acc ->
{node, acc}
end)
Enum.filter(@guard_names, &(&1 in defined))
end
# The `name/arity` a `defguard(p)` head defines, stripping the `when` guard.
defp guard_head({:when, _, [call, _guard]}), do: guard_head(call)
defp guard_head({name, _, args}) when is_atom(name) and is_list(args), do: {name, length(args)}
defp guard_head(_), do: nil
defp build_issue(name, meta) do
%Issue{
rule: :hallucinated_guard,
message:
"`#{name}/1` does not exist in Elixir. " <>
"Replace with the equivalent guard expression.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end