Skip to main content

lib/firebreak/report.ex

defmodule Firebreak.Report do
  @moduledoc """
  Renders an `Firebreak.Analysis` as human-readable text or as JSON (the CI
  artifact / handoff format).
  """

  alias Firebreak.{Analysis, Finding, ModuleInfo}

  @severities [:high, :medium, :low, :info]
  @labels %{high: "HIGH", medium: "MED ", low: "LOW ", info: "INFO"}

  @doc """
  Render the text report. By default it's high-signal: primary findings in full,
  secondary (structural/advisory) findings only at `:medium`+ with the rest
  collapsed to a count. Pass `all: true` to show everything.
  """
  @spec text(Analysis.t(), keyword()) :: String.t()
  def text(%Analysis{} = a, opts \\ []) do
    verbose? = Keyword.get(opts, :all, false)

    (header_lines(a) ++
       [""] ++
       forest_lines(a) ++
       [""] ++
       findings_lines(a, verbose?) ++
       [""] ++
       simulation_lines(a) ++
       [""] ++
       footer_lines(a))
    |> Enum.join("\n")
  end

  # How many top failure simulations to print in the text report; the full set
  # is always available in the JSON artifact.
  @sim_text_limit 5

  @spec json(Analysis.t()) :: String.t()
  def json(%Analysis{} = a) do
    {exact, static} = tree_source_counts(a)

    %{
      summary: %{
        files_scanned: a.files_scanned,
        modules: map_size(a.modules),
        supervisors: length(a.supervisors),
        supervision_source: %{exact: exact, static: static},
        edges: length(a.edges),
        findings: length(a.findings),
        by_severity: Map.new(@severities, fn s -> {s, count(a, s)} end)
      },
      roots: a.roots,
      supervisors: Enum.map(a.supervisors, &supervisor_json(&1, a)),
      edges: Enum.map(a.edges, &edge_json/1),
      findings: Enum.map(a.findings, &finding_json/1),
      simulations: Enum.map(a.simulations, &simulation_json/1)
    }
    |> Firebreak.JSON.encode_to_string()
  end

  @doc """
  GitHub Actions workflow commands — one annotation per finding, so findings show
  up inline on the PR diff. Severity maps to `error` (high) / `warning` (medium) /
  `notice` (low, info). Paths are emitted as-is; in CI they're relative to the
  repo root, which is what GitHub needs to attach the annotation to the diff.
  """
  @spec github(Analysis.t()) :: String.t()
  def github(%Analysis{findings: []}), do: "::notice title=firebreak::no findings"

  def github(%Analysis{findings: findings}) do
    Enum.map_join(findings, "\n", &github_line/1)
  end

  defp github_line(f) do
    "::#{gh_level(f.severity)} #{gh_loc(f)}title=firebreak: #{f.check}::#{gh_escape(f.message)}"
  end

  defp gh_level(:high), do: "error"
  defp gh_level(:medium), do: "warning"
  defp gh_level(_), do: "notice"

  defp gh_loc(%Finding{file: nil}), do: ""

  defp gh_loc(%Finding{file: file, line: line}) when is_integer(line),
    do: "file=#{file},line=#{line},"

  defp gh_loc(%Finding{file: file}), do: "file=#{file},"

  # GitHub workflow-command message escaping.
  defp gh_escape(msg) do
    msg
    |> String.replace("%", "%25")
    |> String.replace("\r", "%0D")
    |> String.replace("\n", "%0A")
  end

  @doc """
  A single self-contained HTML page: the tiered findings followed by the
  supervision + coupling graph (rendered client-side from Mermaid via CDN).
  """
  @spec html(Analysis.t()) :: String.t()
  def html(%Analysis{} = a) do
    {exact, static} = tree_source_counts(a)
    {primary, secondary} = Enum.split_with(a.findings, &(Finding.tier(&1.check) == :primary))

    """
    <!doctype html><html lang="en"><head><meta charset="utf-8">
    <title>firebreak report</title>
    <style>
      body{font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;margin:2rem;color:#1a1a1a;max-width:60rem}
      h1{font-size:1.4rem} h2{font-size:1.1rem;margin-top:2rem;border-bottom:1px solid #ddd;padding-bottom:.3rem}
      .meta{color:#666} .f{border-left:4px solid #ccc;padding:.4rem .8rem;margin:.5rem 0;background:#fafafa}
      .high{border-color:#d33} .medium{border-color:#e90} .low{border-color:#39c} .info{border-color:#bbb}
      .sev{font-weight:700;text-transform:uppercase;font-size:.75rem} .chk{color:#555;font-family:monospace}
      .loc{color:#888;font-size:.8rem} .none{color:#693}
      pre.mermaid{background:#fff}
    </style></head><body>
    <h1>firebreak report</h1>
    <p class="meta">#{a.files_scanned} files &middot; #{map_size(a.modules)} modules &middot; #{length(a.supervisors)} supervisors (#{exact} exact, #{static} static) &middot; #{length(a.edges)} coupling edges &middot; #{length(a.findings)} findings</p>
    #{html_section("Primary findings — coupling &amp; correctness", primary, a)}
    #{html_section("Structural &amp; advisory — often by-design", secondary, a)}
    <h2>Supervision &amp; coupling graph</h2>
    <pre class="mermaid">
    #{html_escape(Firebreak.Viz.mermaid(a))}
    </pre>
    <script type="module">
      import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
      mermaid.initialize({ startOnLoad: true, securityLevel: "strict" });
    </script>
    </body></html>
    """
  end

  defp html_section(_title, [], _a), do: ""

  defp html_section(title, findings, a) do
    body =
      @severities
      |> Enum.flat_map(fn sev -> Enum.filter(findings, &(&1.severity == sev)) end)
      |> Enum.map_join("\n", &html_finding(&1, a))

    "<h2>#{title} (#{length(findings)})</h2>\n#{body}"
  end

  defp html_finding(f, a) do
    """
    <div class="f #{f.severity}">
      <span class="sev">#{f.severity}</span>
      <span class="chk">#{f.check}</span>
      <span class="loc">#{html_escape(loc(f, a))}</span>
      <div>#{html_escape(f.message)}</div>
    </div>
    """
  end

  defp html_escape(text) do
    text
    |> to_string()
    |> String.replace("&", "&amp;")
    |> String.replace("<", "&lt;")
    |> String.replace(">", "&gt;")
  end

  @doc "Drop findings below the given severity (nil = keep all)."
  @spec filter_min_severity(Analysis.t(), Finding.severity() | nil) :: Analysis.t()
  def filter_min_severity(%Analysis{} = a, nil), do: a

  def filter_min_severity(%Analysis{} = a, sev) do
    max_rank = Finding.rank(sev)
    %{a | findings: Enum.filter(a.findings, &(Finding.rank(&1.severity) <= max_rank))}
  end

  # --- text sections ---

  defp header_lines(a) do
    {exact, static} = tree_source_counts(a)

    [
      "firebreak - supervision & coupling analysis",
      "  #{a.files_scanned} files | #{map_size(a.modules)} modules | " <>
        "#{length(a.supervisors)} supervisors (#{exact} exact, #{static} static) | " <>
        "#{length(a.edges)} coupling edges"
    ]
  end

  defp forest_lines(%{roots: []}), do: ["Supervision roots: (none detected)"]

  defp forest_lines(%{roots: roots}) do
    ["Supervision roots:" | Enum.map(roots, &"  - #{inspect(&1)}")]
  end

  defp findings_lines(%{findings: []}, _verbose?), do: ["Findings: none."]

  # Lead with the high-signal coupling/correctness findings (always in full);
  # group the structural/advisory ones after them, and — unless verbose — collapse
  # the low/info structural noise to a count so a first run stays high-signal.
  defp findings_lines(a, verbose?) do
    {primary, secondary} = Enum.split_with(a.findings, &(Finding.tier(&1.check) == :primary))

    {sec_shown, sec_hidden} =
      if verbose?,
        do: {secondary, []},
        else: Enum.split_with(secondary, &(Finding.rank(&1.severity) <= Finding.rank(:medium)))

    blocks =
      [
        finding_block(
          "Primary findings - coupling & correctness",
          primary,
          length(primary),
          [],
          a
        ),
        finding_block(
          "Structural & advisory - often by-design, review with context",
          sec_shown,
          length(secondary),
          sec_hidden,
          a
        )
      ]
      |> Enum.reject(&(&1 == []))

    case blocks do
      [] -> ["Findings: none."]
      bs -> bs |> Enum.intersperse([""]) |> List.flatten()
    end
  end

  # Header shows the tier's total count; the body shows `shown`; a trailing hint
  # notes any collapsed low/info findings.
  defp finding_block(_title, [], 0, [], _a), do: []

  defp finding_block(title, shown, total, hidden, a) do
    body =
      Enum.flat_map(@severities, fn sev ->
        shown |> Enum.filter(&(&1.severity == sev)) |> Enum.flat_map(&finding_text(&1, a))
      end)

    hint =
      case hidden do
        [] -> []
        h -> ["  (+ #{length(h)} low/info finding(s) hidden - run with --all to show)"]
      end

    ["#{title} (#{total}):"] ++ body ++ hint
  end

  defp finding_text(f, a) do
    [
      "  [#{@labels[f.severity]}] #{f.check} (#{conf_label(f.confidence)})  #{loc(f, a)}",
      "         " <> f.message
    ]
  end

  defp conf_label(:exact), do: "exact"
  defp conf_label(_), do: "best-effort"

  # "If this process crashes right now, who blocks?" — the per-victim view that
  # complements the per-supervisor crash_cascade findings. Ranked by the analysis
  # (most external blockers first); we print the worst handful and point at the
  # JSON for the rest.
  defp simulation_lines(%{simulations: []}), do: ["Failure simulations: none."]

  defp simulation_lines(%{simulations: sims}) do
    shown = Enum.take(sims, @sim_text_limit)
    total = length(sims)

    header =
      if total > @sim_text_limit do
        "Failure simulations (showing #{@sim_text_limit} of #{total}):"
      else
        "Failure simulations (#{total}):"
      end

    [header | Enum.map(shown, &simulation_text/1)]
  end

  defp simulation_text(sim) do
    n = length(sim.external_blockers)

    tags =
      [if(sim.amplified?, do: "amplified"), if(sim.sync?, do: "sync")] |> Enum.reject(&is_nil/1)

    tag_str = if tags == [], do: "", else: " (#{Enum.join(tags, ", ")})"

    "  - if #{inspect(sim.victim)} crashes: #{n} module(s) block#{tag_str}: " <>
      Enum.map_join(sim.external_blockers, ", ", &inspect/1)
  end

  defp footer_lines(a) do
    summary = @severities |> Enum.map_join(", ", fn s -> "#{count(a, s)} #{s}" end)
    ["Summary: #{summary}"]
  end

  defp loc(%Finding{module: m} = f, a) do
    info = Analysis.module(a, m)
    file = f.file || (info && info.file)
    line = f.line || (info && info.line)
    base = inspect(m)

    cond do
      file && line -> "#{base} (#{file}:#{line})"
      file -> "#{base} (#{file})"
      true -> base
    end
  end

  defp count(a, sev), do: Enum.count(a.findings, &(&1.severity == sev))

  # How many supervisors had their tree read exactly (runtime init/1) vs parsed
  # statically. Application modules are always static (their start/2 boots the
  # tree, so we don't invoke it).
  defp tree_source_counts(a) do
    a.supervisors
    |> Enum.map(&Analysis.module(a, &1))
    |> Enum.reject(&is_nil/1)
    |> Enum.reduce({0, 0}, fn m, {exact, static} ->
      if ModuleInfo.exact?(m), do: {exact + 1, static}, else: {exact, static + 1}
    end)
  end

  defp finding_json(f) do
    %{
      check: f.check,
      tier: Finding.tier(f.check),
      severity: f.severity,
      confidence: f.confidence,
      module: f.module,
      file: f.file,
      line: f.line,
      message: f.message,
      details: f.details
    }
  end

  defp supervisor_json(name, a) do
    m = Analysis.module(a, name)

    %{
      module: name,
      tree_source: m && m.tree_source,
      strategy: m && m.strategy,
      max_restarts: m && ModuleInfo.effective_max_restarts(m),
      max_seconds: m && ModuleInfo.effective_max_seconds(m),
      children: ((m && m.children) || []) |> Enum.map(&child_json/1),
      # Best-effort children recovered from start_child call sites — never in
      # init/1, so kept distinct from the declared `children` above.
      dynamic_children: ((m && m.dynamic_children) || []) |> Enum.map(&child_json/1)
    }
  end

  defp child_json(c) do
    %{
      id: c.id,
      module: c.module,
      name: c.name,
      type: c.type,
      restart: c.restart,
      shutdown: c.shutdown
    }
  end

  defp edge_json(e) do
    %{
      from: e.from,
      to: e.to,
      kind: e.kind,
      target: target_json(e.target),
      target_raw: e.target_raw,
      resolved: e.resolved?,
      sync: e.sync?,
      in_init: e.in_init?,
      file: e.file,
      line: e.line
    }
  end

  defp target_json({:module, mod}), do: %{type: :module, value: mod}
  defp target_json({:name, atom}), do: %{type: :name, value: atom}
  defp target_json({:unknown, raw}), do: %{type: :unknown, value: raw}
  defp target_json(_), do: nil

  defp simulation_json(sim) do
    %{
      victim: sim.victim,
      # The restart closure as a sorted list (MapSet isn't JSON-encodable).
      closure: sim.closure |> MapSet.to_list() |> Enum.sort(),
      external_blockers: sim.external_blockers,
      blocker_count: length(sim.external_blockers),
      amplified: sim.amplified?,
      sync: sim.sync?
    }
  end
end