# `PhoenixKit.ModuleDiscovery`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.55.1/lib/phoenix_kit/module_discovery.ex#L1)

Zero-config auto-discovery of external PhoenixKit modules.

Uses the same pattern as Elixir's protocol consolidation: scans `.beam` files
for persisted `@phoenix_kit_module` attributes via `:beam_lib.chunks/2`.
No module loading required — pure file I/O.

## How It Works

1. `use PhoenixKit.Module` persists `@phoenix_kit_module true` in the `.beam` file
2. This module scans only deps that depend on `:phoenix_kit` (fast, targeted)
3. Reads the persisted attribute from each beam file without loading the module
4. Works at both compile time (route generation) and runtime (ModuleRegistry)

## Fallback

Also reads `Application.get_env(:phoenix_kit, :modules, [])` for backwards
compatibility. Both sources are merged and deduplicated.

# `cached_external_modules`

```elixir
@spec cached_external_modules() :: [module()]
```

Like `discover_external_modules/0`, but scans the disk once per VM and then
answers from `:persistent_term`.

**Runtime callers only** (the admin Modules page, `PhoenixKit.ModuleRegistry`).
The scan reads every beam file of every phoenix_kit-dependent dep, which takes
seconds on a cold or busy disk, while the set it finds only changes when the
release is rebuilt. Compile-time callers (router macros, `module_hash/0`, the
Mix compilers) must keep using `discover_external_modules/0`: a long-lived
`iex -S mix` / code-reloader VM recompiles against a changing disk and has to
see it.

The cache is dropped by `clear_cache/0` and rebuilt by `refresh_cache/0`.
`PhoenixKit.ModuleRegistry` refreshes it at boot and on `rescan/0` and drops it
on `register/1` / `unregister/1`. Concurrent cold readers share one scan. The
cache also reflects `config :phoenix_kit, :modules` as of the scan.

# `clear_cache`

```elixir
@spec clear_cache() :: :ok
```

Drops the cached scan; the next `cached_external_modules/0` call scans again.

# `discover_external_modules`

```elixir
@spec discover_external_modules() :: [module()]
```

Discovers external PhoenixKit modules from beam files + config fallback.

Returns a deduplicated list of module atoms that implement `PhoenixKit.Module`.
Excludes internal modules (those in the `PhoenixKit.Modules` namespace that are
bundled with PhoenixKit itself).

# `module_hash`

```elixir
@spec module_hash() :: binary()
```

Returns a deterministic hash of the current set of discovered external modules.

The exact reference for `module_hash_fast/0`, which is what `__mix_recompile__?/0`
(injected into the host router) calls to detect when modules are added or
removed, triggering router recompilation. Always scans the disk — it must
never be answered from the runtime cache.

# `module_hash_fast`

```elixir
@spec module_hash_fast() :: binary()
```

Same answer as `module_hash/0`, without re-reading the beams when nothing on
disk changed since the last call.

Meant for `__mix_recompile__?/0`, which Mix evaluates on every compile — and
`Phoenix.CodeReloader` compiles on every dev request, so `module_hash/0` there
re-ran the whole scan per HTTP request.

It fingerprints what the scan reads, with `stat` only — no beam is opened and no
directory is listed: the mtime of every code-path directory (a file added to or
removed from one changes it), plus mtime and size of the `.app` files and of the
beams of the dependent apps found by the last full scan (a dep that starts or
stops depending on `:phoenix_kit`, a module gaining or losing
`@phoenix_kit_module`), and `config :phoenix_kit, :modules`. The full scan runs
when the fingerprint differs from the remembered one, and nothing is remembered
while any of it is newer than 2 seconds, so a change
within the filesystem's timestamp resolution is never missed.

The assumption is that a rebuild leaves a new mtime or size on the files it
writes, as Mix and `erlc` do. A file replaced by one of the same size *and* the
same mtime (`cp -p`, `rsync -t` from an older build) is not seen — use
`module_hash/0` where that matters.

A miss costs one scan plus the `stat`s of the fingerprint (it builds the hash
from the same directory listings the fingerprint needs, so nothing is listed or
read twice); the first call of a VM is always a miss. Any error falls back to
`module_hash/0`.

# `phoenix_kit_dependent_apps`

```elixir
@spec phoenix_kit_dependent_apps() :: [atom()]
```

Returns the names of dependency apps on disk that declare `:phoenix_kit` in their
`applications` (i.e. via `extra_applications`).

Filesystem-based, independent of load state. Used by the CSS-sources compiler to
warn when discovery yields zero sources even though phoenix_kit-dependent deps
are present.

# `refresh_cache`

```elixir
@spec refresh_cache() :: [module()]
```

Scans the disk now (`discover_external_modules/0`), stores the result for
`cached_external_modules/0` and returns it.

# `scan_beam_files`

```elixir
@spec scan_beam_files() :: [module()]
```

Scans beam files of phoenix_kit-dependent apps for `@phoenix_kit_module` attribute.

Walks dependency `ebin` directories on disk (pure file I/O) rather than relying
on `:application.loaded_applications/0`, so it is deterministic at compile time —
it returns the same set whether or not the apps happen to be loaded yet. An app
qualifies when its `<app>.app` lists `:phoenix_kit` in `applications`; its beams
are then read with `:beam_lib.chunks/2` to keep the ones carrying
`@phoenix_kit_module true`. No module loading required.

---

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