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 the following checks 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.

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

The installer scopes ash_credo to :dev/:test and sets runtime: false automatically, matching how credo itself is typically declared. Pass --only dev,test if you'd like an early error when running from another MIX_ENV.

Manual

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

def deps do
[
{:ash_credo, "~> 0.15", 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

CheckCategoryPriorityDefaultDescription
ActorOnCallOptionsWarningHighNoFlags actor:/tenant: passed in the options of Ash.read!/Ash.create!/... when the subject visibly went through a for_* builder - per Ash's authorization usage rules they belong on the query/changeset/input
AuthorizeFalseWarningHighNoFlags literal authorize?: false in Ash calls, action DSL, and (by default) any other call site
AuthorizerWithoutPoliciesWarningHighNoDetects resources with Ash.Policy.Authorizer but no policies defined. Requires compiled project.
CompileTimeDefaultWarningHighYesFlags default: DateTime.utc_now() / Ash.UUID.generate() (missing &.../0 capture) on attributes and arguments - the call runs once at compile time, so every record gets the same frozen value
EmptyDomainWarningNormalNoFlags domains with no resources registered
MissingBuiltinWrapperWarningHighYesFlags builtin change/validation/preparation/calculation functions (set_attribute, present, build, concat, ...) used without their change/validate/prepare/calculate wrapper in action bodies, pipelines, and global sections - the bare call compiles but is silently discarded
MissingDomainWarningNormalNoEnsures non-embedded resources set the domain: option
MissingMacroDirectiveWarningHighYesFlags 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.
OverlyPermissivePolicyWarningHighNoFlags unscoped authorize_if always() policies
PinnedTimeInExpressionWarningHighYesFlags ^Date.utc_today() / ^DateTime.utc_now() in Ash expressions (frozen at compile time)
RedundantValidationWarningNormalNoFlags validate present(...) on attributes that already have allow_nil? false - the constraint guarantees presence, making the validation redundant (skips allow_nil_input escape hatches). Requires compiled project.
SensitiveAttributeExposedWarningHighNoFlags sensitive attributes (password, token, secret, ...) not marked sensitive?: true
SensitiveFieldInAcceptWarningHighNoFlags privilege-escalation fields (is_admin, permissions, ...) in accept lists
UnknownActionWarningHighNoFlags Ash.* calls referencing actions that do not exist on the resolved resource, with a fuzzy Did you mean hint. Requires compiled project.
WildcardAcceptOnActionWarningHighNoDetects accept :* on create/update actions (mass-assignment risk)
AnonymousFunctionInDslRefactorNormalNoFlags fn/& captures passed to change/validate/prepare/calculate - anonymous functions can never be atomic (changes/validations) or supply an expression (calculations); extract them into callback modules
DirectiveInFunctionBodyRefactorNormalNoFlags require/import/alias of configured modules (default Ash.Query, Ash.Expr) declared inside function bodies instead of at module level
LargeResourceRefactorLowNoFlags resource files exceeding 400 lines
RaisingCallRefactorLowNoFlags Ash bang calls - top-level Ash.read!/Ash.create!/Ash.Filter.parse! plus code-interface bangs like MyApp.Blog.create_post!. Orphan bangs (those without a non-bang counterpart, e.g. Ash.stream!, Ash.Seed.seed!) are detected dynamically and skipped. Test directories excluded by default. Requires compiled project.
UseCodeInterfaceRefactorNormalNoFlags 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.
MissingCodeInterfaceDesignLowNoFlags each action on non-embedded resources that has no code interface (resource- or domain-level). Requires compiled project.
MissingIdentityDesignNormalNoSuggests identities for attributes like email, username, slug on non-embedded resources. Requires compiled project.
MissingPrimaryActionDesignNormalNoFlags missing primary?: true when multiple actions of the same CRUD type exist (generic :action types are excluded). Requires compiled project.
MissingTimestampsDesignNormalNoSuggests adding timestamps() to persisted resources. Requires compiled project.
ActionMissingDescriptionReadabilityLowNoFlags actions without a description
BelongsToMissingAllowNilReadabilityNormalNoFlags 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.

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 (the default-on checks listed above do not need an entry):

checks: %{
extra: [
{AshCredo.Check.Warning.ActorOnCallOptions, []},
{AshCredo.Check.Warning.AuthorizeFalse, []},
{AshCredo.Check.Warning.AuthorizerWithoutPolicies, []},
{AshCredo.Check.Warning.EmptyDomain, []},
{AshCredo.Check.Warning.MissingDomain, []},
{AshCredo.Check.Warning.OverlyPermissivePolicy, []},
{AshCredo.Check.Warning.RedundantValidation, []},
{AshCredo.Check.Warning.SensitiveAttributeExposed, []},
{AshCredo.Check.Warning.SensitiveFieldInAccept, []},
{AshCredo.Check.Warning.UnknownAction, []},
{AshCredo.Check.Warning.WildcardAcceptOnAction, []},
{AshCredo.Check.Refactor.AnonymousFunctionInDsl, []},
{AshCredo.Check.Refactor.DirectiveInFunctionBody, []},
{AshCredo.Check.Refactor.LargeResource, []},
{AshCredo.Check.Refactor.RaisingCall, []},
{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:

CheckParameterDefaultDescription
Warning.AuthorizeFalseinclude_non_ash_callstrueWhen false, only checks Ash API calls and action DSL definitions
Warning.AuthorizeFalseexcluded_paths[~r"/test/", "test"]Paths or regexes to skip. Binary entries match as path segments or full file paths. Defaults to test directories where authorize?: false is typically intentional
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 password_digest token access_token secret client_secret totp_secret api_key private_key ssn)aAttribute names to flag when not marked sensitive?: true. Atom entries match exactly; Regex entries (e.g. ~r/_token$/) match against the attribute name
Warning.SensitiveFieldInAcceptdangerous_fields~w(is_admin admin permissions api_key secret_key)aField 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_lines400Maximum line count before triggering
Refactor.RaisingCallexcluded_functions[]{module, :fun!} tuples to allow without flagging. Bang-only APIs (those with no non-bang counterpart) are detected dynamically by probing module.__info__(:functions), so no curated allowlist is needed - this option only exists for cases where you want to silence a real bang/non-bang pair
Refactor.RaisingCallexcluded_paths[~r"/test/", "test"]Paths or regexes to skip. Binary entries match as path segments ("test" excludes any file under a test/ directory) or as full file paths ("priv/seeds.exs" excludes that file). Defaults to test directories, where bang versions are idiomatic
Refactor.RaisingCallflag_bang_only_apisfalseWhen true, also flag bangs whose non-bang counterpart doesn't exist (e.g. Ash.stream!, Ash.Seed.seed!) with a generic "ensure failures are properly handled" message. Default is false since the suggested non-bang twin wouldn't exist for these calls; opt in only if your team policy is "no bare bang calls anywhere"
Refactor.UseCodeInterfaceenforce_code_interface_in_domaintrueSee Adapting UseCodeInterface below
Refactor.UseCodeInterfaceenforce_code_interface_outside_domaintrueSee Adapting UseCodeInterface below
Refactor.UseCodeInterfaceprefer_interface_scope:autoSee 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)aAttribute 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.