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