SephiaCredo

Hex.pmHexDocsLicense: MIT

Credo checks for common Elixir pitfalls.

SephiaCredo catches performance anti-patterns, incorrect operator usage, and dead code in your test setups — issues that the compiler and standard Credo rules miss.

Installation

SephiaCredo requires Credo to already be installed in your project.

If your project uses Igniter, a single command will add the dependency and register all checks in your .credo.exs:

mix igniter.install sephia_credo --only dev,test

Manual

Add sephia_credo to your list of dependencies in mix.exs:

def deps do
[
{:sephia_credo, "~> 0.1", only: [:dev, :test], runtime: false}
]
end

Then fetch the dependency and add the checks to the extra section of your .credo.exs:

mix deps.get
# .credo.exs
%{
configs: [
%{
name: "default",
checks: %{
extra: [
{SephiaCredo.Checks.AppendInLoop, []},
{SephiaCredo.Checks.AshCodeInterfaceReadWithArgs, []},
{SephiaCredo.Checks.AssertWithoutAssertion, []},
{SephiaCredo.Checks.ProcessSleepInTests, []},
{SephiaCredo.Checks.RawRuntimeError, []},
{SephiaCredo.Checks.StructComparisonOperator, []},
{SephiaCredo.Checks.UnusedSetupKeysInTests, []},
{SephiaCredo.Checks.UnusedSetupKeysPerTest, []}
# Opt-in (not enabled by default):
# {SephiaCredo.Checks.SysGetStateWithoutTimeoutInPoll, []}
]
}
}
]
}

Upgrading from 0.2

UnusedSetupKeysPerTest now flags only a test that consumes none of the setup keys in scope for it, instead of every test that fails to consume all of them. The old rule treated a shared fixture as a defect and was too noisy to enable — on a 767-file suite it reported 1747 issues, against 152 under the new rule. No config change is needed; expect far fewer reports.

Both setup-key checks now follow a context handed to a def/defp in the same file, so analyze(ctx, ...) helper patterns no longer report their keys as unused.

Upgrading from 0.1

NoDateTimeOperatorCompare has been replaced with the more general StructComparisonOperator (now also covers Decimal and Version, with a configurable extra_modules list). Update your .credo.exs: replace the old tuple with {SephiaCredo.Checks.StructComparisonOperator, []}.

Checks

CheckCategoryDescription
AppendInLoopRefactorFlags O(n²) ++ inside loops (reduce, fold, for/reduce, recursive functions)
AshCodeInterfaceReadWithArgsWarningFlags define :name, action: :read, args: [...] inside code_interface — Ash's generic :read action raises at runtime when called with args
AssertWithoutAssertionWarningFlags assert pattern = expr in tests where the bound variables are never used — the match succeeds vacuously
ProcessSleepInTestsRefactorFlags Process.sleep in *_test.exs files — causes flakes and slows the suite
RawRuntimeErrorWarningFlags raise "msg" and raise RuntimeError, ... — error trackers can't group these meaningfully
StructComparisonOperatorWarningForbids </>/<=/>=/==/!= on Date/Time/DateTime/NaiveDateTime/Decimal/Version — use *.compare/2 instead
SysGetStateWithoutTimeoutInPollWarning (opt-in)Flags :sys.get_state/1 inside a polling fn without surrounding try/catch :exit — flakes under load
UnusedSetupKeysInTestsDesignFlags setup return keys no test in scope consumes
UnusedSetupKeysPerTestDesignFlags a test that consumes none of the setup keys in scope for it

AppendInLoop

Appending to a list with ++ inside a loop (Enum.reduce, Enum.flat_map_reduce, for/reduce, or a recursive function) creates a new copy of the left-hand list on every iteration, turning an O(n) traversal into O(n²). This check flags those call sites and suggests prepending with [head | acc] and reversing at the end, or collecting into a different data structure.

AshCodeInterfaceReadWithArgs

