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

RulePattern flagged
ForgeCredoChecks.FilterMapEnum.filter |> Enum.map
ForgeCredoChecks.RejectMapEnum.reject |> Enum.map
ForgeCredoChecks.MapRejectEnum.map |> Enum.reject
ForgeCredoChecks.MapRejectNilEnum.map |> Enum.reject(&is_nil/1)

Hand-rolled map building: use Map.new/2

RulePattern flagged
ForgeCredoChecks.MapNewFromIntoEnum.into(%{}, fn ...)
ForgeCredoChecks.MapNewFromReduceEnum.reduce(_, %{}, &Map.put(acc, k, v))

Wasteful list-extremum patterns

RulePattern flaggedReplacement
ForgeCredoChecks.ReverseListFirstxs |> Enum.reverse() |> List.first()List.last(xs)
ForgeCredoChecks.SortListFirstEnum.sort \\| List.firstEnum.min/Enum.max/*_by

with-macro conventions

RulePattern flaggedConfigurable
ForgeCredoChecks.WithBareBinding= clauses inside a with chain (must be <-)no
ForgeCredoChecks.WithElseClauseswith 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

RulePattern flagged
ForgeCredoChecks.MapGetWithOr`Map.get(_, _)
ForgeCredoChecks.ChainedMapGet`Map.get(_, _)

LLM tells (function shape and idiomatic Elixir)

RulePattern flagged
ForgeCredoChecks.InconsistentParamNamesmulti-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.NoKernelShadowingvariable 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.NoUnnecessaryCatchAllRaisedef/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.NoCaseTrueFalsecase <bool_expr> do true -> ...; false -> ... end (and true/_, false/_ variants)
ForgeCredoChecks.NoKernelOpInPipelinepipeline |> Kernel.<op>(arg) for comparison/boolean operators (==/!=/</>/<=/>=/===/!==/and/or)
ForgeCredoChecks.NoInlineRegexinline ~r/~R regex sigils inside function bodies; define regexes in module attributes instead
ForgeCredoChecks.MultilineStringConcatmulti-line string-literal <> concatenation ("a" <> "b" <> "c" across lines); use a heredoc instead

Typespec precision

RulePattern flagged
ForgeCredoChecks.NoAnyOrTermTypesany() / 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.

RulePattern flaggedConfigurable
ForgeCredoChecks.LargeStructdefstruct with :max_fields or more fields (a map of 32+ keys drops the BEAM flat-map representation):max_fields (default 32)
ForgeCredoChecks.UnsupervisedSpawnraw spawn/spawn_link/spawn_monitor/Process.spawn (start the process under a supervisor instead):excluded_paths
ForgeCredoChecks.NamespaceTrespassingdefmodule 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.

RulePattern flaggedConfigurable
ForgeCredoChecks.OneModulePerFileevery literal defmodule after the first in a source file, including nested modules; quoted AST is ignored:excluded_paths (defaults to test files)
ForgeCredoChecks.PortProducerBoundaryexternal-model producers (subprocess/HTTP dispatch) defined outside a declared boundary moduleboundary allow-lists
ForgeCredoChecks.NoSourceInspectionInTesttests 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.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.TimingAndPrivateStateGuardactual Process.sleep/1 and :sys.replace_state/2 call nodes; string and atom 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:

  1. Comprehension (preferred). Single pass, preserves order, no intermediate list, no reverse step.
  2. Enum.flat_map/2 when the transform is naturally 0-or-more (e.g. parse(x) returning nil-or-value).
  3. Enum.reduce/3 only as a last resort, and only when the consumer does not care about order. Do not tack on |> Enum.reverse/1 to 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.TaintedSourceInspection, []},
{ForgeCredoChecks.TimingAndPrivateStateGuard, []},
{ForgeCredoChecks.MultilineStringConcat, []}
]
}
]
}

License

MIT