AshCredo

Hex.pmHexDocsLicense: MIT

Unofficial static code analysis checks for the Ash Framework, built as a Credo plugin.

AshCredo detects common anti-patterns, security pitfalls, and missing best practices in your Ash resources, domains, and the code that calls into them. Some checks analyse unexpanded source AST; others introspect the compiled modules to see the fully-resolved DSL state, including anything Spark transformers and extensions contribute.

[!WARNING] This project is experimental and might break frequently.

Note: Only MissingChangeWrapper and MissingMacroDirective are enabled by default. All other checks are opt-in - enable them individually in your .credo.exs (see Configuration).

Installation

AshCredo 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 the plugin in your .credo.exs:

mix igniter.install ash_credo --only dev,test

This keeps ash_credo scoped to the same :dev/:test environments as credo. The installer also sets runtime: false.

Manual

Add ash_credo to your list of dependencies in mix.exs:

def deps do
  [
    {:ash_credo, "~> 0.8", only: [:dev, :test], runtime: false}
  ]
end

Then fetch the dependency and register the plugin in your .credo.exs:

mix deps.get
# .credo.exs
%{
  configs: [
    %{
      name: "default",
      plugins: [{AshCredo, []}]
    }
  ]
}

Running

mix credo

If you have any compiled-introspection checks enabled, run mix compile before mix credo - typically via a Mix alias like lint: ["compile", "credo --strict"]. See Checks that require a compiled project for the full list and the rationale.

Checks

Check Category Priority Default Description
AuthorizeFalse Warning High No Flags literal authorize?: false in Ash calls, action DSL, and (by default) any other call site
AuthorizerWithoutPolicies Warning High No Detects resources with Ash.Policy.Authorizer but no policies defined. Requires compiled project.
EmptyDomain Warning Normal No Flags domains with no resources registered
MissingChangeWrapper Warning High Yes Flags builtin change functions (manage_relationship, set_attribute, ...) used without change wrapper in actions
MissingDomain Warning Normal No Ensures non-embedded resources set the domain: option
MissingMacroDirective Warning High Yes Flags qualified calls to Ash.Query/Ash.Expr macros (filter, expr, ...) when the enclosing module does not have a matching module-level require/import. Catches the runtime UndefinedFunctionError that slips past the compiler when the macro argument is a bare runtime value. Requires compiled project and configurable.
MissingPrimaryKey Warning High No Ensures resources with data layers have a primary key
NoActions Warning Normal No Flags resources with data layers but no actions defined. Requires compiled project.
OverlyPermissivePolicy Warning High No Flags unscoped authorize_if always() policies
PinnedTimeInExpression Warning High No Flags ^Date.utc_today() / ^DateTime.utc_now() in Ash expressions (frozen at compile time)
SensitiveAttributeExposed Warning High No Flags sensitive attributes (password, token, secret, ...) not marked sensitive?: true
SensitiveFieldInAccept Warning High No Flags privilege-escalation fields (is_admin, permissions, ...) in accept lists
UnknownAction Warning High No Flags Ash.* calls referencing actions that do not exist on the resolved resource, with a fuzzy Did you mean hint. Requires compiled project.
WildcardAcceptOnAction Warning High No Detects accept :* on create/update actions (mass-assignment risk)
DirectiveInFunctionBody Refactor Normal No Flags require/import/alias of configured modules (default Ash.Query, Ash.Expr) declared inside function bodies instead of at module level
LargeResource Refactor Low No Flags resource files exceeding 400 lines
UseCodeInterface Refactor Normal No Flags Ash.* calls where both resource and action are literals - names the exact code interface function to call instead. Requires compiled project and configurable (see below). Pair with Warning.UnknownAction for typo detection.
MissingCodeInterface Design Low No Flags each action that has no code interface (resource- or domain-level). Requires compiled project.
MissingIdentity Design Normal No Suggests identities for attributes like email, username, slug. Requires compiled project.
MissingPrimaryAction Design Normal No Flags missing primary?: true when multiple actions of the same type exist. Requires compiled project.
MissingTimestamps Design Normal No Suggests adding timestamps() to persisted resources. Requires compiled project.
ActionMissingDescription Readability Low No Flags actions without a description
BelongsToMissingAllowNil Readability Normal No Flags belongs_to without explicit allow_nil?

