MikaCredoRules.ErrorMessageRequired (MikaCredoRules v0.1.0)

Copy Markdown View Source

Error tuples must carry a structured %ErrorMessage{}, not a bare string literal.

{:error, "something went wrong"} gives callers nothing to match on — a string reason can only be compared byte-for-byte and carries no code or details. The house convention returns {:error, %ErrorMessage{}} built through the elixir_error_message constructors.

# BAD — unmatchable, unstructured reason
defmodule MyApp.Users do
  def find(nil), do: {:error, "user id is required"}
end

# GOOD — structured error with code, message and details
defmodule MyApp.Users do
  def find(nil), do: {:error, ErrorMessage.bad_request("user id is required")}
end

Only literal reasons are flagged. Variables, atoms and structs pass:

{:error, changeset}                       # passes — reason unknowable statically
{:error, :timeout}                        # passes by default (:also_flag_atoms)
{:error, %ErrorMessage{}}                 # passes — already structured
{:error, ErrorMessage.not_found("...")}   # passes

Matching on an error tuple someone else constructed is fine — only construction is flagged:

case ThirdParty.call() do
  {:error, "expired"} -> :retry           # passes — a match, not a construction
end

Test files are skipped by default (see :excluded_paths) — {:error, "..."} literals are legitimate fixture data in tests. :excluded_paths is the single scoping mechanism on purpose: Credo's builtin :files param is deliberately not defaulted, because it prunes files before run/2 is ever called and would silently override excluded_paths: [] for consumers re-enabling test-file flagging.

Known limitations

Two-element tuples of literals carry no position metadata in the Elixir AST, so the reported line is the nearest enclosing expression that has one — exact for one-liners and clause bodies, the def line for a construction inside a multi-line body.

Keyword pairs are indistinguishable from tuple literals in the AST — [error: "boom"] and [{:error, "boom"}] parse identically — so pairs inside list and map literals are never flagged.

Reasons built at runtime ("failed: #{inspect(reason)}" interpolation or "prefix" <> rest concatenation) are not literals and are not flagged.