Current section
Files
Jump to
Current section
Files
README.md
# LexCredo
[](https://github.com/sippy-platform/lex_credo)
[](https://hex.pm/packages/lex_credo)
[](https://hex.pm/packages/lex_credo)
[](https://hexdocs.pm/lex_credo)
[](https://codecov.io/gh/sippy-platform/lex_credo)
A collection of custom [Credo](https://github.com/rrrene/credo) checks for Elixir projects, designed to catch common anti-patterns and guide both **human developers and AI coding agents** toward idiomatic, maintainable Elixir code.
> **Note:** This library was originally created to assist AI agents (such as Copilot, Claude, and similar tools) in avoiding common Elixir anti-patterns that they tend to reproduce. The checks are equally useful for human developers, but the primary motivation was to give AI-assisted codebases a static analysis layer that pushes back on known-bad patterns before they land in review.
Anti-patterns targeted by this library are drawn primarily from:
- [Elixir's official anti-patterns guide](https://hexdocs.pm/elixir/what-anti-patterns.html)
- [Chris Keathley's "Good and Bad Elixir"](https://keathley.io/blog/good-and-bad-elixir.html)
> #### Note {: .tip}
>
> Checks are here to assist, not to legislate. Some rules in this library are genuinely controversial within the Elixir community — reasonable people disagree. The goal is to nudge toward good defaults, not to enforce a single style religion. If a check doesn't fit your project's conventions, disable it. See [Disabling Checks](#disabling-checks).
---
## Installation
Add `lex_credo` to your dependencies in `mix.exs`. It is typically only needed in `:dev` and `:test`:
```elixir
def deps do
[
{:lex_credo, "~> 0.4.1", only: [:dev, :test], runtime: false}
]
end
```
Then run:
```sh
mix deps.get
```
---
## Usage
### 1. Add checks to your `.credo.exs`
In the `checks: %{enabled: [...]}` section of your `.credo.exs`, add any checks you want to enable:
```elixir
checks: %{
enabled: [
# ... your existing checks ...
{LexCredo.Check.Design.NoNestedModules, []},
{LexCredo.Check.Readability.DocExamplesSection, []},
{LexCredo.Check.Refactor.NoEnumWrapperFunctions, []},
{LexCredo.Check.Warning.StructMatchInFunctionHead, []},
{LexCredo.Check.Warning.NoComplexWithElse, []},
{LexCredo.Check.Warning.NoComments, []},
{LexCredo.Check.Warning.NoEnumAllAssert, []},
{LexCredo.Check.Warning.NoProcessSleepInTests, []},
{LexCredo.Check.Warning.NamedOtpBuiltins, []},
{LexCredo.Check.Warning.NonBooleanWithStrictOperator, []},
{LexCredo.Check.Warning.PreferBooleanOperators, []},
{LexCredo.Check.Warning.UsePositiveTypeGuards, []},
{LexCredo.Check.Warning.UseStartSupervised, []},
]
}
```
### 2. Run Credo
```sh
mix credo
# or for strict mode (includes low-priority checks):
mix credo --strict
```
---
## General Parameters
All checks accept the following parameters.
### `exclude_test_files`
Skip test files for this check. Default is `true` for `NoNestedModules` (which
skips test files by default to allow inline helper modules) and `false` for all
other checks.
```elixir
# Skip test files for a check that normally runs everywhere
{LexCredo.Check.Readability.DocExamplesSection, [exclude_test_files: true]}
# Run NoNestedModules in test files too
{LexCredo.Check.Design.NoNestedModules, [exclude_test_files: false]}
```
### Standard Credo parameters
- **`false`** — disable the check entirely:
```elixir
{LexCredo.Check.Warning.NoComplexWithElse, false}
```
- **`exit_status`** — make a check advisory-only (reports issues but does not
affect the exit code):
```elixir
{LexCredo.Check.Warning.PreferBooleanOperators, [exit_status: 0]}
```
- **`priority`** — override the check's base priority.
> #### Suppressing individual warnings {: .tip}
>
> Use a Credo inline comment to suppress a single occurrence without disabling
> the check globally:
>
> result = transform(value) # credo:disable-for-next-line LexCredo.Check.Warning.StructMatchInFunctionHead
---
## Included Checks
### Design
#### `LexCredo.Check.Design.NoNestedModules`
**Category:** Design | **Priority:** High
Flags `defmodule` blocks nested inside another `defmodule`. Nested modules obscure the module hierarchy and make code harder to navigate. Define each module in its own file instead.
```elixir
# flagged
defmodule Outer do
defmodule Inner do # <-- flagged
...
end
end
# preferred
defmodule Outer do ... end
defmodule Outer.Inner do ... end
```
> Skips test files, where anonymous and helper modules inline are common.
---
### Readability
#### `LexCredo.Check.Readability.DocExamplesSection`
**Category:** Readability | **Priority:** Normal
> #### Controversial {: .warning}
>
> Requiring an `## Examples` section in every public function doc is a strong convention that not all teams share. Some functions are genuinely self-documenting. Consider disabling this check if your team finds it too prescriptive.
Flags `@doc` strings on public functions that are missing an `## Examples` section. Inline examples improve discoverability and double as living documentation when used with `doctest`.
```elixir
# flagged — no Examples section
@doc """
Parses a date string.
"""
def parse_date(str), do: ...
# preferred
@doc """
Parses a date string.
## Examples
iex> MyApp.parse_date("2024-01-01")
{:ok, ~D[2024-01-01]}
"""
def parse_date(str), do: ...
```
> Does not fire on `@doc false` or `@doc nil`.
---
### Refactor
#### `LexCredo.Check.Refactor.NoEnumWrapperFunctions`
**Category:** Refactor | **Priority:** Normal
> #### Controversial {: .warning}
>
> There are legitimate reasons to wrap an `Enum` call in a named function (naming intent, easier testing, future extensibility). This check flags only the cases where the wrapper adds no abstraction value at all. If your wrapper function carries a meaningful name that clarifies intent, disable this check or add it to an allow-list.
Flags named functions (`def`/`defp`) whose entire body is a single `Enum` or `Stream` transformation call (`map`, `flat_map`, `each`, `map_reduce`, `flat_map_reduce`, `scan`). These wrappers add indirection without adding meaning — callers can compose `Enum` directly.
```elixir
# flagged
def user_names(users), do: Enum.map(users, & &1.name)
# preferred — inline at the call site
Enum.map(users, & &1.name)
# or, if transformation logic is complex, name the transform itself
def name_of(%User{name: name}), do: name
Enum.map(users, &name_of/1)
```
> Aggregation and predicate functions (`any?`, `all?`, `count`, `sum`, etc.) are intentionally excluded from this check.
---
### Warning
#### `LexCredo.Check.Warning.StructMatchInFunctionHead`
**Category:** Warning | **Priority:** Normal
Flags `%Struct{} = param` at the top level of a function body when `param` is a plain-variable argument. Moving the struct match to the function head makes the expected type visible in the signature, enables multi-clause dispatch, and allows Elixir's type checker to infer the parameter type directly from the function signature.
```elixir
# flagged
def process(data) do
%User{name: name, email: email} = data
send_welcome(name, email)
end
# preferred — struct contract visible in the signature
def process(%User{name: name, email: email}) do
send_welcome(name, email)
end
# preferred — keep the binding if you also need the whole struct
def process(%User{name: name, email: email} = data) do
send_welcome(name, email)
audit_log(data)
end
# not flagged — match is inside a case branch, not a top-level statement
def process(data) do
case condition() do
true -> %User{name: name} = data; name
false -> nil
end
end
```
---
#### `LexCredo.Check.Warning.NoComplexWithElse`
**Category:** Warning | **Priority:** Normal | **Configurable**
Flags `with` expressions whose `else` block has more than `max_else_clauses` clauses (default: `1`). Complex `else` blocks usually signal that error normalisation should happen in a helper or that a `case` expression is more appropriate.
```elixir
# flagged — 2 else clauses, default max is 1
with {:ok, user} <- fetch_user(id),
{:ok, post} <- fetch_post(user) do
post
else
{:error, :not_found} -> {:error, :user_not_found}
{:error, :forbidden} -> {:error, :access_denied}
end
# preferred — each function returns a normalised error atom,
# so a single catch-all clause in else is sufficient
with {:ok, user} <- fetch_user(id),
{:ok, post} <- fetch_post(user) do
post
else
{:error, reason} -> {:error, reason}
end
```
**Configuration:**
```elixir
{LexCredo.Check.Warning.NoComplexWithElse, [max_else_clauses: 2]}
```
---
#### `LexCredo.Check.Warning.NoComments`
**Category:** Warning | **Priority:** High | **Configured paths**
Flags every `#` comment and documentation attribute (`@moduledoc`, `@doc`,
`@typedoc`, and `@shortdoc`) in conventional Ecto migration files:
`priv/*/migrations/*.exs`. Migrations should be concise, executable records of
schema and data transitions. Keep the rationale for a migration, a field type,
or an exceptional operational requirement in the pull request, linked issue,
ADR, or deployment documentation instead.
**Configuration:**
The `paths:` parameter accepts exact relative file paths, relative directory
paths, and regular expressions. It defaults to conventional Ecto migration
directories, but makes the check useful for any code area where comments are
forbidden.
```elixir
# A single generated file
{LexCredo.Check.Warning.NoComments,
[paths: ["lib/my_app/generated/schema.ex"]]}
# All files beneath a directory
{LexCredo.Check.Warning.NoComments,
[paths: ["lib/my_app/generated"]]}
# Arbitrary matching files
{LexCredo.Check.Warning.NoComments,
[paths: [~r{^priv/.+/migrations/.+\.exs$}]}
```
For the rare migration where an inline comment is essential, explicitly disable
the check for that file:
```elixir
# credo:disable-for-this-file LexCredo.Check.Warning.NoComments
defmodule MyApp.Repo.Migrations.ExceptionalMigration do
use Ecto.Migration
# Documents an exceptional operational constraint.
def change, do: :ok
end
```
---
#### `LexCredo.Check.Warning.NoEnumAllAssert`
**Category:** Warning | **Priority:** Normal | **Test files only**
Flags `assert Enum.all?(collection, predicate)` in test files. When this assertion fails, you get no indication of *which* element failed. A `for` loop with individual assertions gives a specific failure message for each item.
```elixir
# flagged
assert Enum.all?(users, &(&1.active))
# preferred
for user <- users do
assert user.active, "expected user #{user.id} to be active"
end
```
---
#### `LexCredo.Check.Warning.NoProcessSleepInTests`
**Category:** Warning | **Priority:** High | **Test files only**
Flags `Process.sleep/1`, `:timer.sleep/1`, and `Process.alive?/1` in test files. `sleep` makes test suites slow and flaky; `alive?` produces race conditions. Use `Process.monitor/1` + `assert_receive {:DOWN, ...}` or `:sys.get_state/1` for deterministic synchronisation.
```elixir
# flagged
Process.sleep(100)
:timer.sleep(100)
assert Process.alive?(pid)
# preferred
ref = Process.monitor(pid)
assert_receive {:DOWN, ^ref, :process, ^pid, _reason}
```
---
#### `LexCredo.Check.Warning.NamedOtpBuiltins`
**Category:** Warning | **Priority:** Normal | **Configurable**
Flags literal `DynamicSupervisor` and `Registry` child specs without a `name:`
option. Naming these processes lets callers use the standard APIs without
passing PIDs around. Dynamically assembled options are not inspected.
```elixir
# flagged
{DynamicSupervisor, strategy: :one_for_one}
{Registry, keys: :unique}
# preferred
{DynamicSupervisor, strategy: :one_for_one, name: MyApp.DynamicSupervisor}
{Registry, keys: :unique, name: MyApp.Registry}
```
To skip test files:
```elixir
{LexCredo.Check.Warning.NamedOtpBuiltins, [exclude_test_files: true]}
```
---
#### `LexCredo.Check.Warning.NonBooleanWithStrictOperator`
**Category:** Warning | **Priority:** Normal
Flags `and`, `or`, and `not` when any operand is clearly not a boolean — such
as a struct field access without a `?` suffix, a non-boolean literal, or `nil`.
Using strict boolean operators with non-boolean operands either causes a runtime
`ArgumentError` (if the left operand is not `true`/`false`) or signals
misleading intent (if the expression can return a non-boolean value).
```elixir
# flagged — right side returns an ID, not a boolean; use &&
Enum.find_value(accounts, &(&1.extension.exten == number and &1.id))
# flagged — left side is a string field; and raises if it is nil
user.name and is_valid?(user)
# flagged — not requires a boolean argument; use !
not user.name
# preferred
&1.extension.exten == number && &1.id
user.name && is_valid?(user)
!user.name
# not flagged — ?-suffixed fields follow Elixir's boolean-returning convention
user.active? and is_admin?(user)
# not flagged — plain variables and unknown function calls; type cannot be
# determined statically
a and b
MyModule.fetch() and condition
```
---
#### `LexCredo.Check.Warning.PreferBooleanOperators`
**Category:** Warning | **Priority:** Normal
> #### Controversial {: .warning}
>
> The `&&`/`||`/`!` vs `and`/`or`/`not` distinction is one of the most debated style points in Elixir. Both sets of operators are valid in non-guard contexts. `&&`/`||` are more familiar to developers coming from other languages. This check reflects the opinion from the official anti-patterns guide that `and`/`or`/`not` should be preferred when operands are boolean-typed, since it signals intent more clearly. Disable if your team prefers `&&`/`||` uniformly.
Flags `&&`, `||`, and `!` when **both** operands are clearly boolean-yielding expressions (an `is_*` guard, a comparison, a boolean literal, or another boolean operator). In these cases, `and`, `or`, and `not` are preferred.
```elixir
# flagged — operands are boolean-typed
is_binary(x) && is_integer(y)
is_nil(x) || is_atom(x)
!is_nil(value)
# preferred
is_binary(x) and is_integer(y)
is_nil(x) or is_atom(x)
not is_nil(value)
# not flagged — truthy/falsy short-circuit idiom, not boolean-typed
user && user.name
config[:timeout] || 5_000
```
> See also `NonBooleanWithStrictOperator` for the complementary check.
---
#### `LexCredo.Check.Warning.UsePositiveTypeGuards`
**Category:** Warning | **Priority:** High
Flags negated type guards in function heads. A negated guard like `not is_nil(x)` does not tell you what `x` *is* — only what it isn't. Prefer a specific positive guard (`is_binary`, `is_integer`, etc.) that accurately constrains the clause.
```elixir
# flagged
def process(x) when not is_nil(x), do: ...
def process(x) when x != nil, do: ...
# preferred
def process(x) when is_binary(x), do: ...
```
Covers `not is_*()`, `!= nil`, and `!== nil` patterns. Recurses through compound `and`/`or` guards so all violations in a single guard are reported.
---
#### `LexCredo.Check.Warning.UseStartSupervised`
**Category:** Warning | **Priority:** Normal | **Test files only**
Flags direct `start_link`/`start` calls to `GenServer`, `Agent`, `Task`, `Supervisor`, and `DynamicSupervisor` in test files. Processes started this way are not automatically cleaned up when the test exits, which can cause interference between tests. Use `start_supervised!/1` (or `start_supervised/1`) instead.
```elixir
# flagged
{:ok, pid} = GenServer.start_link(MyServer, [])
# preferred
pid = start_supervised!(MyServer)
```
---
## Disabling Checks
To disable a check project-wide, move it to the `disabled:` list in `.credo.exs`:
```elixir
checks: %{
disabled: [
{LexCredo.Check.Warning.NoComplexWithElse, []},
]
}
```
See [General Parameters](#general-parameters) for per-occurrence suppression and
the [Credo inline config documentation](https://hexdocs.pm/credo/inline_config.html)
for all available directives.
---
## Contributing
New checks should follow the same structure as the existing ones: one module per file under `lib/lex_credo/check/<category>/`, using `use Credo.Check` and returning a list of issues from `run/2`. Run `mix precommit` before opening a pull request — it compiles, formats, runs Credo (including these checks on themselves), and runs the test suite.
```sh
mix precommit
```
---
## License
MIT