Tiny Robots Credo Checks
Highly opinionated Credo checks used by Tiny Robots. They encode the Elixir / Phoenix / LiveView conventions we rely on across our apps.
These checks are opinionated by design — they enforce the Tiny Robots house style and may not be suitable (or desired) for contribution upstream to mainline Credo. They're published here so our projects can share them, but anyone sharing our conventions is welcome to use them.
Available checks
See the individual modules for full descriptions and examples. The recommended way to enable
them is Rbtz.CredoChecks.all/0 — see Installation and configuration.
Design
Rbtz.CredoChecks.Design.BareScriptInHeex: Forbids raw<script>tags in HEEx templates — use aphx-hook, the root layout, or import through the asset bundler.Rbtz.CredoChecks.Design.CnInClassList: Enforces correct use of thecn(...)class-merging helper in HEExclass={...}attributes — flagscn([...])with no@assign(wasted), bare lists mixing literal classes with any caller-provided assign (unwrapped, so TwMerge can't dedupe), andcn(...)calls where assigns aren't listed last (TwMerge keeps the last value, so assigns before literals lose their override). Configurable via:helper_name(default"cn").Rbtz.CredoChecks.Design.CustomAliasInRouterScope: Forbids manualaliasstatements inside Phoenixscopeblocks.Rbtz.CredoChecks.Design.OneModulePerFile: Requires one module per file underlib/— flags nested and sibling modules. Exception modules (defexception) and struct-only modules (defstructplus attributes) may stay with their owner; modules generated insidequoteare ignored.Rbtz.CredoChecks.Design.PreferLogsterInLib: Forbids the standardLoggermodule in application code underlib/— use Logster instead.Rbtz.CredoChecks.Design.RawHtmlElementsInHeex: Forbids raw<button>,<input>,<select>,<textarea>, and<a>in HEEx — use the app's components instead.Rbtz.CredoChecks.Design.RawSvgInHeex: Forbids raw<svg>tags in HEEx templates.
Readability
Rbtz.CredoChecks.Readability.AtomHttpStatusCodes: Forbids passing integer HTTP status codes toPlug.Conn/ Phoenix controllers andPhoenix.ConnTestassertion helpers (json_response,html_response,response,redirected_to) — use atoms like:not_found.Rbtz.CredoChecks.Readability.AwkwardPipe: Flags pipe usages that hurt readability without giving chaining benefit — pipe on either side of&&/||/++/<>/in/and/or, pipes into anyKernel.operator form (e.g.Kernel.&&/Kernel.||/Kernel.+/Kernel.-/Kernel.==/Kernel.</Kernel.in), single-step pipes in tuple literals / string interpolation / non-first arg positions / single-line lambdas, any pipe joined in anif/unless/condcondition, single-step pipes on the RHS of<-inside HEEx:for=/forcomprehensions, and single-step pipes wrapped in parens just to dot-access their result.Rbtz.CredoChecks.Readability.ClassAttrFormatting: Enforces HEExclass={...}attributes use list syntax for multiple values, and flags any class-attribute line — single-line or inside a multi-line list — that exceeds:max_line_length(default 98).Rbtz.CredoChecks.Readability.FunctionSpacing: Requires consistent blank-line spacing around function definitions — blank line above header blocks (@doc/@impl/@spec/ …), no blank between header and first clause, multi-clause functions compact when all single-line and separated when any clause is multi-line.Rbtz.CredoChecks.Readability.LiveViewCallbackOrder: Enforces the canonical callback order inPhoenix.LiveViewmodules:mount→handle_params→handle_event→handle_info→handle_async→ helpers →render.Rbtz.CredoChecks.Readability.MimicCallStyle: Enforces the call style Mimic's docs use for test doubles — pipe the module intoexpect/stub(Module |> expect(:fun, ...), chaining calls on the same module) and passrejecta function capture (reject(&Module.fun/arity)). Only runs when the project depends on:mimic(Mox and Hammox share theexpect/stubnames and document the unpiped form).Rbtz.CredoChecks.Readability.ModuleAttrCollectionFormatting: Flags multi-line collections assigned to module attributes — word sigils (~w[...]/~W[...]), lists, maps/structs, and tuples — that pack more than one item onto a line. Put one item per line so the collection is easy to scan, diff, and update. Single-line collections are unaffected, however many items they hold.Rbtz.CredoChecks.Readability.NoModuledocFalse: Forbids@moduledoc false— write a@moduledocor leave the attribute out. Pair it with Credo'sReadability.ModuleDoccheck disabled.@doc falseis unaffected.Rbtz.CredoChecks.Readability.PreferBlockFormForMultilineIf: Forbids the keyword formif cond, do: x, else: y(and theunlessequivalent) when the expression spans more than one line — switch to thedo ... else ... endblock form. Single-line keyword form is fine.Rbtz.CredoChecks.Readability.PreferBooleanDataAttrShorthand: Forbidsdata-[name]:bracket-variant syntax for boolean data attributes — usedata-name:instead, reserving brackets for value matching (data-[state=open]:).Rbtz.CredoChecks.Readability.PreferCapture: Encourages the capture syntax (&foo/1,&Mod.foo/2,&(&1 * 2)) overfn x -> ... endwhen the anonymous function just forwards its arguments to another call in the same order, applies a single operator, or partially applies a call.Rbtz.CredoChecks.Readability.PreferDateTimeShift: PrefersDateTime.shift/2(and theNaiveDateTime/Date/Timeequivalents) for calendar arithmetic — flagsaddwith a:minute/:hour/:day/:weekunit, a second offset whose integer literals multiply to whole minutes (300,2 * 3600), and everyDate.add/2, naming the equivalentshiftoption.add/3stays for genuine second / millisecond offsets.Rbtz.CredoChecks.Readability.PreferModuleAttributeForPageMetadata: Requires static page metadata to come from module attributes — flags a string literal forpage_title/page_description(configurable via:keys) passed toassignorrenderas a keyword, map entry, orassign(socket, :key, "..."). Interpolated and computed values are left alone.Rbtz.CredoChecks.Readability.PreferNilEquality: Prefersx == nil/x != niloveris_nil(x)/not is_nil(x)inif/unless/cond/caseconditions and inassert/refutearguments.is_nil/1inwhenguards and Ecto query DSL is unaffected.Rbtz.CredoChecks.Readability.PreferSelectorModuleAttributes: Requires test selectors to be declared as module attributes — flags string-literal selectors passed to selector-taking functions (element,has_element?,form,with_target,file_input,LazyHTML.query/filter,Floki.find/filter_out, and DOM helpers; configurable via:functions) and literal arguments to selector builders likeby_test_id(:selector_builders) in test files. Bare tag names ("h1") are allowed.Rbtz.CredoChecks.Readability.PreferSigilSForEscapedQuotes: Encourages the~ssigil for strings that would otherwise need\"escapes.Rbtz.CredoChecks.Readability.PreferToTimeout: Encouragesto_timeout(minute: 15)(Elixir 1.17+) over Erlang's:timer.seconds/1,:timer.minutes/1,:timer.hours/1, and:timer.hms/3.Rbtz.CredoChecks.Readability.PreferVerifiedRoutes: Requires~pfor in-app paths — flags raw"/path"strings inpush_navigate/push_patch/redirectto:, HEExnavigate=/patch=attributes, and (in tests)live, thePhoenix.ConnTestrequest helpers, andassert_redirect/assert_patch, and comparisons withredirected_to(conn). Skipsrouter.ex;ignore_tags:exempts tests tagged for paths~pcan't express.Rbtz.CredoChecks.Readability.RedundantClassAttrWrapping: Flags HEExclass={...}attributes whose wrapping is unnecessary —class={"foo"}/class={["foo"]}should beclass="foo";class={[expr]}should beclass={expr}.Rbtz.CredoChecks.Readability.ReqTestCallStyle: Enforces the call style Req's docs use forReq.Test.expect/Req.Test.stub— the name is the first argument, not piped in (Req.Test.stub/2returns:ok, so piped chains break).Rbtz.CredoChecks.Readability.ShorthandDefMustBeCompact: Forbids the shorthanddef name(args), do: bodyform whose body spans more than one line — switch to ado...endblock when the body has to wrap. Multi-line heads (e.g. nested pattern matches) are fine as long as the body stays on a single line.Rbtz.CredoChecks.Readability.SnakeCaseVariableNumbering: Encourages numbered variables to use a separating underscore:user_1,user_2(notuser1,user2). Configurable via:exclude.Rbtz.CredoChecks.Readability.TopLevelAliasImportRequire: Ensuresalias,import, andrequirestatements appear only at the top level of a module.
Refactor
Rbtz.CredoChecks.Refactor.PreferEctoMigrationHelper: Discourages raw SQLexecute("...")in Ecto migrations when an equivalent migration helper exists (e.g.CREATE TABLE,ALTER TABLE,CREATE INDEX). Raw SQL forCREATE EXTENSION, data backfills, etc. is allowed.Rbtz.CredoChecks.Refactor.PreferForAttrOverForBlock: Prefers:for={item <- @collection}directly on an element over a<%= for ... do %>EEx block when the block wraps a single element.Rbtz.CredoChecks.Refactor.PreferTextColumns: Ensures Ecto migrations use:textrather than:stringfor column types.Rbtz.CredoChecks.Refactor.PreferToFormInTemplates: Forbids passing a raw@changesetto a<form>/<.form>in HEEx — wrap withto_form/2and pass@forminstead.Rbtz.CredoChecks.Refactor.PreferWithOverCase: Flags a two-clausecasewhose only non-happy-path clause re-returns an error shape unchanged — an{:error, ...}tuple or the bare:erroratom (e.g.{:error, reason} -> {:error, reason}). Awithexpresses this more directly via its implicit pass-throughelse.Rbtz.CredoChecks.Refactor.RawHtmlMatchInLiveViewTests: Forbids=~matches against string literals in LiveView test files.Rbtz.CredoChecks.Refactor.RedundantThen: Flags unnecessary uses ofKernel.then/2— when the function passed tothen/2is a simple pass-through or partial application where the piped value already lands at the first-arg position,then/2is pure indirection and can be removed.
Warning
Rbtz.CredoChecks.Warning.ApplicationPutEnvInTests: ForbidsApplication.put_env/Application.put_all_envin test files — application env is global, so it forcesasync: falseand anon_exitrestore; stub a function that reads the value instead.test_helper.exsis exempt.Rbtz.CredoChecks.Warning.AssertNonEmptyBeforeIterate: Requires tests that iterate a collection withassert/refuteinside to first assert the collection is non-empty.Rbtz.CredoChecks.Warning.BooleanDataAttrCoalescesNil: Requires booleandata-*attributes in HEEx to coalesce withnil(e.g.data-disabled={@disabled || nil}) so the attribute is omitted when falsy.Rbtz.CredoChecks.Warning.DisableMigrationLock: Forbids@disable_migration_lock truein Ecto migration files.Rbtz.CredoChecks.Warning.EnumEachInHeex: Forbids<% Enum.each %>and other side-effecting EEx constructs in HEEx templates.Rbtz.CredoChecks.Warning.LiveViewFormCanBeRehydrated: Ensures LiveView forms (withphx-submit) also carryidandphx-changeso state survives deploys and reconnects.Rbtz.CredoChecks.Warning.ObanUniqueOptions: Requiresuse Oban.Worker/use Oban.Pro.Workerwith a literalunique:(orunique: true) to stateperiod:— Oban's default dedupes for only 60 seconds — and to pairperiod: :infinitywithstates: :incomplete, since the default states block re-enqueueing once a job completes. Finite periods and computed values are left alone.Rbtz.CredoChecks.Warning.PhxClickAwayWithoutId: Requires every element withphx-click-awayto also carry anidattribute.Rbtz.CredoChecks.Warning.PhxHookComponentWithoutStableId: Requires function components whose template usesphx-hookto bind the hook target to a stable DOMid— either a literal (id="foo") orid={@name}bound to an attr declared withrequired: trueor a binarydefault:.Rbtz.CredoChecks.Warning.PhxHookWithoutId: Requires every element withphx-hookto also carry anidattribute.Rbtz.CredoChecks.Warning.PhxUpdateStreamWithoutId: Requires every element withphx-update="stream"to also carry anidattribute.Rbtz.CredoChecks.Warning.PreferGetFieldOnChangeset: RequiresEcto.Changeset.get_field/2overchangeset.fieldorchangeset[:field]— direct access returns the original field, not the changeset's current (potentially changed) value, which is a frequent source of subtle bugs.Rbtz.CredoChecks.Warning.PushEventSocketBinding: Requires the result ofpush_event/3to be reassigned tosocket.Rbtz.CredoChecks.Warning.ReqTestWithoutVerifyOnExit: Requires test modules that mock HTTP withReq.Test.stub/expectto callReq.Test.verify_on_exit!/0in asetup/setup_allblock.Rbtz.CredoChecks.Warning.SetMimicGlobal: Forbids enabling Mimic in global mode (set_mimic_global) inside test files.Rbtz.CredoChecks.Warning.SortKeywordValidateResult: RequiresEnum.sort/1betweenKeyword.validate!/2and any binding that pattern-matches the result — unsorted results can silently break pattern matches on keyword list order.Rbtz.CredoChecks.Warning.StringInterpolationInClassAttr: Forbids string interpolation inside HEExclass=attributes — Tailwind's static class extractor can't see interpolated classes, so they silently don't ship in the compiled CSS.Rbtz.CredoChecks.Warning.UnmatchedRepoUpdateAll: Requires pattern-matchingRepo.update_allresults underlib/({1, nil} = ...when exactly one row should change, at minimum{_, nil} = ...) — flags a dropped statement or a match against_/_name, since an update that matches no rows is otherwise silent.Rbtz.CredoChecks.Warning.UnnamedOtpProcess: RequiresDynamicSupervisorandRegistrychild specs to declare a:name.
Installation and configuration
-
Add
rbtz_credo_checksto yourmix.exsdependencies:def deps do [ {:rbtz_credo_checks, "~> 0.1", only: [:dev, :test], runtime: false} ] end -
Run
mix deps.get. -
Enable the checks in your
.credo.exs. The recommended way isRbtz.CredoChecks.all/0, which returns every check as a{module, opts}tuple ready to append to yourenabled:list. It stays in sync automatically as this library adds checks — there's nothing to hand-maintain:%{ configs: [ %{ checks: %{ enabled: [ # ...your Credo built-in checks... ] ++ Rbtz.CredoChecks.all() } } ] }
Customising and disabling checks
Use Rbtz.CredoChecks.all_replacing/1 with a list of {module, opts} replacements. Each is
matched by module name and replaces that check's options, leaving every other check untouched.
To customise a check's options, pass a keyword list:
Rbtz.CredoChecks.all_replacing([
{Rbtz.CredoChecks.Readability.SnakeCaseVariableNumbering, exclude: ["utf8", "ipv4", "ipv6"]}
])
To disable a check, pass false as its options — Credo treats {check, false} as
disabled, so the check stays in the list but is turned off:
Rbtz.CredoChecks.all_replacing([
{Rbtz.CredoChecks.Design.PreferLogsterInLib, false}
])
You can combine both, and replacements naming a module that isn't one of these checks raise
an ArgumentError, so typos surface immediately:
enabled:
[
# ...your Credo built-in checks...
] ++
Rbtz.CredoChecks.all_replacing([
{Rbtz.CredoChecks.Readability.SnakeCaseVariableNumbering, exclude: ["utf8", "ipv4", "ipv6"]},
{Rbtz.CredoChecks.Design.PreferLogsterInLib, false}
])
Enabling checks individually
If you'd rather not enable everything, you can skip the helpers and list the checks by hand
in your enabled: list. This is the full set Rbtz.CredoChecks.all/0 returns — drop any you
don't want, and add a second tuple element to customise a check (or false to disable it):
enabled:
[
# ...your Credo built-in checks...
{Rbtz.CredoChecks.Design.BareScriptInHeex, []},
{Rbtz.CredoChecks.Design.CnInClassList, []},
{Rbtz.CredoChecks.Design.CustomAliasInRouterScope, []},
{Rbtz.CredoChecks.Design.OneModulePerFile, []},
{Rbtz.CredoChecks.Design.PreferLogsterInLib, []},
{Rbtz.CredoChecks.Design.RawHtmlElementsInHeex, []},
{Rbtz.CredoChecks.Design.RawSvgInHeex, []},
{Rbtz.CredoChecks.Readability.AtomHttpStatusCodes, []},
{Rbtz.CredoChecks.Readability.AwkwardPipe, []},
{Rbtz.CredoChecks.Readability.ClassAttrFormatting, []},
{Rbtz.CredoChecks.Readability.FunctionSpacing, []},
{Rbtz.CredoChecks.Readability.LiveViewCallbackOrder, []},
{Rbtz.CredoChecks.Readability.MimicCallStyle, []},
{Rbtz.CredoChecks.Readability.ModuleAttrCollectionFormatting, []},
{Rbtz.CredoChecks.Readability.NoModuledocFalse, []},
{Rbtz.CredoChecks.Readability.PreferBlockFormForMultilineIf, []},
{Rbtz.CredoChecks.Readability.PreferBooleanDataAttrShorthand, []},
{Rbtz.CredoChecks.Readability.PreferCapture, []},
{Rbtz.CredoChecks.Readability.PreferDateTimeShift, []},
{Rbtz.CredoChecks.Readability.PreferModuleAttributeForPageMetadata, []},
{Rbtz.CredoChecks.Readability.PreferNilEquality, []},
{Rbtz.CredoChecks.Readability.PreferSelectorModuleAttributes, []},
{Rbtz.CredoChecks.Readability.PreferSigilSForEscapedQuotes, []},
{Rbtz.CredoChecks.Readability.PreferToTimeout, []},
{Rbtz.CredoChecks.Readability.PreferVerifiedRoutes, []},
{Rbtz.CredoChecks.Readability.RedundantClassAttrWrapping, []},
{Rbtz.CredoChecks.Readability.ReqTestCallStyle, []},
{Rbtz.CredoChecks.Readability.ShorthandDefMustBeCompact, []},
{Rbtz.CredoChecks.Readability.SnakeCaseVariableNumbering, []},
{Rbtz.CredoChecks.Readability.TopLevelAliasImportRequire, []},
{Rbtz.CredoChecks.Refactor.PreferEctoMigrationHelper, []},
{Rbtz.CredoChecks.Refactor.PreferForAttrOverForBlock, []},
{Rbtz.CredoChecks.Refactor.PreferTextColumns, []},
{Rbtz.CredoChecks.Refactor.PreferToFormInTemplates, []},
{Rbtz.CredoChecks.Refactor.PreferWithOverCase, []},
{Rbtz.CredoChecks.Refactor.RawHtmlMatchInLiveViewTests, []},
{Rbtz.CredoChecks.Refactor.RedundantThen, []},
{Rbtz.CredoChecks.Warning.ApplicationPutEnvInTests, []},
{Rbtz.CredoChecks.Warning.AssertNonEmptyBeforeIterate, []},
{Rbtz.CredoChecks.Warning.BooleanDataAttrCoalescesNil, []},
{Rbtz.CredoChecks.Warning.DisableMigrationLock, []},
{Rbtz.CredoChecks.Warning.EnumEachInHeex, []},
{Rbtz.CredoChecks.Warning.LiveViewFormCanBeRehydrated, []},
{Rbtz.CredoChecks.Warning.ObanUniqueOptions, []},
{Rbtz.CredoChecks.Warning.PhxClickAwayWithoutId, []},
{Rbtz.CredoChecks.Warning.PhxHookComponentWithoutStableId, []},
{Rbtz.CredoChecks.Warning.PhxHookWithoutId, []},
{Rbtz.CredoChecks.Warning.PhxUpdateStreamWithoutId, []},
{Rbtz.CredoChecks.Warning.PreferGetFieldOnChangeset, []},
{Rbtz.CredoChecks.Warning.PushEventSocketBinding, []},
{Rbtz.CredoChecks.Warning.ReqTestWithoutVerifyOnExit, []},
{Rbtz.CredoChecks.Warning.SetMimicGlobal, []},
{Rbtz.CredoChecks.Warning.SortKeywordValidateResult, []},
{Rbtz.CredoChecks.Warning.StringInterpolationInClassAttr, []},
{Rbtz.CredoChecks.Warning.UnmatchedRepoUpdateAll, []},
{Rbtz.CredoChecks.Warning.UnnamedOtpProcess, []}
]
License
MIT. See LICENSE.