Checks that require a compiled project

Several checks read Ash's runtime introspection (Ash.Resource.Info, Ash.Domain.Info, and Ash.Policy.Info) rather than source AST. They see the fully-resolved resource state - including anything Spark transformers or extensions contribute - and catch bugs that pure AST scanning would miss (e.g. identities on AshAuthentication-injected :email attributes, fragment-spliced actions, extension-added authorizers).

Your project must be compiled before running mix credo, otherwise these checks emit a configuration diagnostic and become a no-op. Typically chain the two commands in a Mix alias:

# mix.exs
defp aliases do
  [
    lint: ["compile", "credo --strict"]
  ]
end

If a referenced resource cannot be loaded, the check emits a per-call-site "could not load" issue pointing at the resource. If Ash itself is not available in the VM running Credo (why are you using ash_credo without depending on Ash?), these checks emit a single shared diagnostic and become no-ops. You can disable any of them in .credo.exs if your workflow can't run mix compile beforehand.

Caching and long-lived VMs

Compiled checks cache introspection results (per-module facts, per-domain resource references, and the ash_available? probe) in Erlang's :persistent_term. This keeps a single mix credo run cheap - Credo dispatches each check × file pair into its own short-lived task, and :persistent_term is the only process-independent store that survives that task churn without the ETS ownership problem.

This has ramifications for setups that reuse a single BEAM across multiple Credo invocations:

If you hit stale results in a long-lived VM, restart it or run AshCredo.Introspection.Compiled.clear_cache/0.

Configuration

Enable additional checks by adding them to the extra section of your .credo.exs:

%{
  configs: [
    %{
      name: "default",
      plugins: [{AshCredo, []}],
      checks: %{
        extra: [
          # Enable checks
          {AshCredo.Check.Warning.AuthorizeFalse, []},
          {AshCredo.Check.Warning.SensitiveFieldInAccept, []},
          {AshCredo.Check.Warning.WildcardAcceptOnAction, []},

          # Enable with custom parameters
          {AshCredo.Check.Refactor.LargeResource, [max_lines: 250]},
          {AshCredo.Check.Warning.SensitiveAttributeExposed, [
            sensitive_names: ~w(password token secret api_key)a
          ]},
          {AshCredo.Check.Design.MissingIdentity, [
            identity_candidates: ~w(email username slug)a
          ]}
        ]
      }
    }
  ]
}

To enable all checks at once (Warning.MissingChangeWrapper and Warning.MissingMacroDirective are already on by default and do not need an entry):

checks: %{
  extra: [
    {AshCredo.Check.Warning.AuthorizeFalse, []},
    {AshCredo.Check.Warning.AuthorizerWithoutPolicies, []},
    {AshCredo.Check.Warning.EmptyDomain, []},
    {AshCredo.Check.Warning.MissingDomain, []},
    {AshCredo.Check.Warning.MissingPrimaryKey, []},
    {AshCredo.Check.Warning.NoActions, []},
    {AshCredo.Check.Warning.OverlyPermissivePolicy, []},
    {AshCredo.Check.Warning.PinnedTimeInExpression, []},
    {AshCredo.Check.Warning.SensitiveAttributeExposed, []},
    {AshCredo.Check.Warning.SensitiveFieldInAccept, []},
    {AshCredo.Check.Warning.UnknownAction, []},
    {AshCredo.Check.Warning.WildcardAcceptOnAction, []},
    {AshCredo.Check.Refactor.DirectiveInFunctionBody, []},
    {AshCredo.Check.Refactor.LargeResource, []},
    {AshCredo.Check.Refactor.UseCodeInterface, []},
    {AshCredo.Check.Design.MissingCodeInterface, []},
    {AshCredo.Check.Design.MissingIdentity, []},
    {AshCredo.Check.Design.MissingPrimaryAction, []},
    {AshCredo.Check.Design.MissingTimestamps, []},
    {AshCredo.Check.Readability.ActionMissingDescription, []},
    {AshCredo.Check.Readability.BelongsToMissingAllowNil, []}
  ]
}

