Skip to main content

lib/firebreak.ex

defmodule Firebreak do
  @moduledoc """
  Firebreak reads the supervision structure of an Elixir/OTP application and
  reports where the *declared* failure topology (the supervision tree) diverges
  from the *actual* one (the runtime coupling between processes).

  The supervision tree tells you what each supervisor contains. It does not tell
  you who, from outside a subtree, calls into it — and those are exactly the
  dependencies that turn a "contained" restart into a cascade. Firebreak recovers
  both structures and reports the gap.

  ## How the two structures are recovered

    * **Supervision tree** — preferred exact, via the same `Mod.init/1` call OTP
      makes (see `Firebreak.Runtime`); static AST parsing
      (`Firebreak.Source`) is the fallback for code that can't be loaded.
    * **Coupling graph** — always static: who calls/casts/looks-up whom is read
      from the AST, since `init/1` doesn't reveal it.

  ## Usage

      analysis = Firebreak.analyze("path/to/app")
      IO.puts(Firebreak.Report.text(analysis))

  Or via the Mix task in the target project:

      mix firebreak
      mix firebreak --format json
  """

  alias Firebreak.{
    Analysis,
    BlastRadius,
    BootOrder,
    Checks,
    Coupling,
    Cycles,
    FailureSim,
    Finding,
    Forest,
    ModuleInfo,
    Observe,
    Orphans,
    Runtime,
    Source
  }

  @doc """
  Analyse a project rooted at `path`.

  Parses the source statically, then upgrades each loadable supervisor's tree to
  exact runtime data via `Firebreak.Runtime`. Pass `runtime: false` to stay
  purely static. Pass `observe: "node@host"` (and optionally `cookie:`) to fold
  in the live shape of a running node via `Firebreak.Observe`.
  """
  @spec analyze(Path.t(), keyword()) :: Analysis.t()
  def analyze(path, opts \\ []) do
    modules =
      path
      |> Source.parse_project(opts)
      |> Runtime.enrich(Keyword.put(opts, :root, path))

    case Observe.maybe_snapshot(modules, opts) do
      {:ok, snapshot} ->
        analysis =
          modules
          |> Observe.enrich_modules(snapshot)
          |> analyze_modules()

        analysis
        |> add_findings(
          Observe.cardinality_findings(snapshot, modules) ++
            Observe.mailbox_findings(snapshot, analysis.edges)
        )
        # Retain the live reading so `Firebreak.RuntimeOverlay` can join the
        # static IR against observed reality.
        |> Map.put(:snapshot, snapshot)

      :none ->
        analyze_modules(modules)
    end
  end

  # Append findings produced outside the module-only core (e.g. live-node
  # observation), keeping the overall severity ordering intact.
  defp add_findings(%Analysis{findings: existing} = a, extra) do
    %{a | findings: Finding.sort(existing ++ extra)}
  end

  @doc """
  Analyse an already-parsed list of modules (the testable core).

  Operates on whatever modules it is handed — static or runtime-enriched — and
  does not itself touch the target runtime, so it stays pure and deterministic.
  """
  @spec analyze_modules([ModuleInfo.t()]) :: Analysis.t()
  def analyze_modules(modules) do
    {modules, name_index, edges} = Coupling.resolve(modules)
    forest = Forest.build(modules)
    tier1 = Checks.run(modules)
    boot = BootOrder.analyze(modules)
    orphans = Orphans.analyze(modules, forest, edges)
    {_rankings, tier2} = BlastRadius.analyze(forest, edges)
    {simulations, cascades} = FailureSim.analyze(forest, edges)
    cycles = Cycles.analyze(edges)

    %Analysis{
      modules: Map.new(modules, &{&1.name, &1}),
      supervisors: forest.supervisors,
      roots: forest.roots,
      edges: edges,
      name_index: name_index,
      subtree: forest.subtree,
      child_map: forest.child_map,
      strategies: forest.strategies,
      simulations: simulations,
      findings: Finding.sort(tier1 ++ boot ++ orphans ++ tier2 ++ cascades ++ cycles),
      files_scanned: modules |> Enum.map(& &1.file) |> Enum.uniq() |> length()
    }
  end

  @doc "Convenience: analyse a single source string (handy in tests/iex)."
  @spec analyze_string(String.t(), String.t() | nil) :: Analysis.t()
  def analyze_string(source, file \\ "nofile") do
    source |> Source.parse_string(file) |> analyze_modules()
  end
end