Exact supervision-tree extraction by the canonical OTP method.
OTP never parses supervision trees from source. supervisor.erl calls
Mod:init(Args) at runtime and reads the returned child specs and restart
flags as data — child specs are runtime values, not syntax. When the target
modules are compiled and loadable, Firebreak does the same: it calls init/1
to obtain the exact {SupFlags, child_specs} and tags the result :exact.
Crucially, init/1 does not start any children — in OTP, start_link
calls init/1 first and only afterwards iterates the returned specs to start
them. So evaluating init/1 is side-effect-light and tells us the real tree,
including child lists assembled with pipes, ++, List.flatten/1,
conditionals, or builder calls that defeat any static pass.
What stays static
Applicationmodules. Theirstart/2callback actually boots the tree (it callsSupervisor.start_link), so we must not invoke it. The static parser reads the child list out ofstart/2instead.- Un-loadable modules. Analysing source that isn't compiled (no
_build), we fall back to the staticModuleInfoas parsed. - The coupling graph (
edges,registers,unsupervised_spawns,traps_exit?). Those describe who-calls-whom;init/1does not reveal them, and they remain pure static analysis.
The init argument
init/1 receives whatever start_link passed it, which we can't know without
running the app. We try a short list of common arguments (:ok, [], nil,
%{}) and take the first that returns a well-formed supervisor result. The
overwhelming majority of supervisors ignore the argument (def init(_)), so
this is exact in practice.
When init/1 pattern-matches a struct (def init(%Config{} = conf)), the
simple arguments can't match, so we synthesise one via the struct module's
new/1 (the Elixir convention for a populated, valid struct) — trying
Config.new([]), Config.new(%{}), Config.new(), then a raw struct/2 —
before falling back. This reads config-driven supervisors (Oban, etc.) exactly
instead of statically. The struct type is detected from the source
(Firebreak.ModuleInfo.init_arg_struct).
A supervisor whose child set branches on its argument is the one case where
the tree reflects the probe/synthesised argument rather than production —
notably a synthesised default config can omit config-driven children (queues,
plugins); --observe gives the real runtime shape. Anything that raises for
every candidate falls back to static.
Summary
Functions
Replace the supervision-tree fields of loadable supervisor modules with exact
data read from init/1. Application and un-loadable modules are returned
unchanged (still :static). Pass runtime: false to disable entirely.
Functions
@spec enrich( [Firebreak.ModuleInfo.t()], keyword() ) :: [Firebreak.ModuleInfo.t()]
Replace the supervision-tree fields of loadable supervisor modules with exact
data read from init/1. Application and un-loadable modules are returned
unchanged (still :static). Pass runtime: false to disable entirely.