defmodule Reach do @moduledoc """ Program Dependence Graph for Elixir and Erlang. Reach analyzes Elixir and Erlang source code and builds a graph that captures **what depends on what**: which expressions produce values consumed by others (data dependence), and which expressions control whether others execute (control dependence). ## Building a graph # Elixir (default) {:ok, graph} = Reach.string_to_graph(\""" def foo(x) do y = x + 1 if y > 10, do: :big, else: :small end \""") # Erlang {:ok, graph} = Reach.string_to_graph(source, language: :erlang) # Auto-detected from file extension {:ok, graph} = Reach.file_to_graph("lib/my_module.ex") {:ok, graph} = Reach.file_to_graph("src/my_module.erl") ## Querying Reach.backward_slice(graph, node_id) Reach.forward_slice(graph, node_id) Reach.independent?(graph, id_a, id_b) Reach.nodes(graph, type: :call, module: Enum) Reach.nodes(graph, type: :call, module: Enum, function: :map, arity: 2) Reach.data_flows?(graph, source_id, sink_id) ## Inspecting nodes node = Reach.node(graph, some_id) node.type #=> :call node.meta #=> %{module: Enum, function: :map, arity: 2} node.source_span #=> %{file: "lib/foo.ex", start_line: 5, ...} Reach.pure?(node) #=> true """ alias Reach.{Effects, Frontend, SystemDependence} alias Reach.IR.{Counter, Node} import Reach.IR.Helpers, only: [module_from_path: 1] @typedoc "A program dependence graph. Built by `string_to_graph/2`, `file_to_graph/2`, etc." @type graph :: struct() @type node_id :: Node.id() # --- Building --- @doc """ Parses a source string and builds a program dependence graph. Returns `{:ok, graph}` or `{:error, reason}`. ## Options * `:language` — `:elixir` (default) or `:erlang` * `:file` — filename for source locations (default: `"nofile"`) * `:module` — module name for call graph resolution """ @spec string_to_graph(String.t(), keyword()) :: {:ok, graph()} | {:error, term()} def string_to_graph(source, opts \\ []) do {language, opts} = Keyword.pop(opts, :language, :elixir) case parse_string(source, language, opts) do {:ok, ir_nodes} -> {:ok, SystemDependence.build(ir_nodes, opts)} {:error, _} = err -> err end end @doc """ Same as `string_to_graph/2` but raises on parse error. """ @spec string_to_graph!(String.t(), keyword()) :: graph() def string_to_graph!(source, opts \\ []) do case string_to_graph(source, opts) do {:ok, graph} -> graph {:error, reason} -> raise ArgumentError, "Reach parse error: #{inspect(reason)}" end end @doc """ Reads a source file and builds a program dependence graph. The language is auto-detected from the file extension (`.ex` / `.exs` for Elixir, `.erl` / `.hrl` for Erlang), or can be set explicitly via the `:language` option. Returns `{:ok, graph}` or `{:error, reason}`. """ @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) opts = Keyword.put(opts, :language, language) case language do :erlang -> opts = Keyword.put_new(opts, :file, path) case Frontend.Erlang.parse_file(path, opts) do {:ok, nodes} -> {:ok, SystemDependence.build(nodes, opts)} {:error, _} = err -> err end _elixir -> case File.read(path) do {:ok, source} -> opts = opts |> Keyword.put_new(:file, path) |> Keyword.put_new(:module, module_from_path(path)) parse_and_build(source, :elixir, opts) {:error, reason} -> {:error, {:file, reason}} end end end @doc """ Same as `file_to_graph/2` but raises on error. """ @spec file_to_graph!(Path.t(), keyword()) :: graph() def file_to_graph!(path, opts \\ []) do case file_to_graph(path, opts) do {:ok, graph} -> graph {:error, reason} -> raise ArgumentError, "Reach error: #{inspect(reason)}" end end @doc """ Builds a graph from a compiled module (loaded in the VM). Analyzes the macro-expanded Erlang abstract forms from the BEAM bytecode. This captures code injected by `use`, `defmacro`, and other macros that the source-level frontend misses. Requires the module to be compiled with debug info (the default). """ @spec module_to_graph(module(), keyword()) :: {:ok, graph()} | {:error, term()} def module_to_graph(module, opts \\ []) do case Frontend.BEAM.from_module(module, opts) do {:ok, nodes} -> {:ok, SystemDependence.build(nodes, opts)} {:error, _} = err -> err end end @doc """ Compiles an Elixir source string and builds a graph from the expanded bytecode. Unlike `string_to_graph/2`, this compiles the code first, so macro-expanded constructs (try/rescue inside macros, `use` callbacks, etc.) are visible. The source must define complete modules. """ @spec compiled_to_graph(String.t() | [{module(), binary()}], keyword()) :: {:ok, graph()} | {:error, term()} def compiled_to_graph(source_or_modules, opts \\ []) def compiled_to_graph(source, opts) when is_binary(source) do case Frontend.BEAM.from_compiled_string(source, opts) do {:ok, nodes} -> {:ok, SystemDependence.build(nodes, opts)} {:error, _} = err -> err end end def compiled_to_graph(compiled, opts) when is_list(compiled) do {:ok, nodes} = Frontend.BEAM.from_compiled_modules(compiled, opts) {:ok, SystemDependence.build(nodes, opts)} end def compiled_to_graph(_, _opts), do: {:error, :invalid_input} @doc """ Builds a graph from an already-parsed Elixir AST. Useful when you already have the AST (e.g. from Credo or ExDNA) and don't want to re-parse source. """ @spec ast_to_graph(Macro.t(), keyword()) :: {:ok, graph()} | {:error, term()} def ast_to_graph(ast, opts \\ []) do counter = Counter.new() file = Keyword.get(opts, :file, "nofile") nodes = Frontend.Elixir.translate_ast(ast, counter, file) {:ok, SystemDependence.build(List.wrap(nodes), opts)} end @doc """ Returns the children of a block in canonical order. Independent sibling expressions are sorted by structural hash so that reordered-but-equivalent blocks produce the same sequence. Dependent expressions preserve their relative order. Returns a list of `{node_id, ir_node}` pairs. ## Example # These two blocks produce the same canonical order: # a = 1; b = 2; c = a + b # b = 2; a = 1; c = a + b # Because a=1 and b=2 are independent, they get sorted, # while c=a+b stays last (depends on both). """ @spec canonical_order(graph(), Reach.IR.Node.id()) :: [{Reach.IR.Node.id(), Reach.IR.Node.t()}] def canonical_order(%SystemDependence{} = graph, block_node_id) do block = node(graph, block_node_id) case block do %{type: type, children: children} when type in [:block, :clause, :function_def] -> sort_preserving_deps(graph, children) _ -> case block do %{id: id} = n -> [{id, n}] nil -> [] end end end defp sort_preserving_deps(graph, children) do indexed = Enum.with_index(children) # Build a dependency map: which children must come before which must_precede = for {a, i} <- indexed, {b, j} <- indexed, i < j, not independent?(graph, a.id, b.id), reduce: MapSet.new() do acc -> MapSet.put(acc, {i, j}) end # Topological sort respecting dependencies, breaking ties by structural hash sorted_indices = topo_sort_with_hash(indexed, must_precede) Enum.map(sorted_indices, fn i -> {node, _} = Enum.at(indexed, i) {node.id, node} end) end defp topo_sort_with_hash(indexed, must_precede) do n = length(indexed) # Build adjacency + in-degree {adj, in_deg} = Enum.reduce(must_precede, {%{}, Map.new(0..(n - 1), &{&1, 0})}, fn {i, j}, {a, d} -> {Map.update(a, i, [j], &[j | &1]), Map.update(d, j, 1, &(&1 + 1))} end) # Compute structural hash for each child (for deterministic tie-breaking) hashes = Map.new(indexed, fn {node, i} -> {i, :erlang.phash2(node)} end) # Kahn's algorithm with hash-based priority ready = in_deg |> Enum.filter(fn {_, d} -> d == 0 end) |> Enum.map(&elem(&1, 0)) |> Enum.sort_by(&Map.get(hashes, &1)) do_topo_sort(ready, adj, in_deg, hashes, []) end defp do_topo_sort([], _adj, _in_deg, _hashes, acc), do: Enum.reverse(acc) defp do_topo_sort([current | rest], adj, in_deg, hashes, acc) do neighbors = Map.get(adj, current, []) {new_ready, in_deg} = Enum.reduce(neighbors, {[], in_deg}, fn neighbor, {ready, deg} -> new_deg = Map.get(deg, neighbor, 0) - 1 deg = Map.put(deg, neighbor, new_deg) if new_deg == 0, do: {[neighbor | ready], deg}, else: {ready, deg} end) next_ready = (rest ++ new_ready) |> Enum.sort_by(&Map.get(hashes, &1)) do_topo_sort(next_ready, adj, in_deg, hashes, [current | acc]) end # --- Slicing --- @doc """ Returns all node IDs that affect the given node (backward slice). The backward slice answers: "what does this expression depend on?" """ @spec backward_slice(graph(), node_id()) :: [term()] def backward_slice(%SystemDependence{graph: g}, node_id) do if Graph.has_vertex?(g, node_id) do Graph.reaching(g, [node_id]) -- [node_id] else [] end end @doc """ Returns all node IDs affected by the given node (forward slice). The forward slice answers: "what does this expression influence?" """ @spec forward_slice(graph(), node_id()) :: [term()] def forward_slice(%SystemDependence{graph: g}, node_id) do if Graph.has_vertex?(g, node_id) do Graph.reachable(g, [node_id]) -- [node_id] else [] end end @doc """ Returns node IDs on all paths from `source` to `sink`. The chop answers: "how does A influence B?" """ @spec chop(graph(), node_id(), node_id()) :: [node_id()] def chop(graph, source, sink) do fwd = forward_slice(graph, source) |> MapSet.new() bwd = backward_slice(graph, sink) |> MapSet.new() MapSet.intersection(fwd, bwd) |> MapSet.to_list() end # --- Independence --- @doc """ Returns true if two expressions are independent. Two expressions are independent when: 1. No data flows between them in either direction 2. They execute under the same conditions (same control dependencies) 3. Their side effects don't conflict Independent expressions can be safely reordered. """ @spec independent?(graph(), node_id(), node_id()) :: boolean() def independent?(%SystemDependence{graph: g, nodes: node_map} = sdg, id_x, id_y) do data_only = build_data_graph(g) ids_x = descendant_ids(node_map, id_x) ids_y = descendant_ids(node_map, id_y) not any_data_path?(data_only, ids_x, ids_y) and not any_data_path?(data_only, ids_y, ids_x) and same_control_deps?(sdg, id_x, id_y) and not conflicting_effects?(node_map, id_x, id_y) end defp descendant_ids(node_map, id) do case Map.get(node_map, id) do nil -> [id] node -> [id | Reach.IR.all_nodes(node) |> Enum.map(& &1.id)] end end defp any_data_path?(data_only, from_ids, to_ids) do Enum.any?(from_ids, fn from -> Enum.any?(to_ids, fn to -> from != to and data_path?(data_only, from, to) end) end) end # --- Querying nodes --- @doc """ Returns all IR nodes, optionally filtered. ## Options * `:type` — filter by node type (`:call`, `:match`, `:var`, etc.) * `:module` — filter calls by module * `:function` — filter calls by function name * `:arity` — filter by arity ## Examples Reach.nodes(graph, type: :call) Reach.nodes(graph, type: :call, module: Enum) Reach.nodes(graph, type: :call, module: Enum, function: :map, arity: 2) """ @spec nodes(graph(), keyword()) :: [Node.t()] def nodes(graph, opts \\ []) def nodes(%{nodes: node_map}, opts) do node_map |> Map.values() |> filter_nodes(opts) end @doc """ Returns the IR node for a given ID, or `nil`. """ @spec node(graph(), Node.id()) :: Node.t() | nil def node(%SystemDependence{nodes: nodes}, id), do: Map.get(nodes, id) @doc """ Returns true if there's a data-dependence path from `source` to `sink`. """ def data_flows?(%SystemDependence{nodes: node_map} = graph, source_id, sink_id) do source_ids = descendant_ids(node_map, source_id) sink_ids = descendant_ids(node_map, sink_id) |> MapSet.new() Enum.any?(source_ids, fn sid -> forward_slice(graph, sid) |> Enum.any?(&MapSet.member?(sink_ids, &1)) end) end @doc """ Returns true if `controller` has a control-dependence edge to `target`. """ @spec controls?(graph(), node_id(), node_id()) :: boolean() def controls?(graph, controller_id, target_id) do control_deps(graph, target_id) |> Enum.any?(fn {id, _label} -> id == controller_id end) end @doc """ Returns true if there's any dependence path between two nodes. """ @spec depends?(graph(), node_id(), node_id()) :: boolean() def depends?(graph, id_a, id_b) do id_b in forward_slice(graph, id_a) or id_a in forward_slice(graph, id_b) end @doc """ Returns true if the node has data dependents (its value is used elsewhere). """ @spec has_dependents?(graph(), node_id()) :: boolean() def has_dependents?(graph, node_id) do forward_slice(graph, node_id) != [] end @doc """ Returns true if the path from `source` to `sink` passes through any node matching `predicate`. Useful for taint analysis — check if sanitization occurs between a source and sink. """ @spec passes_through?(graph(), node_id(), node_id(), (Node.t() -> boolean())) :: boolean() def passes_through?(graph, source_id, sink_id, predicate) do chop(graph, source_id, sink_id) |> Enum.any?(fn id -> case node(graph, id) do nil -> false n -> predicate.(n) end end) end # --- Effects --- @doc """ Returns true if the node is pure (no side effects). """ @spec pure?(Node.t()) :: boolean() defdelegate pure?(node), to: Effects @doc """ Returns the effect classification of a node. Possible values: `:pure`, `:read`, `:write`, `:io`, `:send`, `:receive`, `:exception`, `:nif`, `:unknown`. """ @spec classify_effect(Node.t()) :: Effects.effect() defdelegate classify_effect(node), to: Effects, as: :classify # --- Graph access --- @doc """ Returns all dependence edges in the graph. """ @spec edges(graph()) :: [Graph.Edge.t()] def edges(%SystemDependence{graph: g}), do: Graph.edges(g) def edges(%Reach.Project{graph: g}), do: Graph.edges(g) @doc """ Returns the control dependencies of a node. Each entry is `{controller_node_id, label}`. """ @spec control_deps(graph(), Node.id()) :: [{Node.id(), term()}] def control_deps(%SystemDependence{graph: g}, node_id) do g |> Graph.in_edges(node_id) |> Enum.filter(fn e -> match?({:control, _}, e.label) end) |> Enum.map(fn e -> {e.v1, e.label} end) end @doc """ Returns the data dependencies of a node. Each entry is `{source_node_id, variable_name}`. """ @spec data_deps(graph(), Node.id()) :: [{Node.id(), atom()}] def data_deps(%SystemDependence{graph: g}, node_id) do g |> Graph.in_edges(node_id) |> Enum.filter(fn e -> match?({:data, _}, e.label) end) |> Enum.map(fn e -> {:data, var} = e.label {e.v1, var} end) end @doc """ Returns the per-function PDG for a `{module, function, arity}` tuple. """ @spec function_graph(graph(), {module() | nil, atom(), non_neg_integer()}) :: graph() | nil defdelegate function_graph(graph, function_id), to: SystemDependence, as: :function_pdg @doc """ Performs a context-sensitive backward slice through call boundaries. Uses the Horwitz-Reps-Binkley two-phase algorithm to avoid impossible paths through call sites. """ @spec context_sensitive_slice(graph(), Node.id()) :: [Node.id()] defdelegate context_sensitive_slice(graph, node_id), to: SystemDependence @doc """ Returns the call graph as a `Graph.t()`. Vertices are `{module, function, arity}` tuples. """ @spec call_graph(graph()) :: Graph.t() def call_graph(%SystemDependence{call_graph: cg}), do: cg @doc """ Exports the graph to DOT format for Graphviz visualization. """ @spec to_dot(graph()) :: {:ok, String.t()} def to_dot(%SystemDependence{graph: g}), do: Graph.to_dot(g) def to_dot(%Reach.Project{graph: g}), do: Graph.to_dot(g) @doc """ Returns the underlying `Graph.t()` (libgraph) for direct traversal. Use this when you need graph operations that Reach doesn't expose — path finding, subgraphs, BFS/DFS, topological sort, etc. raw = Reach.to_graph(graph) Graph.vertices(raw) |> length() Graph.get_shortest_path(raw, id_a, id_b) """ @spec to_graph(graph()) :: Graph.t() def to_graph(%SystemDependence{graph: g}), do: g def to_graph(%Reach.Project{graph: g}), do: g @doc """ Returns node IDs directly connected to `node_id`. With no label filter, returns all neighbors (both incoming and outgoing). With a label, returns only neighbors connected by edges with that label. # All direct neighbors Reach.neighbors(graph, node_id) # Only nodes connected by :state_read edges Reach.neighbors(graph, node_id, :state_read) # Only data dependencies Reach.neighbors(graph, node_id, {:data, :x}) """ @spec neighbors(graph(), Node.id(), term() | nil) :: [Node.id()] def neighbors(graph, node_id, label \\ nil) def neighbors(%SystemDependence{graph: g}, node_id, nil) do in_ids = g |> Graph.in_neighbors(node_id) out_ids = g |> Graph.out_neighbors(node_id) Enum.uniq(in_ids ++ out_ids) end def neighbors(%SystemDependence{graph: g}, node_id, label) do in_edges = Graph.in_edges(g, node_id) out_edges = Graph.out_edges(g, node_id) (in_edges ++ out_edges) |> Enum.filter(&match_label?(&1.label, label)) |> Enum.map(fn e -> if e.v1 == node_id, do: e.v2, else: e.v1 end) |> Enum.uniq() end # --- Dead code --- @doc """ Returns nodes whose values are never used and have no side effects. A node is dead if: 1. It is pure (no side effects) 2. No observable output depends on it (return values or effectful calls) """ @spec dead_code(graph()) :: [Node.t()] def dead_code(graph) do observables = observable_nodes(graph) observable_ids = MapSet.new(observables, & &1.id) alive_ids = observables |> Enum.flat_map(&backward_slice(graph, &1.id)) |> MapSet.new() |> MapSet.union(observable_ids) all = nodes(graph) # Also mark parents of alive nodes as alive alive_ids = expand_alive_to_parents(all, alive_ids) find_dead_nodes(all, alive_ids) end defp observable_nodes(graph) do ret_ids = return_node_ids(graph) nodes(graph) |> Enum.filter(fn node -> not pure?(node) or MapSet.member?(ret_ids, node.id) end) end defp return_node_ids(graph) do nodes(graph, type: :clause) |> Enum.filter(&(&1.meta[:kind] == :function_clause)) |> Enum.map(fn clause -> case List.last(clause.children) do %{type: :block, children: children} -> List.last(children) other -> other end end) |> Enum.reject(&is_nil/1) |> MapSet.new(& &1.id) end # --- Taint analysis --- @doc """ Finds data flow paths from taint sources to dangerous sinks. Returns a list of `%{source: node, sink: node, path: [node_id], sanitized: boolean}` for each source→sink pair where data flows. Sources, sinks, and sanitizers can be specified as keyword filters (same format as `nodes/2`) or as predicate functions. ## Options * `:sources` — keyword filter or predicate identifying taint sources * `:sinks` — keyword filter or predicate identifying dangerous sinks * `:sanitizers` — keyword filter or predicate identifying sanitization points (optional) ## Examples Reach.taint_analysis(graph, sources: [type: :call, function: :get_param], sinks: [type: :call, module: System, function: :cmd, arity: 2], sanitizers: [type: :call, function: :sanitize] ) # Predicates also work Reach.taint_analysis(graph, sources: &(&1.meta[:function] in [:params, :body_params]), sinks: [type: :call, module: Ecto.Adapters.SQL] ) """ @spec taint_analysis(graph(), keyword()) :: [map()] def taint_analysis(graph, opts) do source_filter = Keyword.fetch!(opts, :sources) sink_filter = Keyword.fetch!(opts, :sinks) sanitizer_filter = Keyword.get(opts, :sanitizers) all = nodes(graph) sources = filter_by(all, source_filter) sinks = filter_by(all, sink_filter) sanitizer_pred = to_predicate(all, sanitizer_filter) for source <- sources, sink <- sinks, data_flows?(graph, source.id, sink.id) do path = chop(graph, source.id, sink.id) sanitized = sanitizer_pred != nil and passes_through?(graph, source.id, sink.id, sanitizer_pred) %{ source: source, sink: sink, path: path, sanitized: sanitized } end end # --- Private --- defp filter_by(nodes, filter) when is_list(filter), do: filter_nodes(nodes, filter) defp filter_by(nodes, filter) when is_function(filter), do: Enum.filter(nodes, filter) defp to_predicate(_all, nil), do: nil defp to_predicate(_all, pred) when is_function(pred), do: pred defp to_predicate(all, filter) when is_list(filter) do matching_ids = filter_nodes(all, filter) |> MapSet.new(& &1.id) fn node -> MapSet.member?(matching_ids, node.id) end end defp match_label?(label, label), do: true defp match_label?({tag, _}, tag) when is_atom(tag), do: true defp match_label?(_, _), do: false defp expand_alive_to_parents(all_nodes, alive_ids) do Enum.reduce(all_nodes, alive_ids, fn node, ids -> child_ids = Enum.map(node.children, & &1.id) if Enum.any?(child_ids, &MapSet.member?(ids, &1)) do MapSet.put(ids, node.id) else ids end end) end defp find_dead_nodes(all_nodes, alive_ids) do impure_ids = collect_impure_ids(all_nodes) all_nodes |> Enum.filter(&candidate_for_dead?/1) |> Enum.reject(&(MapSet.member?(impure_ids, &1.id) or MapSet.member?(alive_ids, &1.id))) end defp collect_impure_ids(all_nodes) do all_nodes |> Enum.reject(&pure?/1) |> Enum.flat_map(fn node -> [node.id | Enum.map(node.children, & &1.id)] end) |> MapSet.new() end defp candidate_for_dead?(%Node{type: t} = node) when t in [:call, :binary_op, :unary_op, :match] do pure?(node) and not attribute_or_typespec?(node) end defp candidate_for_dead?(_), do: false defp attribute_or_typespec?(%{type: :call, meta: %{function: f}}) when f in [:@, :__aliases__], do: true defp attribute_or_typespec?(_), do: false defp filter_nodes(nodes, []), do: nodes defp filter_nodes(nodes, [{:type, type} | rest]) do nodes |> Enum.filter(&(&1.type == type)) |> filter_nodes(rest) end defp filter_nodes(nodes, [{:module, module} | rest]) do nodes |> Enum.filter(&(&1.meta[:module] == module)) |> filter_nodes(rest) end defp filter_nodes(nodes, [{:function, function} | rest]) do nodes |> Enum.filter(&(&1.meta[:function] == function)) |> filter_nodes(rest) end defp filter_nodes(nodes, [{:arity, arity} | rest]) do nodes |> Enum.filter(&(&1.meta[:arity] == arity)) |> filter_nodes(rest) end defp filter_nodes(nodes, [_ | rest]) do filter_nodes(nodes, rest) end defp build_data_graph(graph) do graph |> Graph.edges() |> Enum.filter(fn e -> match?({:data, _}, e.label) or e.label in [:containment, :match_binding, :higher_order] end) |> then(&Graph.add_edges(Graph.new(), &1)) end defp data_path?(data_graph, from, to) do if Graph.has_vertex?(data_graph, from) and Graph.has_vertex?(data_graph, to) do Graph.get_shortest_path(data_graph, from, to) != nil else false end end defp conflicting_effects?(node_map, id_x, id_y) do case {Map.get(node_map, id_x), Map.get(node_map, id_y)} do {%{} = x, %{} = y} -> Effects.conflicting?(Effects.classify(x), Effects.classify(y)) _ -> true end end defp same_control_deps?(sdg, id_x, id_y) do deps_x = control_deps(sdg, id_x) |> MapSet.new() deps_y = control_deps(sdg, id_y) |> MapSet.new() MapSet.equal?(deps_x, deps_y) end defp parse_string(source, :erlang, opts) do Frontend.Erlang.parse_string(source, opts) end defp parse_string(source, _elixir, opts) do Frontend.Elixir.parse(source, opts) end defp parse_and_build(source, language, opts) do case parse_string(source, language, opts) do {:ok, ir_nodes} -> {:ok, SystemDependence.build(ir_nodes, opts)} {:error, _} = err -> err end end defp language_from_extension(path) do case Path.extname(path) do ext when ext in [".erl", ".hrl"] -> :erlang _ -> :elixir end end end