SephiaCredo
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.
With Igniter (recommended)
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
| Check | Category | Description |
|---|---|---|
AppendInLoop | Refactor | Flags O(n²) ++ inside loops (reduce, fold, for/reduce, recursive functions) |
AshCodeInterfaceReadWithArgs | Warning | Flags define :name, action: :read, args: [...] inside code_interface — Ash's generic :read action raises at runtime when called with args |
AssertWithoutAssertion | Warning | Flags assert pattern = expr in tests where the bound variables are never used — the match succeeds vacuously |
ProcessSleepInTests | Refactor | Flags Process.sleep in *_test.exs files — causes flakes and slows the suite |
RawRuntimeError | Warning | Flags raise "msg" and raise RuntimeError, ... — error trackers can't group these meaningfully |
StructComparisonOperator | Warning | Forbids </>/<=/>=/==/!= on Date/Time/DateTime/NaiveDateTime/Decimal/Version — use *.compare/2 instead |
SysGetStateWithoutTimeoutInPoll | Warning (opt-in) | Flags :sys.get_state/1 inside a polling fn without surrounding try/catch :exit — flakes under load |
UnusedSetupKeysInTests | Design | Flags setup return keys no test in scope consumes |
UnusedSetupKeysPerTest | Design | Flags 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
- Fork the repository
- Create your feature branch (
git switch -c my-new-check) - Apply formatting and make sure tests pass (
mix format,mix test) - Commit your changes
- Open a pull request
License
MIT - see LICENSE for details.