defmodule Reach.OTP.Analysis do @moduledoc "Analyzes GenServer and gen_statem state machines from IR to extract behaviours, state transforms, and missing handlers." alias Reach.IR alias Reach.OTP.Analysis.{Behaviour, MissingHandler, Result, StateTransform, Supervisor} alias Reach.OTP.CrossProcess alias Reach.OTP.DeadReply alias Reach.OTP.GenStatem def run(project, scope) do nodes = Map.values(project.nodes) all_ir = Enum.flat_map(nodes, &IR.all_nodes/1) plugins = Map.get(project, :plugins, []) behaviours = find_gen_servers(nodes, scope, plugins) state_machines = find_gen_statems(all_ir, scope) hidden_coupling = find_hidden_coupling(nodes) missing_handlers = find_missing_handlers(nodes) supervision = find_supervision(nodes) dead_replies = DeadReply.find_dead_replies(nodes, all_nodes: all_ir) cross_process = CrossProcess.find_cross_process_coupling(nodes, all_nodes: all_ir) Result.new( behaviours: behaviours, state_machines: state_machines, hidden_coupling: hidden_coupling, missing_handlers: missing_handlers, supervision: supervision, dead_replies: dead_replies, cross_process: cross_process ) end defp find_gen_servers(nodes, scope, plugins) do nodes |> Enum.filter(&(&1.type == :function_def)) |> Enum.group_by(&module_key(&1, scope)) |> Enum.reject(fn {mod, _} -> is_nil(mod) end) |> Enum.map(fn {mod, func_defs} -> callbacks = group_callbacks(func_defs) if map_size(callbacks) > 0 do state_transforms = analyze_state_transforms(callbacks) Behaviour.new( module: mod, behaviour: detect_behaviour(callbacks, plugins), callbacks: callbacks, state_transforms: state_transforms ) end end) |> Enum.reject(&is_nil/1) end defp find_gen_statems(nodes, scope) do nodes |> Enum.filter(fn n -> n.type == :module_def and (!scope || (n.meta[:name] && to_string(n.meta[:name]) =~ scope)) end) |> Enum.map(fn mod_node -> children = [mod_node | IR.all_nodes(mod_node)] analysis = GenStatem.analyze(children) if analysis do location = location(mod_node) Map.merge(analysis, %{module: mod_node.meta[:name], location: location}) end end) |> Enum.reject(&is_nil/1) end defp location(%{source_span: %{file: file, start_line: line}}), do: "#{file}:#{line}" defp location(_node), do: "unknown" defp module_key(node, scope) do mod = node.meta[:module] file = if node.source_span, do: node.source_span[:file], else: nil key = mod || file cond do scope && key && to_string(key) =~ scope -> key scope -> nil true -> key end end defp group_callbacks(func_defs) do func_defs |> Enum.filter(fn n -> n.meta[:name] in [ :init, :handle_call, :handle_cast, :handle_info, :handle_continue, :terminate, :code_change ] end) |> Map.new(fn n -> {{n.meta[:name], n.meta[:arity]}, n} end) end defp detect_behaviour(callbacks, plugins) do names = callbacks |> Enum.map(fn {{name, _arity}, _node} -> name end) |> Enum.uniq() cond do Map.has_key?(callbacks, {:handle_call, 3}) -> "GenServer" Map.has_key?(callbacks, {:init, 1}) -> "GenServer" true -> Reach.Plugin.behaviour_label(plugins, names) || "unknown" end end defp analyze_state_transforms(callbacks) do callbacks |> Enum.filter(fn {{name, _}, _} -> name in [:init, :handle_call, :handle_cast, :handle_info, :handle_continue] end) |> Enum.map(fn {{name, arity}, node} -> state_param = find_state_param(node, arity) action = infer_state_action(node, state_param) StateTransform.new( callback: {name, arity}, action: action, location: location(node) ) end) end defp find_state_param(node, arity) do node.children |> Enum.filter(&(&1.type == :clause)) |> Enum.flat_map(fn clause -> clause.children |> Enum.take(arity) |> Enum.take(-1) end) |> Enum.map(&unwrap_state_param/1) |> Enum.reject(&is_nil/1) |> List.first() end defp unwrap_state_param(%{type: :match, children: children}) do # Prefer var over struct (e.g. %State{} = state → use :state) Enum.reduce_while(children, nil, fn child, fallback -> cond do child.type == :var -> {:halt, child} child.type in [:struct, :map] -> {:cont, fallback || child} true -> {:cont, fallback || unwrap_state_param(child)} end end) end defp unwrap_state_param(%{type: t} = node) when t in [:var, :struct, :map], do: node defp unwrap_state_param(_), do: nil defp infer_state_action(_node, nil), do: :unknown defp infer_state_action(node, state_param) do state_name = state_param_name(state_param) all = IR.all_nodes(node) cond do writes_state?(all, state_name) -> :read_write reads_state?(all, state_name) -> :reads state_name != nil -> :passes_through true -> :unknown end end defp reads_state?(all, state_name) do field_access?(all, state_name) or state_passed_as_arg?(all, state_name) end defp field_access?(all, state_name) do Enum.any?(all, fn n -> n.type == :call and n.meta[:kind] == :remote and n.meta[:module] == state_name end) end defp state_passed_as_arg?(all, state_name) do Enum.any?(all, fn n -> n.type == :call and Enum.any?(n.children, &var_named?(&1, state_name)) end) end defp writes_state?(all, state_name) do Enum.any?(all, fn n -> struct_or_map_update?(n, state_name) or map_write_call?(n, state_name) or ets_write_with_state?(n, state_name) end) end defp struct_or_map_update?(n, state_name) do n.type in [:struct, :map] and has_update_syntax?(n, state_name) end defp map_write_call?(n, state_name) do n.type == :call and n.meta[:function] in [:put, :update, :merge, :delete] and n.meta[:module] == Map and Enum.any?(n.children, &var_named?(&1, state_name)) end defp ets_write_with_state?(n, state_name) do n.type == :call and n.meta[:module] == :ets and n.meta[:function] in [:insert, :insert_new, :update_element, :delete] and Enum.any?(n.children, &var_named?(&1, state_name)) end defp state_param_name(%{type: :var, meta: %{name: name}}), do: name defp state_param_name(%{type: type, children: children}) when type in [:struct, :map] do case Enum.find(children, &(&1.type == :match)) do %{children: [_, %{type: :var, meta: %{name: name}}]} -> name _ -> case Enum.reverse(children) do [%{type: :var, meta: %{name: name}} | _rest] -> name _ -> nil end end end defp state_param_name(_), do: nil defp var_named?(%{type: :var, meta: %{name: name}}, target), do: name == target defp var_named?(_, _), do: false defp has_update_syntax?(%{children: children}, state_name) do match?([%{type: :var, meta: %{name: ^state_name}} | _], children) end defp find_hidden_coupling(nodes) do ets_ops = find_ets_ops(nodes) pdict_ops = find_pdict_ops(nodes) %{ets: ets_ops, process_dict: pdict_ops} end defp find_ets_ops(nodes) do nodes |> Enum.filter(fn n -> n.type == :call and n.meta[:module] == :ets end) |> Enum.map(fn n -> action = classify_ets_action(n.meta[:function]) %{action: action, function: n.meta[:function], node: n, location: location(n)} end) |> Enum.group_by(fn op -> case op.node.children do [%{type: :literal, meta: %{value: name}} | _] when is_atom(name) -> name _ -> :unknown_table end end) end defp classify_ets_action(func) do cond do func in [:insert, :insert_new, :delete] -> :write func in [:lookup, :lookup_element, :match, :select, :member] -> :read true -> :unknown end end defp find_pdict_ops(nodes) do nodes |> Enum.filter(fn n -> n.type == :call and n.meta[:module] == Process and n.meta[:function] in [:put, :get, :delete] end) |> Enum.map(fn n -> key = case n.children do [%{type: :literal, meta: %{value: k}} | _] -> k _ -> :unknown_key end %{ key: key, action: if(n.meta[:function] in [:put, :delete], do: :write, else: :read), location: location(n) } end) |> Enum.group_by(& &1.key) end defp find_missing_handlers(nodes) do sends = find_gen_server_sends(nodes) {handles, has_catch_all} = find_handled_messages(nodes) sends |> Enum.reject(fn n -> has_catch_all and not literal_atom_msg?(n) end) |> Enum.filter(&unmatched_send?(&1, handles)) |> Enum.map(fn n -> MissingHandler.new(location: location(n), message: n.meta[:function]) end) end defp literal_atom_msg?(%{children: children}) do Enum.any?(children, fn %{type: :literal, meta: %{value: v}} when is_atom(v) -> true _ -> false end) end defp find_gen_server_sends(nodes) do Enum.filter(nodes, fn n -> n.type == :call and n.meta[:function] in [:cast, :call] and n.meta[:module] == GenServer end) end defp find_handled_messages(nodes) do handlers = nodes |> Enum.filter(fn n -> n.type == :function_def and n.meta[:name] in [:handle_call, :handle_cast, :handle_info] end) has_catch_all = Enum.any?(handlers, fn handler -> handler.children |> Enum.filter(&(&1.type == :clause)) |> Enum.any?(&catch_all_clause?/1) end) handles = handlers |> Enum.flat_map(&extract_clause_message_types/1) |> MapSet.new() {handles, has_catch_all} end defp catch_all_clause?(clause) do match?([%{type: :var} | _], clause.children) end defp extract_clause_message_types(func) do func.children |> Enum.filter(&(&1.type == :clause)) |> Enum.flat_map(&extract_message_patterns(&1, func.meta[:arity])) end defp extract_message_patterns(clause, arity) do clause.children |> Enum.take(arity) |> Enum.flat_map(fn %{type: :literal, meta: %{value: val}} when is_atom(val) -> [val] %{type: :tuple, children: children} -> children |> Enum.filter(&(&1.type == :literal)) |> Enum.map(& &1.meta[:value]) _ -> [] end) end defp unmatched_send?(send_node, handles) do case send_node.children do [_mod_or_pid, %{type: :literal, meta: %{value: msg_type}} | _] -> msg_type not in handles [%{type: :literal, meta: %{value: msg_type}} | _] -> msg_type not in handles _ -> false end end defp find_supervision(nodes) do init_supervisors = nodes |> Enum.filter(fn n -> n.type == :function_def and n.meta[:name] == :init and n.meta[:arity] == 1 and supervisor_init?(n) end) |> Enum.map(fn n -> children = extract_supervisor_children(n) Supervisor.new( module: module_for_node(nodes, n), location: location(n), children: children ) end) inline_supervisors = nodes |> Enum.filter(fn n -> n.type == :call and n.meta[:module] == Supervisor and n.meta[:function] == :start_link end) |> Enum.map(fn n -> parent = find_containing_function(nodes, n.id) children = if parent, do: extract_children_from_scope(parent, n), else: [] mod = if parent, do: module_for_node(nodes, parent), else: find_enclosing_module(nodes, n.id) Supervisor.new(module: mod, location: location(n), children: children) end) init_supervisors |> Kernel.++(inline_supervisors) |> Enum.reject(&is_nil(&1.module)) |> Enum.uniq_by(& &1.module) end defp module_for_node(nodes, node) do node.meta[:module] || find_enclosing_module(nodes, node.id) end defp supervisor_init?(node) do node |> IR.all_nodes() |> Enum.any?(fn child -> child.type == :call and (child.meta[:function] in [:supervise, :init, :child_spec] or (child.meta[:module] == Supervisor and child.meta[:function] == :start_link)) end) end defp extract_supervisor_children(func_node) do func_node |> IR.all_nodes() |> Enum.filter(&supervisor_start_link?/1) |> Enum.flat_map(&extract_children_from_scope(func_node, &1)) end defp supervisor_start_link?(%{type: :call, meta: %{module: Supervisor, function: :start_link}}), do: true defp supervisor_start_link?(_), do: false defp extract_children_from_scope(func_node, call_node) do all = IR.all_nodes(func_node) case call_node.children do [first_arg | _] -> resolve_child_list(first_arg, all) _ -> [] end end defp resolve_child_list(%{type: :list, children: items}, _all) do items |> Enum.map(&extract_child_module/1) |> Enum.reject(&is_nil/1) end defp resolve_child_list(%{type: :var, meta: %{name: var_name}}, all) do resolve_var_to_list(all, var_name) end defp resolve_child_list(_, _), do: [] defp resolve_var_to_list(all_nodes, var_name) do case Enum.find(all_nodes, fn n -> n.type == :match and match?( [%{type: :var, meta: %{name: ^var_name, binding_role: :definition}} | _], n.children ) end) do %{children: [_, %{type: :list, children: items}]} -> Enum.map(items, &extract_child_module/1) _ -> [] end end defp extract_child_module(%{type: :literal, meta: %{value: mod}}) when is_atom(mod), do: mod defp extract_child_module(%{type: :struct, meta: %{name: mod}}), do: mod defp extract_child_module(%{type: :tuple, children: [first | _]}) do extract_child_module(first) end defp extract_child_module(%{type: :call, meta: %{function: :__aliases__}, children: parts}) do parts |> Enum.map(fn %{type: :literal, meta: %{value: v}} when is_atom(v) -> v _ -> nil end) |> then(fn atoms -> if Enum.all?(atoms, &is_atom/1), do: Module.concat(atoms) end) end defp extract_child_module(%{type: :call, meta: %{module: mod}}) when is_atom(mod) and not is_nil(mod), do: mod defp extract_child_module(_), do: nil defp find_containing_function(nodes, target_id) do Enum.find(nodes, fn n -> n.type == :function_def and target_id in Enum.map(IR.all_nodes(n), & &1.id) end) end defp find_enclosing_module(nodes, target_id) do case Enum.find(nodes, fn n -> n.type == :module_def and target_id in Enum.map(IR.all_nodes(n), & &1.id) end) do %{meta: %{name: name}} -> name _ -> nil end end end