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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### New

- **LiveView/HEEx lowering** — added an optional LiveView plugin that recognizes `~H` and `.heex` templates, lowers HEEx control flow (`if`/`case`, `:if`, `:for`) into Reach's Elixir IR with template source labels/spans, and hides LiveView rendering helper edges from graph presentation. The plugin uses LiveView's parser when available and falls back to the stable `TagEngine` path for current 1.1.x projects.
- **LiveView semantic edges** — LiveView analysis now connects `JS.push`/`push_event` calls to `handle_event/3`, assign writes to HEEx assign reads, parser-lowered component attr values to component calls, and stream writes to `@streams.*` reads. Static `phx-*` event attrs are modeled by the parser-backed HEEx lowerer when available.
- **Architecture layer ergonomics** — `.reach.exs` now validates unknown layer references, supports allowlist-style dependency policy, dependency exceptions, optional layer coverage checks, and layer-cycle violations with concrete call-edge witnesses.

### Changed
Expand Down
6 changes: 3 additions & 3 deletions lib/reach.ex
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ defmodule Reach do
"""
@spec file_to_graph(Path.t(), keyword()) :: {:ok, graph()} | {:error, term()}
def file_to_graph(path, opts \\ []) do
language = Keyword.get(opts, :language) || language_from_extension(path)
language = Keyword.get(opts, :language) || language_from_extension(path, opts)
opts = Keyword.put_new(opts, :file, path) |> Keyword.put(:language, language)

case language do
Expand Down Expand Up @@ -1151,11 +1151,11 @@ defmodule Reach do
end
end

defp language_from_extension(path) do
defp language_from_extension(path, opts) do
case Path.extname(path) do
ext when ext in [".erl", ".hrl"] -> :erlang
".gleam" -> :gleam
ext -> Plugin.source_language(Plugin.detect(), ext) || :elixir
ext -> Plugin.source_language(Plugin.resolve(opts), ext) || :elixir
end
end
end
29 changes: 28 additions & 1 deletion lib/reach/cli/render/check/smells.ex
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,39 @@ defmodule Reach.CLI.Render.Check.Smells do
render_group(structural_consistency(grouped), "Structural consistency")
render_group(Map.get(grouped, :behaviour_candidate, []), "Behaviour candidates")
render_group(Map.get(grouped, :dual_key_access, []), "Loose map contracts")
rendered_kinds = rendered_kinds()

render_group(Map.get(grouped, :fixed_shape_map, []), "Repeated map shapes")

IO.puts("#{length(findings)} finding(s)\n")
grouped
|> Map.drop(rendered_kinds)
|> Enum.sort_by(fn {kind, _findings} -> to_string(kind) end)
|> Enum.each(fn {kind, findings} ->
render_group(findings, Format.humanize(kind))
end)

IO.puts(" #{length(findings)} finding(s)\n")
end
end

defp rendered_kinds do
[
:redundant_traversal,
:suboptimal,
:redundant_computation,
:eager_pattern,
:string_building,
:config_phase,
:return_contract_drift,
:side_effect_order_drift,
:map_contract_drift,
:validation_drift,
:behaviour_candidate,
:dual_key_access,
:fixed_shape_map
]
end

defp structural_consistency(grouped) do
[:return_contract_drift, :side_effect_order_drift, :map_contract_drift, :validation_drift]
|> Enum.flat_map(&Map.get(grouped, &1, []))
Expand Down
128 changes: 87 additions & 41 deletions lib/reach/frontend/elixir.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ defmodule Reach.Frontend.Elixir do
"""

alias Reach.IR.{Counter, Node}
alias Reach.{Plugin, Source}
import Reach.IR.Helpers, only: [mark_as_definitions: 1]

