defmodule Bylaw.Credo.Check.Elixir.NoLowLevelProcessPrimitives do
@moduledoc """
Disallows direct usage of `Process`, `GenServer`, and `:ets`.
## Examples
Avoid:
Process.put(:current_tenant, tenant_id)
GenServer.call(MyApp.Registry, {:lookup, key})
:ets.lookup(:cache, key)
Prefer:
fetch_tenant(conn)
MyApp.Registry.lookup(key)
Cache.fetch(key)
## Notes
Stateful and process-based primitives are **exceptions, not
defaults**. They exist for very specific use cases and should only
be introduced after gaining explicit approval.
Most of the time the right answer is simpler than you think - plain
functions, passing values through arguments, or returning data from
the caller is almost always preferable to reaching for `Process`,
`GenServer`, `:ets`, or any other stateful primitive.
Note: `Agent` is not flagged because it cannot be reliably
distinguished from aliased application modules (e.g.
`MyApp.Agents.Agent`) at the AST level. `Task` is also not
flagged - it is a reasonable concurrency tool that does not
introduce hidden state.
**Before adding process-level machinery, ask yourself:**
1. Can I solve this with a plain function and its arguments?
2. Am I introducing state/concurrency where none is needed?
3. Have I gotten explicit approval to use process primitives here?
If the answer to all three is "yes, I really need this", disable the
check for the call site:
# credo:disable-for-next-line Bylaw.Credo.Check.Elixir.NoLowLevelProcessPrimitives
Process.put(:key, value)
Path exclusions are matched against the source filename and are intended for generated files or temporary migration areas.
The check uses static AST analysis, so dynamic code generation and macro-expanded code may fall outside its signal.
## Options
Configure options in `.credo.exs` with the check tuple:
```elixir
%{
configs: [
%{
name: "default",
checks: [
{Bylaw.Credo.Check.Elixir.NoLowLevelProcessPrimitives,
[
excluded_paths: ["test/support/"]
]}
]
}
]
}
```
- `:excluded_paths` - List of path prefixes or regexes to exclude from this check.
## Usage
Add this check to Credo's `checks:` list in `.credo.exs`:
```elixir
%{
configs: [
%{
name: "default",
checks: [
{Bylaw.Credo.Check.Elixir.NoLowLevelProcessPrimitives, []}
]
}
]
}
```
"""
use Credo.Check,
base_priority: :high,
category: :warning,
param_defaults: [excluded_paths: []],
explanations: [
check: @moduledoc,
params: [
excluded_paths: "List of path prefixes or regexes to exclude from this check."
]
]
@flagged_modules [:Process, :GenServer]
@doc false
@impl Credo.Check
def run(%Credo.SourceFile{} = source_file, params \\ []) do
excluded_paths = Params.get(params, :excluded_paths, __MODULE__)
if excluded?(source_file.filename, excluded_paths) do
[]
else
issue_meta = IssueMeta.for(source_file, params)
Credo.Code.prewalk(source_file, &traverse(&1, &2, issue_meta))
end
end
defp excluded?(filename, excluded_paths) do
Enum.any?(excluded_paths, fn
%Regex{} = regex -> Regex.match?(regex, filename)
path when is_binary(path) -> String.contains?(filename, path)
end)
end
# Process.func(...) / GenServer.func(...)
defp traverse(
{{:., _dot_meta, [{:__aliases__, _aliases_meta, [mod]}, func]}, meta, _args} = ast,
issues,
issue_meta
)
when mod in @flagged_modules and is_atom(func) do
{ast, [issue_for(issue_meta, meta[:line] || 0, "#{mod}.#{func}") | issues]}
end
# :ets.func(...)
defp traverse(
{{:., _dot_meta, [:ets, func]}, meta, _args} = ast,
issues,
issue_meta
)
when is_atom(func) do
{ast, [issue_for(issue_meta, meta[:line] || 0, ":ets.#{func}") | issues]}
end
defp traverse(ast, issues, _issue_meta), do: {ast, issues}
defp issue_for(issue_meta, line_no, trigger) do
format_issue(
issue_meta,
message:
"Found `#{trigger}` - stateful/process primitives are exceptions, not defaults. " <>
"Can you solve this with plain functions and arguments instead? " <>
"These should only be introduced after gaining explicit approval. " <>
"If you have approval, disable the check with " <>
"`# credo:disable-for-next-line`.",
trigger: trigger,
line_no: line_no
)
end
end