Skip to main content

lib/mix/tasks/firebreak.ex

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