Detects cycles of synchronous calls in the coupling graph — a deadlock hazard the supervision tree can't show.
If module A synchronously calls B (a GenServer.call) and, directly or
transitively, B synchronously calls back to A, the two processes can
deadlock: each blocks inside handle_call waiting for the other's reply, and
neither can make progress. This is a property of the call graph, invisible in
the supervision structure.
We build the directed graph of resolved synchronous :call edges and report
each strongly-connected component of two or more modules — every such component
contains a cycle. It is a hazard, not a certainty: the two calls must be able
to be in flight at the same time for the deadlock to occur. So it is reported at
:medium, best-effort, with the cycle's members spelled out.
Asynchronous edges (cast/send/pubsub) can't deadlock this way — the caller
never blocks — so they're excluded.
Boot-order cycles
A cycle made entirely of in-init/1 synchronous calls is the sharper variant
(boot_order_cycle, :high): on startup each init/1 synchronously calls the
next module around the loop, but that module isn't running yet, so none can
finish initialising — the supervision tree can't boot at all. Such a cycle is
also a plain synchronous cycle, so it's reported once, under the more specific
boot_order_cycle, and suppressed from cyclic_coupling.
Summary
Functions
@spec analyze([Firebreak.Edge.t()]) :: [Firebreak.Finding.t()]