scry
Analysis-only Mix compiler for BEAM projects: incremental argus analyses via roux, reported as rich compiler diagnostics.
Scry runs after the Elixir compiler, reads the .beam files it produced, and
runs argus's Datalog analyses over them — supervision-tree anti-patterns, leaked
tasks, deadlock-prone call cycles, unsafe deserialization, and more. Findings
flow through the standard Mix compiler diagnostics infrastructure (editors and
CI see them like any compiler warning) and render as
pentiment frames that show the
responsible lines, connected evidence in other files, and how to fix the issue:
warning[scry.one_for_one_coupling]: Coupled children under one_for_one
╭─[lib/depot/application.ex:19:5]
│
17 │
18 │ opts = [strategy: :one_for_one, name: Depot.Supervisor]
19 │ Supervisor.start_link(children, opts)
• ──────────────────┬──────────────────
• ╰── supervision tree defined here
20 │ end
21 │ end
│
├─[lib/depot/queue.ex:76:5]
│
74 │
75 │ defp broadcast(state, payload) do
76 │ Notifier.notify(state.notifier, @channel, payload)
• ────────────────────────┬─────────────────────────
• ╰── coupling call
77 │ end
78 │ end
│
├─[lib/depot/notifier.ex:1:1]
│
1 │ defmodule Depot.Notifier do
• ─────────────┬─────────────
• ╰── called sibling
2 │ @moduledoc """
3 │ In-process pub/sub for job lifecycle events. Listener registrations
│
╰─────
note: Depot.Queue calls Depot.Notifier, but both are children of the
one_for_one supervisor Depot.Application. When Depot.Notifier
crashes and restarts, Depot.Queue is not restarted with it and
keeps any stale pid, monitor, or cached state it held.
help: restart-coupled siblings belong under `rest_for_one`, with
`Depot.Notifier` started before `Depot.Queue` — a `Depot.Notifier`
restart then restarts `Depot.Queue` too
help: alternatively, have `Depot.Queue` monitor `Depot.Notifier` and
re-resolve it on every use instead of caching state across crashes
(Real output over the test fixture in test/fixtures/depot; note/help
prose re-wrapped for README width.)
Fact extraction and Datalog solving are incremental: results are memoized in a
roux database persisted across mix compile runs, so a warm mix compile
re-analyzes nothing and a comment-only edit re-extracts one module and re-runs
zero analyses.
Installation
Add scry in all environments with runtime: false (an only: dep breaks
MIX_ENV=prod mix compile, because compilers: would reference a missing
task; runtime: false keeps scry out of releases):
def project do
[
# ...
compilers: Mix.compilers() ++ [:scry]
]
end
def deps do
[
{:scry, "~> 0.1.0", runtime: false}
]
end
For syntax-highlighted terminal frames, also add {:makeup_elixir, "~> 1.0"}
and {:makeup_erlang, "~> 1.0"} (they are optional; without them frames
render plain).
Projects that declare an explicit applications: list in application/0
(rather than extra_applications) must also set prune_code_paths: false
in project/0, or Mix prunes scry off the code path before the :scry
compiler can run.
Solving requires a Souffle binary on
PATH. Without one, scry skips analyses and emits a single notice (set
scry: [souffle: :require] to make it a hard error instead).
Usage
mix compile now reports findings. Configuration lives under the :scry
project key:
def project do
[
# ...
scry: [
analyses: [:supervision, :unsafe_task], # default: a curated quiet set
severity: [unsafe_task: :error], # per-analysis override
ignore: [modules: [~r/^MyApp\.Gen/], files: ["lib/legacy/**"]],
include_deps: false,
fail_on: :error, # :warning promotes findings to build failures
souffle: :warn # | :require
]
]
end
A standalone task drives the same incremental core for one-shot and CI use:
mix scry # all configured analyses
mix scry supervision # a specific analysis
mix scry --list # available analyses
mix scry --format json # machine-readable findings
mix scry --fail-above 0 # exit 1 on any finding
Limitations
-
Umbrellas are per-app: each child app analyzes its own beams with its own manifest, so cross-app analyses (call cycles, supervision across apps) under-report.
include_deps: trueon the app owning the supervision root pulls sibling ebins into the call graph as an escape hatch. -
Line-granular anchors: BEAM Line chunks carry no columns, so a label spans the anchored line's code, never a sub-expression.
-
Not yet on Hex: roux and argus are pinned to tagged GitHub releases (argus's name is taken on hex; roux pins a GitHub fork of gen_lsp), so scry itself is consumed as a GitHub dependency:
{:scry, github: "QuinnWilton/scry", tag: "v0.1.4", runtime: false}
License
MIT