Inside an Ash code_interface do ... end block, define :name, action: :read, args: [...] registers a code interface against Ash's generic :read action, which declares no inputs. Calling the resulting function raises Ash.Error.Invalid.NoSuchInput at runtime. The bug typically ships silently — LiveView callers wrap the call in else {:error, _} -> ... and the page just "doesn't do anything." Define a custom read action that declares the args, or remove args:.

AssertWithoutAssertion

assert x = expr (or any pattern with fresh bindings on the left) succeeds vacuously: the pattern always matches a bare variable, so the assertion tests nothing about expr. If the bound variables are never referenced afterward, the assertion is dead. Reference them in subsequent assertions, or use assert match?(pattern, expr). Test files only (*_test.exs).

ProcessSleepInTests

Process.sleep/1 in test bodies, setup blocks, or setup_all blocks causes timing-dependent flakes and slows the suite linearly. Prefer assert_receive, assert_eventually, or a polling helper. Test files only (*_test.exs).

RawRuntimeError

raise "msg" and raise RuntimeError, ... both lower to a RuntimeError exception. Error trackers (Appsignal, Sentry, etc.) group exceptions by module name — every distinct RuntimeError message becomes its own issue, hiding the signal in noise. Define a defexception module with a descriptive name and raise that instead.

StructComparisonOperator

Elixir's comparison operators (<, >, ==, etc.) use Erlang's term order on structs, which walks fields in declaration order. For most calendar/numeric structs this produces silently incorrect results — for example, Decimal.new("1.0") == Decimal.new("1.00") returns false, and Decimal.new("1.5") > Decimal.new("2") returns true. This check enforces the use of Date.compare/2, DateTime.compare/2, Decimal.compare/2, Version.compare/2, etc. instead. Built-in coverage: Date, Time, DateTime, NaiveDateTime, Decimal, Version. Configurable via extra_modules.

SysGetStateWithoutTimeoutInPoll (opt-in)

Inside a polling fn (configurable via poll_functions:, defaults to [:wait_until]), :sys.get_state(pid) without an explicit timeout uses the default 5-second timeout. If the GenServer is blocked (e.g. by cascading PubSub), the call raises :exit — which rescue doesn't catch — and the test crashes. Pass a short explicit timeout AND wrap in try ... catch :exit, _ -> false. Add this check manually to .credo.exs if you use poll-style test helpers.

UnusedSetupKeysInTests

ExUnit has no lazy let: every key a setup returns is built for every test in its scope, whether that test looks at it or not. This check flags a key that no test in scope consumes — a fixture the whole block pays for and nobody reads.

A test consumes a key by destructuring it (test "...", %{key: v}), by reading it off its context binding (ctx.key), or by handing the context to a def/defpin the same file that does either — so the common analyze(ctx, ...) helper pattern is understood. A context handed to something the check cannot read (an imported or remote function) makes the test opaque, and an opaque test suppresses the report rather than risking a false positive.

Before deleting a key to satisfy this check, confirm nothing reads it. See usage-rules.md for why that order matters.

UnusedSetupKeysPerTest

The narrow companion to UnusedSetupKeysInTests. Where that one asks whether any test uses a key, this one asks whether this test uses any key at all, and flags a test that consumes none of the fixture in scope for it.

It deliberately says nothing about a test that consumes part of a shared fixture — different tests reading different parts of one setup is what setup is for.

Known limitation: a test can depend on a fixture without naming it, when setup inserts rows that the code under test then queries. This check cannot see that and will flag such a test — disable it for those files rather than deleting the setup.

Usage rules for AI agents

This package ships a usage-rules.md consumed by usage_rules. It documents how to respond to each check — in particular, that a report is a suspicion to verify rather than a licence to delete code:

mix usage_rules.sync AGENTS.md sephia_credo

Contributing

  1. Fork the repository
  2. Create your feature branch (git switch -c my-new-check)
  3. Apply formatting and make sure tests pass (mix format, mix test)
  4. Commit your changes
  5. Open a pull request

License

MIT - see LICENSE for details.