@doc """
Expand All @@ -24,8 +25,16 @@ defmodule Reach.Frontend.Elixir do
) do
{:ok, ast} ->
counter = Keyword.get(opts, :counter, Counter.new())
nodes = translate(ast, counter, file)
{:ok, List.wrap(nodes)}
plugins = Plugin.resolve(opts)
previous_plugins = Process.get(:reach_plugins)
Process.put(:reach_plugins, plugins)

try do
nodes = translate(ast, counter, file)
{:ok, List.wrap(nodes)}
after
restore_process_value(:reach_plugins, previous_plugins)
end

{:error, _} = err ->
err
Expand Down Expand Up @@ -111,6 +120,7 @@ defmodule Reach.Frontend.Elixir do
%Node{
id: Counter.next(counter),
type: :block,
meta: origin_meta(meta),
children: children,
source_span: span_from_meta(meta, file)
}
Expand Down Expand Up @@ -215,7 +225,7 @@ defmodule Reach.Frontend.Elixir do
%Node{
id: Counter.next(counter),
type: :case,
meta: %{desugared_from: kind},
meta: Map.merge(%{desugared_from: kind}, origin_meta(meta)),
children: [condition_node, true_clause, false_clause],
source_span: span_from_meta(meta, file)
}
Expand All @@ -241,7 +251,7 @@ defmodule Reach.Frontend.Elixir do
%Node{
id: Counter.next(counter),
type: :case,
meta: %{desugared_from: :cond},
meta: Map.merge(%{desugared_from: :cond}, origin_meta(meta)),
children: children,
source_span: span_from_meta(meta, file)
}
Expand Down Expand Up @@ -269,6 +279,7 @@ defmodule Reach.Frontend.Elixir do
%Node{
id: Counter.next(counter),
type: :case,
meta: origin_meta(meta),
children: [expr_node | clause_nodes],
source_span: span_from_meta(meta, file)
}
Expand Down Expand Up @@ -337,7 +348,7 @@ defmodule Reach.Frontend.Elixir do
%Node{
id: Counter.next(counter),
type: :case,
meta: %{desugared_from: :with},
meta: Map.merge(%{desugared_from: :with}, origin_meta(meta)),
children: clause_nodes ++ [body_node | else_nodes],
source_span: span_from_meta(meta, file)
}
Expand Down Expand Up @@ -458,6 +469,7 @@ defmodule Reach.Frontend.Elixir do
%Node{
id: Counter.next(counter),
type: :generator,
meta: origin_meta(clause_meta),
children: [pat_node, enum_node],
source_span: span_from_meta(clause_meta, file)
}
Expand All @@ -469,7 +481,7 @@ defmodule Reach.Frontend.Elixir do
%Node{
id: Counter.next(counter),
type: :generator,
meta: %{kind: :binary},
meta: Map.merge(%{kind: :binary}, origin_meta(clause_meta)),
children: [pat_node, enum_node],
source_span: span_from_meta(clause_meta, file)
}
Expand All @@ -483,13 +495,14 @@ defmodule Reach.Frontend.Elixir do
end)

body_node = translate(Keyword.get(opts, :do), counter, file)
children = clause_nodes ++ [body_node]

%Node{
id: Counter.next(counter),
type: :comprehension,
meta: Map.new(Keyword.drop(opts, [:do])),
children: clause_nodes ++ [body_node],
source_span: span_from_meta(meta, file)
meta: Map.merge(Map.new(Keyword.drop(opts, [:do])), origin_meta(meta)),
children: children,
source_span: span_from_meta(meta, file) || Enum.find_value(children, &first_child_span/1)
}
end

Expand Down Expand Up @@ -708,7 +721,9 @@ defmodule Reach.Frontend.Elixir do
%Node{
id: Counter.next(counter),
type: :call,
meta: %{module: resolved_module, function: fun_name, kind: :remote},
meta:
%{module: resolved_module, function: fun_name, kind: :remote}
|> Map.merge(origin_meta(meta)),
children: receiver_children,
source_span: span_from_meta(meta, file)
}
Expand All @@ -723,7 +738,7 @@ defmodule Reach.Frontend.Elixir do
%Node{
id: Counter.next(counter),
type: :call,
meta: %{arity: length(args), kind: :dynamic},
meta: %{arity: length(args), kind: :dynamic} |> Map.merge(origin_meta(call_meta || meta)),
children: [callee_node | arg_nodes],
source_span: span_from_meta(call_meta || meta, file)
}
Expand Down Expand Up @@ -753,7 +768,9 @@ defmodule Reach.Frontend.Elixir do
%Node{
id: Counter.next(counter),
type: :call,
meta: %{module: resolved_module, function: field, arity: 0, kind: :remote},
meta:
%{module: resolved_module, function: field, arity: 0, kind: :remote}
|> Map.merge(origin_meta(call_meta || meta)),
children: receiver_children,
source_span: span_from_meta(call_meta || meta, file)
}
Expand Down Expand Up @@ -786,32 +803,42 @@ defmodule Reach.Frontend.Elixir do
%Node{
id: Counter.next(counter),
type: :call,
meta: %{
module: resolved_module,
function: fun_name,
arity: length(args),
kind: :remote
},
meta:
%{
module: resolved_module,
function: fun_name,
arity: length(args),
kind: :remote
}
|> Map.merge(origin_meta(call_meta || meta)),
children: receiver_children ++ arg_nodes,
source_span: span_from_meta(call_meta || meta, file)
}
end

# Local call: function(args) — check if imported
defp translate({fun_name, meta, args}, counter, file)
defp translate({fun_name, meta, args} = ast, counter, file)
when is_atom(fun_name) and is_list(args) do
arg_nodes = Enum.map(args, &translate(&1, counter, file))
arity = length(args)
case lower_elixir_ast(ast, file) do
{:ok, lowered_ast} ->
translate(lowered_ast, counter, file)

{module, kind} = resolve_import(fun_name, arity)
_ ->
arg_nodes = Enum.map(args, &translate(&1, counter, file))
arity = length(args)

%Node{
id: Counter.next(counter),
type: :call,
meta: %{function: fun_name, arity: arity, module: module, kind: kind},
children: arg_nodes,
source_span: span_from_meta(meta, file)
}
{module, kind} = resolve_import(fun_name, arity)

%Node{
id: Counter.next(counter),
type: :call,
meta:
%{function: fun_name, arity: arity, module: module, kind: kind}
|> Map.merge(origin_meta(meta)),
children: arg_nodes,
source_span: span_from_meta(meta, file)
}
end
end

# Catch-all for unhandled AST forms
Expand Down Expand Up @@ -1281,20 +1308,39 @@ defmodule Reach.Frontend.Elixir do
end

defp span_from_meta(meta, file) when is_list(meta) do
case meta[:line] do
nil ->
nil
Source.span_from_origin(Source.origin(meta)) ||
case meta[:line] do
nil ->
nil

line ->
%{
file: file,
start_line: line,
start_col: meta[:column] || 1,
end_line: nil,
end_col: nil
}
end
end

line ->
%{
file: file,
start_line: line,
start_col: meta[:column] || 1,
end_line: nil,
end_col: nil
}
defp span_from_meta(_, _), do: nil

defp origin_meta(meta) when is_list(meta) do
case Source.origin(meta) do
nil -> %{}
origin -> %{origin: origin}
end
end

defp span_from_meta(_, _), do: nil
defp origin_meta(_), do: %{}

defp lower_elixir_ast(ast, file) do
plugins = Process.get(:reach_plugins, [])
opts = [file: file, module: Process.get(:reach_current_module)]
Plugin.lower_elixir_ast(plugins, ast, opts)
end

defp restore_process_value(key, nil), do: Process.delete(key)
defp restore_process_value(key, value), do: Process.put(key, value)
end
33 changes: 31 additions & 2 deletions lib/reach/plugin.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ defmodule Reach.Plugin do
3. **Source frontends** — optional `source_extensions/0` and `parse_file/2`
callbacks let plugins own language frontends for optional ecosystems.

4. **Embedded IR** — `analyze_embedded/2` extracts code from string
4. **Elixir AST lowering** — `lower_elixir_ast/2` translates framework
DSL forms, sigils, or template syntax into analysis-friendly Elixir AST.

5. **Embedded IR** — `analyze_embedded/2` extracts code from string
literals (e.g. JS inside QuickBEAM.eval) and returns additional IR
nodes plus cross-language edges.

5. **Framework presentation/patterns** — optional callbacks provide
6. **Framework presentation/patterns** — optional callbacks provide
framework-specific trace presets, behaviour labels, and visualization
edge filtering.

Expand Down Expand Up @@ -84,6 +87,16 @@ defmodule Reach.Plugin do
connecting them to the host graph.
"""
@callback analyze_embedded(all_nodes :: [Node.t()], opts :: keyword()) :: embedded_result()

