Current section
Files
Jump to
Current section
Files
lib/rule/inconsistent_param_names.ex
defmodule Credence.Rule.InconsistentParamNames do
@moduledoc """
Detects functions where the same positional parameter uses different
variable names across clauses.
## Why this matters
LLMs generate function clauses semi-independently, often drifting
parameter names between clauses of the same function:
# Flagged — first arg is "current" in one clause, "prev" in another
defp do_fibonacci(current, _next, 0), do: current
defp do_fibonacci(prev, current, steps), do: do_fibonacci(current, prev + current, steps - 1)
# Consistent — same name at each position across all clauses
defp do_fibonacci(prev, _current, 0), do: prev
defp do_fibonacci(prev, current, steps), do: do_fibonacci(current, prev + current, steps - 1)
Inconsistent names make the reader question whether the function is
correct — if the first argument is called `current` in one clause and
`prev` in another, which is it?
## Detection scope (strict)
Only simple variables are compared. A position is **skipped** if
any clause uses a pattern, literal, or underscore-prefixed variable
at that position. This avoids false positives on legitimate
pattern-matching dispatch like:
def handle({:ok, result}), do: result
def handle({:error, reason}), do: raise reason
Single-clause functions are never flagged.
## Severity
`:warning`
"""
@behaviour Credence.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
clauses = collect_clauses(ast)
clauses
|> Enum.group_by(fn {name, arity, _args, _meta, _def_type} -> {name, arity} end)
|> Enum.flat_map(fn {_key, group} -> analyze_group(group) end)
|> Enum.sort_by(fn issue -> issue.meta[:line] || 0 end)
end
# ------------------------------------------------------------
# CLAUSE COLLECTION
# ------------------------------------------------------------
defp collect_clauses(ast) do
{_ast, clauses} =
Macro.prewalk(ast, [], fn node, acc ->
case extract_clause(node) do
{:ok, clause} -> {node, [clause | acc]}
:error -> {node, acc}
end
end)
Enum.reverse(clauses)
end
# Guarded form must come first (same issue as NoUnderscoreFunctionName)
defp extract_clause({def_type, meta, [{:when, _, [{fn_name, _, args}, _guard]}, _body]})
when def_type in [:def, :defp] and is_atom(fn_name) and is_list(args) do
{:ok, {fn_name, length(args), args, meta, def_type}}
end
defp extract_clause({def_type, meta, [{fn_name, _, args}, _body]})
when def_type in [:def, :defp] and is_atom(fn_name) and is_list(args) do
{:ok, {fn_name, length(args), args, meta, def_type}}
end
defp extract_clause(_), do: :error
# ------------------------------------------------------------
# GROUP ANALYSIS
# ------------------------------------------------------------
defp analyze_group(clauses) when length(clauses) < 2, do: []
defp analyze_group(clauses) do
[{name, arity, _, _, def_type} | _] = clauses
args_lists = Enum.map(clauses, fn {_, _, args, _, _} -> args end)
meta = clauses |> hd() |> elem(3)
Enum.flat_map(0..(arity - 1), fn pos ->
names_at_pos =
args_lists
|> Enum.map(fn args -> Enum.at(args, pos) end)
|> Enum.map(&extract_simple_var/1)
if Enum.any?(names_at_pos, &is_nil/1) do
# At least one clause uses a pattern, literal, or underscore
# at this position — skip to avoid false positives.
[]
else
unique_names = Enum.uniq(names_at_pos)
if length(unique_names) > 1 do
[build_issue(def_type, name, arity, pos + 1, unique_names, meta)]
else
[]
end
end
end)
end
# ------------------------------------------------------------
# VARIABLE EXTRACTION
#
# Returns the atom name for simple, non-underscore variables.
# Returns nil for everything else: patterns, literals, pins,
# and underscore-prefixed names. When any clause returns nil
# for a position, that position is skipped entirely.
# ------------------------------------------------------------
defp extract_simple_var({name, _, ctx}) when is_atom(name) and is_atom(ctx) do
str = Atom.to_string(name)
if String.starts_with?(str, "_") do
nil
else
name
end
end
defp extract_simple_var(_), do: nil
# ------------------------------------------------------------
# MESSAGE GENERATION
# ------------------------------------------------------------
defp build_issue(def_type, name, arity, position, conflicting_names, meta) do
%Issue{
rule: :inconsistent_param_names,
severity: :warning,
message: build_message(def_type, name, arity, position, conflicting_names),
meta: %{line: Keyword.get(meta, :line)}
}
end
defp build_message(def_type, name, arity, position, conflicting_names) do
names_str = conflicting_names |> Enum.map(&"`#{&1}`") |> Enum.join(", ")
"""
Inconsistent parameter names in `#{def_type} #{name}/#{arity}` \
at position #{position}: #{names_str}.
Using different names for the same parameter across clauses makes \
the code harder to follow. Choose one name and use it consistently, \
or use `_` to indicate the parameter is unused in that clause.
"""
end
end