AshCredo
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
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.10", 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).
Refactor.UseCodeInterfaceDesign.MissingCodeInterfaceDesign.MissingPrimaryActionDesign.MissingTimestampsDesign.MissingIdentityWarning.MissingMacroDirectiveWarning.NoActionsWarning.AuthorizerWithoutPoliciesWarning.UnknownAction
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 (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.AuthorizeFalse | include_non_ash_calls | true |
When false, only checks Ash API calls and action DSL definitions |
Warning.AuthorizeFalse | excluded_paths | [~r"/test/", "test"] |
Paths or regexes to skip. Defaults to test directories where authorize?: false is typically intentional |
Warning.MissingMacroDirective | macro_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.SensitiveAttributeExposed | sensitive_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.SensitiveFieldInAccept | dangerous_fields | ~w(is_admin admin permissions api_key secret_key)a |
Field names to flag when found in accept lists |
Refactor.DirectiveInFunctionBody | directive_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.LargeResource | max_lines | 400 | Maximum line count before triggering |
Refactor.UseCodeInterface | enforce_code_interface_in_domain | true | See Adapting UseCodeInterface below |
Refactor.UseCodeInterface | enforce_code_interface_outside_domain | true | See Adapting UseCodeInterface below |
Refactor.UseCodeInterface | prefer_interface_scope | :auto | See Adapting UseCodeInterface below |
Design.MissingCodeInterface | excluded_actions | [] |
List of "Module.action_name" strings whose missing interface should be suppressed (e.g. AshAuthentication-generated actions) |
Design.MissingIdentity | identity_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]},enforce_code_interface_in_domain(truedefault) - whenfalse, leaves callers that share a domain with the resource alone (Opinion A).enforce_code_interface_outside_domain(truedefault) - whenfalse, silences every case where the caller is not confirmed to be in the resource's domain (different domain, plain controller/LiveView, domainless resource,:not_loadableresource) (Opinion C).prefer_interface_scope(:auto | :resource | :domain, default:auto)-
overrides which interface the check points at.
:autofollows the in-domain/outside-domain heuristic;:resourcealways suggests a resource-level function (Opinion B);:domainalways suggests a domain-level function.
-
overrides which interface the check points at.
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
- Fork the repository
-
Create your feature branch (
git switch -c my-new-check) -
Apply formatting and make sure tests and lints pass (
mix format,mix test,mix lint) - Commit your changes
-
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.