Skip to main content

README.md

# LexCredo

[![Last Commit](https://img.shields.io/github/last-commit/sippy-platform/lex_credo)](https://github.com/sippy-platform/lex_credo)
[![Hex Version](https://img.shields.io/hexpm/v/lex_credo)](https://hex.pm/packages/lex_credo)
[![Hex Downloads](https://img.shields.io/hexpm/dt/lex_credo)](https://hex.pm/packages/lex_credo)
[![Documentation](https://img.shields.io/badge/hex-docs-purple?logo=elixir)](https://hexdocs.pm/lex_credo)
[![Coverage](https://codecov.io/gh/sippy-platform/lex_credo/graph/badge.svg)](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.2.0", 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.NoEnumAllAssert, []},
    {LexCredo.Check.Warning.NoProcessSleepInTests, []},
    {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.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` 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)
assert Process.alive?(pid)

# preferred
ref = Process.monitor(pid)
assert_receive {:DOWN, ^ref, :process, ^pid, _reason}
```

---

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