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 · #{map_size(a.modules)} modules · #{length(a.supervisors)} supervisors (#{exact} exact, #{static} static) · #{length(a.edges)} coupling edges · #{length(a.findings)} findings</p>
#{html_section("Primary findings — coupling & correctness", primary, a)}
#{html_section("Structural & advisory — often by-design", secondary, a)}
<h2>Supervision & 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("&", "&")
|> String.replace("<", "<")
|> String.replace(">", ">")
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