Skip to main content

README.md

# ForgeCredoChecks

Custom [Credo](https://github.com/rrrene/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.

```elixir
# 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`:

```elixir
def deps do
  [
    {:forge_credo_checks, "~> 0.3", only: [:dev, :test], runtime: false}
  ]
end
```

Then add to `.credo.exs`:

```elixir
%{
  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