Reach ships with built-in smell checks, but projects often have local rules that are too specific to belong in Reach itself: forbidden internal APIs, deprecated wrappers, project-specific data contracts, migration rules, or architectural conventions that are easier to express against Reach's IR than with text search.
Custom smells let a consuming project add those rules to mix reach.check --smells.
When to write a custom smell
Use a custom smell when the rule is:
- specific to your application or organization
- structural enough that text search is too noisy
- useful in CI as an advisory or strict gate
- easier to express with modules, calls, source spans, effects, or Reach graph data
For simple dependency boundaries, prefer .reach.exs architecture policy first. For framework semantics that should benefit many users, prefer a Reach plugin. For local lint-style rules, custom smells are the right fit.
Register a custom check
Add the check module to your application and list it in .reach.exs:
# .reach.exs
[
smells: [
strict: true,
custom_checks: [MyApp.ReachSmells.NoFoo]
]
]Reach validates that every listed module implements Reach.Smell.Check. Custom findings participate in strict mode and baseline filtering just like built-in findings.
Minimal custom smell
A smell check implements Reach.Smell.Check and returns a list of Reach.Smell.Finding structs. Checks may also expose kinds/0; Reach's corpus scan tooling uses it to run selected checks without executing unrelated smell modules.
defmodule MyApp.ReachSmells.NoFoo do
@behaviour Reach.Smell.Check
alias Reach.Smell.Finding
@impl true
def kinds, do: [:my_app_no_foo]
@impl true
def run(project) do
for {_id, node} <- project.nodes,
node.type == :call,
node.meta[:module] == MyApp.Foo do
Finding.new(
kind: :my_app_no_foo,
message: "Use MyApp.Bar instead of MyApp.Foo",
location: location(node)
)
end
end
defp location(%{source_span: %{file: file, start_line: line}}), do: "#{file}:#{line}"
defp location(_node), do: "unknown"
endRun it:
mix reach.check --smells
mix reach.check --smells --strict
Finding fields
Reach.Smell.Finding.new/1 accepts these common fields:
Finding.new(
kind: :my_app_no_foo,
message: "Use MyApp.Bar instead of MyApp.Foo",
location: "lib/my_app/foo.ex:12",
confidence: :high,
evidence: ["lib/my_app/foo.ex:12", "lib/my_app/bar.ex:18"]
)Use stable, namespaced kind atoms for project-local rules, such as :my_app_no_foo or :billing_deprecated_money_api. The finding kind is shown in JSON output and contributes to baseline fingerprints.
location should be either "unknown" or a file:line string. Baselines and terminal output are most useful when every finding points to the primary source location.
Walking the project
The project argument is the loaded Reach project. The most direct API is project.nodes, a map of node IDs to IR nodes.
for {_id, node} <- project.nodes,
node.type == :call,
node.meta[:module] == LegacyAPI do
# emit a finding
endUseful node fields:
node.type— IR node type, such as:module_def,:function_def,:call,:var,:literal,:matchnode.meta— node-specific metadata, such as:module,:function,:arity,:name, or:kindnode.children— nested IR nodesnode.source_span— source location metadata, usually%{file: ..., start_line: ...}
Function-scoped checks
For checks that inspect each function body, use Reach's IR traversal helpers:
defmodule MyApp.ReachSmells.NoDebugCalls do
@behaviour Reach.Smell.Check
alias Reach.IR
alias Reach.Smell.Finding
@impl true
def run(project) do
project.nodes
|> Enum.flat_map(fn
{_id, %{type: :function_def} = function} -> debug_findings(function)
_entry -> []
end)
end
defp debug_findings(function) do
function
|> IR.all_nodes()
|> Enum.filter(&debug_call?/1)
|> Enum.map(fn node ->
Finding.new(
kind: :my_app_debug_call,
message: "Remove debug call before merging",
location: location(node)
)
end)
end
defp debug_call?(%{type: :call, meta: %{module: IO, function: :inspect}}), do: true
defp debug_call?(_node), do: false
defp location(%{source_span: %{file: file, start_line: line}}), do: "#{file}:#{line}"
defp location(_node), do: "unknown"
endSource DSL checks
For source-shape rules that are easy to express as patterns or per-node callbacks, use Reach.Smell.Check.Source. It supports ExAST patterns/selectors and AST callback rules through the same smell macro.
defmodule MyApp.ReachSmells.NoBooleanCase do
use Reach.Smell.Check.Source
smell(
:boolean_case,
:my_app_boolean_case,
"prefer if/else for boolean case expressions",
mode: :ast,
prefilter: {:all, ["case"]}
)
defp boolean_case({:case, meta, [subject, _clauses]}) do
if match?({op, _, _} when op in [:==, :!=, :and, :or], subject), do: {:ok, meta}
end
defp boolean_case(_node), do: nil
endUse source DSL checks when a rule is local to one AST node or can be expressed as an ExAST ~p pattern. Use Reach.Smell.Check.AST when the check needs full-file state or a custom traversal.
AST-backed source checks
For full-file source-shape rules, use Reach.Smell.Check.AST. It loads each source file once via Sourceror, reuses Reach's AST cache, and calls scan_ast/2 with the file path.
defmodule MyApp.ReachSmells.MissingTemplateResource do
use Reach.Smell.Check.AST
alias Reach.Smell.Finding
@impl true
def kinds, do: [:my_app_missing_template_resource]
defp scan_ast(ast, file) do
{_ast, findings} =
Macro.prewalk(ast, [], fn
{:@, meta, [{:template, _, [path]}]} = node, findings when is_binary(path) ->
finding =
Finding.new(
kind: :my_app_missing_template_resource,
message: "template module attribute should declare @external_resource",
location: "#{file}:#{meta[:line] || 0}"
)
{node, [finding | findings]}
node, findings ->
{node, findings}
end)
Enum.reverse(findings)
end
endPrefer AST checks for syntax-sensitive rules such as DSL shape, module attributes, query macros, or literal interpolation. Prefer IR checks for semantic rules involving calls, effects, data flow, or nested function bodies.
Baselines and strict mode
Custom smell findings use the same gating behavior as built-in smell findings.
Advisory mode:
mix reach.check --smells
Strict mode:
mix reach.check --smells --strict
Baseline existing findings:
mix reach.check --smells --write-baseline .reach-baseline.json
mix reach.check --smells --strict --baseline .reach-baseline.json
Or configure both in .reach.exs:
[
checks: [baseline: ".reach-baseline.json"],
smells: [
strict: true,
custom_checks: [MyApp.ReachSmells.NoFoo]
]
]With this setup, known findings in the baseline are suppressed and new findings fail CI.
JSON output
Custom findings are included in JSON output:
mix reach.check --smells --format json
Example shape:
{
"command": "reach.check",
"tool": "reach.check",
"findings": [
{
"kind": "my_app_no_foo",
"message": "Use MyApp.Bar instead of MyApp.Foo",
"location": "lib/my_app/foo.ex:12",
"confidence": "high"
}
]
}Testing custom smells
Test custom checks directly with a small project fixture when possible. A minimal unit test can pass a hand-built project map:
defmodule MyApp.ReachSmells.NoFooTest do
use ExUnit.Case, async: true
test "flags Foo calls" do
project = %{
nodes: %{
1 => %Reach.IR.Node{
id: 1,
type: :call,
meta: %{module: MyApp.Foo, function: :run},
source_span: %{file: "lib/example.ex", start_line: 10},
children: []
}
}
}
assert [%Reach.Smell.Finding{kind: :my_app_no_foo}] =
MyApp.ReachSmells.NoFoo.run(project)
end
endFor higher-confidence tests, parse a small source fixture with Reach and run the check against the resulting project.
Framework-specific smells
Framework-specific smells belong in plugins rather than generic Reach.Smell.* modules. A plugin can expose smell modules with smell_checks/0:
defmodule Reach.Plugins.MyFramework do
@behaviour Reach.Plugin
@impl true
def smell_checks do
[Reach.Plugins.MyFramework.Smells.NoLegacyAPI]
end
endPlugin smell checks still implement Reach.Smell.Check, run through the same registry as built-in and project-local checks, and participate in strict mode and baselines. This keeps framework policy near framework semantics such as effect classification, trace presets, and graph edges.
Best practices
- Keep checks focused: one rule per module is easier to baseline and explain.
- Use precise locations. Avoid
"unknown"unless the finding is truly project-level. - Prefer stable messages and kinds so baselines do not churn unnecessarily.
- Avoid hardcoding generated paths unless the rule is specifically about generated code.
- Keep project-specific semantics in your application; contribute broadly useful semantics as Reach plugins or built-in checks.