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, or project-specific data-shape smells like
Map.get/2 || fallback. 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]) |
Map shape normalization
| Rule | Pattern flagged |
|---|---|
ForgeCredoChecks.MapGetWithOr |
`Map.get(_, _) |
ForgeCredoChecks.ChainedMapGet |
`Map.get(_, _) |
LLM tells (function shape and idiomatic Elixir)
| Rule | Pattern flagged |
|---|---|
ForgeCredoChecks.InconsistentParamNames |
multi-clause function (within the same module) whose same positional argument has different base names across clauses. Clauses in separate defimpl/defmodule blocks are never cross-compared. |
ForgeCredoChecks.NoKernelShadowing |
variable binding (=, fn, def/defp param) named max/min/length/elem/hd/tl/abs/round/trunc/div/rem/tuple_size/map_size/byte_size/bit_size |
ForgeCredoChecks.NoUnnecessaryCatchAllRaise |
def/defp catch-all clause (every arg wildcard, body is raise(...)) when at least one sibling clause exists for the same {name, arity} in the same module. Single-clause raise functions (stubs, arity redirects) are not flagged. |
ForgeCredoChecks.NoCaseTrueFalse |
case <bool_expr> do true -> ...; false -> ... end (and true/_, false/_ variants) |
ForgeCredoChecks.NoKernelOpInPipeline |
pipeline |> Kernel.<op>(arg) for comparison/boolean operators (==/!=/</>/<=/>=/===/!==/and/or) |
ForgeCredoChecks.NoInlineRegex |
inline ~r/~R regex sigils inside function bodies; define regexes in module attributes instead |
ForgeCredoChecks.MultilineStringConcat |
multi-line string-literal <> concatenation ("a" <> "b" <> "c" across lines); use a heredoc instead |
Typespec precision
| Rule | Pattern flagged |
|---|---|
ForgeCredoChecks.NoAnyOrTermTypes |
any() / term() in @spec, @callback, @macrocallback, @type, @typep, and @opaque declarations. Each occurrence reports its own column, so several broad types on one line are distinct findings you can act on individually. |
Official Elixir anti-patterns
A few of the official Elixir anti-patterns
that have a reliable AST signature. The rest of the catalog is either already
covered by stock Credo (e.g. Warning.UnsafeToAtom, Refactor.FunctionArity,
Refactor.WithClauses) or is too semantic to detect statically.
| Rule | Pattern flagged | Configurable |
|---|---|---|
ForgeCredoChecks.LargeStruct |
defstruct with :max_fields or more fields (a map of 32+ keys drops the BEAM flat-map representation) |
:max_fields (default 32) |
ForgeCredoChecks.UnsupervisedSpawn |
raw spawn/spawn_link/spawn_monitor/Process.spawn (start the process under a supervisor instead) |
:excluded_paths |
ForgeCredoChecks.NamespaceTrespassing |
defmodule whose top segment is a dependency's namespace (Phoenix.*, Ecto.*, ...) |
:namespaces |
Architecture and test discipline
Boundary checks that enforce project structure rather than a language idiom.
| Rule | Pattern flagged | Configurable |
|---|---|---|
ForgeCredoChecks.FileLength |
source files longer than :max_lines lines, counting documentation, comments and blank lines (test files are not checked), and allowlisted files that are back within the limit so their entry gets removed |
:max_lines (default 800), :allowlist (exact paths relative to where mix credo runs, default []) |
ForgeCredoChecks.OneModulePerFile |
every literal defmodule after the first in a source file, including nested modules; quoted AST is ignored |
:excluded_paths (defaults to test files) |
ForgeCredoChecks.PortProducerBoundary |
external-model producers (subprocess/HTTP dispatch) defined outside a declared boundary module | boundary allow-lists |
ForgeCredoChecks.NoSourceInspectionInTest |
tests that read or AST-parse lib/*.ex source (File.read! / Code.string_to_quoted, literal or carried as data) instead of exercising the real function |
:included_paths |
ForgeCredoChecks.NoTelemetryAssertionsInTest |
tests that attach telemetry handlers (directly or via apply/3) or assert on telemetry events instead of behavioral outcomes; resolves event names lifted into module attributes |
:telemetry_event_roots (defaults to six event roots) |
ForgeCredoChecks.NoGlobalPubSubWildcardRefute |
refute_receive/refute_received on a globally subscribed PubSub event where every payload element is an unpinned variable (a leading underscore is irrelevant) |
:global_subscriptions (declare your project's subscribe functions and the tags they deliver) |
ForgeCredoChecks.NoDetsInfoOpenGuard |
:dets.info/1 compared with :undefined as a stale guard around :dets.open_file/2, including through a single-assignment binding of the info result |
no |
ForgeCredoChecks.TaintedSourceInspection |
=~ / String.contains? / Regex.* / Code.eval_string applied to text tainted from File.read! / File.stream! of non-test .ex / .exs source |
:excluded_paths |
ForgeCredoChecks.MimicCopyOutsideRegistry |
Mimic.copy (and a bare copy reached through use/import Mimic) called anywhere but the suite's single registration point, which copies a module the mocking registry never sees |
:included_paths, :registration_paths, :registry |
ForgeCredoChecks.TimingAndPrivateStateGuard |
actual Process.sleep/1, :timer.sleep/1, :sys.replace_state/2, and :sys.get_state/1,2 call nodes, including piped and apply/3 forms; string, atom, comment, and capture mentions are not flagged |
:excluded_paths |
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.7", 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, []},
{ForgeCredoChecks.InconsistentParamNames, []},
{ForgeCredoChecks.NoKernelShadowing, []},
{ForgeCredoChecks.NoUnnecessaryCatchAllRaise, []},
{ForgeCredoChecks.NoCaseTrueFalse, []},
{ForgeCredoChecks.NoKernelOpInPipeline, []},
{ForgeCredoChecks.NoInlineRegex, []},
{ForgeCredoChecks.NoAnyOrTermTypes, []},
{ForgeCredoChecks.MapGetWithOr, []},
{ForgeCredoChecks.ChainedMapGet, []},
{ForgeCredoChecks.LargeStruct, []},
{ForgeCredoChecks.UnsupervisedSpawn, []},
{ForgeCredoChecks.NamespaceTrespassing, []},
{ForgeCredoChecks.OneModulePerFile, []},
{ForgeCredoChecks.FileLength, []},
{ForgeCredoChecks.NoTelemetryAssertionsInTest,
telemetry_event_roots: [:my_app, :my_library]},
{ForgeCredoChecks.NoGlobalPubSubWildcardRefute,
global_subscriptions: [subscribe_orders: [:order_placed, :order_cancelled]]},
{ForgeCredoChecks.NoDetsInfoOpenGuard, []},
{ForgeCredoChecks.MimicCopyOutsideRegistry, []},
{ForgeCredoChecks.TaintedSourceInspection, []},
{ForgeCredoChecks.TimingAndPrivateStateGuard, []},
{ForgeCredoChecks.MultilineStringConcat, []}
]
}
]
}
License
MIT