DpExchange.Core.UnwiredCheck (DpExchangeCore v0.3.120)

Copy Markdown View Source

Finds an internal function nothing in a package's own lib/ ever calls.

The defect this exists for

"Mechanism built, documented, and never wired." Six instances in one week across this family, every one shipped green:

  • rate_limit_blocking plumbed through Core.HttpClient but never set by the caller — dp_exchange_robinhood (issue #16), dp_exchange_webull (issue #23, where it had to be threaded through three separate option allowlists and a fix stopping at the first layer still passed every test asserting the keyword was present), dp_exchange_coinbase (issue #26).
  • FrameSender's retry path in dp_exchange_coinbase — documented as the thing a caller does after a failed batch; the caller reported and never retried (issue #22).
  • dp_exchange_schwab's subscribe_notices/1 facade — built, nothing ever emitted to it, because the internal registry it should have delegated to (Feed.subscribe_notices/2) had no caller anywhere in lib/.
  • dp_exchange_schwab's Auth.refresh/2 — zero call sites anywhere in lib/, while Socket captured access_token once at start_link/1 and websockex reconnects synchronously with no delay, so an expired token became an undelayed loop hammering the venue with a credential the package could not replace.

In every case a test called the function directly, so coverage stayed high and the suite stayed green — a test is not a caller. An internal function with no caller in the package's own lib/ is either dead code or an unwired mechanism, and both are defects.

Real call-graph data, not a grep

Built on :xref, the same OTP cross-reference tool Core.AdapterContract's "7. purity" assertion already reads imports chunks through. A grep trips on apply(Mod, :fun, args) and on a captured &Mod.fun/1 — both are ordinary, correct ways to call a function and neither looks like a call textually. :xref's edge relation (E, queried below as E || M:F/A — restrict the range to a specific callee) resolves both correctly, including a call from a function back to its own module. What it cannot resolve is genuinely dynamic dispatch — a module or function name assembled at runtime — and no static tool can; that limit is inherent to the problem, not particular to this check.

:xref.analyze(:exports_not_used) was considered and rejected: its own semantics are "not called from outside the defining module," so a public function called only by a sibling function in the same module reads as unused even though something in lib/ plainly calls it. Querying the raw edge relation and building the "was this ever a callee, from anywhere in the analysed set" answer ourselves avoids that false positive.

What counts as a caller

Anything reachable through :xref's edge relation from a module whose compiled :compile_info source path is under lib_root — regardless of which Mix environment produced the .beam files. A package compiled under MIX_ENV=test (elixirc_paths commonly adding test/support) still only has its lib/-sourced modules added to the :xref server here, so a function reachable only from test/support — a fake, a fixture, a helper only the suite imports — is not treated as wired. That is deliberate: it is exactly the shape of six known instances of this defect, where a test calling the function directly was the reason nothing looked wrong.

What is excluded, and why each is safe to exclude without an allowlist that rots

  • excluded_modules — whole modules the caller names explicitly, not inferred. Core.AdapterContract's "16. internal wiring" assertion passes [@venue, @fake]: the facade (called only by consumers, by the family's own contract — see usage-rules/adapter.md) and the in-process fake (called only by a consumer's tests, never by this package's own lib/). Both bindings already exist for other assertions in the same suite; nothing new is hand-maintained here.
  • Behaviour callbacks — read from the module's own :attributes chunk (@behaviour targets) and each target's own behaviour_info(:callbacks), so GenServer, WebSockex, Supervisor, DpExchange.Core.Venue and any other behaviour a module declares excuses exactly the callbacks that behaviour defines — never a function the module merely happens to export. A callback's default body, injected by use and never overridden, is still excused: it satisfies the same name/arity whether hand-written or macro-generated.
  • child_spec and start_link, at any arity — always excused, on every module, because a Supervisor calls them by convention rather than through any call :xref can see in the analysed set.
  • Compiler-injected exports — module_info/0, module_info/1, and any export whose name is wrapped in double underscores (__struct__, __info__, __impl__, …), the compiler's own naming convention for exactly this category.
  • Macros — every export named MACRO-<name>, which is how defmacro compiles. A macro call is expanded where it is written and leaves no call behind in the caller's beam, so a macro used a hundred times inside lib/ has no edge in the call graph and read as unwired. The price is stated rather than hidden: a macro nothing uses is not reported. (None of the five venues defines one.)

What this does not catch

Anything reached only through data. A {Mod, :fun, args} tuple handed to :timer.apply_after/4, Task.Supervisor.start_child/4 or a child spec's :start is a literal MFA, but it is a tuple and not a call, so :xref sees no edge to Mod.fun/n and the function reads as unwired though it runs. The remedy is the one the rule already names — call it where it is wired (a captured &Mod.fun/1 or a literal apply/3 is resolved) — never an allowlist.

Dead code that calls itself in a ring. "Has a caller" is the test, not "is reachable from an entry point": two dead functions that call each other each have one, and neither is reported. A dead chain is reported at its top, and the next run reports the next link once that one is deleted. Reachability from the facade, the fake and the behaviour callbacks would be the stricter question; it was not built because it cannot be checked against the five venues without running them.

A function reachable only through the package's own Fake module and never through any real production path is not flagged — Fake is part of lib/, so a call from it satisfies the letter of "something in lib/ calls it," even though the real code path may still be dead. That is a narrower, rarer defect shape than the one this check was built for, and asserting it would mean inferring which of a module's callers are "real," which is not mechanically separable from the fake/real distinction this family already draws by hand in each package's own fixtures.

Summary

Functions

Renders run/3 violations as one line per finding, for a failure message.

Finds internal functions with no caller under lib_root.

Types

violation()

@type violation() :: %{
  module: module(),
  function: atom(),
  arity: non_neg_integer(),
  file: String.t() | nil,
  line: pos_integer() | nil
}

Functions

format(violations)

@spec format([violation()]) :: String.t()

Renders run/3 violations as one line per finding, for a failure message.

run(beam_dir, lib_root, excluded_modules \\ [])

@spec run(Path.t(), Path.t(), [module()]) :: {:ok, [violation()]} | {:error, term()}

Finds internal functions with no caller under lib_root.

beam_dir is a directory of compiled .beam files (typically Mix.Project.build_path() |> Path.join("lib/#{app}/ebin")). Only the ones among them whose recorded :compile_info source path falls under lib_root are analysed; the rest are silently ignored, whichever Mix environment produced them.

excluded_modules are analysed for call-graph purposes (so a function they call still counts as wired) but never themselves reported as violations — pass the facade and any fake/reference implementation the caller exposes as public surface in its own right.