# `MobDev.OtpAudit`
[🔗](https://github.com/genericjam/mob_dev/blob/master/lib/mob_dev/otp_audit.ex#L1)

Reachability analysis for the bundled OTP runtime tree of a Mob app.

Walks every `.beam` file under an OTP root, extracts the `imports` chunk
to learn who calls whom, and computes the transitive closure starting
from the app's entry-point modules. Anything not reachable is a strip
candidate — modules, whole libs, or duplicate library versions.

Used by `mix mob.audit_otp` (read-only report) and the planned
`mix mob.release --slim` flag (auto-strip based on the report).

## Entry points

Reachability seeding is deliberately generous:

  * App's `start/2` callback module (read from each `.app` file's `mod` key)
  * All exported functions of `kernel` and `stdlib` (BEAM startup needs them)
  * `elixir`, `logger`, `eex` (runtime support that gets called via macros)

Anything reachable from those is kept; the rest is candidate-for-strip.

## Output

Returns a map with:

  * `:libs` — every lib found, with reachable/total module counts and KB
  * `:duplicates` — libs that appear multiple times (only newest is kept)
  * `:foreign_apps` — non-OTP, non-app code in the lib dir (other projects)
  * `:strippable_libs` — libs with zero reachable modules
  * `:total_kb` / `:reachable_kb` / `:strippable_kb` — size summary

All sizes are post-strip — i.e. what `mix mob.release` actually ships,
not raw OTP source.

# `lib_name`

```elixir
@type lib_name() :: String.t()
```

# `lib_report`

```elixir
@type lib_report() :: %{
  name: lib_name(),
  version: String.t() | nil,
  path: String.t(),
  modules_total: non_neg_integer(),
  modules_reachable: non_neg_integer(),
  modules_traced: non_neg_integer() | nil,
  kb_total: non_neg_integer(),
  kb_reachable: non_neg_integer(),
  unreachable_modules: [module_atom()],
  untraced_modules: [module_atom()] | nil,
  is_app_under_test?: boolean()
}
```

# `module_atom`

```elixir
@type module_atom() :: atom()
```

# `report`

```elixir
@type report() :: %{
  otp_root: String.t(),
  app_name: String.t() | nil,
  libs: [lib_report()],
  duplicates: %{required(lib_name()) =&gt; [String.t()]},
  foreign_apps: [String.t()],
  foreign_app_names: [lib_name()],
  strippable_libs: [lib_name()],
  trace_strippable_libs: [lib_name()] | nil,
  total_kb: non_neg_integer(),
  reachable_kb: non_neg_integer(),
  strippable_kb: non_neg_integer()
}
```

# `audit`

```elixir
@spec audit(
  String.t(),
  keyword()
) :: report()
```

Run the audit. `otp_root` is the directory containing `lib/` and `erts-*/`
(typically the runtime tree extracted into the app bundle, or the cache
the release packaging copies from).

## Options

  * `:app_name` — the application's atom name (e.g. `:air_cart_max`).
    Used to seed reachability from the app's modules. If omitted, every
    lib with no `mod` callback is treated as a potential entry point
    (broader, finds less to strip).

  * `:project_deps` — list of atoms naming the project's deps (the
    transitive closure, as Mix sees them). When given, the foreign-app
    classifier uses it as the authoritative set of "non-OTP libs that
    are supposed to be in this bundle." Anything in the bundle whose
    name isn't OTP-shipped, Elixir-shipped, the app under test, or in
    `:project_deps` is classified as foreign and quarantined into
    `report.foreign_apps`. The `mix mob.audit_otp` task auto-detects
    this from the current `Mix.Project`.

    When omitted, the classifier falls back to a narrow name-pattern
    heuristic (`test_`, `toy_`, `mob_test`, `scratch_`) — sufficient
    for tests, less accurate in real bundles.

  * `:trace_input` — a `MapSet` (or list) of `module()` atoms that
    were observed at runtime during a trace window. Comes from
    `MobDev.OtpTrace.capture/1` (local synthetic harness) or
    `Mob.Diag.mfa_trace/1` (remote device trace). When given, the
    report grows two fields per lib (`modules_traced`,
    `untraced_modules`) and one top-level field
    (`trace_strippable_libs`) listing libs whose modules were
    ALL absent from the trace — i.e., empirically never called.

    Static reachability misses dynamic dispatch (`apply/3`,
    `:erlang.load_nif`, runtime config). Trace data catches
    everything that actually ran. The intersection `strippable_libs
    ∩ trace_strippable_libs` is the high-confidence strip set;
    `trace_strippable_libs \ strippable_libs` is the "static graph
    reaches it but nothing actually called it" set that lets you
    strip partly-used libs like megaco / snmp / diameter.

# `union_trace_jsons`

```elixir
@spec union_trace_jsons([Path.t()], (Path.t(), term() -&gt; any())) :: MapSet.t() | nil
```

Reads one or more JSON trace files written by
`mix mob.trace_otp --json` and unions their `modules` atoms into a
single MapSet suitable for `:trace_input`.

Returns:

  * `nil` when given an empty list (the audit will take its
    no-trace branch).
  * `nil` when every read failed — better than handing back an
    empty set, which would let the trace-augmented expansion
    strip every partly-used lib in the bundle. A warning is
    emitted via `on_read_error.(path, reason)` for each failure
    so callers can route them through their own logging.
  * `MapSet.t/0` of `module()` atoms otherwise.

Multi-trace union is the right shape for "this lib is never
called" claims: a 60-second window only exercises one slice of
the app. Unioning boot + UI + auth + idle captures lets users
say "across ALL captured sessions, this lib was never touched"
— a much stronger signal than any single trace.

## Example

    iex> MobDev.OtpAudit.union_trace_jsons(["priv/boot.json", "priv/ui.json"])
    #MapSet<[:kernel, :Elixir.Enum, :Elixir.Map, ...]>

---

*Consult [api-reference.md](api-reference.md) for complete listing*
