Writing Custom Smells

Copy Markdown View Source

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"
end

Run 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
end

Useful node fields:

  • node.type — IR node type, such as :module_def, :function_def, :call, :var, :literal, :match
  • node.meta — node-specific metadata, such as :module, :function, :arity, :name, or :kind
  • node.children — nested IR nodes
  • node.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"
end

Source 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
end

Use 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
end

Prefer 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
end

For 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
end

Plugin 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.