ForgeCredoChecks
Custom Credo checks targeting Enum
anti-patterns LLMs (and some humans) commonly produce in Elixir code.
Stock Credo ships rules for filter |> filter, reject |> reject,
map |> join, etc. (same operation chained, or map terminating in a
collector). It does not catch chains where one operation composes
with the complementary one. These checks fill that gap.
Rules
Two-pass Enum chains: use a comprehension
| Rule | Pattern flagged |
|---|---|
ForgeCredoChecks.FilterMap | Enum.filter |> Enum.map |
ForgeCredoChecks.RejectMap | Enum.reject |> Enum.map |
ForgeCredoChecks.MapReject | Enum.map |> Enum.reject |
ForgeCredoChecks.MapRejectNil | Enum.map |> Enum.reject(&is_nil/1) |
Hand-rolled map building: use Map.new/2
| Rule | Pattern flagged |
|---|---|
ForgeCredoChecks.MapNewFromInto | Enum.into(%{}, fn ...) |
ForgeCredoChecks.MapNewFromReduce | Enum.reduce(_, %{}, &Map.put(acc, k, v)) |
Wasteful list-extremum patterns
| Rule | Pattern flagged | Replacement |
|---|---|---|
ForgeCredoChecks.ReverseListFirst | xs |> Enum.reverse() |> List.first() | List.last(xs) |
ForgeCredoChecks.SortListFirst | Enum.sort \| List.first | Enum.min/Enum.max/*_by |
with-macro conventions
| Rule | Pattern flagged | Configurable |
|---|---|---|
ForgeCredoChecks.WithBareBinding | = clauses inside a with chain (must be <-) | no |
ForgeCredoChecks.WithElseClauses | with blocks whose else exceeds :max_clauses | :max_clauses (default 1) |
ForgeCredoChecks.WithResultTag | <- clauses with atom-tagged LHS outside the allowlist | :allowed_atoms (default [:ok, :error]) |
The two-pass Enum chains walk the input twice and allocate intermediate
lists; a comprehension does both in one pass and preserves order naturally.
The map-building forms are pure equivalences with cleaner intent. The
sort-then-pick patterns are O(N log N) when O(N) suffices. The with
checks codify the convention that every clause uses <-, that step
return shapes get normalized in helpers (so non-matches fall through),
and that result tags stay within a project's intended vocabulary.
# Flagged by FilterMap
things
|> Enum.filter(&keep?/1)
|> Enum.map(&transform/1)
# Preferred replacement: comprehension (one pass, in-order, no reverse)
for x <- things, keep?(x), do: transform(x)
For the Enum-chain checks the suggested fix order is:
- Comprehension (preferred). Single pass, preserves order, no
intermediate list, no
reversestep. Enum.flat_map/2when the transform is naturally 0-or-more (e.g.parse(x)returningnil-or-value).Enum.reduce/3only as a last resort, and only when the consumer does not care about order. Do not tack on|> Enum.reverse/1to restore order: that second pass is exactly the tax the comprehension exists to avoid.
All Enum-chain rules detect the four AST shapes Elixir parses for any two-call chain: direct nested call, two-step pipe, partial pipe + call, and longer pipe chains.
Installation
Add to mix.exs:
def deps do
[
{:forge_credo_checks, "~> 0.3", only: [:dev, :test], runtime: false}
]
end
Then add to .credo.exs:
%{
configs: [
%{
name: "default",
checks: [
# ...
{ForgeCredoChecks.FilterMap, []},
{ForgeCredoChecks.RejectMap, []},
{ForgeCredoChecks.MapReject, []},
{ForgeCredoChecks.MapRejectNil, []},
{ForgeCredoChecks.MapNewFromInto, []},
{ForgeCredoChecks.MapNewFromReduce, []},
{ForgeCredoChecks.ReverseListFirst, []},
{ForgeCredoChecks.SortListFirst, []},
{ForgeCredoChecks.WithBareBinding, []},
{ForgeCredoChecks.WithElseClauses, []},
{ForgeCredoChecks.WithResultTag, []}
]
}
]
}License
MIT