Configurable parameters

The following checks accept custom parameters:

Check Parameter Default Description
Warning.AuthorizeFalseinclude_non_ash_callstrue When false, only checks Ash API calls and action DSL definitions
Warning.MissingMacroDirectivemacro_modules[Ash.Query, Ash.Expr] Modules whose qualified macro calls the check validates. Macros are read from module.__info__(:macros), so only real macros are flagged
Warning.SensitiveAttributeExposedsensitive_names~w(password hashed_password password_hash token secret api_key private_key ssn)a Attribute names to flag when not marked sensitive?: true
Warning.SensitiveFieldInAcceptdangerous_fields~w(is_admin admin permissions api_key secret_key)a Field names to flag when found in accept lists
Refactor.DirectiveInFunctionBodydirective_modules[Ash.Query, Ash.Expr] List of modules whose require/import/alias must live at module level. Add any other macro module your team treats the same way
Refactor.LargeResourcemax_lines400 Maximum line count before triggering
Refactor.UseCodeInterfaceenforce_code_interface_in_domaintrue See Adapting UseCodeInterface below
Refactor.UseCodeInterfaceenforce_code_interface_outside_domaintrue See Adapting UseCodeInterface below
Refactor.UseCodeInterfaceprefer_interface_scope:auto See Adapting UseCodeInterface below
Design.MissingCodeInterfaceexcluded_actions[] List of "Module.action_name" strings whose missing interface should be suppressed (e.g. AshAuthentication-generated actions)
Design.MissingIdentityidentity_candidates~w(email username slug handle phone)a Attribute names to suggest adding identities for

Adapting UseCodeInterface to your team's conventions

UseCodeInterface accepts three params that map to common code-interface philosophies. The two enforce_* flags decide which call sites the check fires on; prefer_interface_scope decides which interface the suggestion points at. They compose freely.

# Default - "in-domain caller → resource interface,
#            outside-domain caller → domain interface" hierarchy.
{AshCredo.Check.Refactor.UseCodeInterface, []},

# Opinion A - raw Ash.* calls are OK when the caller is in the resource's
# domain (e.g. inside a Change / Preparation / Validation module). Only
# flag callers from outside the domain.
{AshCredo.Check.Refactor.UseCodeInterface,
 [enforce_code_interface_in_domain: false]},

# Opinion B - code interfaces are only defined on resources; never suggest
# reaching for a domain-level interface, regardless of the call site.
{AshCredo.Check.Refactor.UseCodeInterface,
 [prefer_interface_scope: :resource]},

# Opinion C - the inverse of Opinion A. Strict inside the domain (changes,
# preparations, validations, sibling resources must use code interfaces),
# but permissive outside (controllers, LiveViews, workers can call Ash.*
# directly without a nag). Useful for incremental adoption - enforce the
# pattern in the resource layer first, clean up the web layer later.
{AshCredo.Check.Refactor.UseCodeInterface,
 [enforce_code_interface_outside_domain: false]},

# Opinion A + B - allow same-domain raw calls AND always direct the rest
# at the resource-level interface.
{AshCredo.Check.Refactor.UseCodeInterface,
 [enforce_code_interface_in_domain: false, prefer_interface_scope: :resource]},

Setting both enforce_* flags to false effectively disables the check for loadable resources. In this configuration prefer_interface_scope becomes inert - no suggestion path fires, so combining Opinions A + B + C is observationally identical to A + C alone.

Contributing

  1. Fork the repository
  2. Create your feature branch (git switch -c my-new-check)
  3. Apply formatting and make sure tests and lints pass (mix format, mix test, mix lint)
  4. Commit your changes
  5. Open a pull request - PR titles must follow the Conventional Commits format (e.g. feat: add check for XY, fix: handle XY edge case)

License

MIT - see LICENSE for details.