Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- Changed-code output now reports assessment confidence and line/file coverage separately from function risk, including deleted-only and otherwise unassessed changes.
- The false-success smell check no longer crashes on calls qualified through `__MODULE__` or other aliases containing non-atom AST segments ([#23](https://github.com/elixir-vibe/reach/issues/23)).
- Smell checks now run against deterministic snapshots of eight pinned Hex packages in CI, catching new false-positive drift on idiomatic real-world code.
- Generic smells now ignore source ranges whose syntax is reinterpreted by Ecto, Ash, Nx, plugin-defined, or configured project DSL macros.

## 2.7.5 - 2026-06-12

Expand Down
19 changes: 19 additions & 0 deletions guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ The file must evaluate to a keyword list. Start from [`examples/reach.exs`](../e
max_clones: 50
],
smells: [
dsl_macros: [
{MyApp.Expr, :expr, 1},
{nil, :imported_query, :any}
],
fixed_shape_map: [
min_keys: 3,
min_occurrences: 3,
Expand Down Expand Up @@ -527,6 +531,21 @@ smells: [

Custom checks run alongside Reach's built-in smell checks and participate in `--strict` and baseline filtering. A custom check must implement `Reach.Smell.Check` and define `run/1`. See the custom smells guide for a deeper walkthrough.

### `smells[:dsl_macros]`

Some macros reinterpret ordinary Elixir syntax, so generic AST/IR smells are not valid inside them. Reach plugins guard known Ecto query, Ash expression/resource, and Nx `defn` ranges automatically. Configure project-specific DSLs as `{module_or_nil, function, arity_or_any}` tuples:

```elixir
smells: [
dsl_macros: [
{MyApp.Expr, :expr, 1},
{nil, :imported_query, :any}
]
]
```

Use `nil` for imported/local macro calls and a module for qualified calls. Plugin-provided findings remain active inside their own DSL ranges; only generic findings are guarded.

### Built-in smell examples

Reach combines generic Elixir smells with plugin-provided Phoenix, Ecto, and Oban checks. Examples include:
Expand Down
38 changes: 38 additions & 0 deletions lib/reach/ast.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,42 @@ defmodule Reach.AST do

Enum.reverse(modules)
end

@doc "Returns the module, function, and arguments represented by an Elixir call AST node."
@spec call(Macro.t()) :: {module() | nil, atom(), [Macro.t()]} | nil
def call({{:., _dot_meta, [module_ast, name]}, _meta, args})
when is_atom(name) and is_list(args) do
case ast_module(module_ast) do
nil -> nil
module -> {module, name, args}
end
end

def call({name, _meta, args}) when is_atom(name) and is_list(args),
do: {nil, name, args}

def call(_ast), do: nil

@doc "Returns whether keyword AST contains a key."
@spec keyword?(Macro.t(), atom()) :: boolean()
def keyword?(entries, key) when is_list(entries) do
Enum.any?(entries, fn
{{:__block__, _meta, [^key]}, _value} -> true
{^key, _value} -> true
_entry -> false
end)
end

def keyword?(_entries, _key), do: false

defp ast_module({:__aliases__, _meta, parts}) when is_list(parts) do
if Enum.all?(parts, &is_atom/1) do
Module.safe_concat(parts)
end
rescue
ArgumentError -> nil
end

defp ast_module(module) when is_atom(module), do: module
defp ast_module(_ast), do: nil
end
16 changes: 13 additions & 3 deletions lib/reach/check/smells.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ defmodule Reach.Check.Smells do
Runs structural and performance smell checks over a loaded project.
"""

alias Reach.Config
alias Reach.Smell.{SourceRunner, Suppressions}
alias Reach.{Config, Plugin}
alias Reach.Smell.{DSLGuard, Registry, SourceRunner, Suppressions}

def run(project, config \\ []) do
config = Config.normalize(config)
Expand All @@ -16,7 +16,17 @@ defmodule Reach.Check.Smells do
SourceRunner.run(project, pattern_checks) ++
Enum.flat_map(checks, &run_check(&1, project, config))

Suppressions.filter(findings, project, config)
plugin_checks = project |> Map.get(:plugins, []) |> Plugin.smell_checks() |> MapSet.new()

plugin_kinds =
(pattern_checks ++ checks)
|> Enum.filter(&MapSet.member?(plugin_checks, &1))
|> Enum.flat_map(&Registry.kinds/1)
|> MapSet.new()

findings
|> DSLGuard.filter(project, config, plugin_kinds)
|> Suppressions.filter(project, config)
end

def analyze(project), do: run(project)
Expand Down
27 changes: 27 additions & 0 deletions lib/reach/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ defmodule Reach.Config do
defstruct strict: false,
custom_checks: [],
ignore: [],
dsl_macros: [],
fixed_shape_map: nil,
behaviour_candidate: nil
end
Expand Down Expand Up @@ -137,6 +138,8 @@ defmodule Reach.Config do
end
end

@type t :: %__MODULE__{}

defstruct layers: [],
deps: nil,
calls: nil,
Expand Down Expand Up @@ -299,6 +302,7 @@ defmodule Reach.Config do
strict: nested(config, [:smells, :strict], nil, false),
custom_checks: nested(config, [:smells, :custom_checks], nil, []),
ignore: nested(config, [:smells, :ignore], nil, []),
dsl_macros: nested(config, [:smells, :dsl_macros], nil, []),
fixed_shape_map: %Smells.FixedShapeMap{
min_keys: nested(config, [:smells, :fixed_shape_map, :min_keys], nil, 3),
min_occurrences: nested(config, [:smells, :fixed_shape_map, :min_occurrences], nil, 3),
Expand Down Expand Up @@ -575,6 +579,12 @@ defmodule Reach.Config do
&valid_ignore?/1,
"expected keyword list with paths and modules"
)
|> check(
config,
[:smells, :dsl_macros],
&valid_dsl_macros?/1,
"expected {module_or_nil, function, arity_or_any} tuples"
)
|> check(config, [:smells, :fixed_shape_map], &valid_group?/1, "expected keyword list")
|> check(config, [:smells, :behaviour_candidate], &valid_group?/1, "expected keyword list")
|> check(
Expand Down Expand Up @@ -787,6 +797,7 @@ defmodule Reach.Config do
:strict,
:custom_checks,
:ignore,
:dsl_macros,
:fixed_shape_map,
:behaviour_candidate
])
Expand Down Expand Up @@ -935,6 +946,22 @@ defmodule Reach.Config do

defp valid_ignore?(_value), do: false

defp valid_dsl_macros?(value) when is_list(value) do
Enum.all?(value, fn
{module, function, :any} when is_atom(module) and is_atom(function) ->
true

{module, function, arity}
when is_atom(module) and is_atom(function) and is_integer(arity) and arity >= 0 ->
true

_shape ->
false
end)
end

defp valid_dsl_macros?(_value), do: false

defp valid_except_edges?(value) when is_list(value) do
Enum.all?(value, fn
{caller_pattern, callee_pattern}
Expand Down
35 changes: 35 additions & 0 deletions lib/reach/evidence/dsl_guard.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
defmodule Reach.Evidence.DSLGuard do
@moduledoc "Filters generic evidence emitted from source ranges with reinterpreted DSL semantics."

alias Reach.Config
alias Reach.Source.DSLGuard, as: SourceGuard

@spec filter([map()], Macro.t(), [module()], Config.t() | keyword()) :: [map()]
def filter(evidence, ast, plugins, config \\ []) do
config = Config.normalize(config)
shapes = config.smells.dsl_macros

if SourceGuard.enabled?(plugins, shapes) do
ranges = SourceGuard.ranges(ast, plugins, shapes)
Enum.reject(evidence, &guarded?(&1, ranges))
else
evidence
end
end

defp guarded?(evidence, ranges) do
case evidence_line(evidence) do
line when is_integer(line) -> SourceGuard.guarded_line?(ranges, line)
_line -> false
end
end

defp evidence_line(%{meta: meta}), do: position_line(meta)
defp evidence_line(%{location: location}), do: position_line(location)
defp evidence_line(_evidence), do: nil

defp position_line(position) when is_list(position), do: position[:line]
defp position_line(%{line: line}), do: line
defp position_line(%{start_line: line}), do: line
defp position_line(_position), do: nil
end
17 changes: 16 additions & 1 deletion lib/reach/plugin.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ defmodule Reach.Plugin do
framework-specific trace presets, behaviour labels, and visualization
edge filtering.

7. **Reinterpreted DSL semantics** — `reinterpreted_ast?/1` identifies macro
ranges where generic Elixir smell/evidence rules do not apply.

## Implementing a plugin

defmodule MyPlugin do
Expand All @@ -40,7 +43,7 @@ defmodule Reach.Plugin do

## Built-in plugins

Plugins for Phoenix, Ecto, Oban, GenStage, Jido, and OpenTelemetry
Plugins for Phoenix, Ecto, Ash, Nx, Oban, GenStage, Jido, and OpenTelemetry
are included and auto-detected at runtime. Override with the
`:plugins` option:

Expand Down Expand Up @@ -114,6 +117,9 @@ defmodule Reach.Plugin do
@callback trace_pattern(pattern :: String.t()) :: (Node.t() -> boolean()) | nil
@callback behaviour_label(callbacks :: [atom()]) :: String.t() | nil
@callback expected_effect_boundary?(module(), atom(), non_neg_integer()) :: boolean() | nil
@doc "Returns whether an AST node introduces a DSL that reinterprets ordinary Elixir semantics."
@callback reinterpreted_ast?(ast :: Macro.t()) :: boolean()

@callback ignore_call_edge?(Graph.Edge.t()) :: boolean()

@optional_callbacks analyze_project: 3,
Expand All @@ -132,6 +138,7 @@ defmodule Reach.Plugin do
trace_pattern: 1,
behaviour_label: 1,
expected_effect_boundary?: 3,
reinterpreted_ast?: 1,
ignore_call_edge?: 1

@known_plugins [
Expand All @@ -148,6 +155,7 @@ defmodule Reach.Plugin do
{Poison, Reach.Plugins.Poison},
{Zoi, Reach.Plugins.Zoi},
{NimbleOptions, Reach.Plugins.NimbleOptions},
{Nx, Reach.Plugins.Nx},
{QuickBEAM, Reach.Plugins.QuickBEAM}
]

Expand Down Expand Up @@ -196,6 +204,13 @@ defmodule Reach.Plugin do
end)
end

@doc "Returns whether any configured plugin owns reinterpreted semantics for an AST node."
def reinterpreted_ast?(plugins, ast) do
Enum.any?(plugins, fn plugin ->
exports?(plugin, :reinterpreted_ast?, 1) and plugin.reinterpreted_ast?(ast)
end)
end

@doc "Runs module-local analysis hooks for the configured plugins."
def run_analyze(plugins, all_nodes, opts) do
Enum.flat_map(plugins, fn plugin ->
Expand Down
17 changes: 15 additions & 2 deletions lib/reach/plugins/ash.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ defmodule Reach.Plugins.Ash do
@moduledoc "Plugin for Ash framework action and resource semantics."
@behaviour Reach.Plugin

alias Reach.IR
alias Reach.{AST, IR, MacroFact}
alias Reach.IR.Node
alias Reach.MacroFact

@impl true
def inference_hints do
Expand Down Expand Up @@ -240,6 +239,20 @@ defmodule Reach.Plugins.Ash do
:deprecated_states
]

@reinterpreted_block_dsl @ash_resource_block_dsl ++ @ash_action_dsl ++ @state_machine_dsl

@impl true
def reinterpreted_ast?(ast) do
case AST.call(ast) do
{nil, :expr, [_expression]} -> true
{Ash.Expr, :expr, [_expression]} -> true
{nil, name, args} when name in @reinterpreted_block_dsl -> block_call?(args)
_other -> false
end
end

defp block_call?(args), do: args |> List.last() |> AST.keyword?(:do)

# --- Ash.Notifier ---

@notifier_fns [:notify]
Expand Down
20 changes: 18 additions & 2 deletions lib/reach/plugins/ecto.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ defmodule Reach.Plugins.Ecto do
@moduledoc "Plugin for Ecto query DSL, Repo calls, and schema semantics."
@behaviour Reach.Plugin

alias Reach.IR
alias Reach.{AST, IR, MacroFact}
alias Reach.IR.Node
alias Reach.MacroFact

import Reach.Plugins.Helpers, only: [find_vars_in: 1]

Expand Down Expand Up @@ -108,6 +107,23 @@ defmodule Reach.Plugins.Ecto do
:ilike
]

@reinterpreted_query_macros @query_dsl ++ [:dynamic]

@impl true
def reinterpreted_ast?(ast) do
case AST.call(ast) do
{nil, name, [_argument | _rest]} when name in @reinterpreted_query_macros ->
true

{module, name, [_argument | _rest]}
when module in [Ecto.Query, Ecto.Query.API] and name in @reinterpreted_query_macros ->
true

_other ->
false
end
end

@changeset_fns [
:cast,
:validate_required,
Expand Down
25 changes: 25 additions & 0 deletions lib/reach/plugins/nx.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
defmodule Reach.Plugins.Nx do
@moduledoc "Plugin for Nx.Defn numerical-definition DSL semantics."
@behaviour Reach.Plugin

alias Reach.AST

@defn_macros [:defn, :defnp]

@impl true
def analyze(_nodes, _opts), do: []

@impl true
def inference_hints, do: %{deps: [:nx], source: ["Nx", "Nx.Defn"]}

@impl true
def reinterpreted_ast?(ast) do
case AST.call(ast) do
{nil, name, [_head, body]} when name in @defn_macros -> keyword_body?(body)
{Nx.Defn, name, [_head, body]} when name in @defn_macros -> keyword_body?(body)
_other -> false
end
end

defp keyword_body?(body), do: AST.keyword?(body, :do)
end
Loading
Loading