Current section

Files

Jump to
credence lib rule no_map_then_aggregate.ex
Raw

lib/rule/no_map_then_aggregate.ex

defmodule Credence.Rule.NoMapThenAggregate do
@moduledoc """
Detects `Enum.map/2` immediately followed by a terminal aggregation
like `Enum.max/1`, `Enum.min/1`, or `Enum.sum/1`, which creates an
unnecessary intermediate list.
## Why this matters
LLMs default to "transform then aggregate" as the natural functional
decomposition. While readable, the intermediate list from `Enum.map`
is allocated only to be traversed once and discarded:
# Flagged — two passes, intermediate list allocation
numbers
|> Enum.chunk_every(k, 1, :discard)
|> Enum.map(&Enum.sum/1)
|> Enum.max()
# Better — single pass, no intermediate list
numbers
|> Enum.chunk_every(k, 1, :discard)
|> Enum.reduce(fn chunk, best -> max(Enum.sum(chunk), best) end)
For `max` and `min`, the fix is `Enum.reduce/2` with `max/2` or
`min/2`. For `sum`, the fix is `Enum.reduce/3` accumulating the
result directly.
## Flagged patterns
`Enum.map(f)` piped into or wrapping:
- `Enum.max/1`
- `Enum.min/1`
- `Enum.sum/1`
Both pipeline and direct-call nesting forms are detected.
## Severity
`:warning`
"""
@behaviour Credence.Rule
alias Credence.Issue
@aggregators [:max, :min, :sum]
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn node, issues ->
case check_node(node) do
{:ok, issue} -> {node, [issue | issues]}
:error -> {node, issues}
end
end)
Enum.reverse(issues)
end
# ------------------------------------------------------------
# NODE MATCHING
# ------------------------------------------------------------
# Pipeline form: ... |> Enum.map(f) |> Enum.max()
defp check_node({:|>, meta, _} = node) do
pipeline = flatten_pipeline(node)
check_pipeline(pipeline, meta)
end
# Direct call form: Enum.max(Enum.map(enum, f))
defp check_node({{:., meta, [mod, agg_fn]}, _, [inner]})
when agg_fn in @aggregators do
if enum_module?(mod) and map_call?(inner) do
{:ok, build_issue(agg_fn, meta)}
else
:error
end
end
defp check_node(_), do: :error
# ------------------------------------------------------------
# PIPELINE ANALYSIS
# ------------------------------------------------------------
defp check_pipeline(steps, meta) do
steps
|> Enum.chunk_every(2, 1, :discard)
|> Enum.find_value(fn [first, second] ->
if map_step?(first) and agg_step?(second) do
{:ok, build_issue(agg_fn_name(second), meta)}
end
end)
|> case do
{:ok, _} = result -> result
_ -> :error
end
end
# ------------------------------------------------------------
# STEP DETECTION
# ------------------------------------------------------------
# Enum.map(enum, f) — direct call (2 args)
defp map_call?({{:., _, [mod, :map]}, _, args})
when is_list(args) and length(args) == 2 do
enum_module?(mod)
end
defp map_call?(_), do: false
# Enum.map(f) — pipeline form (1 arg)
defp map_step?({{:., _, [mod, :map]}, _, args})
when is_list(args) and length(args) in [1, 2] do
enum_module?(mod)
end
defp map_step?(_), do: false
# Enum.max/min/sum() — pipeline form (0 args)
# Enum.max/min/sum(enum) — direct call (1 arg, but handled separately)
defp agg_step?({{:., _, [mod, fn_name]}, _, args})
when fn_name in @aggregators and is_list(args) and length(args) in [0, 1] do
enum_module?(mod)
end
defp agg_step?(_), do: false
defp agg_fn_name({{:., _, [_, fn_name]}, _, _}), do: fn_name
# ------------------------------------------------------------
# HELPERS
# ------------------------------------------------------------
defp flatten_pipeline({:|>, _, [left, right]}) do
flatten_pipeline(left) ++ [right]
end
defp flatten_pipeline(expr), do: [expr]
defp enum_module?({:__aliases__, _, [:Enum]}), do: true
defp enum_module?(_), do: false
# ------------------------------------------------------------
# MESSAGE GENERATION
# ------------------------------------------------------------
defp build_issue(agg_fn, meta) do
%Issue{
rule: :no_map_then_aggregate,
severity: :warning,
message: build_message(agg_fn),
meta: %{line: Keyword.get(meta, :line)}
}
end
defp build_message(:max) do
"""
`Enum.map/2` piped into `Enum.max/1` creates an intermediate list.
Fuse into a single pass:
Enum.reduce(enumerable, fn x, best -> max(f.(x), best) end)
"""
end
defp build_message(:min) do
"""
`Enum.map/2` piped into `Enum.min/1` creates an intermediate list.
Fuse into a single pass:
Enum.reduce(enumerable, fn x, best -> min(f.(x), best) end)
"""
end
defp build_message(:sum) do
"""
`Enum.map/2` piped into `Enum.sum/1` creates an intermediate list.
Fuse into a single pass:
Enum.reduce(enumerable, 0, fn x, acc -> acc + f.(x) end)
"""
end
end