@doc """
Lowers framework-specific Elixir AST into ordinary Elixir AST before Reach IR translation.

Return `:ignore` when the plugin does not own the AST node. Returned AST may
carry `%Reach.Source.Origin{}` metadata under the `:reach` metadata key.
"""
@callback lower_elixir_ast(ast :: Macro.t(), opts :: keyword()) ::
{:ok, Macro.t()} | :ignore | {:error, term()}

@callback source_extensions() :: [String.t()]
@callback source_language(extension :: String.t()) :: atom() | nil
@callback parse_file(path :: Path.t(), opts :: keyword()) ::
Expand All @@ -100,6 +113,7 @@ defmodule Reach.Plugin do
@optional_callbacks analyze_project: 3,
classify_effect: 1,
analyze_embedded: 2,
lower_elixir_ast: 2,
source_extensions: 0,
source_language: 1,
parse_file: 2,
Expand All @@ -112,6 +126,7 @@ defmodule Reach.Plugin do

@known_plugins [
{Phoenix.Router, Reach.Plugins.Phoenix},
{Phoenix.LiveView, Reach.Plugins.LiveView},
{Ecto, Reach.Plugins.Ecto},
{Ash, Reach.Plugins.Ash},
{Oban, Reach.Plugins.Oban},
Expand Down Expand Up @@ -162,6 +177,20 @@ defmodule Reach.Plugin do
end)
end

@doc "Asks plugins to lower a framework-specific Elixir AST node."
def lower_elixir_ast(plugins, ast, opts) do
Enum.find_value(plugins, :ignore, &lower_elixir_ast_with_plugin(&1, ast, opts))
end

defp lower_elixir_ast_with_plugin(plugin, ast, opts) do
if exports?(plugin, :lower_elixir_ast, 2) do
case plugin.lower_elixir_ast(ast, opts) do
:ignore -> nil
result -> result
end
end
end

@doc "Returns source extensions handled by plugins."
def source_extensions(plugins) do
plugins
Expand Down
Loading
Loading