AshCredo.Check.Warning.MissingMacroDirective (ash_credo v0.12.1)

Copy Markdown View Source

Basics

This check is disabled by default.

Learn how to enable it via .credo.exs.

This check has a base priority of high and works with any version of Elixir.

Explanation

Flags qualified calls to macros on configured modules (default Ash.Query and Ash.Expr) when no matching require or import of the macro module is lexically in scope at the call site.

Several Ash.Query and Ash.Expr functions are actually macros - Ash.Query.filter/2, equivalent_to/2, superset_of/2, subset_of/2 and their ? variants, and Ash.Expr.expr/1, where/2, or_where/2, calc/1..2. Calling any of them without a matching require in scope has three different failure modes, depending on the shape of the argument:

# 1. Literal expression -> compile error with a misleading message
Ash.Query.filter(Post, state == :published)
# ** (CompileError) undefined variable "state"

# 2. Pinned variable -> compile error about the pin operator
Ash.Query.filter(Post, ^pre_built)
# ** (CompileError) misplaced operator ^pre_built

# 3. Bare variable holding a runtime value -> compiles with an
# easy-to-miss warning, then fails at RUNTIME with
# UndefinedFunctionError when the function is actually called.
def foo(f), do: Ash.Query.filter(Post, f)
# warning: Ash.Query.filter/2 is undefined or private...
# ...later at runtime:
# ** (UndefinedFunctionError) function Ash.Query.filter/2 is
#    undefined or private

Case #3 is the important one for a linter - the other two fail loudly at compile time, but this one ships to production if the warning is missed.

# Flagged
defmodule MyApp.PostQueries do
  def published do
    MyApp.Post
    |> Ash.Query.filter(state == :published)
    |> Ash.read!()
  end
end

# Preferred
defmodule MyApp.PostQueries do
  require Ash.Query

  def published do
    MyApp.Post
    |> Ash.Query.filter(state == :published)
    |> Ash.read!()
  end
end

require and import both satisfy the check - import <Module> implies require <Module> in Elixir, so qualified macro calls work after either directive.

Only qualified remote calls (Ash.Query.filter(...)) are inspected. Unqualified calls like filter(...) after import Ash.Query are out of scope: if the import is missing, Elixir raises a clean undefined function filter/2 at compile time, which is obvious enough to need no lint.

require/import are accepted in any lexical scope visible to the call - module top, the enclosing def/defp body, an enclosing if/case/with branch, etc. - matching Elixir's own scoping rules. A directive in one function does not reach calls in a sibling function.

Each configured module is tracked independently: require Ash.Query does not cover Ash.Expr.expr(...), and vice versa. A module that uses macros from both modules needs both directives.

Calls inside quote do ... end blocks are deliberately ignored. A macro author who writes quote do Ash.Query.filter(...) end is injecting the call into the caller's site, not emitting it from their own module, so flagging it would be a false positive.

Nested defmodule blocks inherit require/import and alias from the enclosing module the same way Elixir does, so an outer require Ash.Query (or alias Ash.Query, as: Q) is honored by Ash.Query.filter(...) (or Q.filter(...)) inside a nested defmodule.

This check is a correctness backstop: for projects without --warnings-as-errors, it converts the easy-to-miss runtime case (#3 above) into a lint issue. Style rules about where directives live are out of scope; pair with AshCredo.Check.Refactor.DirectiveInFunctionBody if your team wants to centralise directives at module top.

Precision

The check uses compiled-BEAM introspection (module.__info__(:macros)) to learn which functions on each configured module are actually macros. This means:

  • It only flags real macro calls - non-macro calls on the same module (Ash.Query.new/1, for example) are never flagged.
  • New macros added in future Ash releases are automatically picked up without code changes here.
  • User-supplied modules in macro_modules are handled with the same precision as Ash.Query/Ash.Expr - only their actual macros are flagged, not every qualified call.

Requirements

Your project must be compiled before running mix credo. If Ash is not available in the VM running Credo, the check is a no-op and emits a single diagnostic. If a configured module cannot be loaded (typical cause: you added one of your own modules to macro_modules and have not compiled yet), the check emits a per-module "could not load" diagnostic and skips that module for the run.

Configuration

macro_modules defaults to [Ash.Query, Ash.Expr]. Extend the list with additional macro modules your team uses:

{AshCredo.Check.Warning.MissingMacroDirective,
 [macro_modules: [Ash.Query, Ash.Expr, MyApp.QueryMacros]]}

Check-Specific Parameters

Use the following parameters to configure this check:

:macro_modules

List of modules whose qualified macro calls the check validates. For each call to <Module>.<macro>/n the check requires a require or import of <Module> to be lexically in scope - module top, the enclosing def body, an enclosing branch, or inherited from an enclosing defmodule. Defaults to [Ash.Query, Ash.Expr]. The exact set of macros on each module is read from compiled-BEAM introspection (module.__info__(:macros)), so only real macros are flagged - regular functions on the same module are ignored.

This parameter defaults to [Ash.Query, Ash.Expr].

General Parameters

Like with all checks, general params can be applied.

Parameters can be configured via the .credo.exs config file.