scry

CI Hex.pm Docs

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

License

MIT