defmodule Mix.Tasks.Firebreak do
@shortdoc "Analyse supervision trees and cross-tree coupling"
@moduledoc """
Analyses an Elixir/OTP project's supervision structure and the runtime coupling
between processes, reporting where the supervision tree understates the real
failure topology.
Firebreak reads the supervision tree the same way OTP does — by calling each
supervisor's `init/1` — whenever the project is compiled (it compiles the
current project first for you). It never starts your supervision tree; `init/1`
only *returns* child specs, it doesn't run them. For code it can't load it
falls back to static source parsing, and the coupling graph is always static.
mix firebreak # analyse the current project, text report
mix firebreak ../other_app # analyse a project at another path
mix firebreak --format json # machine-readable output (CI artifact)
mix firebreak --format dot # Graphviz graph (pipe to `dot -Tsvg`)
mix firebreak --format mermaid # Mermaid graph (paste into a Markdown doc)
mix firebreak --min-severity medium
mix firebreak --fail-on high # exit non-zero if any HIGH finding (CI gate)
mix firebreak --path "lib/**/*.ex" # restrict source globs (repeatable)
mix firebreak --no-compile # skip compilation; analyse statically
mix firebreak --observe app@host # fold in a live node's real runtime shape
mix firebreak --write-baseline .firebreak_baseline.exs # snapshot current findings
mix firebreak --baseline .firebreak_baseline.exs --fail-on info # gate on regressions only
## Options
* `--format` (`-f`) — `text` (default), `json`, `dot`, `mermaid`, `github`
(PR annotations), `html`, `model` (the supervision-model IR; see
`mix firebreak.spec` for TLA+/Quint generation), `score` (a structural
supervision-risk score + per-supervisor ranking, for dashboards/trend
tracking), `failure` (a Mermaid diagram of the cross-tree failure modes —
who blocks on `:noproc` when a process in another subtree restarts), or
`overlay` (the static IR's synchronous crossings annotated with a live
node's observed state — target alive?, mailbox depth, instance count;
requires `--observe`, see `Firebreak.RuntimeOverlay`)
* `--all` — show every finding (default text output collapses the
low/info structural & advisory findings to a count for a high-signal first read)
* `--min-severity` — hide findings below this severity (high|medium|low|info)
* `--fail-on` — exit 1 if any finding is at or above this severity
* `--path` — source glob to scan (repeatable; defaults to lib/ + umbrella apps)
* `--no-compile` — don't compile first; rely on static parsing only
* `--observe` — attach to a live node (`name@host`) and fold in its real
runtime shape: live (incl. `DynamicSupervisor`) children, recovered registered
names, and `runtime_fanout` cardinality findings (see `Firebreak.Observe`)
* `--cookie` — distribution cookie for `--observe` (if not the default)
* `--config` — suppression config (defaults to `.firebreak.exs` in the project root)
* `--baseline` — only report findings absent from this baseline file (regression gate)
* `--write-baseline` — write the current findings as a baseline and exit without failing
* `--write-expected` — snapshot the current supervision topology to a conformance
spec file (commit it), without failing the run
* `--expect` — diff the current topology against a committed spec and report
`topology_drift` findings (strategy flips, dropped children, intensity changes)
## CI gating
Commit a `.firebreak.exs` allowlist for findings you've accepted, and/or
snapshot a baseline so the pipeline fails only on *new* coupling:
mix firebreak --write-baseline .firebreak_baseline.exs # once, on a green commit
mix firebreak --baseline .firebreak_baseline.exs --fail-on info # in CI thereafter
See `Firebreak.Baseline` for the `.firebreak.exs` format.
"""
use Mix.Task
@impl Mix.Task
def run(argv) do
{opts, args} =
OptionParser.parse!(argv,
strict: [
format: :string,
min_severity: :string,
fail_on: :string,
path: :keep,
compile: :boolean,
config: :string,
baseline: :string,
write_baseline: :string,
observe: :string,
cookie: :string,
all: :boolean,
expect: :string,
write_expected: :string
],
aliases: [f: :format]
)
root = List.first(args) || File.cwd!()
# Compiling makes the exact init/1 path available. We only compile when
# analysing the *current* project (no path arg) — `Mix.Task.run("compile")`
# always targets the current project, never the one at `../other_app`.
if args == [], do: maybe_compile(opts)
analyze_opts =
case Keyword.get_values(opts, :path) do
[] -> []
paths -> [globs: paths]
end
# --no-compile means "stay static": skip the exact init/1 path too.
analyze_opts =
if opts[:compile] == false,
do: Keyword.put(analyze_opts, :runtime, false),
else: analyze_opts
# --observe NODE [--cookie COOKIE] folds a live node's runtime shape in.
analyze_opts =
analyze_opts
|> maybe_put(:observe, opts[:observe])
|> maybe_put(:cookie, opts[:cookie])
base = Firebreak.analyze(root, analyze_opts)
# --write-expected snapshots the current topology as a conformance spec and,
# like --write-baseline, never fails the run.
maybe_write_expected(base, opts)
analysis =
base
|> maybe_check_conformance(opts)
|> Firebreak.Report.filter_min_severity(parse_sev(opts[:min_severity]))
# Layer the CI controls on top of the (already severity-filtered) analysis:
# drop allowlisted findings, optionally snapshot a baseline, then keep only
# findings that regress against a prior baseline.
final = apply_ci_controls(analysis, root, opts)
case opts[:format] || "text" do
"json" ->
IO.puts(Firebreak.Report.json(final))
"text" ->
IO.puts(Firebreak.Report.text(final, all: opts[:all] || false))
"dot" ->
IO.puts(Firebreak.Viz.dot(final))
"mermaid" ->
IO.puts(Firebreak.Viz.mermaid(final))
"github" ->
IO.puts(Firebreak.Report.github(final))
"html" ->
IO.puts(Firebreak.Report.html(final))
"model" ->
IO.puts(Firebreak.Model.json(final))
"score" ->
IO.puts(Firebreak.RiskScore.json(final))
"failure" ->
IO.puts(Firebreak.FailureViz.mermaid(final))
"overlay" ->
IO.puts(Firebreak.RuntimeOverlay.json(final))
other ->
Mix.raise(
"unknown --format #{inspect(other)} " <>
"(expected: text | json | dot | mermaid | github | html | model | score | failure | overlay)"
)
end
# Writing a baseline or an expected-topology spec establishes accepted state;
# neither fails the run.
unless opts[:write_baseline] || opts[:write_expected],
do: maybe_fail(final, parse_sev(opts[:fail_on]))
end
# --write-expected FILE: snapshot the current supervision topology to a
# committable conformance spec. Best-effort note to stderr; never fatal.
defp maybe_write_expected(analysis, opts) do
case opts[:write_expected] do
nil ->
:ok
path ->
:ok = Firebreak.Conformance.write(path, analysis)
n = length(Firebreak.Conformance.projection(analysis))
IO.puts(:stderr, "firebreak: wrote expected topology of #{n} supervisor(s) to #{path}")
end
end
# --expect FILE: fold topology-drift findings (current tree vs the committed
# spec) into the analysis so they filter and gate like any finding.
defp maybe_check_conformance(analysis, opts) do
case opts[:expect] do
nil ->
analysis
path ->
case Firebreak.Conformance.check(analysis, path) do
{checked, :ok} ->
drift = length(checked.findings) - length(analysis.findings)
IO.puts(:stderr, "firebreak: #{drift} topology-drift finding(s) vs #{path}")
checked
{unchanged, :no_spec} ->
IO.puts(
:stderr,
"firebreak: --expect spec #{path} is missing or unreadable; skipping conformance"
)
unchanged
end
end
end
# Suppression (.firebreak.exs) then baseline diff. Notes go to stderr so the
# stdout payload (json/dot/mermaid) stays pipeable.
defp apply_ci_controls(analysis, root, opts) do
config_path = opts[:config] || Path.join(root, Firebreak.Baseline.default_config_name())
matchers = Firebreak.Baseline.load_config(config_path)
suppressed = Firebreak.Baseline.suppress(analysis, matchers)
note_suppressed(analysis, suppressed, config_path)
if path = opts[:write_baseline] do
:ok = Firebreak.Baseline.write(path, suppressed)
IO.puts(
:stderr,
"firebreak: wrote baseline of #{length(suppressed.findings)} finding(s) to #{path}"
)
end
case opts[:baseline] do
nil ->
suppressed
baseline_path ->
final = Firebreak.Baseline.diff(suppressed, Firebreak.Baseline.load(baseline_path))
known = length(suppressed.findings) - length(final.findings)
IO.puts(
:stderr,
"firebreak: #{length(final.findings)} new finding(s); #{known} already in baseline #{baseline_path}"
)
final
end
end
defp note_suppressed(analysis, suppressed, config_path) do
case length(analysis.findings) - length(suppressed.findings) do
0 -> :ok
n -> IO.puts(:stderr, "firebreak: suppressed #{n} finding(s) via #{config_path}")
end
end
# Compile the current project so the exact init/1 path can load its modules.
# Best-effort: a compile failure just means we analyse statically.
defp maybe_compile(opts) do
if opts[:compile] == false do
:ok
else
try do
Mix.Task.run("compile", ["--no-deps-check"])
rescue
_ -> :ok
catch
_, _ -> :ok
end
end
end
defp maybe_put(opts, _key, nil), do: opts
defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
defp parse_sev(nil), do: nil
defp parse_sev(s) when s in ["high", "medium", "low", "info"], do: String.to_existing_atom(s)
defp parse_sev(other),
do: Mix.raise("invalid severity #{inspect(other)} (expected: high | medium | low | info)")
defp maybe_fail(_analysis, nil), do: :ok
defp maybe_fail(analysis, sev) do
threshold = Firebreak.Finding.rank(sev)
if Enum.any?(analysis.findings, &(Firebreak.Finding.rank(&1.severity) <= threshold)) do
Mix.raise("firebreak: findings at or above #{sev} severity")
else
:ok
end
end
end