Checks @spec declarations against the type signatures the Elixir compiler infers. Tested on Elixir 1.18–1.20; Elixir 1.21 support is experimental. Requires Elixir 1.18 or later; 1.20 infers much more (argument types from guards and patterns), so 1.18 and 1.19 find less.

# mix.exs
{:spec_lint, "~> 0.1", only: [:dev, :test], runtime: false}
$ mix spec_lint
lib/my_app/accounts.ex:41: error: MyApp.Accounts.lookup/1: inferred return {:ok, term()} is disjoint from the spec return atom()
    @spec lookup(integer()) :: atom()
    inferred: (term()) -> dynamic({:ok, term()})
lib/my_app/deposits.ex:88: warning: MyApp.Deposits.request/3: inferred return includes {:error, :merchant_inactive}, which the spec does not declare
    @spec request(t(), binary(), keyword()) :: {:ok, t()} | {:error, :not_found}
    inferred: (%MyApp.Deposit{status: :inactive}, binary(), term()) -> {:error, :merchant_inactive}
    inferred: (%MyApp.Deposit{status: :active}, binary(), term()) -> dynamic({:ok, term()} or {:error, :not_found})
spec_lint: 421 spec clauses checked, 1 error(s), 1 warning(s)

How it works

The compiler stores inferred signatures in the ExCk chunk of each BEAM file. Analysis covers exported functions with readable specs and stored inferred signatures in the current application, or owned applications in an umbrella. Dependencies and private functions are excluded. mix spec_lint compiles the project, translates each spec clause into the compiler's own types, applies the inferred signature to the spec's argument types with the compiler's own application rule, and compares the returns. Nothing beyond the normal compilation (and the config file) is executed; no project function is called.

Errors are contradictions in the compiler's types:

  • return_conflict: the inferred return is disjoint from the spec return.
  • domain_rejected: no clause accepts the spec's arguments, so the compiler would warn on every conforming call.

Warnings indicate possible mismatches, not proven runtime behavior:

  • clause_conflict: a clause that accepts only spec-conforming arguments returns a value disjoint from the spec return. The compiler keeps clauses that can never match, so this is not proof.

  • missing_return: the inferred return has a literal shape the spec lacks, such as an {:error, _} tuple, an atom or a struct the spec never mentions.

  • unexpected_return: the spec says no_return() and the compiler infers a return.

Spec clauses that use a construct the translator cannot read (a when constraint other than x :: type) are skipped and counted. Erlang -nominal types are read on Elixir 1.21 and treated as any term before that; record types (#name{}, classic or OTP 29 native) are any term, since the BEAM does not say which kind they are.

Inference is conservative, so an inferred type wider than the spec is normal and silent. Values that flow straight from arguments or callbacks are dynamic() to the compiler and cannot be checked; those omissions stay silent by design. Silence does not establish that a spec is correct.

Options

mix spec_lint [--warnings-as-errors] [--format json] [--output FILE]
              [--module Mod ...] [--config FILE]

Exit status is 0 when there is no error (and no warning with --warnings-as-errors), 1 otherwise.

The format is console (default) or json. JSON requires --output FILE:

mix spec_lint --format json --output findings.json

The file is a machine-readable JSON array of findings; compilation messages and the summary stay on the console. Each finding has severity (error or warning), check, module, function, arity, file, line, message, spec and inferred. Module, function and check names are strings; arity is an integer, file is a string or null, line is an integer or null, and spec and inferred are arrays of strings. An empty result is [].

Repeat --module Mod to select project modules; unmatched names are usage errors. --config FILE selects an executable Elixir configuration file. --no-warnings-as-errors overrides a true configuration value. Unknown formats, options and positional arguments are rejected.

The supported public interfaces are this Mix task, its configuration, and the JSON findings file. The programmatic SpecLint API and compiler translator helpers are internal and unstable.

Ignoring findings

.spec_lint.exs in the project root:

[
  ignore: [
    MyApp.Generated,            # a whole module
    {MyApp.Legacy, :parse},     # a function, any arity
    {MyApp.Legacy, :parse, 2},  # one function
    ~r/^MyApp\.Proto\./         # a regex on "Module.function/arity"
  ],
  warnings_as_errors: false
]

Ignore entries match findings by exact module/function/arity as shown, or by regex against the displayed Module.function/arity string (without an Elixir. prefix). Ignoring removes findings before severity totals and exit status are calculated. The checked count is successfully compared spec clauses, not functions or findings; ignored findings do not reduce it. The skipped count is spec clauses that could not be translated or compared, and may count multiple clauses of one function.

Specs injected by a library macro (for example Phoenix.View's template_not_found/2) are reported against the module that overrides the function; ignore them here or fix the spec upstream.

Development

mix test
mix credo --strict
mix format --check-formatted
mix docs --warnings-as-errors

CI runs the suite on Elixir 1.18 (OTP 27), 1.19 (OTP 28) and 1.20 (OTP 28 and 29).

License

Apache-2.0. <code>SpecLint.apply_infer/2</code> is a copy of a private function of the Elixir compiler; see THIRD_PARTY_NOTICES.md.