Skip to main content

guides/custom-smells.md

# Writing Custom Smells

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`:

```elixir
# .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.

```elixir
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:

```bash
mix reach.check --smells
mix reach.check --smells --strict
```

## Finding fields

`Reach.Smell.Finding.new/1` accepts these common fields:

```elixir
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.

```elixir
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:

```elixir
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.

```elixir
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.

```elixir
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:

```bash
mix reach.check --smells
```

Strict mode:

```bash
mix reach.check --smells --strict
```

Baseline existing findings:

```bash
mix reach.check --smells --write-baseline .reach-baseline.json
mix reach.check --smells --strict --baseline .reach-baseline.json
```

Or configure both in `.reach.exs`:

```elixir
[
  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:

```bash
mix reach.check --smells --format json
```

Example shape:

```json
{
  "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:

```elixir
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`:

```elixir
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.